diff --git a/admin-console/src/pages/device-detail/index.vue b/admin-console/src/pages/device-detail/index.vue
index a26e6a6..70b3833 100644
--- a/admin-console/src/pages/device-detail/index.vue
+++ b/admin-console/src/pages/device-detail/index.vue
@@ -95,7 +95,7 @@
-
+
@@ -154,6 +154,9 @@ export default {
}
})
},
+ onViewLogs() {
+ uni.redirectTo({ url: '/pages/log/index?device_id=' + this.deviceId })
+ },
onCheckUpdate() {
uni.showToast({ title: '正在检查更新...', icon: 'none' })
},
diff --git a/admin-console/src/pages/log/index.vue b/admin-console/src/pages/log/index.vue
index 1bc8239..9c0f8c2 100644
--- a/admin-console/src/pages/log/index.vue
+++ b/admin-console/src/pages/log/index.vue
@@ -15,15 +15,15 @@
-
+
{{ formatDate(item.created_at) }}
- {{ logTypeText(item.operator_type) }}
+ {{ actorLabel(item) }}
- {{ item.operator_type === 1 ? '管理员' : '系统' }}
- 对 {{ item.target_type }} {{ item.target_id || '' }}
- 执行了 {{ item.action }}
+ {{ actionLabel(item.action) }}
+ — {{ item.detail }}
+ IP: {{ item.ip }}
@@ -60,6 +60,11 @@ export default {
return Math.ceil(this.total / this.pageSize) || 1
}
},
+ onLoad(options) {
+ if (options && options.device_id) {
+ this.filters.device_id = options.device_id
+ }
+ },
onShow() {
this.loadLogs()
},
@@ -84,20 +89,24 @@ export default {
this.page = p
this.loadLogs()
},
- logTypeText(type) {
- if (type === 1) return '管理操作'
- if (type === 2) return '系统事件'
- return '用户操作'
+ actorLabel(item) {
+ if (item.admin_id) return '管理员 #' + item.admin_id
+ if (item.user_id) return '用户 #' + item.user_id
+ return '系统'
},
- logTypeBadge(type) {
- if (type === 1) return 'badge badge-pink'
- if (type === 2) return 'badge badge-blue'
- return 'badge badge-green'
+ actorBadge(item) {
+ if (item.admin_id) return 'badge badge-pink'
+ if (item.user_id) return 'badge badge-green'
+ return 'badge badge-blue'
},
- logActorClass(type) {
- if (type === 1) return 'actor-admin'
- if (type === 2) return 'actor-system'
- return 'actor-user'
+ actionLabel(action) {
+ var map = {
+ admin_login: '管理员登录', admin_device_create: '创建设备', admin_device_unbind: '后台解绑',
+ admin_device_command: '下发指令', user_register: '用户注册', user_login: '用户登录',
+ device_bind: '设备绑定', device_unbind: '设备解绑', treatment_sync: '护理记录同步',
+ subscription_verify: '订阅核销'
+ }
+ return map[action] || action
},
formatDate: formatDateUtil,
async onExport() {
@@ -170,29 +179,18 @@ export default {
color: #666;
}
-.log-actor {
- font-weight: 600;
-}
-
-.actor-admin {
- color: #E6508C;
-}
-
-.actor-system {
- color: #1890ff;
-}
-
-.actor-user {
- color: #52c41a;
-}
-
-.log-target {
- color: #333;
- font-weight: 500;
-}
-
.log-action {
color: #333;
font-weight: 500;
}
+
+.log-detail {
+ color: #999;
+}
+
+.log-meta {
+ font-size: 12px;
+ color: #bbb;
+ margin-top: 2px;
+}
diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue
index 3f0fbe3..01c6a4e 100644
--- a/admin-console/src/pages/subscription/index.vue
+++ b/admin-console/src/pages/subscription/index.vue
@@ -2,6 +2,7 @@
@@ -53,16 +54,15 @@
{{ item.nickname || item.user_id }}
{{ planText(item.plan) }}
¥{{ item.amount || '-' }}
- {{ formatDate(item.started_at) }}
- {{ formatDate(item.expired_at) }}
+ {{ formatDate(item.start_time) }}
+ {{ formatDate(item.expire_time) }}
{{ statusText(item.status) }}
- 详情
- 延期
- 开通
- 续费
+ 详情
+ 延期
+ 续费
@@ -166,16 +166,49 @@ export default {
this.creating = false
}
},
+ onDetail(item) {
+ if (item.user_id) {
+ uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + item.user_id })
+ }
+ },
+ onExtend(item) {
+ var self = this
+ uni.showModal({
+ title: '延期订阅',
+ content: '为用户 #' + item.user_id + ' 延期 30 天?',
+ success: async function (res) {
+ if (res.confirm) {
+ try {
+ await post('/api/v1/admin/subscriptions', {
+ user_id: String(item.user_id),
+ plan: item.plan || 'monthly',
+ days: 30
+ })
+ uni.showToast({ title: '延期成功', icon: 'success' })
+ self.loadSubscriptions()
+ } catch (e) {
+ uni.showToast({ title: '操作失败', icon: 'none' })
+ }
+ }
+ }
+ })
+ },
+ onRenew(item) {
+ this.createForm.user_id = String(item.user_id || '')
+ this.createForm.plan = item.plan || 'monthly'
+ 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: '已过期' }
+ const map = { 1: '生效中', 2: '已过期', 3: '已取消' }
return map[status] || '-'
},
statusBadge(status) {
- const map = { 1: 'badge badge-success', 2: 'badge badge-warning', 3: 'badge badge-error' }
+ const map = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
return map[status] || 'badge badge-default'
},
formatDate: formatDateShort,
@@ -188,7 +221,7 @@ export default {
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.started_at ? r.started_at.slice(0, 10) : '', r.expired_at ? r.expired_at.slice(0, 10) : '', statusMap[r.status] || '']
+ 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] || '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
diff --git a/docs/architecture/01-服务端架构.md b/docs/architecture/01-服务端架构.md
new file mode 100644
index 0000000..e1a31c9
--- /dev/null
+++ b/docs/architecture/01-服务端架构.md
@@ -0,0 +1,388 @@
+# 服务端架构
+
+## 概述
+
+服务端是一个运行在腾讯云 SCF(Serverless Cloud Function)上的 Node.js 应用,以 HTTP 函数形式对外暴露 RESTful API。不依赖任何 Web 框架(如 Express/Koa),所有路由和请求处理均为手写实现。
+
+**技术栈:** Node.js + mysql2 + jsonwebtoken + bcryptjs + cos-nodejs-sdk-v5
+
+---
+
+## 目录结构
+
+```
+server/
+├── index.js # 入口:re-export src/index.js
+├── scf_bootstrap # SCF Web 函数冷启动脚本
+├── package.json
+├── scripts/
+│ ├── init-db.js # 数据库初始化(建表 + 种子数据)
+│ └── local-server.js # 本地开发 HTTP 服务器
+├── sql/
+│ └── schema.sql # 完整 DDL(10 张表)
+└── src/
+ ├── index.js # SCF 入口:导出 main_handler(event, context)
+ ├── app.js # 请求分发:解析 → 路由匹配 → 执行 → 响应
+ ├── config.js # 环境变量集中配置
+ ├── lib/
+ │ ├── auth.js # JWT 签发/验证、bcrypt 密码哈希
+ │ ├── cos.js # 腾讯云 COS 客户端(预签名 URL)
+ │ ├── db.js # MySQL 连接池、query/one/transaction
+ │ ├── log.js # 操作日志写入
+ │ ├── request.js # SCF event → ctx 对象解析
+ │ ├── response.js # ok/fail/http 响应构造
+ │ ├── router.js # 轻量正则路由器
+ │ ├── utils.js # 日期格式化工具
+ │ └── wechat.js # 微信小程序 API(code2Session、手机号)
+ └── routes/
+ ├── admin.js # 管理后台 CRUD
+ ├── auth.js # 微信登录 + token 刷新
+ ├── device.js # 设备绑定/解绑/命令/事件
+ ├── firmware.js # 固件管理 + OTA 检查
+ ├── subscription.js # 订阅状态/购买/核销
+ ├── treatment.js # 护理记录同步/查询
+ └── user.js # 用户信息 CRUD
+```
+
+---
+
+## 请求生命周期
+
+一个请求从进入到返回的完整流程:
+
+```
+SCF 触发 / HTTP 请求
+ │
+ ▼
+scf_bootstrap (PORT=9000, node scripts/local-server.js)
+ │
+ ▼
+local-server.js 收集请求体,构造 API Gateway event 对象
+ │
+ ▼
+src/index.js → main_handler(event, context)
+ │
+ ▼
+src/app.js → handle(event)
+ │
+ ├── createContext(event) ← 解析 method/path/headers/query/body/ip
+ ├── OPTIONS ? → 直接返回 204(CORS 预检)
+ ├── /health ? → 返回 { status: 'ok' }
+ ├── router.match(method, path)
+ │ ├── 无匹配 → 404 not_found
+ │ └── 有匹配 → 提取 URL 参数,调用 handler(ctx)
+ │ ├── handler 返回值 → http(200, body)
+ │ └── handler 抛异常 → http(500, fail(3001, 'server_error'))
+ │
+ ▼
+SCF API Gateway 响应格式:
+{
+ isBase64Encoded: false,
+ statusCode: 200,
+ headers: { 'Access-Control-Allow-Origin': '*', ... },
+ body: '{"code":0,"message":"success","data":{...}}'
+}
+```
+
+### 关键设计点
+
+- **无中间件链**:没有 Koa/Express 那样的中间件栈。鉴权在每个路由处理函数开头手动调用 `requireUser(ctx)` 或 `requireAdmin(ctx)`。
+- **CORS**:所有响应都带 `Access-Control-Allow-Origin: *`,允许 `Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform` 请求头。
+- **路由器**:自研轻量实现,把 `/api/v1/device/:device_id` 这样的路径转换成正则表达式,匹配时提取参数写入 `ctx.params`。
+
+---
+
+## 配置结构
+
+所有配置通过 `dotenv` 从 `.env` 文件加载,集中定义在 `src/config.js`:
+
+| 配置项 | 环境变量 | 默认值 | 说明 |
+|--------|---------|--------|------|
+| `db.host` | `DB_HOST` | - | MySQL 地址 |
+| `db.port` | `DB_PORT` | 3306 | MySQL 端口 |
+| `db.user` | `DB_USER` | root | |
+| `db.password` | `DB_PASSWORD` | - | |
+| `db.database` | `DB_NAME` | jw_beauty | |
+| `jwt.secret` | `JWT_SECRET` | dev-user-secret | 用户 token 签名密钥 |
+| `jwt.adminSecret` | `ADMIN_JWT_SECRET` | dev-admin-secret | 管理员 token 签名密钥 |
+| `jwt.expiresIn` | - | 7d | token 有效期 |
+| `cos.secretId` | `TENCENT_SECRET_ID` | - | COS 密钥 |
+| `cos.secretKey` | `TENCENT_SECRET_KEY` | - | COS 密钥 |
+| `cos.bucket` | `COS_BUCKET` | jw-bucket-1426323813 | |
+| `cos.region` | `COS_REGION` | ap-guangzhou | |
+| `wechat.appid` | `WECHAT_APPID` | - | 小程序 AppID |
+| `wechat.secret` | `WECHAT_SECRET` | - | 小程序密钥 |
+
+**生产环境保护**:如果 `NODE_ENV=production` 且 JWT 密钥仍为默认值,进程启动时会直接抛异常,防止带着测试密钥上线。
+
+---
+
+## 数据库层
+
+### 连接方式
+
+使用 `mysql2/promise`,惰性初始化单例连接池:
+
+- 连接数上限:5
+- 命名占位符:`:param_name`(通过 `namedPlaceholders: true` 启用)
+- 时区:`+08:00`
+
+### 查询工具
+
+| 函数 | 说明 |
+|------|------|
+| `query(sql, params)` | 执行查询,返回行数组 |
+| `one(sql, params)` | 执行查询,返回第一行或 `null` |
+| `transaction(work)` | 获取连接 → BEGIN → 执行 work(conn) → COMMIT/ROLLBACK → 释放 |
+| `limitClause(pageSize, offset)` | 返回 ` LIMIT N OFFSET M` 字符串片段 |
+
+### 数据表一览
+
+共 10 张表,全部 InnoDB + utf8mb4_unicode_ci:
+
+| 表名 | 用途 | 主键 |
+|------|------|------|
+| `users` | 小程序用户 | user_id (自增) |
+| `devices` | 设备信息 | device_id (字符串) |
+| `bindings` | 用户-设备绑定关系 | binding_id (自增) |
+| `subscriptions` | 用户订阅 | subscription_id (自增) |
+| `treatment_records` | 护理记录 | record_id (自增),session_id 唯一索引 |
+| `device_events` | 设备事件日志 | event_id (自增) |
+| `device_commands` | 远程指令队列 | command_id (自增) |
+| `operation_logs` | 操作审计日志 | log_id (自增) |
+| `admin_accounts` | 管理员账号 | admin_id (自增) |
+| `system_settings` | 系统配置键值对 | setting_key (字符串) |
+| `firmware_files` | 固件文件记录 | firmware_id (自增) |
+
+### 核心表详细结构
+
+**users**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| user_id | BIGINT UNSIGNED AUTO_INCREMENT | 主键 |
+| openid | VARCHAR(64) UNIQUE | 微信 openid |
+| nickname | VARCHAR(100) | 昵称 |
+| avatar | VARCHAR(500) | 头像 URL |
+| phone | VARCHAR(32) | 手机号 |
+| gender | TINYINT | 0=未知 |
+| status | TINYINT | 1=正常 |
+
+**devices**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| device_id | VARCHAR(32) | 主键,设备编号 |
+| product_id | VARCHAR(64) | 产品型号,默认 HOX_LIGHT_MASK |
+| device_secret | VARCHAR(128) | 设备密钥 |
+| firmware_version | VARCHAR(32) | 固件版本 |
+| status | TINYINT | 1=未激活 2=在线 3=离线 4=禁用 |
+| battery | TINYINT UNSIGNED | 电量 |
+| temperature | TINYINT UNSIGNED | 温度 |
+| last_online_at | DATETIME | 最后在线时间 |
+
+**bindings**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| binding_id | BIGINT AUTO_INCREMENT | 主键 |
+| user_id | BIGINT | 外键 → users |
+| device_id | VARCHAR(32) | 外键 → devices |
+| bind_token | CHAR(16) | 绑定令牌(16位 hex) |
+| bind_expires | DATETIME | 令牌过期时间(10 分钟) |
+| bind_status | TINYINT | 1=已绑定 2=已解绑 3=待确认 |
+
+**subscriptions**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| subscription_id | BIGINT AUTO_INCREMENT | 主键 |
+| user_id | BIGINT | 外键 → users |
+| plan | VARCHAR(32) | trial / monthly / yearly |
+| status | TINYINT | 1=生效 2=过期 3=取消 |
+| amount | DECIMAL(10,2) | 金额 |
+| start_time / expire_time | DATETIME | 有效期 |
+
+**treatment_records**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| record_id | BIGINT AUTO_INCREMENT | 主键 |
+| session_id | VARCHAR(64) UNIQUE | 会话 ID(唯一标识一次护理) |
+| device_id / user_id | | 设备和用户 |
+| regions | VARCHAR(255) | 护理区域(逗号分隔或位掩码) |
+| total_duration_ms | INT UNSIGNED | 护理时长(毫秒) |
+| mode | TINYINT | 0=普通 1=智能 |
+| avg_pd | DECIMAL(8,4) | 平均光密度 |
+| pd_json | JSON | 详细光密度数据 |
+
+---
+
+## 鉴权系统
+
+### 双密钥 JWT
+
+系统使用两套独立的 JWT 密钥:
+
+```
+用户 token: JWT_SECRET → type: 'user', payload: { user_id, openid }
+管理员 token: ADMIN_JWT_SECRET → type: 'admin', payload: { admin_id, username, role }
+```
+
+两种 token 都是 7 天有效期。
+
+### 用户登录流程
+
+```
+小程序 wx.login() → code
+ │
+ ▼
+POST /api/v1/auth/login { code }
+ │
+ ├── server 调用微信 jscode2session 换取 openid
+ ├── 查询/自动创建 users 记录
+ ├── 签发 JWT(含 user_id, openid)
+ └── 返回 { token, user_id, user_info, expires_in: 604800 }
+```
+
+### 管理员登录流程
+
+```
+POST /api/v1/admin/login { username, password }
+ │
+ ├── 查询 admin_accounts 表
+ ├── bcrypt 验证密码
+ │ └── 失败 → 尝试旧 SHA-256 验证
+ │ └── 成功 → 自动迁移密码到 bcrypt
+ ├── 签发 JWT(含 admin_id, username, role)
+ └── 返回 { token, admin_id, username, real_name, role }
+```
+
+### Token 刷新
+
+`POST /api/v1/auth/refresh`:
+
+- token 未过期且剩余 > 7 天 → 原样返回
+- token 未过期且剩余 ≤ 7 天 → 签发新 token
+- token 已过期但在 3 天宽限期内 → 签发新 token
+- token 已过期超过 3 天 → 拒绝,需重新登录
+
+---
+
+## API 接口清单
+
+### 认证与用户
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| POST | `/api/v1/auth/login` | 无 | 微信登录,返回 JWT |
+| POST | `/api/v1/auth/refresh` | Bearer (可过期) | 刷新 token |
+| GET | `/api/v1/user/profile` | 用户 | 获取个人信息 |
+| PUT | `/api/v1/user/profile` | 用户 | 修改昵称/头像/性别 |
+| POST | `/api/v1/user/phone` | 用户 | 绑定手机号(微信授权) |
+
+### 设备
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| POST | `/api/v1/device/bind` | 用户 | 发起绑定(生成 bind_token,10分钟有效) |
+| POST | `/api/v1/device/bind/confirm` | 用户 | 确认绑定(BLE 握手完成后调用) |
+| POST | `/api/v1/device/unbind` | 用户 | 解绑设备 |
+| GET | `/api/v1/device/list` | 用户 | 已绑定设备列表 |
+| GET | `/api/v1/device/:device_id` | 用户 | 设备详情(校验归属) |
+| GET | `/api/v1/device/command/pending` | 用户 | 拉取待执行远程指令 |
+| POST | `/api/v1/device/command/result` | 用户 | 上报指令执行结果 |
+| POST | `/api/v1/device/event` | 用户 | 上报设备事件 |
+
+### 订阅
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| GET | `/api/v1/subscription` | 用户 | 查询当前订阅状态 |
+| POST | `/api/v1/subscription/purchase` | 用户 | 购买(未接入支付,仅生成订单号) |
+| POST | `/api/v1/subscription/verify` | 管理员 | 后台核销订阅(临时方案) |
+
+### 护理记录
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| GET | `/api/v1/treatment/history` | 用户 | 分页查询历史记录 |
+| POST | `/api/v1/treatment/sync` | 用户 | 同步护理记录(INSERT ON DUPLICATE) |
+| GET | `/api/v1/treatment/:record_id` | 用户 | 单条记录详情(按 session_id 查) |
+
+### 固件
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| GET | `/api/v1/firmware/latest` | 用户 | OTA 更新检查 |
+| GET | `/api/v1/admin/firmware` | 管理员 | 固件列表 |
+| POST | `/api/v1/admin/firmware` | 管理员 | 上传固件记录 |
+| POST | `/api/v1/admin/firmware/:id/status` | 管理员 | 启用/禁用固件 |
+
+### 管理后台
+
+| 方法 | 路径 | 鉴权 | 说明 |
+|------|------|------|------|
+| POST | `/api/v1/admin/login` | 无 | 管理员登录 |
+| GET | `/api/v1/admin/dashboard` | 管理员 | 仪表盘统计 |
+| GET/POST | `/api/v1/admin/devices` | 管理员 | 设备列表/预生成产品码 |
+| GET | `/api/v1/admin/devices/:id` | 管理员 | 设备详情 |
+| POST | `/api/v1/admin/devices/:id/unbind` | 管理员 | 强制解绑 |
+| POST | `/api/v1/admin/devices/:id/command` | 管理员 | 下发远程指令 |
+| GET | `/api/v1/admin/devices/:id/commands` | 管理员 | 指令历史 |
+| GET | `/api/v1/admin/users` | 管理员 | 用户列表 |
+| GET | `/api/v1/admin/users/:id` | 管理员 | 用户详情(含设备和最近护理) |
+| GET/POST | `/api/v1/admin/subscriptions` | 管理员 | 订阅列表/创建订阅 |
+| GET | `/api/v1/admin/records` | 管理员 | 所有护理记录 |
+| GET | `/api/v1/admin/logs` | 管理员 | 操作日志 |
+| GET/POST | `/api/v1/admin/settings` | 管理员 | 系统设置读写 |
+
+---
+
+## 外部服务集成
+
+### 微信小程序 API
+
+| 接口 | 用途 |
+|------|------|
+| `jscode2session` | 用 code 换取 openid(登录) |
+| `cgi-bin/token` | 获取 access_token(缓存在内存中,带 60s 安全边际) |
+| `getuserphonenumber` | 通过授权码获取用户手机号 |
+
+开发环境绕过:如果 `NODE_ENV=development` 且 code 为空或以 `dev_` 开头,返回合成的 openid,不调用微信。
+
+### 腾讯云 COS
+
+用于固件文件存储。`getObjectUrl(key, expiresSeconds)` 生成预签名下载 URL,默认 3600 秒有效。
+
+---
+
+## 错误码约定
+
+所有响应格式为 `{ code, message, data }`:
+
+| code | 含义 |
+|------|------|
+| 0 | 成功 |
+| 404 | 路由不存在 |
+| 1001 | 鉴权失败(token 无效/过期) |
+| 1002 | 管理员权限不足 |
+| 1004 | 用户不存在 |
+| 1005 | 资源不存在(设备/记录) |
+| 1006 | 设备未绑定 |
+| 2001 | 参数校验失败 |
+| 3001 | 服务器内部错误 |
+
+---
+
+## 部署方式
+
+```
+腾讯云 SCF(Web 函数)
+ ├── 入口:scf_bootstrap → node scripts/local-server.js
+ ├── 端口:9000
+ ├── 运行时:Node.js
+ └── 触发方式:API Gateway HTTP 触发
+```
+
+本地开发:`npm start` → `node scripts/local-server.js` → 监听 `config.port`(默认 3000)。
diff --git a/docs/architecture/02-小程序架构.md b/docs/architecture/02-小程序架构.md
new file mode 100644
index 0000000..e53858a
--- /dev/null
+++ b/docs/architecture/02-小程序架构.md
@@ -0,0 +1,423 @@
+# 小程序架构
+
+## 概述
+
+微信原生小程序(非 uni-app),使用 WXML + WXSS + JS 开发。通过 BLE 5.0 与光子美容仪设备通信,通过 HTTP 与腾讯云 SCF 后端通信。
+
+**AppID:** wxc4045074ef298510
+**项目名:** hox-beauty
+**基础库:** 3.15.2
+
+---
+
+## 目录结构
+
+```
+miniprogram/
+├── app.js / app.json / app.wxss # 应用入口
+├── config/
+│ └── env.js # 环境配置(API 地址)
+├── pages/
+│ ├── index/ # 首页(Tab)—— 设备状态、开始护理
+│ ├── history/ # 记录(Tab)—— 护理历史列表
+│ ├── profile/ # 我的(Tab)—— 个人中心
+│ ├── login/ # 微信登录
+│ ├── scan/ # 扫码绑定设备
+│ ├── ble-connect/ # 蓝牙连接 + BLE 绑定
+│ ├── bind-success/ # 绑定成功
+│ ├── wear-check/ # 佩戴检测
+│ ├── treatment-setup/# 护理模式选择
+│ ├── auto-scan/ # 智能模式面部扫描
+│ ├── treating/ # 护理进行中
+│ ├── treatment-done/ # 护理完成
+│ ├── subscribe-prompt/ # 订阅引导
+│ ├── subscribe-plans/ # 订阅套餐选择
+│ └── subscribe-success/ # 订阅成功
+├── services/
+│ ├── ble.js # BLE 通信协议核心
+│ └── command-sync.js # 远程指令拉取与执行
+├── utils/
+│ ├── request.js # HTTP 请求封装 + token 刷新
+│ └── mock.js # 开发用 mock(已禁用)
+├── scripts/
+│ └── test-ble-frame.js # BLE 帧构建/解析单元测试
+├── project.config.json
+└── sitemap.json
+```
+
+共 15 个页面,无自定义组件,全部使用微信原生组件。
+
+---
+
+## 应用生命周期
+
+### app.js
+
+**globalData:**
+
+| 字段 | 用途 |
+|------|------|
+| `userInfo` | 用户信息对象(来自 `/api/v1/user/profile`) |
+| `userId` | 用户 ID 字符串 |
+| `connectedDevice` / `currentDevice` | 当前绑定的设备信息 |
+| `currentTreatment` | 最近一次护理结果(跨页传递) |
+| `statusBarHeight` | 系统状态栏高度(默认 44) |
+
+**onLaunch 流程:**
+
+1. `wx.getSystemInfoSync()` → 读取 `statusBarHeight`
+2. `checkLogin()` → 从 Storage 读取 token
+ - 有 token → 调用 `loadProfile()` 加载用户信息
+ - 无 token → 设置 `userInfo = null`(各页面自行跳转登录)
+
+---
+
+## 页面导航流程
+
+### 主要业务路径
+
+```
+ ┌─────────┐
+ │ login │ ← 无 token 时跳转
+ └────┬────┘
+ │ wx.switchTab
+ ▼
+ ┌──────────────────────┐
+ │ index(首页 Tab) │
+ │ 设备状态 / 订阅状态 │
+ └──────┬───────────────┘
+ │
+ ┌──────────┼──────────┐
+ │ 无设备 │ 有设备 │ 有设备但无订阅
+ ▼ ▼ ▼
+ ┌──────┐ ┌────────┐ ┌─────────────────┐
+ │ scan │ │ wear- │ │subscribe-prompt │
+ │ 扫码 │ │ check │ │ 订阅引导 │
+ └──┬───┘ └───┬────┘ └───────┬─────────┘
+ │ │ │
+ ▼ ▼ ▼
+ ┌───────────┐ ┌──────────────┐ ┌─────────────────┐
+ │ble-connect│ │treatment- │ │subscribe-plans │
+ │ 蓝牙配对 │ │setup 模式选择│ │ 套餐选择 │
+ └─────┬─────┘ └──────┬───────┘ └─────────────────┘
+ │ │
+ ▼ ┌────┴────┐
+ ┌───────────┐ │ │
+ │bind- │ 普通模式 智能模式
+ │success │ │ │
+ └───────────┘ │ ┌────┴─────┐
+ │ │auto-scan │
+ │ │ 面部扫描 │
+ │ └────┬─────┘
+ │ │
+ ▼ ▼
+ ┌──────────────────┐
+ │ treating │
+ │ 护理进行中 │
+ └────────┬─────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ treatment-done │
+ │ 护理完成 │
+ └──────────────────┘
+```
+
+### Tab 页
+
+| Tab | 页面 | 说明 |
+|-----|------|------|
+| 首页 | index | 设备连接状态、开始护理入口 |
+| 记录 | history | 护理历史列表(分页、下拉刷新) |
+| 我的 | profile | 个人信息、订阅状态、设置 |
+
+### 导航方式
+
+- Tab 间切换:`wx.switchTab`
+- 业务流程前进:`wx.navigateTo`(压栈)或 `wx.redirectTo`(替换)
+- 强制跳转:`wx.reLaunch`(清空页面栈,用于登录跳转)
+
+---
+
+## 页面详情
+
+### login — 微信登录
+
+- 调用 `wx.getUserProfile()` 获取头像昵称(2022.10 后始终返回「微信用户」和灰色头像)
+- 调用 `app.doLogin()` → `wx.login()` 获取 code → `POST /api/v1/auth/login`
+- 登录后保存 `token` 和 `token_expiry` 到 Storage
+- 成功后 `wx.switchTab` 到首页
+
+### index — 首页
+
+- `GET /api/v1/device/list` 获取绑定设备列表
+- `GET /api/v1/subscription` 获取订阅状态
+- BLE:订阅 `ble.on('status')` 更新电量和设备状态
+- 连接设备后自动调用 `ble.queryStatus()`
+- 解绑操作:`ble.disconnect()` + `POST /api/v1/device/unbind`
+
+### scan — 扫码绑定
+
+- `wx.scanCode({ onlyFromCamera: true, scanType: ['qrCode'] })` 扫描设备二维码
+- 支持手动输入 16 位 hex 设备号
+- `POST /api/v1/device/bind` → 获取 `bind_token`
+- 跳转到 `ble-connect` 页面
+
+### ble-connect — 蓝牙连接
+
+- 状态机:`scanning → connecting → binding → done / error`
+- BLE 扫描过滤名称含 `HOX` 或 `LIGHTMASK` 的设备
+- 连接后发送 BIND 命令,等待 `bind_result` 通知
+- 成功后 `POST /api/v1/device/bind/confirm`
+
+### wear-check — 佩戴检测
+
+- 通过 `ble.queryStatus()` 检查设备 `bind_status === 1`
+- 确认佩戴后进入模式选择
+
+### treatment-setup — 模式选择
+
+- 普通模式(mode=0):全脸、10 分钟固定
+- 智能模式(mode=1):需要有效订阅,可选区域
+- 区域位掩码:左脸(0x01) 右脸(0x02) 额头(0x04) 下巴(0x08) 鼻部(0x10)
+- 普通模式直接发 `setParams` + `startTreatment` 开始护理
+- 智能模式先跳到 auto-scan 扫描
+
+### treating — 护理中
+
+- 订阅 6 个 BLE 事件:`status`, `treatment_complete`, `exception`, `disconnected`, `reconnected`, `reconnect_failed`
+- 本地 1 秒定时器作为倒计时后备
+- 接收设备 `status` 报告校正剩余时间
+- 每次 status 更新触发 `commandSync.sync(deviceId)` 拉取远程指令
+- 断连时暂停计时器,重连后恢复
+- 护理结束触发条件:设备完成通知 / 本地定时器归零 / 手动停止 / 重连失败
+
+### treatment-done — 护理完成
+
+- `POST /api/v1/treatment/sync` 同步记录到服务器
+- 展示护理时长、区域、模式等摘要
+
+### history — 护理记录
+
+- `GET /api/v1/treatment/history?page=N&page_size=20` 分页加载
+- 支持下拉刷新和触底加载
+- 累计统计:总次数、总时长、本月次数
+
+### profile — 我的
+
+- `GET /api/v1/user/profile` + `GET /api/v1/subscription`
+- 菜单入口:设备管理、护理记录、订阅管理、使用帮助、联系我们
+- 退出登录:清除 token → `wx.reLaunch` 到 login
+
+---
+
+## BLE 通信协议
+
+### GATT 服务和特征值
+
+| 服务 UUID | 名称 | 特征值 |
+|-----------|------|--------|
+| FFE0 | 设备信息服务 | FFE3 (设备信息 - 读) |
+| FFE1 | 数据通信服务 | FFE4 (命令 - 写), FFE5 (状态 - 通知), FFE6 (绑定信息) |
+| FFE2 | OTA 服务 | FFE7 (OTA控制), FFE8 (OTA数据), FFE9 (OTA状态) |
+
+### 帧格式
+
+```
+┌──────┬──────┬────────┬──────┬─────────────────┬──────────┐
+│ 0xAA │ 0x55 │ Length │ Type │ Payload │ Checksum │
+│ 1B │ 1B │ 1B │ 1B │ Length bytes │ 1B (XOR) │
+└──────┴──────┴────────┴──────┴─────────────────┴──────────┘
+```
+
+- **Header**:固定 `0xAA 0x55`
+- **Length**:Payload 字节数
+- **Type**:命令/通知类型
+- **Checksum**:前面所有字节的 XOR 值
+
+### 命令类型(小程序 → 设备)
+
+| 类型 | 值 | Payload |
+|------|----|---------|
+| SET_PARAMS | 0x01 | region_mask, wavelength, brightness, duration_ms(4B大端), mode, seq |
+| START | 0x02 | region_mask, seq |
+| STOP | 0x03 | seq |
+| QUERY_STATUS | 0x04 | seq |
+| BIND | 0x05 | 0x01, userId(8B), bindToken(8B), timestamp(4B大端), seq |
+| UNBIND | 0x06 | 0x02, userId(8B), seq |
+
+每条命令的 payload 末尾都有 `seq` 字节(0-255 循环),用于匹配 ACK 响应。
+
+### 通知类型(设备 → 小程序)
+
+| 类型 | 值 | Payload |
+|------|----|---------|
+| STATUS_REPORT | 0x21 | mode_state, region_mask, wavelength, brightness, remaining_ms(4B), error_code, command_seq, battery, temperature, bind_status, subscription — 共 14 字节 |
+| ACK | 0x22 | seq, error_code |
+| TREATMENT_COMPLETE | 0x31 | session_id(8B), regions, total_duration_ms(4B), avg_pd |
+| EXCEPTION | 0x32 | error_code, temperature |
+| BIND_SUCCESS | 0x33 | result (0x00=成功) |
+
+### 命令重试机制
+
+`writeCommandWithRetry(type, payload, maxRetries=3)`:
+
+- 调用 `writeCommand` 发送命令
+- 失败时等待 500ms 后重试
+- 最多重试 3 次
+- 如果错误码为 `0x0C`(BLE 断连),立即失败不重试
+- ACK 超时时间:5 秒
+
+### 自动重连机制
+
+- 检测到 BLE 断连时自动触发
+- 等待 2 秒后第一次重连尝试
+- 最多 3 次,间隔 2 秒
+- 重连成功后重新发现服务、重新订阅特征值通知
+- 手动调用 `disconnect()` 时禁用自动重连
+
+### 设备状态机
+
+| 值 | 状态 | 说明 |
+|----|------|------|
+| 0x00 | IDLE | 空闲 |
+| 0x01 | SCANNING | 面部扫描中 |
+| 0x02 | ACTIVE | 护理中 |
+| 0x03 | PAUSED | 已暂停 |
+| 0x04 | COMPLETED | 已完成 |
+| 0x05 | ERROR | 异常 |
+| 0x06 | OTA | 固件升级中 |
+
+### 区域位掩码
+
+| 位 | 值 | 区域 |
+|----|----|------|
+| 0 | 0x01 | 左脸颊 |
+| 1 | 0x02 | 右脸颊 |
+| 2 | 0x04 | 额头 |
+| 3 | 0x08 | 下巴 |
+| 4 | 0x10 | 鼻部 |
+| 5 | 0x20 | 左眼周 |
+| 6 | 0x40 | 右眼周 |
+| 全部 | 0x7F | 全脸 |
+
+### 设备错误码
+
+| 码 | 名称 | 说明 |
+|----|------|------|
+| 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 | OTA 失败 |
+| 0x0C | ERR_BLE_DISCONNECTED | 蓝牙断连 |
+
+---
+
+## 远程指令同步(command-sync.js)
+
+服务端可以通过管理后台向设备下发远程指令,小程序在护理过程中定期拉取并执行:
+
+```
+commandSync.sync(deviceId)
+ │
+ ├── GET /api/v1/device/command/pending?device_id=xxx
+ │ └── 返回待执行命令列表(status=1 → 标记为 status=2)
+ │
+ ├── 逐条执行 BLE 命令(按 opcode 分发到对应函数)
+ │
+ └── POST /api/v1/device/command/result
+ └── 上报执行结果(成功/失败)
+```
+
+---
+
+## HTTP 请求层(request.js)
+
+### Token 自动刷新
+
+- 每次请求前检查 `token_expiry`:如果距离过期不到 24 小时,先调用 `POST /api/v1/auth/refresh` 刷新
+- 并发请求的刷新去重:使用 `_refreshing` 标志和 `_refreshQueue` 队列,保证同一时间只有一个刷新请求
+- 刷新成功后更新 Storage 中的 `token` 和 `token_expiry`
+
+### 鉴权失败处理
+
+- 响应 code 为 1001 或 1002 时:清除 token → `wx.reLaunch` 到 login 页
+
+### 请求头
+
+```
+Authorization: Bearer
+Content-Type: application/json
+X-App-Version: 1.0.0
+X-Platform: wechat
+```
+
+---
+
+## 环境配置
+
+| 环境 | API 地址 |
+|------|---------|
+| local | `http://localhost:3000` |
+| test | `https://1426323813-ilxkhlxf4p.ap-guangzhou.tencentscf.com` |
+| prod | 待配置(发布阻断项) |
+
+当前设置:`ENV = 'test'`
+
+---
+
+## UI 模式
+
+### 自定义导航栏
+
+所有非 Tab 页使用 `"navigationStyle": "custom"`,通过 `statusBarHeight + 24px` 的 padding-top 避开系统状态栏:
+
+```html
+