commit 3ec8f3d2d6342df2a31c79a892556011a1d36a58 Author: root Date: Mon Jul 13 06:24:58 2026 +0200 feat: 实现 Sub2API 每日抽奖与排行榜 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e47a3b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.state +node_modules +scripts +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2a09a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.state/ +*.sqlite +*.sqlite-shm +*.sqlite-wal +dist/ +.env +*.log +logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f14e9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1.2.21-slim + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +COPY config ./config +COPY src ./src +COPY static ./static + +ENV NODE_ENV=production +EXPOSE 8080 + +CMD ["bun", "src/server.ts", "--config", "config/sub2rank.yaml", "--runtime", "k8s"] diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..48481b4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "sub2rank", + "dependencies": { + "luxon": "^3.7.2", + "yaml": "^2.8.1", + }, + "devDependencies": { + "@types/bun": "^1.2.21", + "@types/luxon": "^3.7.1", + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/luxon": ["@types/luxon@3.7.2", "", {}, "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/config/sub2rank.yaml b/config/sub2rank.yaml new file mode 100644 index 0000000..8b48b55 --- /dev/null +++ b/config/sub2rank.yaml @@ -0,0 +1,83 @@ +apiVersion: sub2rank.pikapython.com/v1 +kind: Sub2Rank + +metadata: + name: sub2rank + owner: unidesk + +sub2api: + baseUrl: https://api.pikapython.com/api/v1 + requestTimeoutMs: 20000 + pageSize: 100 + adminCredentials: + sourceRef: platform-infra/sub2api.env + emailKey: ADMIN_EMAIL + passwordKey: ADMIN_PASSWORD + +lottery: + timezone: Asia/Shanghai + initialDrawCount: 1 + dailyGrant: + hour: 6 + minute: 0 + count: 1 + eligibility: + activeWithinHours: 24 + statuses: + - active + excludedRoles: + - admin + excludedIdentities: + - zhuo + - huiguo + identityFields: + - username + - email + - emailLocalPart + prize: + amountUsd: 100 + automaticCredit: + enabled: false + mode: dry-run + notesPrefix: Sub2Rank 每日抽奖奖金 + creditTest: + targetIdentifier: admin + identityFields: + - username + - emailLocalPart + amountUsd: 0.01 + notes: Sub2Rank CLI 管理员充值链路测试 + +ranking: + timezone: Asia/Shanghai + windowDays: 1 + sourceLimit: 100 + displayLimit: 20 + +records: + publicLimit: 20 + +runtime: + secretsRoot: /root/.unidesk/.state/secrets + secretSourcePaths: + platform-infra/sub2api.env: /root/.unidesk/.state/secrets/platform-infra/sub2api.env + platform-infra/sub2rank.env: /root/.unidesk/.state/secrets/platform-infra/sub2rank.env + defaultCliTarget: local + cliTargets: + local: + mode: embedded + databasePath: .state/sub2rank.sqlite + production: + mode: http + baseUrl: https://rank.pikapython.com + adminToken: + sourceRef: platform-infra/sub2rank.env + sourceKey: SUB2RANK_ADMIN_TOKEN + serverTargets: + k8s: + listenHost: 0.0.0.0 + listenPort: 8080 + databasePath: /var/lib/sub2rank/sub2rank.sqlite + adminTokenEnv: SUB2RANK_ADMIN_TOKEN + sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL + sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e1aa97 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "sub2rank", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "cli": "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml", + "server": "bun src/server.ts --config config/sub2rank.yaml --runtime k8s", + "check": "bunx tsc --noEmit" + }, + "dependencies": { + "luxon": "^3.7.2", + "yaml": "^2.8.1" + }, + "devDependencies": { + "@types/bun": "^1.2.21", + "@types/luxon": "^3.7.1", + "typescript": "^5.9.2" + } +} diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts new file mode 100644 index 0000000..24e7be7 --- /dev/null +++ b/scripts/src/cli.ts @@ -0,0 +1,136 @@ +import { AdminHttpClient } from "../../src/admin-http-client"; +import { createEmbeddedContext } from "../../src/bootstrap"; +import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget } from "../../src/config"; + +interface Parsed { + configPath: string; + targetId: string | null; + command: string[]; + confirm: boolean; + includeRecords: boolean; + id: string | null; + limit: number | null; + draws: number | null; +} + +function value(args: string[], name: string): string | null { + const index = args.indexOf(name); + if (index < 0) return null; + const result = args[index + 1]; + if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`); + return result; +} + +function parseArgs(args: string[]): Parsed { + const configPath = value(args, "--config"); + if (!configPath) throw new Error("--config is required"); + const optionNames = new Set(["--config", "--target", "--id", "--limit", "--draws"]); + const flags = new Set(["--confirm", "--include-records"]); + const command: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const item = args[index]!; + if (optionNames.has(item)) { index += 1; continue; } + if (flags.has(item)) continue; + if (item.startsWith("--")) throw new Error(`unknown option ${item}`); + command.push(item); + } + const integer = (name: string): number | null => { + const raw = value(args, name); + if (raw === null) return null; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`); + return parsed; + }; + return { + configPath, + targetId: value(args, "--target"), + command, + confirm: args.includes("--confirm"), + includeRecords: args.includes("--include-records"), + id: value(args, "--id"), + limit: integer("--limit"), + draws: integer("--draws"), + }; +} + +function help(): Record { + return { + ok: true, + usage: "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml [--target local|production] ", + commands: [ + "config validate", + "backend check", + "lottery status", + "lottery draw [--confirm]", + "lottery reset [--draws N] [--include-records] --confirm", + "records list [--limit N]", + "records delete --id --confirm", + "credit test [--confirm]", + ], + }; +} + +async function embedded(parsed: Parsed, config: ReturnType, target: EmbeddedCliTarget): Promise { + const context = createEmbeddedContext(config, target); + try { + const [group, action] = parsed.command; + if (group === "backend" && action === "check") return await context.service.status(true); + if (group === "lottery" && action === "status") return await context.service.status(false); + if (group === "lottery" && action === "draw") return parsed.confirm ? { ok: true, record: await context.service.draw() } : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" }; + if (group === "lottery" && action === "reset") { + const draws = parsed.draws ?? config.lottery.initialDrawCount; + return parsed.confirm ? context.service.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" }; + } + if (group === "records" && action === "list") return { ok: true, records: context.service.listRecords(parsed.limit ?? config.records.publicLimit) }; + if (group === "records" && action === "delete") { + if (!parsed.id) throw new Error("records delete requires --id"); + return parsed.confirm ? context.service.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" }; + } + if (group === "credit" && action === "test") return await context.service.creditTest(parsed.confirm); + throw new Error(`unknown command: ${parsed.command.join(" ")}`); + } finally { + context.close(); + } +} + +async function remote(parsed: Parsed, config: ReturnType, target: HttpCliTarget): Promise { + const client = new AdminHttpClient(config, target); + const [group, action] = parsed.command; + if (group === "backend" && action === "check") return await client.backendCheck(); + if (group === "lottery" && action === "status") return await client.status(); + if (group === "lottery" && action === "draw") return parsed.confirm ? await client.draw() : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" }; + if (group === "lottery" && action === "reset") { + const draws = parsed.draws ?? config.lottery.initialDrawCount; + return parsed.confirm ? await client.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" }; + } + if (group === "records" && action === "list") return await client.records(parsed.limit ?? config.records.publicLimit); + if (group === "records" && action === "delete") { + if (!parsed.id) throw new Error("records delete requires --id"); + return parsed.confirm ? await client.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" }; + } + if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm); + throw new Error(`unknown command: ${parsed.command.join(" ")}`); +} + +export async function runCli(args: string[]): Promise { + try { + if (args.includes("--help") || args.length === 0) { + console.log(JSON.stringify(help(), null, 2)); + return; + } + const parsed = parseArgs(args); + const config = loadConfig(parsed.configPath); + if (parsed.command.join(" ") === "config validate") { + console.log(JSON.stringify({ ok: true, configPath: config.configPath, kind: config.kind, automaticCreditEnabled: config.lottery.automaticCredit.enabled, excludedIdentities: config.lottery.eligibility.excludedIdentities }, null, 2)); + return; + } + const targetId = parsed.targetId ?? config.runtime.defaultCliTarget; + const target = config.runtime.cliTargets[targetId]; + if (!target) throw new Error(`runtime.cliTargets.${targetId} does not exist`); + const result = target.mode === "embedded" ? await embedded(parsed, config, target) : await remote(parsed, config, target); + console.log(JSON.stringify({ target: targetId, ...result as Record }, null, 2)); + } catch (error) { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2)); + process.exitCode = 1; + } +} diff --git a/scripts/sub2rank-cli.ts b/scripts/sub2rank-cli.ts new file mode 100644 index 0000000..ba3a71e --- /dev/null +++ b/scripts/sub2rank-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { runCli } from "./src/cli"; + +await runCli(process.argv.slice(2)); diff --git a/src/admin-http-client.ts b/src/admin-http-client.ts new file mode 100644 index 0000000..2dc91f1 --- /dev/null +++ b/src/admin-http-client.ts @@ -0,0 +1,34 @@ +import type { AppConfig, HttpCliTarget } from "./config"; +import { readSecret } from "./secrets"; + +export class AdminHttpClient { + private readonly token: string; + + constructor(private readonly config: AppConfig, private readonly target: HttpCliTarget) { + this.token = readSecret(config, target.adminToken); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${this.token}`); + if (init.body) headers.set("content-type", "application/json"); + const response = await fetch(`${this.target.baseUrl.replace(/\/$/u, "")}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs), + }); + const payload = await response.json().catch(() => null) as { ok?: boolean; error?: string } | null; + if (!response.ok || !payload) throw new Error(payload?.error ?? `Sub2Rank admin API failed with HTTP ${response.status}`); + return payload as T; + } + + status(): Promise> { return this.request("/api/admin/status"); } + backendCheck(): Promise> { return this.request("/api/admin/backend-check"); } + draw(): Promise> { return this.request("/api/admin/draw", { method: "POST" }); } + reset(draws: number, includeRecords: boolean): Promise> { + return this.request("/api/admin/reset", { method: "POST", body: JSON.stringify({ draws, includeRecords }) }); + } + records(limit: number): Promise> { return this.request(`/api/admin/records?limit=${limit}`); } + deleteRecord(id: string): Promise> { return this.request(`/api/admin/records/${encodeURIComponent(id)}`, { method: "DELETE" }); } + creditTest(execute: boolean): Promise> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); } +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..67d9301 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,27 @@ +import type { AppConfig, EmbeddedCliTarget, ServerTarget } from "./config"; +import { resolveDataPath } from "./config"; +import { LotteryService } from "./lottery-service"; +import { readSub2ApiCredentials } from "./secrets"; +import { LotteryStore } from "./store"; +import { Sub2ApiClient } from "./sub2api-client"; + +export interface AppContext { + service: LotteryService; + store: LotteryStore; + close(): void; +} + +export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext { + const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); + const client = new Sub2ApiClient(config, readSub2ApiCredentials(config)); + return { service: new LotteryService(config, store, client), store, close: () => store.close() }; +} + +export function createServerContext(config: AppConfig, target: ServerTarget): AppContext { + const email = process.env[target.sub2apiAdminEmailEnv]; + const password = process.env[target.sub2apiAdminPasswordEnv]; + if (!email || !password) throw new Error(`server target requires env ${target.sub2apiAdminEmailEnv} and ${target.sub2apiAdminPasswordEnv}`); + const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); + const client = new Sub2ApiClient(config, { email, password }); + return { service: new LotteryService(config, store, client), store, close: () => store.close() }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..618b267 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,240 @@ +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { DateTime } from "luxon"; +import { parse } from "yaml"; + +export type IdentityField = "username" | "email" | "emailLocalPart"; + +export interface SecretRef { + sourceRef: string; + sourceKey: string; +} + +export interface AppConfig { + apiVersion: string; + kind: string; + metadata: { name: string; owner: string }; + sub2api: { + baseUrl: string; + requestTimeoutMs: number; + pageSize: number; + adminCredentials: { sourceRef: string; emailKey: string; passwordKey: string }; + }; + lottery: { + timezone: string; + initialDrawCount: number; + dailyGrant: { hour: number; minute: number; count: number }; + eligibility: { + activeWithinHours: number; + statuses: string[]; + excludedRoles: string[]; + excludedIdentities: string[]; + identityFields: IdentityField[]; + }; + prize: { amountUsd: number }; + automaticCredit: { enabled: boolean; mode: "dry-run" | "live"; notesPrefix: string }; + creditTest: { + targetIdentifier: string; + identityFields: IdentityField[]; + amountUsd: number; + notes: string; + }; + }; + ranking: { timezone: string; windowDays: number; sourceLimit: number; displayLimit: number }; + records: { publicLimit: number }; + runtime: { + secretsRoot: string; + secretSourcePaths: Record; + defaultCliTarget: string; + cliTargets: Record; + serverTargets: Record; + }; + configPath: string; + rootDirectory: string; +} + +export interface EmbeddedCliTarget { + mode: "embedded"; + databasePath: string; +} + +export interface HttpCliTarget { + mode: "http"; + baseUrl: string; + adminToken: SecretRef; +} + +export interface ServerTarget { + listenHost: string; + listenPort: number; + databasePath: string; + adminTokenEnv: string; + sub2apiAdminEmailEnv: string; + sub2apiAdminPasswordEnv: string; +} + +type ObjectValue = Record; + +function object(value: unknown, path: string): ObjectValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as ObjectValue; +} + +function stringValue(parent: ObjectValue, key: string, path: string): string { + const value = parent[key]; + if (typeof value !== "string" || value.trim() === "") throw new Error(`${path}.${key} must be a non-empty string`); + return value; +} + +function numberValue(parent: ObjectValue, key: string, path: string, minimum = 0): number { + const value = parent[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) throw new Error(`${path}.${key} must be a number >= ${minimum}`); + return value; +} + +function integerValue(parent: ObjectValue, key: string, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + const value = parent[key]; + if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) throw new Error(`${path}.${key} must be an integer between ${minimum} and ${maximum}`); + return Number(value); +} + +function timezoneValue(parent: ObjectValue, key: string, path: string): string { + const value = stringValue(parent, key, path); + if (!DateTime.now().setZone(value).isValid) throw new Error(`${path}.${key} must be a valid IANA timezone`); + return value; +} + +function booleanValue(parent: ObjectValue, key: string, path: string): boolean { + const value = parent[key]; + if (typeof value !== "boolean") throw new Error(`${path}.${key} must be boolean`); + return value; +} + +function strings(parent: ObjectValue, key: string, path: string): string[] { + const value = parent[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new Error(`${path}.${key} must be a string array`); + return value as string[]; +} + +function identityFields(parent: ObjectValue, key: string, path: string): IdentityField[] { + const values = strings(parent, key, path); + const supported = new Set(["username", "email", "emailLocalPart"]); + if (values.some((value) => !supported.has(value))) throw new Error(`${path}.${key} contains an unsupported identity field`); + return values as IdentityField[]; +} + +function secretRef(value: unknown, path: string): SecretRef { + const raw = object(value, path); + return { sourceRef: stringValue(raw, "sourceRef", path), sourceKey: stringValue(raw, "sourceKey", path) }; +} + +export function loadConfig(path: string): AppConfig { + const configPath = resolve(path); + const rootDirectory = resolve(dirname(configPath), ".."); + const raw = object(parse(readFileSync(configPath, "utf8")), "config"); + const metadata = object(raw.metadata, "metadata"); + const sub2api = object(raw.sub2api, "sub2api"); + const adminCredentials = object(sub2api.adminCredentials, "sub2api.adminCredentials"); + const lottery = object(raw.lottery, "lottery"); + const dailyGrant = object(lottery.dailyGrant, "lottery.dailyGrant"); + const eligibility = object(lottery.eligibility, "lottery.eligibility"); + const prize = object(lottery.prize, "lottery.prize"); + const automaticCredit = object(lottery.automaticCredit, "lottery.automaticCredit"); + const creditTest = object(lottery.creditTest, "lottery.creditTest"); + const ranking = object(raw.ranking, "ranking"); + const records = object(raw.records, "records"); + const runtime = object(raw.runtime, "runtime"); + const secretSourcePathsRaw = object(runtime.secretSourcePaths, "runtime.secretSourcePaths"); + const cliTargetsRaw = object(runtime.cliTargets, "runtime.cliTargets"); + const serverTargetsRaw = object(runtime.serverTargets, "runtime.serverTargets"); + const cliTargets: Record = {}; + for (const [id, value] of Object.entries(cliTargetsRaw)) { + const target = object(value, `runtime.cliTargets.${id}`); + const mode = stringValue(target, "mode", `runtime.cliTargets.${id}`); + if (mode === "embedded") cliTargets[id] = { mode, databasePath: stringValue(target, "databasePath", `runtime.cliTargets.${id}`) }; + else if (mode === "http") cliTargets[id] = { mode, baseUrl: stringValue(target, "baseUrl", `runtime.cliTargets.${id}`), adminToken: secretRef(target.adminToken, `runtime.cliTargets.${id}.adminToken`) }; + else throw new Error(`runtime.cliTargets.${id}.mode must be embedded or http`); + } + const serverTargets: Record = {}; + for (const [id, value] of Object.entries(serverTargetsRaw)) { + const target = object(value, `runtime.serverTargets.${id}`); + serverTargets[id] = { + listenHost: stringValue(target, "listenHost", `runtime.serverTargets.${id}`), + listenPort: numberValue(target, "listenPort", `runtime.serverTargets.${id}`, 1), + databasePath: stringValue(target, "databasePath", `runtime.serverTargets.${id}`), + adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`), + sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`), + sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`), + }; + } + const automaticMode = stringValue(automaticCredit, "mode", "lottery.automaticCredit"); + if (automaticMode !== "dry-run" && automaticMode !== "live") throw new Error("lottery.automaticCredit.mode must be dry-run or live"); + const defaultCliTarget = stringValue(runtime, "defaultCliTarget", "runtime"); + if (!cliTargets[defaultCliTarget]) throw new Error(`runtime.defaultCliTarget references missing target ${defaultCliTarget}`); + return { + apiVersion: stringValue(raw, "apiVersion", "config"), + kind: stringValue(raw, "kind", "config"), + metadata: { name: stringValue(metadata, "name", "metadata"), owner: stringValue(metadata, "owner", "metadata") }, + sub2api: { + baseUrl: stringValue(sub2api, "baseUrl", "sub2api").replace(/\/$/u, ""), + requestTimeoutMs: integerValue(sub2api, "requestTimeoutMs", "sub2api", 1), + pageSize: integerValue(sub2api, "pageSize", "sub2api", 1, 100), + adminCredentials: { + sourceRef: stringValue(adminCredentials, "sourceRef", "sub2api.adminCredentials"), + emailKey: stringValue(adminCredentials, "emailKey", "sub2api.adminCredentials"), + passwordKey: stringValue(adminCredentials, "passwordKey", "sub2api.adminCredentials"), + }, + }, + lottery: { + timezone: timezoneValue(lottery, "timezone", "lottery"), + initialDrawCount: integerValue(lottery, "initialDrawCount", "lottery"), + dailyGrant: { + hour: integerValue(dailyGrant, "hour", "lottery.dailyGrant", 0, 23), + minute: integerValue(dailyGrant, "minute", "lottery.dailyGrant", 0, 59), + count: integerValue(dailyGrant, "count", "lottery.dailyGrant", 1), + }, + eligibility: { + activeWithinHours: integerValue(eligibility, "activeWithinHours", "lottery.eligibility", 1), + statuses: strings(eligibility, "statuses", "lottery.eligibility"), + excludedRoles: strings(eligibility, "excludedRoles", "lottery.eligibility"), + excludedIdentities: strings(eligibility, "excludedIdentities", "lottery.eligibility"), + identityFields: identityFields(eligibility, "identityFields", "lottery.eligibility"), + }, + prize: { amountUsd: numberValue(prize, "amountUsd", "lottery.prize", 0.01) }, + automaticCredit: { + enabled: booleanValue(automaticCredit, "enabled", "lottery.automaticCredit"), + mode: automaticMode, + notesPrefix: stringValue(automaticCredit, "notesPrefix", "lottery.automaticCredit"), + }, + creditTest: { + targetIdentifier: stringValue(creditTest, "targetIdentifier", "lottery.creditTest"), + identityFields: identityFields(creditTest, "identityFields", "lottery.creditTest"), + amountUsd: numberValue(creditTest, "amountUsd", "lottery.creditTest", 0.01), + notes: stringValue(creditTest, "notes", "lottery.creditTest"), + }, + }, + ranking: { + timezone: timezoneValue(ranking, "timezone", "ranking"), + windowDays: integerValue(ranking, "windowDays", "ranking", 1), + sourceLimit: integerValue(ranking, "sourceLimit", "ranking", 1), + displayLimit: integerValue(ranking, "displayLimit", "ranking", 1), + }, + records: { publicLimit: integerValue(records, "publicLimit", "records", 1) }, + runtime: { + secretsRoot: stringValue(runtime, "secretsRoot", "runtime"), + secretSourcePaths: Object.fromEntries(Object.entries(secretSourcePathsRaw).map(([ref, value]) => { + if (typeof value !== "string" || value.trim() === "") throw new Error(`runtime.secretSourcePaths.${ref} must be a non-empty string`); + return [ref, value]; + })), + defaultCliTarget, + cliTargets, + serverTargets, + }, + configPath, + rootDirectory, + }; +} + +export function resolveDataPath(config: AppConfig, value: string): string { + return isAbsolute(value) ? value : resolve(config.rootDirectory, value); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..42c5496 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,63 @@ +import type { LotteryService } from "./lottery-service"; +import { resolve } from "node:path"; + +const staticRoot = resolve(import.meta.dir, "../static"); + +function json(data: unknown, status = 200): Response { + return Response.json(data, { status, headers: { "cache-control": "no-store" } }); +} + +async function body(request: Request): Promise> { + return await request.json().catch(() => ({})) as Record; +} + +function errorResponse(error: unknown, publicRequest: boolean): Response { + const message = error instanceof Error ? error.message : String(error); + const status = /does not exist|no draw chance|no eligible/u.test(message) ? 409 : 500; + if (status >= 500) console.error(JSON.stringify({ ok: false, path: publicRequest ? "public" : "admin", error: message })); + return json({ ok: false, error: publicRequest && status >= 500 ? "开奖服务暂时不可用,请稍后重试" : message }, status); +} + +async function staticFile(name: string, contentType: string): Promise { + const file = Bun.file(resolve(staticRoot, name)); + if (!(await file.exists())) return json({ ok: false, error: "not found" }, 404); + return new Response(file, { headers: { "content-type": contentType, "cache-control": name === "index.html" ? "no-cache" : "public, max-age=300" } }); +} + +export function createHandler(service: LotteryService, adminToken: string): (request: Request) => Promise { + return async (request) => { + const url = new URL(request.url); + try { + if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, service: "sub2rank" }); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/") return await staticFile("index.html", "text/html; charset=utf-8"); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/styles.css") return await staticFile("styles.css", "text/css; charset=utf-8"); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/app.js") return await staticFile("app.js", "text/javascript; charset=utf-8"); + if (request.method === "GET" && url.pathname === "/api/public/state") return json(await service.publicState()); + if (request.method === "POST" && url.pathname === "/api/public/draw") return json({ ok: true, record: await service.publicDraw() }); + if (!url.pathname.startsWith("/api/admin/")) return json({ ok: false, error: "not found" }, 404); + if (request.headers.get("authorization") !== `Bearer ${adminToken}`) return json({ ok: false, error: "unauthorized" }, 401); + if (request.method === "GET" && url.pathname === "/api/admin/status") return json(await service.status(false)); + if (request.method === "GET" && url.pathname === "/api/admin/backend-check") return json(await service.status(true)); + if (request.method === "POST" && url.pathname === "/api/admin/draw") return json({ ok: true, record: await service.draw() }); + if (request.method === "POST" && url.pathname === "/api/admin/reset") { + const input = await body(request); + if (!Number.isInteger(input.draws) || Number(input.draws) < 0 || typeof input.includeRecords !== "boolean") return json({ ok: false, error: "draws must be a non-negative integer and includeRecords must be boolean" }, 400); + return json(service.reset(Number(input.draws), input.includeRecords)); + } + if (request.method === "GET" && url.pathname === "/api/admin/records") { + const limit = Number(url.searchParams.get("limit")); + if (!Number.isInteger(limit) || limit < 1) return json({ ok: false, error: "limit must be a positive integer" }, 400); + return json({ ok: true, records: service.listRecords(limit) }); + } + if (request.method === "DELETE" && url.pathname.startsWith("/api/admin/records/")) return json(service.deleteRecord(decodeURIComponent(url.pathname.slice("/api/admin/records/".length)))); + if (request.method === "POST" && url.pathname === "/api/admin/credit-test") { + const input = await body(request); + if (typeof input.execute !== "boolean") return json({ ok: false, error: "execute must be boolean" }, 400); + return json(await service.creditTest(input.execute)); + } + return json({ ok: false, error: "not found" }, 404); + } catch (error) { + return errorResponse(error, url.pathname.startsWith("/api/public/")); + } + }; +} diff --git a/src/lottery-service.ts b/src/lottery-service.ts new file mode 100644 index 0000000..335ebcc --- /dev/null +++ b/src/lottery-service.ts @@ -0,0 +1,225 @@ +import { DateTime } from "luxon"; +import { createHash, randomInt, randomUUID } from "node:crypto"; +import type { AppConfig, IdentityField } from "./config"; +import { LotteryStore } from "./store"; +import { Sub2ApiClient } from "./sub2api-client"; +import type { DrawRecord, PublicDrawRecord, PublicRankingRow, Sub2ApiUser } from "./types"; + +class Mutex { + private tail = Promise.resolve(); + + async run(operation: () => Promise): Promise { + const previous = this.tail; + let release!: () => void; + this.tail = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function identityValues(user: Sub2ApiUser, fields: IdentityField[]): string[] { + return fields.map((field) => { + if (field === "username") return normalized(user.username ?? ""); + if (field === "email") return normalized(user.email ?? ""); + return normalized((user.email ?? "").split("@", 1)[0] ?? ""); + }).filter(Boolean); +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@", 2); + if (!domain) return "匿名用户"; + const visible = local.slice(0, Math.min(2, local.length)); + return `${visible}${"*".repeat(Math.max(2, Math.min(5, local.length - visible.length)))}@${domain}`; +} + +function displayName(user: Sub2ApiUser): string { + return user.username.trim() || maskEmail(user.email); +} + +export class LotteryService { + private readonly mutex = new Mutex(); + + constructor( + readonly config: AppConfig, + private readonly store: LotteryStore, + private readonly sub2api: Sub2ApiClient, + ) {} + + private excluded(user: Sub2ApiUser): boolean { + const eligibility = this.config.lottery.eligibility; + const roles = new Set(eligibility.excludedRoles.map(normalized)); + const statuses = new Set(eligibility.statuses.map(normalized)); + const identities = new Set(eligibility.excludedIdentities.map(normalized)); + return roles.has(normalized(user.role)) || !statuses.has(normalized(user.status)) || identityValues(user, eligibility.identityFields).some((value) => identities.has(value)); + } + + async eligibleUsers(now = new Date()): Promise { + const cutoff = now.getTime() - this.config.lottery.eligibility.activeWithinHours * 60 * 60 * 1000; + return (await this.sub2api.listUsers()).filter((user) => { + if (this.excluded(user) || !user.last_active_at) return false; + const activeAt = Date.parse(user.last_active_at); + return Number.isFinite(activeAt) && activeAt >= cutoff && activeAt <= now.getTime(); + }); + } + + async ranking(now = new Date()): Promise<{ rows: PublicRankingRow[]; totals: { actualCost: number; requests: number; tokens: number }; startDate: string; endDate: string }> { + const local = DateTime.fromJSDate(now, { zone: this.config.ranking.timezone }); + const startDate = local.minus({ days: this.config.ranking.windowDays - 1 }).toISODate(); + const endDate = local.toISODate(); + if (!startDate || !endDate) throw new Error("failed to calculate ranking date range"); + const [source, users] = await Promise.all([this.sub2api.getUsageRanking(startDate, endDate), this.sub2api.listUsers()]); + const userMap = new Map(users.map((user) => [user.id, user])); + const filtered = source.ranking + .map((row) => ({ row, user: userMap.get(row.user_id) })) + .filter((item): item is { row: typeof item.row; user: Sub2ApiUser } => Boolean(item.user) && !this.excluded(item.user!)); + const rows = filtered + .slice(0, this.config.ranking.displayLimit) + .map(({ row, user }, index) => ({ + rank: index + 1, + displayName: displayName(user), + actualCost: row.actual_cost, + requests: row.requests, + tokens: row.tokens, + })); + return { + rows, + totals: filtered.reduce((totals, { row }) => ({ + actualCost: totals.actualCost + row.actual_cost, + requests: totals.requests + row.requests, + tokens: totals.tokens + row.tokens, + }), { actualCost: 0, requests: 0, tokens: 0 }), + startDate, + endDate, + }; + } + + private publicRecord(record: DrawRecord): PublicDrawRecord { + return { + id: record.id, + drawnAt: record.drawnAt, + eligibleCount: record.eligibleCount, + winnerDisplayName: record.winnerDisplayName, + prizeAmountUsd: record.prizeAmountUsd, + creditStatus: record.creditStatus, + }; + } + + async publicState(): Promise> { + const state = await this.status(true); + return { + ...state, + records: this.store.listRecords(this.config.records.publicLimit).map((record) => this.publicRecord(record)), + rankingWindowDays: this.config.ranking.windowDays, + }; + } + + async publicDraw(): Promise { + return this.publicRecord(await this.draw()); + } + + async status(includeLiveData = false): Promise> { + const grants = this.store.reconcileGrants(); + const result: Record = { + ok: true, + remainingDraws: grants.remainingDraws, + nextGrantAt: grants.nextGrantAt, + dailyGrantCount: this.config.lottery.dailyGrant.count, + prizeAmountUsd: this.config.lottery.prize.amountUsd, + activeWithinHours: this.config.lottery.eligibility.activeWithinHours, + automaticCredit: { + enabled: this.config.lottery.automaticCredit.enabled, + mode: this.config.lottery.automaticCredit.mode, + }, + records: this.store.listRecords(this.config.records.publicLimit), + }; + if (includeLiveData) { + const [eligible, ranking] = await Promise.all([this.eligibleUsers(), this.ranking()]); + result.eligibleUserCount = eligible.length; + result.ranking = ranking; + } + return result; + } + + async draw(): Promise { + return await this.mutex.run(async () => { + const state = this.store.reconcileGrants(); + if (state.remainingDraws < 1) throw new Error("no draw chance is available"); + const users = await this.eligibleUsers(); + if (users.length === 0) throw new Error("no eligible user was active in the configured window"); + const winner = users[randomInt(users.length)]!; + const record: DrawRecord = { + id: randomUUID(), + drawnAt: new Date().toISOString(), + eligibleCount: users.length, + winnerUserId: winner.id, + winnerDisplayName: displayName(winner), + prizeAmountUsd: this.config.lottery.prize.amountUsd, + creditStatus: this.config.lottery.automaticCredit.enabled ? (this.config.lottery.automaticCredit.mode === "dry-run" ? "dry_run" : "pending") : "disabled", + creditMessage: this.config.lottery.automaticCredit.enabled ? (this.config.lottery.automaticCredit.mode === "dry-run" ? "YAML dry-run:未修改账户额度" : "等待自动充值") : "YAML 开关关闭:未修改账户额度", + }; + this.store.createDraw(record); + if (this.config.lottery.automaticCredit.enabled && this.config.lottery.automaticCredit.mode === "live") { + try { + await this.sub2api.addBalance(winner.id, record.prizeAmountUsd, `${this.config.lottery.automaticCredit.notesPrefix} ${record.id}`); + record.creditStatus = "succeeded"; + record.creditMessage = "Sub2API 余额增加成功"; + } catch (error) { + record.creditStatus = "failed"; + record.creditMessage = error instanceof Error ? error.message : String(error); + } + this.store.updateCredit(record.id, record.creditStatus, record.creditMessage); + } + return record; + }); + } + + reset(draws: number, includeRecords: boolean): Record { + const result = this.store.resetData(draws, includeRecords); + return { ok: true, ...result, includeRecords }; + } + + listRecords(limit: number): DrawRecord[] { + return this.store.listRecords(limit); + } + + deleteRecord(id: string): Record { + const deleted = this.store.deleteRecord(id); + if (!deleted) throw new Error(`draw record ${id} does not exist`); + return { ok: true, deletedId: id }; + } + + async creditTest(execute: boolean): Promise> { + const test = this.config.lottery.creditTest; + const users = await this.sub2api.listUsers(); + const target = users.find((user) => normalized(user.role) === "admin" && identityValues(user, test.identityFields).includes(normalized(test.targetIdentifier))); + if (!target) throw new Error("configured admin credit-test target was not found"); + const plan = { + ok: true, + execute, + targetUserId: target.id, + targetDisplayName: displayName(target), + role: target.role, + amountUsd: test.amountUsd, + automaticCreditEnabled: this.config.lottery.automaticCredit.enabled, + }; + if (!execute) return plan; + const before = target.balance; + const updated = await this.sub2api.addBalance(target.id, test.amountUsd, test.notes); + const observedDeltaUsd = Number((updated.balance - before).toFixed(8)); + if (Math.abs(observedDeltaUsd - test.amountUsd) > 0.00000001) throw new Error(`admin credit test delta mismatch: expected ${test.amountUsd}, observed ${observedDeltaUsd}`); + return { + ...plan, + credited: true, + observedDeltaUsd, + operationFingerprint: createHash("sha256").update(`${target.id}:${test.amountUsd}:${test.notes}`).digest("hex").slice(0, 16), + }; + } +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..8cc7e54 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import type { AppConfig, SecretRef } from "./config"; + +function parseEnv(text: string): Map { + const values = new Map(); + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index < 1) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1); + values.set(key, value); + } + return values; +} + +export function readSecret(config: AppConfig, ref: SecretRef): string { + const path = config.runtime.secretSourcePaths[ref.sourceRef]; + if (!path) throw new Error(`runtime.secretSourcePaths is missing sourceRef ${ref.sourceRef}`); + const value = parseEnv(readFileSync(path, "utf8")).get(ref.sourceKey); + if (!value) throw new Error(`secret source ${ref.sourceRef} is missing key ${ref.sourceKey}`); + return value; +} + +export function readSub2ApiCredentials(config: AppConfig): { email: string; password: string } { + const sourceRef = config.sub2api.adminCredentials.sourceRef; + return { + email: readSecret(config, { sourceRef, sourceKey: config.sub2api.adminCredentials.emailKey }), + password: readSecret(config, { sourceRef, sourceKey: config.sub2api.adminCredentials.passwordKey }), + }; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..4a12bbf --- /dev/null +++ b/src/server.ts @@ -0,0 +1,33 @@ +import { loadConfig } from "./config"; +import { createServerContext } from "./bootstrap"; +import { createHandler } from "./http"; + +function option(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value) throw new Error(`${name} is required`); + return value; +} + +const config = loadConfig(option("--config")); +const runtimeId = option("--runtime"); +const target = config.runtime.serverTargets[runtimeId]; +if (!target) throw new Error(`runtime.serverTargets.${runtimeId} does not exist`); +const adminToken = process.env[target.adminTokenEnv]; +if (!adminToken) throw new Error(`server target requires env ${target.adminTokenEnv}`); +const context = createServerContext(config, target); +const server = Bun.serve({ + hostname: target.listenHost, + port: target.listenPort, + fetch: createHandler(context.service, adminToken), +}); + +console.log(JSON.stringify({ ok: true, service: config.metadata.name, runtime: runtimeId, listen: server.url.toString(), automaticCreditEnabled: config.lottery.automaticCredit.enabled })); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + context.close(); + server.stop(true); + process.exit(0); + }); +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..b90823c --- /dev/null +++ b/src/store.ts @@ -0,0 +1,147 @@ +import { Database } from "bun:sqlite"; +import { dirname } from "node:path"; +import { mkdirSync } from "node:fs"; +import { DateTime } from "luxon"; +import type { AppConfig } from "./config"; +import type { DrawRecord } from "./types"; + +interface StoredRecordRow { + id: string; + drawn_at: string; + eligible_count: number; + winner_user_id: number; + winner_display_name: string; + prize_amount_usd: number; + credit_status: DrawRecord["creditStatus"]; + credit_message: string | null; +} + +export class LotteryStore { + private readonly db: Database; + + constructor(private readonly config: AppConfig, path: string) { + mkdirSync(dirname(path), { recursive: true }); + this.db = new Database(path, { create: true, strict: true }); + this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;"); + this.db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS draw_records ( + id TEXT PRIMARY KEY, + drawn_at TEXT NOT NULL, + eligible_count INTEGER NOT NULL, + winner_user_id INTEGER NOT NULL, + winner_display_name TEXT NOT NULL, + prize_amount_usd REAL NOT NULL, + credit_status TEXT NOT NULL, + credit_message TEXT + ); + CREATE INDEX IF NOT EXISTS draw_records_drawn_at ON draw_records(drawn_at DESC); + `); + } + + close(): void { + this.db.close(); + } + + private meta(key: string): string | null { + const row = this.db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?").get(key); + return row?.value ?? null; + } + + private setMeta(key: string, value: string): void { + this.db.query("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value); + } + + private latestGrant(now: Date): DateTime { + const zone = this.config.lottery.timezone; + const local = DateTime.fromJSDate(now, { zone }); + let grant = local.set({ hour: this.config.lottery.dailyGrant.hour, minute: this.config.lottery.dailyGrant.minute, second: 0, millisecond: 0 }); + if (grant > local) grant = grant.minus({ days: 1 }); + return grant; + } + + reconcileGrants(now = new Date()): { remainingDraws: number; nextGrantAt: string; added: number } { + return this.db.transaction(() => { + const latest = this.latestGrant(now); + const latestKey = latest.toISODate(); + if (!latestKey) throw new Error("failed to calculate latest grant key"); + const previousKey = this.meta("last_grant_key"); + let remaining = Number(this.meta("remaining_draws") ?? "0"); + let added = 0; + if (previousKey === null) { + remaining = this.config.lottery.initialDrawCount; + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(remaining)); + } else { + const previous = DateTime.fromISO(previousKey, { zone: this.config.lottery.timezone }).startOf("day"); + const days = Math.max(0, Math.round(latest.startOf("day").diff(previous, "days").days)); + added = days * this.config.lottery.dailyGrant.count; + if (added > 0) { + remaining += added; + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(remaining)); + } + } + const nextGrantAt = latest.plus({ days: 1 }).toUTC().toISO(); + if (!nextGrantAt) throw new Error("failed to calculate next grant time"); + return { remainingDraws: remaining, nextGrantAt, added }; + })(); + } + + createDraw(record: DrawRecord): void { + this.db.transaction(() => { + const remaining = Number(this.meta("remaining_draws") ?? "0"); + if (remaining < 1) throw new Error("no draw chance is available"); + this.db.query(`INSERT INTO draw_records( + id, drawn_at, eligible_count, winner_user_id, winner_display_name, prize_amount_usd, credit_status, credit_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( + record.id, + record.drawnAt, + record.eligibleCount, + record.winnerUserId, + record.winnerDisplayName, + record.prizeAmountUsd, + record.creditStatus, + record.creditMessage, + ); + this.setMeta("remaining_draws", String(remaining - 1)); + })(); + } + + updateCredit(id: string, status: DrawRecord["creditStatus"], message: string | null): void { + const result = this.db.query("UPDATE draw_records SET credit_status = ?, credit_message = ? WHERE id = ?").run(status, message, id); + if (result.changes !== 1) throw new Error(`draw record ${id} does not exist`); + } + + listRecords(limit: number): DrawRecord[] { + const rows = this.db.query("SELECT * FROM draw_records ORDER BY drawn_at DESC LIMIT ?").all(limit); + return rows.map((row) => ({ + id: row.id, + drawnAt: row.drawn_at, + eligibleCount: row.eligible_count, + winnerUserId: row.winner_user_id, + winnerDisplayName: row.winner_display_name, + prizeAmountUsd: row.prize_amount_usd, + creditStatus: row.credit_status, + creditMessage: row.credit_message, + })); + } + + deleteRecord(id: string): boolean { + return this.db.query("DELETE FROM draw_records WHERE id = ?").run(id).changes === 1; + } + + resetData(draws: number, includeRecords: boolean, now = new Date()): { remainingDraws: number; recordsDeleted: number } { + return this.db.transaction(() => { + const deleted = includeRecords ? this.db.query("DELETE FROM draw_records").run().changes : 0; + const latestKey = this.latestGrant(now).toISODate(); + if (!latestKey) throw new Error("failed to calculate reset grant key"); + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(draws)); + return { remainingDraws: draws, recordsDeleted: deleted }; + })(); + } +} diff --git a/src/sub2api-client.ts b/src/sub2api-client.ts new file mode 100644 index 0000000..947c209 --- /dev/null +++ b/src/sub2api-client.ts @@ -0,0 +1,94 @@ +import type { AppConfig } from "./config"; +import type { RankingSourceRow, Sub2ApiUser } from "./types"; + +interface Envelope { + code: number; + message: string; + data: T; +} + +interface Paginated { + items: T[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export class Sub2ApiClient { + private token: string | null = null; + private tokenExpiresAt = 0; + + constructor( + private readonly config: AppConfig, + private readonly credentials: { email: string; password: string }, + ) {} + + private async request(path: string, init: RequestInit = {}, authenticate = true): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body) headers.set("content-type", "application/json"); + if (authenticate) headers.set("authorization", `Bearer ${await this.accessToken()}`); + const response = await fetch(`${this.config.sub2api.baseUrl}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs), + }); + const payload = (await response.json().catch(() => null)) as Envelope | null; + if (!response.ok || !payload || payload.code !== 0) throw new Error(`Sub2API ${init.method ?? "GET"} ${path} failed: HTTP ${response.status} ${payload?.message ?? "invalid response"}`); + return payload.data; + } + + private async accessToken(): Promise { + if (this.token && Date.now() < this.tokenExpiresAt) return this.token; + const data = await this.request<{ access_token: string; expires_in?: number }>("/auth/login", { + method: "POST", + body: JSON.stringify(this.credentials), + }, false); + if (!data.access_token) throw new Error("Sub2API login response is missing access_token"); + this.token = data.access_token; + const lifetimeSeconds = data.expires_in && data.expires_in > 120 ? data.expires_in - 60 : 300; + this.tokenExpiresAt = Date.now() + lifetimeSeconds * 1000; + return this.token; + } + + async listUsers(): Promise { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const users = new Map(); + let expectedTotal = 0; + let page = 1; + for (;;) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(this.config.sub2api.pageSize), + sort_by: "id", + sort_order: "asc", + }); + const data = await this.request>(`/admin/users?${params}`); + expectedTotal = data.total; + for (const user of data.items) users.set(user.id, user); + if (page >= data.pages) break; + page += 1; + } + if (users.size === expectedTotal) return [...users.values()].sort((left, right) => left.id - right.id); + } + throw new Error("Sub2API user list changed during pagination; retry the operation"); + } + + async getUsageRanking(startDate: string, endDate: string): Promise<{ ranking: RankingSourceRow[]; total_actual_cost: number; total_requests: number; total_tokens: number }> { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + timezone: this.config.ranking.timezone, + limit: String(this.config.ranking.sourceLimit), + }); + return await this.request(`/admin/dashboard/users-ranking?${params}`); + } + + async addBalance(userId: number, amountUsd: number, notes: string): Promise { + return await this.request(`/admin/users/${userId}/balance`, { + method: "POST", + body: JSON.stringify({ balance: amountUsd, operation: "add", notes }), + }); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6542233 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,45 @@ +export interface Sub2ApiUser { + id: number; + email: string; + username: string; + role: string; + status: string; + balance: number; + last_active_at?: string | null; +} + +export interface RankingSourceRow { + user_id: number; + email: string; + actual_cost: number; + requests: number; + tokens: number; +} + +export interface PublicRankingRow { + rank: number; + displayName: string; + actualCost: number; + requests: number; + tokens: number; +} + +export interface PublicDrawRecord { + id: string; + drawnAt: string; + eligibleCount: number; + winnerDisplayName: string; + prizeAmountUsd: number; + creditStatus: DrawRecord["creditStatus"]; +} + +export interface DrawRecord { + id: string; + drawnAt: string; + eligibleCount: number; + winnerUserId: number; + winnerDisplayName: string; + prizeAmountUsd: number; + creditStatus: "disabled" | "dry_run" | "pending" | "succeeded" | "failed"; + creditMessage: string | null; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..dd6441b --- /dev/null +++ b/static/app.js @@ -0,0 +1,160 @@ +const $ = (selector) => document.querySelector(selector) +const fields = (name) => document.querySelectorAll(`[data-field="${name}"]`) +const setField = (name, value) => fields(name).forEach((node) => { node.textContent = String(value) }) + +const button = $('#draw-button') +const stage = $('.draw-stage') +const statusLine = $('#draw-status') +const rankingBody = $('#ranking-body') +const recordList = $('#record-list') +const creditMode = $('#credit-mode') +const dialog = $('#winner-dialog') + +let state = null +let drawing = false + +function money(value, digits = 2) { + return Number(value).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }) +} + +function compact(value) { + return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value)) +} + +function localTime(value) { + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) +} + +function renderRanking(ranking) { + const rows = ranking?.rows ?? [] + if (!rows.length) { + rankingBody.innerHTML = '当前窗口暂无用量' + return + } + rankingBody.innerHTML = rows.map((row) => ` + ${String(row.rank).padStart(2, '0')} + ${escapeHtml(row.displayName)} + $${money(row.actualCost, 3)} + ${compact(row.requests)} 次${compact(row.tokens)} tokens + `).join('') +} + +function renderRecords(records) { + if (!records?.length) { + recordList.innerHTML = '
  • 第一位幸运用户,会是谁?
  • ' + return + } + recordList.innerHTML = records.map((record, index) => `
  • + #${String(index + 1).padStart(2, '0')} + ${escapeHtml(record.winnerDisplayName)}${localTime(record.drawnAt)} · ${record.eligibleCount} 人等概率 + $${money(record.prizeAmountUsd, 0)}${creditLabel(record.creditStatus)} +
  • `).join('') +} + +function creditLabel(status) { + return ({ succeeded: '已充值', dry_run: '模拟充值', disabled: '充值未开启', pending: '充值待确认', failed: '充值失败' })[status] ?? status +} + +function escapeHtml(value) { + const node = document.createElement('span') + node.textContent = String(value) + return node.innerHTML +} + +function render(next) { + state = next + setField('active-hours', next.activeWithinHours) + setField('prize', money(next.prizeAmountUsd, 0)) + setField('remaining', next.remainingDraws) + setField('eligible', next.eligibleUserCount ?? '—') + setField('daily-grant', next.dailyGrantCount) + setField('next-grant', localTime(next.nextGrantAt)) + setField('ranking-label', next.rankingWindowDays === 1 ? '今日' : `近 ${next.rankingWindowDays} 日`) + setField('ranking-total', `$${money(next.ranking?.totals?.actualCost ?? 0)}`) + renderRanking(next.ranking) + renderRecords(next.records) + const autoEnabled = next.automaticCredit?.enabled === true + creditMode.textContent = autoEnabled ? (next.automaticCredit.mode === 'live' ? '自动充值已开启' : '自动充值模拟中') : '自动充值暂未开启' + button.disabled = drawing || Number(next.remainingDraws) < 1 || Number(next.eligibleUserCount) < 1 + statusLine.textContent = Number(next.remainingDraws) < 1 ? '今天的机会已用完,明早 06:00 再来' : `${next.eligibleUserCount} 名候选人已就位` +} + +async function requestJson(url, options = {}) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 12000) + try { + const response = await fetch(url, { ...options, signal: controller.signal }) + const raw = await response.text() + let data = null + try { data = raw ? JSON.parse(raw) : null } catch { /* handled below */ } + if (!response.ok || !data?.ok) throw new Error(data?.error ?? `服务暂不可用(HTTP ${response.status})`) + return data + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw new Error('请求超时,请稍后重试') + throw error + } finally { + clearTimeout(timeout) + } +} + +async function loadState() { + const data = await requestJson('/api/public/state', { cache: 'no-store' }) + render(data) +} + +function showStateError(error) { + const message = error instanceof Error ? error.message : String(error) + statusLine.textContent = `${message},正在重试…` + rankingBody.innerHTML = '服务暂时不可用,正在重试…' + creditMode.textContent = '连接中断' + button.disabled = true +} + +function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } + +async function draw() { + if (drawing) return + drawing = true + button.disabled = true + stage.classList.add('is-drawing') + const phrases = ['锁定活跃用户…', '打乱候选顺序…', '正在碰撞好运…', '马上揭晓!'] + let phraseIndex = 0 + const ticker = setInterval(() => { + statusLine.textContent = phrases[phraseIndex % phrases.length] + phraseIndex += 1 + }, 620) + try { + const request = requestJson('/api/public/draw', { method: 'POST', headers: { 'content-type': 'application/json' } }) + const [, data] = await Promise.all([sleep(2900), request]) + $('#winner-name').textContent = data.record.winnerDisplayName + $('#winner-prize').textContent = money(data.record.prizeAmountUsd, 0) + $('#winner-meta').textContent = `${data.record.eligibleCount} 人等概率 · ${creditLabel(data.record.creditStatus)}` + dialog.showModal() + await loadState() + } catch (error) { + statusLine.textContent = error instanceof Error ? error.message : String(error) + await loadState().catch((stateError) => { + state = null + showStateError(stateError) + setTimeout(boot, 5000) + }) + } finally { + clearInterval(ticker) + drawing = false + stage.classList.remove('is-drawing') + button.disabled = !state || Number(state.remainingDraws) < 1 || Number(state.eligibleUserCount) < 1 + } +} + +button.addEventListener('click', draw) +$('#winner-close').addEventListener('click', () => dialog.close()) +dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close() }) + +async function boot() { + try { await loadState() } catch (error) { + showStateError(error) + setTimeout(boot, 5000) + } +} + +boot() diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..31e9d6c --- /dev/null +++ b/static/index.html @@ -0,0 +1,87 @@ + + + + + + + Sub2Rank 幸运额度站 + + + + + + + +
    + + S2 + SUB2RANK幸运额度站 + +
    PK01 LIVE
    +

    最近 24 小时活跃用户 · 等概率

    +
    + +
    +
    +
    +

    DAILY CREDIT DRAW / 每日额度抽奖

    +

    今天,
    好运值多少钱?

    +

    任何人都可以按下按钮。开奖瞬间从符合条件的活跃用户中等概率抽出一位。

    +
    +
    本期奖金
    $
    +
    剩余次数
    +
    候选人数
    +
    +
    + +
    + + +
    +
    ONE PRESSONE WINNER
    + +
    正在连接开奖台…
    +
    +

    下次增加 次:

    +
    +
    + +
    +
    +
    +

    01 / USAGE

    用量排行榜

    +
    今日$—
    +
    +
    + + + +
    名次用户用量请求 / Token
    正在载入排行榜…
    +
    +
    + +
    +
    +

    02 / ARCHIVE

    开奖记录

    + 充值状态读取中 +
    +
    1. 暂无开奖记录
    +
    +
    +
    + + + +

    TODAY'S LUCKY USER

    +

    恭喜

    +

    获得 $ 额度

    +

    + +
    + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..a6c4921 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,146 @@ +:root { + --ink: #11110f; + --paper: #f0eadb; + --acid: #dfff00; + --signal: #ff4d2e; + --cyan: #60e8df; + --line: rgba(240, 234, 219, 0.19); + --muted: #a8a496; + --serif: "Bodoni 72", "Noto Serif SC", "Songti SC", Georgia, serif; + --sans: "Avenir Next Condensed", "DIN Condensed", "Noto Sans SC", sans-serif; +} + +* { box-sizing: border-box; } +html { background: var(--ink); color: var(--paper); scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; font-family: var(--sans); background: + linear-gradient(90deg, transparent 49.9%, rgba(255,255,255,.026) 50%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, + linear-gradient(rgba(255,255,255,.026) 49.9%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, + radial-gradient(circle at 78% 24%, rgba(255,77,46,.11), transparent 31rem), var(--ink); } +.noise { position: fixed; inset: 0; opacity: .07; pointer-events: none; z-index: 20; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E"); } + +.topbar { min-height: 5.6rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; padding: 1rem clamp(1.2rem, 4vw, 4.5rem); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; width: fit-content; } +.brand-mark { display: grid; place-items: center; width: 2.55rem; aspect-ratio: 1; background: var(--acid); color: var(--ink); font: 900 1rem/1 var(--sans); clip-path: polygon(50% 0, 100% 20%, 90% 100%, 10% 100%, 0 20%); } +.brand b { display: block; letter-spacing: .16em; font-size: .86rem; } +.brand small { color: var(--muted); font-size: .72rem; } +.live-sign { border: 1px solid var(--line); border-radius: 99px; padding: .48rem .8rem; font-size: .68rem; letter-spacing: .16em; } +.live-sign i { display: inline-block; width: .46rem; height: .46rem; background: var(--signal); border-radius: 50%; margin-right: .42rem; box-shadow: 0 0 0 .25rem rgba(255,77,46,.15); animation: pulse 1.6s infinite; } +.rule-copy { justify-self: end; color: var(--muted); font-size: .78rem; margin: 0; } + +main { width: min(1500px, 100%); margin: 0 auto; padding: 0 clamp(1.2rem, 4vw, 4.5rem) 4rem; } +.hero { min-height: 43rem; display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(25rem, .98fr); align-items: center; gap: clamp(2rem, 7vw, 8rem); border-bottom: 1px solid var(--line); } +.eyebrow, .panel-index { color: var(--acid); letter-spacing: .22em; font-size: .7rem; font-weight: 800; } +h1 { max-width: 14ch; font: 400 clamp(3.8rem, 5.7vw, 7.1rem)/.88 var(--serif); letter-spacing: -.065em; margin: 1rem 0 1.8rem; } +h1 em { color: var(--acid); font-weight: 400; } +.hero-note { max-width: 37rem; color: var(--muted); font: 400 clamp(.9rem, 1.3vw, 1.05rem)/1.75 var(--sans); } +.stats-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 3rem 0 0; border-block: 1px solid var(--line); } +.stats-strip div { padding: 1.2rem 1rem 1.2rem 0; } +.stats-strip div + div { border-left: 1px solid var(--line); padding-left: 1.2rem; } +.stats-strip dt { color: var(--muted); font-size: .7rem; letter-spacing: .1em; } +.stats-strip dd { margin: .35rem 0 0; font: 500 clamp(1.8rem, 3vw, 2.6rem)/1 var(--serif); } +.stats-strip small, .currency { font: 700 .75rem/1 var(--sans); color: var(--acid); } + +.draw-stage { min-height: 36rem; display: grid; place-items: center; position: relative; isolation: isolate; overflow: clip; } +.draw-machine { width: clamp(18rem, 31vw, 29rem); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; position: relative; background: radial-gradient(circle, #373732 0 45%, #171715 46% 58%, #292924 59% 60%, #121210 61%); box-shadow: inset 0 0 2rem #000, 0 3rem 6rem rgba(0,0,0,.55); } +.draw-machine::before { content: ""; position: absolute; inset: 5%; border-radius: 50%; border: 1px dashed rgba(223,255,0,.38); animation: rotate 24s linear infinite; } +.machine-label { position: absolute; inset: 1.5rem 0 auto; display: flex; justify-content: center; gap: 1.3rem; color: var(--muted); letter-spacing: .18em; font-size: .5rem; } +.draw-button { width: 50%; aspect-ratio: 1; border: 0; border-radius: 50%; position: relative; overflow: hidden; cursor: pointer; color: #fff5e9; background: radial-gradient(circle at 36% 26%, #ff9a70, var(--signal) 37%, #c52912 70%); box-shadow: 0 .8rem 0 #76180d, 0 1.4rem 3rem rgba(255,77,46,.32), inset 0 .25rem .35rem rgba(255,255,255,.45); transition: transform .18s, filter .18s, box-shadow .18s; z-index: 2; } +.draw-button:not(:disabled):hover { transform: translateY(-.25rem) scale(1.025); filter: saturate(1.2); } +.draw-button:focus-visible, .winner-dialog button:focus-visible, .brand:focus-visible { outline: .22rem solid var(--cyan); outline-offset: .3rem; } +.draw-button:not(:disabled):active { transform: translateY(.45rem) scale(.98); box-shadow: 0 .25rem 0 #76180d, 0 .6rem 1.6rem rgba(255,77,46,.28), inset 0 .25rem .35rem rgba(255,255,255,.35); } +.draw-button:disabled { cursor: not-allowed; filter: grayscale(.65) brightness(.65); } +.button-glare { position: absolute; inset: 7% 18% 56%; border-radius: 50%; background: rgba(255,255,255,.24); filter: blur(.35rem); } +.button-copy { position: relative; display: grid; gap: .25rem; } +.button-copy b { font-size: clamp(1.35rem, 2.4vw, 2.2rem); letter-spacing: .04em; } +.button-copy small { font-size: .52rem; letter-spacing: .18em; } +.machine-status { position: absolute; bottom: 18%; z-index: 3; width: 70%; text-align: center; color: var(--paper); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.orbit { position: absolute; border-radius: 50%; pointer-events: none; } +.orbit-one { width: 88%; aspect-ratio: 1; border: 1px solid rgba(96,232,223,.25); transform: rotate(-14deg) scaleY(.38); } +.orbit-two { width: 104%; aspect-ratio: 1; border: 1px solid rgba(223,255,0,.2); transform: rotate(19deg) scaleY(.52); } +.next-grant { position: absolute; bottom: 1.1rem; color: var(--muted); font-size: .72rem; letter-spacing: .04em; } +.next-grant b { color: var(--acid); } +.draw-stage.is-drawing .draw-machine { animation: machineCharge 2.8s cubic-bezier(.32,.02,.26,1); } +.draw-stage.is-drawing .draw-button { animation: buttonCharge .38s alternate infinite; } +.draw-stage.is-drawing .orbit-one { animation: orbitDash .7s linear infinite; border-color: var(--acid); } +.draw-stage.is-drawing .orbit-two { animation: orbitDash .95s linear infinite reverse; border-color: var(--signal); } + +.board-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(22rem, .65fr); border-inline: 1px solid var(--line); } +.panel { min-width: 0; padding: clamp(1.3rem, 3vw, 2.4rem); } +.panel + .panel { border-left: 1px solid var(--line); } +.panel-head { min-height: 5.5rem; display: flex; justify-content: space-between; align-items: start; gap: 1rem; border-bottom: 1px solid var(--line); } +.panel-head h2 { margin: .3rem 0 1.2rem; font: 400 clamp(1.8rem, 3.2vw, 2.8rem)/1 var(--serif); } +.panel-index { margin: 0; } +.range-chip, .credit-mode { text-align: right; color: var(--muted); font-size: .67rem; letter-spacing: .08em; } +.range-chip span, .range-chip b { display: block; } +.range-chip b { color: var(--paper); font: 400 1.35rem/1.4 var(--serif); } +.credit-mode { border: 1px solid var(--line); padding: .5rem .65rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { color: var(--muted); font-size: .62rem; letter-spacing: .1em; text-align: left; font-weight: 500; padding: .9rem .5rem; } +td { border-top: 1px solid rgba(240,234,219,.1); padding: 1rem .5rem; font-size: .82rem; } +td:first-child { width: 4rem; color: var(--acid); font: 400 1.6rem/1 var(--serif); } +td:nth-child(3) { font: 400 1.1rem/1 var(--serif); } +.usage-detail { display: block; color: var(--muted); font-size: .63rem; margin-top: .3rem; } +tbody tr { transition: background .2s, transform .2s; } +tbody tr:hover { background: rgba(223,255,0,.04); transform: translateX(.2rem); } +.record-list { list-style: none; margin: 0; padding: 0; } +.record-list li { display: grid; grid-template-columns: 2.6rem 1fr auto; gap: .8rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid rgba(240,234,219,.1); } +.record-badge { display: grid; place-items: center; width: 2.5rem; aspect-ratio: 1; border-radius: 50%; border: 1px solid var(--acid); color: var(--acid); font: 700 .6rem/1 var(--sans); } +.record-main b { display: block; font: 400 1.05rem/1.25 var(--serif); } +.record-main small, .record-prize small { color: var(--muted); font-size: .61rem; } +.record-prize { text-align: right; font: 400 1.25rem/1 var(--serif); } +.empty { color: var(--muted) !important; text-align: center !important; padding: 2.6rem 1rem !important; font: .75rem/1.5 var(--sans) !important; } +.record-list li.empty { display: block; } + +.winner-dialog { width: min(38rem, calc(100% - 2rem)); border: 1px solid rgba(223,255,0,.5); color: var(--paper); background: #171714; padding: clamp(2rem, 6vw, 4.5rem); text-align: center; overflow: hidden; box-shadow: 0 2rem 8rem #000; } +.winner-dialog::backdrop { background: rgba(5,5,4,.82); backdrop-filter: blur(.7rem); } +.winner-dialog[open] { animation: reveal .65s cubic-bezier(.18,.85,.3,1.15); } +.winner-kicker { color: var(--acid); font-size: .68rem; letter-spacing: .2em; } +.winner-dialog h2 { font: 400 clamp(2.3rem, 7vw, 4rem)/1 var(--serif); margin: 1rem 0; } +.winner-dialog h2 span { display: block; color: var(--acid); font-size: clamp(1.65rem, 5vw, 3.3rem); overflow-wrap: anywhere; margin-top: .3rem; } +.winner-prize { color: var(--muted); } +.winner-prize b { display: block; color: var(--signal); font: 400 clamp(3.4rem, 10vw, 6rem)/1 var(--serif); margin-top: .5rem; } +.winner-meta { color: var(--muted); font-size: .72rem; min-height: 1em; } +.winner-dialog button { margin-top: 1.5rem; padding: .8rem 1.5rem; border: 0; background: var(--acid); color: var(--ink); font-weight: 800; cursor: pointer; } +.burst { position: absolute; inset: 50%; width: 1px; height: 1px; box-shadow: 0 -12rem 0 1px var(--acid), 8rem -8rem 0 1px var(--signal), 12rem 0 0 1px var(--cyan), 8rem 8rem 0 1px var(--acid), 0 12rem 0 1px var(--signal), -8rem 8rem 0 1px var(--cyan), -12rem 0 0 1px var(--acid), -8rem -8rem 0 1px var(--signal); animation: burst 1.4s ease-out both; } +footer { display: flex; justify-content: space-between; padding: 1.2rem clamp(1.2rem, 4vw, 4.5rem); border-top: 1px solid var(--line); color: var(--muted); font-size: .6rem; letter-spacing: .12em; } + +@keyframes pulse { 50% { opacity: .45; } } +@keyframes rotate { to { transform: rotate(360deg); } } +@keyframes orbitDash { to { rotate: 360deg; } } +@keyframes buttonCharge { to { filter: brightness(1.35) saturate(1.4); transform: scale(1.035); } } +@keyframes machineCharge { 20% { transform: scale(.98); } 55% { transform: scale(1.035); filter: hue-rotate(20deg); } 100% { transform: scale(1); } } +@keyframes reveal { from { opacity: 0; transform: translateY(2rem) scale(.88) rotate(-1deg); } } +@keyframes burst { from { transform: scale(.2) rotate(0); opacity: 1; } to { transform: scale(1.4) rotate(50deg); opacity: 0; } } + +@media (max-width: 960px) { + .topbar { grid-template-columns: 1fr auto; } + .rule-copy { display: none; } + .hero { grid-template-columns: 1fr; padding-block: 4rem 2rem; } + .hero-copy { text-align: center; } + h1, .hero-note { margin-inline: auto; } + .stats-strip { text-align: left; } + .draw-stage { min-height: 31rem; } + .board-grid { grid-template-columns: 1fr; } + .panel + .panel { border-left: 0; border-top: 1px solid var(--line); } +} + +@media (max-width: 560px) { + .topbar { min-height: 4.6rem; padding: .8rem 1rem; } + .live-sign { font-size: .55rem; } + main { padding-inline: 1rem; } + h1 { font-size: 3.4rem; } + .stats-strip dd { font-size: 1.7rem; } + .draw-stage { min-height: 26rem; } + .draw-machine { width: min(21rem, 92vw); } + .board-grid { border-inline: 0; } + .panel { padding-inline: .5rem; } + th:last-child, td:last-child { display: none; } + .record-list li { grid-template-columns: 2.6rem 1fr; } + .record-prize { grid-column: 2; text-align: left; } + footer { gap: 1rem; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..25c2250 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +}