# FSD M1 · 认证与用户

LLMS 索引： [llms.txt](/llms.txt)

---

> [← 总则](/qmtv/fsd-qmtv/00-%E6%80%BB%E5%88%99/) ｜ [附录 →](/qmtv/fsd-qmtv/99-%E9%99%84%E5%BD%95/) ｜ 上游：[PRD §4 M1](/qmtv/product/prd-qmtv-v1/)
> 实现落点：`backend/internal/domain/auth/`（service.go/model.go/repo.go）+ `cmd/server/auth_handlers.go`（handlers）+ `cmd/server/routes.go:34-51`（挂载）。事实基准=Go 代码；冻结版 `qmtv/server/index.js:73-230` 仅差异对拍参照。

### M1 认证与用户

9 端点全量（基础信封见总则 §2.2：成功 `{"ok":true,"data":…}` / 失败 `<码> {"error":"文案"}`）。本域零扣费、零 SSE、零素材上传；台账/通知为写入副作用（register/改邮箱两处）。

#### F1.01 注册

> 对应 PRD：§4 M1 · POST /api/auth/register（限流 5/h）｜ 状态：实现 ｜ 实现落点：auth/service.go:68-140、auth_handlers.go:173-188 ｜ UI：`/register`（RegisterView）｜ 场景挂钩：无（e2e 未覆盖注册链，人工清单 T0 具名账号由 seed 产出）

##### 1 功能定义

创建用户账号并签发首个会话（access + refresh 双令牌）：初始积分发放、邀请码建分销关系、欢迎通知三件副作用一次完成。

##### 2 触发条件与前置状态

- 未登录访问 `/register` 提交表单 → `POST /api/auth/register`（公开面，`WindowRateLimit("register", 5, time.Hour)`——同 IP 每小时 5 次，超出 429）。
- 前置：邮箱未被注册；降级模式（无 DSN）下 503「数据库未配置」。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| email | JSON body | 是 | 非空（`邮箱和密码必填`）→ 严格正则 `^[^@\s]+@[^@\s]+\.[^@\s]+$`（F7 统一版；冻结 register 原损坏正则被取代，PRD 差异已登记）→ 唯一性（`邮箱已被注册`）。三关任一失败 400 |
| password | JSON body | 是 | 非空 + `len ≥ 8`（`密码至少 8 位`）400 |
| nickname | JSON body | 否 | 缺省取邮箱 `@` 前缀 |
| invite_code | JSON body | 否 | 命中 referral_code 建分销关系；**不命中静默跳过**（不报错，冻结同） |

##### 4 处理流程

```mermaid
flowchart TD
  A["POST /auth/register"] --> B["四关校验"]
  B --> C["invite_code 查询邀人<br/>（不命中静默）"]
  C --> D["建 users 行<br/>role=user / status=active / workspace_version=classic<br/>referral_code=QM+6位base36大写（crypto/rand）"]
  D --> E{"有邀人?"}
  E -->|是| F["INSERT referral_relations"]
  E -->|否| G["跳过"]
  F --> G
  G --> H["台账 amount=0 / balance_after=初始积分<br/>type=register 描述「注册赠送 200 积分（初始）」"]
  H --> I["欢迎通知（积分数字跟随 trial 实际值）"]
  I --> J{"有邀人?"}
  J -->|是| K["邀人台账 +50 type=referral"]
  J -->|否| L["跳过"]
  K --> L
  L --> M["issueSession → 200 {ok,data:{token,refreshToken,user}}"]
```

##### 5 输出与结果状态

- `200 {"ok":true,"data":{"token","refreshToken","user":PublicUser}}`（AuthSession 统一会话体，model.go:78-82）。
- user 投影字段集（冻结 publicUser 逐字节，model.go:33-44）：`id/email/nickname/role/credits/org_id/workspace_version/referral_code/affiliate_enabled/created_at`；`org_id` 空串与 `referral_code` NULL 均→JSON null。

##### 6 状态流转

无状态机。用户初始 `status='active'`；`workspace_version='classic'`（后续 F1.09 可改）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| email/password 缺失 | 400 | `邮箱和密码必填` |
| 邮箱不过正则 | 400 | `邮箱格式不正确` |
| 密码 <8 位 | 400 | `密码至少 8 位` |
| 邮箱已注册 | 400 | `邮箱已被注册` |
| 限流超 5 次/时 | 429 | 限流信封（gateway.WindowRateLimit） |
| trial_credits 配置为 N（默认 200） | 实发 N 积分；欢迎通知文案跟随 N；**台账描述硬编码「注册赠送 200 积分（初始）」不跟随**（冻结 index.js:92 同 quirk 保真）→ DIF-28-1 | — |
| body 非 JSON | 400 | `请求体格式错误`（handlers 层，非哨兵） |

##### 8 权限与数据规则

- 权限：公开面（限流替代鉴权）；写入 `users`/`referral_relations`/`credit_records`/`notifications` 四表。
- 数据规则：`users.email` UNIQUE；`referral_code` UNIQUE（碰撞时 INSERT 报错与冻结一致，crypto/rand 36^6 空间）；`invited_by` 哨兵空串（冻结写 null，DDL NOT NULL 差异已登记）；`credits` 初始 = trial_credits settings 值（F6 Billing.TrialCredits——冻结硬编码 200 属刻意差异）。

#### F1.02 登录

> 对应 PRD：§4 M1 · POST /api/auth/login（限流 20/min）｜ 状态：实现 ｜ 实现落点：auth/service.go:143-156、auth_handlers.go:195-208 ｜ UI：`/login`（LoginView——错密码 alert「邮箱或密码错误」，登录成功跳 `/`）｜ 场景挂钩：E2E-AUTH-01（断言册 auth.spec.ts）

##### 1 功能定义

邮箱+密码换取会话双令牌；不存在与密码错合并同一 401（不暴露账号存在性），停用账号 403。

##### 2 触发条件与前置状态

- 未登录（或会话过期）提交登录表单 → `POST /api/auth/login`（公开面，限流 20/min）。
- 前置：用户存在且 `status='active'`。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| email | JSON body | 是 | 不存在与密码错**合并** 401（冻结 index.js:100-106 语义） |
| password | JSON body | 是 | bcrypt 校验失败 401 |

##### 4 处理流程

```mermaid
flowchart TD
  A["POST /auth/login"] --> B["ByEmail 查用户"]
  B -->|不存在| C["401 邮箱或密码错误"]
  B -->|存在| D["VerifyPassword"]
  D -->|不符| C
  D -->|符合| E{"status=active?"}
  E -->|否| F["403 账号已被停用"]
  E -->|是| G["issueSession<br/>access JWT（claims 携 rol）+ rt_ 刷新令牌<br/>原文返回前端 / SHA-256 落库 refresh_tokens"]
  G --> H["200 {ok,data:AuthSession}"]
```

##### 5 输出与结果状态

同 F1.01（AuthSession）。JWT claims 含 `rol`（AdminMiddleware 零查库鉴权——**角色变更需重登生效**，PRD §3 登记）；access TTL 72h。

##### 6 状态流转

无状态机；refresh_tokens 表新增一行（`revoked_at` NULL / `replaced_by` 空）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 邮箱不存在 | 401 | `邮箱或密码错误`（合并语义） |
| 密码错误 | 401 | 同上 |
| 账号停用（status≠active） | 403 | `账号已被停用`（登录时才知道身份，区别于 F1.05 的 401） |
| 限流超 20 次/分 | 429 | 限流信封（e2e 扩量场景需留意，遗留⑤登记） |

##### 8 权限与数据规则

- 权限：公开面；读 `users`、写 `refresh_tokens`。
- 数据规则：refresh token 原文不落库（SHA-256 `token_hash` + `expires_at` LocalTime）；每次登录新增一行（不清理旧行，过期行由 `ValidByHash` 谓词自然失效）。

#### F1.03 令牌刷新（F1 真轮换）

> 对应 PRD：§4 M1 · POST /api/auth/refresh ｜ 状态：实现 ｜ 实现落点：auth/service.go:159-179、auth_handlers.go:214-227 ｜ UI：前端 api 层 401 自动触发（frontend/src/api.ts:36-44）｜ 场景挂钩：ci --with-env refresh 轮换断言（新旧 RT 不同/旧复刷 401）

##### 1 功能定义

用 refreshToken 换新会话：**旧令牌撤销、新令牌串链（replaced_by）**——同一 refreshToken 只能用一次；与冻结版差异（冻结 refresh 恒 401 无新字段），属 PRD F1 刻意差异。

##### 2 触发条件与前置状态

- 前端任何 API 401 → 自动 POST `/api/auth/refresh` 重试一次；或手动调用于 `POST /api/auth/refresh`（公开面无限流）。
- 前置：refreshToken 未撤销、未过期、属主 `status='active'`。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| refreshToken | JSON body | 是 | SHA-256 后查 `ValidByHash`：不存在/已撤销/过期任一 → 401 `invalid refresh token`；属主停用 → 同 401（安全收敛） |

##### 4 处理流程

```mermaid
flowchart TD
  A["POST /auth/refresh"] --> B["SHA-256 查 ValidByHash"]
  B -->|无效| C["401 invalid refresh token"]
  B -->|有效| D["查属主 + 停用校验"]
  D -->|停用/不存在| C
  D -->|active| E["issueSession 签新对"]
  E --> F["撤旧行：revoked_at 置位 + replaced_by=新 RT"]
  F --> G["200 {ok,data:AuthSession}"]
```

##### 5 输出与结果状态

`200 {"ok":true,"data":AuthSession}`——`refreshToken` 字段含**新令牌**（冻结版响应只有 `{token,user}`，无此字段——前端 W9a 同期消费）。旧令牌自此 401。

##### 6 状态流转

refresh_tokens 行级状态机：`active（revoked_at NULL）→ rotated（revoked_at 置位+replaced_by 串链）`；无单向状态图面，串链即审计线索。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| body 空/字段空 | 401 | `invalid refresh token` |
| 已用过的旧令牌二刷 | 401 | 同上（轮换唯一性） |
| 过期令牌 | 401 | 同上 |
| 属主已停用 | 401 | 同上（非 403——不暴露身份） |

##### 8 权限与数据规则

- 权限：公开面；读 `refresh_tokens`/`users`、写 `refresh_tokens`（撤旧）。
- 数据规则：原文仅存在响应体，库中只存 SHA-256（泄库不可逆推）；`replaced_by` 构成轮换链。

#### F1.04 登出

> 对应 PRD：§4 M1 · POST /api/auth/logout ｜ 状态：实现 ｜ 实现落点：auth/service.go:258-260、auth_handlers.go:240-248 ｜ UI：顶栏退出按钮（LoginView 登出回 `/login`）｜ 场景挂钩：E2E-AUTH-01（admin 可达 /admin 并登出回 /login）

##### 1 功能定义

撤销当前用户**全部** refreshToken（F1 增强；冻结版无服务端动作，PRD v1.2 补登差异）——改密/登出后所有已发刷新令牌一并失效。

##### 2 触发条件与前置状态

- 已登录点击退出 → `POST /api/auth/logout`（Bearer）。
- 前置：Bearer 有效；降级模式 503。

##### 3 输入与校验

无 body。身份取自 JWT claims `user_id`。

##### 4 处理流程

`POST /auth/logout` → `RevokeAllForUser(userID)`（`UPDATE refresh_tokens SET revoked_at=NOW() WHERE user_id=? AND revoked_at IS NULL`）→ `200 {"ok":true,"data":true}`。

##### 5 输出与结果状态

`200 {"ok":true,"data":true}`；该用户全部未撤销 refresh_tokens 行 `revoked_at` 置位。

##### 6 状态流转

同 F1.03 行级状态：active → rotated（批量）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 无刷新令牌在册 | 仍 200（UPDATE 零行非错误） | — |
| 停用用户带有效 Bearer 调用 | 401 `unauthorized`（authRequired 层拦截，见 F1.05） | — |

##### 8 权限与数据规则

- 权限：登录态（amw）；只写 `refresh_tokens` 自己的行（user_id 来自 claims，非入参——不可越权撤他人）。

#### F1.05 当前用户信息（me）

> 对应 PRD：§4 M1 · GET /api/auth/me ｜ 状态：实现 ｜ 实现落点：auth/service.go:182-191、auth_handlers.go:229-238 ｜ UI：顶栏积分胶囊/角色菜单数据源｜ 场景挂钩：E2E-AUTH-01（间接：全站登录态面）

##### 1 功能定义

返回当前登录用户公开投影（PublicUser）；**已登录态遇停用账号返回 401**（P2 期首核查 A1 恢复冻结 util.js:53 全 authed 拦截语义，触发前端登出链路）。

##### 2 触发条件与前置状态

- 前端挂载/刷新/积分变动后拉取 → `GET /api/auth/me`（Bearer）。
- 另有 query token 兜底形态 `GET /api/auth/me?token=`（ci 冒烟 C3 裁决面——与 SSE/images 同族，仅此三处）。

##### 3 输入与校验

无参数。身份取自 claims。中间件层已验签（JWTAuth.AuthMiddleware）；service 层仅补停用校验。

##### 4 处理流程

`GET /auth/me` → `ByID(userID)` → 停用? →401 : →`u.Public()` 投影 → `200 {"ok":true,"data":PublicUser}`。

##### 5 输出与结果状态

字段集同 F1.01 §5。`credits` 为当前实时余额（DECIMAL 读侧 float64）。

##### 6 状态流转

无。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 用户已被停用（曾登录） | **401** `unauthorized`——区别于登录时 403「账号已被停用」；前端 401→refresh→仍停用→登出 | — |
| JWT 过期/伪造 | 401 `unauthorized`（中间件层，json 键亦 `error`） | — |

##### 8 权限与数据规则

- 权限：登录态；只读自己一行（claims 绑定）。

#### F1.06 积分流水分页

> 对应 PRD：§4 M1 · GET /api/auth/credit-records ｜ 状态：实现 ｜ 实现落点：auth_handlers.go:309-324（委托 credits.Records）｜ UI：`/credits`（CreditsView）/ 顶栏积分弹层｜ 场景挂钩：E2E-CRD-01

##### 1 功能定义

当前用户全局积分台账分页读（每页 20，`created_at DESC,id DESC`）——所有域的扣费/退款/充值/佣金行共用此读面。

##### 2 触发条件与前置状态

- 进入积分流水页/翻页 → `GET /api/auth/credit-records?page=N`（Bearer）。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| page | query | 否 | 缺省 1；非数字或 ≤0 **折 1**（不报 400，handlers.go:313-318 quirk） |

##### 4 处理流程

`GET /auth/credit-records` → page 归一 → `credits.Records(userID, page)` → `200 {"ok":true,"data":{"list":[…],"total":N,"page":N}}`。

##### 5 输出与结果状态

- `list` 行字段：台账行投影（type/desc/amount/balance_after/created_at/task_id 等，credits 域读侧投影）。
- `total` 全量行数（非当前页）；`page` 回显归一后值。

##### 6 状态流转

无（纯读）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| page=abc / page=0 / page=-1 | 折 1 返回第一页 | — |
| 空台账 | `list:[]`、`total:0` 200 | — |

##### 8 权限与数据规则

- 权限：登录态；WHERE user_id=claims 绑定（看不到他人流水）。
- 数据规则：排序 `created_at DESC,id DESC`（同秒稳定序）；分页 size 固定 20。

#### F1.07 修改密码

> 对应 PRD：§4 M1 · POST /api/auth/change-password ｜ 状态：实现 ｜ 实现落点：auth/service.go:194-217、auth_handlers.go:255-267 ｜ UI：`/account`（AccountView 安全区）｜ 场景挂钩：无独立 e2e（人工清单 T7 掉线重登路径）

##### 1 功能定义

验原密码后更新密码哈希，并**撤销全部 refreshToken**（F1：改密后所有已登录会话的刷新能力失效，access 令牌存续至 72h 自然过期）。

##### 2 触发条件与前置状态

- 账号设置页提交改密表单 → `POST /api/auth/change-password`（Bearer）。
- 前置：status='active'（停用 401，A1 拦截——本就查库零成本恢复冻结语义）。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| old_password | JSON body | 是 | bcrypt 验证失败 400 `原密码错误` |
| new_password | JSON body | 是 | 空/`len < 8` 400 `新密码至少 8 位` |

校验序：查用户→停用拦→验原密码→验新密码（十六续 A1 修序先例在 M12 admin 域；本域顺序冻结逐字）。

##### 4 处理流程

`POST /auth/change-password` → 停用?→401 : 验旧密码→验新密码→`UpdatePassword`（新 bcrypt 哈希）→`RevokeAllForUser`→`200 {"ok":true,"data":true}`。

##### 5 输出与结果状态

`200 {"ok":true,"data":true}`；`users.password_hash` 更新；该用户全部 refresh_tokens 撤销（前端引导重登）。

##### 6 状态流转

无用户级状态机；refresh_tokens 批量 active→rotated。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 原密码错 | 400 | `原密码错误` |
| 新密码 <8 位或空 | 400 | `新密码至少 8 位` |
| 停用态调用 | 401 | `unauthorized` |

##### 8 权限与数据规则

- 权限：登录态；只改自己（claims 绑定）。

#### F1.08 修改邮箱

> 对应 PRD：§4 M1 · PUT /api/auth/email ｜ 状态：实现 ｜ 实现落点：auth/service.go:220-244、auth_handlers.go:274-287 ｜ UI：`/account`（AccountView 安全区）｜ 场景挂钩：无独立 e2e（FAQ「改密登出」同族人工面）

##### 1 功能定义

需当前密码确认的邮箱变更；成功后发站内通知（冻结 index.js:127-136 文案）。**登出不联动**（改邮箱不清会话，与改密不同——冻结语义保真）。

##### 2 触发条件与前置状态

- 账号设置页提交改邮箱表单 → `PUT /api/auth/email`（Bearer）。
- 前置：新邮箱未被任何账号占用。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| password | JSON body | 是 | 当前密码验证失败 400 `当前密码错误` |
| new_email | JSON body | 是 | 非空+严格正则（F7 统一版）400 `新邮箱格式不正确` → 唯一性 400 `该邮箱已被占用` |

##### 4 处理流程

`PUT /auth/email` → 停用拦 → 验当前密码 → 验新邮箱格式 → 唯一性 → `UpdateEmail` → 通知「登录邮箱已变更／登录邮箱已变更为 X，请使用新邮箱登录。」→ `200 {"ok":true,"data":{"email":"<新邮箱>"}}`。

##### 5 输出与结果状态

`data.email` = 新邮箱；`users.email` 更新；`notifications` 新增一行（标题+文案冻结逐字）。

##### 6 状态流转

无。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 当前密码错 | 400 | `当前密码错误` |
| 新邮箱格式非法 | 400 | `新邮箱格式不正确` |
| 新邮箱已被占用 | 400 | `该邮箱已被占用` |
| 停用态调用 | 401 | `unauthorized` |

##### 8 权限与数据规则

- 权限：登录态；只改自己。
- 数据规则：`users.email` UNIQUE 兜底；通知文案与新邮箱值拼接（无模板）。

#### F1.09 工作台版本状态

> 对应 PRD：§4 M1 · PUT /api/auth/workspace-state ｜ 状态：实现 ｜ 实现落点：auth/service.go:247-255、auth_handlers.go:289-306 ｜ UI：工作台切换入口（双工作台 `shot._tbl` 通路的选择器）｜ 场景挂钩：无（M2 拆解域间接消费）

##### 1 功能定义

保存用户工作台版本偏好（`users.workspace_version`）；**非 `'legacy'` 值一律折 `'classic'`**（白名单外静默归一，冻结 index.js:215 逐字）。

##### 2 触发条件与前置状态

- 前端切换工作台版本 → `PUT /api/auth/workspace-state`（Bearer）。

##### 3 输入与校验

| 字段 | 来源 | 必填 | 校验 |
|---|---|---|---|
| version | JSON body | 是 | **body 字段名是 `version`**（非 workspace_version——冻结 quirk）；值 ≠`legacy` 一律落 `classic`，无 400 面 |

##### 4 处理流程

`PUT /auth/workspace-state` → 归一（≠legacy→classic）→ `UpdateWorkspaceVersion` → `200 {"ok":true,"data":{"workspace_version":"<归一值>"}}`。

##### 5 输出与结果状态

`data.workspace_version` 为**归一后**值（传 `bogus` 得 `"classic"`）；`users.workspace_version` 持久化。

##### 6 状态流转

classic ⇄ legacy 二值域，无第三态。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 传任意非法值（空串/`bogus`/null） | 静默落 `classic`，200 | — |
| 仅 `legacy` 字面 | 落 `legacy` | — |

##### 8 权限与数据规则

- 权限：登录态；只改自己。
- 数据规则：M2 拆解域读此列决定 shots/director_shots 双表通路归属（`shot._tbl` 约定——见总则 §1 术语）。

---

### M1 端点覆盖对账

| # | 端点 | 功能节 |
|---|---|---|
| 1 | POST /api/auth/register | F1.01 |
| 2 | POST /api/auth/login | F1.02 |
| 3 | POST /api/auth/refresh | F1.03 |
| 4 | POST /api/auth/logout | F1.04 |
| 5 | GET /api/auth/me | F1.05 |
| 6 | GET /api/auth/credit-records | F1.06 |
| 7 | POST /api/auth/change-password | F1.07 |
| 8 | PUT /api/auth/email | F1.08 |
| 9 | PUT /api/auth/workspace-state | F1.09 |

9/9 对账 PRD §4 M1（DIF 新发现：DIF-28-1 注册台账描述硬编码——见 [99-附录 B](/qmtv/fsd-qmtv/99-%E9%99%84%E5%BD%95/)）。

---

反链：

- [QMTV 功能规格说明书（FSD）v1.0 · 总则](/qmtv/fsd-qmtv/00-%E6%80%BB%E5%88%99/)
- [FSD 附录 · PRD 对照 / 代码域索引 / 差异台账](/qmtv/fsd-qmtv/99-%E9%99%84%E5%BD%95/)
