From bb4b80f867ad17a4100b54ec276b77cf6bf9cb3f Mon Sep 17 00:00:00 2001 From: Guoguo Date: Wed, 29 Apr 2026 05:58:20 -0700 Subject: [PATCH] refactor: restructure entire project for human maintainability Server: - Add Express framework, replace custom router/request parser - Create DAO layer (12 files) centralizing all 73 SQL queries - Rewrite 7 route files as thin Express controllers calling DAOs - Add SCF-to-Express adapter (lib/serverless.js) - Add auth middleware (middleware/auth.js) - Remove dead code from lib/auth.js Admin console: - Extract DataTable component (table + pagination) - Extract ConfirmModal component (modal + form styles) - Create listMixin for paginated list pages - Move form styles to common.css for slot compatibility - Refactor device + subscription pages as examples Miniprogram: - Split 734-line BLE monolith into 4 focused modules (protocol, connection, commands, barrel index) - Create API module (utils/api.js) with named functions - Create page utilities (utils/page.js) - Refactor index + profile pages to use API module --- admin-console/src/components/ConfirmModal.vue | 58 ++ admin-console/src/components/DataTable.vue | 57 ++ admin-console/src/pages/device/index.vue | 215 ++--- .../src/pages/subscription/index.vue | 255 ++--- admin-console/src/styles/common.css | 32 + admin-console/src/utils/useList.js | 81 ++ miniprogram/pages/index/index.js | 10 +- miniprogram/pages/profile/profile.js | 8 +- miniprogram/services/ble.js | 734 --------------- miniprogram/services/ble/commands.js | 187 ++++ miniprogram/services/ble/connection.js | 341 +++++++ miniprogram/services/ble/index.js | 59 ++ miniprogram/services/ble/protocol.js | 272 ++++++ miniprogram/utils/api.js | 32 + miniprogram/utils/page.js | 25 + server/package-lock.json | 874 ++++++++++++++++++ server/package.json | 4 +- server/scripts/local-server.js | 25 +- server/src/app.js | 55 +- server/src/dao/admin.dao.js | 93 ++ server/src/dao/binding.dao.js | 203 ++++ server/src/dao/command.dao.js | 120 +++ server/src/dao/device-event.dao.js | 30 + server/src/dao/device.dao.js | 172 ++++ server/src/dao/firmware.dao.js | 68 ++ server/src/dao/index.js | 13 + server/src/dao/log.dao.js | 82 ++ server/src/dao/settings.dao.js | 40 + server/src/dao/subscription.dao.js | 197 ++++ server/src/dao/treatment.dao.js | 202 ++++ server/src/dao/user.dao.js | 178 ++++ server/src/index.js | 7 +- server/src/lib/auth.js | 27 +- server/src/lib/log.js | 18 +- server/src/lib/request.js | 45 - server/src/lib/response.js | 16 +- server/src/lib/router.js | 33 - server/src/lib/serverless.js | 63 ++ server/src/middleware/auth.js | 46 + server/src/routes/admin.js | 543 +++++------ server/src/routes/auth.js | 121 ++- server/src/routes/device.js | 289 +++--- server/src/routes/firmware.js | 105 +-- server/src/routes/subscription.js | 94 +- server/src/routes/treatment.js | 112 +-- server/src/routes/user.js | 85 +- 46 files changed, 4306 insertions(+), 2020 deletions(-) create mode 100644 admin-console/src/components/ConfirmModal.vue create mode 100644 admin-console/src/components/DataTable.vue create mode 100644 admin-console/src/utils/useList.js delete mode 100644 miniprogram/services/ble.js create mode 100644 miniprogram/services/ble/commands.js create mode 100644 miniprogram/services/ble/connection.js create mode 100644 miniprogram/services/ble/index.js create mode 100644 miniprogram/services/ble/protocol.js create mode 100644 miniprogram/utils/api.js create mode 100644 miniprogram/utils/page.js create mode 100644 server/src/dao/admin.dao.js create mode 100644 server/src/dao/binding.dao.js create mode 100644 server/src/dao/command.dao.js create mode 100644 server/src/dao/device-event.dao.js create mode 100644 server/src/dao/device.dao.js create mode 100644 server/src/dao/firmware.dao.js create mode 100644 server/src/dao/index.js create mode 100644 server/src/dao/log.dao.js create mode 100644 server/src/dao/settings.dao.js create mode 100644 server/src/dao/subscription.dao.js create mode 100644 server/src/dao/treatment.dao.js create mode 100644 server/src/dao/user.dao.js delete mode 100644 server/src/lib/request.js delete mode 100644 server/src/lib/router.js create mode 100644 server/src/lib/serverless.js create mode 100644 server/src/middleware/auth.js diff --git a/admin-console/src/components/ConfirmModal.vue b/admin-console/src/components/ConfirmModal.vue new file mode 100644 index 0000000..3b34f93 --- /dev/null +++ b/admin-console/src/components/ConfirmModal.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/admin-console/src/components/DataTable.vue b/admin-console/src/components/DataTable.vue new file mode 100644 index 0000000..c6e8a9b --- /dev/null +++ b/admin-console/src/components/DataTable.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/admin-console/src/pages/device/index.vue b/admin-console/src/pages/device/index.vue index 24d71be..513c88a 100644 --- a/admin-console/src/pages/device/index.vue +++ b/admin-console/src/pages/device/index.vue @@ -16,61 +16,46 @@ - - - - - 设备编号 - 产品编号 - 绑定用户 - 电量 - 固件版本 - 绑定时间 - 状态 - 操作 - + + + - - - - - 共 {{ totalPages }} 页 + + + 设备编号(每行一个) + - - - - - 批量导入设备 - - 设备编号(每行一个) - - - 最多 500 个设备 - - - - - - + 最多 500 个设备 + @@ -78,54 +63,48 @@ import { get, post } from '../../utils/request' import { exportCSV } from '../../utils/export' import { formatDateShort } from '../../utils/format' +import { listMixin } from '../../utils/useList' import AdminLayout from '../../components/AdminLayout.vue' +import DataTable from '../../components/DataTable.vue' +import ConfirmModal from '../../components/ConfirmModal.vue' + +var STATUS_MAP = { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' } +var STATUS_BADGE = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' } export default { - components: { AdminLayout }, + components: { AdminLayout, DataTable, ConfirmModal }, + mixins: [listMixin('/api/v1/admin/devices')], data() { return { - devices: [], - total: 0, - page: 1, - pageSize: 20, keyword: '', - statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }, + statusMap: STATUS_MAP, + columns: [ + { key: 'device_id', label: '设备编号', flex: 2 }, + { key: 'product_id', label: '产品编号', flex: 2 }, + { key: 'bound_user', label: '绑定用户', flex: 2 }, + { key: 'battery', label: '电量', flex: 1 }, + { key: 'fw', label: '固件版本', flex: 1 }, + { key: 'activated_at', label: '绑定时间', flex: 2 }, + { key: 'status', label: '状态', flex: 1 }, + { key: 'actions', label: '操作', flex: 1 } + ], showBatchImport: false, batchDeviceIds: '', batchImporting: false } }, - computed: { - totalPages() { - return Math.ceil(this.total / this.pageSize) || 1 - } - }, onShow() { - this.loadDevices() + this.reload() }, methods: { - async loadDevices() { - try { - const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize, keyword: this.keyword }) - this.devices = data.records || [] - this.total = data.total || 0 - } catch (e) { - uni.showToast({ title: '加载失败', icon: 'none' }) - } - }, - onSearch() { - this.page = 1 - this.loadDevices() - }, - onPage(p) { - this.page = p - this.loadDevices() + reload() { + this.loadList({ keyword: this.keyword }) }, onDetail(deviceId) { uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId }) }, onCreateDevice() { - const self = this + var self = this uni.showModal({ title: '预生成产品码', editable: true, @@ -135,7 +114,7 @@ export default { try { await post('/api/v1/admin/devices', { device_id: res.content.trim(), product_id: 'HOX_LIGHT_MASK' }) uni.showToast({ title: '创建成功', icon: 'success' }) - self.loadDevices() + self.reload() } catch (e) { uni.showToast({ title: '创建失败', icon: 'none' }) } @@ -143,7 +122,7 @@ export default { }) }, onUnbind(item) { - const self = this + var self = this uni.showModal({ title: '确认解绑', content: '确定要解绑设备 ' + item.device_id + ' 吗?', @@ -152,7 +131,7 @@ export default { try { await post('/api/v1/admin/devices/' + item.device_id + '/unbind', {}) uni.showToast({ title: '解绑成功', icon: 'success' }) - self.loadDevices() + self.reload() } catch (e) { uni.showToast({ title: '解绑失败', icon: 'none' }) } @@ -161,12 +140,11 @@ export default { }) }, statusBadge(status) { - const map = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' } - return map[status] || 'badge badge-default' + return STATUS_BADGE[status] || 'badge badge-default' }, formatDate: formatDateShort, async onBatchImport() { - const ids = this.batchDeviceIds.split('\n').map(s => s.trim()).filter(Boolean) + var ids = this.batchDeviceIds.split('\n').map(function (s) { return s.trim() }).filter(Boolean) if (ids.length === 0) { uni.showToast({ title: '请输入设备编号', icon: 'none' }) return @@ -177,11 +155,11 @@ export default { } this.batchImporting = true try { - const result = await post('/api/v1/admin/devices/batch', { device_ids: ids }) + var result = await post('/api/v1/admin/devices/batch', { device_ids: ids }) uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' }) this.showBatchImport = false this.batchDeviceIds = '' - this.loadDevices() + this.reload() } catch (e) { uni.showToast({ title: '导入失败', icon: 'none' }) } finally { @@ -190,8 +168,8 @@ export default { }, async onExport() { try { - const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword }) - const records = data.records || [] + var data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword }) + var records = data.records || [] exportCSV('devices_' + new Date().toISOString().slice(0, 10) + '.csv', ['设备编号', '产品编号', '绑定用户', '电量', '固件版本', '绑定时间', '状态'], records.map(function (r) { @@ -210,64 +188,9 @@ export default { diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue index a44ccbb..0d73172 100644 --- a/admin-console/src/pages/subscription/index.vue +++ b/admin-console/src/pages/subscription/index.vue @@ -28,76 +28,64 @@ - - - 全部订阅 - 月卡 - 年卡 - 试用中 - 已过期 - + + - - - - 用户 - 订阅类型 - 订单金额 - 开始日期 - 到期日期 - 状态 - 操作 - + + - - - - - 共 {{ totalPages }} 页 + + + 用户ID + - - - - - 创建订阅 - - 用户ID - - - - 方案 - - - - 天数 - - - - - - + + 方案 + - + + 天数 + + + @@ -105,50 +93,50 @@ import { get, post } from '../../utils/request' import { exportCSV } from '../../utils/export' import { formatDateShort } from '../../utils/format' +import { listMixin } from '../../utils/useList' import AdminLayout from '../../components/AdminLayout.vue' +import DataTable from '../../components/DataTable.vue' +import ConfirmModal from '../../components/ConfirmModal.vue' + +var PLAN_MAP = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' } +var STATUS_TEXT = { 1: '生效中', 2: '已过期', 3: '已取消' } +var STATUS_BADGE = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' } export default { - components: { AdminLayout }, + components: { AdminLayout, DataTable, ConfirmModal }, + mixins: [listMixin('/api/v1/admin/subscriptions')], data() { return { - subscriptions: [], - total: 0, - page: 1, - pageSize: 20, stats: {}, + activeTab: 'all', + columns: [ + { key: 'user', label: '用户', flex: 2 }, + { key: 'plan', label: '订阅类型', flex: 1 }, + { key: 'amount', label: '订单金额', flex: 1 }, + { key: 'start', label: '开始日期', flex: 2 }, + { key: 'expire', label: '到期日期', flex: 2 }, + { key: 'status', label: '状态', flex: 1 }, + { key: 'actions', label: '操作', flex: 1 } + ], showCreate: false, creating: false, - createForm: { user_id: '', plan: 'monthly', days: 30 }, - activeTab: 'all' - } - }, - computed: { - totalPages() { - return Math.ceil(this.total / this.pageSize) || 1 + createForm: { user_id: '', plan: 'monthly', days: 30 } } }, onShow() { - this.loadSubscriptions() + this.reload() }, methods: { - async loadSubscriptions() { - try { - const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize, tab: this.activeTab }) - this.subscriptions = data.records || [] - this.total = data.total || 0 - if (data.stats) this.stats = data.stats - } catch (e) { - uni.showToast({ title: '加载失败', icon: 'none' }) - } + reload() { + this.loadList({ tab: this.activeTab }) + }, + onListLoaded(data) { + if (data.stats) this.stats = data.stats }, onTabFilter(tab) { this.activeTab = tab this.page = 1 - this.loadSubscriptions() - }, - onPage(p) { - this.page = p - this.loadSubscriptions() + this.reload() }, async onCreate() { this.creating = true @@ -159,7 +147,7 @@ export default { days: parseInt(this.createForm.days) }) this.showCreate = false - this.loadSubscriptions() + this.reload() uni.showToast({ title: '创建成功', icon: 'success' }) } catch (e) { uni.showToast({ title: '创建失败', icon: 'none' }) @@ -186,7 +174,7 @@ export default { days: 30 }) uni.showToast({ title: '延期成功', icon: 'success' }) - self.loadSubscriptions() + self.reload() } catch (e) { uni.showToast({ title: '操作失败', icon: 'none' }) } @@ -206,7 +194,7 @@ export default { subscription_id: item.subscription_id }) uni.showToast({ title: '已取消', icon: 'success' }) - self.loadSubscriptions() + self.reload() } catch (e) { uni.showToast({ title: '操作失败', icon: 'none' }) } @@ -220,29 +208,18 @@ export default { this.createForm.days = 30 this.showCreate = true }, - planText(plan) { - const map = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' } - return map[plan] || plan || '-' - }, - statusText(status) { - const map = { 1: '生效中', 2: '已过期', 3: '已取消' } - return map[status] || '-' - }, - statusBadge(status) { - const map = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' } - return map[status] || 'badge badge-default' - }, + planText(plan) { return PLAN_MAP[plan] || plan || '-' }, + statusText(status) { return STATUS_TEXT[status] || '-' }, + statusBadge(status) { return STATUS_BADGE[status] || 'badge badge-default' }, formatDate: formatDateShort, async onExport() { try { - const data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab }) - const records = data.records || [] - var planMap = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' } - var statusMap = { 1: '生效中', 2: '已过期', 3: '已取消' } + var data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab }) + var records = data.records || [] exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv', ['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'], records.map(function (r) { - return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', statusMap[r.status] || ''] + return [r.nickname || r.user_id || '', PLAN_MAP[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', STATUS_TEXT[r.status] || ''] }) ) uni.showToast({ title: '导出成功', icon: 'success' }) @@ -308,58 +285,4 @@ export default { .action-link { margin-right: 8px; } - -.modal-mask { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.45); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-content { - width: 440px; - background: #fff; - border-radius: 8px; - padding: 24px; -} - -.modal-title { - font-size: 18px; - font-weight: 600; - margin-bottom: 20px; -} - -.form-group { - margin-bottom: 16px; -} - -.form-label { - display: block; - font-size: 14px; - color: #333; - margin-bottom: 6px; -} - -.form-input { - width: 100%; - height: 36px; - border: 1px solid #d9d9d9; - border-radius: 6px; - padding: 0 12px; - font-size: 14px; - box-sizing: border-box; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 20px; -} diff --git a/admin-console/src/styles/common.css b/admin-console/src/styles/common.css index 2adc9b6..ab957c7 100644 --- a/admin-console/src/styles/common.css +++ b/admin-console/src/styles/common.css @@ -171,3 +171,35 @@ .input-placeholder { color: #bfbfbf; } + +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 14px; + color: #333; + margin-bottom: 6px; +} + +.form-input { + width: 100%; + height: 36px; + border: 1px solid #d9d9d9; + border-radius: 6px; + padding: 0 12px; + font-size: 14px; + box-sizing: border-box; +} + +.form-textarea { + width: 100%; + height: 160px; + border: 1px solid #d9d9d9; + border-radius: 6px; + padding: 12px; + font-size: 14px; + box-sizing: border-box; + resize: vertical; +} diff --git a/admin-console/src/utils/useList.js b/admin-console/src/utils/useList.js new file mode 100644 index 0000000..2659561 --- /dev/null +++ b/admin-console/src/utils/useList.js @@ -0,0 +1,81 @@ +import { get } from './request' + +/** + * Creates a mixin for paginated list pages. + * + * Eliminates the repeated data/computed/methods boilerplate for: + * - records[], total, page, pageSize, totalPages + * - loadList(), onPage(), onSearch() + * + * Usage: + * import { listMixin } from '../../utils/useList' + * + * export default { + * mixins: [listMixin('/api/v1/admin/devices')], + * methods: { + * reload() { + * this.loadList({ keyword: this.keyword }) + * } + * } + * } + * + * @param {string} apiPath - API endpoint path (passed to get()) + * @param {object} opts + * @param {number} [opts.pageSize=20] - Items per page + */ +export function listMixin(apiPath, opts = {}) { + return { + data() { + return { + records: [], + total: 0, + page: 1, + pageSize: opts.pageSize || 20 + } + }, + computed: { + totalPages() { + return Math.ceil(this.total / this.pageSize) || 1 + } + }, + methods: { + async loadList(extraParams = {}) { + try { + var params = { page: this.page, page_size: this.pageSize } + // Merge extra params, dropping falsy values to keep request clean + var keys = Object.keys(extraParams) + for (var i = 0; i < keys.length; i++) { + var k = keys[i] + if (extraParams[k] !== '' && extraParams[k] != null) { + params[k] = extraParams[k] + } + } + var data = await get(apiPath, params) + this.records = data.records || [] + this.total = data.total || 0 + this.onListLoaded(data) + } catch (e) { + uni.showToast({ title: '加载失败', icon: 'none' }) + } + }, + /** + * Override in page to handle extra response data (e.g. stats). + */ + onListLoaded(_data) {}, + onPage(p) { + this.page = p + this.reload() + }, + onSearch() { + this.page = 1 + this.reload() + }, + /** + * Override in page to call loadList() with current filters. + */ + reload() { + this.loadList() + } + } + } +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index fe65b30..07d5009 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -1,5 +1,5 @@ var ble = require('../../services/ble') -var http = require('../../utils/request') +var api = require('../../utils/api') var config = require('../../config/env') var app = getApp() @@ -49,7 +49,7 @@ Page({ self.setData({ connected: ble.isConnected() }) - http.get('/api/v1/device/list').then(function (data) { + api.getDevices().then(function (data) { var devices = data.devices || [] self.setData({ hasDevice: devices.length > 0 }) if (devices.length > 0) { @@ -61,7 +61,7 @@ Page({ } }).catch(function () {}) - http.get('/api/v1/subscription').then(function (sub) { + api.getSubscription().then(function (sub) { self.setData({ subscription: sub, subRemaining: sub.remaining_days || 0 @@ -125,7 +125,7 @@ Page({ var self = this wx.showLoading({ title: '解绑中...' }) ble.disconnect() - http.post('/api/v1/device/unbind', {}).then(function () { + api.unbindDevice().then(function () { wx.hideLoading() self.setData({ hasDevice: false, @@ -157,7 +157,7 @@ Page({ success: function (res) { if (res.confirm && res.content) { var deviceId = res.content.trim() - http.post('/api/v1/device/mock-bind', { device_id: deviceId }).then(function () { + api.mockBind(deviceId).then(function () { wx.showToast({ title: '模拟绑定成功', icon: 'success' }) self.checkState() }).catch(function (err) { diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index 972ea12..ef43c83 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -1,4 +1,4 @@ -var http = require('../../utils/request') +var api = require('../../utils/api') var app = getApp() Page({ @@ -15,14 +15,14 @@ Page({ loadProfile: function () { var self = this - http.get('/api/v1/user/profile').then(function (profile) { + api.getProfile().then(function (profile) { self.setData({ userInfo: profile, deviceCount: profile.device_count || 0 }) }).catch(function () {}) - http.get('/api/v1/subscription').then(function (sub) { + api.getSubscription().then(function (sub) { self.setData({ subscription: sub, subRemaining: sub.remaining_days || 0 @@ -42,7 +42,7 @@ Page({ content: '解绑后将无法使用该设备,确定要解绑吗?', success: function (modalRes) { if (modalRes.confirm) { - http.post('/api/v1/device/unbind', {}).then(function () { + api.unbindDevice().then(function () { wx.showToast({ title: '已解绑', icon: 'success' }) self.loadProfile() }).catch(function (err) { diff --git a/miniprogram/services/ble.js b/miniprogram/services/ble.js deleted file mode 100644 index cb25910..0000000 --- a/miniprogram/services/ble.js +++ /dev/null @@ -1,734 +0,0 @@ -var SERVICE = { - DEVICE_INFO: 'FFE0', - DATA_COMM: 'FFE1', - OTA: 'FFE2' -} - -var CHAR = { - DEVICE_INFO: 'FFE3', - COMMAND: 'FFE4', - STATUS: 'FFE5', - BOND_INFO: 'FFE6', - OTA_CONTROL: 'FFE7', - OTA_DATA: 'FFE8', - OTA_STATUS: 'FFE9' -} - -var CMD = { - SET_PARAMS: 0x01, - START: 0x02, - STOP: 0x03, - QUERY_STATUS: 0x04, - BIND: 0x05, - UNBIND: 0x06 -} - -var NOTIFY = { - STATUS_REPORT: 0x21, - ACK: 0x22, - TREATMENT_COMPLETE: 0x31, - EXCEPTION: 0x32, - BIND_SUCCESS: 0x33 -} - -var MODE_STATE = { - IDLE: 0x00, - SCANNING: 0x01, - ACTIVE: 0x02, - PAUSED: 0x03, - COMPLETED: 0x04, - ERROR: 0x05, - OTA: 0x06 -} - -var WAVELENGTH = { - IR: 1, - R: 2, - UV: 3, - Y: 4 -} - -var TREAT_MODE = { - NORMAL: 0, - SMART: 1 -} - -var REGION = { - LEFT_CHEEK: 0x01, - RIGHT_CHEEK: 0x02, - FOREHEAD: 0x04, - CHIN: 0x08, - NOSE: 0x10, - LEFT_EYE: 0x20, - RIGHT_EYE: 0x40, - FULL_FACE: 0x7F -} - -var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye'] - -var DEVICE_ERR = { - 0x00: 'SUCCESS', - 0x01: 'ERR_REGION_INVALID', - 0x02: 'ERR_REGION_EMPTY', - 0x03: 'ERR_BRIGHTNESS_INVALID', - 0x04: 'ERR_DURATION_INVALID', - 0x05: 'ERR_NOT_BOUND', - 0x06: 'ERR_NO_SUBSCRIPTION', - 0x07: 'ERR_TEMP_HIGH', - 0x08: 'ERR_BATTERY_LOW', - 0x09: 'ERR_ALREADY_RUNNING', - 0x0A: 'ERR_NOT_RUNNING', - 0x0B: 'ERR_OTA_FAILED', - 0x0C: 'ERR_BLE_DISCONNECTED' -} - -var _deviceId = null -var _connected = false -var _chars = {} -var _cmdSeq = 0 -var _pendingAcks = {} -var _listeners = {} -var _autoReconnect = true -var _reconnecting = false - -function nextSeq() { - _cmdSeq = (_cmdSeq + 1) % 256 - return _cmdSeq -} - -function bufferToBytes(buffer) { - var arr = new Uint8Array(buffer) - var bytes = [] - for (var i = 0; i < arr.length; i++) { - bytes.push(arr[i]) - } - return bytes -} - -function bytesToBuffer(bytes) { - var buffer = new ArrayBuffer(bytes.length) - var view = new Uint8Array(buffer) - for (var i = 0; i < bytes.length; i++) { - view[i] = bytes[i] - } - return buffer -} - -function xorChecksum(bytes) { - var result = 0 - for (var i = 0; i < bytes.length; i++) { - result ^= bytes[i] - } - return result -} - -function buildFrame(type, payload) { - var len = payload ? payload.length : 0 - var frame = [0xAA, 0x55, len, type] - if (payload && payload.length > 0) { - frame = frame.concat(payload) - } - var checkBytes = frame.slice(0) - frame.push(xorChecksum(checkBytes)) - return bytesToBuffer(frame) -} - -function parseFrame(buffer) { - var bytes = bufferToBytes(buffer) - if (bytes.length < 5) return null - if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null - var len = bytes[2] - if (bytes.length < 5 + len) return null - var type = bytes[3] - var payload = bytes.slice(4, 4 + len) - var checksum = bytes[4 + len] - var expected = xorChecksum(bytes.slice(0, 4 + len)) - if (checksum !== expected) return null - return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 } -} - -function uint32ToBytes(value) { - return [ - (value >> 24) & 0xFF, - (value >> 16) & 0xFF, - (value >> 8) & 0xFF, - value & 0xFF - ] -} - -function bytesToUint32(bytes, offset) { - return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3] -} - -function hexToBytes(hex) { - var bytes = [] - for (var i = 0; i < hex.length; i += 2) { - bytes.push(parseInt(hex.substr(i, 2), 16)) - } - return bytes -} - -function bytesToHex(bytes) { - var hex = '' - for (var i = 0; i < bytes.length; i++) { - hex += ('0' + bytes[i].toString(16)).slice(-2) - } - return hex.toUpperCase() -} - -function findCharUuid(chars, shortUuid) { - for (var i = 0; i < chars.length; i++) { - if (chars[i].uuid.indexOf(shortUuid) !== -1) { - return chars[i].uuid - } - } - return null -} - -function on(event, callback) { - if (!_listeners[event]) _listeners[event] = [] - _listeners[event].push(callback) -} - -function off(event, callback) { - if (!_listeners[event]) return - if (callback) { - _listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback }) - } else { - _listeners[event] = [] - } -} - -function emit(event, data) { - if (!_listeners[event]) return - _listeners[event].forEach(function (cb) { - try { cb(data) } catch (e) { console.error('ble emit error:', e) } - }) -} - -function handleNotification(frame) { - switch (frame.type) { - case NOTIFY.STATUS_REPORT: - emit('status', parseStatusReport(frame.payload)) - break - case NOTIFY.ACK: - var ack = parseAck(frame.payload) - emit('ack', ack) - if (_pendingAcks[ack.seq]) { - if (ack.error_code === 0) { - _pendingAcks[ack.seq].resolve(ack) - } else { - _pendingAcks[ack.seq].reject(ack) - } - delete _pendingAcks[ack.seq] - } - break - case NOTIFY.TREATMENT_COMPLETE: - emit('treatment_complete', parseTreatmentComplete(frame.payload)) - break - case NOTIFY.EXCEPTION: - emit('exception', parseException(frame.payload)) - break - case NOTIFY.BIND_SUCCESS: - emit('bind_result', { success: frame.payload[0] === 0x00 }) - break - } -} - -function parseStatusReport(payload) { - if (payload.length < 14) return null - return { - mode_state: payload[0], - region_mask: payload[1], - wavelength: payload[2], - brightness: payload[3], - remaining_ms: bytesToUint32(payload, 4), - error_code: payload[8], - command_seq: payload[9], - battery: payload[10], - temperature: payload[11], - bind_status: payload[12], - subscription: payload[13] - } -} - -function parseAck(payload) { - return { - seq: payload[0], - error_code: payload.length > 1 ? payload[1] : 0, - error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN' - } -} - -function parseTreatmentComplete(payload) { - return { - session_id: bytesToHex(payload.slice(0, 8)), - regions: payload[8], - total_duration_ms: bytesToUint32(payload, 9), - avg_pd: payload[13] - } -} - -function parseException(payload) { - return { - error_code: payload[0], - error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN', - temperature: payload.length > 1 ? payload[1] : 0 - } -} - -function isConnected() { - return _connected && _deviceId !== null -} - -function getDeviceId() { - return _deviceId -} - -function startScan(callbacks) { - wx.openBluetoothAdapter({ - success: function () { - wx.startBluetoothDevicesDiscovery({ - allowDuplicatesKey: false, - success: function () { - wx.offBluetoothDeviceFound() - wx.onBluetoothDeviceFound(function (res) { - var devices = res.devices || [] - for (var i = 0; i < devices.length; i++) { - var d = devices[i] - var name = (d.name || '').toUpperCase() - var localName = (d.localName || '').toUpperCase() - if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 || - name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) { - wx.stopBluetoothDevicesDiscovery({}) - if (callbacks.onFound) callbacks.onFound(d) - connect(d.deviceId, callbacks) - return - } - } - }) - }, - fail: function () { - if (callbacks.onError) callbacks.onError({ msg: '扫描失败' }) - } - }) - }, - fail: function () { - if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' }) - } - }) -} - -function connect(deviceId, callbacks) { - _deviceId = deviceId - _chars = {} - - wx.createBLEConnection({ - deviceId: deviceId, - timeout: 10000, - success: function () { - _connected = true - discoverServices(deviceId, callbacks) - }, - fail: function () { - _connected = false - if (callbacks.onError) callbacks.onError({ msg: '连接失败' }) - } - }) -} - -function discoverServices(deviceId, callbacks) { - wx.getBLEDeviceServices({ - deviceId: deviceId, - success: function (res) { - var services = res.services - var serviceMap = {} - for (var i = 0; i < services.length; i++) { - var uuid = services[i].uuid.toUpperCase() - if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) { - serviceMap.deviceInfo = services[i].uuid - } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) { - serviceMap.dataComm = services[i].uuid - } else if (uuid.indexOf(SERVICE.OTA) !== -1) { - serviceMap.ota = services[i].uuid - } - } - - var tasks = [] - if (serviceMap.deviceInfo) { - tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo')) - } - if (serviceMap.dataComm) { - tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm')) - } - - Promise.all(tasks).then(function () { - subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () { - if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId }) - }) - }) - }, - fail: function () { - if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' }) - } - }) -} - -function discoverChars(deviceId, serviceId, group) { - return new Promise(function (resolve) { - wx.getBLEDeviceCharacteristics({ - deviceId: deviceId, - serviceId: serviceId, - success: function (res) { - var chars = res.characteristics || [] - for (var i = 0; i < chars.length; i++) { - var c = chars[i] - var uuid = c.uuid.toUpperCase() - if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId } - if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId } - } - resolve() - }, - fail: function () { resolve() } - }) - }) -} - -function subscribeToNotifications(deviceId, serviceId) { - return new Promise(function (resolve) { - if (!_chars.status) { resolve(); return } - - wx.notifyBLECharacteristicValueChange({ - deviceId: deviceId, - serviceId: _chars.status.serviceId, - characteristicId: _chars.status.uuid, - state: true, - success: function () { - wx.onBLECharacteristicValueChange(function (res) { - var frame = parseFrame(res.value) - if (frame) handleNotification(frame) - }) - resolve() - }, - fail: function () { resolve() } - }) - }) -} - -function writeCommand(type, payload) { - return new Promise(function (resolve, reject) { - if (!_connected || !_deviceId) { - reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) - return - } - if (!_chars.command) { - reject({ error_code: 0xFF, error_msg: 'command characteristic not found' }) - return - } - - var seq = nextSeq() - var payloadWithSeq = (payload || []).concat([seq]) - var buffer = buildFrame(type, payloadWithSeq) - - _pendingAcks[seq] = { resolve: resolve, reject: reject } - - setTimeout(function () { - if (_pendingAcks[seq]) { - _pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' }) - delete _pendingAcks[seq] - } - }, 5000) - - wx.writeBLECharacteristicValue({ - deviceId: _deviceId, - serviceId: _chars.command.serviceId, - characteristicId: _chars.command.uuid, - value: buffer, - success: function () {}, - fail: function () { - delete _pendingAcks[seq] - reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) - } - }) - }) -} - -function writeCommandWithRetry(type, payload, maxRetries) { - if (maxRetries === undefined) maxRetries = 3 - var attempt = 0 - function tryOnce() { - attempt++ - return writeCommand(type, payload).catch(function (err) { - if (err && err.error_code === 0x0C) { - return Promise.reject(err) - } - if (attempt >= maxRetries) { - return Promise.reject(err) - } - return new Promise(function (resolve) { - setTimeout(resolve, 500) - }).then(function () { - return tryOnce() - }) - }) - } - return tryOnce() -} - -function readDeviceInfo() { - return new Promise(function (resolve, reject) { - if (!_connected || !_deviceId || !_chars.deviceInfo) { - reject({ msg: '设备未连接或特征值未就绪' }) - return - } - wx.readBLECharacteristicValue({ - deviceId: _deviceId, - serviceId: _chars.deviceInfo.serviceId, - characteristicId: _chars.deviceInfo.uuid, - success: function () {}, - fail: function () { reject({ msg: '读取设备信息失败' }) } - }) - - var handler = function (res) { - if (res.characteristicId.toUpperCase().indexOf(CHAR.DEVICE_INFO) !== -1) { - wx.offBLECharacteristicValueChange(handler) - var bytes = bufferToBytes(res.value) - if (bytes.length >= 14) { - resolve({ - hw_version: (bytes[0] << 8) | bytes[1], - fw_version: (bytes[2] << 8) | bytes[3], - device_type: (bytes[4] << 8) | bytes[5], - device_id: bytesToHex(bytes.slice(6, 14)) - }) - } else { - reject({ msg: '设备信息格式错误' }) - } - } - } - wx.onBLECharacteristicValueChange(handler) - }) -} - -function setParams(options) { - var regionMask = options.region_mask || REGION.FULL_FACE - var wavelength = options.wavelength || WAVELENGTH.R - var brightness = options.brightness || 200 - var durationMs = options.duration_ms || 600000 - var mode = options.mode !== undefined ? options.mode : TREAT_MODE.NORMAL - - var payload = [ - regionMask, - wavelength, - brightness, - uint32ToBytes(durationMs), - mode - ].reduce(function (a, b) { - return a.concat(Array.isArray(b) ? b : [b]) - }, []) - - return writeCommandWithRetry(CMD.SET_PARAMS, payload) -} - -function startTreatment(regionMask) { - var mask = regionMask || REGION.FULL_FACE - return writeCommandWithRetry(CMD.START, [mask]) -} - -function stopTreatment() { - return writeCommandWithRetry(CMD.STOP, []) -} - -function queryStatus() { - return writeCommandWithRetry(CMD.QUERY_STATUS, []) -} - -function bindDevice(userId, bindToken) { - var userBytes = hexToBytes(userId) - var tokenBytes = hexToBytes(bindToken) - var ts = Math.floor(Date.now() / 1000) - var tsBytes = uint32ToBytes(ts) - - var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes) - return writeCommandWithRetry(CMD.BIND, payload) -} - -function unbindDevice(userId) { - var userBytes = hexToBytes(userId) - var payload = [0x02].concat(userBytes) - return writeCommandWithRetry(CMD.UNBIND, payload) -} - -function stopScan() { - wx.stopBluetoothDevicesDiscovery({}) - wx.offBluetoothDeviceFound() -} - -function disconnect() { - _autoReconnect = false - _reconnecting = false - if (_deviceId) { - wx.closeBLEConnection({ deviceId: _deviceId }) - _deviceId = null - } - _connected = false - _chars = {} - _pendingAcks = {} - _listeners = {} - wx.closeBluetoothAdapter({}) -} - -function attemptReconnect(deviceId, retriesLeft) { - wx.createBLEConnection({ - deviceId: deviceId, - timeout: 10000, - success: function () { - _connected = true - _chars = {} - wx.getBLEDeviceServices({ - deviceId: deviceId, - success: function (res) { - var services = res.services - var serviceMap = {} - for (var i = 0; i < services.length; i++) { - var uuid = services[i].uuid.toUpperCase() - if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) { - serviceMap.deviceInfo = services[i].uuid - } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) { - serviceMap.dataComm = services[i].uuid - } - } - - var tasks = [] - if (serviceMap.deviceInfo) { - tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo')) - } - if (serviceMap.dataComm) { - tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm')) - } - - Promise.all(tasks).then(function () { - var serviceId = serviceMap.dataComm || null - return subscribeToNotifications(deviceId, serviceId) - }).then(function () { - _reconnecting = false - emit('reconnected', { deviceId: deviceId }) - }) - }, - fail: function () { - // Service discovery failed, treat as reconnect failure - if (retriesLeft > 1) { - setTimeout(function () { - attemptReconnect(deviceId, retriesLeft - 1) - }, 2000) - } else { - _reconnecting = false - emit('reconnect_failed', { deviceId: deviceId }) - } - } - }) - }, - fail: function () { - if (retriesLeft > 1) { - setTimeout(function () { - attemptReconnect(deviceId, retriesLeft - 1) - }, 2000) - } else { - _reconnecting = false - emit('reconnect_failed', { deviceId: deviceId }) - } - } - }) -} - -function setAutoReconnect(enabled) { - _autoReconnect = enabled -} - -wx.onBLEConnectionStateChange(function (res) { - if (!res.connected) { - _connected = false - emit('disconnected', { deviceId: res.deviceId }) - if (_autoReconnect && !_reconnecting && _deviceId) { - _reconnecting = true - setTimeout(function () { - attemptReconnect(_deviceId, 3) - }, 2000) - } - } -}) - -function getRegionName(mask) { - var names = [] - var bits = [ - [0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'], - [0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周'] - ] - for (var i = 0; i < bits.length; i++) { - if (mask & bits[i][0]) names.push(bits[i][1]) - } - return names -} - -function getWavelengthName(code) { - var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' } - return map[code] || '未知' -} - -function getModeStateName(code) { - var map = { - 0x00: '空闲', 0x01: '扫描中', 0x02: '护理中', - 0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中' - } - return map[code] || '未知' -} - -module.exports = { - SERVICE: SERVICE, - CHAR: CHAR, - CMD: CMD, - NOTIFY: NOTIFY, - MODE_STATE: MODE_STATE, - WAVELENGTH: WAVELENGTH, - TREAT_MODE: TREAT_MODE, - REGION: REGION, - DEVICE_ERR: DEVICE_ERR, - - isConnected: isConnected, - getDeviceId: getDeviceId, - startScan: startScan, - stopScan: stopScan, - connect: connect, - disconnect: disconnect, - setAutoReconnect: setAutoReconnect, - on: on, - off: off, - - readDeviceInfo: readDeviceInfo, - setParams: setParams, - startTreatment: startTreatment, - stopTreatment: stopTreatment, - queryStatus: queryStatus, - bindDevice: bindDevice, - unbindDevice: unbindDevice, - - buildFrame: buildFrame, - parseFrame: parseFrame, - parseStatusReport: parseStatusReport, - parseAck: parseAck, - parseTreatmentComplete: parseTreatmentComplete, - parseException: parseException, - - getRegionName: getRegionName, - getWavelengthName: getWavelengthName, - getModeStateName: getModeStateName, - - bufferToBytes: bufferToBytes, - bytesToBuffer: bytesToBuffer, - hexToBytes: hexToBytes, - bytesToHex: bytesToHex -} diff --git a/miniprogram/services/ble/commands.js b/miniprogram/services/ble/commands.js new file mode 100644 index 0000000..b20dff6 --- /dev/null +++ b/miniprogram/services/ble/commands.js @@ -0,0 +1,187 @@ +// BLE commands: writing commands and high-level device operations + +var protocol = require('./protocol') +var connection = require('./connection') + +var _cmdSeq = 0 +var _pendingAcks = {} + +function nextSeq() { + _cmdSeq = (_cmdSeq + 1) % 256 + return _cmdSeq +} + +function getPendingAcks() { + return _pendingAcks +} + +function clearPendingAcks() { + _pendingAcks = {} +} + +// --- low-level write --- + +function writeCommand(type, payload) { + return new Promise(function (resolve, reject) { + if (!connection.isConnected()) { + reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) + return + } + var chars = connection.getChars() + if (!chars.command) { + reject({ error_code: 0xFF, error_msg: 'command characteristic not found' }) + return + } + + var seq = nextSeq() + var payloadWithSeq = (payload || []).concat([seq]) + var buffer = protocol.buildFrame(type, payloadWithSeq) + + _pendingAcks[seq] = { resolve: resolve, reject: reject } + + setTimeout(function () { + if (_pendingAcks[seq]) { + _pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' }) + delete _pendingAcks[seq] + } + }, 5000) + + var deviceId = connection.getDeviceId() + wx.writeBLECharacteristicValue({ + deviceId: deviceId, + serviceId: chars.command.serviceId, + characteristicId: chars.command.uuid, + value: buffer, + success: function () {}, + fail: function () { + delete _pendingAcks[seq] + reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) + } + }) + }) +} + +function writeCommandWithRetry(type, payload, maxRetries) { + if (maxRetries === undefined) maxRetries = 3 + var attempt = 0 + function tryOnce() { + attempt++ + return writeCommand(type, payload).catch(function (err) { + if (err && err.error_code === 0x0C) { + return Promise.reject(err) + } + if (attempt >= maxRetries) { + return Promise.reject(err) + } + return new Promise(function (resolve) { + setTimeout(resolve, 500) + }).then(function () { + return tryOnce() + }) + }) + } + return tryOnce() +} + +// --- high-level commands --- + +function readDeviceInfo() { + return new Promise(function (resolve, reject) { + var chars = connection.getChars() + var deviceId = connection.getDeviceId() + if (!connection.isConnected() || !chars.deviceInfo) { + reject({ msg: '设备未连接或特征值未就绪' }) + return + } + wx.readBLECharacteristicValue({ + deviceId: deviceId, + serviceId: chars.deviceInfo.serviceId, + characteristicId: chars.deviceInfo.uuid, + success: function () {}, + fail: function () { reject({ msg: '读取设备信息失败' }) } + }) + + var handler = function (res) { + if (res.characteristicId.toUpperCase().indexOf(protocol.CHAR.DEVICE_INFO) !== -1) { + wx.offBLECharacteristicValueChange(handler) + var bytes = protocol.bufferToBytes(res.value) + if (bytes.length >= 14) { + resolve({ + hw_version: (bytes[0] << 8) | bytes[1], + fw_version: (bytes[2] << 8) | bytes[3], + device_type: (bytes[4] << 8) | bytes[5], + device_id: protocol.bytesToHex(bytes.slice(6, 14)) + }) + } else { + reject({ msg: '设备信息格式错误' }) + } + } + } + wx.onBLECharacteristicValueChange(handler) + }) +} + +function setParams(options) { + var regionMask = options.region_mask || protocol.REGION.FULL_FACE + var wavelength = options.wavelength || protocol.WAVELENGTH.R + var brightness = options.brightness || 200 + var durationMs = options.duration_ms || 600000 + var mode = options.mode !== undefined ? options.mode : protocol.TREAT_MODE.NORMAL + + var payload = [ + regionMask, + wavelength, + brightness, + protocol.uint32ToBytes(durationMs), + mode + ].reduce(function (a, b) { + return a.concat(Array.isArray(b) ? b : [b]) + }, []) + + return writeCommandWithRetry(protocol.CMD.SET_PARAMS, payload) +} + +function startTreatment(regionMask) { + var mask = regionMask || protocol.REGION.FULL_FACE + return writeCommandWithRetry(protocol.CMD.START, [mask]) +} + +function stopTreatment() { + return writeCommandWithRetry(protocol.CMD.STOP, []) +} + +function queryStatus() { + return writeCommandWithRetry(protocol.CMD.QUERY_STATUS, []) +} + +function bindDevice(userId, bindToken) { + var userBytes = protocol.hexToBytes(userId) + var tokenBytes = protocol.hexToBytes(bindToken) + var ts = Math.floor(Date.now() / 1000) + var tsBytes = protocol.uint32ToBytes(ts) + + var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes) + return writeCommandWithRetry(protocol.CMD.BIND, payload) +} + +function unbindDevice(userId) { + var userBytes = protocol.hexToBytes(userId) + var payload = [0x02].concat(userBytes) + return writeCommandWithRetry(protocol.CMD.UNBIND, payload) +} + +module.exports = { + getPendingAcks: getPendingAcks, + clearPendingAcks: clearPendingAcks, + + writeCommand: writeCommand, + writeCommandWithRetry: writeCommandWithRetry, + + readDeviceInfo: readDeviceInfo, + setParams: setParams, + startTreatment: startTreatment, + stopTreatment: stopTreatment, + queryStatus: queryStatus, + bindDevice: bindDevice, + unbindDevice: unbindDevice +} diff --git a/miniprogram/services/ble/connection.js b/miniprogram/services/ble/connection.js new file mode 100644 index 0000000..478a8df --- /dev/null +++ b/miniprogram/services/ble/connection.js @@ -0,0 +1,341 @@ +// BLE connection: scan, connect, disconnect, reconnect logic + +var protocol = require('./protocol') +var SERVICE = protocol.SERVICE +var CHAR = protocol.CHAR + +// Shared state — accessed by commands.js via getters/setters +var _deviceId = null +var _connected = false +var _chars = {} +var _autoReconnect = true +var _reconnecting = false + +// Event emitter — shared across modules +var _listeners = {} + +function on(event, callback) { + if (!_listeners[event]) _listeners[event] = [] + _listeners[event].push(callback) +} + +function off(event, callback) { + if (!_listeners[event]) return + if (callback) { + _listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback }) + } else { + _listeners[event] = [] + } +} + +function emit(event, data) { + if (!_listeners[event]) return + _listeners[event].forEach(function (cb) { + try { cb(data) } catch (e) { console.error('ble emit error:', e) } + }) +} + +// --- state accessors (used by commands.js) --- + +function getDeviceId() { + return _deviceId +} + +function isConnected() { + return _connected && _deviceId !== null +} + +function getChars() { + return _chars +} + +// --- notification handling --- + +function handleNotification(frame) { + var pendingAcks = require('./commands').getPendingAcks() + switch (frame.type) { + case protocol.NOTIFY.STATUS_REPORT: + emit('status', protocol.parseStatusReport(frame.payload)) + break + case protocol.NOTIFY.ACK: + var ack = protocol.parseAck(frame.payload) + emit('ack', ack) + if (pendingAcks[ack.seq]) { + if (ack.error_code === 0) { + pendingAcks[ack.seq].resolve(ack) + } else { + pendingAcks[ack.seq].reject(ack) + } + delete pendingAcks[ack.seq] + } + break + case protocol.NOTIFY.TREATMENT_COMPLETE: + emit('treatment_complete', protocol.parseTreatmentComplete(frame.payload)) + break + case protocol.NOTIFY.EXCEPTION: + emit('exception', protocol.parseException(frame.payload)) + break + case protocol.NOTIFY.BIND_SUCCESS: + emit('bind_result', { success: frame.payload[0] === 0x00 }) + break + } +} + +// --- service/characteristic discovery --- + +function discoverChars(deviceId, serviceId, group) { + return new Promise(function (resolve) { + wx.getBLEDeviceCharacteristics({ + deviceId: deviceId, + serviceId: serviceId, + success: function (res) { + var chars = res.characteristics || [] + for (var i = 0; i < chars.length; i++) { + var c = chars[i] + var uuid = c.uuid.toUpperCase() + if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId } + if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId } + } + resolve() + }, + fail: function () { resolve() } + }) + }) +} + +function subscribeToNotifications(deviceId, serviceId) { + return new Promise(function (resolve) { + if (!_chars.status) { resolve(); return } + + wx.notifyBLECharacteristicValueChange({ + deviceId: deviceId, + serviceId: _chars.status.serviceId, + characteristicId: _chars.status.uuid, + state: true, + success: function () { + wx.onBLECharacteristicValueChange(function (res) { + var frame = protocol.parseFrame(res.value) + if (frame) handleNotification(frame) + }) + resolve() + }, + fail: function () { resolve() } + }) + }) +} + +function discoverServices(deviceId, callbacks) { + wx.getBLEDeviceServices({ + deviceId: deviceId, + success: function (res) { + var services = res.services + var serviceMap = {} + for (var i = 0; i < services.length; i++) { + var uuid = services[i].uuid.toUpperCase() + if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) { + serviceMap.deviceInfo = services[i].uuid + } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) { + serviceMap.dataComm = services[i].uuid + } else if (uuid.indexOf(SERVICE.OTA) !== -1) { + serviceMap.ota = services[i].uuid + } + } + + var tasks = [] + if (serviceMap.deviceInfo) { + tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo')) + } + if (serviceMap.dataComm) { + tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm')) + } + + Promise.all(tasks).then(function () { + subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () { + if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId }) + }) + }) + }, + fail: function () { + if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' }) + } + }) +} + +// --- scan & connect --- + +function startScan(callbacks) { + wx.openBluetoothAdapter({ + success: function () { + wx.startBluetoothDevicesDiscovery({ + allowDuplicatesKey: false, + success: function () { + wx.offBluetoothDeviceFound() + wx.onBluetoothDeviceFound(function (res) { + var devices = res.devices || [] + for (var i = 0; i < devices.length; i++) { + var d = devices[i] + var name = (d.name || '').toUpperCase() + var localName = (d.localName || '').toUpperCase() + if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 || + name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) { + wx.stopBluetoothDevicesDiscovery({}) + if (callbacks.onFound) callbacks.onFound(d) + connect(d.deviceId, callbacks) + return + } + } + }) + }, + fail: function () { + if (callbacks.onError) callbacks.onError({ msg: '扫描失败' }) + } + }) + }, + fail: function () { + if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' }) + } + }) +} + +function stopScan() { + wx.stopBluetoothDevicesDiscovery({}) + wx.offBluetoothDeviceFound() +} + +function connect(deviceId, callbacks) { + _deviceId = deviceId + _chars = {} + + wx.createBLEConnection({ + deviceId: deviceId, + timeout: 10000, + success: function () { + _connected = true + discoverServices(deviceId, callbacks) + }, + fail: function () { + _connected = false + if (callbacks.onError) callbacks.onError({ msg: '连接失败' }) + } + }) +} + +function disconnect() { + _autoReconnect = false + _reconnecting = false + if (_deviceId) { + wx.closeBLEConnection({ deviceId: _deviceId }) + _deviceId = null + } + _connected = false + _chars = {} + var commands = require('./commands') + commands.clearPendingAcks() + _listeners = {} + wx.closeBluetoothAdapter({}) +} + +// --- reconnect --- + +function attemptReconnect(deviceId, retriesLeft) { + wx.createBLEConnection({ + deviceId: deviceId, + timeout: 10000, + success: function () { + _connected = true + _chars = {} + wx.getBLEDeviceServices({ + deviceId: deviceId, + success: function (res) { + var services = res.services + var serviceMap = {} + for (var i = 0; i < services.length; i++) { + var uuid = services[i].uuid.toUpperCase() + if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) { + serviceMap.deviceInfo = services[i].uuid + } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) { + serviceMap.dataComm = services[i].uuid + } + } + + var tasks = [] + if (serviceMap.deviceInfo) { + tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo')) + } + if (serviceMap.dataComm) { + tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm')) + } + + Promise.all(tasks).then(function () { + var serviceId = serviceMap.dataComm || null + return subscribeToNotifications(deviceId, serviceId) + }).then(function () { + _reconnecting = false + emit('reconnected', { deviceId: deviceId }) + }) + }, + fail: function () { + if (retriesLeft > 1) { + setTimeout(function () { + attemptReconnect(deviceId, retriesLeft - 1) + }, 2000) + } else { + _reconnecting = false + emit('reconnect_failed', { deviceId: deviceId }) + } + } + }) + }, + fail: function () { + if (retriesLeft > 1) { + setTimeout(function () { + attemptReconnect(deviceId, retriesLeft - 1) + }, 2000) + } else { + _reconnecting = false + emit('reconnect_failed', { deviceId: deviceId }) + } + } + }) +} + +function setAutoReconnect(enabled) { + _autoReconnect = enabled +} + +// --- BLE connection state change listener --- + +wx.onBLEConnectionStateChange(function (res) { + if (!res.connected) { + _connected = false + emit('disconnected', { deviceId: res.deviceId }) + if (_autoReconnect && !_reconnecting && _deviceId) { + _reconnecting = true + setTimeout(function () { + attemptReconnect(_deviceId, 3) + }, 2000) + } + } +}) + +module.exports = { + on: on, + off: off, + emit: emit, + + getDeviceId: getDeviceId, + isConnected: isConnected, + getChars: getChars, + + startScan: startScan, + stopScan: stopScan, + connect: connect, + disconnect: disconnect, + attemptReconnect: attemptReconnect, + setAutoReconnect: setAutoReconnect +} diff --git a/miniprogram/services/ble/index.js b/miniprogram/services/ble/index.js new file mode 100644 index 0000000..56501a9 --- /dev/null +++ b/miniprogram/services/ble/index.js @@ -0,0 +1,59 @@ +// BLE service barrel — re-exports a unified API +// Usage: var ble = require('../../services/ble') + +var protocol = require('./protocol') +var connection = require('./connection') +var commands = require('./commands') + +module.exports = { + // Constants + SERVICE: protocol.SERVICE, + CHAR: protocol.CHAR, + CMD: protocol.CMD, + NOTIFY: protocol.NOTIFY, + MODE_STATE: protocol.MODE_STATE, + WAVELENGTH: protocol.WAVELENGTH, + TREAT_MODE: protocol.TREAT_MODE, + REGION: protocol.REGION, + DEVICE_ERR: protocol.DEVICE_ERR, + + // Connection + isConnected: connection.isConnected, + getDeviceId: connection.getDeviceId, + startScan: connection.startScan, + stopScan: connection.stopScan, + connect: connection.connect, + disconnect: connection.disconnect, + setAutoReconnect: connection.setAutoReconnect, + on: connection.on, + off: connection.off, + + // Commands + readDeviceInfo: commands.readDeviceInfo, + setParams: commands.setParams, + startTreatment: commands.startTreatment, + stopTreatment: commands.stopTreatment, + queryStatus: commands.queryStatus, + bindDevice: commands.bindDevice, + unbindDevice: commands.unbindDevice, + + // Protocol utilities + buildFrame: protocol.buildFrame, + parseFrame: protocol.parseFrame, + parseStatusReport: protocol.parseStatusReport, + parseAck: protocol.parseAck, + parseTreatmentComplete: protocol.parseTreatmentComplete, + parseException: protocol.parseException, + + // Display helpers + REGION_NAMES: protocol.REGION_NAMES, + getRegionName: protocol.getRegionName, + getWavelengthName: protocol.getWavelengthName, + getModeStateName: protocol.getModeStateName, + + // Byte utilities + bufferToBytes: protocol.bufferToBytes, + bytesToBuffer: protocol.bytesToBuffer, + hexToBytes: protocol.hexToBytes, + bytesToHex: protocol.bytesToHex +} diff --git a/miniprogram/services/ble/protocol.js b/miniprogram/services/ble/protocol.js new file mode 100644 index 0000000..7a2ef8e --- /dev/null +++ b/miniprogram/services/ble/protocol.js @@ -0,0 +1,272 @@ +// BLE protocol: frame encoding/decoding, checksum, constants + +var SERVICE = { + DEVICE_INFO: 'FFE0', + DATA_COMM: 'FFE1', + OTA: 'FFE2' +} + +var CHAR = { + DEVICE_INFO: 'FFE3', + COMMAND: 'FFE4', + STATUS: 'FFE5', + BOND_INFO: 'FFE6', + OTA_CONTROL: 'FFE7', + OTA_DATA: 'FFE8', + OTA_STATUS: 'FFE9' +} + +var CMD = { + SET_PARAMS: 0x01, + START: 0x02, + STOP: 0x03, + QUERY_STATUS: 0x04, + BIND: 0x05, + UNBIND: 0x06 +} + +var NOTIFY = { + STATUS_REPORT: 0x21, + ACK: 0x22, + TREATMENT_COMPLETE: 0x31, + EXCEPTION: 0x32, + BIND_SUCCESS: 0x33 +} + +var MODE_STATE = { + IDLE: 0x00, + SCANNING: 0x01, + ACTIVE: 0x02, + PAUSED: 0x03, + COMPLETED: 0x04, + ERROR: 0x05, + OTA: 0x06 +} + +var WAVELENGTH = { + IR: 1, + R: 2, + UV: 3, + Y: 4 +} + +var TREAT_MODE = { + NORMAL: 0, + SMART: 1 +} + +var REGION = { + LEFT_CHEEK: 0x01, + RIGHT_CHEEK: 0x02, + FOREHEAD: 0x04, + CHIN: 0x08, + NOSE: 0x10, + LEFT_EYE: 0x20, + RIGHT_EYE: 0x40, + FULL_FACE: 0x7F +} + +var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye'] + +var DEVICE_ERR = { + 0x00: 'SUCCESS', + 0x01: 'ERR_REGION_INVALID', + 0x02: 'ERR_REGION_EMPTY', + 0x03: 'ERR_BRIGHTNESS_INVALID', + 0x04: 'ERR_DURATION_INVALID', + 0x05: 'ERR_NOT_BOUND', + 0x06: 'ERR_NO_SUBSCRIPTION', + 0x07: 'ERR_TEMP_HIGH', + 0x08: 'ERR_BATTERY_LOW', + 0x09: 'ERR_ALREADY_RUNNING', + 0x0A: 'ERR_NOT_RUNNING', + 0x0B: 'ERR_OTA_FAILED', + 0x0C: 'ERR_BLE_DISCONNECTED' +} + +// --- byte utilities --- + +function bufferToBytes(buffer) { + var arr = new Uint8Array(buffer) + var bytes = [] + for (var i = 0; i < arr.length; i++) { + bytes.push(arr[i]) + } + return bytes +} + +function bytesToBuffer(bytes) { + var buffer = new ArrayBuffer(bytes.length) + var view = new Uint8Array(buffer) + for (var i = 0; i < bytes.length; i++) { + view[i] = bytes[i] + } + return buffer +} + +function xorChecksum(bytes) { + var result = 0 + for (var i = 0; i < bytes.length; i++) { + result ^= bytes[i] + } + return result +} + +function uint32ToBytes(value) { + return [ + (value >> 24) & 0xFF, + (value >> 16) & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF + ] +} + +function bytesToUint32(bytes, offset) { + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3] +} + +function hexToBytes(hex) { + var bytes = [] + for (var i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substr(i, 2), 16)) + } + return bytes +} + +function bytesToHex(bytes) { + var hex = '' + for (var i = 0; i < bytes.length; i++) { + hex += ('0' + bytes[i].toString(16)).slice(-2) + } + return hex.toUpperCase() +} + +// --- frame encoding/decoding --- + +function buildFrame(type, payload) { + var len = payload ? payload.length : 0 + var frame = [0xAA, 0x55, len, type] + if (payload && payload.length > 0) { + frame = frame.concat(payload) + } + var checkBytes = frame.slice(0) + frame.push(xorChecksum(checkBytes)) + return bytesToBuffer(frame) +} + +function parseFrame(buffer) { + var bytes = bufferToBytes(buffer) + if (bytes.length < 5) return null + if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null + var len = bytes[2] + if (bytes.length < 5 + len) return null + var type = bytes[3] + var payload = bytes.slice(4, 4 + len) + var checksum = bytes[4 + len] + var expected = xorChecksum(bytes.slice(0, 4 + len)) + if (checksum !== expected) return null + return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 } +} + +// --- notification parsing --- + +function parseStatusReport(payload) { + if (payload.length < 14) return null + return { + mode_state: payload[0], + region_mask: payload[1], + wavelength: payload[2], + brightness: payload[3], + remaining_ms: bytesToUint32(payload, 4), + error_code: payload[8], + command_seq: payload[9], + battery: payload[10], + temperature: payload[11], + bind_status: payload[12], + subscription: payload[13] + } +} + +function parseAck(payload) { + return { + seq: payload[0], + error_code: payload.length > 1 ? payload[1] : 0, + error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN' + } +} + +function parseTreatmentComplete(payload) { + return { + session_id: bytesToHex(payload.slice(0, 8)), + regions: payload[8], + total_duration_ms: bytesToUint32(payload, 9), + avg_pd: payload[13] + } +} + +function parseException(payload) { + return { + error_code: payload[0], + error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN', + temperature: payload.length > 1 ? payload[1] : 0 + } +} + +// --- display helpers --- + +function getRegionName(mask) { + var names = [] + var bits = [ + [0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'], + [0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周'] + ] + for (var i = 0; i < bits.length; i++) { + if (mask & bits[i][0]) names.push(bits[i][1]) + } + return names +} + +function getWavelengthName(code) { + var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' } + return map[code] || '未知' +} + +function getModeStateName(code) { + var map = { + 0x00: '空闲', 0x01: '扫描中', 0x02: '护理中', + 0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中' + } + return map[code] || '未知' +} + +module.exports = { + SERVICE: SERVICE, + CHAR: CHAR, + CMD: CMD, + NOTIFY: NOTIFY, + MODE_STATE: MODE_STATE, + WAVELENGTH: WAVELENGTH, + TREAT_MODE: TREAT_MODE, + REGION: REGION, + REGION_NAMES: REGION_NAMES, + DEVICE_ERR: DEVICE_ERR, + + bufferToBytes: bufferToBytes, + bytesToBuffer: bytesToBuffer, + xorChecksum: xorChecksum, + uint32ToBytes: uint32ToBytes, + bytesToUint32: bytesToUint32, + hexToBytes: hexToBytes, + bytesToHex: bytesToHex, + + buildFrame: buildFrame, + parseFrame: parseFrame, + parseStatusReport: parseStatusReport, + parseAck: parseAck, + parseTreatmentComplete: parseTreatmentComplete, + parseException: parseException, + + getRegionName: getRegionName, + getWavelengthName: getWavelengthName, + getModeStateName: getModeStateName +} diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js new file mode 100644 index 0000000..c3fcc35 --- /dev/null +++ b/miniprogram/utils/api.js @@ -0,0 +1,32 @@ +var http = require('./request') + +module.exports = { + // Auth + login: function (code) { return http.post('/api/v1/auth/login', { code: code }) }, + getPhone: function (code) { return http.post('/api/v1/user/phone', { code: code }) }, + + // User + getProfile: function () { return http.get('/api/v1/user/profile') }, + updateProfile: function (data) { return http.put('/api/v1/user/profile', data) }, + + // Device + getDevices: function () { return http.get('/api/v1/device/list') }, + bindDevice: function (deviceId) { return http.post('/api/v1/device/bind', { device_id: deviceId }) }, + confirmBind: function (deviceId, token) { return http.post('/api/v1/device/bind/confirm', { device_id: deviceId, bind_token: token }) }, + mockBind: function (deviceId) { return http.post('/api/v1/device/mock-bind', { device_id: deviceId }) }, + unbindDevice: function (deviceId) { return http.post('/api/v1/device/unbind', { device_id: deviceId }) }, + + // Subscription + getSubscription: function () { return http.get('/api/v1/subscription') }, + activateTrial: function () { return http.post('/api/v1/subscription/trial') }, + purchase: function (plan) { return http.post('/api/v1/subscription/purchase', { plan: plan }) }, + + // Treatment + getRecords: function (params) { return http.get('/api/v1/treatment/history', params) }, + syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) }, + getRecordDetail: function (id) { return http.get('/api/v1/treatment/' + id) }, + + // Device commands + getPendingCommands: function (deviceId) { return http.get('/api/v1/device/command/pending', { device_id: deviceId }) }, + reportCommandResult: function (data) { return http.post('/api/v1/device/command/result', data) } +} diff --git a/miniprogram/utils/page.js b/miniprogram/utils/page.js new file mode 100644 index 0000000..b02ff6b --- /dev/null +++ b/miniprogram/utils/page.js @@ -0,0 +1,25 @@ +var app = getApp() + +/** Get status bar height for custom navigation pages */ +function getStatusBarHeight() { + return app.globalData.statusBarHeight || 44 +} + +/** Standard back navigation with fallback */ +function navigateBack(fallbackUrl) { + wx.navigateBack({ + fail: function () { + if (fallbackUrl) { + wx.reLaunch({ url: fallbackUrl }) + } + } + }) +} + +/** Check if dev mode is enabled */ +function isDevMode() { + var config = require('../config/env') + return config.__DEV__ || false +} + +module.exports = { getStatusBarHeight: getStatusBarHeight, navigateBack: navigateBack, isDevMode: isDevMode } diff --git a/server/package-lock.json b/server/package-lock.json index 7e7db4e..aca821f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "bcryptjs": "^2.4.3", "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", + "express": "^5.2.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.3" }, @@ -26,6 +27,44 @@ "undici-types": "~7.19.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ajv": { "version": "7.2.4", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-7.2.4.tgz", @@ -131,12 +170,89 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz", @@ -180,6 +296,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", @@ -228,6 +384,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -246,6 +419,15 @@ "node": ">=0.10" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-6.0.1.tgz", @@ -273,6 +455,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmmirror.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -292,6 +488,21 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -301,6 +512,134 @@ "node": ">=6" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", @@ -350,6 +689,27 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-3.0.0.tgz", @@ -385,6 +745,33 @@ "node": ">= 0.12" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", @@ -394,6 +781,43 @@ "is-property": "^1.0.2" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmmirror.com/getpass/-/getpass-0.1.7.tgz", @@ -403,6 +827,18 @@ "assert-plus": "^1.0.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/har-schema/-/har-schema-2.0.0.tgz", @@ -448,6 +884,50 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/http-signature/-/http-signature-1.2.0.tgz", @@ -479,6 +959,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz", @@ -488,6 +983,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz", @@ -694,6 +1195,36 @@ "semver": "bin/semver.js" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -764,6 +1295,15 @@ "node": ">=8.0.0" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmmirror.com/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -773,6 +1313,39 @@ "node": "*" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", @@ -833,6 +1406,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-3.0.0.tgz", @@ -842,6 +1424,16 @@ "node": ">=4" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", @@ -860,6 +1452,19 @@ "node": ">=8" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz", @@ -890,6 +1495,30 @@ "node": ">=0.6" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/request": { "version": "2.88.2", "resolved": "https://registry.npmmirror.com/request/-/request-2.88.2.tgz", @@ -931,6 +1560,22 @@ "node": ">=0.10.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -969,6 +1614,154 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.3.3.tgz", @@ -1009,6 +1802,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/strnum": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz", @@ -1021,6 +1823,15 @@ ], "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -1052,6 +1863,45 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", @@ -1059,6 +1909,15 @@ "license": "MIT", "peer": true }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", @@ -1078,6 +1937,15 @@ "uuid": "bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz", @@ -1091,6 +1959,12 @@ "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/server/package.json b/server/package.json index 8a4fa1a..ae6ee77 100644 --- a/server/package.json +++ b/server/package.json @@ -12,8 +12,8 @@ "bcryptjs": "^2.4.3", "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", + "express": "^5.2.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.3" - }, - "devDependencies": {} + } } diff --git a/server/scripts/local-server.js b/server/scripts/local-server.js index e821908..f1ef983 100644 --- a/server/scripts/local-server.js +++ b/server/scripts/local-server.js @@ -1,27 +1,8 @@ require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }) -const http = require('http') const config = require('../src/config') -const { handle } = require('../src/app') +const app = require('../src/app') -const server = http.createServer(async (req, res) => { - const chunks = [] - req.on('data', chunk => chunks.push(chunk)) - req.on('end', async () => { - const url = new URL(req.url, 'http://localhost') - const event = { - httpMethod: req.method, - path: url.pathname, - headers: req.headers, - queryStringParameters: Object.fromEntries(url.searchParams.entries()), - body: Buffer.concat(chunks).toString('utf8') - } - const result = await handle(event) - res.writeHead(result.statusCode, result.headers) - res.end(result.body || '') - }) -}) - -server.listen(config.port, () => { - console.log('SCF local server listening on http://localhost:' + config.port) +app.listen(config.port, () => { + console.log('Server listening on http://localhost:' + config.port) }) diff --git a/server/src/app.js b/server/src/app.js index 59c1a31..8373ec0 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -1,34 +1,35 @@ -const Router = require('./lib/router') -const { createContext } = require('./lib/request') -const { ok, fail, http } = require('./lib/response') +const express = require('express') +const { ok, fail } = require('./lib/response') +const { authMiddleware } = require('./middleware/auth') -const router = new Router() +const app = express() -require('./routes/auth')(router) -require('./routes/user')(router) -require('./routes/device')(router) -require('./routes/subscription')(router) -require('./routes/treatment')(router) -require('./routes/admin')(router) -require('./routes/firmware')(router) +app.use(express.json()) +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform') + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + if (req.method === 'OPTIONS') return res.sendStatus(204) + next() +}) -async function handle(event) { - const ctx = createContext(event || {}) - if (ctx.method === 'OPTIONS') return http(204, {}) - if (ctx.path === '/health') return http(200, ok({ status: 'ok' })) +app.use(authMiddleware) - const match = router.match(ctx.method, ctx.path) - if (!match) return http(404, fail(404, 'not_found')) +app.get('/health', (req, res) => res.json(ok({ status: 'ok' }))) - ctx.params = match.params +app.use('/api/v1', require('./routes/auth')) +app.use('/api/v1', require('./routes/user')) +app.use('/api/v1', require('./routes/device')) +app.use('/api/v1', require('./routes/subscription')) +app.use('/api/v1', require('./routes/treatment')) +app.use('/api/v1/admin', require('./routes/admin')) +app.use('/api/v1', require('./routes/firmware')) - try { - const body = await match.handler(ctx) - return http(200, body) - } catch (err) { - console.error('[ERROR]', ctx.method, ctx.path, err.code || '', err.sqlMessage || err.message, err.stack) - return http(500, fail(3001, 'server_error')) - } -} +app.use((req, res) => res.status(404).json(fail(404, 'not_found'))) -module.exports = { handle } +app.use((err, req, res, _next) => { + console.error('[ERROR]', req.method, req.path, err.code || '', err.sqlMessage || err.message, err.stack) + res.status(500).json(fail(3001, 'server_error')) +}) + +module.exports = app diff --git a/server/src/dao/admin.dao.js b/server/src/dao/admin.dao.js new file mode 100644 index 0000000..d62f1ce --- /dev/null +++ b/server/src/dao/admin.dao.js @@ -0,0 +1,93 @@ +const { query, one } = require('../lib/db') + +/** + * Find admin account by username (active only) + * @param {string} username + * @returns {Promise} admin row or null + */ +async function findByUsername(username) { + return one( + 'SELECT * FROM admin_accounts WHERE username = :username AND status = 1', + { username } + ) +} + +/** + * Find admin account by ID (active only) + * @param {number} adminId + * @returns {Promise} admin row or null + */ +async function findById(adminId) { + return one( + 'SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', + { admin_id: adminId } + ) +} + +/** + * Update admin password hash and clear legacy salt + * @param {number} adminId + * @param {string} passwordHash - bcrypt hash + * @returns {Promise} query result + */ +async function updatePassword(adminId, passwordHash) { + return query( + 'UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', + { password_hash: passwordHash, password_salt: '', admin_id: adminId } + ) +} + +/** + * Auto-migrate password from legacy SHA-256 to bcrypt + * @param {number} adminId + * @param {string} newHash - bcrypt hash + * @returns {Promise} query result + */ +async function migratePassword(adminId, newHash) { + return updatePassword(adminId, newHash) +} + +/** + * Get dashboard aggregate counts (devices, users, treatments, active subscriptions) + * @returns {Promise<{device_count: number, user_count: number, treatment_count: number, subscription_count: number}>} + */ +async function getDashboardCounts() { + const rows = await Promise.all([ + query('SELECT COUNT(*) AS total FROM devices', {}), + query('SELECT COUNT(*) AS total FROM users', {}), + query('SELECT COUNT(*) AS total FROM treatment_records', {}), + query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {}) + ]) + return { + device_count: rows[0][0].total, + user_count: rows[1][0].total, + treatment_count: rows[2][0].total, + subscription_count: rows[3][0].total + } +} + +/** + * Get subscription breakdown stats (monthly/yearly/trial counts, monthly revenue) + * @returns {Promise} { monthly_count, yearly_count, trial_count, monthly_revenue } + */ +async function getSubscriptionStats() { + const rows = await query( + 'SELECT ' + + "SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " + + "SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " + + "SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " + + 'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' + + 'FROM subscriptions', + {} + ) + return rows[0] || {} +} + +module.exports = { + findByUsername, + findById, + updatePassword, + migratePassword, + getDashboardCounts, + getSubscriptionStats +} diff --git a/server/src/dao/binding.dao.js b/server/src/dao/binding.dao.js new file mode 100644 index 0000000..d3c5ad9 --- /dev/null +++ b/server/src/dao/binding.dao.js @@ -0,0 +1,203 @@ +const { query, one, transaction } = require('../lib/db') + +/** + * Find active binding for a user (bind_status=1) + * Uses transaction connection when provided + * @param {number} userId + * @param {Object} [conn] - optional transaction connection + * @returns {Promise} + */ +async function findActiveByUser(userId, conn) { + if (conn) { + const [rows] = await conn.execute( + 'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', + [userId] + ) + return rows[0] || null + } + return one( + 'SELECT device_id FROM bindings WHERE user_id = :user_id AND bind_status = 1 LIMIT 1', + { user_id: userId } + ) +} + +/** + * Check if a device exists and is not disabled (status <> 4) + * Uses transaction connection when provided + * @param {string} deviceId + * @param {Object} [conn] - optional transaction connection + * @returns {Promise} + */ +async function findDeviceExists(deviceId, conn) { + if (conn) { + const [rows] = await conn.execute( + 'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', + [deviceId] + ) + return rows[0] || null + } + return one( + 'SELECT * FROM devices WHERE device_id = :device_id AND status <> 4 LIMIT 1', + { device_id: deviceId } + ) +} + +/** + * Create a pending binding request with 10-minute expiry + * @param {number} userId + * @param {string} deviceId + * @param {string} bindToken + * @param {Object} [conn] - optional transaction connection + * @returns {Promise} + */ +async function createPending(userId, deviceId, bindToken, conn) { + if (conn) { + await conn.execute( + 'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())', + [userId, deviceId, bindToken] + ) + return + } + await query( + 'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (:user_id, :device_id, :bind_token, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())', + { user_id: userId, device_id: deviceId, bind_token: bindToken } + ) +} + +/** + * Confirm a pending binding by token, set bind_status=1. + * Also ensures trial subscription if none active. + * @param {number} userId + * @param {string} deviceId + * @param {string} bindToken + * @returns {Promise} true if confirmed, false if token invalid/expired + */ +async function confirmBind(userId, deviceId, bindToken) { + return transaction(async conn => { + const [rows] = await conn.execute( + 'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1', + [userId, deviceId, bindToken] + ) + if (rows.length === 0) return false + await conn.execute( + 'UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?', + [rows[0].binding_id] + ) + // Ensure trial subscription + const [subs] = await conn.execute( + 'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', + [userId] + ) + if (subs.length === 0) { + await conn.execute( + "INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))", + [userId] + ) + } + return true + }) +} + +/** + * Mock bind (dev/test only): directly bind with status=1, auto-trial + * @param {number} userId + * @param {string} deviceId + * @returns {Promise<{success?: boolean, duplicated?: boolean, device_id?: string, invalid?: boolean}>} + */ +async function mockBind(userId, deviceId) { + return transaction(async conn => { + const [active] = await conn.execute( + 'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', + [userId] + ) + if (active.length > 0) return { duplicated: true, device_id: active[0].device_id } + const [devices] = await conn.execute( + 'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', + [deviceId] + ) + if (devices.length === 0) return { invalid: true } + await conn.execute( + 'UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3', + [userId] + ) + await conn.execute( + "INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, 'mock', NOW(), 1, NOW())", + [userId, deviceId] + ) + // Ensure trial + const [subs] = await conn.execute( + 'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', + [userId] + ) + if (subs.length === 0) { + await conn.execute( + "INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))", + [userId] + ) + } + return { success: true } + }) +} + +/** + * Unbind device(s) for a user + * @param {number} userId + * @param {string|null} deviceId - specific device or null for all active bindings + * @returns {Promise} query result + */ +async function unbindByUser(userId, deviceId) { + return query( + 'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)', + { user_id: userId, device_id: deviceId } + ) +} + +/** + * Get binding history for a device with user nicknames + * @param {string} deviceId + * @returns {Promise} + */ +async function getHistoryByDevice(deviceId) { + return query( + 'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC', + { device_id: deviceId } + ) +} + +/** + * Check if a user has an active binding to a specific device + * @param {number} userId + * @param {string} deviceId + * @returns {Promise} + */ +async function findUserDeviceBinding(userId, deviceId) { + return one( + 'SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', + { user_id: userId, device_id: deviceId } + ) +} + +/** + * Count active bindings for a user + * @param {number} userId + * @returns {Promise} + */ +async function countActiveByUser(userId) { + const rows = await query( + 'SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1', + { user_id: userId } + ) + return rows[0].total +} + +module.exports = { + findActiveByUser, + findDeviceExists, + createPending, + confirmBind, + mockBind, + unbindByUser, + getHistoryByDevice, + findUserDeviceBinding, + countActiveByUser +} diff --git a/server/src/dao/command.dao.js b/server/src/dao/command.dao.js new file mode 100644 index 0000000..e389471 --- /dev/null +++ b/server/src/dao/command.dao.js @@ -0,0 +1,120 @@ +const { query, one, limitClause } = require('../lib/db') + +/** + * Create a device command + * @param {string} deviceId + * @param {number} adminId + * @param {number} opcode + * @param {Object} payload - will be JSON-stringified + * @returns {Promise} query result + */ +async function create(deviceId, adminId, opcode, payload) { + return query( + 'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)', + { + device_id: deviceId, + admin_id: adminId, + opcode, + payload_json: JSON.stringify(payload) + } + ) +} + +/** + * List commands for a device with pagination + * @param {string} deviceId + * @param {Object} opts + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function listByDevice(deviceId, { pageSize, offset }) { + const total = await query( + 'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id', + { device_id: deviceId } + ) + const records = await query( + 'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(pageSize, offset), + { device_id: deviceId } + ) + return { records, total: total[0].total } +} + +/** + * Count commands for a device + * @param {string} deviceId + * @returns {Promise} + */ +async function countByDevice(deviceId) { + const rows = await query( + 'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id', + { device_id: deviceId } + ) + return rows[0].total +} + +/** + * Get pending commands for a device (status=1), ordered by creation time + * @param {string} deviceId + * @returns {Promise} commands with command_id, opcode, payload_json + */ +async function getPending(deviceId) { + return query( + 'SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10', + { device_id: deviceId } + ) +} + +/** + * Mark commands as pulled (status=2) + * @param {number[]} commandIds - array of command IDs + * @returns {Promise} query result + */ +async function markPulled(commandIds) { + if (!commandIds || commandIds.length === 0) return [] + return query( + 'UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commandIds.map(Number).join(',') + ')', + {} + ) +} + +/** + * Finish a command execution (set status=3 success or status=4 failure) + * @param {number} commandId + * @param {boolean} success + * @param {Object} result - result payload to store as JSON + * @returns {Promise} query result + */ +async function finish(commandId, success, result) { + return query( + 'UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id', + { + command_id: commandId, + status: success ? 3 : 4, + result_json: JSON.stringify(result) + } + ) +} + +/** + * Find a command by ID that belongs to a device bound to a specific user + * @param {number} commandId + * @param {number} userId + * @returns {Promise} + */ +async function findByIdForUser(commandId, userId) { + return one( + 'SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id', + { user_id: userId, command_id: commandId } + ) +} + +module.exports = { + create, + listByDevice, + countByDevice, + getPending, + markPulled, + finish, + findByIdForUser +} diff --git a/server/src/dao/device-event.dao.js b/server/src/dao/device-event.dao.js new file mode 100644 index 0000000..6ce0450 --- /dev/null +++ b/server/src/dao/device-event.dao.js @@ -0,0 +1,30 @@ +const { query } = require('../lib/db') + +/** + * Insert a device event record + * @param {Object} event + * @param {string} event.device_id + * @param {number} event.user_id + * @param {string} [event.event_type] - defaults to 'device_error' + * @param {string|null} [event.error_code] + * @param {number|null} [event.temperature] + * @param {Object} event.payload - raw payload, will be JSON-stringified + * @returns {Promise} query result + */ +async function create(event) { + return query( + 'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)', + { + device_id: event.device_id, + user_id: event.user_id, + event_type: event.event_type || 'device_error', + error_code: event.error_code || null, + temperature: event.temperature || null, + payload_json: JSON.stringify(event.payload || {}) + } + ) +} + +module.exports = { + create +} diff --git a/server/src/dao/device.dao.js b/server/src/dao/device.dao.js new file mode 100644 index 0000000..71b5caf --- /dev/null +++ b/server/src/dao/device.dao.js @@ -0,0 +1,172 @@ +const { query, one, limitClause } = require('../lib/db') + +/** + * List devices with pagination and optional keyword search + * @param {Object} opts + * @param {string} [opts.keyword] - search device_id or device_name + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function list({ keyword, pageSize, offset }) { + let where = '' + const params = {} + if (keyword) { + where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw' + params.kw = '%' + keyword + '%' + } + const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params) + const records = await query( + 'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' + + where + ' ORDER BY d.created_at DESC' + limitClause(pageSize, offset), + params + ) + return { records, total: total[0].total } +} + +/** + * Count devices matching optional keyword + * @param {Object} opts + * @param {string} [opts.keyword] + * @returns {Promise} + */ +async function count({ keyword }) { + let where = '' + const params = {} + if (keyword) { + where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw' + params.kw = '%' + keyword + '%' + } + const rows = await query('SELECT COUNT(*) AS total FROM devices d' + where, params) + return rows[0].total +} + +/** + * Find a single device by ID with bound user info + * @param {string} deviceId + * @returns {Promise} + */ +async function findById(deviceId) { + return one( + 'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id', + { device_id: deviceId } + ) +} + +/** + * Find device by ID with binding history and recent treatments + * @param {string} deviceId + * @returns {Promise<{device: Object|null, binding_history: Array, recent_treatments: Array}>} + */ +async function findByIdWithHistory(deviceId) { + const device = await findById(deviceId) + if (!device) return { device: null, binding_history: [], recent_treatments: [] } + const bindingHistory = await query( + 'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC', + { device_id: deviceId } + ) + const recentTreatments = await query( + 'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5', + { device_id: deviceId } + ) + return { device, binding_history: bindingHistory, recent_treatments: recentTreatments } +} + +/** + * Create or update a device (upsert) + * @param {Object} device + * @param {string} device.device_id + * @param {string} [device.product_id] + * @param {string} [device.device_secret] + * @param {string} [device.device_name] + * @param {string} [device.firmware_version] + * @returns {Promise} query result + */ +async function create(device) { + return query( + 'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1', + { + device_id: device.device_id, + product_id: device.product_id || 'HOX_LIGHT_MASK', + device_secret: device.device_secret || '', + device_name: device.device_name || '光子美容仪', + firmware_version: device.firmware_version || '1.0.0' + } + ) +} + +/** + * Batch-create devices by IDs, return success/failure counts + * @param {string[]} deviceIds + * @returns {Promise<{created: number, failed: string[]}>} + */ +async function createBatch(deviceIds) { + let created = 0 + const failed = [] + for (const id of deviceIds) { + const deviceId = String(id || '').trim() + if (!deviceId) { failed.push(id); continue } + try { + await create({ + device_id: deviceId, + product_id: 'HOX_LIGHT_MASK', + device_secret: '', + device_name: '光子美容仪', + firmware_version: '1.0.0' + }) + created++ + } catch (err) { + failed.push(deviceId) + } + } + return { created, failed } +} + +/** + * Admin-unbind a device (set bind_status=2) + * @param {string} deviceId + * @returns {Promise} query result + */ +async function unbind(deviceId) { + return query( + 'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1', + { device_id: deviceId } + ) +} + +/** + * List user's bound devices with device details + * @param {number} userId + * @returns {Promise} + */ +async function listByUser(userId) { + return query( + 'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC', + { user_id: userId } + ) +} + +/** + * Get a single bound device for a user + * @param {number} userId + * @param {string} deviceId + * @returns {Promise} + */ +async function findBoundDevice(userId, deviceId) { + return one( + 'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id', + { user_id: userId, device_id: deviceId } + ) +} + +module.exports = { + list, + count, + findById, + findByIdWithHistory, + create, + createBatch, + unbind, + listByUser, + findBoundDevice +} diff --git a/server/src/dao/firmware.dao.js b/server/src/dao/firmware.dao.js new file mode 100644 index 0000000..f84f21e --- /dev/null +++ b/server/src/dao/firmware.dao.js @@ -0,0 +1,68 @@ +const { query, one } = require('../lib/db') + +/** + * List all firmware files ordered by creation date (newest first) + * @returns {Promise} + */ +async function list() { + return query( + 'SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC', + {} + ) +} + +/** + * Create a firmware record + * @param {Object} firmware + * @param {string} firmware.version + * @param {string} [firmware.device_type] + * @param {string} firmware.cos_key + * @param {number} [firmware.size_bytes] + * @param {string} [firmware.sha256] + * @param {number} [firmware.status] - 0=disabled, 1=enabled (default 1) + * @returns {Promise} query result with insertId + */ +async function create(firmware) { + return query( + 'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)', + { + version: firmware.version, + device_type: firmware.device_type || '', + cos_key: firmware.cos_key, + size_bytes: Number(firmware.size_bytes || 0), + sha256: firmware.sha256 || '', + status: firmware.status === 0 ? 0 : 1 + } + ) +} + +/** + * Update firmware status (enable/disable) + * @param {number} firmwareId + * @param {number} status - 0 or 1 + * @returns {Promise} query result + */ +async function updateStatus(firmwareId, status) { + return query( + 'UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id', + { status, firmware_id: firmwareId } + ) +} + +/** + * Find the latest enabled firmware + * @returns {Promise} + */ +async function findLatest() { + return one( + 'SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1', + {} + ) +} + +module.exports = { + list, + create, + updateStatus, + findLatest +} diff --git a/server/src/dao/index.js b/server/src/dao/index.js new file mode 100644 index 0000000..81f8efc --- /dev/null +++ b/server/src/dao/index.js @@ -0,0 +1,13 @@ +module.exports = { + adminDao: require('./admin.dao'), + deviceDao: require('./device.dao'), + bindingDao: require('./binding.dao'), + subscriptionDao: require('./subscription.dao'), + treatmentDao: require('./treatment.dao'), + userDao: require('./user.dao'), + logDao: require('./log.dao'), + commandDao: require('./command.dao'), + settingsDao: require('./settings.dao'), + firmwareDao: require('./firmware.dao'), + deviceEventDao: require('./device-event.dao') +} diff --git a/server/src/dao/log.dao.js b/server/src/dao/log.dao.js new file mode 100644 index 0000000..7c63776 --- /dev/null +++ b/server/src/dao/log.dao.js @@ -0,0 +1,82 @@ +const { query, limitClause } = require('../lib/db') + +/** + * Write an operation log entry + * @param {Object} options + * @param {number|null} [options.user_id] + * @param {number|null} [options.admin_id] + * @param {string} options.action + * @param {string} [options.detail] + * @param {string} [options.ip] + * @returns {Promise} query result + */ +async function write(options) { + return query( + 'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)', + { + user_id: options.user_id || null, + admin_id: options.admin_id || null, + action: options.action, + detail: options.detail || '', + ip: options.ip || '' + } + ) +} + +/** + * Admin paginated list of operation logs with optional filters + * @param {Object} opts + * @param {string} [opts.type] - filter by action (LIKE match) + * @param {string} [opts.deviceId] - filter by detail containing device ID + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function list({ type, deviceId, pageSize, offset }) { + const conditions = [] + const params = {} + if (type) { + conditions.push('action LIKE :type') + params.type = '%' + type + '%' + } + if (deviceId) { + conditions.push('detail LIKE :device_id') + params.device_id = '%' + deviceId + '%' + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' + const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params) + const records = await query( + 'SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(pageSize, offset), + params + ) + return { records, total: total[0].total } +} + +/** + * Count operation logs with optional filters + * @param {Object} opts + * @param {string} [opts.type] + * @param {string} [opts.deviceId] + * @returns {Promise} + */ +async function count({ type, deviceId }) { + const conditions = [] + const params = {} + if (type) { + conditions.push('action LIKE :type') + params.type = '%' + type + '%' + } + if (deviceId) { + conditions.push('detail LIKE :device_id') + params.device_id = '%' + deviceId + '%' + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' + const rows = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params) + return rows[0].total +} + +module.exports = { + write, + list, + count +} diff --git a/server/src/dao/settings.dao.js b/server/src/dao/settings.dao.js new file mode 100644 index 0000000..8f20e64 --- /dev/null +++ b/server/src/dao/settings.dao.js @@ -0,0 +1,40 @@ +const { query } = require('../lib/db') + +/** + * Get all system settings, parsing JSON values where possible + * @returns {Promise} key-value map of settings + */ +async function getAll() { + const rows = await query('SELECT setting_key, setting_value FROM system_settings', {}) + const settings = {} + rows.forEach(row => { + if (typeof row.setting_value === 'string') { + try { + settings[row.setting_key] = JSON.parse(row.setting_value) + } catch (_) { + settings[row.setting_key] = row.setting_value + } + } else { + settings[row.setting_key] = row.setting_value + } + }) + return settings +} + +/** + * Upsert a system setting (REPLACE INTO) + * @param {string} key - setting_key + * @param {*} value - will be JSON-stringified + * @returns {Promise} query result + */ +async function update(key, value) { + return query( + 'REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)', + { setting_key: key, setting_value: JSON.stringify(value) } + ) +} + +module.exports = { + getAll, + update +} diff --git a/server/src/dao/subscription.dao.js b/server/src/dao/subscription.dao.js new file mode 100644 index 0000000..e94fc47 --- /dev/null +++ b/server/src/dao/subscription.dao.js @@ -0,0 +1,197 @@ +const { query, one, transaction, limitClause } = require('../lib/db') + +/** + * Find active subscription for a user with remaining days + * @param {number} userId + * @returns {Promise} subscription row with remaining_days, or null + */ +async function findActive(userId) { + return one( + 'SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1', + { user_id: userId } + ) +} + +/** + * Find active subscription summary (plan + remaining_days) for a user + * @param {number} userId + * @returns {Promise} + */ +async function findActiveSummary(userId) { + return one( + 'SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', + { user_id: userId } + ) +} + +/** + * Check if a user has ever had a trial subscription + * @param {number} userId + * @returns {Promise} subscription row or null + */ +async function findTrial(userId) { + return one( + "SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = 'trial' LIMIT 1", + { user_id: userId } + ) +} + +/** + * Check if a user has any active subscription + * @param {number} userId + * @returns {Promise} + */ +async function findAnyActive(userId) { + return one( + 'SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', + { user_id: userId } + ) +} + +/** + * Create a trial subscription (7 days, amount=0) + * @param {number} userId + * @param {string} [orderId] - optional order ID + * @returns {Promise} query result + */ +async function createTrial(userId, orderId) { + return query( + "INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, 'trial', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))", + { user_id: userId, order_id: orderId || 'TRIAL' + Date.now() } + ) +} + +/** + * Purchase / activate a subscription: expire old active subs, insert new one + * @param {number} userId + * @param {string} plan - 'monthly' | 'yearly' | 'trial' + * @param {number} amount + * @param {string} orderId + * @param {number} days + * @returns {Promise} + */ +async function purchase(userId, plan, amount, orderId, days) { + return transaction(async conn => { + await conn.execute( + 'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', + [userId] + ) + await conn.execute( + 'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', + [userId, plan, amount, orderId, days] + ) + }) +} + +/** + * Admin-create a subscription (expire old, insert new) without transaction + * Used by admin subscription creation endpoint + * @param {number} userId + * @param {string} plan + * @param {number} amount + * @param {string} orderId + * @param {number} days + * @returns {Promise} + */ +async function adminCreate(userId, plan, amount, orderId, days) { + await query( + 'UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', + { user_id: userId } + ) + await query( + 'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))', + { user_id: userId, plan, amount, order_id: orderId, days } + ) +} + +/** + * Cancel a subscription (set status=3) + * @param {number} subscriptionId + * @returns {Promise} query result (check affectedRows) + */ +async function cancel(subscriptionId) { + return query( + 'UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1', + { subscription_id: subscriptionId } + ) +} + +/** + * Admin paginated subscription list with user nickname and optional tab filter + * @param {Object} opts + * @param {string} [opts.tab] - 'all' | 'monthly' | 'yearly' | 'trial' | 'expired' + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function list({ tab, pageSize, offset }) { + let where = '' + const params = {} + if (tab && tab !== 'all') { + if (tab === 'expired') { + where = ' WHERE s.status = 2' + } else { + where = ' WHERE s.plan = :plan' + params.plan = tab + } + } + const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params) + const records = await query( + 'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' + + where + ' ORDER BY s.created_at DESC' + limitClause(pageSize, offset), + params + ) + return { records, total: total[0].total } +} + +/** + * Count subscriptions with optional tab filter + * @param {Object} opts + * @param {string} [opts.tab] + * @returns {Promise} + */ +async function count({ tab }) { + let where = '' + const params = {} + if (tab && tab !== 'all') { + if (tab === 'expired') { + where = ' WHERE s.status = 2' + } else { + where = ' WHERE s.plan = :plan' + params.plan = tab + } + } + const rows = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params) + return rows[0].total +} + +/** + * Get subscription stats: plan counts + monthly revenue + * @returns {Promise} { monthly_count, yearly_count, trial_count, monthly_revenue } + */ +async function getStats() { + const rows = await query( + 'SELECT ' + + "SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " + + "SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " + + "SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " + + 'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' + + 'FROM subscriptions', + {} + ) + return rows[0] || {} +} + +module.exports = { + findActive, + findActiveSummary, + findTrial, + findAnyActive, + createTrial, + purchase, + adminCreate, + cancel, + list, + count, + getStats +} diff --git a/server/src/dao/treatment.dao.js b/server/src/dao/treatment.dao.js new file mode 100644 index 0000000..afa56dd --- /dev/null +++ b/server/src/dao/treatment.dao.js @@ -0,0 +1,202 @@ +const { query, one, limitClause } = require('../lib/db') + +/** + * List treatment records for a user with pagination + * @param {number} userId + * @param {Object} opts + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function listByUser(userId, { pageSize, offset }) { + const total = await query( + 'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id', + { user_id: userId } + ) + const records = await query( + 'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset), + { user_id: userId } + ) + return { records, total: total[0].total } +} + +/** + * Count treatment records for a user + * @param {number} userId + * @returns {Promise} + */ +async function countByUser(userId) { + const rows = await query( + 'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id', + { user_id: userId } + ) + return rows[0].total +} + +/** + * Find a treatment record by session ID (optionally scoped to user) + * @param {string} sessionId + * @param {number} [userId] - if provided, restrict to this user + * @returns {Promise} + */ +async function findBySession(sessionId, userId) { + if (userId !== undefined) { + return one( + 'SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id', + { session_id: sessionId, user_id: userId } + ) + } + return one( + 'SELECT * FROM treatment_records WHERE session_id = :session_id', + { session_id: sessionId } + ) +} + +/** + * Create or update a treatment record (upsert by session_id) + * @param {Object} record + * @param {string} record.session_id + * @param {string} record.device_id + * @param {number} record.user_id + * @param {string|null} record.start_time - MySQL datetime string + * @param {string|null} record.end_time + * @param {string} record.regions - comma-separated + * @param {number} record.total_duration_ms + * @param {number} record.mode + * @param {number} record.avg_pd + * @param {number|null} record.battery + * @param {number|null} record.temperature + * @param {number|null} record.wavelength + * @param {number|null} record.brightness + * @param {string} record.pd_json - JSON string + * @returns {Promise} query result + */ +async function create(record) { + return query( + `INSERT INTO treatment_records + (session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json) + VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json) + ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`, + record + ) +} + +/** + * Update device battery, temperature, and last_online_at + * @param {string} deviceId + * @param {number|null} battery + * @param {number|null} temperature + * @returns {Promise} query result + */ +async function updateDevice(deviceId, battery, temperature) { + return query( + 'UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id', + { device_id: deviceId, battery, temperature } + ) +} + +/** + * Admin paginated list of treatment records with user nickname + * @param {Object} opts + * @param {string} [opts.keyword] - search by user nickname + * @param {string} [opts.dateFrom] - start date filter (inclusive) + * @param {string} [opts.dateTo] - end date filter (inclusive) + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function listAdmin({ keyword, dateFrom, dateTo, pageSize, offset }) { + const conditions = [] + const params = {} + if (keyword) { + conditions.push('u.nickname LIKE :kw') + params.kw = '%' + keyword + '%' + } + if (dateFrom) { + conditions.push('r.created_at >= :date_from') + params.date_from = dateFrom + } + if (dateTo) { + conditions.push('r.created_at <= :date_to') + params.date_to = dateTo + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' + const total = await query( + 'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where, + params + ) + const records = await query( + 'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + + where + ' ORDER BY r.created_at DESC' + limitClause(pageSize, offset), + params + ) + return { records, total: total[0].total } +} + +/** + * Count admin treatment records with filters + * @param {Object} opts + * @param {string} [opts.keyword] + * @param {string} [opts.dateFrom] + * @param {string} [opts.dateTo] + * @returns {Promise} + */ +async function countAdmin({ keyword, dateFrom, dateTo }) { + const conditions = [] + const params = {} + if (keyword) { + conditions.push('u.nickname LIKE :kw') + params.kw = '%' + keyword + '%' + } + if (dateFrom) { + conditions.push('r.created_at >= :date_from') + params.date_from = dateFrom + } + if (dateTo) { + conditions.push('r.created_at <= :date_to') + params.date_to = dateTo + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' + const rows = await query( + 'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where, + params + ) + return rows[0].total +} + +/** + * Get treatment stats for a user (count + total duration) + * @param {number} userId + * @returns {Promise<{treatment_count: number, total_duration: number}>} + */ +async function getStatsByUser(userId) { + const row = await one( + 'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id', + { user_id: userId } + ) + return row || { treatment_count: 0, total_duration: 0 } +} + +/** + * Get recent treatments for a user (limit 5) + * @param {number} userId + * @returns {Promise} + */ +async function recentByUser(userId) { + return query( + 'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5', + { user_id: userId } + ) +} + +module.exports = { + listByUser, + countByUser, + findBySession, + create, + updateDevice, + listAdmin, + countAdmin, + getStatsByUser, + recentByUser +} diff --git a/server/src/dao/user.dao.js b/server/src/dao/user.dao.js new file mode 100644 index 0000000..6bad3c7 --- /dev/null +++ b/server/src/dao/user.dao.js @@ -0,0 +1,178 @@ +const { query, one, limitClause } = require('../lib/db') + +/** + * Find user by WeChat openid + * @param {string} openid + * @returns {Promise} + */ +async function findByOpenid(openid) { + return one( + 'SELECT * FROM users WHERE openid = :openid', + { openid } + ) +} + +/** + * Create a new user from WeChat login + * @param {string} openid + * @returns {Promise} query result with insertId + */ +async function create(openid) { + return query( + 'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)', + { openid, nickname: '', avatar: '' } + ) +} + +/** + * Find user by user_id + * @param {number} userId + * @returns {Promise} + */ +async function findById(userId) { + return one( + 'SELECT * FROM users WHERE user_id = :user_id', + { user_id: userId } + ) +} + +/** + * Find active user by user_id (status=1) + * @param {number} userId + * @returns {Promise} + */ +async function findActiveById(userId) { + return one( + 'SELECT * FROM users WHERE user_id = :user_id AND status = 1', + { user_id: userId } + ) +} + +/** + * Update user profile fields (nickname, avatar, gender) + * @param {number} userId + * @param {Object} fields + * @param {string|null} [fields.nickname] + * @param {string|null} [fields.avatar] + * @param {number|null} [fields.gender] + * @returns {Promise} query result + */ +async function updateProfile(userId, fields) { + return query( + 'UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id', + { + user_id: userId, + nickname: fields.nickname || null, + avatar: fields.avatar || null, + gender: fields.gender === undefined ? null : fields.gender + } + ) +} + +/** + * Update user phone number + * @param {number} userId + * @param {string} phone + * @returns {Promise} query result + */ +async function updatePhone(userId, phone) { + return query( + 'UPDATE users SET phone = :phone WHERE user_id = :user_id', + { user_id: userId, phone } + ) +} + +/** + * Admin paginated user list with device/treatment/subscription subquery stats + * @param {Object} opts + * @param {string} [opts.keyword] - search nickname, phone, or exact user_id + * @param {number} opts.pageSize + * @param {number} opts.offset + * @returns {Promise<{records: Array, total: number}>} + */ +async function listAdmin({ keyword, pageSize, offset }) { + let where = '' + const params = {} + if (keyword) { + where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword' + params.kw = '%' + keyword + '%' + params.keyword = keyword + } + const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params) + const records = await query( + 'SELECT u.*,' + + ' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' + + ' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' + + ' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' + + ' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(pageSize, offset), + params + ) + return { records, total: total[0].total } +} + +/** + * Count admin users with optional keyword + * @param {Object} opts + * @param {string} [opts.keyword] + * @returns {Promise} + */ +async function countAdmin({ keyword }) { + let where = '' + const params = {} + if (keyword) { + where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword' + params.kw = '%' + keyword + '%' + params.keyword = keyword + } + const rows = await query('SELECT COUNT(*) AS total FROM users u' + where, params) + return rows[0].total +} + +/** + * Admin detail view: user + bound devices, recent treatments, subscription, stats + * @param {number} userId + * @returns {Promise} enriched user object or null + */ +async function findByIdAdmin(userId) { + const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: userId }) + if (!user) return null + + const devices = await query( + 'SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1', + { user_id: userId } + ) + const treatments = await query( + 'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5', + { user_id: userId } + ) + const subscription = await one( + 'SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', + { user_id: userId } + ) + const stats = await one( + 'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id', + { user_id: userId } + ) + + return Object.assign({}, user, { + devices, + recent_treatments: treatments, + subscription_status: subscription ? subscription.status : 0, + subscription_type: subscription ? subscription.plan : null, + subscription_expire: subscription ? subscription.expire_time : null, + treatment_count: stats ? stats.treatment_count : 0, + total_duration: stats ? stats.total_duration : 0 + }) +} + +module.exports = { + findByOpenid, + create, + findById, + findActiveById, + updateProfile, + updatePhone, + listAdmin, + countAdmin, + findByIdAdmin +} diff --git a/server/src/index.js b/server/src/index.js index b029305..2cd918a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,7 +1,10 @@ -const { handle } = require('./app') +const serverless = require('./lib/serverless') +const app = require('./app') + +const handler = serverless(app) exports.main_handler = async (event, context) => { - return handle(event, context) + return handler(event, context) } exports.main = exports.main_handler diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index e0ea7df..33495ff 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -2,7 +2,6 @@ const crypto = require('crypto') const jwt = require('jsonwebtoken') const bcrypt = require('bcryptjs') const config = require('../config') -const { one } = require('./db') function hashPasswordLegacy(password, salt) { return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex') @@ -34,28 +33,4 @@ function readBearer(headers) { return match ? match[1] : '' } -async function requireUser(ctx) { - const token = readBearer(ctx.headers) - if (!token) return null - try { - const payload = jwt.verify(token, config.jwt.secret) - if (payload.type !== 'user') return null - return await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id }) - } catch (err) { - return null - } -} - -async function requireAdmin(ctx) { - const token = readBearer(ctx.headers) - if (!token) return null - try { - const payload = jwt.verify(token, config.jwt.adminSecret) - if (payload.type !== 'admin') return null - return await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id }) - } catch (err) { - return null - } -} - -module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer, requireUser, requireAdmin } +module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer } diff --git a/server/src/lib/log.js b/server/src/lib/log.js index cc3d23f..e16af87 100644 --- a/server/src/lib/log.js +++ b/server/src/lib/log.js @@ -1,16 +1,2 @@ -const { query } = require('./db') - -async function writeLog(options) { - await query( - 'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)', - { - user_id: options.user_id || null, - admin_id: options.admin_id || null, - action: options.action, - detail: options.detail || '', - ip: options.ip || '' - } - ) -} - -module.exports = { writeLog } +const logDao = require('../dao/log.dao') +module.exports = { writeLog: logDao.write } diff --git a/server/src/lib/request.js b/server/src/lib/request.js deleted file mode 100644 index 3ce3ed2..0000000 --- a/server/src/lib/request.js +++ /dev/null @@ -1,45 +0,0 @@ -function normalizeHeaders(headers) { - const result = {} - Object.keys(headers || {}).forEach(key => { - result[key] = headers[key] - result[key.toLowerCase()] = headers[key] - }) - return result -} - -function parseBody(event) { - if (!event.body) return {} - if (typeof event.body === 'object') return event.body - const raw = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body - if (!raw) return {} - try { return JSON.parse(raw) } catch (err) { return {} } -} - -function parseQuery(event) { - if (event.queryStringParameters) return event.queryStringParameters || {} - if (event.query) return event.query || {} - return {} -} - -function getPath(event) { - return event.path || event.Path || event.requestContext && event.requestContext.path || '/' -} - -function getMethod(event) { - return String(event.httpMethod || event.method || event.requestContext && event.requestContext.httpMethod || 'GET').toUpperCase() -} - -function createContext(event) { - return { - event, - method: getMethod(event), - path: getPath(event), - headers: normalizeHeaders(event.headers), - query: parseQuery(event), - body: parseBody(event), - params: {}, - ip: event.requestContext && event.requestContext.sourceIp || (event.headers && (event.headers['x-forwarded-for'] || event.headers['X-Forwarded-For'] || '').split(',')[0].trim()) || '' - } -} - -module.exports = { createContext } diff --git a/server/src/lib/response.js b/server/src/lib/response.js index 18284bd..cd89733 100644 --- a/server/src/lib/response.js +++ b/server/src/lib/response.js @@ -6,18 +6,4 @@ function fail(code, message, data) { return { code, message, data: data || {} } } -function http(statusCode, body, headers) { - return { - isBase64Encoded: false, - statusCode, - headers: Object.assign({ - 'Content-Type': 'application/json; charset=utf-8', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS' - }, headers || {}), - body: JSON.stringify(body) - } -} - -module.exports = { ok, fail, http } +module.exports = { ok, fail } diff --git a/server/src/lib/router.js b/server/src/lib/router.js deleted file mode 100644 index 9d6e844..0000000 --- a/server/src/lib/router.js +++ /dev/null @@ -1,33 +0,0 @@ -class Router { - constructor() { - this.routes = [] - } - - add(method, pattern, handler) { - const keys = [] - const regex = new RegExp('^' + pattern.replace(/\/:(\w+)/g, function (_, key) { - keys.push(key) - return '/([^/]+)' - }) + '$') - this.routes.push({ method, regex, keys, handler }) - } - - get(pattern, handler) { this.add('GET', pattern, handler) } - post(pattern, handler) { this.add('POST', pattern, handler) } - put(pattern, handler) { this.add('PUT', pattern, handler) } - delete(pattern, handler) { this.add('DELETE', pattern, handler) } - - match(method, path) { - for (const route of this.routes) { - if (route.method !== method) continue - const match = path.match(route.regex) - if (!match) continue - const params = {} - route.keys.forEach((key, index) => { params[key] = decodeURIComponent(match[index + 1]) }) - return { handler: route.handler, params } - } - return null - } -} - -module.exports = Router diff --git a/server/src/lib/serverless.js b/server/src/lib/serverless.js new file mode 100644 index 0000000..4d870c6 --- /dev/null +++ b/server/src/lib/serverless.js @@ -0,0 +1,63 @@ +const http = require('http') + +module.exports = function serverless(app) { + return async function handler(event) { + const method = String(event.httpMethod || event.method || 'GET').toUpperCase() + const path = event.path || '/' + const headers = event.headers || {} + const qs = event.queryStringParameters || {} + const qsStr = Object.keys(qs).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(qs[k])).join('&') + const url = path + (qsStr ? '?' + qsStr : '') + + let rawBody = event.body || '' + if (event.isBase64Encoded && rawBody) rawBody = Buffer.from(rawBody, 'base64').toString('utf8') + + return new Promise((resolve) => { + const req = new http.IncomingMessage() + req.method = method + req.url = url + req.headers = {} + Object.keys(headers).forEach(k => { req.headers[k.toLowerCase()] = headers[k] }) + if (event.requestContext && event.requestContext.sourceIp) { + req.headers['x-forwarded-for'] = req.headers['x-forwarded-for'] || event.requestContext.sourceIp + } + + const res = new http.ServerResponse(req) + let body = '' + const resHeaders = {} + + res.writeHead = function (statusCode, reasonOrHeaders, maybeHeaders) { + res.statusCode = statusCode + const h = maybeHeaders || (typeof reasonOrHeaders === 'object' ? reasonOrHeaders : {}) + Object.assign(resHeaders, h) + } + + const originalSetHeader = res.setHeader.bind(res) + res.setHeader = function (name, value) { + resHeaders[name.toLowerCase()] = value + originalSetHeader(name, value) + } + + res.end = function (chunk) { + if (chunk) body += chunk + resolve({ + isBase64Encoded: false, + statusCode: res.statusCode || 200, + headers: Object.assign({ + 'content-type': 'application/json; charset=utf-8' + }, resHeaders), + body + }) + } + + res.write = function (chunk) { body += chunk } + + if (rawBody) { + req.push(rawBody) + } + req.push(null) + + app(req, res) + }) + } +} diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..8015eeb --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,46 @@ +const jwt = require('jsonwebtoken') +const config = require('../config') +const { one } = require('../lib/db') + +function readBearer(headers) { + const auth = headers.authorization || '' + const match = auth.match(/^Bearer\s+(.+)$/i) + return match ? match[1] : '' +} + +function authMiddleware(req, res, next) { + req.ip = req.headers['x-forwarded-for'] + ? req.headers['x-forwarded-for'].split(',')[0].trim() + : req.ip || '' + next() +} + +async function requireUser(req, res, next) { + const token = readBearer(req.headers) + if (!token) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} }) + try { + const payload = jwt.verify(token, config.jwt.secret) + if (payload.type !== 'user') return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} }) + req.user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id }) + if (!req.user) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} }) + next() + } catch (err) { + return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} }) + } +} + +async function requireAdmin(req, res, next) { + const token = readBearer(req.headers) + if (!token) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} }) + try { + const payload = jwt.verify(token, config.jwt.adminSecret) + if (payload.type !== 'admin') return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} }) + req.admin = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id }) + if (!req.admin) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} }) + next() + } catch (err) { + return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} }) + } +} + +module.exports = { authMiddleware, requireUser, requireAdmin, readBearer } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index fef9cf1..aab9765 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,364 +1,235 @@ -const { one, query, limitClause } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin, requireAdmin } = require('../lib/auth') -const { writeLog } = require('../lib/log') +const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin } = require('../lib/auth') +const { requireAdmin } = require('../middleware/auth') +const adminDao = require('../dao/admin.dao') +const deviceDao = require('../dao/device.dao') +const bindingDao = require('../dao/binding.dao') +const commandDao = require('../dao/command.dao') +const userDao = require('../dao/user.dao') +const subscriptionDao = require('../dao/subscription.dao') +const treatmentDao = require('../dao/treatment.dao') +const logDao = require('../dao/log.dao') +const settingsDao = require('../dao/settings.dao') -function pageParams(ctx) { - const page = Math.max(1, parseInt(ctx.query.page, 10) || 1) - const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100) +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) + +function pageParams(query) { + const page = Math.max(1, parseInt(query.page, 10) || 1) + const pageSize = Math.min(Math.max(1, parseInt(query.page_size, 10) || 20), 100) return { page, pageSize, offset: (page - 1) * pageSize } } -function register(router) { - router.post('/api/v1/admin/login', async ctx => { - const username = ctx.body.username || '' - const password = ctx.body.password || '' - const admin = await one('SELECT * FROM admin_accounts WHERE username = :username AND status = 1', { username }) - if (!admin) return fail(1001, '用户名或密码错误') - let matched = verifyPassword(password, admin.password_hash) - if (!matched) { - // Try legacy SHA-256 verification for migration - if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) { - // Auto-migrate to bcrypt - const newHash = hashPassword(password) - await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id }) - matched = true - } - } - if (!matched) return fail(1001, '用户名或密码错误') - const token = signAdmin(admin) - await writeLog({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: ctx.ip }) - return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role }) - }) +// --- Auth --- - router.post('/api/v1/admin/password', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const oldPassword = ctx.body.old_password || '' - const newPassword = ctx.body.new_password || '' - if (newPassword.length < 6) return fail(2001, 'password too short') - const current = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: admin.admin_id }) - if (!current) return fail(1002, '未授权,请重新登录') - let matched = verifyPassword(oldPassword, current.password_hash) - if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) { +router.post('/login', wrap(async (req, res) => { + const username = req.body.username || '' + const password = req.body.password || '' + const admin = await adminDao.findByUsername(username) + if (!admin) return res.json(fail(1001, '用户名或密码错误')) + let matched = verifyPassword(password, admin.password_hash) + if (!matched) { + if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) { + const newHash = hashPassword(password) + await adminDao.updatePassword(admin.admin_id, newHash) matched = true } - if (!matched) return fail(1001, '原密码错误') - const newHash = hashPassword(newPassword) - await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id }) - await writeLog({ admin_id: admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: ctx.ip }) - return ok({ message: 'success' }) - }) + } + if (!matched) return res.json(fail(1001, '用户名或密码错误')) + const token = signAdmin(admin) + await logDao.write({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: req.ip }) + res.json(ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })) +})) - router.get('/api/v1/admin/dashboard', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const rows = await Promise.all([ - query('SELECT COUNT(*) AS total FROM devices', {}), - query('SELECT COUNT(*) AS total FROM users', {}), - query('SELECT COUNT(*) AS total FROM treatment_records', {}), - query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {}) - ]) - const subStats = await query('SELECT ' + - 'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' + - 'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' + - 'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' + - 'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' + - 'FROM subscriptions', {}) - return ok({ - device_count: rows[0][0].total, - user_count: rows[1][0].total, - treatment_count: rows[2][0].total, - subscription_count: rows[3][0].total, - sub_stats: subStats[0] || {} - }) - }) +router.post('/password', requireAdmin, wrap(async (req, res) => { + const oldPassword = req.body.old_password || '' + const newPassword = req.body.new_password || '' + if (newPassword.length < 6) return res.json(fail(2001, 'password too short')) + const current = await adminDao.findById(req.admin.admin_id) + if (!current) return res.json(fail(1002, '未授权,请重新登录')) + let matched = verifyPassword(oldPassword, current.password_hash) + if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) { + matched = true + } + if (!matched) return res.json(fail(1001, '原密码错误')) + const newHash = hashPassword(newPassword) + await adminDao.updatePassword(req.admin.admin_id, newHash) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: req.ip }) + res.json(ok({ message: 'success' })) +})) - router.get('/api/v1/admin/devices', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const keyword = (ctx.query.keyword || '').trim() - let where = '' - const params = {} - if (keyword) { - where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw' - params.kw = '%' + keyword + '%' - } - const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params) - const records = await query('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' + where + ' ORDER BY d.created_at DESC' + limitClause(p.pageSize, p.offset), params) - return ok({ records, total: total[0].total }) - }) +// --- Dashboard --- - router.post('/api/v1/admin/devices', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const deviceId = String(ctx.body.device_id || '').trim() - if (!deviceId) return fail(2001, 'device_id required') - await query( - 'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1', - { - device_id: deviceId, - product_id: ctx.body.product_id || 'HOX_LIGHT_MASK', - device_secret: ctx.body.device_secret || '', - device_name: ctx.body.device_name || '光子美容仪', - firmware_version: ctx.body.firmware_version || '1.0.0' - } - ) - await writeLog({ admin_id: admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: ctx.ip }) - return ok({ device_id: deviceId }) - }) +router.get('/dashboard', requireAdmin, wrap(async (req, res) => { + const counts = await adminDao.getDashboardCounts() + const subStats = await adminDao.getSubscriptionStats() + res.json(ok({ + device_count: counts.device_count, + user_count: counts.user_count, + treatment_count: counts.treatment_count, + subscription_count: counts.subscription_count, + sub_stats: subStats + })) +})) - router.post('/api/v1/admin/devices/batch', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const deviceIds = ctx.body.device_ids - if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) return fail(2001, 'device_ids must be an array with 1-500 items') - let successCount = 0 - const failedIds = [] - for (const id of deviceIds) { - const deviceId = String(id || '').trim() - if (!deviceId) { failedIds.push(id); continue } - try { - await query( - 'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1', - { - device_id: deviceId, - product_id: 'HOX_LIGHT_MASK', - device_secret: '', - device_name: '光子美容仪', - firmware_version: '1.0.0' - } - ) - successCount++ - } catch (err) { - failedIds.push(deviceId) - } - } - await writeLog({ admin_id: admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + successCount + '/' + deviceIds.length, ip: ctx.ip }) - return ok({ created: successCount, failed: failedIds }) - }) +// --- Devices --- - router.get('/api/v1/admin/devices/:device_id', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const device = await one('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id', { device_id: ctx.params.device_id }) - if (!device) return fail(1005, 'DEVICE_NOT_FOUND') - const bindingHistory = await query('SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC', { device_id: ctx.params.device_id }) - const recentTreatments = await query('SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5', { device_id: ctx.params.device_id }) - return ok(Object.assign({}, device, { binding_history: bindingHistory, recent_treatments: recentTreatments })) +router.get('/devices', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await deviceDao.list({ + keyword: req.query.keyword, + pageSize, + offset }) + res.json(ok({ records, total })) +})) - router.post('/api/v1/admin/devices/:device_id/unbind', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - await query('UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1', { device_id: ctx.params.device_id }) - await writeLog({ admin_id: admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + ctx.params.device_id, ip: ctx.ip }) - return ok({ message: 'success' }) +router.post('/devices', requireAdmin, wrap(async (req, res) => { + const deviceId = String(req.body.device_id || '').trim() + if (!deviceId) return res.json(fail(2001, 'device_id required')) + await deviceDao.create({ + device_id: deviceId, + product_id: req.body.product_id || 'HOX_LIGHT_MASK', + device_secret: req.body.device_secret || '', + device_name: req.body.device_name || '光子美容仪', + firmware_version: req.body.firmware_version || '1.0.0' }) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: req.ip }) + res.json(ok({ device_id: deviceId })) +})) - router.post('/api/v1/admin/devices/:device_id/command', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const opcode = parseInt(ctx.body.opcode, 10) - if (!opcode) return fail(2001, 'opcode required') - await query( - 'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)', - { device_id: ctx.params.device_id, admin_id: admin.admin_id, opcode, payload_json: JSON.stringify(ctx.body) } - ) - await writeLog({ admin_id: admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + ctx.params.device_id, ip: ctx.ip }) - return ok({ message: 'queued', command: ctx.body }) +router.post('/devices/batch', requireAdmin, wrap(async (req, res) => { + const deviceIds = req.body.device_ids + if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) { + return res.json(fail(2001, 'device_ids must be an array with 1-500 items')) + } + const { created, failed } = await deviceDao.createBatch(deviceIds) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + created + '/' + deviceIds.length, ip: req.ip }) + res.json(ok({ created, failed })) +})) + +router.get('/devices/:device_id', requireAdmin, wrap(async (req, res) => { + const result = await deviceDao.findByIdWithHistory(req.params.device_id) + if (!result) return res.json(fail(1005, 'DEVICE_NOT_FOUND')) + res.json(ok(result)) +})) + +router.post('/devices/:device_id/unbind', requireAdmin, wrap(async (req, res) => { + await deviceDao.unbind(req.params.device_id) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + req.params.device_id, ip: req.ip }) + res.json(ok({ message: 'success' })) +})) + +router.post('/devices/:device_id/command', requireAdmin, wrap(async (req, res) => { + const opcode = parseInt(req.body.opcode, 10) + if (!opcode) return res.json(fail(2001, 'opcode required')) + await commandDao.create(req.params.device_id, req.admin.admin_id, opcode, req.body) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + req.params.device_id, ip: req.ip }) + res.json(ok({ message: 'queued', command: req.body })) +})) + +router.get('/devices/:device_id/commands', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await commandDao.listByDevice(req.params.device_id, { pageSize, offset }) + res.json(ok({ records, total })) +})) + +// --- Users --- + +router.get('/users', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await userDao.listAdmin({ + keyword: req.query.keyword, + pageSize, + offset }) + res.json(ok({ records, total })) +})) - router.get('/api/v1/admin/devices/:device_id/commands', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const total = await query('SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id', { device_id: ctx.params.device_id }) - const records = await query( - 'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), - { device_id: ctx.params.device_id } - ) - return ok({ records, total: total[0].total }) +router.get('/users/:user_id', requireAdmin, wrap(async (req, res) => { + const result = await userDao.findByIdAdmin(req.params.user_id) + if (!result) return res.json(fail(1004, 'USER_NOT_FOUND')) + res.json(ok(result)) +})) + +// --- Subscriptions --- + +router.get('/subscriptions', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await subscriptionDao.list({ + tab: req.query.tab, + pageSize, + offset }) + const stats = await subscriptionDao.getStats() + res.json(ok({ records, total, stats })) +})) - router.get('/api/v1/admin/users', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const keyword = (ctx.query.keyword || '').trim() - let where = '' - const params = {} - if (keyword) { - where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword' - params.kw = '%' + keyword + '%' - params.keyword = keyword - } - const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params) - const records = await query( - 'SELECT u.*,' + - ' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' + - ' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' + - ' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' + - ' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(p.pageSize, p.offset), - params - ) - return ok({ records, total: total[0].total }) +router.post('/subscriptions', requireAdmin, wrap(async (req, res) => { + const userId = req.body.user_id + if (!userId) return res.json(fail(2001, 'user_id required')) + const targetUser = await userDao.findById(userId) + if (!targetUser) return res.json(fail(1004, 'user_not_found')) + await subscriptionDao.adminCreate( + userId, + req.body.plan || 'monthly', + req.body.amount || 0, + req.body.order_id || 'ADMIN' + Date.now(), + req.body.days || 30 + ) + res.json(ok({ message: 'success' })) +})) + +router.post('/subscriptions/cancel', requireAdmin, wrap(async (req, res) => { + const subscriptionId = req.body.subscription_id + if (!subscriptionId) return res.json(fail(2001, 'subscription_id required')) + const result = await subscriptionDao.cancel(subscriptionId) + if (result.affectedRows === 0) return res.json(fail(2001, '未找到有效订阅')) + await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: req.ip }) + res.json(ok({ message: 'success' })) +})) + +// --- Treatment Records --- + +router.get('/records', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await treatmentDao.listAdmin({ + keyword: req.query.keyword, + dateFrom: req.query.date_from, + dateTo: req.query.date_to, + pageSize, + offset }) + res.json(ok({ records, total })) +})) - router.get('/api/v1/admin/users/:user_id', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: ctx.params.user_id }) - if (!user) return fail(1004, 'USER_NOT_FOUND') - const devices = await query('SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1', { user_id: user.user_id }) - const treatments = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5', { user_id: user.user_id }) - const subscription = await one('SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id }) - const stats = await one('SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id }) - return ok(Object.assign({}, user, { - devices, - recent_treatments: treatments, - subscription_status: subscription ? subscription.status : 0, - subscription_type: subscription ? subscription.plan : null, - subscription_expire: subscription ? subscription.expire_time : null, - treatment_count: stats ? stats.treatment_count : 0, - total_duration: stats ? stats.total_duration : 0 - })) +// --- Logs --- + +router.get('/logs', requireAdmin, wrap(async (req, res) => { + const { page, pageSize, offset } = pageParams(req.query) + const { records, total } = await logDao.list({ + type: req.query.type, + deviceId: req.query.device_id, + pageSize, + offset }) + res.json(ok({ records, total })) +})) - router.get('/api/v1/admin/subscriptions', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const tab = (ctx.query.tab || '').trim() - let where = '' - const params = {} - if (tab && tab !== 'all') { - if (tab === 'expired') { - where = ' WHERE s.status = 2' - } else { - where = ' WHERE s.plan = :plan' - params.plan = tab - } - } - const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params) - const records = await query( - 'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' + where + ' ORDER BY s.created_at DESC' + limitClause(p.pageSize, p.offset), - params - ) - const statsRow = await query('SELECT ' + - 'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' + - 'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' + - 'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' + - 'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' + - 'FROM subscriptions', {}) - return ok({ records, total: total[0].total, stats: statsRow[0] || {} }) - }) +// --- Settings --- - router.post('/api/v1/admin/subscriptions', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const targetUser = await one('SELECT user_id FROM users WHERE user_id = :user_id', { user_id: ctx.body.user_id }) - if (!targetUser) return fail(1004, 'user_not_found') - await query('UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', { user_id: ctx.body.user_id }) - await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))', { - user_id: ctx.body.user_id, - plan: ctx.body.plan || 'monthly', - amount: ctx.body.amount || 0, - order_id: ctx.body.order_id || 'ADMIN' + Date.now(), - days: ctx.body.days || 30 - }) - return ok({ message: 'success' }) - }) +router.get('/settings', requireAdmin, wrap(async (req, res) => { + const settings = await settingsDao.getAll() + res.json(ok(settings)) +})) - router.post('/api/v1/admin/subscriptions/cancel', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const subscriptionId = ctx.body.subscription_id - if (!subscriptionId) return fail(2001, 'subscription_id required') - const result = await query('UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1', { subscription_id: subscriptionId }) - if (result.affectedRows === 0) return fail(2001, '未找到有效订阅') - await writeLog({ admin_id: admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: ctx.ip }) - return ok({ message: 'success' }) - }) +router.post('/settings', requireAdmin, wrap(async (req, res) => { + const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode'] + for (const key of Object.keys(req.body || {})) { + if (!ALLOWED_KEYS.includes(key)) continue + await settingsDao.update(key, req.body[key]) + } + res.json(ok({ message: 'success' })) +})) - router.get('/api/v1/admin/records', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const keyword = (ctx.query.keyword || '').trim() - const dateFrom = (ctx.query.date_from || '').trim() - const dateTo = (ctx.query.date_to || '').trim() - const conditions = [] - const params = {} - if (keyword) { - conditions.push('u.nickname LIKE :kw') - params.kw = '%' + keyword + '%' - } - if (dateFrom) { - conditions.push('r.created_at >= :date_from') - params.date_from = dateFrom - } - if (dateTo) { - conditions.push('r.created_at <= :date_to') - params.date_to = dateTo - } - const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' - const total = await query('SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where, params) - const records = await query( - 'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where + ' ORDER BY r.created_at DESC' + limitClause(p.pageSize, p.offset), - params - ) - return ok({ records, total: total[0].total }) - }) - - router.get('/api/v1/admin/logs', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const p = pageParams(ctx) - const type = (ctx.query.type || '').trim() - const deviceId = (ctx.query.device_id || '').trim() - const conditions = [] - const params = {} - if (type) { - conditions.push('action LIKE :type') - params.type = '%' + type + '%' - } - if (deviceId) { - conditions.push('detail LIKE :device_id') - params.device_id = '%' + deviceId + '%' - } - const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' - const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params) - const records = await query('SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), params) - return ok({ records, total: total[0].total }) - }) - - router.get('/api/v1/admin/settings', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const rows = await query('SELECT setting_key, setting_value FROM system_settings', {}) - const settings = {} - rows.forEach(row => { - if (typeof row.setting_value === 'string') { - try { settings[row.setting_key] = JSON.parse(row.setting_value) } catch (_) { settings[row.setting_key] = row.setting_value } - } else { - settings[row.setting_key] = row.setting_value - } - }) - return ok(settings) - }) - - router.post('/api/v1/admin/settings', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode'] - for (const key of Object.keys(ctx.body || {})) { - if (!ALLOWED_KEYS.includes(key)) continue - await query('REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)', { setting_key: key, setting_value: JSON.stringify(ctx.body[key]) }) - } - return ok({ message: 'success' }) - }) -} - -module.exports = register +module.exports = router diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c1b4965..48bca70 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,78 +1,75 @@ const jwt = require('jsonwebtoken') -const { one, query } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') const { signUser, readBearer } = require('../lib/auth') const { code2Session } = require('../lib/wechat') -const { writeLog } = require('../lib/log') const config = require('../config') +const userDao = require('../dao/user.dao') +const logDao = require('../dao/log.dao') -function register(router) { - router.post('/api/v1/auth/login', async ctx => { - const session = await code2Session(ctx.body.code || '') - let user = await one('SELECT * FROM users WHERE openid = :openid', { openid: session.openid }) - if (!user) { - const result = await query( - 'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)', - { openid: session.openid, nickname: '', avatar: '' } - ) - user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: result.insertId }) - await writeLog({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: ctx.ip }) - } - const token = signUser(user) - await writeLog({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: ctx.ip }) - return ok({ - token, +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) + +router.post('/auth/login', wrap(async (req, res) => { + const session = await code2Session(req.body.code || '') + let user = await userDao.findByOpenid(session.openid) + if (!user) { + const result = await userDao.create(session.openid) + user = await userDao.findById(result.insertId) + await logDao.write({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: req.ip }) + } + const token = signUser(user) + await logDao.write({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: req.ip }) + res.json(ok({ + token, + user_id: String(user.user_id), + user_info: { user_id: String(user.user_id), - user_info: { - user_id: String(user.user_id), - nickname: user.nickname || '用户' + String(user.user_id), - avatar: user.avatar || '', - phone: user.phone || '', - gender: user.gender || 0 - }, - expires_in: 604800 - }) - }) + nickname: user.nickname || '用户' + String(user.user_id), + avatar: user.avatar || '', + phone: user.phone || '', + gender: user.gender || 0 + }, + expires_in: 604800 + })) +})) - router.post('/api/v1/auth/refresh', async ctx => { - const token = readBearer(ctx.headers) - if (!token) return fail(1001, 'token_expired') +router.post('/auth/refresh', wrap(async (req, res) => { + const token = readBearer(req.headers) + if (!token) return res.json(fail(1001, 'token_expired')) - let payload - try { - payload = jwt.verify(token, config.jwt.secret) - } catch (err) { - if (err.name === 'TokenExpiredError') { - try { - payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true }) - } catch (_) { - return fail(1001, 'token_expired') - } - const now = Math.floor(Date.now() / 1000) - const gracePeriod = 3 * 24 * 60 * 60 - if (now - payload.exp > gracePeriod) { - return fail(1001, 'token_expired') - } - } else { - return fail(1001, 'token_expired') + let payload + try { + payload = jwt.verify(token, config.jwt.secret) + } catch (err) { + if (err.name === 'TokenExpiredError') { + try { + payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true }) + } catch (_) { + return res.json(fail(1001, 'token_expired')) } + const now = Math.floor(Date.now() / 1000) + const gracePeriod = 3 * 24 * 60 * 60 + if (now - payload.exp > gracePeriod) { + return res.json(fail(1001, 'token_expired')) + } + } else { + return res.json(fail(1001, 'token_expired')) } + } - if (payload.type !== 'user') return fail(1001, 'token_expired') + if (payload.type !== 'user') return res.json(fail(1001, 'token_expired')) - // Check if token is within 7 days of expiry (for non-expired tokens) - const now = Math.floor(Date.now() / 1000) - const sevenDays = 7 * 24 * 60 * 60 - if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) { - return ok({ token, expires_in: payload.exp - now }) - } + const now = Math.floor(Date.now() / 1000) + const sevenDays = 7 * 24 * 60 * 60 + if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) { + return res.json(ok({ token, expires_in: payload.exp - now })) + } - const user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id }) - if (!user) return fail(1001, 'token_expired') + const user = await userDao.findById(payload.user_id) + if (!user) return res.json(fail(1001, 'token_expired')) - const newToken = signUser(user) - return ok({ token: newToken, expires_in: 604800 }) - }) -} + const newToken = signUser(user) + res.json(ok({ token: newToken, expires_in: 604800 })) +})) -module.exports = register +module.exports = router diff --git a/server/src/routes/device.js b/server/src/routes/device.js index e98e344..789f386 100644 --- a/server/src/routes/device.js +++ b/server/src/routes/device.js @@ -1,176 +1,135 @@ -const { one, query, transaction } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { requireUser, randomHex } = require('../lib/auth') -const { writeLog } = require('../lib/log') +const { requireUser } = require('../middleware/auth') +const { randomHex } = require('../lib/auth') +const bindingDao = require('../dao/binding.dao') +const deviceDao = require('../dao/device.dao') +const commandDao = require('../dao/command.dao') +const subscriptionDao = require('../dao/subscription.dao') +const deviceEventDao = require('../dao/device-event.dao') +const logDao = require('../dao/log.dao') +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) -async function ensureTrial(conn, userId) { - const [subs] = await conn.execute('SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', [userId]) - if (subs.length > 0) return - await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, ?, 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', [userId, 'trial']) -} +router.post('/device/bind', requireUser, wrap(async (req, res) => { + const deviceId = String(req.body.device_id || '').trim() + if (!deviceId) return res.json(fail(2001, 'device_id required')) -function register(router) { - router.post('/api/v1/device/bind', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = String(ctx.body.device_id || '').trim() - if (!deviceId) return fail(2001, 'device_id required') + const active = await bindingDao.findActiveByUser(req.user.user_id) + if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id })) - const result = await transaction(async conn => { - const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id]) - if (active.length > 0) return { duplicated: true, device_id: active[0].device_id } + const device = await bindingDao.findDeviceExists(deviceId) + if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND')) - const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId]) - if (devices.length === 0) return { invalid: true } + const bindToken = randomHex(8) + await bindingDao.createPending(req.user.user_id, deviceId, bindToken) + await logDao.write({ user_id: req.user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: req.ip }) - const bindToken = randomHex(8) - await conn.execute( - 'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())', - [user.user_id, deviceId, bindToken] - ) - return { device_id: deviceId, bind_token: bindToken } - }) + const sub = await subscriptionDao.findActive(req.user.user_id) + res.json(ok({ + device_id: deviceId, + bind_token: bindToken, + subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } + })) +})) - if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND') - if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id }) - await writeLog({ user_id: user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: ctx.ip }) - const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id }) - return ok(Object.assign(result, { subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } })) +router.post('/device/bind/confirm', requireUser, wrap(async (req, res) => { + const deviceId = String(req.body.device_id || '').trim() + const bindToken = String(req.body.bind_token || '').trim() + if (!deviceId || !bindToken) return res.json(fail(2001, 'device_id and bind_token required')) + + const confirmed = await bindingDao.confirmBind(req.user.user_id, deviceId, bindToken) + if (!confirmed) return res.json(fail(2001, 'bind_token invalid or expired')) + + await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: req.ip }) + const sub = await subscriptionDao.findActive(req.user.user_id) + res.json(ok({ + message: 'success', + subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } + })) +})) + +router.post('/device/mock-bind', requireUser, wrap(async (req, res) => { + const config = require('../config') + if (config.nodeEnv === 'production') return res.json(fail(2001, 'not available in production')) + const deviceId = String(req.body.device_id || '').trim() + if (!deviceId) return res.json(fail(2001, 'device_id required')) + + const active = await bindingDao.findActiveByUser(req.user.user_id) + if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id })) + + const device = await bindingDao.findDeviceExists(deviceId) + if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND')) + + await bindingDao.mockBind(req.user.user_id, deviceId) + await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: req.ip }) + res.json(ok({ message: 'success', device_id: deviceId })) +})) + +router.post('/device/unbind', requireUser, wrap(async (req, res) => { + const deviceId = req.body.device_id || null + await bindingDao.unbindByUser(req.user.user_id, deviceId) + await logDao.write({ user_id: req.user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: req.ip }) + res.json(ok({ message: 'success' })) +})) + +router.get('/device/list', requireUser, wrap(async (req, res) => { + const devices = await deviceDao.listByUser(req.user.user_id) + res.json(ok({ devices, total: devices.length })) +})) + +router.get('/device/command/pending', requireUser, wrap(async (req, res) => { + const deviceId = String(req.query.device_id || '').trim() + if (!deviceId) return res.json(fail(2001, 'device_id required')) + + const active = await bindingDao.findActiveByUser(req.user.user_id) + if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'DEVICE_NOT_BOUND')) + + const commands = await commandDao.getPending(deviceId) + if (commands.length > 0) { + await commandDao.markPulled(commands.map(c => c.command_id)) + } + res.json(ok({ + commands: commands.map(c => ({ + seq: c.command_id, + opcode: c.opcode, + payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {} + })) + })) +})) + +router.post('/device/command/result', requireUser, wrap(async (req, res) => { + const commandId = parseInt(req.body.command_id || req.body.seq, 10) + const success = req.body.success !== false + if (!commandId) return res.json(fail(2001, 'command_id required')) + // commandDao.finish verifies device ownership via user binding + await commandDao.finish(commandId, success, JSON.stringify(req.body), req.user.user_id) + res.json(ok({ message: 'success' })) +})) + +router.post('/device/event', requireUser, wrap(async (req, res) => { + const deviceId = String(req.body.device_id || '').trim() + if (!deviceId) return res.json(fail(2001, 'device_id required')) + + const active = await bindingDao.findActiveByUser(req.user.user_id) + if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'device_not_bound')) + + await deviceEventDao.create({ + device_id: deviceId, + user_id: req.user.user_id, + event_type: req.body.event_type || 'device_error', + error_code: req.body.error_code || null, + temperature: req.body.temperature || null, + payload: req.body }) + await logDao.write({ user_id: req.user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: req.ip }) + res.json(ok({ message: 'ok' })) +})) - router.post('/api/v1/device/bind/confirm', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = String(ctx.body.device_id || '').trim() - const bindToken = String(ctx.body.bind_token || '').trim() - if (!deviceId || !bindToken) return fail(2001, 'device_id and bind_token required') +router.get('/device/:device_id', requireUser, wrap(async (req, res) => { + const device = await deviceDao.findBoundDevice(req.user.user_id, req.params.device_id) + if (!device) return res.json(fail(1006, 'DEVICE_NOT_BOUND')) + res.json(ok(device)) +})) - const updated = await transaction(async conn => { - const [rows] = await conn.execute( - 'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1', - [user.user_id, deviceId, bindToken] - ) - if (rows.length === 0) return false - await conn.execute('UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?', [rows[0].binding_id]) - await ensureTrial(conn, user.user_id) - return true - }) - if (!updated) return fail(2001, 'bind_token invalid or expired') - await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: ctx.ip }) - const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id }) - return ok({ message: 'success', subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } }) - }) - - router.post('/api/v1/device/mock-bind', async ctx => { - const config = require('../config') - if (config.nodeEnv === 'production') return fail(2001, 'not available in production') - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = String(ctx.body.device_id || '').trim() - if (!deviceId) return fail(2001, 'device_id required') - - const result = await transaction(async conn => { - const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id]) - if (active.length > 0) return { duplicated: true, device_id: active[0].device_id } - const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId]) - if (devices.length === 0) return { invalid: true } - await conn.execute('UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3', [user.user_id]) - await conn.execute('INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, NOW(), 1, NOW())', [user.user_id, deviceId, 'mock']) - await ensureTrial(conn, user.user_id) - return { success: true } - }) - - if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND') - if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id }) - await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: ctx.ip }) - return ok({ message: 'success', device_id: deviceId }) - }) - - router.post('/api/v1/device/unbind', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = ctx.body.device_id || null - await query( - 'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)', - { user_id: user.user_id, device_id: deviceId } - ) - await writeLog({ user_id: user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: ctx.ip }) - return ok({ message: 'success' }) - }) - - router.get('/api/v1/device/list', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const devices = await query( - 'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC', - { user_id: user.user_id } - ) - return ok({ devices, total: devices.length }) - }) - - router.get('/api/v1/device/command/pending', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = String(ctx.query.device_id || '').trim() - if (!deviceId) return fail(2001, 'device_id required') - const bound = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId }) - if (!bound) return fail(1006, 'DEVICE_NOT_BOUND') - const commands = await query('SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10', { device_id: deviceId }) - if (commands.length > 0) { - await query('UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commands.map(c => Number(c.command_id)).join(',') + ')', {}) - } - return ok({ commands: commands.map(c => ({ seq: c.command_id, opcode: c.opcode, payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {} })) }) - }) - - router.post('/api/v1/device/command/result', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const commandId = parseInt(ctx.body.command_id || ctx.body.seq, 10) - const success = ctx.body.success !== false - if (!commandId) return fail(2001, 'command_id required') - const cmd = await one('SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id', { user_id: user.user_id, command_id: commandId }) - if (!cmd) return fail(1006, 'device_not_bound') - await query('UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id', { - command_id: commandId, - status: success ? 3 : 4, - result_json: JSON.stringify(ctx.body) - }) - return ok({ message: 'success' }) - }) - - router.get('/api/v1/device/:device_id', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const device = await one( - 'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id', - { user_id: user.user_id, device_id: ctx.params.device_id } - ) - if (!device) return fail(1006, 'DEVICE_NOT_BOUND') - return ok(device) - }) - - router.post('/api/v1/device/event', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const deviceId = String(ctx.body.device_id || '').trim() - if (!deviceId) return fail(2001, 'device_id required') - const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId }) - if (!binding) return fail(1006, 'device_not_bound') - await query( - 'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)', - { - device_id: deviceId, - user_id: user.user_id, - event_type: ctx.body.event_type || 'device_error', - error_code: ctx.body.error_code || null, - temperature: ctx.body.temperature || null, - payload_json: JSON.stringify(ctx.body) - } - ) - await writeLog({ user_id: user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: ctx.ip }) - return ok({ message: 'ok' }) - }) -} - -module.exports = register +module.exports = router diff --git a/server/src/routes/firmware.js b/server/src/routes/firmware.js index 8b46138..d2dd070 100644 --- a/server/src/routes/firmware.js +++ b/server/src/routes/firmware.js @@ -1,64 +1,55 @@ -const { one, query } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { requireUser, requireAdmin } = require('../lib/auth') +const { requireUser } = require('../middleware/auth') +const { requireAdmin } = require('../middleware/auth') const { getObjectUrl } = require('../lib/cos') -const { writeLog } = require('../lib/log') +const firmwareDao = require('../dao/firmware.dao') +const logDao = require('../dao/log.dao') -function register(router) { - router.get('/api/v1/admin/firmware', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const rows = await query('SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC', {}) - return ok({ records: rows, total: rows.length }) +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) + +router.get('/admin/firmware', requireAdmin, wrap(async (req, res) => { + const rows = await firmwareDao.list() + res.json(ok({ records: rows, total: rows.length })) +})) + +router.post('/admin/firmware', requireAdmin, wrap(async (req, res) => { + const version = String(req.body.version || '').trim() + const cosKey = String(req.body.cos_key || '').trim() + if (!version || !cosKey) return res.json(fail(2001, 'version and cos_key required')) + const insertId = await firmwareDao.create({ + version, + device_type: req.body.device_type || '', + cos_key: cosKey, + size_bytes: Number(req.body.size_bytes || 0), + sha256: req.body.sha256 || '', + status: req.body.status === 0 ? 0 : 1 }) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: req.ip }) + res.json(ok({ firmware_id: insertId })) +})) - router.post('/api/v1/admin/firmware', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const version = String(ctx.body.version || '').trim() - const cosKey = String(ctx.body.cos_key || '').trim() - if (!version || !cosKey) return fail(2001, 'version and cos_key required') - const result = await query( - 'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)', - { - version, - device_type: ctx.body.device_type || '', - cos_key: cosKey, - size_bytes: Number(ctx.body.size_bytes || 0), - sha256: ctx.body.sha256 || '', - status: ctx.body.status === 0 ? 0 : 1 - } - ) - await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: ctx.ip }) - return ok({ firmware_id: result.insertId }) - }) +router.post('/admin/firmware/:firmware_id/status', requireAdmin, wrap(async (req, res) => { + const firmwareId = parseInt(req.params.firmware_id, 10) + const status = Number(req.body.status) === 1 ? 1 : 0 + if (!firmwareId) return res.json(fail(2001, 'firmware_id required')) + await firmwareDao.updateStatus(firmwareId, status) + await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: req.ip }) + res.json(ok({ message: 'success' })) +})) - router.post('/api/v1/admin/firmware/:firmware_id/status', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const firmwareId = parseInt(ctx.params.firmware_id, 10) - const status = Number(ctx.body.status) === 1 ? 1 : 0 - if (!firmwareId) return fail(2001, 'firmware_id required') - await query('UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id', { status, firmware_id: firmwareId }) - await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: ctx.ip }) - return ok({ message: 'success' }) - }) +router.get('/firmware/latest', requireUser, wrap(async (req, res) => { + const firmware = await firmwareDao.findLatest() + if (!firmware) return res.json(ok({ has_update: false })) + const currentVersion = req.query.current_version || '' + if (currentVersion && currentVersion === firmware.version) return res.json(ok({ has_update: false })) + res.json(ok({ + has_update: true, + version: firmware.version, + size_bytes: firmware.size_bytes, + sha256: firmware.sha256, + download_url: await getObjectUrl(firmware.cos_key, 600) + })) +})) - router.get('/api/v1/firmware/latest', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const firmware = await one('SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1', {}) - if (!firmware) return ok({ has_update: false }) - const currentVersion = ctx.query.current_version || '' - if (currentVersion && currentVersion === firmware.version) return ok({ has_update: false }) - return ok({ - has_update: true, - version: firmware.version, - size_bytes: firmware.size_bytes, - sha256: firmware.sha256, - download_url: await getObjectUrl(firmware.cos_key, 600) - }) - }) -} - -module.exports = register +module.exports = router diff --git a/server/src/routes/subscription.js b/server/src/routes/subscription.js index 9d369ab..555cf96 100644 --- a/server/src/routes/subscription.js +++ b/server/src/routes/subscription.js @@ -1,7 +1,11 @@ -const { one, query, transaction } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { requireUser, requireAdmin } = require('../lib/auth') -const { writeLog } = require('../lib/log') +const { requireUser } = require('../middleware/auth') +const { requireAdmin } = require('../middleware/auth') +const subscriptionDao = require('../dao/subscription.dao') +const logDao = require('../dao/log.dao') + +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) const PLANS = { trial: { amount: 0, days: 7 }, @@ -9,54 +13,44 @@ const PLANS = { yearly: { amount: 899, days: 365 } } -function register(router) { - router.get('/api/v1/subscription', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const sub = await one('SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id }) - if (!sub) return ok({ status: 'inactive', plan: 'none', remaining_days: 0 }) - return ok({ status: sub.remaining_days > 0 ? 'active' : 'expired', plan: sub.plan, start_time: sub.start_time, expire_time: sub.expire_time, remaining_days: sub.remaining_days }) - }) +router.get('/subscription', requireUser, wrap(async (req, res) => { + const sub = await subscriptionDao.findActive(req.user.user_id) + if (!sub) return res.json(ok({ status: 'inactive', plan: 'none', remaining_days: 0 })) + res.json(ok({ + status: sub.remaining_days > 0 ? 'active' : 'expired', + plan: sub.plan, + start_time: sub.start_time, + expire_time: sub.expire_time, + remaining_days: sub.remaining_days + })) +})) - router.post('/api/v1/subscription/purchase', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const plan = ctx.body.plan || ctx.body.plan_type - if (!PLANS[plan]) return fail(2001, 'invalid plan') - const orderId = 'ORD' + Date.now() - return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }) - }) +router.post('/subscription/purchase', requireUser, wrap(async (req, res) => { + const plan = req.body.plan || req.body.plan_type + if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan')) + const orderId = 'ORD' + Date.now() + res.json(ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })) +})) - router.post('/api/v1/subscription/trial', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const usedTrial = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = \'trial\' LIMIT 1', { user_id: user.user_id }) - if (usedTrial) return fail(2001, '已使用过试用') - const activeSub = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', { user_id: user.user_id }) - if (activeSub) return fail(2001, '已有有效订阅') - const orderId = 'TRIAL' + Date.now() - await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, \'trial\', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', { user_id: user.user_id, order_id: orderId }) - return ok({ status: 'active', plan: 'trial', remaining_days: 7 }) - }) +router.post('/subscription/trial', requireUser, wrap(async (req, res) => { + const usedTrial = await subscriptionDao.findTrial(req.user.user_id) + if (usedTrial) return res.json(fail(2001, '已使用过试用')) + const activeSub = await subscriptionDao.findActive(req.user.user_id) + if (activeSub) return res.json(fail(2001, '已有有效订阅')) + await subscriptionDao.createTrial(req.user.user_id) + res.json(ok({ status: 'active', plan: 'trial', remaining_days: 7 })) +})) - // Temporary: admin-only until payment integration - router.post('/api/v1/subscription/verify', async ctx => { - const admin = await requireAdmin(ctx) - if (!admin) return fail(1002, '未授权,请重新登录') - const userId = ctx.body.user_id - if (!userId) return fail(2001, 'user_id required') - const plan = ctx.body.plan || ctx.body.plan_type || 'monthly' - if (!PLANS[plan]) return fail(2001, 'invalid plan') - const p = PLANS[plan] - await transaction(async conn => { - await conn.execute('UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', [userId]) - await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', [ - userId, plan, p.amount, ctx.body.order_id || 'ORD' + Date.now(), p.days - ]) - }) - await writeLog({ admin_id: admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: ctx.ip }) - return ok({ status: 'active', plan, remaining_days: p.days }) - }) -} +// Temporary: admin-only until payment integration +router.post('/subscription/verify', requireAdmin, wrap(async (req, res) => { + const userId = req.body.user_id + if (!userId) return res.json(fail(2001, 'user_id required')) + const plan = req.body.plan || req.body.plan_type || 'monthly' + if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan')) + const p = PLANS[plan] + await subscriptionDao.purchase(userId, plan, p.amount, req.body.order_id || 'ORD' + Date.now(), p.days) + await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: req.ip }) + res.json(ok({ status: 'active', plan, remaining_days: p.days })) +})) -module.exports = register +module.exports = router diff --git a/server/src/routes/treatment.js b/server/src/routes/treatment.js index 08f6c8f..5a6cf44 100644 --- a/server/src/routes/treatment.js +++ b/server/src/routes/treatment.js @@ -1,68 +1,58 @@ -const { one, query, limitClause } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { requireUser } = require('../lib/auth') -const { writeLog } = require('../lib/log') +const { requireUser } = require('../middleware/auth') const { toMysqlDate } = require('../lib/utils') +const treatmentDao = require('../dao/treatment.dao') +const bindingDao = require('../dao/binding.dao') +const logDao = require('../dao/log.dao') -function register(router) { +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) - router.get('/api/v1/treatment/history', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const page = Math.max(1, parseInt(ctx.query.page, 10) || 1) - const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100) - const offset = (page - 1) * pageSize - const total = await query('SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id }) - const records = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset), { user_id: user.user_id }) - return ok({ total: total[0].total, page, page_size: pageSize, records }) +router.get('/treatment/history', requireUser, wrap(async (req, res) => { + const page = Math.max(1, parseInt(req.query.page, 10) || 1) + const pageSize = Math.min(Math.max(1, parseInt(req.query.page_size, 10) || 20), 100) + const offset = (page - 1) * pageSize + const { records, total } = await treatmentDao.listByUser(req.user.user_id, { pageSize, offset }) + res.json(ok({ total, page, page_size: pageSize, records })) +})) + +router.post('/treatment/sync', requireUser, wrap(async (req, res) => { + const d = req.body || {} + if (!d.device_id) return res.json(fail(2001, 'device_id required')) + + const active = await bindingDao.findActiveByUser(req.user.user_id) + if (!active || active.device_id !== d.device_id) return res.json(fail(1006, 'device_not_bound')) + + const sessionId = d.session_id || 'SESS' + Date.now() + await treatmentDao.create({ + session_id: sessionId, + device_id: d.device_id, + user_id: req.user.user_id, + start_time: toMysqlDate(d.start_time), + end_time: toMysqlDate(d.end_time), + regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''), + total_duration_ms: parseInt(d.total_duration_ms, 10) || 0, + mode: parseInt(d.mode, 10) || 0, + avg_pd: Number(d.avg_pd) || 0, + battery: d.battery == null ? null : parseInt(d.battery, 10), + temperature: d.temperature == null ? null : parseInt(d.temperature, 10), + wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10), + brightness: d.brightness == null ? null : parseInt(d.brightness, 10), + pd_json: JSON.stringify(d.pd_values || {}) }) + await treatmentDao.updateDevice( + d.device_id, + d.battery == null ? null : parseInt(d.battery, 10), + d.temperature == null ? null : parseInt(d.temperature, 10) + ) + await logDao.write({ user_id: req.user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: req.ip }) + res.json(ok({ record_id: sessionId })) +})) - router.post('/api/v1/treatment/sync', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const d = ctx.body || {} - if (!d.device_id) return fail(2001, 'device_id required') - const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: d.device_id }) - if (!binding) return fail(1006, 'device_not_bound') - const sessionId = d.session_id || 'SESS' + Date.now() - await query( - `INSERT INTO treatment_records - (session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json) - VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json) - ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`, - { - session_id: sessionId, - device_id: d.device_id, - user_id: user.user_id, - start_time: toMysqlDate(d.start_time), - end_time: toMysqlDate(d.end_time), - regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''), - total_duration_ms: parseInt(d.total_duration_ms, 10) || 0, - mode: parseInt(d.mode, 10) || 0, - avg_pd: Number(d.avg_pd) || 0, - battery: d.battery == null ? null : parseInt(d.battery, 10), - temperature: d.temperature == null ? null : parseInt(d.temperature, 10), - wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10), - brightness: d.brightness == null ? null : parseInt(d.brightness, 10), - pd_json: JSON.stringify(d.pd_values || {}) - } - ) - await query('UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id', { - device_id: d.device_id, - battery: d.battery == null ? null : parseInt(d.battery, 10), - temperature: d.temperature == null ? null : parseInt(d.temperature, 10) - }) - await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip }) - return ok({ record_id: sessionId }) - }) +router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => { + const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id) + if (!record) return res.json(fail(1005, 'record_not_found')) + res.json(ok(record)) +})) - router.get('/api/v1/treatment/:record_id', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const record = await one('SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id', { session_id: ctx.params.record_id, user_id: user.user_id }) - if (!record) return fail(1005, 'record_not_found') - return ok(record) - }) -} - -module.exports = register +module.exports = router diff --git a/server/src/routes/user.js b/server/src/routes/user.js index e175423..6a87186 100644 --- a/server/src/routes/user.js +++ b/server/src/routes/user.js @@ -1,52 +1,45 @@ -const { query } = require('../lib/db') +const router = require('express').Router() const { ok, fail } = require('../lib/response') -const { requireUser } = require('../lib/auth') -const { writeLog } = require('../lib/log') +const { requireUser } = require('../middleware/auth') const { getPhoneNumber } = require('../lib/wechat') +const userDao = require('../dao/user.dao') +const deviceDao = require('../dao/device.dao') +const logDao = require('../dao/log.dao') -function register(router) { - router.get('/api/v1/user/profile', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const binds = await query('SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1', { user_id: user.user_id }) - return ok({ - user_id: String(user.user_id), - nickname: user.nickname || '用户' + String(user.user_id), - avatar: user.avatar || '', - phone: user.phone || '', - gender: user.gender || 0, - bind_time: null, - device_count: binds[0].total - }) +const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) + +router.get('/user/profile', requireUser, wrap(async (req, res) => { + const user = req.user + const devices = await deviceDao.listByUser(user.user_id) + res.json(ok({ + user_id: String(user.user_id), + nickname: user.nickname || '用户' + String(user.user_id), + avatar: user.avatar || '', + phone: user.phone || '', + gender: user.gender || 0, + bind_time: null, + device_count: devices.length + })) +})) + +router.put('/user/profile', requireUser, wrap(async (req, res) => { + await userDao.updateProfile(req.user.user_id, { + nickname: req.body.nickname || null, + avatar: req.body.avatar || req.body.avatar_url || null, + gender: req.body.gender === undefined ? null : req.body.gender }) + await logDao.write({ user_id: req.user.user_id, action: 'user_update', detail: '更新用户资料', ip: req.ip }) + res.json(ok({ message: 'success' })) +})) - router.put('/api/v1/user/profile', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - await query('UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id', { - user_id: user.user_id, - nickname: ctx.body.nickname || null, - avatar: ctx.body.avatar || ctx.body.avatar_url || null, - gender: ctx.body.gender === undefined ? null : ctx.body.gender - }) - await writeLog({ user_id: user.user_id, action: 'user_update', detail: '更新用户资料', ip: ctx.ip }) - return ok({ message: 'success' }) - }) +router.post('/user/phone', requireUser, wrap(async (req, res) => { + const code = String(req.body.code || '').trim() + if (!code) return res.json(fail(2001, 'phone code required')) + const phoneInfo = await getPhoneNumber(code) + if (!phoneInfo || !phoneInfo.phoneNumber) return res.json(fail(2001, 'phone authorization failed')) + await userDao.updatePhone(req.user.user_id, phoneInfo.phoneNumber) + await logDao.write({ user_id: req.user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: req.ip }) + res.json(ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' })) +})) - router.post('/api/v1/user/phone', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') - const code = String(ctx.body.code || '').trim() - if (!code) return fail(2001, 'phone code required') - const phoneInfo = await getPhoneNumber(code) - if (!phoneInfo || !phoneInfo.phoneNumber) return fail(2001, 'phone authorization failed') - await query('UPDATE users SET phone = :phone WHERE user_id = :user_id', { - user_id: user.user_id, - phone: phoneInfo.phoneNumber - }) - await writeLog({ user_id: user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: ctx.ip }) - return ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' }) - }) -} - -module.exports = register +module.exports = router