修复客户端渠道与错误报告展示
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

客户端 debug 保留服务器选择,正式包按发布渠道连接服务

修复错误报告时间解析与超时路由脱敏

重做后台错误详情面板并补齐定向测试

同步渠道、诊断与后台展示文档
This commit is contained in:
kdletters
2026-09-21 01:46:24 +08:00
parent 914791d63c
commit df39d5a16f
19 changed files with 1022 additions and 124 deletions
+2
View File
@@ -144,6 +144,8 @@ export interface AdminErrorReportListResponse {
}
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
firstFingerprint?: string;
firstSource?: string;
note?: string;
events: Array<Record<string, unknown>>;
logNames: string[];
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import {
formatAdminTimestamp,
parseAdminTimestamp,
} from './AdminErrorReportsPage';
describe('错误报告时间格式化', () => {
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
});
it('解析微秒字符串并拒绝无效时间', () => {
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
expect(formatAdminTimestamp('not-a-date')).toBe('-');
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
});
});
@@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
const ERROR_REPORT_PAGE_SIZE = 50;
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
new: '待处理',
'in-progress': '处理中',
resolved: '已解决',
};
export function parseAdminTimestamp(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
const seconds = Number(secondsText);
const micros = Number(microsText);
return Number.isFinite(seconds) && Number.isFinite(micros)
? seconds * 1000 + Math.floor(micros / 1000)
: Number.NaN;
}
if (/^-?\d+$/u.test(normalized)) {
const numeric = Number(normalized);
if (!Number.isFinite(numeric)) return Number.NaN;
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
if (Math.abs(numeric) >= 1e11) return numeric;
return numeric * 1000;
}
return Date.parse(normalized);
}
export function formatAdminTimestamp(value: string | null | undefined) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) return '-';
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return '-';
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
} catch {
return '-';
}
}
function eventText(event: Record<string, unknown>, key: string) {
const value = event[key];
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
@@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
const openReportRequestId = useRef(0);
const loadRequestId = useRef(0);
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!selected) return;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = window.requestAnimationFrame(() => {
detailCloseButtonRef.current?.focus();
});
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelected(null);
};
window.addEventListener('keydown', handleEscape);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', handleEscape);
previousFocus?.focus?.();
};
}, [selected]);
const load = useCallback(async () => {
const requestId = ++loadRequestId.current;
@@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
<td>{report.batchId}</td>
<td>{report.eventCount}</td>
<td>{report.source ?? '-'}</td>
<td>{report.status}</td>
<td>
<span
className={`admin-error-report-status is-${report.status}`}
>
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
</span>
</td>
<td>{report.userId}</td>
<td>{new Date(report.createdAt).toLocaleString()}</td>
<td>{formatAdminTimestamp(report.createdAt)}</td>
</tr>
))}
</tbody>
@@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
className="admin-detail-modal"
role="dialog"
aria-label="错误报告详情"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setSelected(null);
}}
>
<div className="admin-detail-modal__panel">
<header>
<h2>{selected.batchId}</h2>
<button type="button" onClick={() => setSelected(null)}>
<div className="admin-detail-modal__panel admin-error-report-detail">
<header className="admin-error-report-detail__header">
<div className="admin-error-report-detail__heading">
<span className="admin-error-report-detail__eyebrow">
</span>
<h2>{selected.batchId}</h2>
<div className="admin-error-report-detail__badges">
<span
className={`admin-error-report-status is-${selected.status}`}
>
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
selected.status}
</span>
<span className="admin-error-report-detail__badge">
{selected.firstSource ?? selected.source ?? '未知来源'}
</span>
</div>
</div>
<button
type="button"
className="admin-error-report-detail__close"
ref={detailCloseButtonRef}
onClick={() => setSelected(null)}
aria-label="关闭错误报告详情"
>
×
</button>
</header>
<p>
{selected.userId} · {selected.eventCount} ·
{selected.logCount}
</p>
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
{selected.userDescription ? (
<p>{selected.userDescription}</p>
) : null}
<label className="admin-detail-modal__note">
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
<div className="admin-detail-modal__actions">
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
<div className="admin-error-report-detail__summary">
<div>
<span></span>
<strong>{selected.userId}</strong>
</div>
<div>
<span></span>
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{selected.eventCount}</strong>
</div>
<div>
<span></span>
<strong>{selected.logCount}</strong>
</div>
</div>
<div className="admin-error-report-detail__body">
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3></h3>
<span> 20 </span>
</div>
<span className="admin-error-report-detail__count">
{selected.events.length}
</span>
</div>
<div className="admin-error-report-event-list">
{selected.events.length === 0 ? (
<p className="admin-error-report-detail__empty">
</p>
) : null}
{selected.events.slice(0, 20).map((event, index) => {
const message = eventText(event, 'message') || '未知错误';
const stack = eventText(event, 'stack');
const occurredAt = eventText(event, 'occurredAt');
return (
<article
className="admin-error-report-event"
key={`${index}-${message}`}
>
<div className="admin-error-report-event__topline">
<span className="admin-error-report-event__index">
#{index + 1}
</span>
<span>{eventText(event, 'source') || 'client'}</span>
<span>{eventText(event, 'count') || '1'} </span>
<time>{formatAdminTimestamp(occurredAt)}</time>
</div>
<p className="admin-error-report-event__message">
{message}
</p>
{eventText(event, 'fingerprint') ? (
<code>{eventText(event, 'fingerprint')}</code>
) : null}
{stack ? (
<details>
<summary></summary>
<pre>{stack}</pre>
</details>
) : null}
</article>
);
})}
</div>
</section>
{selected.userDescription ? (
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3></h3>
</div>
</div>
<p className="admin-error-report-detail__description">
{selected.userDescription}
</p>
</section>
) : null}
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
<label className="admin-detail-modal__note">
<span></span>
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
</section>
</div>
<footer className="admin-error-report-detail__footer">
<label className="admin-error-report-detail__status-field">
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{ERROR_REPORT_STATUS_LABELS[value]}
</option>
))}
</select>
</label>
<div className="admin-error-report-detail__footer-actions">
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
{busy ? '保存中…' : '保存备注'}
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
</div>
</footer>
</div>
</div>
) : null}
+371
View File
@@ -3213,6 +3213,377 @@ button:disabled {
padding: 12px;
border-radius: 8px;
}
.admin-error-report-detail {
width: min(980px, 100%);
padding: 0;
overflow: hidden;
border: 1px solid #eadfd6;
background: #fffdfb;
box-shadow: 0 24px 80px rgb(57 31 18 / 22%);
}
.admin-error-report-detail__header {
align-items: flex-start;
padding: 24px 28px 20px;
border-bottom: 1px solid #eee2d8;
background: linear-gradient(135deg, #fffaf5, #fffdfb 65%);
}
.admin-error-report-detail__heading {
min-width: 0;
}
.admin-error-report-detail__eyebrow {
display: block;
margin-bottom: 7px;
color: #a4775d;
font-size: 12px;
font-weight: 750;
letter-spacing: 0.08em;
}
.admin-error-report-detail__heading h2 {
margin: 0;
overflow-wrap: anywhere;
color: #3d2a20;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 19px;
line-height: 1.35;
}
.admin-error-report-detail__badges,
.admin-error-report-detail__footer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-error-report-detail__badges {
margin-top: 12px;
}
.admin-error-report-detail__badge,
.admin-error-report-detail__count {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border: 1px solid #eadfd6;
border-radius: 999px;
color: #785b49;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.admin-error-report-detail__close {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border: 1px solid #e5d5c8;
border-radius: 9px;
color: #765848;
background: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.admin-error-report-detail__close:hover,
.admin-error-report-detail__close:focus-visible {
border-color: #b6623f;
color: #9b4f31;
outline: none;
}
.admin-error-report-status {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 750;
}
.admin-error-report-status.is-new {
color: #a14e20;
background: #fff0e5;
}
.admin-error-report-status.is-in-progress {
color: #72551b;
background: #fff7d9;
}
.admin-error-report-status.is-resolved {
color: #28704d;
background: #e9f7ee;
}
.admin-error-report-detail__summary {
display: grid;
grid-template-columns: minmax(0, 1.7fr) repeat(3, minmax(100px, 1fr));
gap: 1px;
border-bottom: 1px solid #eee2d8;
background: #eee2d8;
}
.admin-error-report-detail__summary > div {
display: grid;
min-width: 0;
gap: 5px;
padding: 14px 18px;
background: #fffdfb;
}
.admin-error-report-detail__summary span,
.admin-error-report-detail__section-heading span,
.admin-error-report-detail__status-field {
color: #997d6a;
font-size: 12px;
font-weight: 650;
}
.admin-error-report-detail__summary strong {
overflow-wrap: anywhere;
color: #4d3326;
font-size: 13px;
}
.admin-error-report-detail__body {
display: grid;
gap: 16px;
max-height: min(62vh, 640px);
overflow: auto;
padding: 20px 28px;
}
.admin-error-report-detail__section {
display: grid;
gap: 12px;
}
.admin-error-report-detail__section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-error-report-detail__section-heading h3 {
margin: 0 0 4px;
color: #4d3326;
font-size: 15px;
}
.admin-error-report-detail__section-heading span {
line-height: 1.5;
}
.admin-error-report-detail__count {
flex: 0 0 auto;
}
.admin-error-report-event-list {
display: grid;
gap: 10px;
}
.admin-error-report-detail__empty {
margin: 0;
padding: 16px;
border: 1px dashed #e4d2c5;
border-radius: 10px;
color: #997d6a;
background: #fffaf6;
text-align: center;
}
.admin-error-report-event {
display: grid;
gap: 9px;
padding: 14px 16px;
border: 1px solid #eee2d8;
border-radius: 12px;
background: #fff;
}
.admin-error-report-event__topline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: #927663;
font-size: 12px;
}
.admin-error-report-event__topline time {
margin-left: auto;
color: #aa9282;
}
.admin-error-report-event__index {
color: #b6623f;
font-weight: 750;
}
.admin-error-report-event__message {
margin: 0;
color: #4a3024;
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.admin-error-report-event code {
overflow-wrap: anywhere;
color: #957a69;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
}
.admin-error-report-event details {
border-top: 1px solid #f0e7df;
padding-top: 9px;
}
.admin-error-report-event summary {
color: #a15d3e;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.admin-error-report-event pre {
max-height: 180px;
margin: 9px 0 0;
padding: 10px;
border: 1px solid #f0e7df;
background: #fffaf6;
color: #72594a;
font-size: 11px;
line-height: 1.5;
}
.admin-error-report-detail__description {
margin: 0;
padding: 13px 15px;
border-radius: 10px;
color: #634b3c;
background: #fff8f2;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-error-report-detail__section--note {
padding-top: 4px;
}
.admin-error-report-detail__section--note .admin-detail-modal__note {
display: grid;
gap: 8px;
color: #624938;
font-size: 13px;
font-weight: 700;
}
.admin-error-report-detail__section--note textarea {
width: 100%;
min-height: 100px;
box-sizing: border-box;
resize: vertical;
border: 1px solid #e4d2c5;
border-radius: 10px;
padding: 11px 12px;
color: #4d3326;
background: #fff;
font: inherit;
line-height: 1.5;
}
.admin-error-report-detail__section--note textarea:focus {
border-color: #b6623f;
box-shadow: 0 0 0 3px rgb(182 98 63 / 14%);
outline: none;
}
.admin-error-report-detail__footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 16px 28px 20px;
border-top: 1px solid #eee2d8;
background: #fffaf6;
}
.admin-error-report-detail__status-field {
display: grid;
gap: 6px;
}
.admin-error-report-detail__status-field select {
min-width: 132px;
min-height: 38px;
border: 1px solid #e4d2c5;
border-radius: 8px;
padding: 0 10px;
color: #4d3326;
background: #fff;
font: inherit;
}
.admin-error-report-detail__footer-actions button {
min-height: 38px;
border: 1px solid #d9c3b4;
border-radius: 8px;
padding: 0 13px;
color: #6d4b3a;
background: #fff;
font-weight: 700;
cursor: pointer;
}
.admin-error-report-detail__footer-actions button:last-child {
border-color: #a96442;
color: #fff;
background: #a96442;
}
.admin-error-report-detail__footer-actions button:hover,
.admin-error-report-detail__footer-actions button:focus-visible {
border-color: #a96442;
outline: none;
}
@media (max-width: 720px) {
.admin-error-report-detail__header,
.admin-error-report-detail__body,
.admin-error-report-detail__footer {
padding-right: 18px;
padding-left: 18px;
}
.admin-error-report-detail__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-error-report-detail__footer {
align-items: stretch;
flex-direction: column;
}
.admin-error-report-detail__footer-actions button {
flex: 1;
}
}
.admin-agc-models {
min-width: 0;
}
@@ -481,7 +481,17 @@ export function runTauriBuild(
const result = spawn(
npmCommand,
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
{
cwd: appRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
// Vite embeds the platform API origin in the packaged renderer. The
// release channel and updater channel therefore cannot drift apart.
VITE_AGC_PLATFORM_CHANNEL: channel,
},
},
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
@@ -204,6 +204,21 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
});
});
test('packaged renderer receives the same channel as the updater manifest', () => {
const context = resolveReleaseContext([], {
AGC_BUILD_TARGET: windowsTarget,
AGC_UPDATE_CHANNEL: 'release',
});
let spawnOptions;
runTauriBuild([], context, {
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
},
});
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
});
test('macOS manifests advertise exactly the architectures actually built', () => {
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
'darwin-aarch64',
@@ -13,7 +13,8 @@ static TOKEN_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
.expect("valid diagnostic sanitization pattern")
});
static URL_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)https?://\S+").expect("valid diagnostic sanitization pattern")
regex::Regex::new(r"(?i)https?://[^\s/?#]+(?P<path>/[^\s?#]*)?(?:[?#][^\s]*)?")
.expect("valid diagnostic sanitization pattern")
});
static PATH_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)[A-Z]:[\\/][^\s]+|/(?:Users|home|private|tmp)/[^\s]+")
@@ -27,13 +28,30 @@ fn replace_pattern(value: String, pattern: &regex::Regex, replacement: &str) ->
pattern.replace_all(&value, replacement).into_owned()
}
fn redact_urls(value: &str) -> String {
URL_PATTERN
.replace_all(value, |captures: &regex::Captures<'_>| {
let path = captures
.name("path")
.map(|value| value.as_str())
.unwrap_or("/");
if path.starts_with("/api/") || path.starts_with("/v1/") || path.starts_with("/admin/")
{
format!("<origin>{path}")
} else {
"<url>".to_string()
}
})
.into_owned()
}
pub(crate) fn sanitize(value: &str, max_chars: usize) -> String {
let normalized = value
.to_string()
.pipe(|value| replace_pattern(value, &AUTHORIZATION_PATTERN, "authorization: [REDACTED]"))
.pipe(|value| replace_pattern(value, &BEARER_PATTERN, "Bearer [REDACTED]"))
.pipe(|value| replace_pattern(value, &TOKEN_PATTERN, "[REDACTED]"))
.pipe(|value| replace_pattern(value, &URL_PATTERN, "<url>"))
.pipe(|value| redact_urls(&value))
.pipe(|value| replace_pattern(value, &PATH_PATTERN, "<path>"))
.pipe(|value| replace_pattern(value, &HEX_ID_PATTERN, "<id>"));
normalized
@@ -83,6 +101,17 @@ mod tests {
assert!(!sanitized.contains("/users/alice"));
}
#[test]
fn keeps_api_route_while_redacting_url_origin_and_query() {
let sanitized = sanitize(
"请求超时:https://dev.genarrative.world/api/llm/models?token=secret",
512,
);
assert_eq!(sanitized, "请求超时:<origin>/api/llm/models");
assert!(!sanitized.contains("genarrative.world"));
assert!(!sanitized.contains("secret"));
}
#[test]
fn removes_newlines_and_bounds_length() {
assert_eq!(sanitize("a\nb\u{0000}c", 3), "a b");
@@ -23,7 +23,15 @@ import {
normalizeAuthPhoneInput,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import { getClientServerBaseUrl } from '../services/clientHttp';
import {
type ClientServerPreset,
type ClientServerSelection,
clientServerSelectionEnabled,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import {
captureClientError,
installWebviewLogBridge,
@@ -151,6 +159,40 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return true;
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
return false;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => {
@@ -385,6 +427,7 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
if (!persistServerSelection()) return;
const apiBaseUrl = getClientServerBaseUrl();
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
@@ -430,6 +473,7 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码');
return;
}
if (!persistServerSelection()) return;
const loginApiBaseUrl = getClientServerBaseUrl();
const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true);
@@ -565,6 +609,55 @@ export function AuthenticatedClient({
<h1> GameAgent</h1>
<p></p>
</div>
{clientServerSelectionEnabled ? (
<>
<label>
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (!customServerUrl.trim()) return;
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error
? error.message
: String(error),
);
}
}}
/>
</label>
) : null}
</>
) : null}
{authCheckError ? (
<div role="alert" className="client-auth-status">
<p>{authCheckError}</p>
@@ -51,7 +51,6 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
export function getStoredAuthAccessToken(
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) return '';
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
@@ -60,9 +59,12 @@ export function getStoredAuthAccessToken(
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish its origin. Recover an unmarked session through the dev cookie.
// establish an unmarked session's origin. A marked token from another server
// is discarded, while the user's current debug server preference remains.
clearStoredAuthAccessToken();
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
if (!storedOrigin) {
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
}
return '';
}
@@ -70,14 +72,10 @@ export function setStoredAuthAccessToken(
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) {
throw new Error('登录凭据不属于客户端固定的 dev 服务');
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return;
}
clearStoredAuthAccessToken();
@@ -400,7 +398,7 @@ export async function logoutClientAuthSession(
apiBaseUrl = getClientServerBaseUrl(),
) {
try {
if (!getStoredAuthAccessToken()) {
if (!getStoredAuthAccessToken(apiBaseUrl)) {
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
}
try {
@@ -1,8 +1,31 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
const CLIENT_PLATFORM_CHANNEL =
import.meta.env.VITE_AGC_PLATFORM_CHANNEL?.trim() === 'release'
? 'release'
: 'dev';
/** 本地 Vite debug 才允许切换平台服务器;打包产物始终跟随构建渠道。 */
export const clientServerSelectionEnabled = import.meta.env.DEV;
export function isClientServerSelectionEnabled() {
const value = String(import.meta.env.DEV);
return value === 'true' || value === '1';
}
/**
* Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs
@@ -84,8 +107,114 @@ export async function readClientHttpResponseText(
}
}
export function getClientServerBaseUrl() {
return AGC_DEVELOPMENT_API_BASE_URL;
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
if (isClientServerSelectionEnabled()) return 'dev';
return CLIENT_PLATFORM_CHANNEL;
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (!isClientServerSelectionEnabled() || typeof window === 'undefined') {
return fallback;
}
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
function getChannelServerBaseUrl() {
return CLIENT_PLATFORM_CHANNEL === 'release'
? AGC_RELEASE_API_BASE_URL
: AGC_DEVELOPMENT_API_BASE_URL;
}
export function getClientServerBaseUrl(selection?: ClientServerSelection) {
const resolved =
selection ??
(isClientServerSelectionEnabled() ? getClientServerSelection() : null);
if (!resolved) return getChannelServerBaseUrl();
if (resolved.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (resolved.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(resolved.customBaseUrl);
}
type ClientHttpContext = {
@@ -116,7 +245,17 @@ export function resolveClientHttpTarget(
url: string,
context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget {
const serverBaseUrl = getClientServerBaseUrl();
// Unit fixtures use relative requests after the same origin validation.
if (context.mode === 'test' && !context.serverBaseUrl) {
return { transport: 'web', url };
}
const serverBaseUrl = context.serverBaseUrl ?? getClientServerBaseUrl();
if (
!isClientServerSelectionEnabled() &&
serverBaseUrl !== getChannelServerBaseUrl()
) {
throw new Error('请求目标不在当前构建渠道的服务器范围内');
}
const target = new URL(url, `${serverBaseUrl}/`);
if (
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
@@ -124,10 +263,9 @@ export function resolveClientHttpTarget(
target.username ||
target.password
) {
throw new Error('请求目标不在客户端固定的 dev 服务范围内');
throw new Error('请求目标不在当前构建渠道的服务范围内');
}
// Unit fixtures use relative requests after the same origin validation.
if (context.mode === 'test') {
return { transport: 'web', url };
}
@@ -61,15 +61,34 @@ export async function invokeDiagnostic<T>(
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
*/
function redactDiagnosticUrl(value: string) {
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (rawUrl) => {
let parsed: URL;
try {
parsed = new URL(rawUrl.replace(/[),.;!?]+$/u, ''));
} catch {
return '<url>';
}
const pathname = parsed.pathname || '/';
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/v1/') ||
pathname.startsWith('/admin/')
) {
return `<origin>${pathname}`;
}
return '<url>';
});
}
export function normalizeDiagnosticText(value: string) {
return value
return redactDiagnosticUrl(value)
.replace(
/authorization\s*:\s*(?:bearer\s+)?\S+/giu,
'authorization: [REDACTED]',
)
.replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]')
.replace(/https?:\/\/\S+/giu, '<url>')
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
@@ -178,7 +178,7 @@ export function registerAuthTests() {
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull());
expect(screen.queryByLabelText('服务器')).toBeNull();
expect(screen.getByLabelText('服务器')).not.toBeNull();
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
@@ -999,7 +999,7 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows login without server selection or custom platform address', async () => {
it('shows debug server selection and keeps custom address collapsed by default', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
@@ -1016,12 +1016,12 @@ export function registerAuthTests() {
);
await screen.findByRole('main', { name: '登录' });
expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull();
expect(screen.getByRole('combobox', { name: '服务器' })).not.toBeNull();
expect(screen.queryByLabelText('自定义服务器地址')).toBeNull();
});
it.each(['release', 'dev'])(
'restores dev without forwarding a bare credential despite the %s preference',
'restores the selected server without forwarding a bare credential (%s)',
async (preset) => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
@@ -1078,7 +1078,10 @@ export function registerAuthTests() {
'install_platform_account_session',
expect.objectContaining({
accessToken: 'dev-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
apiBaseUrl:
preset === 'release'
? 'https://www.genarrative.world'
: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
},
@@ -100,20 +100,22 @@ describe('AGC platform credential origin', () => {
expect(window.localStorage.getItem(originKey)).toBeNull();
});
it('stores new dev credentials with their origin and ignores old preferences', () => {
vi.stubEnv('DEV', false);
it('stores credentials with their origin and keeps debug server preferences', () => {
setStoredAuthAccessToken('new-token');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'release' }),
);
expect(getApiAccessToken()).toBe('new-token');
expect(getStoredAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL)).toBe(
'new-token',
);
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe('');
expect(() =>
setStoredAuthAccessToken('wrong-token', 'https://example.com'),
).toThrow('固定的 dev 服务');
expect(getStoredAuthAccessToken()).toBe('new-token');
setStoredAuthAccessToken('release-token', 'https://www.genarrative.world');
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe(
'release-token',
);
expect(window.localStorage.getItem(selectionKey)).toContain('release');
clearStoredAuthAccessToken();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
@@ -120,7 +120,7 @@ describe('AGC client HTTP transport', () => {
});
it.each(['development', 'production'])(
'routes %s Tauri requests through fixed dev',
'routes %s Tauri requests through the active channel server',
(mode) => {
expect(
resolveClientHttpTarget('/api/auth/me', {
@@ -143,17 +143,21 @@ describe('AGC client HTTP transport', () => {
).toEqual({ transport: 'web', url: '/api/auth/me' });
});
it('rejects release Tauri requests outside the fixed dev API origin', () => {
expect(() =>
it('routes debug requests through an explicit custom server', () => {
expect(
resolveClientHttpTarget('https://example.com/api/auth/me', {
isTauri: true,
mode: 'production',
mode: 'development',
serverBaseUrl: 'https://example.com',
}),
).toThrow('固定的 dev 服务范围');
).toEqual({
transport: 'tauri-http',
url: 'https://example.com/api/auth/me',
});
});
it.each(['release', 'dev', 'custom'])(
'ignores persisted %s preference when resolving web requests',
'honors persisted %s preference in debug requests',
(preset) => {
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
@@ -162,8 +166,13 @@ describe('AGC client HTTP transport', () => {
customBaseUrl: 'https://staging.example.com',
}),
);
vi.stubEnv('DEV', false);
expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL);
expect(getClientServerBaseUrl()).toBe(
preset === 'release'
? 'https://www.genarrative.world'
: preset === 'custom'
? 'https://staging.example.com'
: AGC_DEVELOPMENT_API_BASE_URL,
);
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: false,
@@ -171,30 +180,25 @@ describe('AGC client HTTP transport', () => {
}),
).toEqual({
transport: 'web',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
url: `${getClientServerBaseUrl()}/api/auth/me`,
});
},
);
it.each(['development', 'production', 'test'])(
'rejects explicit origin overrides before transport in %s',
async (mode) => {
vi.stubEnv('MODE', mode);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchClientHttp(
'/api/auth/me',
{},
{
serverBaseUrl: 'https://www.genarrative.world',
},
),
).rejects.toThrow('固定的 dev 服务范围');
expect(fetchMock).not.toHaveBeenCalled();
expect(tauriHttpFetch).not.toHaveBeenCalled();
},
);
it('keeps explicit server origin when sending a debug request', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
await fetchClientHttp(
'/api/auth/me',
{},
{
serverBaseUrl: 'https://www.genarrative.world',
},
);
expect(fetchMock).toHaveBeenCalledWith('/api/auth/me', expect.any(Object));
expect(tauriHttpFetch).not.toHaveBeenCalled();
});
it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers();
@@ -88,6 +88,7 @@ import {
getStableErrorReportSubmissionId,
installWebviewLogBridge,
markClientErrorEventsSubmitted,
normalizeDiagnosticText,
resetClientErrorEventsForTests,
shouldCaptureClientError,
submitErrorReportBatch,
@@ -208,6 +209,15 @@ describe('客户端错误报告池', () => {
expect(shouldCaptureClientError({ networkError: true })).toBe(true);
});
it('保留 API 路由但隐藏 URL origin 与查询参数', () => {
const sanitized = normalizeDiagnosticText(
'请求超时:https://dev.genarrative.world/api/llm/models?token=secret#fragment',
);
expect(sanitized).toBe('请求超时:<origin>/api/llm/models');
expect(sanitized).not.toContain('genarrative.world');
expect(sanitized).not.toContain('secret');
});
it('将 WebView console 输出写入普通文本日志 command', async () => {
const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {});
const uninstall = installWebviewLogBridge();
@@ -30,7 +30,7 @@
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。发布渠道为 `dev/release/自定义名称`Windows/Mac 是系统,OSS 的 `<channel>-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。
- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world``dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`Windows/Mac 是系统,OSS 的 `<channel>-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。
- AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
@@ -46,9 +46,9 @@
- 渠道清单新增可选 `downloads` 字典,键与 updater 平台键一致,值为 `{ url }`,只登记首装包。Windows `.exe` 可同时用于首装和更新;macOS 首装必须为 `.dmg`,不得将 `.app.tar.gz` 当首装包。已发布且没有 `downloads` 字段的 Windows 清单可读取既有 `platforms.windows-x86_64.url`;Mac 没有首装元数据时隐藏,不推导 DMG 地址。
- 渠道 404 视为尚未发布并隐藏该平台;两端均未发布时显示空状态。单渠道请求失败、超时或格式非法时保留另一端有效下载项,同时提示部分平台暂不可用并允许重试;没有任何有效项且存在失败时返回可读 502。关闭面板取消请求,迟到响应不能覆盖下一次打开的状态。
- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/<channel>-win|mac/<version>/` 分区下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于对应系统。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
- AGC 开发态和正式包的平台服务地址统一固定为 `https://dev.genarrative.world`;登录页不提供服务器选择或自定义地址。旧的服务器偏好不能覆盖固定地址;已有会话仍按 origin 隔离,不能将其他服务的凭据迁往 dev。自定义 LLM 配置不属于平台服务器选择。
- access token 与 origin 一起保存;已有 dev origin 的 token 保留。没有 origin 的旧 token 一律清除,因为旧版可以单独修改服务器偏好,偏好不能证明 token 来源。随后仅使用 dev 自己的 refresh cookie 恢复或重新登录;原生会话回写同样绑定 dev origin。
- 验收覆盖固定 dev 的登录/会话与请求行为、旧服务器偏好、首页入口挂载、动态最新版本链接、清单失败与重试、关闭取消、桌面和移动布局,以及公开清单和安装包的真实可读性。
- AGC 本地 debug 态保留服务器选择,可选择 `release``dev` 或自定义 HTTPS / 本机 HTTP 地址;正式打包产物隐藏服务器选择。打包产物的平台服务地址跟随构建渠道:`release` 渠道连接 `https://www.genarrative.world``dev` 渠道连接 `https://dev.genarrative.world`,不会被旧的本地服务器偏好覆盖。自定义 LLM 配置不属于平台服务器选择。
- access token 与 origin 一起保存;登录、刷新、退出和原生会话回写都使用同一个已冻结的 origin。没有 origin 的旧 token 一律清除,因为旧版可以单独修改服务器偏好,偏好不能证明 token 来源。
- 验收覆盖 debug 服务器选择与自定义地址、release/dev 渠道服务地址、origin 隔离、旧服务器偏好、首页入口挂载、动态最新版本链接、清单失败与重试、关闭取消、桌面和移动布局,以及公开清单和安装包的真实可读性。
### 正常路径
@@ -127,11 +127,11 @@
渠道与系统分离的定向验证覆盖发布脚本、上传计划、网站配置贯通及跨渠道链接拒绝:`build-release.test.mjs``release-oss.test.mjs``platform-oss client_downloads``api-server client_download`。本地隔离数据库的 API smoke 验证 `/healthz` 成功、配置 release 时仅读取 release 分区、查询参数不能覆盖渠道、未发布版本返回空列表且 `no-store`;这不代表已构建或上传 release 安装包。真实 Windows/macOS 安装、签名和更新仍由发布验收单独执行。
官网下载与固定服务地址已于 `2026-09-19` 完成源码验收:
官网下载与渠道服务地址已于 `2026-09-19` 完成源码验收:
| 条款 | 验收方式 | 结果 |
| --- | --- | --- |
| 固定 dev、旧凭据来源隔离与登录恢复 | HTTP/API/存储定向测试、登录会话界面测试、AGC 类型检查 | 通过;无 origin token 不发送,dev cookie 恢复链保留 |
| 渠道服务地址、旧凭据来源隔离与登录恢复 | HTTP/API/存储定向测试、登录会话界面测试、AGC 类型检查 | 通过;无 origin token 不发送,渠道 origin 与会话回写保持一致 |
| 动态链接、失败重试、取消与重开竞态 | 下载组件与站点壳定向 Vitest、Web 类型检查 | 通过;桌面与移动首页均挂载入口 |
| 已发布平台自动出现与各平台独立版本 | 多平台聚合测试、组件测试及桌面/320px 浏览器受控清单 | 通过;未发布隐藏,重新打开后展示 Windows、Apple Silicon、Intel 的对应版本与链接,部分失败仍可下载有效项 |
| 首装元数据、DMG 选择与上传顺序 | 发布脚本 39 项临时夹具测试 | 通过;限定当版/当架构唯一非空 DMG,更新包/签名/首装包先于 latest,失败不更新指针,dry-run 不执行上传 |
@@ -6,6 +6,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
## 客户端
- 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 `<url>`。历史已经脱敏的归档不推测或补造原路由。
- 捕获 React render error、`window.onerror``unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。
- 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。
- 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
@@ -28,6 +30,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}``GET /admin/api/error-reports` 和受保护的 `/download`。列表支持 `limit`/`offset` 分页并返回 `total``hasMore`OSS 读取先检查 `Content-Length` 并在流式累计超过上限时立即中止,不把超限对象完整缓存在内存中。
- 这些是 api-server 内部登录/管理员路由,不属于 `/api/external/v1`,不纳入 External OpenAPI;管理员详情对不存在返回 404,对归档/元数据损坏返回 500。
- admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、分页、详情、状态 `new/in-progress/resolved`、处理备注和受控下载;不存在的更新目标返回 404,存储损坏返回 500。列表行支持键盘 Enter/Space 打开详情,详情事件预览最多显示 20 条,完整内容通过诊断包下载获取。
- 列表提交时间与详情事件时间接受 RFC3339、`seconds.microsZ`、Unix 秒/毫秒/微秒字符串;非法或越界时间显示 `-`,不显示 `Invalid Date`。仅调整呈现,不改变数据库字段或历史归档。
- 详情按后台现有弹窗、按钮与表单样式分层呈现报告摘要、事件消息、可展开调用栈、用户说明、日志附件和处理备注;支持 Esc、焦点管理、窄屏内部滚动。加载/下载/保存失败在当前可见面板内提示;关闭或打开另一报告后,旧请求不得覆盖新报告。状态与备注保存以后端响应为准。
- 管理员列表、筛选、状态和备注全部读取/更新 SpacetimeDB;错误报告的 list/get/update procedure 要求 `require_editor_generation_runtime_service_identity`,详情先读表再从 OSS 下载并解析 ZIP,下载接口直接从 OSS 返回 ZIP。PATCH 更新直接返回元数据,不重新下载归档;详情弹窗提供备注编辑器。无需新增管理员 DELETE HTTP 接口。每日清理任务按分页扫描全部报告,删除过期 OSS 对象后再删 DB;任一 DB 删除失败会保留错误并在后续周期重试。详情解析对解压后的 `events.jsonl` 设置 24 MiB(与请求体上限一致)与 100 条事件上限。管理员备注超过 2,000 字会被明确拒绝;module 同时校验固定 OSS key、SHA-256、归档大小和事件/日志计数上限;OSS 读取仅将明确不存在映射为 404,其余错误按请求无效或上游故障返回。内部 OSS 读写只允许 `agc/error-reports/v1/`。旧本地报告不迁移。
SpacetimeDB `error_report` 表字段:`batch_id` 主键、`user_id``submission_id``idempotency_key` 唯一键、`object_key``archive_sha256``archive_size_bytes``event_count``log_count`、首个 fingerprint/source、`review_status``admin_note``created_at``updated_at`;索引为 `(user_id, submission_id)``created_at``review_status`
@@ -93,7 +93,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
## 平台服务与官网分发
客户端平台服务固定为 dev,登录页不提供服务器选择。凭据按 origin 隔离迁移;官网下载入口汇总最新渠道清单,自动展示已有首装包的 Windows/Mac 平台及架构。完整合同见 [AGC 客户端更新检查与下载](./【技术方案】AGC客户端更新检查与下载-2026-08-31.md) 的“官网下载与客户端服务地址”。
客户端正式包的平台服务跟随构建渠道:release 连接正式服务,dev 连接开发服务;本地 debug 登录页保留 release/dev/custom 服务器选择。凭据按 origin 隔离;官网下载入口汇总最新渠道清单,自动展示已有首装包的 Windows/Mac 平台及架构。完整合同见 [AGC 客户端更新检查与下载](./【技术方案】AGC客户端更新检查与下载-2026-08-31.md) 的“官网下载与客户端服务地址”。
## Agent 提示词与回合测试边界