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>
412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
/**
|
|
* 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",
|
|
},
|
|
});
|
|
}
|