From 3cd10c284aacc78f781b73b81ea9c6752f7598e5 Mon Sep 17 00:00:00 2001 From: wuwenbo Date: Sat, 23 May 2026 17:40:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=8C=E5=A4=9A=E8=AF=AD=E8=A8=80=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../15-服务端-GameUploadFunction.md | 71 ++- Tools/OSS/game-upload-function/README.md | 14 +- Tools/OSS/game-upload-function/index.js | 18 +- .../PlayerMultilingualReportViewer/README.md | 12 + .../config.example.json | 7 + .../player_multilingual_report_viewer.py | 495 ++++++++++++++++++ .../启动多语言汇报查看器.bat | 5 + .../Editor/PlayerBugReportEditorWindow.cs | 231 ++++++++ .../TH1_Logic/Editor/SteamEditorWindow.cs | 313 +++++++++++ .../Scripts/TH1_Logic/Oss/OssManager.cs | 39 ++ .../TH1_Logic/Oss/PlayerBugReportService.cs | 172 ++++++ 11 files changed, 1365 insertions(+), 12 deletions(-) create mode 100644 Tools/PlayerMultilingualReportViewer/README.md create mode 100644 Tools/PlayerMultilingualReportViewer/config.example.json create mode 100644 Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py create mode 100644 Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat diff --git a/MD/GameMDFramework/15-服务端-GameUploadFunction.md b/MD/GameMDFramework/15-服务端-GameUploadFunction.md index 9ae31bde1..eeef3d813 100644 --- a/MD/GameMDFramework/15-服务端-GameUploadFunction.md +++ b/MD/GameMDFramework/15-服务端-GameUploadFunction.md @@ -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,测试完整新版上传链路。 + diff --git a/Tools/OSS/game-upload-function/README.md b/Tools/OSS/game-upload-function/README.md index 9f4accc5a..771aaa326 100644 --- a/Tools/OSS/game-upload-function/README.md +++ b/Tools/OSS/game-upload-function/README.md @@ -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)和目标路径,防止客户端篡改上传目标。 - **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。 diff --git a/Tools/OSS/game-upload-function/index.js b/Tools/OSS/game-upload-function/index.js index f1037f116..d891dcf32 100644 --- a/Tools/OSS/game-upload-function/index.js +++ b/Tools/OSS/game-upload-function/index.js @@ -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} 字节`); }) diff --git a/Tools/PlayerMultilingualReportViewer/README.md b/Tools/PlayerMultilingualReportViewer/README.md new file mode 100644 index 000000000..d2411d70e --- /dev/null +++ b/Tools/PlayerMultilingualReportViewer/README.md @@ -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/`,两者都只应保留在本机。 diff --git a/Tools/PlayerMultilingualReportViewer/config.example.json b/Tools/PlayerMultilingualReportViewer/config.example.json new file mode 100644 index 000000000..8c3882a34 --- /dev/null +++ b/Tools/PlayerMultilingualReportViewer/config.example.json @@ -0,0 +1,7 @@ +{ + "access_key_id": "", + "access_key_secret": "", + "endpoint": "oss-cn-shanghai.aliyuncs.com", + "bucket": "th1-oss", + "skip_existing_downloads": true +} diff --git a/Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py b/Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py new file mode 100644 index 000000000..20ee0fe44 --- /dev/null +++ b/Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py @@ -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("<>", 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("<>", 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("", 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("", 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("<>", 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() diff --git a/Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat b/Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat new file mode 100644 index 000000000..a153b32fb --- /dev/null +++ b/Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat @@ -0,0 +1,5 @@ +@echo off +title TH1 Player Multilingual Report Viewer +cd /d "%~dp0" +python player_multilingual_report_viewer.py +pause diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs index 6a63beb78..b5da5bc91 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs @@ -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(); + 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"; + } + } } diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs index 57ba10966..9f782a1ba 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs @@ -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(); + 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> 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(); + } + } } diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs index 15b1ad2de..d7c5c25de 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs index fb27d0a76..f17a677f0 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs @@ -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 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("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;