feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0

竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配
黑名单功能:标记游戏、按原因/严重程度筛选、详情展开
修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-19 00:21:43 +08:00
parent 2d56df940d
commit 60ad9a04cd
24 changed files with 4047 additions and 14 deletions
+218
View File
@@ -0,0 +1,218 @@
import { pb } from './pocketbase'
import type { Bet, BetOption, BetEntry } from '@/types'
export async function createBet(data: {
group: string
title: string
description?: string
options: string[]
minStake?: number
maxStake?: number
deadline: string
}): Promise<Bet> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const bet = await pb.collection('bets').create({
group: data.group,
creator: user.id,
title: data.title,
description: data.description || '',
minStake: data.minStake || 1,
maxStake: data.maxStake || 10,
status: 'open',
deadline: data.deadline,
})
for (let i = 0; i < data.options.length; i++) {
await pb.collection('bet_options').create({
bet: bet.id,
content: data.options[i],
order: i + 1,
})
}
return bet as unknown as Bet
}
export async function listBets(groupId: string, status?: string): Promise<Bet[]> {
let filter = `group="${groupId}"`
if (status) filter += ` && status="${status}"`
const result = await pb.collection('bets').getFullList({
filter,
sort: '-created',
expand: 'creator',
$autoCancel: false,
})
return result as unknown as Bet[]
}
export async function getBet(betId: string): Promise<Bet> {
const result = await pb.collection('bets').getOne(betId, {
expand: 'creator,resultOption',
$autoCancel: false,
})
return result as unknown as Bet
}
export async function getBetOptions(betId: string): Promise<BetOption[]> {
const result = await pb.collection('bet_options').getFullList({
filter: `bet="${betId}"`,
sort: 'order',
$autoCancel: false,
})
return result as unknown as BetOption[]
}
export async function getBetEntries(betId: string): Promise<BetEntry[]> {
const result = await pb.collection('bet_entries').getFullList({
filter: `bet="${betId}"`,
expand: 'user,option',
$autoCancel: false,
})
return result as unknown as BetEntry[]
}
export async function placeBet(betId: string, optionId: string, stake: number): Promise<BetEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 验证竞猜状态和下注范围 (H1)
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
if ((betData as any).status !== 'open') throw new Error('竞猜已关闭,无法下注')
if (stake < (betData as any).minStake || stake > (betData as any).maxStake) {
throw new Error(`下注积分需在 ${(betData as any).minStake}~${(betData as any).maxStake} 之间`)
}
// 防止重复下注
const existing = await pb.collection('bet_entries').getList(1, 1, {
filter: `bet="${betId}" && user="${user.id}"`,
$autoCancel: false,
})
if (existing.items.length > 0) throw new Error('你已经下注过了')
// 重新读取最新积分缓解 TOCTOU (C2)
const currentUser = await pb.collection('users').getOne(user.id, { $autoCancel: false })
const currentPoints = (currentUser as any).points || 0
if (currentPoints < stake) throw new Error('积分不足')
await pb.collection('users').update(user.id, {
points: currentPoints - stake,
})
const entry = await pb.collection('bet_entries').create({
bet: betId,
user: user.id,
option: optionId,
stake,
})
await pb.collection('point_logs').create({
user: user.id,
action: 'bet',
points: -stake,
relatedId: entry.id,
})
return entry as unknown as BetEntry
}
export async function closeBet(betId: string): Promise<Bet> {
const record = await pb.collection('bets').update(betId, { status: 'closed' })
return record as unknown as Bet
}
export async function settleBet(betId: string, resultOptionId: string): Promise<void> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 先标记为 settled 防止双重结算 (C1)
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
if ((betData as any).status === 'settled') throw new Error('竞猜已结算,请勿重复操作')
if ((betData as any).creator !== user.id) throw new Error('只有发起人可以开奖') // (H2)
const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + '.000Z'
await pb.collection('bets').update(betId, {
status: 'settled',
resultOption: resultOptionId,
settledAt: now,
})
const entries = await getBetEntries(betId)
const totalPool = entries.reduce((sum, e) => sum + e.stake, 0)
const winnerEntries = entries.filter(e => e.option === resultOptionId)
const loserEntries = entries.filter(e => e.option !== resultOptionId)
// 并行标记输赢
const markPromises: Promise<void>[] = []
if (winnerEntries.length === 0) {
// 无人猜中,退还所有积分
for (const entry of entries) {
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
await pb.collection('users').update(entry.user, {
points: ((entryUser as any).points || 0) + entry.stake,
})
await pb.collection('point_logs').create({
user: entry.user,
action: 'bet',
points: entry.stake,
relatedId: entry.id,
})
await pb.collection('bet_entries').update(entry.id, { won: false })
})())
}
} else {
const winnerTotalStake = winnerEntries.reduce((sum, e) => sum + e.stake, 0)
let distributed = 0
for (const entry of winnerEntries) {
const winAmount = Math.floor((entry.stake / winnerTotalStake) * totalPool)
distributed += winAmount
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
await pb.collection('users').update(entry.user, {
points: ((entryUser as any).points || 0) + winAmount,
})
await pb.collection('point_logs').create({
user: entry.user,
action: 'bet',
points: winAmount,
relatedId: entry.id,
})
await pb.collection('bet_entries').update(entry.id, { won: true })
})())
}
// 余数分给最后一个赢家而非庄家 (C3)
const remainder = totalPool - distributed
if (remainder > 0 && winnerEntries.length > 0) {
const lastWinner = winnerEntries[winnerEntries.length - 1]
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(lastWinner.user, { $autoCancel: false })
await pb.collection('users').update(lastWinner.user, {
points: ((entryUser as any).points || 0) + remainder,
})
})())
}
for (const entry of loserEntries) {
markPromises.push(pb.collection('bet_entries').update(entry.id, { won: false }).then(() => {}))
}
}
await Promise.all(markPromises)
}
export async function subscribeBets(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('bets').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+57
View File
@@ -0,0 +1,57 @@
import { pb } from './pocketbase'
import type { BlacklistEntry } from '@/types'
export async function createBlacklistEntry(data: {
group: string
game?: string
gameName: string
reason: string
description: string
severity: string
}): Promise<BlacklistEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const payload: Record<string, any> = {
group: data.group,
reporter: user.id,
gameName: data.gameName,
reason: data.reason,
description: data.description,
severity: data.severity,
}
if (data.game) payload.game = data.game
const record = await pb.collection('game_blacklist').create(payload)
return record as unknown as BlacklistEntry
}
export async function listBlacklist(
groupId: string,
options?: { reason?: string; severity?: string }
): Promise<BlacklistEntry[]> {
let filter = `group="${groupId}"`
if (options?.reason) filter += ` && reason="${options.reason}"`
if (options?.severity) filter += ` && severity="${options.severity}"`
const result = await pb.collection('game_blacklist').getFullList({
filter,
sort: '-created',
expand: 'reporter,game',
$autoCancel: false,
})
return result as unknown as BlacklistEntry[]
}
export async function deleteBlacklistEntry(entryId: string): Promise<void> {
await pb.collection('game_blacklist').delete(entryId)
}
export async function subscribeBlacklist(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('game_blacklist').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+2 -1
View File
@@ -4,7 +4,8 @@ import type { PointLog, PointAction } from '@/types'
const POINT_MAP: Record<PointAction, number> = {
vote: 1,
team: 2,
memory: 1
memory: 1,
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
}
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {