diff --git a/DEBUG_GUIDE.md b/DEBUG_GUIDE.md index cef91b5..852d822 100644 --- a/DEBUG_GUIDE.md +++ b/DEBUG_GUIDE.md @@ -177,7 +177,7 @@ cli auto-preview \ --info-output /tmp/teaching-feedback-preview.json ``` -开发者工具模拟器默认访问 `http://127.0.0.1:8080`。进入小程序“我的”页面,应看到“服务、数据库和语音转录正常”。 +开发者工具模拟器默认访问生产服务 `https://feedback.shay7sev.site`。调试本节启动的本地 API 时,先在小程序“我的”页面保存 `http://127.0.0.1:8080`,应看到“服务、数据库和语音转录正常”。 ## 5. 录音测试流程 @@ -253,7 +253,7 @@ lsof -nP -iTCP:8080 -sTCP:LISTEN curl http://127.0.0.1:8080/health ``` -再到小程序“我的”页面确认 API 地址为 `http://127.0.0.1:8080`。 +再到小程序“我的”页面将 API 地址保存为 `http://127.0.0.1:8080`。 ### 真机无法访问 `127.0.0.1` diff --git a/README.md b/README.md index 9d7b6e2..2567518 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cli open --project /Users/zhangheng/project/ai/miniprogram/teaching-feedback-ass 反馈生成页使用小程序原生录音能力。短录音会直接追加到反馈内容,长录音每 8 分钟自动切片,多节录音可以汇总为一次反馈。真机首次使用时需要允许麦克风权限;中文转录默认使用项目自带的本地 FunASR 服务,不要求腾讯云密钥。 -小程序默认连接 `http://127.0.0.1:8080`。在开发者工具模拟器中,可进入“我的”页面保存其他 API 地址并检测服务状态。 +小程序默认连接生产服务 `https://feedback.shay7sev.site`。旧版本缓存的默认本地地址会自动迁移;如需调试本地 API,可在“我的”页面保存 `http://127.0.0.1:8080` 并检测服务状态。 ## 验证 @@ -61,6 +61,6 @@ curl http://127.0.0.1:10095/health - 学生档案、档案预设和反馈记录均通过 `utils/api.ts` 访问 Rust API。 - 反馈页只暴露一个语音入口;反馈批次、课节、录音片段和转录状态由后端自动维护。 -- 开发者工具模拟器使用默认地址 `http://127.0.0.1:8080`。 -- 真机和正式版本不能把 `127.0.0.1` 当作电脑地址;应部署可公网访问的 HTTPS API,并在微信公众平台配置 request 合法域名,然后到小程序“我的”页面修改服务地址。 -- 微信登录尚未接入。业务请求暂时使用固定开发用户 UUID;生产发布前必须改为由后端根据微信登录凭据解析用户身份。 +- 开发者工具模拟器和真机默认使用 `https://feedback.shay7sev.site`。 +- 微信公众平台需要将该 HTTPS 域名同时配置为 `request` 和 `uploadFile` 合法域名。 +- 微信登录尚未接入。业务请求暂时使用固定开发用户 UUID;生产发布前按 [WECHAT_AUTH_PLAN.md](./WECHAT_AUTH_PLAN.md) 改为由后端根据微信登录凭据解析用户身份。 diff --git a/WECHAT_AUTH_PLAN.md b/WECHAT_AUTH_PLAN.md new file mode 100644 index 0000000..3d15aa3 --- /dev/null +++ b/WECHAT_AUTH_PLAN.md @@ -0,0 +1,162 @@ +# 微信登录与教师身份实施方案 + +本文用于把当前客户端可伪造的固定 `X-User-Id`,替换为“微信身份换取后端会话”的正式登录体系。完成前不应公开发布小程序。 + +## 目标流程 + +```text +小程序调用 wx.login() + -> 将一次性 code 发送到 Rust POST /api/v1/auth/wechat/login + -> Rust 使用服务端 AppID、AppSecret 调用微信 code2Session + -> 微信返回 openid(unionid 可选) + -> Rust 查找或创建内部教师 UUID + -> Rust 签发项目自己的随机会话令牌 + -> 小程序以 Authorization: Bearer 调用普通接口和上传录音 +``` + +微信官方入口: + +- [`wx.login`](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html) +- [`code2Session`](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html) +- [小程序登录流程](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html) + +`WECHAT_APP_SECRET` 只能保存在 Rust 服务环境变量中。小程序不得包含 AppSecret,也不得接收或保存微信 `session_key`。当前阶段只需要 `openid` 识别当前小程序中的教师。 + +## 数据模型 + +新增三张表: + +```text +users + id uuid primary key + status text not null + created_at timestamptz not null + updated_at timestamptz not null + +wechat_identities + app_id text not null + openid text not null + unionid text null + user_id uuid not null references users(id) + created_at timestamptz not null + last_login_at timestamptz not null + primary key (app_id, openid) + +auth_sessions + id uuid primary key + user_id uuid not null references users(id) + token_hash bytea not null unique + expires_at timestamptz not null + last_used_at timestamptz not null + revoked_at timestamptz null + created_at timestamptz not null +``` + +业务表继续使用现有 `owner_id` UUID,不直接使用 `openid`。这样微信身份变化、未来增加手机号或管理员账号时,不需要重写业务数据。 + +## 后端接口 + +### `POST /api/v1/auth/wechat/login` + +请求: + +```json +{ "code": "wx.login 返回的一次性 code" } +``` + +处理要求: + +1. 校验 code 非空并限制长度和登录频率。 +2. Rust 使用 `WECHAT_APP_ID`、`WECHAT_APP_SECRET` 调用 `code2Session`。 +3. 微信返回错误码、缺少 `openid` 或网络失败时返回明确的 `401`/`502`,日志不得记录 AppSecret、session_key 或完整 code。 +4. 按 `(app_id, openid)` 原子地查找或创建本地用户。 +5. 生成至少 32 字节的加密安全随机令牌,只把 SHA-256 哈希写入数据库。 +6. 原始令牌只在本次响应中返回,建议有效期 30 天。 + +响应: + +```json +{ + "access_token": "后端随机会话令牌", + "expires_at": "2026-08-20T00:00:00Z", + "user_id": "内部教师 UUID" +} +``` + +同时增加: + +- `GET /api/v1/auth/me`:返回当前内部用户 ID 和会话到期时间。 +- `POST /api/v1/auth/logout`:撤销当前令牌。 + +## 业务接口鉴权 + +实现统一的 Axum 身份 extractor: + +1. 读取 `Authorization: Bearer `。 +2. 对 token 做 SHA-256 后查询 `auth_sessions`。 +3. 拒绝不存在、已撤销、已过期或用户已停用的会话。 +4. 将数据库中的 `user_id` 作为业务 `owner_id`,客户端不能提交或覆盖它。 + +普通 JSON 请求和 `wx.uploadFile` 必须使用相同的 Bearer token。不得把 `openid`、`user_id` 或固定 UUID 当成可信请求头。 + +本地开发可暂时保留 `X-User-Id`,但必须由显式环境变量控制: + +```env +ALLOW_DEVELOPMENT_USER_HEADER=false +``` + +生产默认和 `compose.deploy.yml` 必须为 `false`。只有本地测试环境可设置为 `true`,并在日志中显示开发鉴权已开启。 + +## 小程序登录状态 + +新增独立 `utils/auth.ts`: + +1. 从微信 Storage 读取后端 access token 和到期时间。 +2. 没有有效 token 时调用 `wx.login()`,再调用后端登录接口。 +3. 使用共享的 `loginPromise` 合并并发登录,避免多个页面同时换取 code。 +4. 所有 `wx.request` 和 `wx.uploadFile` 自动附加 Bearer token。 +5. 收到一次 `401` 时清理本地 token、重新登录并重试原请求一次,禁止无限重试。 +6. 退出登录时先调用后端撤销接口,再清理 Storage。 + +“我的”页面移除固定开发 UUID,改为显示登录状态和内部教师 ID;不显示 `openid`、session_key 或完整 access token。 + +## 现有开发数据归属 + +当前数据属于固定开发用户: + +```text +5a4da7f0-d70c-465f-bcab-124c504aa9f0 +``` + +不能把这些数据自动交给“部署后第一个登录的人”,否则存在抢占风险。采用一次性管理命令迁移: + +1. 先发布微信登录功能,由目标微信账号完成一次登录并取得新的内部用户 UUID。 +2. 在服务端运行受控 CLI,例如: + +```text +migrate-user-owner --from 5a4da7f0-d70c-465f-bcab-124c504aa9f0 --to <新用户UUID> +``` + +3. CLI 在单个数据库事务中更新学生档案、预设、反馈记录和反馈会话的 `owner_id`。 +4. 遇到目标账号已有同类数据或唯一约束冲突时中止并报告,不做部分迁移。 +5. 迁移前备份数据库,迁移后比较每张表的记录数并进行页面验收。 + +CLI 不接收 `openid` 或 AppSecret;它只合并两个经过后端确认的内部 UUID,便于审计和回滚。 + +## 分阶段交付 + +1. 数据库迁移、微信 API 客户端、登录接口和会话存储。 +2. 后端统一 Bearer extractor,同时在本地保留受控的开发头兼容模式。 +3. 小程序登录管理、普通请求和录音上传的自动鉴权与一次重试。 +4. 自动化测试:code2Session 模拟、令牌过期/撤销、跨用户隔离、上传鉴权和并发登录。 +5. 部署环境变量并验证目标微信账号登录。 +6. 运行一次性旧数据迁移,关闭生产 `X-User-Id`,最后再提交体验版测试。 + +## 发布验收 + +- 两个不同微信账号看到的数据互相隔离。 +- 客户端伪造 `X-User-Id`、`openid` 或 `user_id` 无法越权。 +- access token 过期或撤销后必须重新登录。 +- 普通接口和录音上传均能在 token 刷新后重试一次。 +- AppSecret、session_key 和令牌未出现在代码、Git、日志或错误响应中。 +- 原开发用户数据仅归属指定的目标微信账号。 diff --git a/server/API_GUIDE.md b/server/API_GUIDE.md index 3e1af0f..a0f7962 100644 --- a/server/API_GUIDE.md +++ b/server/API_GUIDE.md @@ -98,7 +98,7 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' `X-User-Id` 缺失时返回 `401`,格式不是 UUID 时返回 `400`。接入微信登录后,应由后端根据登录凭据确定用户身份,客户端不再直接提交该请求头。 -小程序开发客户端当前使用同一个测试 UUID,并默认连接 `http://127.0.0.1:8080`。可在小程序“我的”页面修改 API 地址并执行健康检查。真机或正式环境应使用已配置为微信 request 合法域名的 HTTPS 地址。 +小程序开发客户端当前使用同一个测试 UUID,并默认连接 `https://feedback.shay7sev.site`。可在小程序“我的”页面修改 API 地址并执行健康检查;本地 API 调试时使用 `http://127.0.0.1:8080`。微信公众平台需将生产域名同时配置为 `request` 和 `uploadFile` 合法域名。 ## 5. 响应状态码 diff --git a/utils/api.ts b/utils/api.ts index 2469d10..443bda1 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -15,7 +15,8 @@ import type { VoiceProcessingStatus } from './types' -const DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080' +const DEFAULT_API_BASE_URL = 'https://feedback.shay7sev.site' +const LEGACY_DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080' const API_BASE_URL_KEY = 'teaching-feedback-api-base-url-v1' export const DEVELOPMENT_USER_ID = '5a4da7f0-d70c-465f-bcab-124c504aa9f0' @@ -131,12 +132,24 @@ export class ApiRequestError extends Error { } } +function normalizeApiBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, '') +} + export function getApiBaseUrl(): string { - return (wx.getStorageSync(API_BASE_URL_KEY) as string) || DEFAULT_API_BASE_URL + const storedValue = wx.getStorageSync(API_BASE_URL_KEY) + if (typeof storedValue !== 'string' || !storedValue.trim()) return DEFAULT_API_BASE_URL + + const normalized = normalizeApiBaseUrl(storedValue) + if (normalized === LEGACY_DEFAULT_API_BASE_URL) { + wx.setStorageSync(API_BASE_URL_KEY, DEFAULT_API_BASE_URL) + return DEFAULT_API_BASE_URL + } + return normalized } export function setApiBaseUrl(value: string): string { - const normalized = value.trim().replace(/\/+$/, '') + const normalized = normalizeApiBaseUrl(value) if (!/^https?:\/\/[^\s]+$/i.test(normalized)) { throw new ApiRequestError('服务地址必须以 http:// 或 https:// 开头') }