服务器更新,多语言上传

This commit is contained in:
wuwenbo 2026-05-23 17:40:20 +08:00
parent b51874fd95
commit 3cd10c284a
11 changed files with 1365 additions and 12 deletions

View File

@ -30,7 +30,7 @@ Content-Type: application/json
| `authTicket` | string | 条件必填 | Steam Auth Ticket (十六进制)。首次预校验或身份缓存未命中时必须提供 |
| `version` | string | ❌ | 客户端版本号 |
| `action` | string | ❌ | `steamauth` 表示只做 Steam 预校验;不传则进入上传凭证流程 |
| `type` | string | ❌ | 上传类型: `ossdata`(默认) / `collectdata` / `bugreport` |
| `type` | string | ❌ | 上传类型: `ossdata`(默认) / `collectdata` / `bugreport` / `multilingualreport` |
### 2.2 成功响应 (200)
@ -159,6 +159,15 @@ Steam 预校验响应:
`bugreport` 用于玩家主动汇报,客户端上传一个 zip 包,包含 `manifest.json`、玩家自述和可选的 `start + continue/end` 存档组合。该类型每次请求都会生成新的 OSS 对象,不复用 Tablestore 缓存,避免连续提交覆盖同一个文件。
### 4.4 multilingualreport 类型
```
有版本号: multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip
无版本号: multilingualreport/common/{steamId}/{timestamp}-{random}.zip
```
`multilingualreport` 用于玩家上报有问题的多语言文本,客户端上传一个 zip 包,包含 `manifest.json``reported_text.txt``description.txt`。该类型每次请求都会生成新的 OSS 对象,不复用 Tablestore 缓存,避免连续提交覆盖同一个文件。
---
## 5. 缓存机制
@ -169,7 +178,7 @@ Steam 预校验响应:
### 5.2 Steam 身份缓存
客户端可以在 Steam 就绪后定时发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth`。后续 `ossdata``collectdata``bugreport` 上传只要在 10 分钟身份缓存时效内,就可以跳过实时 Steam Web API 校验。
客户端可以在 Steam 就绪后定时发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth`。后续 `ossdata``collectdata``bugreport``multilingualreport` 上传只要在 10 分钟身份缓存时效内,就可以跳过实时 Steam Web API 校验。
| 字段 | 说明 |
|------|------|
@ -206,7 +215,7 @@ Steam 预校验响应:
|------|------|
| **Steam验证** | 身份缓存命中时复用预校验结果未命中时强制验证Ticket + SteamID比对 |
| **最小权限STS** | 每个令牌仅限写入带时间戳的精确路径 |
| **Post Policy签名** | 限制文件大小和目标路径;`ossdata`/`collectdata` ≤3MB`bugreport` ≤10MB |
| **Post Policy签名** | 限制文件大小和目标路径;`ossdata`/`collectdata` ≤3MB`bugreport` ≤10MB`multilingualreport` ≤1MB |
| **版本隔离** | 不同版本使用不同路径,令牌不可跨版本复用 |
| **请求限制** | 请求体≤1024字节防止大请求攻击 |
| **CORS** | 允许所有源 (`*`)仅允许POST |
@ -234,7 +243,7 @@ Steam 预校验响应:
|------|----|
| 服务端口 | 9000 |
| 最大请求体 | 1024字节 |
| 最大上传文件 | 普通数据 3MB玩家 Bug 汇报 10MB |
| 最大上传文件 | 普通数据 3MB玩家 Bug 汇报 10MB;玩家多语言汇报 1MB |
| STS有效期 | 900秒 (15分钟) |
| STS上传凭证缓存 | 5分钟 |
| Steam身份缓存 | 10分钟 |
@ -375,3 +384,57 @@ saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{ma
- 预览玩家自述、上传时间、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。
- 一键清理本地 `map_archive_*.dat/.bak` 并替换为该汇报内的存档。
---
## 13. 玩家多语言问题汇报
### 13.1 Unity 侧临时入口
Unity 菜单:`Tools/玩家多语言汇报`
功能:
- 玩家填写多语言 ID 和选择到的异常字符串。
- 语种默认读取当前多语言设置,可手动修改。
- 版本号默认读取当前 `VersionConfig`,可手动修改。
- 可用 ID 读取当前解析文本,便于上传前对比。
- 打包为一个 zip 后走 `type=multilingualreport` 上传,不附带存档。
Zip 内容:
```
manifest.json
reported_text.txt
description.txt
```
`manifest.json` 包含:
- 汇报标识与时间:`reportId``createdAtUtc``createdAtLocal``timezone`
- 玩家与版本:`steamId``version``unityVersion``platform`
- CrashSight 对齐字段:`crashSightDeviceId`
- 设备信息:`deviceName``deviceModel``operatingSystem``processorType``processorCount``systemMemorySizeMb``graphicsDeviceName``graphicsMemorySizeMb`
- 多语言问题字段:`multilingualId``multilingualIdText``language``reportedText``description``resolvedText`
### 13.2 开发者查看器
工具目录:`Tools/PlayerMultilingualReportViewer/`
运行 `启动多语言汇报查看器.bat` 后可:
- 使用 OSS AccessKey 更新 `multilingualreport/` 内容到本地缓存。
- 按版本、语种、SteamID 和多语言 ID 筛选条目。
- 预览玩家选择的字符串、玩家自述、当前解析文本、上传时间、版本、SteamID、CrashSight 设备 ID 和设备信息。
### 13.3 运行时上传测试器
Unity 菜单:`Tools/Steam 上传流程测试器`
进入 Play Mode 且 Steam 登录后可测试:
- `action=steamauth` Steam 身份预校验。
- `type=ossdata` 普通 OSS 上传。
- `type=bugreport` 玩家 Bug 汇报上传。
- `type=multilingualreport` 玩家多语言问题汇报上传。
- 顺序执行 1-4测试完整新版上传链路。

View File

@ -72,7 +72,7 @@
| `authTicket` | string | 条件必填 | Steam 客户端生成的 Auth Ticket十六进制字符串。首次预校验或身份缓存未命中时必须提供 |
| `version` | string | 否 | 客户端版本号,不传则视为老版本 |
| `action` | string | 否 | `steamauth` 表示只做 Steam 预校验;不传则进入上传凭证流程 |
| `type` | string | 否 | 上传类型:`ossdata`(默认) / `collectdata` / `bugreport` |
| `type` | string | 否 | 上传类型:`ossdata`(默认) / `collectdata` / `bugreport` / `multilingualreport` |
**示例:**
```json
@ -135,12 +135,18 @@ collectdata:
bugreport:
有版本号bugreport/{version}/{steamId}/{timestamp}-{random}.zip
无版本号bugreport/common/{steamId}/{timestamp}-{random}.zip
multilingualreport:
有版本号multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip
无版本号multilingualreport/common/{steamId}/{timestamp}-{random}.zip
```
STS 权限策略仅允许写入该精确路径,最小化权限范围。
`bugreport` 用于玩家主动汇报,上传 zip 包,最大 10MB。该类型每次请求都会生成新 objectKey不复用 Tablestore 缓存,避免连续提交覆盖同一个对象。
`multilingualreport` 用于玩家上报有问题的多语言文本,上传 zip 包,最大 1MB。内容包含 `manifest.json`、玩家选择的字符串和玩家自述,同样每次请求生成新 objectKey不复用 Tablestore 缓存。
---
## 缓存机制
@ -149,7 +155,7 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
**Steam 身份缓存:**
客户端可以在 Steam 就绪后发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth`,有效期 **10 分钟**。后续 `ossdata``collectdata``bugreport` 上传在身份缓存有效期内可以跳过实时 Steam API 校验。
客户端可以在 Steam 就绪后发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth`,有效期 **10 分钟**。后续 `ossdata``collectdata``bugreport``multilingualreport` 上传在身份缓存有效期内可以跳过实时 Steam API 校验。
**STS 上传凭证缓存命中条件(同时满足):**
1. 版本号与请求一致(`null` 视为老版本,需严格匹配)
@ -193,7 +199,7 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
|------|----|------|
| 服务端口 | `9000` | HTTP 监听端口 |
| 最大请求体 | `1024` 字节 | 防止过大请求 |
| 最大上传文件 | `3 MB` / `10 MB` | 普通数据 / 玩家 Bug 汇报的 Post Policy 限制 |
| 最大上传文件 | `3 MB` / `10 MB` / `1 MB` | 普通数据 / 玩家 Bug 汇报 / 玩家多语言汇报的 Post Policy 限制 |
| STS 有效期 | `900`15 分钟) | STS 临时凭证有效时长 |
| 缓存有效期 | `5` 分钟 | Tablestore 缓存的最长复用时间 |
| Steam 身份缓存 | `10` 分钟 | 预校验通过后的身份复用时间 |
@ -206,5 +212,5 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
- **Steam 身份验证**:身份缓存命中时复用预校验结果;未命中时强制调用 Steam API 验证票据真实性,并比对 SteamID 防止伪造。
- **最小权限 STS**:每个令牌的 OSS 写入权限仅限于带时间戳的精确路径,无法覆盖其他玩家的文件。
- **Post Policy 签名**限制上传文件大小普通数据≤3MB玩家 Bug 汇报≤10MB和目标路径防止客户端篡改上传目标。
- **Post Policy 签名**限制上传文件大小普通数据≤3MB玩家 Bug 汇报≤10MB玩家多语言汇报≤1MB)和目标路径,防止客户端篡改上传目标。
- **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。

View File

@ -12,6 +12,7 @@ const TOKEN_CACHE_DURATION_MS = 5 * 60 * 1000; // 5分钟
const STEAM_AUTH_CACHE_DURATION_MS = 10 * 60 * 1000; // 10分钟
const MAX_STANDARD_UPLOAD_SIZE = 3 * 1024 * 1024; // 3MB
const MAX_BUG_REPORT_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE = 1 * 1024 * 1024; // 1MB
const STS_DURATION_SECONDS = 900; // 15分钟
const UPLOAD_TYPE_CONFIG = {
@ -31,7 +32,15 @@ const UPLOAD_TYPE_CONFIG = {
cache: false,
extension: 'zip',
maxUploadSize: MAX_BUG_REPORT_UPLOAD_SIZE,
uniqueObjectKey: true,
buildPathPrefix: versionSegment => `bugreport/${versionSegment}`
},
multilingualreport: {
cache: false,
extension: 'zip',
maxUploadSize: MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE,
uniqueObjectKey: true,
buildPathPrefix: versionSegment => `multilingualreport/${versionSegment}`
}
};
@ -67,7 +76,7 @@ function normalizeOtsAttributes(row) {
* Tablestore 获取缓存的令牌
* @param {string} steamId
* @param {string|null} version 客户端版本号null 表示老版本未传
* @param {string} type 上传类型'ossdata' | 'collectdata' | 'bugreport'
* @param {string} type 上传类型'ossdata' | 'collectdata' | 'bugreport' | 'multilingualreport'
*/
async function getCachedToken(steamId, version, type) {
const otsClient = getOtsClient();
@ -137,7 +146,7 @@ async function getCachedToken(steamId, version, type) {
* @param {string} steamId
* @param {object} tokenData
* @param {string|null} version 客户端版本号null 表示老版本未传
* @param {string} type 上传类型'ossdata' | 'collectdata' | 'bugreport'
* @param {string} type 上传类型'ossdata' | 'collectdata' | 'bugreport' | 'multilingualreport'
*/
async function saveTokenToCache(steamId, tokenData, version, type) {
const otsClient = getOtsClient();
@ -505,9 +514,10 @@ async function handleRequest(req, res) {
// ossdata: {version}/{steamId}/{timestamp}.dat 或 common/{steamId}/{timestamp}.dat
// collectdata: collect/{version}/{steamId}/{timestamp}.dat 或 collect/common/{steamId}/{timestamp}.dat
// bugreport: bugreport/{version}/{steamId}/{timestamp}-{random}.zip 或 bugreport/common/{steamId}/{timestamp}-{random}.zip
// multilingualreport: multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip 或 multilingualreport/common/{steamId}/{timestamp}-{random}.zip
const versionSegment = version !== null ? version : 'common';
const pathPrefix = typeConfig.buildPathPrefix(versionSegment);
const uniqueSuffix = type === 'bugreport'
const uniqueSuffix = typeConfig.uniqueObjectKey
? `-${crypto.randomUUID().replace(/-/g, '').substring(0, 12)}`
: '';
const objectKey = `${pathPrefix}/${steamId}/${Date.now()}${uniqueSuffix}.${typeConfig.extension}`;
@ -573,5 +583,5 @@ async function handleRequest(req, res) {
const server = http.createServer(handleRequest);
server.listen(PORT, '0.0.0.0', () => {
console.log(`[服务启动] 服务器已启动,监听端口: ${PORT},标准最大上传: ${MAX_STANDARD_UPLOAD_SIZE} 字节Bug汇报最大上传: ${MAX_BUG_REPORT_UPLOAD_SIZE} 字节`);
console.log(`[服务启动] 服务器已启动,监听端口: ${PORT},标准最大上传: ${MAX_STANDARD_UPLOAD_SIZE} 字节Bug汇报最大上传: ${MAX_BUG_REPORT_UPLOAD_SIZE} 字节,多语言汇报最大上传: ${MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE} 字节`);
})

View File

@ -0,0 +1,12 @@
# TH1 玩家多语言汇报查看器
独立于玩家 Bug 查看器的本地工具,用于从 OSS 拉取玩家提交的 `multilingualreport/` zip 包按版本、语种、SteamID 和多语言 ID 筛选查看。
## 使用
1. 运行 `启动多语言汇报查看器.bat`
2. 填写 OSS `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
3. 点击「更新内容」拉取 `multilingualreport/` 下的 zip。
4. 选择条目查看多语言 ID、玩家选择的字符串、玩家自述、当前解析文本、版本、SteamID、CrashSight 设备 ID 和设备信息。
配置会保存到 `config.local.json`,本地下载缓存放在 `Data/`,两者都只应保留在本机。

View File

@ -0,0 +1,7 @@
{
"access_key_id": "",
"access_key_secret": "",
"endpoint": "oss-cn-shanghai.aliyuncs.com",
"bucket": "th1-oss",
"skip_existing_downloads": true
}

View File

@ -0,0 +1,495 @@
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import threading
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
import zipfile
from dataclasses import dataclass
from pathlib import Path
from tkinter import BOTH, END, LEFT, RIGHT, X, Y, BooleanVar, StringVar, Tk, messagebox
from tkinter import ttk
APP_DIR = Path(__file__).resolve().parent
DATA_DIR = APP_DIR / "Data"
CONFIG_PATH = APP_DIR / "config.local.json"
OSS_PREFIX = "multilingualreport/"
def default_config() -> dict:
return {
"access_key_id": "",
"access_key_secret": "",
"endpoint": "oss-cn-shanghai.aliyuncs.com",
"bucket": "th1-oss",
"skip_existing_downloads": True,
}
def load_config() -> dict:
cfg = default_config()
if CONFIG_PATH.exists():
try:
cfg.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
except Exception:
pass
return cfg
def save_config(cfg: dict) -> None:
CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
def human_bytes(value: int) -> str:
if value >= 1024 * 1024:
return f"{value / 1024 / 1024:.2f} MB"
if value >= 1024:
return f"{value / 1024:.1f} KB"
return f"{value} B"
def parse_oss_time(value: str) -> str:
if not value:
return ""
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
except Exception:
return value[:16]
class OssClient:
def __init__(self, access_key_id: str, access_key_secret: str, endpoint: str, bucket: str):
self.access_key_id = access_key_id.strip()
self.access_key_secret = access_key_secret.strip()
self.endpoint = endpoint.strip()
self.bucket = bucket.strip()
def _sign(self, verb: str, date: str, canonicalized_resource: str) -> str:
string_to_sign = f"{verb}\n\n\n{date}\n{canonicalized_resource}"
digest = hmac.new(
self.access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(digest).decode("ascii")
def _request(self, verb: str, url: str, canonicalized_resource: str) -> bytes:
now = dt.datetime.now(dt.timezone.utc)
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
signature = self._sign(verb, date, canonicalized_resource)
request = urllib.request.Request(url, method=verb)
request.add_header("Date", date)
request.add_header("Authorization", f"OSS {self.access_key_id}:{signature}")
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def list_objects(self, prefix: str) -> list[str]:
keys: list[str] = []
marker = ""
while True:
query = f"prefix={urllib.parse.quote(prefix)}&max-keys=1000"
canonicalized_resource = f"/{self.bucket}/"
if marker:
query += f"&marker={urllib.parse.quote(marker)}"
url = f"https://{self.bucket}.{self.endpoint}/?{query}"
xml_bytes = self._request("GET", url, canonicalized_resource)
root = ET.fromstring(xml_bytes)
ns = ""
if root.tag.startswith("{"):
ns = root.tag.split("}", 1)[0] + "}"
for content in root.findall(f"{ns}Contents"):
key = content.findtext(f"{ns}Key") or ""
if key and not key.endswith("/"):
keys.append(key)
is_truncated = (root.findtext(f"{ns}IsTruncated") or "").lower() == "true"
marker = root.findtext(f"{ns}NextMarker") or (keys[-1] if keys else "")
if not is_truncated or not marker:
break
return keys
def download_object(self, object_key: str, local_path: Path) -> None:
encoded_key = "/".join(urllib.parse.quote(part) for part in object_key.split("/"))
url = f"https://{self.bucket}.{self.endpoint}/{encoded_key}"
canonicalized_resource = f"/{self.bucket}/{object_key}"
data = self._request("GET", url, canonicalized_resource)
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(data)
@dataclass
class ReportEntry:
local_path: Path
object_key: str
report_id: str
version: str
steam_id: str
created_at_utc: str
multilingual_id: str
language: str
reported_text: str
description: str
resolved_text: str
size: int
manifest: dict
def local_path_for_key(object_key: str) -> Path:
relative = object_key[len(OSS_PREFIX):] if object_key.startswith(OSS_PREFIX) else object_key
return DATA_DIR / Path(*relative.split("/"))
def read_report(local_path: Path) -> ReportEntry | None:
try:
with zipfile.ZipFile(local_path, "r") as zf:
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
reported_text = manifest.get("reportedText", "")
if not reported_text and "reported_text.txt" in zf.namelist():
reported_text = zf.read("reported_text.txt").decode("utf-8", errors="replace")
description = manifest.get("description", "")
if not description and "description.txt" in zf.namelist():
description = zf.read("description.txt").decode("utf-8", errors="replace")
parts = local_path.relative_to(DATA_DIR).parts
version = manifest.get("version") or (parts[0] if len(parts) > 0 else "")
steam_id = manifest.get("steamId") or (parts[1] if len(parts) > 1 else "")
object_key = OSS_PREFIX + "/".join(parts).replace("\\", "/")
multilingual_id = str(manifest.get("multilingualIdText") or manifest.get("multilingualId") or "")
return ReportEntry(
local_path=local_path,
object_key=object_key,
report_id=manifest.get("reportId", local_path.stem),
version=version,
steam_id=steam_id,
created_at_utc=manifest.get("createdAtUtc", ""),
multilingual_id=multilingual_id,
language=manifest.get("language", ""),
reported_text=reported_text,
description=description,
resolved_text=manifest.get("resolvedText", ""),
size=local_path.stat().st_size,
manifest=manifest,
)
except Exception:
return None
def load_reports() -> list[ReportEntry]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
reports = [entry for entry in (read_report(path) for path in DATA_DIR.rglob("*.zip")) if entry]
reports.sort(key=lambda item: item.created_at_utc or str(item.local_path.stat().st_mtime), reverse=True)
return reports
def download_reports(cfg: dict) -> tuple[int, int, int]:
client = OssClient(
cfg["access_key_id"],
cfg["access_key_secret"],
cfg["endpoint"],
cfg["bucket"],
)
keys = [key for key in client.list_objects(OSS_PREFIX) if key.endswith(".zip")]
downloaded = 0
skipped = 0
failed = 0
for key in keys:
local_path = local_path_for_key(key)
if cfg.get("skip_existing_downloads", True) and local_path.exists():
skipped += 1
continue
try:
client.download_object(key, local_path)
downloaded += 1
except Exception:
failed += 1
return downloaded, skipped, failed
class PlayerMultilingualReportViewer(Tk):
def __init__(self):
super().__init__()
self.title("TH1 玩家多语言汇报查看器")
self.geometry("1120x720")
self.minsize(900, 560)
self.cfg = load_config()
self.reports: list[ReportEntry] = []
self.filtered_reports: list[ReportEntry] = []
self.selected_report: ReportEntry | None = None
self.access_key_id = StringVar(value=self.cfg.get("access_key_id", ""))
self.access_key_secret = StringVar(value=self.cfg.get("access_key_secret", ""))
self.endpoint = StringVar(value=self.cfg.get("endpoint", "oss-cn-shanghai.aliyuncs.com"))
self.bucket = StringVar(value=self.cfg.get("bucket", "th1-oss"))
self.skip_existing = BooleanVar(value=bool(self.cfg.get("skip_existing_downloads", True)))
self.version_filter = StringVar(value="全部版本")
self.language_filter = StringVar(value="全部语种")
self.steam_filter = StringVar(value="")
self.id_filter = StringVar(value="")
self.status_text = StringVar(value="就绪")
self.reported_text_box = None
self.description_box = None
self.resolved_text_box = None
self._build_ui()
self.refresh_local_reports()
def _build_ui(self) -> None:
config_frame = ttk.LabelFrame(self, text="OSS")
config_frame.pack(fill=X, padx=10, pady=(10, 6))
ttk.Label(config_frame, text="AccessKey ID").grid(row=0, column=0, sticky="w", padx=8, pady=4)
ttk.Entry(config_frame, textvariable=self.access_key_id, width=32).grid(row=0, column=1, sticky="ew", padx=4)
ttk.Label(config_frame, text="AccessKey Secret").grid(row=0, column=2, sticky="w", padx=8)
ttk.Entry(config_frame, textvariable=self.access_key_secret, show="*", width=32).grid(row=0, column=3, sticky="ew", padx=4)
ttk.Label(config_frame, text="Endpoint").grid(row=1, column=0, sticky="w", padx=8, pady=4)
ttk.Entry(config_frame, textvariable=self.endpoint, width=32).grid(row=1, column=1, sticky="ew", padx=4)
ttk.Label(config_frame, text="Bucket").grid(row=1, column=2, sticky="w", padx=8)
ttk.Entry(config_frame, textvariable=self.bucket, width=32).grid(row=1, column=3, sticky="ew", padx=4)
ttk.Checkbutton(config_frame, text="跳过已下载 zip", variable=self.skip_existing).grid(row=2, column=1, sticky="w", padx=4)
ttk.Button(config_frame, text="保存配置", command=self.save_current_config).grid(row=2, column=2, sticky="e", padx=4, pady=4)
ttk.Button(config_frame, text="更新内容", command=self.update_from_oss).grid(row=2, column=3, sticky="w", padx=4, pady=4)
config_frame.columnconfigure(1, weight=1)
config_frame.columnconfigure(3, weight=1)
filter_frame = ttk.Frame(self)
filter_frame.pack(fill=X, padx=10, pady=(0, 6))
ttk.Label(filter_frame, text="版本").pack(side=LEFT)
self.version_combo = ttk.Combobox(filter_frame, textvariable=self.version_filter, state="readonly", width=16)
self.version_combo.pack(side=LEFT, padx=(4, 10))
self.version_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="语种").pack(side=LEFT)
self.language_combo = ttk.Combobox(filter_frame, textvariable=self.language_filter, state="readonly", width=12)
self.language_combo.pack(side=LEFT, padx=(4, 10))
self.language_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="ID").pack(side=LEFT)
id_entry = ttk.Entry(filter_frame, textvariable=self.id_filter, width=12)
id_entry.pack(side=LEFT, padx=4)
id_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="SteamID").pack(side=LEFT)
steam_entry = ttk.Entry(filter_frame, textvariable=self.steam_filter, width=22)
steam_entry.pack(side=LEFT, padx=4)
steam_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
ttk.Button(filter_frame, text="刷新本地", command=self.refresh_local_reports).pack(side=LEFT, padx=8)
ttk.Button(filter_frame, text="打开下载目录", command=self.open_data_dir).pack(side=LEFT)
pane = ttk.PanedWindow(self, orient="horizontal")
pane.pack(fill=BOTH, expand=True, padx=10, pady=(0, 6))
list_frame = ttk.Frame(pane)
self.tree = ttk.Treeview(
list_frame,
columns=("created", "version", "language", "mid", "steam", "size"),
show="headings",
selectmode="browse",
)
for col, text, width in (
("created", "时间", 140),
("version", "版本", 90),
("language", "语种", 80),
("mid", "ID", 80),
("steam", "SteamID", 150),
("size", "大小", 80),
):
self.tree.heading(col, text=text)
self.tree.column(col, width=width, anchor="w")
self.tree.pack(side=LEFT, fill=BOTH, expand=True)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
scrollbar.pack(side=RIGHT, fill=Y)
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.bind("<<TreeviewSelect>>", self.on_select_report)
pane.add(list_frame, weight=3)
preview_frame = ttk.Frame(pane)
self.preview = ttk.Treeview(preview_frame, columns=("field", "value"), show="headings", height=11)
self.preview.heading("field", text="字段")
self.preview.heading("value", text="内容")
self.preview.column("field", width=110, anchor="w")
self.preview.column("value", width=440, anchor="w")
self.preview.pack(fill=X)
self._build_text_boxes(preview_frame)
pane.add(preview_frame, weight=4)
status = ttk.Label(self, textvariable=self.status_text, anchor="w")
status.pack(fill=X, padx=10, pady=(0, 8))
def _build_text_boxes(self, parent) -> None:
import tkinter as tk
reported_frame = ttk.LabelFrame(parent, text="玩家选择的字符串")
reported_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
self.reported_text_box = tk.Text(reported_frame, wrap="word", height=8)
self.reported_text_box.pack(fill=BOTH, expand=True)
self.reported_text_box.configure(state="disabled")
description_frame = ttk.LabelFrame(parent, text="玩家自述")
description_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
self.description_box = tk.Text(description_frame, wrap="word", height=8)
self.description_box.pack(fill=BOTH, expand=True)
self.description_box.configure(state="disabled")
resolved_frame = ttk.LabelFrame(parent, text="当前解析文本")
resolved_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
self.resolved_text_box = tk.Text(resolved_frame, wrap="word", height=8)
self.resolved_text_box.pack(fill=BOTH, expand=True)
self.resolved_text_box.configure(state="disabled")
def current_config(self) -> dict:
return {
"access_key_id": self.access_key_id.get(),
"access_key_secret": self.access_key_secret.get(),
"endpoint": self.endpoint.get(),
"bucket": self.bucket.get(),
"skip_existing_downloads": self.skip_existing.get(),
}
def save_current_config(self) -> None:
self.cfg = self.current_config()
save_config(self.cfg)
self.status_text.set(f"配置已保存到 {CONFIG_PATH}")
def update_from_oss(self) -> None:
cfg = self.current_config()
if not cfg["access_key_id"] or not cfg["access_key_secret"]:
messagebox.showerror("缺少配置", "请先填写 AccessKey ID 和 AccessKey Secret。")
return
self.save_current_config()
self.status_text.set("正在从 OSS 更新多语言汇报...")
self._run_worker(lambda: download_reports(cfg), self._after_download)
def _after_download(self, result) -> None:
if isinstance(result, Exception):
messagebox.showerror("更新失败", str(result))
self.status_text.set(f"更新失败: {result}")
return
downloaded, skipped, failed = result
self.refresh_local_reports()
self.status_text.set(f"更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed};本地缓存 {len(self.reports)}")
def refresh_local_reports(self) -> None:
self.reports = load_reports()
versions = ["全部版本"] + sorted({entry.version for entry in self.reports if entry.version}, reverse=True)
languages = ["全部语种"] + sorted({entry.language for entry in self.reports if entry.language})
self.version_combo.configure(values=versions)
self.language_combo.configure(values=languages)
if self.version_filter.get() not in versions:
self.version_filter.set("全部版本")
if self.language_filter.get() not in languages:
self.language_filter.set("全部语种")
self.apply_filters()
def apply_filters(self) -> None:
version = self.version_filter.get()
language = self.language_filter.get()
steam = self.steam_filter.get().strip()
multilingual_id = self.id_filter.get().strip()
self.filtered_reports = []
for report in self.reports:
if version and version != "全部版本" and report.version != version:
continue
if language and language != "全部语种" and report.language != language:
continue
if steam and steam not in report.steam_id:
continue
if multilingual_id and multilingual_id not in report.multilingual_id:
continue
self.filtered_reports.append(report)
for item in self.tree.get_children():
self.tree.delete(item)
for index, report in enumerate(self.filtered_reports):
self.tree.insert(
"",
END,
iid=str(index),
values=(
parse_oss_time(report.created_at_utc),
report.version,
report.language,
report.multilingual_id,
report.steam_id,
human_bytes(report.size),
),
)
self.status_text.set(f"本地缓存 {len(self.reports)} 条,当前筛选 {len(self.filtered_reports)}")
def on_select_report(self, _event=None) -> None:
selection = self.tree.selection()
if not selection:
self.selected_report = None
return
index = int(selection[0])
if index < 0 or index >= len(self.filtered_reports):
return
self.selected_report = self.filtered_reports[index]
self.render_preview(self.selected_report)
def render_preview(self, report: ReportEntry) -> None:
for item in self.preview.get_children():
self.preview.delete(item)
rows = [
("ReportID", report.report_id),
("多语言ID", report.multilingual_id),
("语种", report.language),
("版本", report.version),
("SteamID", report.steam_id),
("时间(UTC)", parse_oss_time(report.created_at_utc)),
("时间(本地)", report.manifest.get("createdAtLocal", "")),
("时区", report.manifest.get("timezone", "")),
("CrashSight设备ID", report.manifest.get("crashSightDeviceId", "")),
("设备名", report.manifest.get("deviceName", "")),
("设备型号", report.manifest.get("deviceModel", "")),
("系统", report.manifest.get("operatingSystem", "")),
("CPU", report.manifest.get("processorType", "")),
("内存", f"{report.manifest.get('systemMemorySizeMb', '')} MB"),
("显卡", report.manifest.get("graphicsDeviceName", "")),
("显存", f"{report.manifest.get('graphicsMemorySizeMb', '')} MB"),
("OSS Key", report.object_key),
("本地文件", str(report.local_path)),
]
for field, value in rows:
self.preview.insert("", END, values=(field, value))
self._set_text(self.reported_text_box, report.reported_text)
self._set_text(self.description_box, report.description)
self._set_text(self.resolved_text_box, report.resolved_text)
@staticmethod
def _set_text(widget, value: str) -> None:
widget.configure(state="normal")
widget.delete("1.0", END)
widget.insert("1.0", value or "")
widget.configure(state="disabled")
def open_data_dir(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
os.startfile(DATA_DIR)
def _run_worker(self, func, callback) -> None:
def runner():
try:
result = func()
except Exception as exc:
result = exc
self.after(0, lambda: callback(result))
threading.Thread(target=runner, daemon=True).start()
if __name__ == "__main__":
PlayerMultilingualReportViewer().mainloop()

View File

@ -0,0 +1,5 @@
@echo off
title TH1 Player Multilingual Report Viewer
cd /d "%~dp0"
python player_multilingual_report_viewer.py
pause

View File

@ -7,6 +7,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using Logic.Multilingual;
using Steamworks;
using TH1_Logic.Net;
using TH1_Logic.Oss;
@ -284,4 +285,234 @@ namespace Logic.Editor
return $"{bytes} B";
}
}
public class PlayerMultilingualReportEditorWindow : EditorWindow
{
private Vector2 _scrollPosition;
private GUIStyle _whiteBoxStyle;
private GUIStyle _sectionHeaderStyle;
private string _multilingualId = "";
private string _reportedText = "";
private string _description = "";
private string _resolvedText = "";
private string _version = "";
private MultilingualType _language = MultilingualType.EN;
private bool _isUploading;
private string _status = "";
[MenuItem("Tools/玩家多语言汇报")]
private static void ShowWindow()
{
var window = GetWindow<PlayerMultilingualReportEditorWindow>();
window.titleContent = new GUIContent("玩家多语言汇报");
window.minSize = new Vector2(520, 420);
window.Show();
}
private void OnEnable()
{
if (string.IsNullOrEmpty(_version))
_version = PlayerMultilingualReportService.GetCurrentVersion();
_language = PlayerMultilingualReportService.GetCurrentLanguage();
}
private void OnGUI()
{
InitStyles();
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
EditorGUILayout.LabelField("汇报内容", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
_version = EditorGUILayout.TextField("版本号", _version);
_language = (MultilingualType)EditorGUILayout.EnumPopup("语种", _language);
_multilingualId = EditorGUILayout.TextField("多语言 ID", _multilingualId);
EditorGUILayout.LabelField("玩家选择的字符串");
_reportedText = EditorGUILayout.TextArea(_reportedText, GUILayout.MinHeight(110));
EditorGUILayout.LabelField("玩家自述");
_description = EditorGUILayout.TextArea(_description, GUILayout.MinHeight(90));
using (new EditorGUI.DisabledScope(!TryParseMultilingualId(out _)))
{
if (GUILayout.Button("用 ID 读取当前文本", GUILayout.Height(24)))
RefreshResolvedText();
}
if (!string.IsNullOrEmpty(_resolvedText))
{
EditorGUILayout.LabelField("当前解析文本", EditorStyles.boldLabel);
EditorGUILayout.SelectableLabel(_resolvedText, EditorStyles.textArea, GUILayout.MinHeight(60));
}
EditorGUILayout.EndVertical();
EditorGUILayout.Space(8);
DrawSubmitSection();
EditorGUILayout.EndScrollView();
}
private void InitStyles()
{
if (_whiteBoxStyle == null)
{
_whiteBoxStyle = InspectorUtils.GetHelpBoxStyle();
InspectorUtils.AddBorder(_whiteBoxStyle, new Color(1f, 1f, 1f, 0.2f));
}
if (_sectionHeaderStyle == null)
{
_sectionHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 13,
padding = new RectOffset(2, 0, 4, 4)
};
}
}
private void DrawSubmitSection()
{
EditorGUILayout.LabelField("上传", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
var steamId = TryGetSteamId();
EditorGUILayout.LabelField("SteamID", string.IsNullOrEmpty(steamId) ? "(未获取到)" : steamId);
EditorGUILayout.LabelField("上传上限", FormatBytes(PlayerMultilingualReportService.MaxMultilingualReportUploadBytes));
using (new EditorGUI.DisabledScope(_isUploading))
{
if (GUILayout.Button(_isUploading ? "上传中..." : "上传多语言汇报", GUILayout.Height(32)))
SubmitReport();
}
if (!string.IsNullOrEmpty(_status))
EditorGUILayout.HelpBox(_status, MessageType.Info);
EditorGUILayout.EndVertical();
}
private void RefreshResolvedText()
{
if (!TryParseMultilingualId(out var id))
{
_resolvedText = "";
_status = "多语言 ID 必须是大于 0 的整数。";
Repaint();
return;
}
_resolvedText = PlayerMultilingualReportService.ResolveText(id, _language);
if (string.IsNullOrEmpty(_resolvedText))
_status = "未从当前多语言数据中解析到文本。";
Repaint();
}
private async void SubmitReport()
{
if (!TryParseMultilingualId(out var id))
{
_status = "多语言 ID 必须是大于 0 的整数。";
Repaint();
return;
}
if (string.IsNullOrWhiteSpace(_reportedText))
{
_status = "请先填写玩家选择的字符串。";
Repaint();
return;
}
if (string.IsNullOrWhiteSpace(_description))
{
_status = "请先填写玩家自述。";
Repaint();
return;
}
var steamId = TryGetSteamId();
if (string.IsNullOrEmpty(steamId))
{
_status = "未获取到 SteamID。请确认 Steam 已初始化并登录。";
Repaint();
return;
}
_isUploading = true;
_status = "正在打包多语言汇报...";
Repaint();
EditorUtility.DisplayProgressBar("玩家多语言汇报", _status, 0.2f);
try
{
var package = PlayerMultilingualReportService.BuildPackage(steamId, id, _reportedText, _description, _version,
_language);
if (package.Data.Length > PlayerMultilingualReportService.MaxMultilingualReportUploadBytes)
{
_status = $"打包后大小 {FormatBytes(package.Data.Length)},超过 1MB 限制。";
return;
}
_status = $"正在上传 {FormatBytes(package.Data.Length)}...";
EditorUtility.DisplayProgressBar("玩家多语言汇报", _status, 0.6f);
Repaint();
var result = await OssManager.Instance.UploadPlayerMultilingualReportAsync(steamId, package.Data,
package.Manifest.version);
_status = result.success
? $"上传成功: {result.objectKey}"
: "上传失败,请查看 Console 日志。";
}
catch (Exception ex)
{
_status = $"上传异常: {ex.Message}";
Debug.LogError($"PlayerMultilingualReport upload exception: {ex}");
}
finally
{
EditorUtility.ClearProgressBar();
_isUploading = false;
Repaint();
}
}
private bool TryParseMultilingualId(out uint id)
{
return uint.TryParse(_multilingualId?.Trim(), out id) && id > 0;
}
private static string TryGetSteamId()
{
try
{
if (SteamUser.BLoggedOn())
return SteamUser.GetSteamID().m_SteamID.ToString();
}
catch
{
// ignored
}
try
{
var id = LobbyManager.Instance.Lobby.GetSelfMemberId();
return id == 0 ? "" : id.ToString();
}
catch
{
return "";
}
}
private static string FormatBytes(long bytes)
{
if (bytes >= 1024 * 1024)
return $"{bytes / 1024f / 1024f:F2} MB";
if (bytes >= 1024)
return $"{bytes / 1024f:F1} KB";
return $"{bytes} B";
}
}
}

View File

@ -6,8 +6,13 @@
*/
using System;
using System.Text;
using Logic.Multilingual;
using Steamworks;
using TH1_Logic.Core;
using TH1_Logic.Net;
using TH1_Logic.Oss;
using TH1_Logic.Steam;
using UnityEditor;
using UnityEngine;
@ -185,4 +190,312 @@ namespace Logic.Editor
EditorGUILayout.EndScrollView();
}
}
public class SteamUploadFlowTestEditorWindow : EditorWindow
{
private const string FunctionUrl = "https://get-sts-token-qltjykaafr.cn-shanghai.fcapp.run";
private Vector2 _scrollPosition;
private GUIStyle _whiteBoxStyle;
private GUIStyle _sectionHeaderStyle;
private StsTokenService _stsService;
private OssUploadService _uploadService;
private bool _isRunning;
private string _version = "";
private string _normalPayload = "{\"test\":\"ossdata\"}";
private string _bugDescription = "测试 Bug 汇报";
private string _multilingualId = "1";
private string _multilingualSelectedText = "测试多语言选中文本";
private string _multilingualDescription = "测试多语言问题自述";
private MultilingualType _multilingualLanguage = MultilingualType.EN;
private string _status = "就绪";
private string _lastObjectKey = "";
[MenuItem("Tools/Steam 上传流程测试器")]
private static void ShowWindow()
{
var window = GetWindow<SteamUploadFlowTestEditorWindow>();
window.titleContent = new GUIContent("Steam 上传流程测试器");
window.minSize = new Vector2(560, 520);
window.Show();
}
private void OnEnable()
{
_stsService = new StsTokenService(FunctionUrl);
_uploadService = new OssUploadService();
if (string.IsNullOrEmpty(_version))
_version = PlayerBugReportService.GetCurrentVersion();
_multilingualLanguage = PlayerMultilingualReportService.GetCurrentLanguage();
}
private void OnGUI()
{
InitStyles();
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
DrawSteamState();
EditorGUILayout.Space(8);
DrawCommonInputs();
EditorGUILayout.Space(8);
DrawTestButtons();
EditorGUILayout.Space(8);
DrawStatus();
EditorGUILayout.EndScrollView();
}
private void InitStyles()
{
if (_whiteBoxStyle == null)
{
_whiteBoxStyle = InspectorUtils.GetHelpBoxStyle();
InspectorUtils.AddBorder(_whiteBoxStyle, new Color(1f, 1f, 1f, 0.2f));
}
if (_sectionHeaderStyle == null)
{
_sectionHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 13,
padding = new RectOffset(2, 0, 4, 4)
};
}
}
private void DrawSteamState()
{
EditorGUILayout.LabelField("Steam 状态", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
var lobby = LobbyManager.Instance.Lobby as SteamLobbyManager;
EditorGUILayout.LabelField("Play Mode", Application.isPlaying ? "运行中" : "未运行");
if (lobby != null)
{
EditorGUILayout.LabelField("Steam 初始化", lobby.IsSteamInitialized ? "是" : "否");
EditorGUILayout.LabelField("用户登录", lobby.IsloggedIn ? "是" : "否");
EditorGUILayout.LabelField("Lobby 初始化", lobby.IsLobbyInitialized ? "是" : "否");
EditorGUILayout.LabelField("用户", lobby.SelfName);
EditorGUILayout.LabelField("SteamID", lobby.SelfID.ToString());
}
else
{
EditorGUILayout.HelpBox("未找到 SteamLobbyManager。请进入 Play Mode 后再测试。", MessageType.Warning);
}
EditorGUILayout.EndVertical();
}
private void DrawCommonInputs()
{
EditorGUILayout.LabelField("测试参数", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
_version = EditorGUILayout.TextField("版本号", _version);
EditorGUILayout.LabelField("普通 OSS 测试内容");
_normalPayload = EditorGUILayout.TextArea(_normalPayload, GUILayout.MinHeight(45));
EditorGUILayout.LabelField("Bug 汇报自述");
_bugDescription = EditorGUILayout.TextArea(_bugDescription, GUILayout.MinHeight(55));
_multilingualLanguage = (MultilingualType)EditorGUILayout.EnumPopup("多语言语种", _multilingualLanguage);
_multilingualId = EditorGUILayout.TextField("多语言 ID", _multilingualId);
EditorGUILayout.LabelField("多语言选中文本");
_multilingualSelectedText = EditorGUILayout.TextArea(_multilingualSelectedText, GUILayout.MinHeight(55));
EditorGUILayout.LabelField("多语言玩家自述");
_multilingualDescription = EditorGUILayout.TextArea(_multilingualDescription, GUILayout.MinHeight(55));
EditorGUILayout.EndVertical();
}
private void DrawTestButtons()
{
EditorGUILayout.LabelField("上传流程", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
using (new EditorGUI.DisabledScope(_isRunning || !Application.isPlaying))
{
if (GUILayout.Button("1. Steam 身份预校验 action=steamauth", GUILayout.Height(28)))
TestSteamWarmup();
if (GUILayout.Button("2. 普通 OSS 上传 type=ossdata", GUILayout.Height(28)))
TestOssDataUpload();
if (GUILayout.Button("3. Bug 汇报上传 type=bugreport", GUILayout.Height(28)))
TestBugReportUpload();
if (GUILayout.Button("4. 多语言汇报上传 type=multilingualreport", GUILayout.Height(28)))
TestMultilingualReportUpload();
if (GUILayout.Button("顺序测试 1-4", GUILayout.Height(32)))
TestAll();
}
if (!Application.isPlaying)
EditorGUILayout.HelpBox("这个测试器需要 Play Mode 中的 Steam 登录态和 AuthTicket。", MessageType.Info);
EditorGUILayout.EndVertical();
}
private void DrawStatus()
{
EditorGUILayout.LabelField("结果", _sectionHeaderStyle);
EditorGUILayout.BeginVertical(_whiteBoxStyle);
EditorGUILayout.HelpBox(_status, MessageType.Info);
if (!string.IsNullOrEmpty(_lastObjectKey))
EditorGUILayout.SelectableLabel(_lastObjectKey, EditorStyles.textField, GUILayout.Height(22));
EditorGUILayout.EndVertical();
}
private async void TestSteamWarmup()
{
await RunTest("Steam 身份预校验", async () =>
{
var steamId = GetSteamId();
var authTicket = GetAuthTicket();
var response = await _stsService.WarmupSteamAuthAsync(steamId, authTicket, _version);
_lastObjectKey = "";
return $"Steam 预校验成功: cached={response.cached}, expiresIn={response.expiresIn}s, steamId={response.steamId}";
});
}
private async void TestOssDataUpload()
{
await RunTest("普通 OSS 上传", async () =>
{
var steamId = GetSteamId();
var authTicket = GetAuthTicket();
var credentials = await _stsService.RequestStsTokenAsync(steamId, authTicket, "ossdata", _version);
var bytes = Encoding.UTF8.GetBytes(string.IsNullOrEmpty(_normalPayload)
? "{\"test\":\"ossdata\"}"
: _normalPayload);
var success = await _uploadService.UploadFileAsync(credentials, bytes, "application/json");
_lastObjectKey = credentials.objectKey;
return success
? $"普通 OSS 上传成功: {credentials.objectKey}"
: $"普通 OSS 上传失败: {credentials.objectKey}";
});
}
private async void TestBugReportUpload()
{
await RunTest("Bug 汇报上传", async () =>
{
var steamId = GetSteamId();
var package = PlayerBugReportService.BuildPackage(steamId, _bugDescription, _version,
false, false, false);
var result = await OssManager.Instance.UploadPlayerBugReportAsync(steamId, package.Data,
package.Manifest.version);
_lastObjectKey = result.objectKey ?? "";
return result.success
? $"Bug 汇报上传成功: {result.objectKey}"
: $"Bug 汇报上传失败: {result.objectKey}";
});
}
private async void TestMultilingualReportUpload()
{
await RunTest("多语言汇报上传", async () =>
{
if (!uint.TryParse(_multilingualId.Trim(), out var id) || id == 0)
throw new Exception("多语言 ID 必须是大于 0 的整数");
if (string.IsNullOrWhiteSpace(_multilingualSelectedText))
throw new Exception("多语言选中文本不能为空");
if (string.IsNullOrWhiteSpace(_multilingualDescription))
throw new Exception("多语言玩家自述不能为空");
var steamId = GetSteamId();
var package = PlayerMultilingualReportService.BuildPackage(steamId, id, _multilingualSelectedText,
_multilingualDescription, _version, _multilingualLanguage);
var result = await OssManager.Instance.UploadPlayerMultilingualReportAsync(steamId, package.Data,
package.Manifest.version);
_lastObjectKey = result.objectKey ?? "";
return result.success
? $"多语言汇报上传成功: {result.objectKey}"
: $"多语言汇报上传失败: {result.objectKey}";
});
}
private async void TestAll()
{
await RunTest("顺序测试 1-4", async () =>
{
var steamId = GetSteamId();
var authTicket = GetAuthTicket();
var warmup = await _stsService.WarmupSteamAuthAsync(steamId, authTicket, _version);
var credentials = await _stsService.RequestStsTokenAsync(steamId, authTicket, "ossdata", _version);
var normalBytes = Encoding.UTF8.GetBytes(string.IsNullOrEmpty(_normalPayload)
? "{\"test\":\"ossdata\"}"
: _normalPayload);
var normalOk = await _uploadService.UploadFileAsync(credentials, normalBytes, "application/json");
var bugPackage = PlayerBugReportService.BuildPackage(steamId, _bugDescription, _version,
false, false, false);
var bugOk = await OssManager.Instance.UploadPlayerBugReportAsync(steamId, bugPackage.Data,
bugPackage.Manifest.version);
if (!uint.TryParse(_multilingualId.Trim(), out var id) || id == 0)
throw new Exception("多语言 ID 必须是大于 0 的整数");
var multilingualPackage = PlayerMultilingualReportService.BuildPackage(steamId, id,
_multilingualSelectedText, _multilingualDescription, _version, _multilingualLanguage);
var multilingualOk = await OssManager.Instance.UploadPlayerMultilingualReportAsync(steamId,
multilingualPackage.Data, multilingualPackage.Manifest.version);
_lastObjectKey = multilingualOk.objectKey ?? "";
return $"顺序测试完成: steamauth cached={warmup.cached}; ossdata={(normalOk ? "" : "")} {credentials.objectKey}; bugreport={(bugOk.success ? "" : "")} {bugOk.objectKey}; multilingualreport={(multilingualOk.success ? "" : "")} {multilingualOk.objectKey}";
});
}
private async System.Threading.Tasks.Task RunTest(string title,
Func<System.Threading.Tasks.Task<string>> action)
{
_isRunning = true;
_status = $"{title} 运行中...";
Repaint();
try
{
var result = await action();
_status = result;
Debug.Log($"[SteamUploadFlowTest] {result}");
}
catch (Exception ex)
{
_status = $"{title} 失败: {ex.Message}";
Debug.LogError($"[SteamUploadFlowTest] {title} failed: {ex}");
}
finally
{
_isRunning = false;
Repaint();
}
}
private static string GetSteamId()
{
try
{
if (SteamUser.BLoggedOn())
{
var id = SteamUser.GetSteamID().m_SteamID;
if (id != 0) return id.ToString();
}
}
catch
{
// ignored
}
var lobbyId = LobbyManager.Instance.Lobby.GetSelfMemberId();
if (lobbyId != 0) return lobbyId.ToString();
throw new Exception("未获取到 SteamID请确认 Steam 已初始化并登录");
}
private static string GetAuthTicket()
{
var ticket = new byte[1024];
var identity = new SteamNetworkingIdentity();
SteamUser.GetAuthSessionTicket(ticket, 1024, out var ticketSize, ref identity);
if (ticketSize == 0)
throw new Exception("Steam auth ticket is empty");
return BitConverter.ToString(ticket, 0, (int)ticketSize).Replace("-", "").ToLower();
}
}
}

View File

@ -27,6 +27,7 @@ namespace TH1_Logic.Oss
private DateTime _collectCredentialsExpireTime;
public const int MaxBugReportUploadBytes = PlayerBugReportService.MaxBugReportUploadBytes;
public const int MaxMultilingualReportUploadBytes = PlayerMultilingualReportService.MaxMultilingualReportUploadBytes;
private const int SteamAuthWarmupRetrySeconds = 30;
private const int SteamAuthWarmupRefreshSeconds = 4 * 60;
@ -162,6 +163,44 @@ namespace TH1_Logic.Oss
}
}
public async Task<(bool success, string objectKey)> UploadPlayerMultilingualReportAsync(string steamId,
byte[] packageData, string version)
{
try
{
if (packageData == null || packageData.Length == 0)
{
LogSystem.LogError("PlayerMultilingualReport upload failed: package data is empty");
return (false, null);
}
if (packageData.Length > MaxMultilingualReportUploadBytes)
{
LogSystem.LogError(
$"PlayerMultilingualReport upload failed: package size {packageData.Length} exceeds {MaxMultilingualReportUploadBytes}");
return (false, null);
}
var authTicket = GetAuthTicket();
var credentials = await _stsService.RequestStsTokenAsync(steamId, authTicket, "multilingualreport",
version);
var result = await _uploadService.UploadFileAsync(credentials, packageData, "application/zip",
MaxMultilingualReportUploadBytes);
if (result)
{
LogSystem.LogInfo($"PlayerMultilingualReport upload success: {credentials.objectKey}");
return (true, credentials.objectKey);
}
return (false, credentials.objectKey);
}
catch (Exception ex)
{
LogSystem.LogError($"PlayerMultilingualReport upload failed: {ex.Message}");
return (false, null);
}
}
public void UpdateSteamAuthWarmup()
{
if (_isSteamAuthWarmupRunning) return;

View File

@ -12,6 +12,7 @@ using System.Linq;
using System.Text;
using Logic.Config;
using Logic.CrashSight;
using Logic.Multilingual;
using TH1_Logic.Config;
using UnityEngine;
@ -97,6 +98,177 @@ namespace TH1_Logic.Oss
public List<PlayerBugReportArchiveSession> Sessions;
}
[Serializable]
public class PlayerMultilingualReportManifest
{
public string schema = "th1.player-multilingual-report.v1";
public string reportId;
public string createdAtUtc;
public string createdAtLocal;
public string timezone;
public string steamId;
public string version;
public string unityVersion;
public string platform;
public string crashSightDeviceId;
public string deviceModel;
public string deviceName;
public string operatingSystem;
public string processorType;
public int processorCount;
public int systemMemorySizeMb;
public string graphicsDeviceName;
public int graphicsMemorySizeMb;
public uint multilingualId;
public string multilingualIdText;
public string language;
public string reportedText;
public string description;
public string resolvedText;
}
public class PlayerMultilingualReportPackage
{
public byte[] Data;
public PlayerMultilingualReportManifest Manifest;
}
public static class PlayerMultilingualReportService
{
public const int MaxMultilingualReportUploadBytes = 1 * 1024 * 1024;
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
public static string GetCurrentVersion()
{
try
{
if (ConfigManager.Instance.VersionCfg == null)
ConfigManager.Instance.VersionCfg = Resources.Load<VersionConfig>("Export/VersionConfig");
return ConfigManager.Instance.VersionCfg?.CurVersionInfo?.Version ?? Application.version;
}
catch (Exception)
{
return Application.version;
}
}
public static MultilingualType GetCurrentLanguage()
{
try
{
var current = MultilingualManager.Instance.CurrentType;
if (IsValidReportLanguage(current)) return current;
}
catch
{
// ignored
}
try
{
var configured = ConfigManager.Instance.Config.MultilingualType;
if (IsValidReportLanguage(configured)) return configured;
}
catch
{
// ignored
}
return MultilingualType.EN;
}
public static string ResolveText(uint multilingualId, MultilingualType language)
{
if (multilingualId == 0) return "";
language = IsValidReportLanguage(language) ? language : GetCurrentLanguage();
try
{
return MultilingualManager.Instance.GetMultilingualText(multilingualId, language) ?? "";
}
catch
{
return "";
}
}
public static PlayerMultilingualReportPackage BuildPackage(string steamId, uint multilingualId,
string reportedText, string description, string version, MultilingualType language)
{
language = IsValidReportLanguage(language) ? language : GetCurrentLanguage();
var reportId = Guid.NewGuid().ToString("N");
var manifest = new PlayerMultilingualReportManifest
{
reportId = reportId,
createdAtUtc = DateTime.UtcNow.ToString("O"),
createdAtLocal = DateTime.Now.ToString("O"),
timezone = GetLocalTimezone(),
steamId = steamId ?? "",
version = string.IsNullOrWhiteSpace(version) ? GetCurrentVersion() : version.Trim(),
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
crashSightDeviceId = CrashSightManager.GetCrashSightDeviceId(),
deviceModel = SystemInfo.deviceModel,
deviceName = SystemInfo.deviceName,
operatingSystem = SystemInfo.operatingSystem,
processorType = SystemInfo.processorType,
processorCount = SystemInfo.processorCount,
systemMemorySizeMb = SystemInfo.systemMemorySize,
graphicsDeviceName = SystemInfo.graphicsDeviceName,
graphicsMemorySizeMb = SystemInfo.graphicsMemorySize,
multilingualId = multilingualId,
multilingualIdText = multilingualId.ToString(),
language = language.ToString(),
reportedText = reportedText ?? "",
description = description ?? "",
resolvedText = ResolveText(multilingualId, language)
};
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true, Utf8NoBom))
{
WriteTextEntry(zip, "reported_text.txt", manifest.reportedText);
WriteTextEntry(zip, "description.txt", manifest.description);
WriteTextEntry(zip, "manifest.json", JsonUtility.ToJson(manifest, true));
}
return new PlayerMultilingualReportPackage
{
Data = stream.ToArray(),
Manifest = manifest
};
}
private static void WriteTextEntry(ZipArchive zip, string path, string text)
{
var entry = zip.CreateEntry(path, System.IO.Compression.CompressionLevel.Optimal);
using var entryStream = entry.Open();
var bytes = Utf8NoBom.GetBytes(text ?? "");
entryStream.Write(bytes, 0, bytes.Length);
}
private static bool IsValidReportLanguage(MultilingualType language)
{
return language != MultilingualType.None && language != MultilingualType.Max;
}
private static string GetLocalTimezone()
{
try
{
var offset = DateTimeOffset.Now.Offset;
var sign = offset < TimeSpan.Zero ? "-" : "+";
return $"{TimeZoneInfo.Local.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
catch
{
return DateTimeOffset.Now.Offset.ToString();
}
}
}
public static class PlayerBugReportService
{
public const int MaxBugReportUploadBytes = 10 * 1024 * 1024;