Fix player sync empty overwrites and force CSS cache refresh.
Keep D1 recent/heroes/peers/rank when OpenDota is partial; bump site to 0.6.55 and shorten style.css cache so「我」layout is not stuck on stale CSS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,14 @@
|
|||||||
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
||||||
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
|
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
|
||||||
|
|
||||||
|
## [0.6.55] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家同步:OpenDota 空/残缺响应不再覆盖已有近 20 场、常用英雄、队友与段位。
|
||||||
|
- 样式:`style.css` 缓存改为 60s,并 bump 版本戳,避免同 `?v=` 内容变更仍命中旧 CSS 导致「我」页双栏错乱。
|
||||||
|
- 补齐 OSS `ui-icon`(含 wordmark),避免顶栏 Logo 404。
|
||||||
|
|
||||||
## [0.6.54] - 2026-07-31
|
## [0.6.54] - 2026-07-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ components:
|
|||||||
- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。
|
- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。
|
||||||
- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。
|
- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。
|
||||||
- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。
|
- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。
|
||||||
- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。
|
- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。Cloudflare Worker 在 OpenDota 空/残缺时不得覆盖已有近场/英雄/队友/段位。
|
||||||
|
|
||||||
### 导航
|
### 导航
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Upload web/assets/ui_icons/* to OSS ui-icon/ (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import oss2
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "web" / "assets" / "ui_icons"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_ID"
|
||||||
|
)
|
||||||
|
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_SECRET"
|
||||||
|
)
|
||||||
|
if not ak or not sk:
|
||||||
|
print("missing OSS credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
bucket = oss2.Bucket(oss2.Auth(ak, sk), "https://oss-cn-shanghai.aliyuncs.com", "climperor")
|
||||||
|
n = 0
|
||||||
|
for f in sorted(SRC.iterdir()):
|
||||||
|
if not f.is_file() or f.name.startswith("."):
|
||||||
|
continue
|
||||||
|
key = f"ui-icon/{f.name}"
|
||||||
|
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||||
|
bucket.put_object_from_file(
|
||||||
|
key,
|
||||||
|
str(f),
|
||||||
|
headers={"Content-Type": ctype, "Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
print(f"uploaded {n} ui-icon files", flush=True)
|
||||||
|
for name in ("dota2_logo_wordmark.png", "dota2_logo.png"):
|
||||||
|
print(f" {name} exists={bucket.object_exists('ui-icon/' + name)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -37,8 +37,8 @@ export async function upsertProfile(db, accountId, profile) {
|
|||||||
availability_complete, source, fetched_at, enriched_at, updated_at
|
availability_complete, source, fetched_at, enriched_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(account_id) DO UPDATE SET
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
rank_tier=excluded.rank_tier,
|
rank_tier=COALESCE(excluded.rank_tier, player_profiles.rank_tier),
|
||||||
leaderboard_rank=excluded.leaderboard_rank,
|
leaderboard_rank=COALESCE(excluded.leaderboard_rank, player_profiles.leaderboard_rank),
|
||||||
availability_status=excluded.availability_status,
|
availability_status=excluded.availability_status,
|
||||||
availability_note=excluded.availability_note,
|
availability_note=excluded.availability_note,
|
||||||
availability_complete=excluded.availability_complete,
|
availability_complete=excluded.availability_complete,
|
||||||
@@ -102,9 +102,11 @@ export async function upsertStats(db, accountId, scope, stats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceHeroes(db, accountId, heroes) {
|
export async function replaceHeroes(db, accountId, heroes) {
|
||||||
|
// Empty OpenDota heroes must not wipe a good cache.
|
||||||
|
if (!Array.isArray(heroes) || heroes.length === 0) return;
|
||||||
const now = utcNow();
|
const now = utcNow();
|
||||||
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
|
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
|
||||||
for (const h of heroes || []) {
|
for (const h of heroes) {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO player_heroes (
|
`INSERT INTO player_heroes (
|
||||||
@@ -127,9 +129,11 @@ export async function replaceHeroes(db, accountId, heroes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function replacePeers(db, accountId, peers) {
|
export async function replacePeers(db, accountId, peers) {
|
||||||
|
// Empty OpenDota peers must not wipe a good cache.
|
||||||
|
if (!Array.isArray(peers) || peers.length === 0) return;
|
||||||
const now = utcNow();
|
const now = utcNow();
|
||||||
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
|
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
|
||||||
for (const p of peers || []) {
|
for (const p of peers) {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO player_peers (
|
`INSERT INTO player_peers (
|
||||||
|
|||||||
@@ -193,9 +193,17 @@ async function syncAccount(env, msg) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
const existingRecentN = Array.isArray(existing && existing.recent)
|
||||||
|
? existing.recent.length
|
||||||
|
: 0;
|
||||||
|
const existingR20 =
|
||||||
|
existing && existing.recent_20 ? Number(existing.recent_20.sample) || 0 : 0;
|
||||||
|
const odRecentOk = Array.isArray(recentRaw) && recentRaw.length > 0;
|
||||||
|
|
||||||
const fetched = utcNow();
|
const fetched = utcNow();
|
||||||
const availability = mergeAvailability({
|
const availability = mergeAvailability({
|
||||||
opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0,
|
opendotaRecentN: odRecentOk ? recentRaw.length : 0,
|
||||||
career,
|
career,
|
||||||
steamHistoryStatus: steamStatus,
|
steamHistoryStatus: steamStatus,
|
||||||
fetchedAt: fetched,
|
fetchedAt: fetched,
|
||||||
@@ -204,11 +212,13 @@ async function syncAccount(env, msg) {
|
|||||||
availability.status = "private";
|
availability.status = "private";
|
||||||
availability.note = "未公开比赛数据";
|
availability.note = "未公开比赛数据";
|
||||||
}
|
}
|
||||||
// Steam public but OpenDota empty → almost always rate-limit; retry queue.
|
// Cold account + Steam public + empty OpenDota → rate-limit; retry queue.
|
||||||
if (
|
if (
|
||||||
availability.status === "syncing" &&
|
!odRecentOk &&
|
||||||
(!Array.isArray(recentRaw) || recentRaw.length === 0) &&
|
!career &&
|
||||||
!career
|
existingRecentN === 0 &&
|
||||||
|
!(existing && existing.career && existing.career.games > 0) &&
|
||||||
|
steamStatus === 1
|
||||||
) {
|
) {
|
||||||
throw new Error("OpenDota empty while Steam public — retry");
|
throw new Error("OpenDota empty while Steam public — retry");
|
||||||
}
|
}
|
||||||
@@ -222,14 +232,28 @@ async function syncAccount(env, msg) {
|
|||||||
availability,
|
availability,
|
||||||
enriched_at: fetched,
|
enriched_at: fetched,
|
||||||
};
|
};
|
||||||
|
// Partial OpenDota (career ok, recent empty): keep prior freshness so /me
|
||||||
|
// stays soft-stale and do not pretend the row is fully refreshed.
|
||||||
|
if (!odRecentOk && (existingR20 > 0 || existingRecentN > 0)) {
|
||||||
|
profile.enriched_at =
|
||||||
|
(existing && existing.enriched_at) || profile.enriched_at;
|
||||||
|
if (existing && existing.availability && existing.availability.fetched_at) {
|
||||||
|
availability.fetched_at = existing.availability.fetched_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await upsertProfile(env.DB, accountId, profile);
|
await upsertProfile(env.DB, accountId, profile);
|
||||||
if (career) await upsertStats(env.DB, accountId, "career", career);
|
if (career) await upsertStats(env.DB, accountId, "career", career);
|
||||||
await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20));
|
const recentAgg = aggregateFromRows(recent20, 20);
|
||||||
|
if ((recentAgg.sample || 0) > 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
await upsertMatches(env.DB, accountId, recent20);
|
||||||
|
} else if (existingR20 <= 0 && existingRecentN <= 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
}
|
||||||
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
||||||
await replaceHeroes(env.DB, accountId, topHeroes);
|
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||||
await replacePeers(env.DB, accountId, peerRows);
|
await replacePeers(env.DB, accountId, peerRows);
|
||||||
await upsertMatches(env.DB, accountId, recent20);
|
|
||||||
|
|
||||||
// Optional: store a published match detail into R2 (deduped by match_id).
|
// Optional: store a published match detail into R2 (deduped by match_id).
|
||||||
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
|
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ from shared.paths import (
|
|||||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
from serve_relations import WEB_DIR, build_payload
|
||||||
|
|
||||||
SITE_VERSION = "0.6.54"
|
SITE_VERSION = "0.6.55"
|
||||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
Cache-Control: public, max-age=60, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
/style.css
|
/style.css
|
||||||
Cache-Control: public, max-age=300, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
/fonts/*
|
/fonts/*
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||||
var SITE_VERSION = "0.6.54";
|
var SITE_VERSION = "0.6.55";
|
||||||
var SITE_ORIGIN = "";
|
var SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/style.css?v=0.6.54" />
|
<link rel="stylesheet" href="/style.css?v=0.6.55" />
|
||||||
<script src="/mobile-gate.js?v=0.6.54"></script>
|
<script src="/mobile-gate.js?v=0.6.55"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||||
@@ -256,8 +256,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||||
|
|
||||||
<script src="/config.js?v=0.6.54"></script>
|
<script src="/config.js?v=0.6.55"></script>
|
||||||
<script src="/router.js?v=0.6.54"></script>
|
<script src="/router.js?v=0.6.55"></script>
|
||||||
<script src="/app.js?v=0.6.54"></script>
|
<script src="/app.js?v=0.6.55"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user