修复客户端渠道与错误报告展示
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
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:
@@ -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}
|
||||
|
||||
@@ -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: ®ex::Regex, replacement: &str) ->
|
||||
pattern.replace_all(&value, replacement).into_owned()
|
||||
}
|
||||
|
||||
fn redact_urls(value: &str) -> String {
|
||||
URL_PATTERN
|
||||
.replace_all(value, |captures: ®ex::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 提示词与回合测试边界
|
||||
|
||||
|
||||
Reference in New Issue
Block a user