Add PC post-match player pages with opt-in public OSS sync.
Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Pages Function: POST /api/players/publish
|
||||
*
|
||||
* Body: { account_id, match_id }
|
||||
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function envOf(context) {
|
||||
return (context && context.env) || {};
|
||||
}
|
||||
|
||||
function intField(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
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();
|
||||
if (got !== expected) {
|
||||
return jsonResponse({ error: "forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await context.request.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: "invalid json" }, 400);
|
||||
}
|
||||
|
||||
const accountId = intField(body && body.account_id, -1);
|
||||
const matchId = intField(body && body.match_id, -1);
|
||||
if (accountId <= 0 || matchId <= 0) {
|
||||
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 (!match || !Array.isArray(match.players) || !match.players.length) {
|
||||
return jsonResponse(
|
||||
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
|
||||
202
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestOptions() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Climperor-Publish-Secret",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user