feat: implement P0 security and reliability improvements

- bcrypt password hashing with auto-migration from SHA-256
- BLE command retry (3 attempts, 500ms delay, skip on disconnect)
- BLE auto-reconnect with service re-discovery on disconnect
- Treatment page disconnect/reconnect event handling
- Token refresh endpoint with 3-day grace period
- Client-side token auto-refresh when <24h remaining
- Single treatment record detail API with ownership check
这个提交包含在:
Guoguo
2026-04-28 18:12:30 -07:00
父节点 88adee7743
当前提交 91d5937d8e
修改 10 个文件,包含 307 行新增32 行删除
+1
查看文件
@@ -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
+43
查看文件
@@ -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({
+106 -6
查看文件
@@ -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,
+77 -20
查看文件
@@ -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)
}