文件
jw-beauty/docs/architecture/01-服务端架构.md
T
Guoguo c5f6033ccf 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
2026-04-28 18:32:26 -07:00

389 行
14 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 服务端架构
## 概述
服务端是一个运行在腾讯云 SCFServerless 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 # 完整 DDL10 张表)
└── 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 # 微信小程序 APIcode2Session、手机号)
└── 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 | 服务器内部错误 |
---
## 部署方式
```
腾讯云 SCFWeb 函数)
├── 入口:scf_bootstrap → node scripts/local-server.js
├── 端口:9000
├── 运行时:Node.js
└── 触发方式:API Gateway HTTP 触发
```
本地开发:`npm start``node scripts/local-server.js` → 监听 `config.port`(默认 3000)。