前端: - 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>
11 KiB
投票系统重构 · 后端完成报告
完成日期: 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):
-- 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 才安全。
回滚(脚本里有完整注释):
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:
- 需要 shadow database 做 diff —— 生产 RDS 通常不给 CREATE DATABASE 权限,不可行
- 需要先跑
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 分支。
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 新返回结构
{
"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/vote12 票上限校验 + ALREADY_VOTED 兜底/api/me新增votedArtists+voteQuota- Prisma schema 同步,scripts/ 留下可重复使用的工具
⚠️ 已知风险待人工跟进
- 前端 hydrate 仍只用 localStorage:本次没动前端 store(规则约束),前端目前不消费
/api/me.votedArtists。用户清缓存或换设备仍会"丢"已投状态(虽然投票不会丢,DB 还在)。下次前端任务里改 store 在 client 启动时调用/api/me用服务端数据 hydrate。 - 旧 DailyQuota 数据没清理:
daily_quota表 5 行残留,新逻辑不再读取,但表保留。等数据迁移期过后可以 DROP TABLE 或 prune。 - FanSupport.votedTotal 历史数据 > 1 的行没归一化:旧数据有
votedTotal=2/3的行(对应旧每日额度时代多次投同艺人)。新规则下 votedTotal 固定 1,但历史行不变 —— 如果前端只展示"是否已投"就不影响;如果展示具体数字会显示历史值。建议看一眼用户态期望后再决定是否 UPDATE。 - 生产 RDS 直接是本地开发库:所有 schema 改动都立即生效到生产。开发节奏快时风险大。建议下一次空档配 docker MySQL 做本地 dev DB,把生产降级为部署目标。
- 没有 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替代
六、回滚步骤
如线上出问题需要立即回滚:
# 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 拒绝)。这是体感问题不是数据问题,不阻塞投产。
建议节奏:
- 现在:可以投产(前端 + 后端 + DB 都生效)。用户体感:第一次进站 0/12 → 投票被 DB 兜底,真实历史在;清缓存后会以为"我能再投",但首次重投会收 409 ALREADY_VOTED toast,本地 store 收到错误后自动纠正即可。
- 下个迭代:前端 hydrate 接
/api/me.votedArtists,彻底解决体感差异。