服务器更新,多语言上传
This commit is contained in:
parent
b51874fd95
commit
3cd10c284a
@ -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,测试完整新版上传链路。
|
||||
|
||||
|
||||
@ -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)和目标路径,防止客户端篡改上传目标。
|
||||
- **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。
|
||||
|
||||
@ -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} 字节`);
|
||||
})
|
||||
|
||||
12
Tools/PlayerMultilingualReportViewer/README.md
Normal file
12
Tools/PlayerMultilingualReportViewer/README.md
Normal 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/`,两者都只应保留在本机。
|
||||
7
Tools/PlayerMultilingualReportViewer/config.example.json
Normal file
7
Tools/PlayerMultilingualReportViewer/config.example.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"endpoint": "oss-cn-shanghai.aliyuncs.com",
|
||||
"bucket": "th1-oss",
|
||||
"skip_existing_downloads": true
|
||||
}
|
||||
@ -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()
|
||||
5
Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat
Normal file
5
Tools/PlayerMultilingualReportViewer/启动多语言汇报查看器.bat
Normal file
@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
title TH1 Player Multilingual Report Viewer
|
||||
cd /d "%~dp0"
|
||||
python player_multilingual_report_viewer.py
|
||||
pause
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user