fix: improve UX and fix admin console display issues
Miniprogram: - Add back buttons to all custom-nav pages - Add debug/mock buttons (BLE connect, wear check, treatment) controlled by __DEV__ flag (hidden in prod) Admin console: - Fix log page using nonexistent fields (operator_type, target_type) now correctly reads admin_id/user_id/action/detail from API - Fix subscription date fields (started_at→start_time, expired_at→expire_time) - Wire up subscription detail/extend/renew action buttons - Wire up device detail "查看完整日志" button - Add "创建订阅" button to subscription toolbar - Fix subscription status mapping (2=expired, 3=cancelled) Docs: - Add detailed architecture docs for server, miniprogram, admin console
这个提交包含在:
@@ -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)。
|
||||
在新工单中引用
屏蔽一个用户