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
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户