Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
@@ -0,0 +1,29 @@
/**
* GET /api/players/:account_id — public or self profile from D1.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import { jsonResponse, loadPlayerBundle } from "./_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
return jsonResponse({ error: "bad account_id" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const bundle = await loadPlayerBundle(env.DB, accountId);
if (!bundle) return jsonResponse({ error: "not found" }, 404);
if (!self && !bundle.public_share) {
return jsonResponse({ error: "private" }, 404);
}
return jsonResponse(bundle);
} catch (e) {
return jsonResponse(
{ error: "player get failed", detail: String((e && e.message) || e) },
500
);
}
}
@@ -0,0 +1,49 @@
/**
* GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1).
*/
import { sessionFromRequest } from "../../auth/_steam_common.js";
import { jsonResponse } from "../_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
const matchId = Number(context.params && context.params.match_id);
if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) {
return jsonResponse({ error: "bad ids" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const user = await env.DB.prepare(
`SELECT public_share FROM users WHERE account_id = ?`
)
.bind(accountId)
.first();
if (!user) return jsonResponse({ error: "not found" }, 404);
if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404);
const row = await env.DB.prepare(
`SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?`
)
.bind(accountId, matchId)
.first();
const key = (row && row.r2_key) || `matches/${matchId}.json`;
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
const obj = await env.MATCHES.get(key);
if (!obj) return jsonResponse({ error: "match not found" }, 404);
const text = await obj.text();
return new Response(text, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
} catch (e) {
return jsonResponse(
{ error: "match get failed", detail: String((e && e.message) || e) },
500
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/** D1 helpers for Pages Functions (subset of cloudflare/player-sync/src/db.js). */
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function jsonResponse(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extra,
},
});
}
export async function loadPlayerBundle(db, accountId) {
if (!db) return null;
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
export function isStale(fetchedAt, maxAgeMs = 10 * 60 * 1000) {
if (!fetchedAt) return true;
const t = Date.parse(fetchedAt);
if (!Number.isFinite(t)) return true;
return Date.now() - t > maxAgeMs;
}
export async function enqueueRefresh(env, payload) {
if (!env.SYNC_QUEUE) return false;
const jobId = crypto.randomUUID();
const now = utcNow();
if (env.DB) {
await env.DB.prepare(
`INSERT INTO sync_jobs (id, account_id, kind, match_id, status, attempts, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, ?)`
)
.bind(
jobId,
payload.account_id,
payload.kind || "login_refresh",
payload.match_id || null,
now,
now
)
.run();
}
await env.SYNC_QUEUE.send({ ...payload, job_id: jobId });
return true;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/players/me — logged-in user's profile from D1; enqueue refresh if stale.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import {
enqueueRefresh,
isStale,
jsonResponse,
loadPlayerBundle,
} from "./_db.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session || !session.account_id) {
return jsonResponse({ authenticated: false }, 401);
}
const env = context.env || {};
const accountId = Number(session.account_id);
if (!env.DB) {
return jsonResponse(
{ error: "database not configured", account_id: accountId },
503
);
}
// Ensure user row exists for first login.
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
last_login_at=excluded.last_login_at`
)
.bind(
accountId,
String(session.steamid || ""),
session.personaname || null,
session.avatar || null,
now,
now
)
.run();
let bundle = await loadPlayerBundle(env.DB, accountId);
const fetchedAt =
(bundle && bundle.availability && bundle.availability.fetched_at) ||
(bundle && bundle.enriched_at) ||
null;
let stale = !bundle || isStale(fetchedAt);
if (stale) {
await enqueueRefresh(env, {
kind: "login_refresh",
account_id: accountId,
steamid: session.steamid,
personaname: session.personaname,
avatar: session.avatar,
});
}
if (!bundle) {
return jsonResponse({
authenticated: true,
account_id: accountId,
personaname: session.personaname || null,
avatar: session.avatar || null,
public_share: false,
recent: [],
career: null,
recent_20: null,
top_heroes: [],
peers: [],
activity_180: null,
availability: {
status: "unknown",
note: "正在同步…",
complete: false,
stale: true,
},
stale: true,
});
}
if (bundle.availability) bundle.availability.stale = stale;
return jsonResponse({ ...bundle, authenticated: true, stale });
} catch (e) {
return jsonResponse(
{ error: "me failed", detail: String((e && e.message) || e) },
500
);
}
}
+33 -348
View File
@@ -1,35 +1,12 @@
/**
* Pages Function: POST /api/players/publish
* POST /api/players/publish
*
* Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
* Optional header: X-Climperor-Publish-Secret
*
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
* JSON, merges profile.recent, PUTs to Aliyun OSS:
* players/{account_id}/profile.json
* players/{account_id}/matches/{match_id}.json
*
* Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
* optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
*
* Soft-fail: match not ready → 202; bad membership → 403; never echo secrets.
* Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON.
*/
const OPENDOTA = "https://api.opendota.com/api";
const DEFAULT_BUCKET = "climperor";
const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
const RECENT_LIMIT = 30;
function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
import { enqueueRefresh, jsonResponse } from "./_db.js";
function envOf(context) {
return (context && context.env) || {};
@@ -40,278 +17,14 @@ function intField(v, fallback = 0) {
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
function mvpScore(p) {
const k = intField(p.kills);
const d = intField(p.deaths);
const a = intField(p.assists);
const dmg = intField(p.hero_damage);
const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
}
function itemIds(player) {
const out = [];
for (let i = 0; i < 6; i++) {
const id = intField(player[`item_${i}`]);
if (id > 0) out.push(id);
}
return out;
}
function accountInMatch(match, accountId) {
const players = match.players || [];
for (const p of players) {
if (p && intField(p.account_id, -1) === accountId) return true;
}
return false;
}
function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function fetchJson(url, { headers } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function loadHeroMap(opendotaKey) {
const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
try {
const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
const map = new Map();
if (Array.isArray(rows)) {
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(intField(h.id), {
key,
name_loc: h.localized_name || key,
});
}
}
return map;
} catch {
return new Map();
}
}
function normalizeMatch(match, focusAccountId, heroMap) {
const playersRaw = match.players;
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
const matchId = intField(match.match_id);
if (matchId <= 0) return null;
const radiantWin = !!match.radiant_win;
const teamKills = [0, 0];
const teamNw = [0, 0];
const teamDmg = [0, 0];
const slim = [];
for (const p of playersRaw) {
if (!p || typeof p !== "object") continue;
const slot = intField(p.player_slot);
const isRadiant = slot < 128;
const side = isRadiant ? 0 : 1;
const kills = intField(p.kills);
const deaths = intField(p.deaths);
const assists = intField(p.assists);
const heroDamage = intField(p.hero_damage);
let netWorth = intField(p.net_worth);
if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
teamKills[side] += kills;
teamNw[side] += netWorth;
teamDmg[side] += heroDamage;
const heroId = intField(p.hero_id);
const hero = heroMap.get(heroId) || {};
let accountId = null;
if (p.account_id != null) {
const a = intField(p.account_id, -1);
accountId = a >= 0 ? a : null;
}
let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
if (!personaname) personaname = null;
slim.push({
account_id: accountId,
personaname,
hero_id: heroId,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
level: intField(p.level),
kills,
deaths,
assists,
kda: kda(kills, deaths, assists),
hero_damage: heroDamage,
net_worth: netWorth,
items: itemIds(p),
is_radiant: isRadiant,
won: isRadiant ? radiantWin : !radiantWin,
_mvp: mvpScore(p),
_side: side,
});
}
if (slim.length < 2) return null;
for (const p of slim) {
const side = p._side;
const tk = teamKills[side] || 1;
const td = teamDmg[side] || 1;
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
}
let mvp = slim[0];
for (const p of slim) {
if (p._mvp > mvp._mvp) mvp = p;
}
const mvpAccount = mvp.account_id;
for (const p of slim) {
p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
delete p._mvp;
delete p._side;
}
let startTime = null;
if (match.start_time != null) {
const t = intField(match.start_time, -1);
startTime = t >= 0 ? t : null;
}
return {
match_id: matchId,
start_time: startTime,
duration: intField(match.duration),
radiant_win: radiantWin,
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
dire: { kills: teamKills[1], net_worth: teamNw[1] },
mvp_account_id: mvpAccount,
players: slim,
focus_account_id: focusAccountId,
fetched_at: utcNow(),
source: "opendota",
};
}
function summaryForProfile(detail, accountId) {
const focus = (detail.players || []).find((p) => p.account_id === accountId);
if (!focus) return null;
return {
match_id: detail.match_id,
start_time: detail.start_time,
duration: detail.duration,
won: !!focus.won,
hero_id: focus.hero_id,
hero_key: focus.hero_key,
hero_name_loc: focus.hero_name_loc,
kills: focus.kills,
deaths: focus.deaths,
assists: focus.assists,
kda: focus.kda,
};
}
async function ossGetJson(env, key) {
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const url = `https://${bucket}.${endpoint}/${key}`;
try {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function hmacSha1Base64(secret, stringToSign) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
const bytes = new Uint8Array(sig);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
async function ossPutJson(env, key, obj) {
const accessKeyId = env.OSS_ACCESS_KEY_ID;
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
if (!accessKeyId || !accessKeySecret) {
const err = new Error("OSS credentials missing");
err.status = 503;
throw err;
}
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify(obj);
const contentType = "application/json; charset=utf-8";
const date = new Date().toUTCString();
const resource = `/${bucket}/${key}`;
// Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
const url = `https://${bucket}.${endpoint}/${key}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": contentType,
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
"Cache-Control": "public, max-age=60",
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
err.status = 502;
throw err;
}
}
function mergeProfile(existing, accountId, summary, personaname) {
const profile =
existing && typeof existing === "object"
? { ...existing }
: { account_id: accountId, personaname: null, recent: [] };
let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
recent.unshift(summary);
profile.recent = recent.slice(0, RECENT_LIMIT);
profile.account_id = accountId;
if (personaname) profile.personaname = personaname;
profile.public_share = true;
profile.updated_at = utcNow();
return profile;
}
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
const got = (
context.request.headers.get("X-Climperor-Publish-Secret") || ""
).trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
@@ -330,70 +43,42 @@ export async function onRequestPost(context) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
const odKey = (env.OPENDOTA_API_KEY || "").trim();
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
let match;
try {
match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
} catch (e) {
if (e && e.status === 404) {
return jsonResponse(
{ ok: false, pending: true, message: "match not ready on OpenDota" },
202
);
}
return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
if (!env.SYNC_QUEUE) {
return jsonResponse({ error: "sync queue not configured" }, 503);
}
if (!match || !Array.isArray(match.players) || !match.players.length) {
return jsonResponse(
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
202
);
// Mark public_share when PC publishes intentionally.
if (env.DB) {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const steamid = String(BigInt(accountId) + 76561197960265728n);
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, NULL, NULL, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET public_share = 1, last_login_at = excluded.last_login_at`
)
.bind(accountId, steamid, now, now)
.run();
}
if (!accountInMatch(match, accountId)) {
return jsonResponse({ error: "account not in match" }, 403);
}
const heroMap = await loadHeroMap(odKey);
const detail = normalizeMatch(match, accountId, heroMap);
if (!detail) {
return jsonResponse({ error: "normalize failed" }, 500);
}
const summary = summaryForProfile(detail, accountId);
if (!summary) {
return jsonResponse({ error: "focus player missing" }, 500);
}
let personaname = null;
for (const p of detail.players) {
if (p.account_id === accountId && p.personaname) {
personaname = p.personaname;
break;
}
}
const profileKey = `players/${accountId}/profile.json`;
const matchKey = `players/${accountId}/matches/${matchId}.json`;
const existing = await ossGetJson(env, profileKey);
const profile = mergeProfile(existing, accountId, summary, personaname);
await ossPutJson(env, matchKey, detail);
await ossPutJson(env, profileKey, profile);
return jsonResponse({
ok: true,
const queued = await enqueueRefresh(env, {
kind: "publish_match",
account_id: accountId,
match_id: matchId,
public_share: true,
});
if (!queued) {
return jsonResponse({ error: "enqueue failed" }, 503);
}
return jsonResponse({
ok: true,
queued: true,
account_id: accountId,
match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
});
} catch (e) {
const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500
500
);
}
}