# 服务端架构

## 概述

服务端是一个运行在腾讯云 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）。
