diff --git a/admin-console/package.json b/admin-console/package.json index 2bc4c4a..225b97c 100644 --- a/admin-console/package.json +++ b/admin-console/package.json @@ -13,7 +13,6 @@ "@dcloudio/uni-components": "3.0.0-4020920240930001", "@dcloudio/uni-h5": "3.0.0-4020920240930001", "pinia": "^2.1.0", - "uview-plus": "^3.2.0", "vue": "^3.4.0" }, "devDependencies": { diff --git a/admin-console/src/components/AdminLayout.vue b/admin-console/src/components/AdminLayout.vue index 8f0c30f..272ed49 100644 --- a/admin-console/src/components/AdminLayout.vue +++ b/admin-console/src/components/AdminLayout.vue @@ -20,7 +20,7 @@ {{ pageTitle }} - 👤 管理员 + 👤 {{ adminName }} 退出 @@ -32,6 +32,8 @@ diff --git a/admin-console/src/pages/log/index.vue b/admin-console/src/pages/log/index.vue index 32daff6..1bc8239 100644 --- a/admin-console/src/pages/log/index.vue +++ b/admin-console/src/pages/log/index.vue @@ -41,6 +41,7 @@ diff --git a/admin-console/src/pages/login/index.vue b/admin-console/src/pages/login/index.vue index 20da4af..9a9c128 100644 --- a/admin-console/src/pages/login/index.vue +++ b/admin-console/src/pages/login/index.vue @@ -26,15 +26,6 @@ /> - - - - - - 记住登录状态 - - - @@ -51,8 +42,7 @@ export default { data() { return { form: { username: 'admin', password: 'admin' }, - loading: false, - remember: false + loading: false } }, methods: { @@ -145,43 +135,8 @@ export default { color: #bfbfbf; } -.remember-row { - margin-bottom: 24px; -} - -.checkbox-wrap { - display: flex; - align-items: center; - gap: 8px; -} - -.checkbox-box { - width: 16px; - height: 16px; - border: 1px solid #d9d9d9; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; -} - -.checkbox-box.checked { - background: #E6508C; - border-color: #E6508C; -} - -.checkbox-icon { - color: #fff; - font-size: 11px; - line-height: 16px; -} - -.remember-text { - font-size: 14px; - color: #666; -} - .login-btn { + margin-top: 24px; width: 100%; height: 44px; background: #E6508C; diff --git a/admin-console/src/pages/record/index.vue b/admin-console/src/pages/record/index.vue index 6b56cb4..0dac666 100644 --- a/admin-console/src/pages/record/index.vue +++ b/admin-console/src/pages/record/index.vue @@ -64,6 +64,7 @@ diff --git a/admin-console/src/pages/settings/index.vue b/admin-console/src/pages/settings/index.vue index 995c9bc..826862b 100644 --- a/admin-console/src/pages/settings/index.vue +++ b/admin-console/src/pages/settings/index.vue @@ -136,7 +136,9 @@ export default { if (data) { Object.assign(this.settings, data) } - } catch (e) {} + } catch (e) { + uni.showToast({ title: '加载失败', icon: 'none' }) + } }, async onSave() { try { diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue index e2cd066..3f0fbe3 100644 --- a/admin-console/src/pages/subscription/index.vue +++ b/admin-console/src/pages/subscription/index.vue @@ -9,19 +9,19 @@ - {{ stats.monthly_count || 486 }} + {{ stats.monthly_count ?? 0 }} 月卡会员 - {{ stats.yearly_count || 1258 }} + {{ stats.yearly_count ?? 0 }} 年卡会员 - {{ stats.trial_count || 856 }} + {{ stats.trial_count ?? 0 }} 试用中 - ¥{{ stats.monthly_revenue || 45890 }} + ¥{{ stats.monthly_revenue ?? 0 }} 本月收入 @@ -103,6 +103,7 @@ diff --git a/admin-console/src/pages/user/index.vue b/admin-console/src/pages/user/index.vue index 9fd504a..ca712f6 100644 --- a/admin-console/src/pages/user/index.vue +++ b/admin-console/src/pages/user/index.vue @@ -44,7 +44,7 @@ {{ formatDate(item.created_at) }} 详情 - 记录 + 记录 @@ -63,6 +63,7 @@ diff --git a/admin-console/src/store/user.js b/admin-console/src/store/user.js index 10d356f..6e4d36c 100644 --- a/admin-console/src/store/user.js +++ b/admin-console/src/store/user.js @@ -9,12 +9,14 @@ export const useUserStore = defineStore('user', () => { function setToken(val) { token.value = val uni.setStorageSync('admin_token', val) + uni.setStorageSync('admin_token_expiry', Date.now() + 7 * 24 * 3600 * 1000) } function clearToken() { token.value = '' adminInfo.value = null uni.removeStorageSync('admin_token') + uni.removeStorageSync('admin_token_expiry') } async function login(credentials) { @@ -30,7 +32,13 @@ export const useUserStore = defineStore('user', () => { } function isLoggedIn() { - return !!token.value + if (!token.value) return false + const expiry = uni.getStorageSync('admin_token_expiry') || 0 + if (Date.now() > expiry) { + clearToken() + return false + } + return true } return { token, adminInfo, setToken, clearToken, login, isLoggedIn } diff --git a/admin-console/src/styles/common.css b/admin-console/src/styles/common.css new file mode 100644 index 0000000..2adc9b6 --- /dev/null +++ b/admin-console/src/styles/common.css @@ -0,0 +1,173 @@ +.data-table { + width: 100%; +} + +.t-header { + background: #fafafa; +} + +.t-row { + display: flex; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; +} + +.t-th { + font-size: 14px; + color: #666; + font-weight: 500; +} + +.t-td { + font-size: 14px; + color: #333; +} + +.flex1 { + flex: 1; +} + +.flex2 { + flex: 2; +} + +.flex3 { + flex: 3; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; +} + +.badge-success { + background: #f6ffed; + color: #52c41a; +} + +.badge-warning { + background: #fffbe6; + color: #faad14; +} + +.badge-error { + background: #fff2f0; + color: #ff4d4f; +} + +.badge-default { + background: #f5f5f5; + color: #999; +} + +.badge-blue { + background: #e6f7ff; + color: #1890ff; +} + +.btn-primary { + background: #E6508C; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.btn-default { + background: #fff; + color: #333; + border: 1px solid #d9d9d9; + border-radius: 6px; + cursor: pointer; +} + +.btn-sm { + height: 32px; + padding: 0 16px; + font-size: 14px; + line-height: 32px; +} + +.action-link { + color: #E6508C; + font-size: 14px; + margin-right: 12px; + cursor: pointer; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 20px; +} + +.btn-page { + width: 32px; + height: 32px; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: #fff; + color: #333; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.btn-page.active { + background: #E6508C; + color: #fff; + border-color: #E6508C; +} + +.btn-page[disabled] { + opacity: 0.4; +} + +.page-info { + font-size: 13px; + color: #999; + margin-left: 8px; +} + +.page-card { + background: #fff; + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.toolbar { + background: #fff; + border-radius: 8px; + padding: 12px 20px; + margin-bottom: 16px; + display: flex; + justify-content: flex-end; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.search-input { + width: 220px; + height: 32px; + border: 1px solid #d9d9d9; + border-radius: 6px; + padding: 0 12px; + font-size: 14px; + box-sizing: border-box; +} + +.input-placeholder { + color: #bfbfbf; +} diff --git a/admin-console/src/utils/format.js b/admin-console/src/utils/format.js new file mode 100644 index 0000000..2c39347 --- /dev/null +++ b/admin-console/src/utils/format.js @@ -0,0 +1,9 @@ +export function formatDate(d) { + if (!d) return '--' + return String(d).slice(0, 19).replace('T', ' ') +} + +export function formatDateShort(d) { + if (!d) return '--' + return String(d).slice(0, 10) +} diff --git a/admin-console/src/utils/request.js b/admin-console/src/utils/request.js index 573a0bf..049cb1d 100644 --- a/admin-console/src/utils/request.js +++ b/admin-console/src/utils/request.js @@ -1,97 +1,8 @@ import env from '../config/env' const BASE_URL = env.API_BASE -const USE_MOCK = false - -const MOCK_DATA = { - '/api/v1/admin/devices/AABBCCDDEEFF0011': { - device_id: 'AABBCCDDEEFF0011', bound_user: 'user_001', battery: 85, fw_version: '1.0.0', - activated_at: '2025-03-15T10:00:00Z', status: 2, total_usage: '45h', last_online: '2025-04-22T14:30:00Z', - binding_history: [ - { nickname: '张小姐', bound_at: '2025-03-15T10:00:00Z', unbound_at: null, status: 1 } - ] - }, - '/api/v1/admin/dashboard': { - device_count: 12, - user_count: 86, - treatment_count: 1234, - subscription_count: 52 - }, - '/api/v1/admin/devices': { - records: [ - { device_id: 'AABBCCDDEEFF0011', bound_user: 'user_001', battery: 85, fw_version: '1.0.0', activated_at: '2025-03-15T10:00:00Z', status: 2 }, - { device_id: '1122334455667788', bound_user: 'user_002', battery: 60, fw_version: '1.0.0', activated_at: '2025-03-20T14:00:00Z', status: 2 }, - { device_id: 'A1B2C3D4E5F60011', bound_user: null, battery: null, fw_version: '1.0.0', activated_at: '2025-03-10T08:00:00Z', status: 1 } - ], - total: 3 - }, - '/api/v1/admin/users': { - records: [ - { _id: 'u1', openid: 'oXXXX1', nickname: '张小姐', phone: '138****1234', created_at: '2025-03-01T08:00:00Z', subscription_status: 'yearly' }, - { _id: 'u2', openid: 'oXXXX2', nickname: '李女士', phone: '139****5678', created_at: '2025-03-05T10:00:00Z', subscription_status: 'monthly' }, - { _id: 'u3', openid: 'oXXXX3', nickname: '王先生', phone: '137****9012', created_at: '2025-03-10T12:00:00Z', subscription_status: 'trial' } - ], - total: 3 - }, - '/api/v1/admin/users/u1': { - _id: 'u1', openid: 'oXXXX1', nickname: '张小姐', phone: '138****1234', - created_at: '2025-03-01T08:00:00Z', subscription_type: 'yearly', subscription_status: 'active', - subscription_expire: '2026-03-01T08:00:00Z', treatment_count: 28, total_duration: '14h', - total_spent: 899, devices: [{ device_id: 'AABBCCDDEEFF0011', device_name: '我的光面膜' }], - recent_treatments: [ - { started_at: '2025-04-20T10:00:00Z', total_duration_ms: 1200000, device_id: 'AABBCCDDEEFF0011' }, - { started_at: '2025-04-18T09:00:00Z', total_duration_ms: 900000, device_id: 'AABBCCDDEEFF0011' } - ] - }, - '/api/v1/admin/subscriptions': { - records: [ - { id: 's1', user_id: 'user_001', plan: 'yearly', amount: 899, started_at: '2025-03-01T00:00:00Z', expired_at: '2026-03-01T00:00:00Z', status: 1 }, - { id: 's2', user_id: 'user_002', plan: 'monthly', amount: 99, started_at: '2025-04-01T00:00:00Z', expired_at: '2025-05-01T00:00:00Z', status: 1 }, - { id: 's3', user_id: 'user_003', plan: 'trial', amount: '-', started_at: '2025-03-10T00:00:00Z', expired_at: '2025-03-17T00:00:00Z', status: 3 } - ], - total: 3, - stats: { monthly_count: 15, yearly_count: 30, trial_count: 7, monthly_revenue: 45890 } - }, - '/api/v1/admin/records': { - records: [ - { started_at: '2025-04-20T10:00:00Z', total_duration_ms: 1200000, device_id: 'AABBCCDDEEFF0011', openid: 'oXXXX1' }, - { started_at: '2025-04-19T15:00:00Z', total_duration_ms: 900000, device_id: '1122334455667788', openid: 'oXXXX2' } - ], - total: 2 - }, - '/api/v1/admin/logs': { - records: [ - { created_at: '2025-04-20T10:05:00Z', action: 'treatment_complete', detail: '用户张小姐完成护理', openid: 'oXXXX1' }, - { created_at: '2025-04-20T09:00:00Z', action: 'device_bind', detail: '设备AABBCCDDEEFF0011绑定', openid: 'oXXXX1' } - ], - total: 2 - } -} - -function getMockData(url, data) { - if (url.includes('/api/v1/admin/login')) { - if (data && data.username === 'admin' && data.password === 'admin123') { - return { token: 'mock_token_admin', admin_id: 'admin_001', username: 'admin', real_name: '管理员', role: 'admin' } - } - throw { code: 1001, message: '用户名或密码错误' } - } - if (url.startsWith('/api/v1/admin/users/') && !url.includes('users?')) { - const id = url.split('/').pop() - return MOCK_DATA['/api/v1/admin/users/u1'] || { _id: id, nickname: '未知用户' } - } - for (const key of Object.keys(MOCK_DATA)) { - if (url.includes(key)) return MOCK_DATA[key] - } - return {} -} function request(options) { - if (USE_MOCK) { - return new Promise(resolve => { - setTimeout(() => resolve(getMockData(options.url, options.data)), 200) - }) - } - const token = uni.getStorageSync('admin_token') return new Promise((resolve, reject) => { diff --git a/miniprogram/config/env.js b/miniprogram/config/env.js index 0e9fe41..c2f1463 100644 --- a/miniprogram/config/env.js +++ b/miniprogram/config/env.js @@ -1,9 +1,10 @@ var ENV = 'test' +// !! RELEASE BLOCKER: Change ENV to 'prod' and replace prod URL before publishing !! var API_BASES = { local: 'http://localhost:3000', test: 'https://1426323813-ilxkhlxf4p.ap-guangzhou.tencentscf.com', - prod: 'https://replace-with-scf-prod-url' + prod: 'https://replace-with-scf-prod-url' // TODO: replace with actual production SCF URL } module.exports = { diff --git a/miniprogram/pages/auto-scan/auto-scan.js b/miniprogram/pages/auto-scan/auto-scan.js index 7f07ae0..f87a3bf 100644 --- a/miniprogram/pages/auto-scan/auto-scan.js +++ b/miniprogram/pages/auto-scan/auto-scan.js @@ -7,6 +7,7 @@ Page({ scanProgress: 0, regions: 0x7F, regionData: [], + timeout: false, error: '' }, @@ -21,42 +22,45 @@ Page({ startScan: function () { var self = this - self.setData({ scanning: true, scanProgress: 0 }) + self.setData({ scanning: true, scanProgress: 0, timeout: false }) - var progressTimer = setInterval(function () { + this._progressTimer = setInterval(function () { var p = self.data.scanProgress + 2 if (p > 98) p = 98 self.setData({ scanProgress: p }) }, 100) - ble.on('status', function (status) { + this._onStatus = function (status) { if (status.mode_state === 0x01) { self.setData({ scanProgress: 50 }) } else if (status.mode_state === 0x04 || status.mode_state === 0x00) { - clearInterval(progressTimer) + clearInterval(self._progressTimer) self.setData({ scanning: false, scanProgress: 100 }) - if (status.region_mask) { - self.parseScanResults(status) - } + var names = ble.getRegionName(status.region_mask || 0x7F) + self.setData({ regionData: self.parseScanResults(names) }) } - }) + } + ble.on('status', this._onStatus) ble.queryStatus().catch(function () {}) setTimeout(function () { - clearInterval(progressTimer) + clearInterval(self._progressTimer) if (!self.data.scanning) return - self.setData({ scanning: false, scanProgress: 100 }) - self.parseScanResults({ region_mask: self.data.regions }) + self.setData({ scanning: false, timeout: true }) + wx.showToast({ title: '扫描超时,请重试', icon: 'none' }) }, 5000) }, - parseScanResults: function (status) { - var regions = ble.getRegionName(status.region_mask || 0x7F) - var data = regions.map(function (name) { - return { region: name, pd: (Math.random() * 0.3 + 0.3).toFixed(2) } + parseScanResults: function (regions) { + return regions.map(function (name) { + return { region: name, pd: '--' } }) - this.setData({ regionData: data }) + }, + + onUnload: function () { + if (this._progressTimer) clearInterval(this._progressTimer) + if (this._onStatus) ble.off('status', this._onStatus) }, onNext: function () { diff --git a/miniprogram/pages/ble-connect/ble-connect.js b/miniprogram/pages/ble-connect/ble-connect.js index 0cc5441..67783c7 100644 --- a/miniprogram/pages/ble-connect/ble-connect.js +++ b/miniprogram/pages/ble-connect/ble-connect.js @@ -24,6 +24,7 @@ Page({ onUnload: function () { clearTimeout(this._scanTimer) ble.stopScan() + if (this._onBindResult) ble.off('bind_result', this._onBindResult) }, startBleConnect: function () { @@ -55,7 +56,7 @@ Page({ doBind: function () { var self = this var userId = app.globalData.userId || '' - ble.on('bind_result', function (result) { + this._onBindResult = function (result) { if (result.success) { http.post('/api/v1/device/bind/confirm', { device_id: self.data.deviceId, @@ -71,7 +72,8 @@ Page({ } else { self.setData({ state: 'error', error: '设备绑定失败' }) } - }) + } + ble.on('bind_result', this._onBindResult) ble.bindDevice(userId, self.data.bindToken).catch(function (err) { self.setData({ state: 'error', error: err.error_msg || '绑定命令失败' }) diff --git a/miniprogram/pages/history/history.js b/miniprogram/pages/history/history.js index 22c7275..e1dd3c8 100644 --- a/miniprogram/pages/history/history.js +++ b/miniprogram/pages/history/history.js @@ -6,6 +6,7 @@ Page({ records: [], total: 0, totalHours: 0, + totalMs: 0, monthCount: 0, page: 1, pageSize: 20, @@ -43,14 +44,14 @@ Page({ page: page, page_size: self.data.pageSize }).then(function (data) { - var totalMs = 0 + var pageMs = 0 var now = new Date() var monthStart = new Date(now.getFullYear(), now.getMonth(), 1) - var monthCount = 0 + var monthCount = refresh ? 0 : self.data.monthCount var records = (data.records || []).map(function (r) { var durationMin = Math.floor((r.total_duration_ms || 0) / 60000) - totalMs += (r.total_duration_ms || 0) + pageMs += (r.total_duration_ms || 0) r.duration_text = durationMin + '分钟' r.region_names = ble.getRegionName(r.regions || 0).join('、') r.wavelength_name = ble.getWavelengthName(r.wavelength || 2) @@ -62,9 +63,12 @@ Page({ return r }) + var totalMs = refresh ? pageMs : self.data.totalMs + pageMs + self.setData({ records: refresh ? records : self.data.records.concat(records), total: data.total || 0, + totalMs: totalMs, totalHours: Math.round(totalMs / 3600000), monthCount: monthCount, page: page, diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index d109614..3ce5d97 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -17,15 +17,23 @@ Page({ onShow: function () { this.checkState() - ble.on('status', this.onBleStatus.bind(this)) + this._onStatus = this.onBleStatus.bind(this) + ble.on('status', this._onStatus) + }, + + _cleanup: function () { + if (this._onStatus) { + ble.off('status', this._onStatus) + this._onStatus = null + } }, onHide: function () { - ble.off('status') + this._cleanup() }, onUnload: function () { - ble.off('status') + this._cleanup() }, checkState: function () { diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index 7b1e71e..b53c8d3 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -38,7 +38,12 @@ Page({ if (!this.data.subscription || this.data.subscription.status === 0) { wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' }) } else { - wx.navigateTo({ url: '/pages/subscribe-prompt/subscribe-prompt' }) + var sub = this.data.subscription + wx.showModal({ + title: '订阅信息', + content: '套餐类型:' + (sub.plan_type || '未知') + '\n剩余天数:' + (sub.remaining_days || 0) + '天', + showCancel: false + }) } }, diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js index b3f6a59..80ff22c 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.js +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -21,27 +21,10 @@ Page({ }, onPurchase: function () { - var self = this - if (!self.data.selected) { - wx.showToast({ title: '请选择套餐', icon: 'none' }) - return - } - self.setData({ purchasing: true }) - - http.post('/api/v1/subscription/purchase', { - plan_type: self.data.selected, - payment_method: 'wechat' - }).then(function (data) { - return http.post('/api/v1/subscription/verify', { - order_id: data.order_id, - plan_type: self.data.selected - }) - }).then(function () { - self.setData({ purchasing: false }) - wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success' }) - }).catch(function () { - self.setData({ purchasing: false }) - wx.showToast({ title: '购买失败', icon: 'none' }) + wx.showModal({ + title: '暂未开放', + content: '在线购买功能尚未开放,请联系管理员开通订阅。', + showCancel: false }) } }) diff --git a/miniprogram/pages/subscribe-success/subscribe-success.js b/miniprogram/pages/subscribe-success/subscribe-success.js index b8c2054..e49c062 100644 --- a/miniprogram/pages/subscribe-success/subscribe-success.js +++ b/miniprogram/pages/subscribe-success/subscribe-success.js @@ -5,15 +5,23 @@ Page({ expiryDate: '' }, - onLoad: function () { + onLoad: function (options) { var app = getApp() this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + + var planMap = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用会员' } + var durationMap = { yearly: 365, monthly: 30, trial: 7 } + var plan = options.plan || 'yearly' + var now = new Date() - now.setFullYear(now.getFullYear() + 1) + now.setDate(now.getDate() + (durationMap[plan] || 365)) var y = now.getFullYear() var m = ('0' + (now.getMonth() + 1)).slice(-2) var d = ('0' + now.getDate()).slice(-2) - this.setData({ expiryDate: y + '年' + m + '月' + d + '日' }) + this.setData({ + planName: planMap[plan] || '会员', + expiryDate: y + '年' + m + '月' + d + '日' + }) }, onStartSmart: function () { diff --git a/miniprogram/pages/treating/treating.js b/miniprogram/pages/treating/treating.js index 92f9566..7b6cd4f 100644 --- a/miniprogram/pages/treating/treating.js +++ b/miniprogram/pages/treating/treating.js @@ -16,7 +16,6 @@ Page({ paused: false, completed: false, startedAt: 0, - localTimer: null }, onLoad: function (options) { @@ -31,18 +30,21 @@ Page({ startedAt: Date.now() }) - ble.on('status', this.onStatus.bind(this)) - ble.on('treatment_complete', this.onComplete.bind(this)) - ble.on('exception', this.onException.bind(this)) + this._onStatus = this.onStatus.bind(this) + this._onComplete = this.onComplete.bind(this) + this._onException = this.onException.bind(this) + ble.on('status', this._onStatus) + ble.on('treatment_complete', this._onComplete) + ble.on('exception', this._onException) this.startLocalTimer() this.syncCommands() }, onUnload: function () { - ble.off('status') - ble.off('treatment_complete') - ble.off('exception') - if (this.data.localTimer) clearInterval(this.data.localTimer) + if (this._onStatus) ble.off('status', this._onStatus) + if (this._onComplete) ble.off('treatment_complete', this._onComplete) + if (this._onException) ble.off('exception', this._onException) + if (this._localTimer) clearInterval(this._localTimer) }, startLocalTimer: function () { @@ -57,7 +59,7 @@ Page({ self.finishAsComplete() } }, 1000) - this.setData({ localTimer: timer }) + this._localTimer = timer }, updateProgress: function (remaining) { @@ -95,12 +97,14 @@ Page({ var app = getApp() app.globalData.currentTreatment = result + var self = this setTimeout(function () { wx.redirectTo({ url: '/pages/treatment-done/treatment-done?session_id=' + result.session_id + '®ions=' + result.regions + '&duration=' + result.total_duration_ms + - '&avg_pd=' + result.avg_pd + '&avg_pd=' + result.avg_pd + + '&mode=' + (self.data.mode || 0) }) }, 1000) }, @@ -108,10 +112,11 @@ Page({ finishAsComplete: function () { var elapsed = Math.min(this.data.duration, Date.now() - this.data.startedAt) this.onComplete({ - session_id: 'SESS' + Date.now(), + session_id: 'LOCAL_' + Date.now(), regions: this.data.regions, total_duration_ms: elapsed, - avg_pd: 0 + avg_pd: 0, + source: 'client_timer' }) }, diff --git a/miniprogram/pages/treatment-done/treatment-done.js b/miniprogram/pages/treatment-done/treatment-done.js index b3b3007..fbcc872 100644 --- a/miniprogram/pages/treatment-done/treatment-done.js +++ b/miniprogram/pages/treatment-done/treatment-done.js @@ -9,6 +9,7 @@ Page({ regions: 0, duration: 0, avgPd: 0, + mode: 0, durationText: '', regionNames: [], syncing: false, @@ -34,6 +35,7 @@ Page({ duration: durationMs, avgPd: options.avg_pd || 0, durationText: durationText, + mode: parseInt(options.mode) || 0, regionNames: ble.getRegionName(parseInt(options.regions) || 0) }) @@ -42,6 +44,7 @@ Page({ syncRecord: function () { var self = this + var treatment = (app.globalData.currentTreatment) || {} self.setData({ syncing: true }) http.post('/api/v1/treatment/sync', { @@ -51,8 +54,9 @@ Page({ end_time: new Date().toISOString(), regions: self.data.regions, total_duration_ms: self.data.duration, - mode: 0, - avg_pd: self.data.avgPd + mode: self.data.mode, + avg_pd: self.data.avgPd, + source: treatment.source || 'device' }).then(function () { self.setData({ syncing: false, synced: true }) }).catch(function () { diff --git a/miniprogram/pages/treatment-setup/treatment-setup.js b/miniprogram/pages/treatment-setup/treatment-setup.js index 1d7512f..3198541 100644 --- a/miniprogram/pages/treatment-setup/treatment-setup.js +++ b/miniprogram/pages/treatment-setup/treatment-setup.js @@ -29,7 +29,7 @@ Page({ var self = this http.get('/api/v1/subscription').then(function (sub) { self.setData({ - subExpired: sub.status !== 'active', + subExpired: sub.status !== 1, subDays: sub.remaining_days || 0 }) }).catch(function () {}) diff --git a/miniprogram/pages/wear-check/wear-check.js b/miniprogram/pages/wear-check/wear-check.js index d037471..c252731 100644 --- a/miniprogram/pages/wear-check/wear-check.js +++ b/miniprogram/pages/wear-check/wear-check.js @@ -21,20 +21,26 @@ Page({ var self = this self.setData({ checking: true, result: null, error: '' }) - ble.on('status', function (status) { + if (this._onStatus) ble.off('status', this._onStatus) + this._onStatus = function (status) { self.setData({ checking: false }) if (status.bind_status === 1) { self.setData({ result: 'ok' }) } else { self.setData({ result: 'fail', error: '请确认设备已正确佩戴' }) } - }) + } + ble.on('status', this._onStatus) ble.queryStatus().catch(function (err) { self.setData({ checking: false, result: 'fail', error: err.error_msg || '查询失败' }) }) }, + onUnload: function () { + if (this._onStatus) ble.off('status', this._onStatus) + }, + onNext: function () { wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' }) }, diff --git a/miniprogram/services/ble.js b/miniprogram/services/ble.js index 5a290bc..f657c5b 100644 --- a/miniprogram/services/ble.js +++ b/miniprogram/services/ble.js @@ -289,6 +289,7 @@ function startScan(callbacks) { wx.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: function () { + wx.offBluetoothDeviceFound() wx.onBluetoothDeviceFound(function (res) { var devices = res.devices || [] for (var i = 0; i < devices.length; i++) { @@ -555,6 +556,13 @@ function disconnect() { wx.closeBluetoothAdapter({}) } +wx.onBLEConnectionStateChange(function (res) { + if (!res.connected) { + _connected = false + emit('disconnected', { deviceId: res.deviceId }) + } +}) + function getRegionName(mask) { var names = [] var bits = [ diff --git a/miniprogram/services/command-sync.js b/miniprogram/services/command-sync.js index 0773310..c27912d 100644 --- a/miniprogram/services/command-sync.js +++ b/miniprogram/services/command-sync.js @@ -36,7 +36,7 @@ function report(command, success, result) { success: success, opcode: command.opcode, result: result || null - }).catch(function () {}) + }).catch(function (err) { console.error('[command-sync] report failed:', err) }) } function runOne(command) { diff --git a/miniprogram/utils/mock.js b/miniprogram/utils/mock.js index 1412467..7dd57bc 100644 --- a/miniprogram/utils/mock.js +++ b/miniprogram/utils/mock.js @@ -141,5 +141,5 @@ function handle(method, path, data) { module.exports = { handle: handle, MOCK_TOKEN: MOCK_TOKEN, - enabled: true + enabled: false } diff --git a/server/src/config.js b/server/src/config.js index e9ec022..d916e40 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -32,4 +32,9 @@ const config = { } } +if (config.nodeEnv === 'production') { + if (config.jwt.secret === 'dev-user-secret') throw new Error('JWT_SECRET must be set in production') + if (config.jwt.adminSecret === 'dev-admin-secret') throw new Error('ADMIN_JWT_SECRET must be set in production') +} + module.exports = config diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index ce18940..c51b35d 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -38,7 +38,7 @@ async function requireUser(ctx) { } async function requireAdmin(ctx) { - const token = readBearer(ctx.headers) || (ctx.body && ctx.body.token) + const token = readBearer(ctx.headers) if (!token) return null try { const payload = jwt.verify(token, config.jwt.adminSecret) diff --git a/server/src/lib/cos.js b/server/src/lib/cos.js index 4d2b8c2..3466d62 100644 --- a/server/src/lib/cos.js +++ b/server/src/lib/cos.js @@ -14,12 +14,17 @@ function getClient() { } function getObjectUrl(key, expiresSeconds) { - return getClient().getObjectUrl({ - Bucket: config.cos.bucket, - Region: config.cos.region, - Key: key, - Sign: true, - Expires: expiresSeconds || 600 + return new Promise((resolve, reject) => { + getClient().getObjectUrl({ + Bucket: config.cos.bucket, + Region: config.cos.region, + Key: key, + Sign: true, + Expires: expiresSeconds || 3600 + }, (err, data) => { + if (err) reject(err) + else resolve(data.Url) + }) }) } diff --git a/server/src/lib/db.js b/server/src/lib/db.js index a548c66..2045168 100644 --- a/server/src/lib/db.js +++ b/server/src/lib/db.js @@ -45,4 +45,8 @@ async function transaction(work) { } } -module.exports = { getPool, query, one, transaction } +function limitClause(pageSize, offset) { + return ' LIMIT ' + Number(pageSize) + ' OFFSET ' + Number(offset) +} + +module.exports = { getPool, query, one, transaction, limitClause } diff --git a/server/src/lib/utils.js b/server/src/lib/utils.js new file mode 100644 index 0000000..310fba3 --- /dev/null +++ b/server/src/lib/utils.js @@ -0,0 +1,12 @@ +function toMysqlDate(value) { + if (!value) return null + const d = new Date(value) + if (Number.isNaN(d.getTime())) return null + return d.toISOString().slice(0, 19).replace('T', ' ') +} + +function formatDate(date) { + return toMysqlDate(date) +} + +module.exports = { toMysqlDate, formatDate } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 232c21b..885c867 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,23 +1,14 @@ -const { one, query } = require('../lib/db') +const { one, query, limitClause } = require('../lib/db') const { ok, fail } = require('../lib/response') const { hashPassword, signAdmin, requireAdmin } = require('../lib/auth') const { writeLog } = require('../lib/log') function pageParams(ctx) { - const page = Math.max(1, parseInt(ctx.query.page || ctx.body.page, 10) || 1) - const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size || ctx.body.page_size, 10) || 20), 100) + 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) return { page, pageSize, offset: (page - 1) * pageSize } } -function limitClause(p) { - return ' LIMIT ' + Number(p.pageSize) + ' OFFSET ' + Number(p.offset) -} - -async function adminOnly(ctx) { - const admin = await requireAdmin(ctx) - return admin -} - function register(router) { router.post('/api/v1/admin/login', async ctx => { const username = ctx.body.username || '' @@ -30,7 +21,7 @@ function register(router) { }) router.get('/api/v1/admin/dashboard', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const rows = await Promise.all([ query('SELECT COUNT(*) AS total FROM devices', {}), @@ -42,16 +33,16 @@ function register(router) { }) router.get('/api/v1/admin/devices', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const p = pageParams(ctx) const total = await query('SELECT COUNT(*) AS total FROM devices', {}) - 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 ORDER BY d.created_at DESC' + limitClause(p), {}) + 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 ORDER BY d.created_at DESC' + limitClause(p.pageSize, p.offset), {}) return ok({ records, total: total[0].total }) }) router.post('/api/v1/admin/devices', async ctx => { - const admin = await adminOnly(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') @@ -70,7 +61,7 @@ function register(router) { }) router.get('/api/v1/admin/devices/:device_id', async ctx => { - const admin = await adminOnly(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') @@ -78,7 +69,7 @@ function register(router) { }) router.post('/api/v1/admin/devices/:device_id/unbind', async ctx => { - const admin = await adminOnly(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 }) @@ -86,7 +77,7 @@ function register(router) { }) router.post('/api/v1/admin/devices/:device_id/command', async ctx => { - const admin = await adminOnly(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') @@ -99,28 +90,28 @@ function register(router) { }) router.get('/api/v1/admin/devices/:device_id/commands', async ctx => { - const admin = await adminOnly(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), + '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('/api/v1/admin/users', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const p = pageParams(ctx) const total = await query('SELECT COUNT(*) AS total FROM users', {}) - const records = await query('SELECT * FROM users ORDER BY created_at DESC' + limitClause(p), {}) + const records = await query('SELECT * FROM users ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), {}) return ok({ records, total: total[0].total }) }) router.get('/api/v1/admin/users/:user_id', async ctx => { - const admin = await adminOnly(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') @@ -130,17 +121,19 @@ function register(router) { }) router.get('/api/v1/admin/subscriptions', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const p = pageParams(ctx) const total = await query('SELECT COUNT(*) AS total FROM subscriptions', {}) - const records = await query('SELECT * FROM subscriptions ORDER BY created_at DESC' + limitClause(p), {}) + const records = await query('SELECT * FROM subscriptions ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), {}) return ok({ records, total: total[0].total }) }) router.post('/api/v1/admin/subscriptions', async ctx => { - const admin = await adminOnly(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('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', @@ -152,38 +145,44 @@ function register(router) { }) router.get('/api/v1/admin/records', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const p = pageParams(ctx) const total = await query('SELECT COUNT(*) AS total FROM treatment_records', {}) - const records = await query('SELECT * FROM treatment_records ORDER BY created_at DESC' + limitClause(p), {}) + const records = await query('SELECT * FROM treatment_records ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), {}) return ok({ records, total: total[0].total }) }) router.get('/api/v1/admin/logs', async ctx => { - const admin = await adminOnly(ctx) + const admin = await requireAdmin(ctx) if (!admin) return fail(1002, '未授权,请重新登录') const p = pageParams(ctx) const total = await query('SELECT COUNT(*) AS total FROM operation_logs', {}) - const records = await query('SELECT * FROM operation_logs ORDER BY created_at DESC' + limitClause(p), {}) + const records = await query('SELECT * FROM operation_logs ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), {}) return ok({ records, total: total[0].total }) }) router.get('/api/v1/admin/settings', async ctx => { - const admin = await adminOnly(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 => { - settings[row.setting_key] = typeof row.setting_value === 'string' ? JSON.parse(row.setting_value) : row.setting_value + 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 adminOnly(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' }) diff --git a/server/src/routes/device.js b/server/src/routes/device.js index f064d9b..f11ce64 100644 --- a/server/src/routes/device.js +++ b/server/src/routes/device.js @@ -2,10 +2,7 @@ const { one, query, transaction } = require('../lib/db') const { ok, fail } = require('../lib/response') const { requireUser, randomHex } = require('../lib/auth') const { writeLog } = require('../lib/log') - -function formatDate(date) { - return date.toISOString().slice(0, 19).replace('T', ' ') -} +const { formatDate } = require('../lib/utils') 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]) @@ -40,7 +37,8 @@ function register(router) { 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 }) - return ok(Object.assign(result, { subscription: { plan: 'trial', remaining_days: 7 } })) + 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('/api/v1/device/bind/confirm', async ctx => { @@ -62,7 +60,8 @@ function register(router) { }) 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 }) - return ok({ message: 'success', subscription: { plan: 'trial', remaining_days: 7 } }) + 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/unbind', async ctx => { @@ -107,6 +106,8 @@ function register(router) { 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, @@ -131,6 +132,8 @@ function register(router) { 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)', { diff --git a/server/src/routes/firmware.js b/server/src/routes/firmware.js index 887f287..8b46138 100644 --- a/server/src/routes/firmware.js +++ b/server/src/routes/firmware.js @@ -4,20 +4,16 @@ const { requireUser, requireAdmin } = require('../lib/auth') const { getObjectUrl } = require('../lib/cos') const { writeLog } = require('../lib/log') -function adminOnly(ctx) { - return requireAdmin(ctx) -} - function register(router) { router.get('/api/v1/admin/firmware', async ctx => { - const admin = await adminOnly(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 }) }) router.post('/api/v1/admin/firmware', async ctx => { - const admin = await adminOnly(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() @@ -38,7 +34,7 @@ function register(router) { }) router.post('/api/v1/admin/firmware/:firmware_id/status', async ctx => { - const admin = await adminOnly(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 @@ -53,12 +49,14 @@ function register(router) { 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: getObjectUrl(firmware.cos_key, 600) + download_url: await getObjectUrl(firmware.cos_key, 600) }) }) } diff --git a/server/src/routes/subscription.js b/server/src/routes/subscription.js index 8328370..55a2199 100644 --- a/server/src/routes/subscription.js +++ b/server/src/routes/subscription.js @@ -1,6 +1,6 @@ -const { one, query } = require('../lib/db') +const { one, query, transaction } = require('../lib/db') const { ok, fail } = require('../lib/response') -const { requireUser } = require('../lib/auth') +const { requireUser, requireAdmin } = require('../lib/auth') const { writeLog } = require('../lib/log') const PLANS = { @@ -26,21 +26,22 @@ function register(router) { return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }) }) + // Temporary: admin-only until payment integration router.post('/api/v1/subscription/verify', async ctx => { - const user = await requireUser(ctx) - if (!user) return fail(1001, 'invalid_token') + 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 query('UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', { user_id: user.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: user.user_id, - plan, - amount: p.amount, - order_id: ctx.body.order_id || 'ORD' + Date.now(), - days: p.days + 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({ user_id: user.user_id, action: 'subscription_verify', detail: '订阅生效: ' + plan, ip: ctx.ip }) + 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 }) }) } diff --git a/server/src/routes/treatment.js b/server/src/routes/treatment.js index 5d9f2b4..b4c5d75 100644 --- a/server/src/routes/treatment.js +++ b/server/src/routes/treatment.js @@ -1,19 +1,10 @@ -const { query } = require('../lib/db') +const { one, query, limitClause } = require('../lib/db') const { ok, fail } = require('../lib/response') const { requireUser } = require('../lib/auth') const { writeLog } = require('../lib/log') - -function toMysqlDate(value) { - if (!value) return null - const d = new Date(value) - if (Number.isNaN(d.getTime())) return null - return d.toISOString().slice(0, 19).replace('T', ' ') -} +const { toMysqlDate } = require('../lib/utils') function register(router) { - function limitClause(pageSize, offset) { - return ' LIMIT ' + Number(pageSize) + ' OFFSET ' + Number(offset) - } router.get('/api/v1/treatment/history', async ctx => { const user = await requireUser(ctx) @@ -31,6 +22,8 @@ function register(router) { 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