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:
@@ -0,0 +1,296 @@
|
||||
export function utcNow() {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export async function upsertUser(db, user) {
|
||||
const now = utcNow();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
steamid=excluded.steamid,
|
||||
personaname=COALESCE(excluded.personaname, users.personaname),
|
||||
avatar=COALESCE(excluded.avatar, users.avatar),
|
||||
public_share=COALESCE(excluded.public_share, users.public_share),
|
||||
last_login_at=excluded.last_login_at`
|
||||
)
|
||||
.bind(
|
||||
user.account_id,
|
||||
String(user.steamid),
|
||||
user.personaname || null,
|
||||
user.avatar || null,
|
||||
user.public_share == null ? null : user.public_share ? 1 : 0,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function upsertProfile(db, accountId, profile) {
|
||||
const now = utcNow();
|
||||
const avail = profile.availability || {};
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO player_profiles (
|
||||
account_id, rank_tier, leaderboard_rank, availability_status, availability_note,
|
||||
availability_complete, source, fetched_at, enriched_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
rank_tier=excluded.rank_tier,
|
||||
leaderboard_rank=excluded.leaderboard_rank,
|
||||
availability_status=excluded.availability_status,
|
||||
availability_note=excluded.availability_note,
|
||||
availability_complete=excluded.availability_complete,
|
||||
source=excluded.source,
|
||||
fetched_at=excluded.fetched_at,
|
||||
enriched_at=excluded.enriched_at,
|
||||
updated_at=excluded.updated_at`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
profile.rank_tier ?? null,
|
||||
profile.leaderboard_rank ?? null,
|
||||
avail.status || null,
|
||||
avail.note || null,
|
||||
avail.complete ? 1 : 0,
|
||||
avail.source || "opendota",
|
||||
avail.fetched_at || now,
|
||||
profile.enriched_at || now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function upsertStats(db, accountId, scope, stats) {
|
||||
if (!stats) return;
|
||||
const now = utcNow();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO player_stats (
|
||||
account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda,
|
||||
avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id, scope) DO UPDATE SET
|
||||
sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate,
|
||||
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||
avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists,
|
||||
avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage,
|
||||
payload_json=excluded.payload_json, updated_at=excluded.updated_at`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
scope,
|
||||
stats.sample ?? stats.games ?? 0,
|
||||
stats.wins ?? 0,
|
||||
stats.losses ?? 0,
|
||||
stats.winrate ?? null,
|
||||
stats.kills ?? null,
|
||||
stats.deaths ?? null,
|
||||
stats.assists ?? null,
|
||||
stats.kda ?? null,
|
||||
stats.avg_kills ?? null,
|
||||
stats.avg_deaths ?? null,
|
||||
stats.avg_assists ?? null,
|
||||
stats.avg_gpm ?? null,
|
||||
stats.avg_xpm ?? null,
|
||||
stats.avg_hero_damage ?? null,
|
||||
JSON.stringify(stats),
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function replaceHeroes(db, accountId, heroes) {
|
||||
const now = utcNow();
|
||||
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
|
||||
for (const h of heroes || []) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO player_heroes (
|
||||
account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
h.hero_id,
|
||||
h.hero_key || null,
|
||||
h.hero_name_loc || null,
|
||||
h.games || 0,
|
||||
h.wins || 0,
|
||||
h.winrate ?? null,
|
||||
h.last_played ?? null,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function replacePeers(db, accountId, peers) {
|
||||
const now = utcNow();
|
||||
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
|
||||
for (const p of peers || []) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO player_peers (
|
||||
account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
p.account_id,
|
||||
p.personaname || null,
|
||||
p.avatar || null,
|
||||
p.games || 0,
|
||||
p.wins || 0,
|
||||
p.winrate ?? null,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertMatches(db, accountId, recent) {
|
||||
const now = utcNow();
|
||||
for (const r of recent || []) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO player_matches (
|
||||
account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc,
|
||||
kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id, match_id) DO UPDATE SET
|
||||
start_time=excluded.start_time, duration=excluded.duration, won=excluded.won,
|
||||
hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc,
|
||||
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||
gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage,
|
||||
game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
r.match_id,
|
||||
r.start_time ?? null,
|
||||
r.duration ?? null,
|
||||
r.won ? 1 : 0,
|
||||
r.hero_id ?? null,
|
||||
r.hero_key || null,
|
||||
r.hero_name_loc || null,
|
||||
r.kills ?? null,
|
||||
r.deaths ?? null,
|
||||
r.assists ?? null,
|
||||
r.kda ?? null,
|
||||
r.gpm ?? null,
|
||||
r.xpm ?? null,
|
||||
r.hero_damage ?? null,
|
||||
r.game_mode ?? null,
|
||||
r.lobby_type ?? null,
|
||||
r.r2_key || null,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPlayerBundle(db, accountId) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail).
|
||||
*
|
||||
* Message shapes:
|
||||
* { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? }
|
||||
* { kind: "publish_match", account_id, match_id }
|
||||
*/
|
||||
import {
|
||||
loadPlayerBundle,
|
||||
replaceHeroes,
|
||||
replacePeers,
|
||||
upsertMatches,
|
||||
upsertProfile,
|
||||
upsertStats,
|
||||
upsertUser,
|
||||
utcNow,
|
||||
} from "./db.js";
|
||||
import {
|
||||
loadHeroMap,
|
||||
odFetch,
|
||||
steamMatchHistoryStatus,
|
||||
summaryFromRecentRow,
|
||||
} from "./opendota.js";
|
||||
import {
|
||||
aggregateFromRows,
|
||||
careerFromOpenDota,
|
||||
mergeAvailability,
|
||||
winrate,
|
||||
} from "./stats.js";
|
||||
|
||||
function winrateGames(wins, games) {
|
||||
return winrate(wins, Math.max(0, games - wins));
|
||||
}
|
||||
|
||||
async function syncAccount(env, msg) {
|
||||
const accountId = Number(msg.account_id);
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||
throw new Error("bad account_id");
|
||||
}
|
||||
const steamid =
|
||||
msg.steamid || String(BigInt(accountId) + 76561197960265728n);
|
||||
await upsertUser(env.DB, {
|
||||
account_id: accountId,
|
||||
steamid,
|
||||
personaname: msg.personaname || null,
|
||||
avatar: msg.avatar || null,
|
||||
public_share: msg.public_share,
|
||||
});
|
||||
|
||||
const heroMap = await loadHeroMap(env);
|
||||
// Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const player = await odFetch(`/players/${accountId}`, env);
|
||||
await sleep(200);
|
||||
const wl = await odFetch(`/players/${accountId}/wl`, env);
|
||||
await sleep(200);
|
||||
const totals = await odFetch(`/players/${accountId}/totals`, env);
|
||||
await sleep(200);
|
||||
const heroes = await odFetch(`/players/${accountId}/heroes`, env);
|
||||
await sleep(200);
|
||||
const peers = await odFetch(`/players/${accountId}/peers`, env);
|
||||
await sleep(200);
|
||||
const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env);
|
||||
await sleep(200);
|
||||
const matches180 = await odFetch(`/players/${accountId}/matches`, env, {
|
||||
date: 180,
|
||||
significant: 0,
|
||||
});
|
||||
const steamStatus = await steamMatchHistoryStatus(accountId, env);
|
||||
|
||||
const profileBlock = player && player.profile ? player.profile : {};
|
||||
const personaname = profileBlock.personaname || msg.personaname || null;
|
||||
const avatar =
|
||||
profileBlock.avatarfull ||
|
||||
profileBlock.avatarmedium ||
|
||||
profileBlock.avatar ||
|
||||
msg.avatar ||
|
||||
null;
|
||||
await upsertUser(env.DB, {
|
||||
account_id: accountId,
|
||||
steamid,
|
||||
personaname,
|
||||
avatar,
|
||||
});
|
||||
|
||||
const recent = [];
|
||||
if (Array.isArray(recentRaw)) {
|
||||
for (const row of recentRaw) {
|
||||
const summary = summaryFromRecentRow(row, heroMap);
|
||||
if (summary) recent.push(summary);
|
||||
}
|
||||
}
|
||||
recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0));
|
||||
const recent20 = recent.slice(0, 20);
|
||||
|
||||
let career = careerFromOpenDota(wl, totals);
|
||||
// Do not wipe previous career on empty OpenDota response.
|
||||
if (!career) {
|
||||
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||
if (existing && existing.career && existing.career.games > 0) {
|
||||
career = existing.career;
|
||||
}
|
||||
}
|
||||
|
||||
const topHeroes = [];
|
||||
if (Array.isArray(heroes)) {
|
||||
const scored = heroes
|
||||
.filter((h) => h && Number(h.games) > 0)
|
||||
.sort((a, b) => Number(b.games) - Number(a.games))
|
||||
.slice(0, 5);
|
||||
for (const h of scored) {
|
||||
const hid = Number(h.hero_id) || 0;
|
||||
const meta = heroMap.get(hid) || {};
|
||||
const games = Number(h.games) || 0;
|
||||
const wins = Number(h.win) || 0;
|
||||
topHeroes.push({
|
||||
hero_id: hid || null,
|
||||
hero_key: meta.key || null,
|
||||
hero_name_loc: meta.name_loc || meta.key || null,
|
||||
games,
|
||||
wins,
|
||||
winrate: winrateGames(wins, games),
|
||||
last_played: h.last_played != null ? Number(h.last_played) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const peerRows = [];
|
||||
if (Array.isArray(peers)) {
|
||||
for (const p of peers.slice(0, 8)) {
|
||||
if (!p || !p.account_id) continue;
|
||||
const games = Number(p.games) || 0;
|
||||
if (games <= 0) continue;
|
||||
const wins = Number(p.win) || 0;
|
||||
peerRows.push({
|
||||
account_id: Number(p.account_id),
|
||||
personaname: p.personaname || `玩家 ${p.account_id}`,
|
||||
avatar: p.avatarfull || p.avatar || null,
|
||||
games,
|
||||
wins,
|
||||
winrate: winrateGames(wins, games),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let activity180 = null;
|
||||
if (Array.isArray(matches180) && matches180.length) {
|
||||
const byDay = new Map();
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
let maxKills = null;
|
||||
let maxAssists = null;
|
||||
let maxGpm = null;
|
||||
for (const row of matches180) {
|
||||
if (!row || row.start_time == null) continue;
|
||||
const st = Number(row.start_time);
|
||||
const day = new Date(st * 1000).toISOString().slice(0, 10);
|
||||
const cell = byDay.get(day) || { games: 0, wins: 0 };
|
||||
cell.games += 1;
|
||||
const slot = Number(row.player_slot) || 0;
|
||||
const won = slot < 128 ? !!row.radiant_win : !row.radiant_win;
|
||||
if (won) {
|
||||
cell.wins += 1;
|
||||
wins += 1;
|
||||
} else losses += 1;
|
||||
byDay.set(day, cell);
|
||||
const kills = Number(row.kills) || 0;
|
||||
const assists = Number(row.assists) || 0;
|
||||
const gpm = Number(row.gold_per_min) || 0;
|
||||
const heroId = Number(row.hero_id) || null;
|
||||
const mid = Number(row.match_id) || 0;
|
||||
if (!maxKills || kills > maxKills.value) {
|
||||
maxKills = { value: kills, hero_id: heroId, match_id: mid };
|
||||
}
|
||||
if (!maxAssists || assists > maxAssists.value) {
|
||||
maxAssists = { value: assists, hero_id: heroId, match_id: mid };
|
||||
}
|
||||
if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) {
|
||||
maxGpm = { value: gpm, hero_id: heroId, match_id: mid };
|
||||
}
|
||||
}
|
||||
activity180 = {
|
||||
days: 180,
|
||||
sample: wins + losses,
|
||||
wins,
|
||||
losses,
|
||||
winrate: winrate(wins, losses),
|
||||
heatmap: [...byDay.entries()]
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
.map(([date, v]) => ({ date, games: v.games, wins: v.wins })),
|
||||
highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm },
|
||||
label: "最近 180 天样本",
|
||||
};
|
||||
}
|
||||
|
||||
const fetched = utcNow();
|
||||
const availability = mergeAvailability({
|
||||
opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0,
|
||||
career,
|
||||
steamHistoryStatus: steamStatus,
|
||||
fetchedAt: fetched,
|
||||
});
|
||||
if (profileBlock.fh_unavailable && availability.status === "unknown") {
|
||||
availability.status = "private";
|
||||
availability.note = "未公开比赛数据";
|
||||
}
|
||||
// Steam public but OpenDota empty → almost always rate-limit; retry queue.
|
||||
if (
|
||||
availability.status === "syncing" &&
|
||||
(!Array.isArray(recentRaw) || recentRaw.length === 0) &&
|
||||
!career
|
||||
) {
|
||||
throw new Error("OpenDota empty while Steam public — retry");
|
||||
}
|
||||
|
||||
const profile = {
|
||||
rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null,
|
||||
leaderboard_rank:
|
||||
player && player.leaderboard_rank != null
|
||||
? Number(player.leaderboard_rank)
|
||||
: null,
|
||||
availability,
|
||||
enriched_at: fetched,
|
||||
};
|
||||
|
||||
await upsertProfile(env.DB, accountId, profile);
|
||||
if (career) await upsertStats(env.DB, accountId, "career", career);
|
||||
await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20));
|
||||
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
||||
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||
await replacePeers(env.DB, accountId, peerRows);
|
||||
await upsertMatches(env.DB, accountId, recent20);
|
||||
|
||||
// Optional: store a published match detail into R2 (deduped by match_id).
|
||||
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
|
||||
const matchId = Number(msg.match_id);
|
||||
const match = await odFetch(`/matches/${matchId}`, env);
|
||||
if (match && Array.isArray(match.players)) {
|
||||
const key = `matches/${matchId}.json`;
|
||||
await env.MATCHES.put(key, JSON.stringify(match), {
|
||||
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||
});
|
||||
await env.DB.prepare(
|
||||
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
|
||||
)
|
||||
.bind(key, utcNow(), accountId, matchId)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
return loadPlayerBundle(env.DB, accountId);
|
||||
}
|
||||
|
||||
async function markJob(env, jobId, patch) {
|
||||
if (!jobId) return;
|
||||
const now = utcNow();
|
||||
await env.DB.prepare(
|
||||
`UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?,
|
||||
error = ?, lease_until = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(
|
||||
patch.status,
|
||||
patch.bumpAttempts ? 1 : 0,
|
||||
patch.error || null,
|
||||
patch.lease_until || null,
|
||||
now,
|
||||
jobId
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export default {
|
||||
async queue(batch, env) {
|
||||
for (const message of batch.messages) {
|
||||
let body = message.body;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
} catch {
|
||||
message.ack();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const jobId = body && body.job_id;
|
||||
try {
|
||||
if (jobId) {
|
||||
await markJob(env, jobId, {
|
||||
status: "running",
|
||||
lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
await syncAccount(env, body || {});
|
||||
if (jobId) await markJob(env, jobId, { status: "done" });
|
||||
message.ack();
|
||||
} catch (e) {
|
||||
const err = String((e && e.message) || e);
|
||||
if (jobId) {
|
||||
await markJob(env, jobId, {
|
||||
status: "error",
|
||||
bumpAttempts: true,
|
||||
error: err.slice(0, 500),
|
||||
});
|
||||
}
|
||||
message.retry();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret).
|
||||
async fetch(request, env) {
|
||||
if (request.method !== "POST") {
|
||||
return new Response("climperor-player-sync", { status: 200 });
|
||||
}
|
||||
const token = (env.SYNC_HTTP_TOKEN || "").trim();
|
||||
const auth = (request.headers.get("Authorization") || "").trim();
|
||||
if (!token || auth !== `Bearer ${token}`) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const out = await syncAccount(env, body);
|
||||
return new Response(JSON.stringify(out), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
const OPENDOTA = "https://api.opendota.com/api";
|
||||
const STEAM_API = "https://api.steampowered.com";
|
||||
|
||||
export async function odFetch(path, env, query = {}) {
|
||||
const url = new URL(`${OPENDOTA}${path}`);
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v != null) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const key = (env.OPENDOTA_API_KEY || "").trim();
|
||||
if (key) url.searchParams.set("api_key", key);
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "climperor-player-sync",
|
||||
};
|
||||
// CF edge IPs are often rate-limited; retry 429/5xx before giving up.
|
||||
let lastStatus = 0;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
|
||||
}
|
||||
const res = await fetch(url.toString(), { headers });
|
||||
lastStatus = res.status;
|
||||
if (res.status === 403 || res.status === 404) return null;
|
||||
if (res.status === 429 || res.status >= 500) continue;
|
||||
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
if (lastStatus === 429 || lastStatus >= 500) return null;
|
||||
throw new Error(`OpenDota ${lastStatus} ${path}`);
|
||||
}
|
||||
|
||||
export async function steamMatchHistoryStatus(accountId, env) {
|
||||
const key = (env.STEAM_API_KEY || "").trim();
|
||||
if (!key) return null;
|
||||
const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`);
|
||||
url.searchParams.set("key", key);
|
||||
url.searchParams.set("account_id", String(accountId));
|
||||
url.searchParams.set("matches_requested", "1");
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { "User-Agent": "climperor-player-sync" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const status = data && data.result && data.result.status;
|
||||
return status == null ? null : Number(status);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function summaryFromRecentRow(row, heroMap) {
|
||||
const mid = Number(row.match_id) || 0;
|
||||
if (mid <= 0) return null;
|
||||
const heroId = Number(row.hero_id) || 0;
|
||||
const hero = heroMap.get(heroId) || {};
|
||||
const kills = Number(row.kills) || 0;
|
||||
const deaths = Number(row.deaths) || 0;
|
||||
const assists = Number(row.assists) || 0;
|
||||
const playerSlot = Number(row.player_slot) || 0;
|
||||
const radiantWin = !!row.radiant_win;
|
||||
const isRadiant = playerSlot < 128;
|
||||
return {
|
||||
match_id: mid,
|
||||
start_time: row.start_time != null ? Number(row.start_time) : null,
|
||||
duration: Number(row.duration) || 0,
|
||||
won: isRadiant ? radiantWin : !radiantWin,
|
||||
hero_id: heroId || null,
|
||||
hero_key: hero.key || null,
|
||||
hero_name_loc: hero.name_loc || hero.key || null,
|
||||
kills,
|
||||
deaths,
|
||||
assists,
|
||||
kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10,
|
||||
gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null,
|
||||
xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null,
|
||||
hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null,
|
||||
game_mode: row.game_mode != null ? Number(row.game_mode) : null,
|
||||
lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHeroMap(env) {
|
||||
const rows = await odFetch("/heroes", env);
|
||||
const map = new Map();
|
||||
if (!Array.isArray(rows)) return map;
|
||||
for (const h of rows) {
|
||||
if (!h || h.id == null) continue;
|
||||
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
|
||||
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Shared aggregate helpers for the player-sync Worker (mirrors pc/player_stats.py). */
|
||||
|
||||
export function kda(kills, deaths, assists) {
|
||||
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||
}
|
||||
|
||||
export function winrate(wins, losses) {
|
||||
const total = wins + losses;
|
||||
if (total <= 0) return null;
|
||||
return Math.round((wins / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
export function aggregateFromRows(rows, limit = 20) {
|
||||
const sample = (Array.isArray(rows) ? rows : []).slice(0, limit);
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
let kills = 0;
|
||||
let deaths = 0;
|
||||
let assists = 0;
|
||||
let gpmSum = 0;
|
||||
let xpmSum = 0;
|
||||
let dmgSum = 0;
|
||||
let gpmN = 0;
|
||||
let xpmN = 0;
|
||||
let dmgN = 0;
|
||||
const heroes = [];
|
||||
for (const r of sample) {
|
||||
if (!r || typeof r !== "object") continue;
|
||||
if (r.won) wins += 1;
|
||||
else losses += 1;
|
||||
const k = Number(r.kills) || 0;
|
||||
const d = Number(r.deaths) || 0;
|
||||
const a = Number(r.assists) || 0;
|
||||
kills += k;
|
||||
deaths += d;
|
||||
assists += a;
|
||||
if (r.gpm != null) {
|
||||
gpmSum += Number(r.gpm) || 0;
|
||||
gpmN += 1;
|
||||
}
|
||||
if (r.xpm != null) {
|
||||
xpmSum += Number(r.xpm) || 0;
|
||||
xpmN += 1;
|
||||
}
|
||||
if (r.hero_damage != null) {
|
||||
dmgSum += Number(r.hero_damage) || 0;
|
||||
dmgN += 1;
|
||||
}
|
||||
heroes.push({
|
||||
match_id: Number(r.match_id) || 0,
|
||||
hero_id: r.hero_id ?? null,
|
||||
hero_key: r.hero_key || null,
|
||||
hero_name_loc: r.hero_name_loc || null,
|
||||
won: !!r.won,
|
||||
});
|
||||
}
|
||||
const n = wins + losses;
|
||||
return {
|
||||
sample: n,
|
||||
wins,
|
||||
losses,
|
||||
winrate: winrate(wins, losses),
|
||||
kills,
|
||||
deaths,
|
||||
assists,
|
||||
kda: n ? kda(kills, deaths, assists) : null,
|
||||
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||
avg_gpm: gpmN ? Math.round(gpmSum / gpmN) : null,
|
||||
avg_xpm: xpmN ? Math.round(xpmSum / xpmN) : null,
|
||||
avg_hero_damage: dmgN ? Math.round(dmgSum / dmgN) : null,
|
||||
heroes,
|
||||
};
|
||||
}
|
||||
|
||||
export function careerFromOpenDota(wl, totals) {
|
||||
const wins = Number(wl && wl.win) || 0;
|
||||
const losses = Number(wl && wl.lose) || 0;
|
||||
if (wins <= 0 && losses <= 0) return null;
|
||||
const byField = new Map();
|
||||
if (Array.isArray(totals)) {
|
||||
for (const row of totals) {
|
||||
if (row && row.field) byField.set(String(row.field), row);
|
||||
}
|
||||
}
|
||||
const sumOf = (f) => Number((byField.get(f) || {}).sum) || 0;
|
||||
const nOf = (f) => Number((byField.get(f) || {}).n) || 0;
|
||||
const n = wins + losses;
|
||||
const kills = sumOf("kills");
|
||||
const deaths = sumOf("deaths");
|
||||
const assists = sumOf("assists");
|
||||
const gpmN = nOf("gold_per_min");
|
||||
const xpmN = nOf("xp_per_min");
|
||||
const dmgN = nOf("hero_damage");
|
||||
return {
|
||||
games: n,
|
||||
wins,
|
||||
losses,
|
||||
winrate: winrate(wins, losses),
|
||||
kills,
|
||||
deaths,
|
||||
assists,
|
||||
kda: n ? kda(kills, deaths, assists) : null,
|
||||
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||
avg_gpm: gpmN ? Math.round(sumOf("gold_per_min") / gpmN) : null,
|
||||
avg_xpm: xpmN ? Math.round(sumOf("xp_per_min") / xpmN) : null,
|
||||
avg_hero_damage: dmgN ? Math.round(sumOf("hero_damage") / dmgN) : null,
|
||||
source: "opendota",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAvailability({ opendotaRecentN, career, steamHistoryStatus, fetchedAt }) {
|
||||
const odPublic = opendotaRecentN > 0 || !!(career && career.games);
|
||||
const steamAllowed = steamHistoryStatus === 1;
|
||||
const steamDenied = steamHistoryStatus === 15;
|
||||
let status = "unknown";
|
||||
let complete = false;
|
||||
let note = "暂无公开战绩";
|
||||
if (odPublic) {
|
||||
status = "public";
|
||||
complete = true;
|
||||
note = null;
|
||||
} else if (steamAllowed) {
|
||||
status = "syncing";
|
||||
note = "Steam 已公开,OpenDota 同步中";
|
||||
} else if (steamDenied) {
|
||||
status = "private";
|
||||
note = "未公开比赛数据";
|
||||
}
|
||||
return {
|
||||
status,
|
||||
complete,
|
||||
opendota_public: odPublic,
|
||||
steam_history_status: steamHistoryStatus ?? null,
|
||||
source: "opendota+steam",
|
||||
fetched_at: fetchedAt,
|
||||
note,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Node smoke tests for Worker stats helpers (no wrangler). */
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
aggregateFromRows,
|
||||
careerFromOpenDota,
|
||||
kda,
|
||||
mergeAvailability,
|
||||
winrate,
|
||||
} from "./src/stats.js";
|
||||
|
||||
assert.equal(kda(10, 0, 5), 15);
|
||||
assert.equal(winrate(0, 0), null);
|
||||
assert.equal(winrate(1, 1), 50);
|
||||
|
||||
const emptyCareer = careerFromOpenDota({ win: 0, lose: 0 }, []);
|
||||
assert.equal(emptyCareer, null);
|
||||
|
||||
const career = careerFromOpenDota(
|
||||
{ win: 2, lose: 1 },
|
||||
[
|
||||
{ field: "kills", sum: 30, n: 3 },
|
||||
{ field: "deaths", sum: 6, n: 3 },
|
||||
{ field: "assists", sum: 15, n: 3 },
|
||||
]
|
||||
);
|
||||
assert.equal(career.games, 3);
|
||||
assert.equal(career.winrate, 66.7);
|
||||
assert.ok(career.kda > 0);
|
||||
|
||||
const recent = aggregateFromRows(
|
||||
[
|
||||
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 },
|
||||
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 }, // duplicate row ok in unit
|
||||
{ match_id: 2, won: false, kills: 0, deaths: 0, assists: 2, gpm: 400 },
|
||||
],
|
||||
20
|
||||
);
|
||||
assert.equal(recent.sample, 3);
|
||||
assert.equal(recent.wins, 2);
|
||||
|
||||
const syncing = mergeAvailability({
|
||||
opendotaRecentN: 0,
|
||||
career: null,
|
||||
steamHistoryStatus: 1,
|
||||
fetchedAt: "2026-07-31T00:00:00Z",
|
||||
});
|
||||
assert.equal(syncing.status, "syncing");
|
||||
assert.match(syncing.note, /OpenDota/);
|
||||
|
||||
const priv = mergeAvailability({
|
||||
opendotaRecentN: 0,
|
||||
career: null,
|
||||
steamHistoryStatus: 15,
|
||||
fetchedAt: "2026-07-31T00:00:00Z",
|
||||
});
|
||||
assert.equal(priv.status, "private");
|
||||
|
||||
console.log("stats.js ok");
|
||||
@@ -0,0 +1,27 @@
|
||||
name = "climperor-player-sync"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-11-01"
|
||||
workers_dev = false
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "climperor-users"
|
||||
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "MATCHES"
|
||||
bucket_name = "climperor-player-data"
|
||||
|
||||
[[queues.consumers]]
|
||||
queue = "climperor-player-sync"
|
||||
max_batch_size = 5
|
||||
max_retries = 5
|
||||
dead_letter_queue = "climperor-player-sync-dlq"
|
||||
|
||||
[[queues.producers]]
|
||||
binding = "SYNC_QUEUE"
|
||||
queue = "climperor-player-sync"
|
||||
|
||||
# Secrets (wrangler secret put):
|
||||
# STEAM_API_KEY
|
||||
# OPENDOTA_API_KEY (optional)
|
||||
Reference in New Issue
Block a user