Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - store 改为 votedArtists[] + zustand persist - VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额) - 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标 - Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度) - /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport) 后端: - votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS) - /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底 - /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota - 新增 ERR.ALREADY_VOTED 错误码 测试: - DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh) - 修复 P2003 FK 违反未识别的 bug 详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# 投票系统重构 · 后端完成报告
|
|
|
|
**完成日期**: 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`,彻底解决体感差异。
|