UI-UX/scripts/test-vote-rules.mjs
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- 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>
2026-05-15 20:14:57 +08:00

178 lines
5.6 KiB
JavaScript

// 后端投票规则验证(不污染生产数据,所有写操作都在事务里 rollback)
import { PrismaClient, Prisma } from "@prisma/client";
const prisma = new PrismaClient({ log: ["error"] });
const PASS = "\x1b[32mPASS\x1b[0m";
const FAIL = "\x1b[31mFAIL\x1b[0m";
let results = [];
function record(name, ok, detail = "") {
results.push({ name, ok, detail });
console.log(` [${ok ? PASS : FAIL}] ${name}${detail ? " -- " + detail : ""}`);
}
async function main() {
console.log("=== 后端投票规则验证(事务回滚,不留痕)===\n");
// 取一个真实用户(测试数据中的最大投票用户)
const sample = await prisma.$queryRaw`
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id ORDER BY c DESC LIMIT 1
`;
if (sample.length === 0) {
console.log("[note] votes 表为空,跳过有交互的测试");
return;
}
const sampleUserId = BigInt(sample[0].user_id);
const sampleVotes = await prisma.vote.findMany({
where: { userId: sampleUserId },
select: { artistId: true },
});
const sampleArtist = sampleVotes[0].artistId;
console.log(
`测试样本:user=${sampleUserId} 已投艺人=[${sampleVotes
.map((v) => v.artistId)
.join(",")}]\n`,
);
// 测试 1:DB unique 约束生效 —— 重复 (userId, artistId) INSERT 应失败 P2002
console.log("[测试 1] DB unique 约束:重复 (userId, artistId) 必被拒");
try {
await prisma.$transaction(
async (tx) => {
await tx.vote.create({
data: {
userId: sampleUserId,
artistId: sampleArtist,
count: 1,
source: "QUOTA",
},
});
throw new Error("ROLLBACK_AFTER_TEST"); // 强制回滚
},
{ timeout: 10000 },
);
record("unique 约束阻挡重复投票", false, "INSERT 居然成功了");
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === "P2002"
) {
record("unique 约束阻挡重复投票", true, "P2002 unique constraint");
} else if (e.message === "ROLLBACK_AFTER_TEST") {
record(
"unique 约束阻挡重复投票",
false,
"INSERT 居然成功 = 没有 unique 约束!",
);
} else {
record("unique 约束阻挡重复投票", false, `异常: ${e.message}`);
}
}
// 测试 2:不同艺人 INSERT 应该成功(在事务内回滚,不留痕)
console.log(
"\n[测试 2] 不同艺人投票不会被 unique 阻挡(事务内验证后回滚)",
);
const candidateArtist = await prisma.artist.findFirst({
where: {
id: { notIn: sampleVotes.map((v) => v.artistId) },
status: "ACTIVE",
},
select: { id: true },
});
if (!candidateArtist) {
record("跨艺人投票", false, "找不到该用户未投过的艺人样本");
} else {
try {
await prisma.$transaction(
async (tx) => {
await tx.vote.create({
data: {
userId: sampleUserId,
artistId: candidateArtist.id,
count: 1,
source: "QUOTA",
},
});
throw new Error("ROLLBACK_AFTER_TEST");
},
{ timeout: 10000 },
);
record("跨艺人投票", false, "事务未回滚");
} catch (e) {
if (e.message === "ROLLBACK_AFTER_TEST") {
record(
"跨艺人投票",
true,
`允许投给新艺人 ${candidateArtist.id},事务已回滚`,
);
} else {
record("跨艺人投票", false, `异常: ${e.message}`);
}
}
}
// 测试 3:终身额度 12 票上限(由 /api/vote 应用层校验,DB 不强制)
// 验证:对所有用户跑 SELECT COUNT(*) < 12 是 true,确认目前没人超额
console.log("\n[测试 3] 终身额度上限 12 票 - 数据合规性");
const overflows = await prisma.$queryRaw`
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id HAVING c > 12
`;
if (overflows.length === 0) {
record(
"现存数据无超额用户",
true,
"12 票上限对所有现存用户都成立(应用层会兜底)",
);
} else {
record(
"现存数据无超额用户",
false,
`${overflows.length} 个用户已超过 12 票:${overflows
.map((r) => `user=${r.user_id} c=${r.c}`)
.join("; ")}`,
);
}
// 测试 4:回归确认 /api/me 用的 vote 关键查询能返回 votedArtists 列表
console.log("\n[测试 4] /api/me votedArtists 查询正确性");
const votedList = await prisma.vote.findMany({
where: { userId: sampleUserId },
select: { artistId: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
const isAsc = votedList.every(
(v, i) =>
i === 0 || votedList[i - 1].createdAt.getTime() <= v.createdAt.getTime(),
);
record(
"votedArtists 按 createdAt 升序",
isAsc,
`${votedList.length}`,
);
// 测试 5:旧 daily_quota / fan_supports 数据仍可读(向后兼容,不报错)
console.log("\n[测试 5] 旧数据兼容性");
try {
const dqCount = await prisma.dailyQuota.count();
const fsCount = await prisma.fanSupport.count();
record("旧 DailyQuota / FanSupport 仍可读", true, `dq=${dqCount} fs=${fsCount}`);
} catch (e) {
record("旧 DailyQuota / FanSupport 仍可读", false, e.message);
}
// 汇总
console.log("\n=== 汇总 ===");
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
console.log(`通过 ${passed} / 共 ${results.length}${failed ? ` 失败 ${failed}` : ""}`);
await prisma.$disconnect();
if (failed) process.exit(1);
}
main().catch(async (e) => {
console.error("\n[ABORT]", e);
await prisma.$disconnect();
process.exit(1);
});