chore: 初始化 ApiState 服务

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-16 09:01:06 +02:00
commit f539710b86
25 changed files with 2383 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.state
node_modules
scripts
*.log
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.worktree/
.state/
*.sqlite
*.sqlite-shm
*.sqlite-wal
dist/
.env
*.log
logs/
File diff suppressed because one or more lines are too long
+15
View File
@@ -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"]
+35
View File
@@ -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=="],
}
}
+83
View File
@@ -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
+20
View File
@@ -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"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
set -eu
source_root=${SOURCE_ROOT:?SOURCE_ROOT is required}
source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required}
image_repository=${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}
dockerfile=${DOCKERFILE:?DOCKERFILE is required}
build_network=${BUILD_NETWORK:?BUILD_NETWORK is required}
metadata_file=${BUILD_METADATA_FILE:?BUILD_METADATA_FILE is required}
result_file=${BUILD_RESULT_FILE:?BUILD_RESULT_FILE is required}
case "$source_commit" in
*[!0-9a-f]*|'') printf '%s\n' 'SOURCE_COMMIT must be a full lowercase Git SHA' >&2; exit 2 ;;
esac
[ "${#source_commit}" -eq 40 ] || { printf '%s\n' 'SOURCE_COMMIT must be 40 characters' >&2; exit 2; }
[ "$build_network" = host ] || { printf '%s\n' 'BUILD_NETWORK must be host' >&2; exit 2; }
case "$dockerfile" in
/*|*../*|*/..|..) printf '%s\n' 'DOCKERFILE must be a safe relative path' >&2; exit 2 ;;
esac
[ -f "$source_root/$dockerfile" ] || { printf '%s\n' 'Dockerfile is missing' >&2; exit 2; }
image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)"
rm -f "$metadata_file" "$result_file"
buildctl-daemonless.sh build \
--allow network.host \
--frontend dockerfile.v0 \
--local context="$source_root" \
--local dockerfile="$source_root" \
--opt "filename=$dockerfile" \
--opt "network=$build_network" \
--opt "build-arg:HTTP_PROXY=${HTTP_PROXY:-}" \
--opt "build-arg:HTTPS_PROXY=${HTTPS_PROXY:-}" \
--opt "build-arg:ALL_PROXY=${ALL_PROXY:-}" \
--opt "build-arg:NO_PROXY=${NO_PROXY:-}" \
--metadata-file "$metadata_file" \
--output "type=image,name=$image_ref,push=true,registry.insecure=true"
[ -s "$metadata_file" ] || { printf '%s\n' 'BuildKit metadata is missing' >&2; exit 2; }
digest=$(tr -d '\n' < "$metadata_file" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
case "$digest" in
sha256:????????????????????????????????????????????????????????????????) ;;
*) printf '%s\n' 'BuildKit metadata digest is invalid' >&2; exit 2 ;;
esac
printf '{"ok":true,"phase":"image-build","status":"built","imageStatus":"built","sourceCommit":"%s","imageRef":"%s","digest":"%s","digestRef":"%s@%s","valuesPrinted":false}\n' \
"$source_commit" "$image_ref" "$digest" "$image_repository" "$digest" > "$result_file"
cat "$result_file"
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bun
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseAllDocuments } from "yaml";
const placeholders = {
image: "__SUB2RANK_IMAGE_REF__",
configBase64: "__SUB2RANK_CONFIG_BASE64__",
configSha256: "__SUB2RANK_CONFIG_SHA256__",
sourceCommit: "__SUB2RANK_SOURCE_COMMIT__",
};
function option(name) {
const index = process.argv.indexOf(name);
if (index === -1) return null;
const value = process.argv[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function required(value, path) {
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
return value;
}
function record(value, path) {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value;
}
function safePath(value, path) {
const result = required(value, path);
if (result.startsWith("/") || result.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(result)) throw new Error(`${path} must be a safe relative path`);
return result;
}
function run(command, args, cwd, allowFailure = false) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
if (result.error !== undefined) throw result.error;
if (result.status !== 0 && !allowFailure) {
const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000);
throw new Error(`${command} failed (${result.status}): ${detail}`);
}
return result;
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function replaceAllRequired(template, replacements) {
let output = template;
for (const [from, to] of replacements) {
if (!output.includes(from)) throw new Error(`manifest template is missing ${from}`);
output = output.split(from).join(to);
}
for (const placeholder of Object.values(placeholders)) {
if (output.includes(placeholder)) throw new Error(`manifest template retained ${placeholder}`);
}
return output;
}
function validateManifest(manifest, expected) {
const documents = parseAllDocuments(manifest);
const errors = documents.flatMap((document) => document.errors);
if (errors.length > 0) throw new Error(`rendered manifest is invalid YAML: ${errors[0].message}`);
const objects = documents.map((document) => document.toJSON()).filter((value) => value !== null).map((value, index) => record(value, `manifest[${index}]`));
const deployment = objects.find((item) => item.kind === "Deployment" && item.metadata?.name === "sub2rank");
const service = objects.find((item) => item.kind === "Service" && item.metadata?.name === "sub2rank");
const configMap = objects.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "sub2rank-config");
if (deployment === undefined || service === undefined || configMap === undefined) throw new Error("rendered manifest must contain the Sub2Rank Deployment, Service and ConfigMap");
if (deployment.spec?.template?.spec?.containers?.[0]?.image !== expected.digestRef) throw new Error("rendered Deployment image is not the expected digest reference");
if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/source-commit"] !== expected.sourceCommit) throw new Error("rendered Deployment source commit annotation is incorrect");
if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/sub2rank-config-sha256"] !== expected.configSha256) throw new Error("rendered Deployment config SHA annotation is incorrect");
if (configMap.binaryData?.["sub2rank.yaml"] !== expected.configBase64) throw new Error("rendered ConfigMap does not contain the exact source config");
}
function main() {
const sourceRoot = resolve(required(option("--source-root"), "--source-root"));
const sourceCommit = required(option("--source-commit"), "--source-commit");
const configPath = safePath(required(option("--config-path"), "--config-path"), "--config-path");
const metadataPath = resolve(required(option("--metadata"), "--metadata"));
const templateB64 = required(option("--manifest-template-b64"), "--manifest-template-b64");
const imageRepository = required(option("--image-repository"), "--image-repository");
const readUrl = required(option("--gitops-read-url"), "--gitops-read-url");
const writeUrl = required(option("--gitops-write-url"), "--gitops-write-url");
const branch = required(option("--gitops-branch"), "--gitops-branch");
const manifestPath = safePath(required(option("--manifest-path"), "--manifest-path"), "--manifest-path");
const releaseStatePath = safePath(required(option("--release-state-path"), "--release-state-path"), "--release-state-path");
const maxPushAttempts = Number(required(option("--max-push-attempts"), "--max-push-attempts"));
const authorName = required(option("--author-name"), "--author-name");
const authorEmail = required(option("--author-email"), "--author-email");
const worktree = resolve(required(option("--worktree"), "--worktree"));
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full lowercase Git SHA");
if (!Number.isInteger(maxPushAttempts) || maxPushAttempts < 1 || maxPushAttempts > 5) throw new Error("--max-push-attempts must be an integer from 1 to 5");
if (run("git", ["rev-parse", "HEAD"], sourceRoot).stdout.trim() !== sourceCommit) throw new Error("source checkout does not match --source-commit");
const configText = readFileSync(resolve(sourceRoot, configPath), "utf8");
const appConfig = record(Bun.YAML.parse(configText), configPath);
if (appConfig.kind !== "Sub2Rank") throw new Error(`${configPath}.kind must be Sub2Rank`);
const lottery = record(appConfig.lottery, `${configPath}.lottery`);
const automaticCredit = record(lottery.automaticCredit, `${configPath}.lottery.automaticCredit`);
if (typeof automaticCredit.enabled !== "boolean" || !new Set(["dry-run", "live"]).has(automaticCredit.mode)) throw new Error(`${configPath}.lottery.automaticCredit must declare enabled and mode`);
const configBase64 = Buffer.from(configText, "utf8").toString("base64");
const configSha256 = sha256(configText);
const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
const digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("BuildKit metadata digest is invalid");
const digestRef = `${imageRepository}@${digest}`;
const template = Buffer.from(templateB64, "base64").toString("utf8");
const manifest = replaceAllRequired(template, [
[placeholders.image, digestRef],
[placeholders.configBase64, configBase64],
[placeholders.configSha256, configSha256],
[placeholders.sourceCommit, sourceCommit],
]);
validateManifest(manifest, { digestRef, sourceCommit, configSha256, configBase64 });
rmSync(worktree, { recursive: true, force: true });
run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot);
const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0;
if (fetched) run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree);
else {
run("git", ["checkout", "--orphan", branch], worktree);
run("git", ["rm", "-rf", "."], worktree, true);
}
const targetPath = resolve(worktree, manifestPath);
const statePath = resolve(worktree, releaseStatePath);
if (!targetPath.startsWith(`${worktree}/`) || !statePath.startsWith(`${worktree}/`)) throw new Error("GitOps output path escaped the worktree");
mkdirSync(dirname(targetPath), { recursive: true });
writeFileSync(targetPath, manifest, "utf8");
mkdirSync(dirname(statePath), { recursive: true });
writeFileSync(statePath, `${JSON.stringify({
version: 1,
kind: "Sub2RankReleaseState",
service: "sub2rank",
sourceCommit,
configSha256,
digest,
digestRef,
manifestPath,
automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode },
}, null, 2)}\n`, "utf8");
run("git", ["config", "user.name", authorName], worktree);
run("git", ["config", "user.email", authorEmail], worktree);
run("git", ["add", "--", manifestPath, releaseStatePath], worktree);
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
if (changed) run("git", ["commit", "-m", `sub2rank: deploy ${sourceCommit.slice(0, 12)}`], worktree);
run("git", ["remote", "set-url", "origin", writeUrl], worktree);
let pushAttempts = 0;
if (changed) {
let pushed = false;
for (let attempt = 1; attempt <= maxPushAttempts; attempt += 1) {
pushAttempts = attempt;
if (run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true).status === 0) {
pushed = true;
break;
}
run("git", ["fetch", "origin", branch], worktree);
const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true);
if (rebase.status !== 0) {
run("git", ["rebase", "--abort"], worktree, true);
throw new Error(`GitOps rebase failed after push attempt ${attempt}`);
}
}
if (!pushed) throw new Error(`GitOps push failed after ${maxPushAttempts} attempts`);
}
const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim();
const remoteHead = run("git", ["ls-remote", writeUrl, `refs/heads/${branch}`], worktree).stdout.trim().split(/\s+/u)[0] ?? "";
if (remoteHead !== gitopsCommit) throw new Error("GitOps remote branch did not converge to the published commit");
process.stdout.write(`${JSON.stringify({
ok: true,
phase: "gitops-publish",
status: "built",
imageStatus: "built",
sourceCommit,
runtimeSourceCommit: sourceCommit,
configSha256,
automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode },
digest,
digestRef,
gitopsCommit,
changed,
pushAttempts,
valuesPrinted: false,
})}\n`);
}
if (!process.execArgv.includes("--check")) main();
+136
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { runCli } from "./src/cli";
await runCli(process.argv.slice(2));
+34
View File
@@ -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 }) }); }
}
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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/"));
}
};
}
+225
View File
@@ -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),
};
}
}
+33
View File
@@ -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 }),
};
}
+33
View File
@@ -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
View File
@@ -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 };
})();
}
}
+94
View File
@@ -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 }),
});
}
}
+45
View File
@@ -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;
}
+164
View File
@@ -0,0 +1,164 @@
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()
setInterval(() => {
if (!drawing && !document.hidden) loadState().catch(showStateError)
}, 30000)
+87
View File
@@ -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>
+146
View File
@@ -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; }
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
}