# 服务端架构 ## 概述 服务端是一个运行在腾讯云 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(11 张表) └── 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)。