feat: 实现 Sub2API 每日抽奖与排行榜
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.state
|
||||||
|
node_modules
|
||||||
|
scripts
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.state/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
+15
@@ -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"]
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
usage: "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml [--target local|production] <command>",
|
||||||
|
commands: [
|
||||||
|
"config validate",
|
||||||
|
"backend check",
|
||||||
|
"lottery status",
|
||||||
|
"lottery draw [--confirm]",
|
||||||
|
"lottery reset [--draws N] [--include-records] --confirm",
|
||||||
|
"records list [--limit N]",
|
||||||
|
"records delete --id <record-id> --confirm",
|
||||||
|
"credit test [--confirm]",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: EmbeddedCliTarget): Promise<unknown> {
|
||||||
|
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<typeof loadConfig>, target: HttpCliTarget): Promise<unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown> }, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { runCli } from "./src/cli";
|
||||||
|
|
||||||
|
await runCli(process.argv.slice(2));
|
||||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
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<Record<string, unknown>> { return this.request("/api/admin/status"); }
|
||||||
|
backendCheck(): Promise<Record<string, unknown>> { return this.request("/api/admin/backend-check"); }
|
||||||
|
draw(): Promise<Record<string, unknown>> { return this.request("/api/admin/draw", { method: "POST" }); }
|
||||||
|
reset(draws: number, includeRecords: boolean): Promise<Record<string, unknown>> {
|
||||||
|
return this.request("/api/admin/reset", { method: "POST", body: JSON.stringify({ draws, includeRecords }) });
|
||||||
|
}
|
||||||
|
records(limit: number): Promise<Record<string, unknown>> { return this.request(`/api/admin/records?limit=${limit}`); }
|
||||||
|
deleteRecord(id: string): Promise<Record<string, unknown>> { return this.request(`/api/admin/records/${encodeURIComponent(id)}`, { method: "DELETE" }); }
|
||||||
|
creditTest(execute: boolean): Promise<Record<string, unknown>> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); }
|
||||||
|
}
|
||||||
@@ -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() };
|
||||||
|
}
|
||||||
+240
@@ -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<string, string>;
|
||||||
|
defaultCliTarget: string;
|
||||||
|
cliTargets: Record<string, EmbeddedCliTarget | HttpCliTarget>;
|
||||||
|
serverTargets: Record<string, ServerTarget>;
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, EmbeddedCliTarget | HttpCliTarget> = {};
|
||||||
|
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<string, ServerTarget> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
+63
@@ -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<Record<string, unknown>> {
|
||||||
|
return await request.json().catch(() => ({})) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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/"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = this.tail;
|
||||||
|
let release!: () => void;
|
||||||
|
this.tail = new Promise<void>((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<Sub2ApiUser[]> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<PublicDrawRecord> {
|
||||||
|
return this.publicRecord(await this.draw());
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(includeLiveData = false): Promise<Record<string, unknown>> {
|
||||||
|
const grants = this.store.reconcileGrants();
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
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<DrawRecord> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import type { AppConfig, SecretRef } from "./config";
|
||||||
|
|
||||||
|
function parseEnv(text: string): Map<string, string> {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+147
@@ -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<StoredRecordRow, [number]>("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 };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AppConfig } from "./config";
|
||||||
|
import type { RankingSourceRow, Sub2ApiUser } from "./types";
|
||||||
|
|
||||||
|
interface Envelope<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paginated<T> {
|
||||||
|
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<T>(path: string, init: RequestInit = {}, authenticate = true): Promise<T> {
|
||||||
|
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<T> | 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<string> {
|
||||||
|
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<Sub2ApiUser[]> {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
const users = new Map<number, Sub2ApiUser>();
|
||||||
|
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<Paginated<Sub2ApiUser>>(`/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<Sub2ApiUser> {
|
||||||
|
return await this.request(`/admin/users/${userId}/balance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ balance: amountUsd, operation: "add", notes }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+160
@@ -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 = '<tr><td colspan="4" class="empty">当前窗口暂无用量</td></tr>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rankingBody.innerHTML = rows.map((row) => `<tr>
|
||||||
|
<td>${String(row.rank).padStart(2, '0')}</td>
|
||||||
|
<td><b>${escapeHtml(row.displayName)}</b></td>
|
||||||
|
<td>$${money(row.actualCost, 3)}</td>
|
||||||
|
<td>${compact(row.requests)} 次<span class="usage-detail">${compact(row.tokens)} tokens</span></td>
|
||||||
|
</tr>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecords(records) {
|
||||||
|
if (!records?.length) {
|
||||||
|
recordList.innerHTML = '<li class="empty">第一位幸运用户,会是谁?</li>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordList.innerHTML = records.map((record, index) => `<li>
|
||||||
|
<span class="record-badge">#${String(index + 1).padStart(2, '0')}</span>
|
||||||
|
<span class="record-main"><b>${escapeHtml(record.winnerDisplayName)}</b><small>${localTime(record.drawnAt)} · ${record.eligibleCount} 人等概率</small></span>
|
||||||
|
<span class="record-prize">$${money(record.prizeAmountUsd, 0)}<small>${creditLabel(record.creditStatus)}</small></span>
|
||||||
|
</li>`).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 = '<tr><td colspan="4" class="empty">服务暂时不可用,正在重试…</td></tr>'
|
||||||
|
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()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#10100f" />
|
||||||
|
<title>Sub2Rank 幸运额度站</title>
|
||||||
|
<meta name="description" content="PK01 Sub2API 活跃用户每日等概率抽奖与用量排行榜" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23dfff00'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='30' font-family='sans-serif' font-weight='900'%3ES2%3C/text%3E%3C/svg%3E" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
<script src="/app.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="noise" aria-hidden="true"></div>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/" aria-label="Sub2Rank 首页">
|
||||||
|
<span class="brand-mark">S2</span>
|
||||||
|
<span><b>SUB2RANK</b><small>幸运额度站</small></span>
|
||||||
|
</a>
|
||||||
|
<div class="live-sign"><i></i> PK01 LIVE</div>
|
||||||
|
<p class="rule-copy">最近 <span data-field="active-hours">24</span> 小时活跃用户 · 等概率</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero" aria-labelledby="hero-title">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">DAILY CREDIT DRAW / 每日额度抽奖</p>
|
||||||
|
<h1 id="hero-title">今天,<br /><em>好运值多少钱?</em></h1>
|
||||||
|
<p class="hero-note">任何人都可以按下按钮。开奖瞬间从符合条件的活跃用户中等概率抽出一位。</p>
|
||||||
|
<dl class="stats-strip">
|
||||||
|
<div><dt>本期奖金</dt><dd><span class="currency">$</span><span data-field="prize">—</span></dd></div>
|
||||||
|
<div><dt>剩余次数</dt><dd><span data-field="remaining">—</span><small> 次</small></dd></div>
|
||||||
|
<div><dt>候选人数</dt><dd><span data-field="eligible">—</span><small> 人</small></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="draw-stage">
|
||||||
|
<div class="orbit orbit-one" aria-hidden="true"></div>
|
||||||
|
<div class="orbit orbit-two" aria-hidden="true"></div>
|
||||||
|
<div class="draw-machine" id="draw-machine">
|
||||||
|
<div class="machine-label"><span>ONE PRESS</span><span>ONE WINNER</span></div>
|
||||||
|
<button class="draw-button" id="draw-button" type="button" disabled>
|
||||||
|
<span class="button-glare" aria-hidden="true"></span>
|
||||||
|
<span class="button-copy"><b>立即抽奖</b><small>PRESS TO DRAW</small></span>
|
||||||
|
</button>
|
||||||
|
<div class="machine-status" aria-live="polite" id="draw-status">正在连接开奖台…</div>
|
||||||
|
</div>
|
||||||
|
<p class="next-grant">下次增加 <b data-field="daily-grant">—</b> 次:<time data-field="next-grant">—</time></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="board-grid">
|
||||||
|
<article class="panel ranking-panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div><p class="panel-index">01 / USAGE</p><h2>用量排行榜</h2></div>
|
||||||
|
<div class="range-chip"><span data-field="ranking-label">今日</span><b data-field="ranking-total">$—</b></div>
|
||||||
|
</header>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table aria-label="Sub2API 用户用量排行榜">
|
||||||
|
<thead><tr><th>名次</th><th>用户</th><th>用量</th><th>请求 / Token</th></tr></thead>
|
||||||
|
<tbody id="ranking-body"><tr><td colspan="4" class="empty">正在载入排行榜…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel records-panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div><p class="panel-index">02 / ARCHIVE</p><h2>开奖记录</h2></div>
|
||||||
|
<span class="credit-mode" id="credit-mode">充值状态读取中</span>
|
||||||
|
</header>
|
||||||
|
<ol class="record-list" id="record-list"><li class="empty">暂无开奖记录</li></ol>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="winner-dialog" class="winner-dialog" aria-labelledby="winner-title">
|
||||||
|
<div class="burst" aria-hidden="true"></div>
|
||||||
|
<p class="winner-kicker">TODAY'S LUCKY USER</p>
|
||||||
|
<h2 id="winner-title">恭喜 <span id="winner-name">—</span></h2>
|
||||||
|
<p class="winner-prize">获得 <b>$<span id="winner-prize">—</span></b> 额度</p>
|
||||||
|
<p class="winner-meta" id="winner-meta"></p>
|
||||||
|
<button id="winner-close" type="button">收下好运</button>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<footer><span>SUB2RANK / PK01</span><span>配置与排除名单由 YAML 控制</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "scripts/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user