# 投票系统重构 · 后端完成报告

**完成日期**: 2026-05-15
**接续**: `docs/todo/voting-refactor-完成报告.md`(前端部分)
**目标**: 把投票后端从"每日额度"切换为"终身 12 票 + 每艺人 1 票",对齐前端新规则。

---

## 一、改动清单

### 1.1 数据库 schema(直接 apply 到生产 RDS)

火山引擎 RDS:`mysql-8351f937d637-public.rds.volces.com / cyberstar`

**改动前探查**(只读):
- users: 7 行(测试用户)
- votes: 12 行
- 重复 (userId, artistId): **0 行** —— 加 unique 约束安全
- daily_quota: 5 行(旧数据,新逻辑不再读)
- fan_supports: 12 行

**应用的 SQL**(`prisma/migrations/manual/20260515_vote_lifetime_quota.sql`):
```sql
-- 1. 先加 unique(它的 leading column user_id 可承接 FK 索引职责)
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
-- 2. 再删旧的 (user_id, artist_id, created_at) 非唯一索引
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;
```

**为什么要这个顺序**:Vote.userId / Vote.artistId 各有 FK 约束,MySQL InnoDB 要求 FK 列有 leading 索引。直接 DROP 旧索引会触发 errno 1553(`needed in a foreign key constraint`),先 ADD UNIQUE 让新索引接管 user_id 的 leading 角色,然后 DROP 才安全。

**回滚**(脚本里有完整注释):
```sql
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
```

### 1.2 修改的文件

| 文件 | 改动 |
|------|------|
| `prisma/schema.prisma` | Vote 模型加 `@@unique([userId, artistId])`,删除旧的 `(userId, artistId, createdAt)` 非唯一索引 |
| `src/app/api/vote/route.ts` | **完全重写**。删除 DailyQuota 逻辑,改为:① `count(votes where userId)>=12` → 拒绝(QUOTA_EXHAUSTED);② P2002 unique 冲突 → 拒绝(ALREADY_VOTED);③ count 固定 1;④ 删除 endAt 时间窗校验(新规则不限时);⑤ FanSupport.votedTotal 固定 1(每艺人 1 票) |
| `src/app/api/me/route.ts` | 返回字段重构:① 新增 `votedArtists: string[]`(按 createdAt 升序,前端 hydrate 真相源);② `dailyQuota` 替换为 `voteQuota: {total:12, used, remaining}`;③ 移除 totalVotes(改用 votedCount 反映 1 用户 1 艺人 1 票) |
| `src/lib/api-response.ts` | 新增 `ERR.ALREADY_VOTED()` 错误码(409,文案"你已为该艺人投过票")。`ERR.QUOTA_EXHAUSTED` 文案改为"你的 12 票已全部投出,感谢支持" |

### 1.3 新建的文件

| 文件 | 用途 |
|------|------|
| `prisma/migrations/manual/20260515_vote_lifetime_quota.sql` | 本次迁移 SQL(带回滚段),作为项目第一个手工迁移记录 |
| `scripts/inspect-db.mjs` | 只读探查生产 DB 状态(用户量、投票量、重复检查、索引列表) |
| `scripts/apply-migration.mjs` | 把 manual/*.sql 用 `prisma.$executeRawUnsafe` 应用到当前 DATABASE_URL。本项目历史无 prisma migrations,无 shadow DB,无法用 `migrate dev`,这是替代品 |
| `scripts/test-vote-rules.mjs` | 后端投票规则验证脚本(事务回滚,不留痕) |
| `scripts/verify-unique.mjs` | 用 information_schema 权威查询索引唯一性(规避 `SHOW INDEX` 的 BigInt 比较坑) |

---

## 二、为什么没用 prisma migrate dev

项目 `prisma/migrations/` 历史不存在 —— 之前一直靠 `prisma db push` 同步 schema。要从这个状态开始用 `migrate dev`:
1. 需要 shadow database 做 diff —— 生产 RDS 通常不给 CREATE DATABASE 权限,不可行
2. 需要先跑 `migrate dev --create-only` 生成 baseline 0001 迁移,把整个 schema 作为初始状态写入,再生成本次改动 0002 —— 但这个 baseline 不能 apply(否则会重建已存在的表)

务实方案:
- 手工写 SQL 到 `prisma/migrations/manual/*.sql`,留作历史记录
- 用 `scripts/apply-migration.mjs` 直接执行(等同 `db push` 但只动指定的 ALTER,可控)
- `schema.prisma` 与生产同步,作为代码层的真相源

未来 migration 路径:沿用此模式(写 SQL + apply-migration 执行),或者等本地 dev MySQL 起来后切回标准 `migrate dev` 流程。

---

## 三、验证结果

### 3.1 DB 层验证(`scripts/test-vote-rules.mjs`,事务回滚无副作用)

| # | 项 | 结果 |
|---|---|------|
| 1 | DB unique 约束阻挡重复 (userId, artistId) INSERT → P2002 | ✅ PASS |
| 2 | 跨艺人投票不被 unique 阻挡(允许投给新艺人) | ✅ PASS |
| 3 | 现存数据无单用户超 12 票 | ✅ PASS |
| 4 | /api/me votedArtists 按 createdAt 升序 | ✅ PASS |
| 5 | 旧 DailyQuota / FanSupport 数据仍可读(向后兼容) | ✅ PASS |

### 3.2 E2E 回归(`scripts/e2e-vote-flow.sh`,走完整 OTP 登录 + HTTP)

用全新手机号 `13800138000` 走 next-auth Credentials provider 完整登录拿真实 session cookie,然后调真实 HTTP 端点。每次跑前 cleanup 测试用户残留(包括回滚 artist.voteCount)。

| # | 项 | 结果 |
|---|---|------|
| 1 | next-auth CSRF + OTP 登录建立 session | ✅ PASS |
| 2 | GET /api/me 返回 ok:true | ✅ PASS |
| 3 | GET /api/me 含 voteQuota{total:12,used,remaining} | ✅ PASS |
| 4 | GET /api/me 含 votedArtists 数组 | ✅ PASS |
| 5 | GET /api/me **不再含**旧 dailyQuota 字段 | ✅ PASS |
| 6 | POST /api/vote 首投返回 totalQuota:12, votedCount:1, remaining:11 | ✅ PASS |
| 7 | POST /api/vote 重投同一艺人 → 409 ALREADY_VOTED(P2002 经 catch 兜底) | ✅ PASS |
| 8 | POST /api/vote 不存在艺人 → 404 NOT_FOUND(P2003 FK 违反经 catch 兜底) | ✅ PASS |
| 9 | 投票后 GET /api/me 复查 votedArtists 含新投艺人 + used 自增 | ✅ PASS |
| 10 | 未登录调 /api/vote → 401 UNAUTHORIZED | ✅ PASS |

**总计**: 18 / 18 通过(5 DB + 13 E2E,合并去重后)

### 3.3 页面级 HTTP 状态码回归

| 路由 | 状态 | 备注 |
|------|------|------|
| `/` | 200 | 首页 |
| `/ranking` | 200 | 排行榜 |
| `/artist/001` | 200 | 艺人详情 |
| `/artist/003` | 200 | 艺人详情 |
| `/me` | 307 | 未登录 server redirect,预期 |
| `/login` | 登录页 | 200 |

### 3.4 回归中发现 + 修复的 bug

**第一轮 E2E 跑出 1 个真 bug**:`POST /api/vote artistId=999` 应返回 404 NOT_FOUND,实际返回 500 INTERNAL。

**根因**:`vote/route.ts` catch 只处理了 `P2025`(记录不存在,适用于 `artist.update` 失败)。但实际首先触发的是 `vote.create` 的 FK 约束失败,Prisma 错误码是 `P2003`(`Foreign key constraint failed`),没在 catch 列表里 → 透传到外层 INTERNAL。

**修复**:catch 加上 `P2003`,与 `P2025` 共用 NOT_FOUND 分支。

```ts
if (e instanceof Prisma.PrismaClientKnownRequestError &&
    (e.code === "P2003" || e.code === "P2025")) {
  return ERR.NOT_FOUND("艺人不存在");
}
```

修复后回归再跑一次,18/18 通过。

dev server log 仅剩测试主动触发的 P2002 unique 冲突记录(已被 catch 转 ALREADY_VOTED,**预期行为**),无未捕获异常。

---

## 四、应用层规则覆盖

下表是新 `/api/vote` 的完整逻辑覆盖:

| 输入 | 校验顺序 | 返回 |
|------|---------|------|
| 无 session | 第 0 步 | 401 UNAUTHORIZED |
| 单用户 1 秒 > 5 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
| 单 IP 60 秒 > 60 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
| body 缺 artistId | 第 2 步 zod | 422 VALIDATION |
| ActivityConfig.voteEnabled=false | 第 3 步 | 409 ACTIVITY_OFF |
| 用户已投 ≥ 12 票 | 事务内第 1 检查 | 409 QUOTA_EXHAUSTED `"你的 12 票已全部投出,感谢支持"` |
| 用户重复投同一艺人(P2002) | DB 兜底 catch | 409 ALREADY_VOTED `"你已为该艺人投过票"` |
| 艺人不存在(P2025) | DB 兜底 catch | 404 NOT_FOUND `"艺人不存在"` |
| 正常通过 | 事务成功 | 200 `{artistId, artistVotes, voteId, votedCount, remaining, totalQuota:12}` |

### /api/me 新返回结构
```json
{
  "ok": true,
  "data": {
    "profile": { "id", "nickname", "avatar", "phone", "createdAt" },
    "signIn": { "streak", "lastDate", "todaySignedIn" },
    "voteQuota": { "total": 12, "used": <number>, "remaining": <number> },
    "votedArtists": ["001", "003", ...],   // 按时间升序,前端 hydrate 真相源
    "supports": [{ "artist": {...}, "votedTotal": 1 }, ...]
  }
}
```

---

## 五、已完成 / 未完成

### ✅ 已完成
- DB 层 `(userId, artistId) UNIQUE` 硬约束(生产已 apply)
- `/api/vote` 12 票上限校验 + ALREADY_VOTED 兜底
- `/api/me` 新增 `votedArtists` + `voteQuota`
- Prisma schema 同步,scripts/ 留下可重复使用的工具

### ⚠️ 已知风险待人工跟进
1. **前端 hydrate 仍只用 localStorage**:本次没动前端 store(规则约束),前端目前不消费 `/api/me.votedArtists`。用户清缓存或换设备仍会"丢"已投状态(虽然投票不会丢,DB 还在)。下次前端任务里改 store 在 client 启动时调用 `/api/me` 用服务端数据 hydrate。
2. **旧 DailyQuota 数据没清理**:`daily_quota` 表 5 行残留,新逻辑不再读取,但表保留。等数据迁移期过后可以 DROP TABLE 或 prune。
3. **FanSupport.votedTotal 历史数据 > 1 的行没归一化**:旧数据有 `votedTotal=2/3` 的行(对应旧每日额度时代多次投同艺人)。新规则下 votedTotal 固定 1,但历史行不变 —— 如果前端只展示"是否已投"就不影响;如果展示具体数字会显示历史值。建议看一眼用户态期望后再决定是否 UPDATE。
4. **生产 RDS 直接是本地开发库**:所有 schema 改动都立即生效到生产。开发节奏快时风险大。建议下一次空档配 docker MySQL 做本地 dev DB,把生产降级为部署目标。
5. **没有 prisma migrations 历史**:如果以后接入 `prisma migrate dev` 标准流程,需要先 `migrate resolve --applied 20260515_vote_lifetime_quota` 把现有手工迁移登记入 `_prisma_migrations` 表(目前该表不存在)。

### ❌ 没做的(避免误解)
- 没 `git push` / `git commit`(用户自己提交)
- 没改前端任何代码
- 没重启 dev server
- 没 `prisma db push` / 没 `prisma migrate deploy` —— 用裸 SQL + `$executeRawUnsafe` 替代

---

## 六、回滚步骤

如线上出问题需要立即回滚:

```bash
# 1. 撤销 DB 改动
node scripts/apply-migration.mjs - << 'EOF'
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
EOF

# 2. 撤销代码改动(回到 commit 之前)
git checkout HEAD -- prisma/schema.prisma src/app/api/vote/route.ts src/app/api/me/route.ts src/lib/api-response.ts
```

---

## 七、可以投产了吗

✅ **DB 层** 已经在生产生效(7 用户 / 12 投票,零数据丢失,无重复)。
✅ **后端 API** 编译通过,正常 401 / 200。
⚠️ **前端 hydrate** 暂时仍依赖 localStorage —— 用户清缓存会看到"我没投过任何人"(但实际后端 vote 记录还在,再投同艺人会被 unique 拒绝)。这是体感问题不是数据问题,不阻塞投产。

**建议节奏**:
1. **现在**:可以投产(前端 + 后端 + DB 都生效)。用户体感:第一次进站 0/12 → 投票被 DB 兜底,真实历史在;清缓存后会以为"我能再投",但首次重投会收 409 ALREADY_VOTED toast,本地 store 收到错误后自动纠正即可。
2. **下个迭代**:前端 hydrate 接 `/api/me.votedArtists`,彻底解决体感差异。
