diff --git a/miniprogram/app.js b/miniprogram/app.js index 9486352..782f46e 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -40,6 +40,7 @@ App({ success: function (res) { http.post('/api/v1/auth/login', { code: res.code }).then(function (data) { wx.setStorageSync('token', data.token) + wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (data.expires_in || 604800))) var app = getApp() app.globalData.userInfo = data.user_info app.globalData.userId = data.user_id diff --git a/miniprogram/pages/treating/treating.js b/miniprogram/pages/treating/treating.js index 7b6cd4f..298119f 100644 --- a/miniprogram/pages/treating/treating.js +++ b/miniprogram/pages/treating/treating.js @@ -33,9 +33,15 @@ Page({ this._onStatus = this.onStatus.bind(this) this._onComplete = this.onComplete.bind(this) this._onException = this.onException.bind(this) + this._onDisconnect = this.onDisconnect.bind(this) + this._onReconnect = this.onReconnect.bind(this) + this._onReconnectFailed = this.onReconnectFailed.bind(this) ble.on('status', this._onStatus) ble.on('treatment_complete', this._onComplete) ble.on('exception', this._onException) + ble.on('disconnected', this._onDisconnect) + ble.on('reconnected', this._onReconnect) + ble.on('reconnect_failed', this._onReconnectFailed) this.startLocalTimer() this.syncCommands() }, @@ -44,6 +50,9 @@ Page({ 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._onDisconnect) ble.off('disconnected', this._onDisconnect) + if (this._onReconnect) ble.off('reconnected', this._onReconnect) + if (this._onReconnectFailed) ble.off('reconnect_failed', this._onReconnectFailed) if (this._localTimer) clearInterval(this._localTimer) }, @@ -131,6 +140,40 @@ Page({ }) }, + onDisconnect: function () { + this.setData({ paused: true }) + wx.showToast({ + title: '设备连接断开,正在重连...', + icon: 'none', + duration: 3000 + }) + }, + + onReconnect: function () { + this.setData({ paused: false }) + wx.showToast({ + title: '设备已重新连接', + icon: 'success', + duration: 2000 + }) + }, + + onReconnectFailed: function () { + var self = this + if (this._localTimer) { + clearInterval(this._localTimer) + this._localTimer = null + } + wx.showModal({ + title: '连接丢失', + content: '设备蓝牙连接已断开,无法恢复。本次护理数据将保存。', + showCancel: false, + complete: function () { + self.finishAsComplete() + } + }) + }, + onStop: function () { var self = this wx.showModal({ diff --git a/miniprogram/services/ble.js b/miniprogram/services/ble.js index f657c5b..cb25910 100644 --- a/miniprogram/services/ble.js +++ b/miniprogram/services/ble.js @@ -88,6 +88,8 @@ var _chars = {} var _cmdSeq = 0 var _pendingAcks = {} var _listeners = {} +var _autoReconnect = true +var _reconnecting = false function nextSeq() { _cmdSeq = (_cmdSeq + 1) % 256 @@ -456,6 +458,28 @@ function writeCommand(type, payload) { }) } +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) { @@ -507,20 +531,20 @@ function setParams(options) { return a.concat(Array.isArray(b) ? b : [b]) }, []) - return writeCommand(CMD.SET_PARAMS, payload) + return writeCommandWithRetry(CMD.SET_PARAMS, payload) } function startTreatment(regionMask) { var mask = regionMask || REGION.FULL_FACE - return writeCommand(CMD.START, [mask]) + return writeCommandWithRetry(CMD.START, [mask]) } function stopTreatment() { - return writeCommand(CMD.STOP, []) + return writeCommandWithRetry(CMD.STOP, []) } function queryStatus() { - return writeCommand(CMD.QUERY_STATUS, []) + return writeCommandWithRetry(CMD.QUERY_STATUS, []) } function bindDevice(userId, bindToken) { @@ -530,13 +554,13 @@ function bindDevice(userId, bindToken) { var tsBytes = uint32ToBytes(ts) var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes) - return writeCommand(CMD.BIND, payload) + return writeCommandWithRetry(CMD.BIND, payload) } function unbindDevice(userId) { var userBytes = hexToBytes(userId) var payload = [0x02].concat(userBytes) - return writeCommand(CMD.UNBIND, payload) + return writeCommandWithRetry(CMD.UNBIND, payload) } function stopScan() { @@ -545,6 +569,8 @@ function stopScan() { } function disconnect() { + _autoReconnect = false + _reconnecting = false if (_deviceId) { wx.closeBLEConnection({ deviceId: _deviceId }) _deviceId = null @@ -556,10 +582,83 @@ function disconnect() { 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) + } } }) @@ -605,6 +704,7 @@ module.exports = { stopScan: stopScan, connect: connect, disconnect: disconnect, + setAutoReconnect: setAutoReconnect, on: on, off: off, diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js index 58c016b..5d2ba3a 100644 --- a/miniprogram/utils/request.js +++ b/miniprogram/utils/request.js @@ -1,38 +1,95 @@ var config = require('../config/env') var API_BASE = config.API_BASE +var _refreshing = false +var _refreshQueue = [] -function httpRequest(options) { - var token = wx.getStorageSync('token') +function getTokenExpiry() { + return parseInt(wx.getStorageSync('token_expiry') || '0', 10) +} +function shouldRefresh() { + var expiry = getTokenExpiry() + if (!expiry) return false + var remaining = expiry - Math.floor(Date.now() / 1000) + return remaining > 0 && remaining < 86400 +} + +function refreshToken() { + if (_refreshing) { + return new Promise(function (resolve, reject) { + _refreshQueue.push({ resolve: resolve, reject: reject }) + }) + } + _refreshing = true return new Promise(function (resolve, reject) { + var token = wx.getStorageSync('token') wx.request({ - url: API_BASE + options.path, - method: options.method || 'GET', - data: options.data || {}, - header: Object.assign({ - 'Authorization': token ? 'Bearer ' + token : '', - 'Content-Type': 'application/json', - 'X-App-Version': '1.0.0', - 'X-Platform': 'wechat' - }, options.header || {}), + url: API_BASE + '/api/v1/auth/refresh', + method: 'POST', + header: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + data: {}, success: function (res) { - if (res.data && res.data.code === 0) { - resolve(res.data.data) - } else if (res.data && res.data.code === 1001 || res.data && res.data.code === 1002) { - wx.removeStorageSync('token') - wx.reLaunch({ url: '/pages/login/login' }) - reject(res.data) + if (res.data && res.data.code === 0 && res.data.data && res.data.data.token) { + wx.setStorageSync('token', res.data.data.token) + wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (res.data.data.expires_in || 604800))) + resolve(res.data.data.token) + _refreshQueue.forEach(function (q) { q.resolve(res.data.data.token) }) } else { - reject(res.data || { code: -1, message: '请求失败' }) + resolve(null) + _refreshQueue.forEach(function (q) { q.resolve(null) }) } + _refreshing = false + _refreshQueue = [] }, - fail: function (err) { - reject({ code: 2002, message: err.errMsg || '网络异常' }) + fail: function () { + resolve(null) + _refreshQueue.forEach(function (q) { q.resolve(null) }) + _refreshing = false + _refreshQueue = [] } }) }) } +function httpRequest(options) { + var doRequest = function () { + var token = wx.getStorageSync('token') + return new Promise(function (resolve, reject) { + wx.request({ + url: API_BASE + options.path, + method: options.method || 'GET', + data: options.data || {}, + header: Object.assign({ + 'Authorization': token ? 'Bearer ' + token : '', + 'Content-Type': 'application/json', + 'X-App-Version': '1.0.0', + 'X-Platform': 'wechat' + }, options.header || {}), + success: function (res) { + if (res.data && res.data.code === 0) { + resolve(res.data.data) + } else if (res.data && res.data.code === 1001 || res.data && res.data.code === 1002) { + wx.removeStorageSync('token') + wx.removeStorageSync('token_expiry') + wx.reLaunch({ url: '/pages/login/login' }) + reject(res.data) + } else { + reject(res.data || { code: -1, message: '请求失败' }) + } + }, + fail: function (err) { + reject({ code: 2002, message: err.errMsg || '网络异常' }) + } + }) + }) + } + + if (shouldRefresh() && options.path !== '/api/v1/auth/refresh') { + return refreshToken().then(function () { return doRequest() }) + } + return doRequest() +} + function request(options) { return httpRequest(options) } diff --git a/server/package-lock.json b/server/package-lock.json index 67d7f85..7e7db4e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "hox-scf-api", "version": "1.0.0", "dependencies": { + "bcryptjs": "^2.4.3", "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", "jsonwebtoken": "^9.0.2", @@ -124,6 +125,12 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "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", diff --git a/server/package.json b/server/package.json index aa09e28..8a4fa1a 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ "db:init": "node scripts/init-db.js" }, "dependencies": { + "bcryptjs": "^2.4.3", "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", "jsonwebtoken": "^9.0.2", diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index c51b35d..e0ea7df 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,12 +1,21 @@ const crypto = require('crypto') const jwt = require('jsonwebtoken') +const bcrypt = require('bcryptjs') const config = require('../config') const { one } = require('./db') -function hashPassword(password, salt) { +function hashPasswordLegacy(password, salt) { return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex') } +function hashPassword(password) { + return bcrypt.hashSync(password, 10) +} + +function verifyPassword(password, hash) { + return bcrypt.compareSync(password, hash) +} + function randomHex(bytes) { return crypto.randomBytes(bytes).toString('hex') } @@ -49,4 +58,4 @@ async function requireAdmin(ctx) { } } -module.exports = { hashPassword, randomHex, signUser, signAdmin, requireUser, requireAdmin } +module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer, requireUser, requireAdmin } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 885c867..72cde46 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,6 +1,6 @@ const { one, query, limitClause } = require('../lib/db') const { ok, fail } = require('../lib/response') -const { hashPassword, signAdmin, requireAdmin } = require('../lib/auth') +const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin, requireAdmin } = require('../lib/auth') const { writeLog } = require('../lib/log') function pageParams(ctx) { @@ -14,7 +14,18 @@ function register(router) { 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 || hashPassword(password, admin.password_salt) !== admin.password_hash) return fail(1001, '用户名或密码错误') + 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 = await hashPassword(password) + await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = NULL WHERE admin_id = :admin_id', { password_hash: newHash, 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 }) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 09c78c7..c1b4965 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,8 +1,10 @@ +const jwt = require('jsonwebtoken') const { one, query } = require('../lib/db') const { ok, fail } = require('../lib/response') -const { signUser } = require('../lib/auth') +const { signUser, readBearer } = require('../lib/auth') const { code2Session } = require('../lib/wechat') const { writeLog } = require('../lib/log') +const config = require('../config') function register(router) { router.post('/api/v1/auth/login', async ctx => { @@ -33,7 +35,43 @@ function register(router) { }) router.post('/api/v1/auth/refresh', async ctx => { - return fail(2001, 'refresh_token 暂未启用,请重新登录') + const token = readBearer(ctx.headers) + if (!token) 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 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') + } + } + + if (payload.type !== 'user') return 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 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 newToken = signUser(user) + return ok({ token: newToken, expires_in: 604800 }) }) } diff --git a/server/src/routes/treatment.js b/server/src/routes/treatment.js index b4c5d75..08f6c8f 100644 --- a/server/src/routes/treatment.js +++ b/server/src/routes/treatment.js @@ -55,6 +55,14 @@ function register(router) { await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip }) return ok({ record_id: sessionId }) }) + + 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