Merge remote-tracking branch 'origin/master' into feat/video-BGfilter
This commit is contained in:
@@ -22,6 +22,7 @@ import type {
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -230,6 +232,23 @@ export function listAdminTrackingEventKeys(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminFeatureGateConfig(token: string) {
|
||||
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertAdminFeatureGateConfig(
|
||||
token: string,
|
||||
payload: AdminUpsertFeatureGateConfigRequest,
|
||||
) {
|
||||
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
|
||||
@@ -212,6 +212,26 @@ export interface AdminTrackingEventListQuery {
|
||||
exportAll?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminFeatureGateConfigPayload {
|
||||
gateKey: string;
|
||||
enabled: boolean;
|
||||
rolloutPercent: number;
|
||||
allowUserIds: string[];
|
||||
allowUserTags: string[];
|
||||
denyUserIds: string[];
|
||||
description: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminFeatureGateConfigResponse {
|
||||
gates: AdminFeatureGateConfigPayload[];
|
||||
}
|
||||
|
||||
export type AdminUpsertFeatureGateConfigRequest = Omit<
|
||||
AdminFeatureGateConfigPayload,
|
||||
'updatedAt'
|
||||
>;
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
|
||||
@@ -26,6 +26,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
@@ -186,6 +187,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'gray-release' ? (
|
||||
<AdminGrayReleaseConfigPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'redeem' ? (
|
||||
<AdminRedeemCodePage
|
||||
token={token}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Images,
|
||||
Star,
|
||||
WalletCards,
|
||||
@@ -38,6 +39,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
'profile-wallet': WalletCards,
|
||||
|
||||
@@ -40,6 +40,16 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('后台灰度发布路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'gray-release',
|
||||
label: '灰度发布',
|
||||
hash: '#gray-release',
|
||||
});
|
||||
expect(resolveAdminRoute('#gray-release')).toBe('gray-release');
|
||||
expect(routeHash('gray-release')).toBe('#gray-release');
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-assets',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
| 'invite'
|
||||
| 'profile-wallet'
|
||||
@@ -30,6 +31,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
{id: 'gray-release', label: '灰度发布', hash: '#gray-release'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminDatabaseTableRows,
|
||||
getAdminDatabaseTables,
|
||||
} from '../api/adminApiClient';
|
||||
import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage';
|
||||
import { AdminDatabaseTablesPage } from './AdminDatabaseTablesPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
@@ -36,12 +36,7 @@ beforeEach(() => {
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: [
|
||||
'u-b',
|
||||
'u-a',
|
||||
'INV-1001',
|
||||
'2026-05-02T00:00:00Z',
|
||||
],
|
||||
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
@@ -69,32 +64,37 @@ beforeEach(() => {
|
||||
|
||||
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {container} = render(
|
||||
const { container } = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('u-b');
|
||||
await screen.findByText('2026-05-02 08:00:00');
|
||||
|
||||
const tableWrap = container.querySelector('.admin-table-wrap');
|
||||
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
|
||||
expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe(
|
||||
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||
);
|
||||
expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe(
|
||||
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||
);
|
||||
expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe(
|
||||
'原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByRole('option', { name: '邀请关系(profile_referral_relation)' })
|
||||
.getAttribute('title'),
|
||||
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
|
||||
expect(
|
||||
screen.getByText('已选表:邀请关系(profile_referral_relation)'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '邀请关系' }).getAttribute('title'),
|
||||
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
|
||||
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getAdminDatabaseTableRows,
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
AdminDatabaseTableRowPayload,
|
||||
AdminDatabaseTableRowsResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import {handlePageError} from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminDatabaseTablesPageProps {
|
||||
token: string;
|
||||
@@ -35,8 +35,11 @@ export function AdminDatabaseTablesPage({
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState('');
|
||||
const [limit, setLimit] = useState('100');
|
||||
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(null);
|
||||
const [detailRow, setDetailRow] = useState<AdminDatabaseTableRowPayload | null>(null);
|
||||
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [detailRow, setDetailRow] =
|
||||
useState<AdminDatabaseTableRowPayload | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [copyMessage, setCopyMessage] = useState('');
|
||||
const [sortColumn, setSortColumn] = useState('');
|
||||
@@ -90,7 +93,9 @@ export function AdminDatabaseTablesPage({
|
||||
|
||||
const tableOptions = useMemo(() => {
|
||||
const optionNames =
|
||||
tableName && !tables.includes(tableName) ? [tableName, ...tables] : tables;
|
||||
tableName && !tables.includes(tableName)
|
||||
? [tableName, ...tables]
|
||||
: tables;
|
||||
return optionNames.map(getDatabaseTableHeader);
|
||||
}, [tableName, tables]);
|
||||
|
||||
@@ -119,7 +124,7 @@ export function AdminDatabaseTablesPage({
|
||||
}
|
||||
|
||||
return [...rows]
|
||||
.map((row, index) => ({index, row}))
|
||||
.map((row, index) => ({ index, row }))
|
||||
.sort((left, right) => {
|
||||
const comparison = compareTableCellValues(
|
||||
left.row.cells[sortColumn],
|
||||
@@ -131,7 +136,7 @@ export function AdminDatabaseTablesPage({
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map(({row}) => row);
|
||||
.map(({ row }) => row);
|
||||
}, [result, sortColumn, sortDirection, visibleColumns]);
|
||||
|
||||
async function loadTables() {
|
||||
@@ -168,11 +173,15 @@ export function AdminDatabaseTablesPage({
|
||||
setIsLoadingRows(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDatabaseTableRows(token, normalizedTableName, {
|
||||
search: querySearch,
|
||||
filters: queryFilters,
|
||||
limit: parseLimit(queryLimit),
|
||||
});
|
||||
const response = await getAdminDatabaseTableRows(
|
||||
token,
|
||||
normalizedTableName,
|
||||
{
|
||||
search: querySearch,
|
||||
filters: queryFilters,
|
||||
limit: parseLimit(queryLimit),
|
||||
},
|
||||
);
|
||||
setResult(response);
|
||||
setCopyMessage('');
|
||||
} catch (error: unknown) {
|
||||
@@ -199,7 +208,7 @@ export function AdminDatabaseTablesPage({
|
||||
setSearch('');
|
||||
setFilters('');
|
||||
setLimit('100');
|
||||
void refreshRows(tableName, {search: '', filters: '', limit: '100'});
|
||||
void refreshRows(tableName, { search: '', filters: '', limit: '100' });
|
||||
}
|
||||
|
||||
function handleSortColumn(column: string) {
|
||||
@@ -219,7 +228,11 @@ export function AdminDatabaseTablesPage({
|
||||
return;
|
||||
}
|
||||
|
||||
const copiedText = JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2);
|
||||
const copiedText = JSON.stringify(
|
||||
detailRow.raw ?? detailRow.cells,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
try {
|
||||
await navigator.clipboard.writeText(copiedText);
|
||||
setCopyMessage('已复制 JSON');
|
||||
@@ -265,7 +278,7 @@ export function AdminDatabaseTablesPage({
|
||||
value={tableName}
|
||||
onChange={(event) => handleTableChange(event.target.value)}
|
||||
>
|
||||
{tableOptions.map(({name, optionLabel, description}) => (
|
||||
{tableOptions.map(({ name, optionLabel, description }) => (
|
||||
<option key={name} title={description} value={name}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
@@ -296,7 +309,11 @@ export function AdminDatabaseTablesPage({
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="admin-secondary-button" disabled={isLoadingRows} type="submit">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoadingRows}
|
||||
type="submit"
|
||||
>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoadingRows ? '查询中' : '查询'}</span>
|
||||
</button>
|
||||
@@ -327,14 +344,16 @@ export function AdminDatabaseTablesPage({
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3 title={resultTableHeader.description}>{resultTableHeader.label}</h3>
|
||||
<h3 title={resultTableHeader.description}>
|
||||
{resultTableHeader.label}
|
||||
</h3>
|
||||
<span>{result?.totalReturned ?? 0} 条</span>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide admin-database-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columnHeaders.map(({column, label, description}) => {
|
||||
{columnHeaders.map(({ column, label, description }) => {
|
||||
const isSorted = sortColumn === column;
|
||||
return (
|
||||
<th
|
||||
@@ -380,7 +399,10 @@ export function AdminDatabaseTablesPage({
|
||||
onClick={() => setDetailRow(row)}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
const cellValue = formatCellValue(row.cells[column]);
|
||||
const cellValue = formatCellValue(
|
||||
row.cells[column],
|
||||
column,
|
||||
);
|
||||
return (
|
||||
<td key={column}>
|
||||
<span
|
||||
@@ -409,7 +431,9 @@ export function AdminDatabaseTablesPage({
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={Math.max(visibleColumns.length + 1, 1)}>暂无数据</td>
|
||||
<td colSpan={Math.max(visibleColumns.length + 1, 1)}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -419,7 +443,11 @@ export function AdminDatabaseTablesPage({
|
||||
|
||||
{detailRow ? (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section className="admin-detail-panel" role="dialog" aria-modal="true">
|
||||
<section
|
||||
className="admin-detail-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<h3>行详情</h3>
|
||||
<div className="admin-detail-actions">
|
||||
@@ -440,7 +468,9 @@ export function AdminDatabaseTablesPage({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{copyMessage ? <div className="admin-status admin-status-ok">{copyMessage}</div> : null}
|
||||
{copyMessage ? (
|
||||
<div className="admin-status admin-status-ok">{copyMessage}</div>
|
||||
) : null}
|
||||
<pre className="admin-code-block">
|
||||
{JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
|
||||
</pre>
|
||||
@@ -457,7 +487,9 @@ function readHashTableName() {
|
||||
if (queryIndex < 0) {
|
||||
return '';
|
||||
}
|
||||
return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? '';
|
||||
return (
|
||||
new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function parseLimit(value: string) {
|
||||
@@ -501,7 +533,10 @@ function getDatabaseTableLabel(tableName: string) {
|
||||
}
|
||||
|
||||
function getDatabaseTableDescription(tableName: string, label: string) {
|
||||
return databaseTableDescriptionMap[tableName] ?? `当前 SpacetimeDB 中的 ${label} 表`;
|
||||
return (
|
||||
databaseTableDescriptionMap[tableName] ??
|
||||
`当前 SpacetimeDB 中的 ${label} 表`
|
||||
);
|
||||
}
|
||||
|
||||
function getDatabaseTableColumnHeader(tableName: string, column: string) {
|
||||
@@ -512,7 +547,7 @@ function getDatabaseTableColumnHeader(tableName: string, column: string) {
|
||||
normalizedColumn,
|
||||
label,
|
||||
);
|
||||
return {column: normalizedColumn, label, description};
|
||||
return { column: normalizedColumn, label, description };
|
||||
}
|
||||
|
||||
function getDatabaseTableColumnLabel(column: string) {
|
||||
@@ -541,8 +576,7 @@ function getDatabaseTableColumnDescription(
|
||||
) {
|
||||
const exactDescription = databaseTableColumnDescriptionMap[column];
|
||||
const description =
|
||||
exactDescription ??
|
||||
`当前表 ${tableName || '未知'} 中的 ${label} 字段`;
|
||||
exactDescription ?? `当前表 ${tableName || '未知'} 中的 ${label} 字段`;
|
||||
return `原始字段名:${column}。${description}。点击可按此列排序。`;
|
||||
}
|
||||
|
||||
@@ -566,7 +600,9 @@ function compareTableCellValues(
|
||||
}
|
||||
|
||||
if (left.kind !== right.kind) {
|
||||
return direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind));
|
||||
return (
|
||||
direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind))
|
||||
);
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
@@ -578,7 +614,10 @@ function compareTableCellValues(
|
||||
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
|
||||
break;
|
||||
case 'text':
|
||||
comparison = tableSortCollator.compare(left.value, getSortableTextValue(right));
|
||||
comparison = tableSortCollator.compare(
|
||||
left.value,
|
||||
getSortableTextValue(right),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -587,26 +626,26 @@ function compareTableCellValues(
|
||||
|
||||
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
|
||||
if (value === null || typeof value === 'undefined' || value === '') {
|
||||
return {kind: 'empty'};
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return {kind: 'number', value};
|
||||
return { kind: 'number', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return {kind: 'boolean', value};
|
||||
return { kind: 'boolean', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return {kind: 'empty'};
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
return {kind: 'text', value: trimmed};
|
||||
return { kind: 'text', value: trimmed };
|
||||
}
|
||||
|
||||
return {kind: 'text', value: stringifyUnknownValue(value)};
|
||||
return { kind: 'text', value: stringifyUnknownValue(value) };
|
||||
}
|
||||
|
||||
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
|
||||
@@ -614,13 +653,24 @@ function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
|
||||
return `${rowIndex}-${String(firstValue ?? '')}`;
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown): FormattedTableCellValue {
|
||||
function formatCellValue(value: unknown, column = ''): FormattedTableCellValue {
|
||||
if (value === null || typeof value === 'undefined' || value === '') {
|
||||
return {content: '-', fullText: '-'};
|
||||
return { content: '-', fullText: '-' };
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
const text = String(value);
|
||||
return {content: text, fullText: text};
|
||||
const readableTimestamp = formatReadableTimestampValue(value, column);
|
||||
if (readableTimestamp) {
|
||||
return {
|
||||
content: readableTimestamp,
|
||||
fullText: `${readableTimestamp}(原始值:${text})`,
|
||||
};
|
||||
}
|
||||
return { content: text, fullText: text };
|
||||
}
|
||||
return {
|
||||
content: stringifyUnknownValue(value),
|
||||
@@ -628,6 +678,107 @@ function formatCellValue(value: unknown): FormattedTableCellValue {
|
||||
};
|
||||
}
|
||||
|
||||
function formatReadableTimestampValue(
|
||||
value: string | number | boolean,
|
||||
column: string,
|
||||
) {
|
||||
if (typeof value === 'boolean' || !isTimestampColumn(column)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const timestampMs = parseTimestampMillis(value, column);
|
||||
if (timestampMs === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(timestampMs);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatBeijingDateTime(date);
|
||||
}
|
||||
|
||||
function isTimestampColumn(column: string) {
|
||||
const normalizedColumn = column.trim().toLowerCase();
|
||||
return (
|
||||
normalizedColumn.endsWith('_at') ||
|
||||
normalizedColumn.endsWith('_at_ms') ||
|
||||
normalizedColumn.endsWith('_at_micros') ||
|
||||
normalizedColumn.endsWith('_timestamp') ||
|
||||
normalizedColumn.endsWith('_timestamp_ms') ||
|
||||
normalizedColumn.endsWith('_timestamp_micros')
|
||||
);
|
||||
}
|
||||
|
||||
function parseTimestampMillis(value: string | number, column: string) {
|
||||
if (typeof value === 'number') {
|
||||
return parseNumericTimestampMillis(value, column);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmed);
|
||||
if (Number.isFinite(numericValue) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return parseNumericTimestampMillis(numericValue, column);
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseNumericTimestampMillis(value: number, column: string) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedColumn = column.trim().toLowerCase();
|
||||
if (
|
||||
normalizedColumn.endsWith('_at_ms') ||
|
||||
normalizedColumn.endsWith('_timestamp_ms')
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (
|
||||
normalizedColumn.endsWith('_at_micros') ||
|
||||
normalizedColumn.endsWith('_timestamp_micros')
|
||||
) {
|
||||
return Math.floor(value / 1_000);
|
||||
}
|
||||
|
||||
if (value >= 1_000_000_000_000_000) {
|
||||
return Math.floor(value / 1_000);
|
||||
}
|
||||
if (value >= 1_000_000_000_000) {
|
||||
return value;
|
||||
}
|
||||
if (value >= 1_000_000_000) {
|
||||
return value * 1_000;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBeijingDateTime(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
}).formatToParts(date);
|
||||
const partMap = Object.fromEntries(
|
||||
parts.map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
|
||||
}
|
||||
|
||||
function stringifyPrettyUnknownValue(value: unknown) {
|
||||
try {
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
@@ -662,10 +813,10 @@ function getSortKindOrder(kind: SortableTableCellValue['kind']): number {
|
||||
}
|
||||
|
||||
type SortableTableCellValue =
|
||||
| {kind: 'empty'}
|
||||
| {kind: 'number'; value: number}
|
||||
| {kind: 'boolean'; value: boolean}
|
||||
| {kind: 'text'; value: string};
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
interface DatabaseTableHeader {
|
||||
name: string;
|
||||
@@ -1257,7 +1408,8 @@ const databaseTableLabelMap: Record<string, string> = {
|
||||
};
|
||||
|
||||
const databaseTableDescriptionMap: Record<string, string> = {
|
||||
database_migration_operator: '管理数据库迁移导出、导入和增量导入权限的操作员表',
|
||||
database_migration_operator:
|
||||
'管理数据库迁移导出、导入和增量导入权限的操作员表',
|
||||
database_migration_import_chunk: '大迁移 JSON 分片导入的临时表',
|
||||
auth_store_snapshot: '旧认证仓储的整份 JSON 快照表',
|
||||
user_account: '用户账号主表',
|
||||
@@ -1347,23 +1499,25 @@ function getSortableBooleanValue(value: SortableTableCellValue) {
|
||||
}
|
||||
|
||||
function getSortableTextValue(value: SortableTableCellValue) {
|
||||
return isSortableTextValue(value) ? value.value : stringifyUnknownValue(value);
|
||||
return isSortableTextValue(value)
|
||||
? value.value
|
||||
: stringifyUnknownValue(value);
|
||||
}
|
||||
|
||||
function isSortableNumberValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, {kind: 'number'}> {
|
||||
): value is Extract<SortableTableCellValue, { kind: 'number' }> {
|
||||
return value.kind === 'number';
|
||||
}
|
||||
|
||||
function isSortableBooleanValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, {kind: 'boolean'}> {
|
||||
): value is Extract<SortableTableCellValue, { kind: 'boolean' }> {
|
||||
return value.kind === 'boolean';
|
||||
}
|
||||
|
||||
function isSortableTextValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, {kind: 'text'}> {
|
||||
): value is Extract<SortableTableCellValue, { kind: 'text' }> {
|
||||
return value.kind === 'text';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
getAdminFeatureGateConfig,
|
||||
upsertAdminFeatureGateConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
getAdminFeatureGateConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminFeatureGateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const configResponse: AdminFeatureGateConfigResponse = {
|
||||
gates: [
|
||||
{
|
||||
gateKey: 'editor.new-toolbar',
|
||||
enabled: true,
|
||||
rolloutPercent: 25,
|
||||
allowUserIds: ['user-1'],
|
||||
allowUserTags: ['beta'],
|
||||
denyUserIds: ['blocked-1'],
|
||||
description: '新版编辑器工具条',
|
||||
updatedAt: '2026-07-07T01:00:00Z',
|
||||
},
|
||||
{
|
||||
gateKey: 'image.generator.v2',
|
||||
enabled: false,
|
||||
rolloutPercent: 5,
|
||||
allowUserIds: ['artist-1', 'artist-2'],
|
||||
allowUserTags: ['internal', 'trial'],
|
||||
denyUserIds: [],
|
||||
description: '图片生成链路',
|
||||
updatedAt: '2026-07-07T02:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creationEntryResponse: AdminCreationEntryConfigResponse = {
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '3D 消除',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 20,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
],
|
||||
eventBanners: [],
|
||||
publicWorkInteractions: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(
|
||||
creationEntryResponse,
|
||||
);
|
||||
vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
});
|
||||
|
||||
test('灰度发布页加载并展示 gate 列表', async () => {
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'image.generator.v2' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token');
|
||||
expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token');
|
||||
});
|
||||
|
||||
test('灰度发布页可选择已有 gate 编辑', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'image.generator.v2' }),
|
||||
);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'image.generator.v2',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'5',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('artist-1\nartist-2');
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
||||
).toBe('internal\ntrial');
|
||||
expect(
|
||||
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
||||
);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:match3d',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'3D 消除创作入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可通过创作入口生成 Gate Key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['puzzle']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:puzzle',
|
||||
);
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'拼图创作入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'image-editor',
|
||||
]);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'image-editor:agent-sidebar',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('agent-sidebar');
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'画布 Agent 入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
gates: [
|
||||
...configResponse.gates,
|
||||
{
|
||||
gateKey: 'homepage.feed-redesign',
|
||||
enabled: true,
|
||||
rolloutPercent: 42,
|
||||
allowUserIds: ['user-a', 'user-b'],
|
||||
allowUserTags: ['beta', 'staff'],
|
||||
denyUserIds: ['blocked-a'],
|
||||
description: '首页信息流',
|
||||
updatedAt: '2026-07-07T03:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
fireEvent.change(screen.getByLabelText('Gate Key'), {
|
||||
target: { value: 'homepage.feed-redesign' },
|
||||
});
|
||||
await user.click(screen.getByLabelText('启用'));
|
||||
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
||||
target: { value: '42' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
||||
target: { value: ' user-a \n\n user-b ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户标签'), {
|
||||
target: { value: ' beta \n staff ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('拒绝用户 ID'), {
|
||||
target: { value: ' blocked-a \n ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: ' 首页信息流 ' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
||||
gateKey: 'homepage.feed-redesign',
|
||||
enabled: true,
|
||||
rolloutPercent: 42,
|
||||
allowUserIds: ['user-a', 'user-b'],
|
||||
allowUserTags: ['beta', 'staff'],
|
||||
denyUserIds: ['blocked-a'],
|
||||
description: '首页信息流',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('灰度发布页无 token 时不请求配置', () => {
|
||||
render(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(getAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,6 +516,12 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-gate-key-selectors {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr)) auto;
|
||||
@@ -1515,6 +1521,10 @@ button:disabled {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-gate-key-selectors {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
|
||||
Vendored
+2
@@ -139,6 +139,8 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
|
||||
# 可选:显式要求备份工作目录所在文件系统至少保留的可用空间;为空时按数据目录大小 + 安全余量估算。
|
||||
GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES=
|
||||
# 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||
|
||||
@@ -3862,3 +3862,13 @@
|
||||
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。
|
||||
- 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-07 功能灰度以后端事实源判定
|
||||
|
||||
- 背景:平台需要把新功能先开放给部分用户,首个接入点是创作入口;灰度规则不能泄露用户标签或完整受众配置给普通前端。
|
||||
- 决策:新增 SpacetimeDB `feature_gate_config` 表作为通用功能灰度事实源,后台通过 `/admin/api/feature-gates` 配置 gate。创作入口使用 `creation-entry:<id>` gate key 约定;`api-server` 在 `/api/creation-entry/config` 和入口路由熔断中按可选登录用户、用户标签、用户 ID 黑白名单和稳定百分比做判定,只返回当前用户过滤后的入口状态。
|
||||
- 后台:灰度页的 Gate Key 选择器按 `prefix:suffix` 两段式配置;后续新增固定功能灰度 key 时,必须同步维护后台下拉框的固定目标配置,避免运营手输 key。
|
||||
- 语义:未配置 gate 或 `enabled=false` 不限制访问;启用后黑名单用户 ID 优先,其次用户 ID 白名单、用户标签白名单、稳定百分比。`enabled=true` 且 `rolloutPercent=0` 是有意的 kill switch;后台选择尚不存在的新 target 时必须重置启用状态、比例和黑白名单,不能隐式继承上一条 gate 的规则。前端只消费过滤后的 `visible/open`,不承接灰度规则真相。
|
||||
- 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。
|
||||
- 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -192,9 +192,11 @@ npm run check:server-rs-ddd
|
||||
3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
|
||||
4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
|
||||
5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
|
||||
6. `paymentChannel` 缺失、未知或和设备不匹配时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`。
|
||||
6. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;微信内浏览器使用 `wechat_jsapi`;普通 Web 使用 `wechat_native`,历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
|
||||
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||
9. 微信 Native 下单显式传 `time_expire`,当前有效期为 5 分钟,并通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
|
||||
10. 普通微信支付渠道的新建 pending 充值订单会写入 `profile_recharge_order_expiration_schedule`。到期处理由 `api-server` 后台 worker claim 调度行后调用微信查单;只有微信返回 `SUCCESS` 才补确认入账,返回 `NOTPAY` / `CLOSED` / `REVOKED` / `PAYERROR` 才关闭本地订单,查询失败或 `USERPAYING` 保留租约等待重试。SpacetimeDB module 不直接发起微信 HTTP 请求。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
@@ -399,6 +401,14 @@ npm run check:server-rs-ddd
|
||||
- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`、`unified_creation_spec_json`。
|
||||
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时按 `shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖。
|
||||
|
||||
### `feature_gate_config`
|
||||
|
||||
- Rust 结构体:`FeatureGateConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/feature_gate_config.rs`
|
||||
- 字段:`gate_key`、`enabled`、`rollout_percent`、`allow_user_ids`、`allow_user_tags`、`deny_user_ids`、`description`、`updated_at`。
|
||||
- 用途:通用功能灰度事实源。当前创作入口使用 `creation-entry:<id>` 约定关联入口 ID;`api-server` 按当前可选登录用户、用户标签和稳定百分比判定后,只把过滤后的入口配置返回普通前端,不下发灰度规则或用户标签。
|
||||
- 迁移兼容:新增表不改已有入口表字段;未配置 gate 或 `enabled=false` 时不限制功能,黑名单用户 ID 优先于白名单和百分比命中。
|
||||
|
||||
### `custom_world_agent_message`
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentMessage`
|
||||
@@ -687,6 +697,12 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`ProfileRechargeOrder`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
|
||||
### `profile_recharge_order_expiration_schedule`
|
||||
|
||||
- Rust 结构体:`ProfileRechargeOrderExpirationSchedule`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:普通微信充值订单的到期查单调度表。表内只保存待检查订单、计划检查时间和 worker 短租约;支付成功或本地关闭后删除对应行。
|
||||
|
||||
### `profile_redeem_code`
|
||||
|
||||
- Rust 结构体:`ProfileRedeemCode`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,13 +6,14 @@
|
||||
|
||||
- 泥点充值在微信小程序 WebView 内走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_coin` 模式。
|
||||
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。
|
||||
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
||||
- 微信内浏览器走 `wechat_jsapi`,复用微信支付 V3 JSAPI 下单返回的预支付参数并通过 `WeixinJSBridge.invoke('getBrandWCPayRequest')` 调起支付;普通 Web 统一走 `wechat_native` 二维码支付,不进入虚拟支付链路,也不依赖 H5 产品权限。`wechat_h5` 仅作为未来 H5 产品权限明确开通后的保留渠道。
|
||||
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
||||
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
|
||||
- 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。
|
||||
|
||||
## 关键文件
|
||||
|
||||
- JSAPI 支付缺少当前用户 openid 时,前端调用 `GET /api/auth/wechat/bind-start` 发起 OAuth;后端把当前 `user_id` 写入 OAuth state,微信回调仍走 `/api/auth/wechat/callback`,但只把获得的微信身份绑定到当前账号,不走普通 `/api/auth/wechat/start` 的登录/切号流程。
|
||||
- 前端渠道选择:`src/services/payment/paymentPlatform.ts`
|
||||
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
|
||||
|
||||
@@ -60,8 +60,8 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
2. 泥点默认档位为 `60 / 180 / 300 / 680 / 1280 / 3280`,会员默认档位为月卡、季卡、年卡;实际展示、下单校验和支付确认都以后端返回的充值商品配置为准。
|
||||
3. 首充双倍按泥点商品档位独立计算。用户买过 `points_60` 后,只影响 `points_60` 的首充展示和结算,其它未购买档位仍保留各自首充权益。
|
||||
4. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
|
||||
5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
|
||||
6. 小程序 WebView 充值使用 `wechat_mp` 渠道时,H5 只跳转 native 支付页并在返回后请求服务端查单确认;手机微信内网页使用 `wechat_h5` 跳转微信 H5 支付;桌面微信内网页使用 `wechat_native` 二维码。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。
|
||||
5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
|
||||
6. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。
|
||||
7. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。
|
||||
8. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
|
||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: false, description: '是否额外构建并归档 Pingora 影子网关二进制')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: '是否构建并归档 Pingora 影子网关二进制;release 发布默认必须包含')
|
||||
booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 API 发布')
|
||||
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名')
|
||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||
|
||||
@@ -16,7 +16,7 @@ pipeline {
|
||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
||||
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: false, description: '上游构建是否包含 Pingora 影子网关产物')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: '上游构建是否包含 Pingora 影子网关产物;release 发布默认必须包含')
|
||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
|
||||
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
|
||||
string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名')
|
||||
@@ -93,12 +93,17 @@ pipeline {
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs"
|
||||
pingora_deploy_args=()
|
||||
if [[ "${INCLUDE_PINGORA_GATEWAY:-false}" == "true" ]]; then
|
||||
pingora_deploy_args+=(--require-pingora-gateway)
|
||||
fi
|
||||
"build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" \
|
||||
--source-dir "build/${BUILD_VERSION}" \
|
||||
--version "${BUILD_VERSION}" \
|
||||
--release-root "${RELEASE_ROOT}" \
|
||||
--current-link "${CURRENT_LINK}" \
|
||||
--service "${SERVICE_NAME}" \
|
||||
"${pingora_deploy_args[@]}" \
|
||||
--health-url "${HEALTH_URL}" \
|
||||
--api-env-file "${API_ENV_FILE:-/etc/genarrative/api-server.env}" \
|
||||
--database "${DATABASE}" \
|
||||
|
||||
@@ -25,6 +25,7 @@ pipeline {
|
||||
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
|
||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,透传给 Stdb module 构建的迁移 bootstrap secret 凭据 ID;留空则由 Stdb 构建自动生成')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含')
|
||||
string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
||||
string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
||||
string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名')
|
||||
@@ -134,6 +135,7 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
|
||||
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
||||
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
|
||||
]
|
||||
env.API_BUILD_NUMBER = apiRun.number.toString()
|
||||
}
|
||||
@@ -196,6 +198,7 @@ pipeline {
|
||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT),
|
||||
string(name: 'BUILD_JOB_NAME', value: params.API_BUILD_JOB_NAME),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.API_BUILD_NUMBER),
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
|
||||
string(name: 'DATABASE', value: params.DATABASE),
|
||||
string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''),
|
||||
]
|
||||
@@ -234,6 +237,7 @@ pipeline {
|
||||
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')),
|
||||
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''),
|
||||
string(name: 'DATABASE', value: params.DATABASE ?: ''),
|
||||
string(name: 'INCLUDE_PINGORA_GATEWAY', value: String.valueOf(params.INCLUDE_PINGORA_GATEWAY)),
|
||||
string(name: 'SUMMARY', value: '全量构建发布编排结束'),
|
||||
]
|
||||
def notificationRecipients = params.NOTIFICATION_EMAILS?.trim()
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"check:encoding": "node scripts/check-encoding.mjs",
|
||||
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
|
||||
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
|
||||
"check:database-backup": "node scripts/check-database-backup-to-oss.mjs",
|
||||
"check:production-health-patrol": "node scripts/check-production-health-patrol.mjs",
|
||||
"check:production-health-patrol-env": "node scripts/check-production-health-patrol-env-check.mjs",
|
||||
"check:production-api-release": "node scripts/check-production-api-release.mjs",
|
||||
|
||||
@@ -164,6 +164,7 @@ export type ProfileRechargeCenterResponse = {
|
||||
};
|
||||
|
||||
export type WechatMiniProgramPayParams = {
|
||||
appId?: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
@@ -184,6 +185,7 @@ export type WechatH5Payment = {
|
||||
|
||||
export type WechatNativePayment = {
|
||||
codeUrl: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type CreateProfileRechargeOrderRequest = {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs');
|
||||
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-'));
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
main();
|
||||
} finally {
|
||||
rmSync(tmpRoot, {recursive: true, force: true});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:database-backup] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[check:database-backup] OK');
|
||||
|
||||
function main() {
|
||||
assertInsufficientSpaceStopsBeforeServiceChanges();
|
||||
assertArchiveFailureStillRestoresDependentServices();
|
||||
}
|
||||
|
||||
function assertInsufficientSpaceStopsBeforeServiceChanges() {
|
||||
const fixture = createFixture('insufficient-space');
|
||||
const result = runBackup(fixture, [
|
||||
'--stop-service',
|
||||
'spacetimedb.service',
|
||||
'--restart-service-after',
|
||||
'genarrative-api.service',
|
||||
'--min-free-bytes',
|
||||
'999999999999999999',
|
||||
]);
|
||||
|
||||
assertStatus(result, 1, '空间不足时必须失败。');
|
||||
assertIncludes(result.stdout, '备份空间预检', '空间不足失败前应打印空间预检。');
|
||||
assertIncludes(result.stderr, '剩余空间不足', '空间不足失败应说明剩余空间不足。');
|
||||
assertFileMissing(fixture.systemctlLog, '空间不足时不能调用 systemctl。');
|
||||
assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。');
|
||||
}
|
||||
|
||||
function assertArchiveFailureStillRestoresDependentServices() {
|
||||
const fixture = createFixture('tar-failure');
|
||||
const result = runBackup(fixture, [
|
||||
'--stop-service',
|
||||
'spacetimedb.service',
|
||||
'--restart-service-after',
|
||||
'genarrative-api.service',
|
||||
'--restart-service-after',
|
||||
'genarrative-external-generation-worker@1.service',
|
||||
'--restart-service-after',
|
||||
'genarrative-external-generation-controller.service',
|
||||
'--min-free-bytes',
|
||||
'1',
|
||||
]);
|
||||
|
||||
assertStatus(result, 1, 'tar 失败时备份脚本必须失败。');
|
||||
assertIncludes(result.stderr, 'fake tar failure', 'tar 失败原因应保留在错误输出中。');
|
||||
const systemctlLog = readFile(fixture.systemctlLog);
|
||||
const expectedCommands = [
|
||||
'systemctl stop spacetimedb.service',
|
||||
'systemctl start spacetimedb.service',
|
||||
'systemctl restart genarrative-api.service',
|
||||
'systemctl restart genarrative-external-generation-worker@1.service',
|
||||
'systemctl restart genarrative-external-generation-controller.service',
|
||||
];
|
||||
for (const command of expectedCommands) {
|
||||
assertIncludes(systemctlLog, command, `tar 失败后必须执行: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(name) {
|
||||
const root = path.join(tmpRoot, name);
|
||||
const binDir = path.join(root, 'bin');
|
||||
const dataDir = path.join(root, 'data');
|
||||
const workDir = path.join(root, 'work');
|
||||
const systemctlLog = path.join(root, 'systemctl.log');
|
||||
const tarLog = path.join(root, 'tar.log');
|
||||
mkdirSync(binDir, {recursive: true});
|
||||
mkdirSync(dataDir, {recursive: true});
|
||||
writeFileSync(path.join(dataDir, 'sample.bin'), 'sample backup payload\n', 'utf8');
|
||||
writeExecutable(
|
||||
path.join(binDir, 'systemctl'),
|
||||
`#!/usr/bin/env bash
|
||||
printf 'systemctl %s\\n' "$*" >> "${systemctlLog}"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
writeExecutable(
|
||||
path.join(binDir, 'tar'),
|
||||
`#!/usr/bin/env bash
|
||||
printf 'tar %s\\n' "$*" >> "${tarLog}"
|
||||
echo 'fake tar failure' >&2
|
||||
exit 2
|
||||
`,
|
||||
);
|
||||
return {root, binDir, dataDir, workDir, systemctlLog, tarLog};
|
||||
}
|
||||
|
||||
function runBackup(fixture, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
BACKUP_SCRIPT,
|
||||
'--data-dir',
|
||||
fixture.dataDir,
|
||||
'--work-dir',
|
||||
fixture.workDir,
|
||||
'--bucket',
|
||||
'genarrative-test',
|
||||
'--endpoint',
|
||||
'oss-cn-shanghai.aliyuncs.com',
|
||||
'--access-key-id',
|
||||
'test',
|
||||
'--access-key-secret',
|
||||
'test',
|
||||
...extraArgs,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function writeExecutable(filePath, content) {
|
||||
writeFileSync(filePath, content, 'utf8');
|
||||
spawnSync('chmod', ['0755', filePath], {encoding: 'utf8'});
|
||||
}
|
||||
|
||||
function readFile(filePath) {
|
||||
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
||||
}
|
||||
|
||||
function assertStatus(result, expected, reason) {
|
||||
const actual = result.status ?? 0;
|
||||
if (actual !== expected) {
|
||||
failures.push(
|
||||
`${reason} 预期退出码 ${expected},实际 ${actual}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(content, expected, reason) {
|
||||
if (!String(content).includes(expected)) {
|
||||
failures.push(`${reason} 缺少: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFileMissing(filePath, reason) {
|
||||
if (existsSync(filePath)) {
|
||||
failures.push(`${reason} 实际存在: ${filePath}\n${readFile(filePath)}`);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ function main() {
|
||||
assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded();
|
||||
assertDeployRejectsPingoraPublicListenWhenArtifactIncluded();
|
||||
assertDeployRejectsPingoraArtifactMissingManifestEntry();
|
||||
assertDeployRejectsPingoraManifestEntryMissingArtifact();
|
||||
assertDeployRequiresPingoraWhenRequested();
|
||||
assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch();
|
||||
assertMissingReleaseManifestFails();
|
||||
assertReleaseManifestMissingApiArtifactFails();
|
||||
assertDeployRejectsDotVersion();
|
||||
@@ -82,6 +85,18 @@ function readOptionalCommandsLog(fixture) {
|
||||
return readFileSync(fixture.commandsLog, 'utf8');
|
||||
}
|
||||
|
||||
function assertMaintenanceCleared(fixture, reason) {
|
||||
if (existsSync(fixture.maintenanceFile)) {
|
||||
failures.push(`${reason} 时应退出本次打开的维护模式。`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaintenanceKept(fixture, reason) {
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push(`${reason} 时必须保持维护模式。`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
||||
const fixture = prepareFixture('with-direct-checks');
|
||||
const result = runDeploy(fixture);
|
||||
@@ -409,9 +424,7 @@ function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() {
|
||||
) {
|
||||
failures.push('direct-entry capability 存在时不能自动 restart Pingora。');
|
||||
}
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('direct-entry capability 导致部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, 'direct-entry capability 导致部署失败');
|
||||
assertNoReleasePromoted(
|
||||
fixture,
|
||||
'direct-entry capability 导致部署失败时不能提升正式 release。',
|
||||
@@ -442,9 +455,7 @@ function assertDeployRejectsPingoraPublicListenWhenArtifactIncluded() {
|
||||
) {
|
||||
failures.push('公网监听 env 存在时不能自动 restart Pingora。');
|
||||
}
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('公网监听 env 导致部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '公网监听 env 导致部署失败');
|
||||
assertNoReleasePromoted(
|
||||
fixture,
|
||||
'公网监听 env 导致部署失败时不能提升正式 release。',
|
||||
@@ -464,8 +475,67 @@ function assertDeployRejectsPingoraArtifactMissingManifestEntry() {
|
||||
'release-manifest.json 缺少 pingora-gateway artifact',
|
||||
'manifest 未登记 Pingora 时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('manifest 未登记 Pingora 导致部署失败时必须保持维护模式。');
|
||||
assertMaintenanceCleared(fixture, 'manifest 未登记 Pingora 导致部署失败');
|
||||
}
|
||||
|
||||
function assertDeployRejectsPingoraManifestEntryMissingArtifact() {
|
||||
const fixture = prepareFixture('pingora-manifest-entry-missing-artifact');
|
||||
const manifestPath = path.join(fixture.sourceDir, 'release-manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
manifest.artifacts.push({
|
||||
component: 'pingora-gateway',
|
||||
path: 'pingora-gateway',
|
||||
checksum_path: 'pingora-gateway.sha256',
|
||||
});
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('manifest 登记 Pingora 但发布目录缺少二进制时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'release-manifest.json 登记了 pingora-gateway artifact',
|
||||
'manifest 登记 Pingora 但文件缺失时必须给出明确错误。',
|
||||
);
|
||||
assertMaintenanceCleared(fixture, 'manifest 登记 Pingora 但文件缺失导致部署失败');
|
||||
}
|
||||
|
||||
function assertDeployRequiresPingoraWhenRequested() {
|
||||
const fixture = prepareFixture('require-pingora-missing-artifact');
|
||||
const result = runDeploy(fixture, { requirePingoraGateway: true });
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('--require-pingora-gateway 但发布目录缺少 Pingora 时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'本次部署要求 Pingora',
|
||||
'--require-pingora-gateway 缺少 Pingora 文件时必须给出明确错误。',
|
||||
);
|
||||
assertMaintenanceCleared(fixture, '--require-pingora-gateway 缺少 Pingora 文件导致部署失败');
|
||||
}
|
||||
|
||||
function assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch() {
|
||||
const fixture = prepareFixture('readiness-failure');
|
||||
const result = runDeploy(fixture, { curlFails: true });
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('current 切换后的 readiness 失败必须让部署失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'readiness 检查超时',
|
||||
'readiness 失败时必须给出明确错误。',
|
||||
);
|
||||
assertMaintenanceKept(fixture, 'current 切换后的 readiness 失败');
|
||||
|
||||
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
||||
const currentTarget = readlinkSync(fixture.currentLink);
|
||||
if (currentTarget !== releaseDir) {
|
||||
failures.push(
|
||||
`readiness 失败发生在 current 切换后,current link 应指向新 release。实际 ${currentTarget},预期 ${releaseDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,9 +552,7 @@ function assertMissingReleaseManifestFails() {
|
||||
'发布产物缺少 release-manifest.json',
|
||||
'缺少 release-manifest.json 时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('缺少 release-manifest.json 导致部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 release-manifest.json 导致部署失败');
|
||||
}
|
||||
|
||||
function assertReleaseManifestMissingApiArtifactFails() {
|
||||
@@ -504,9 +572,7 @@ function assertReleaseManifestMissingApiArtifactFails() {
|
||||
'release-manifest.json 缺少 api-server artifact',
|
||||
'manifest 缺少 api-server artifact 时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('manifest 缺少 api-server artifact 导致部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, 'manifest 缺少 api-server artifact 导致部署失败');
|
||||
}
|
||||
|
||||
function assertDeployRejectsDotVersion() {
|
||||
@@ -679,9 +745,7 @@ function assertDeployCleansStagingReleaseOnFailure() {
|
||||
if (stagingEntries.length > 0) {
|
||||
failures.push(`部署失败时不应留下 staging release: ${stagingEntries.join(', ')}`);
|
||||
}
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, 'staging 构建中失败');
|
||||
}
|
||||
|
||||
function assertDeployRejectsFinalReleaseRaceAndCleansStaging() {
|
||||
@@ -714,9 +778,7 @@ function assertDeployRejectsFinalReleaseRaceAndCleansStaging() {
|
||||
if (stagingEntries.length > 0) {
|
||||
failures.push(`目标 release 竞态失败后不应留下 staging release: ${stagingEntries.join(', ')}`);
|
||||
}
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('目标 release 竞态失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '目标 release 竞态失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraDirectCheckFails() {
|
||||
@@ -732,9 +794,7 @@ function assertMissingPingoraDirectCheckFails() {
|
||||
'发布产物缺少 Pingora 直连 live smoke 脚本',
|
||||
'缺少 direct live smoke 脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 direct live smoke 脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCanaryLiveFails() {
|
||||
@@ -750,9 +810,7 @@ function assertMissingPingoraCanaryLiveFails() {
|
||||
'发布产物缺少 Pingora canary live smoke 脚本',
|
||||
'缺少 canary live smoke 脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 canary live smoke 脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCanaryAccessLogParityFails() {
|
||||
@@ -773,9 +831,7 @@ function assertMissingPingoraCanaryAccessLogParityFails() {
|
||||
'发布产物缺少 Pingora canary access log 对账脚本',
|
||||
'缺少 canary access log 对账脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 canary access log 对账脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingBackupScriptFails() {
|
||||
@@ -791,9 +847,7 @@ function assertMissingBackupScriptFails() {
|
||||
'发布产物缺少数据库备份脚本',
|
||||
'缺少数据库备份脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少数据库备份脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingHealthPatrolScriptFails() {
|
||||
@@ -811,9 +865,7 @@ function assertMissingHealthPatrolScriptFails() {
|
||||
'发布产物缺少生产健康巡检脚本',
|
||||
'缺少生产健康巡检脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少生产健康巡检脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCurrentReleaseAuditFails() {
|
||||
@@ -834,9 +886,7 @@ function assertMissingPingoraCurrentReleaseAuditFails() {
|
||||
'发布产物缺少 Pingora current release 自审脚本',
|
||||
'缺少 Pingora current release 自审脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora current release 自审脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraDirectRehearsalStatusFails() {
|
||||
@@ -857,9 +907,7 @@ function assertMissingPingoraDirectRehearsalStatusFails() {
|
||||
'发布产物缺少 Pingora 直连彩排状态脚本',
|
||||
'缺少 Pingora 直连彩排状态脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连彩排状态脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCutoverStatusSnapshotFails() {
|
||||
@@ -880,9 +928,7 @@ function assertMissingPingoraCutoverStatusSnapshotFails() {
|
||||
'发布产物缺少 Pingora 直连切换状态快照脚本',
|
||||
'缺少 Pingora 直连切换状态快照脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连切换状态快照脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCutoverEvidenceBundleFails() {
|
||||
@@ -903,9 +949,7 @@ function assertMissingPingoraCutoverEvidenceBundleFails() {
|
||||
'发布产物缺少 Pingora 直连切换证据包脚本',
|
||||
'缺少 Pingora 直连切换证据包脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连切换证据包脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCutoverCommandEvidenceFails() {
|
||||
@@ -926,9 +970,7 @@ function assertMissingPingoraCutoverCommandEvidenceFails() {
|
||||
'发布产物缺少 Pingora 直连切换命令证据脚本',
|
||||
'缺少 Pingora 直连切换命令证据脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连切换命令证据脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCutoverEvidenceVerifyFails() {
|
||||
@@ -949,9 +991,7 @@ function assertMissingPingoraCutoverEvidenceVerifyFails() {
|
||||
'发布产物缺少 Pingora 直连切换证据验真脚本',
|
||||
'缺少 Pingora 直连切换证据验真脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连切换证据验真脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraCutoverEvidenceAuditFails() {
|
||||
@@ -974,9 +1014,7 @@ function assertMissingPingoraCutoverEvidenceAuditFails() {
|
||||
'发布产物缺少 Pingora 直连切换证据根目录审计脚本',
|
||||
'缺少 Pingora 直连切换证据根目录审计脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora 直连切换证据根目录审计脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingHealthPatrolEnvCheckFails() {
|
||||
@@ -994,9 +1032,7 @@ function assertMissingHealthPatrolEnvCheckFails() {
|
||||
'发布产物缺少生产健康巡检 env 复核脚本',
|
||||
'缺少生产健康巡检 env 复核脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少生产健康巡检 env 复核脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraReleaseReadinessFails() {
|
||||
@@ -1016,9 +1052,7 @@ function assertMissingPingoraReleaseReadinessFails() {
|
||||
'发布产物缺少 Pingora release readiness 聚合门禁脚本',
|
||||
'缺少 Pingora release readiness 聚合门禁脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora release readiness 聚合门禁脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraHealthPatrolEnvSwitchFails() {
|
||||
@@ -1039,9 +1073,7 @@ function assertMissingPingoraHealthPatrolEnvSwitchFails() {
|
||||
'发布产物缺少 Pingora health patrol env 切换脚本',
|
||||
'缺少 Pingora health patrol env 切换脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora health patrol env 切换脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraGatewayEnvShadowSwitchFails() {
|
||||
@@ -1064,9 +1096,7 @@ function assertMissingPingoraGatewayEnvShadowSwitchFails() {
|
||||
'发布产物缺少 Pingora gateway env shadow 切换脚本',
|
||||
'缺少 Pingora gateway env shadow 切换脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora gateway env shadow 切换脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraRealpathCanaryEnableFails() {
|
||||
@@ -1087,9 +1117,7 @@ function assertMissingPingoraRealpathCanaryEnableFails() {
|
||||
'发布产物缺少 Pingora realpath canary 启用脚本',
|
||||
'缺少 Pingora realpath canary 启用脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora realpath canary 启用脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraRealpathCanaryDisableFails() {
|
||||
@@ -1110,9 +1138,7 @@ function assertMissingPingoraRealpathCanaryDisableFails() {
|
||||
'发布产物缺少 Pingora realpath canary 关闭脚本',
|
||||
'缺少 Pingora realpath canary 关闭脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora realpath canary 关闭脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingPingoraTlsCertSyncFails() {
|
||||
@@ -1133,9 +1159,7 @@ function assertMissingPingoraTlsCertSyncFails() {
|
||||
'发布产物缺少 Pingora TLS 证书同步脚本',
|
||||
'缺少 Pingora TLS 证书同步脚本时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少 Pingora TLS 证书同步脚本导致部署失败');
|
||||
}
|
||||
|
||||
function assertMissingEnvExamplesFails() {
|
||||
@@ -1154,9 +1178,7 @@ function assertMissingEnvExamplesFails() {
|
||||
'发布产物缺少环境变量示例目录',
|
||||
'缺少环境变量示例目录时必须给出明确错误。',
|
||||
);
|
||||
if (!existsSync(fixture.maintenanceFile)) {
|
||||
failures.push('部署失败时必须保持维护模式。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '缺少环境变量示例目录导致部署失败');
|
||||
}
|
||||
|
||||
function prepareFixture(name) {
|
||||
@@ -1432,6 +1454,19 @@ function prepareFixture(name) {
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
`printf 'curl %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
|
||||
'if [[ "${FAKE_CURL_FAIL:-false}" == "true" ]]; then',
|
||||
' exit 22',
|
||||
'fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(fakeBin, 'sleep'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
`printf 'sleep %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
@@ -1478,6 +1513,7 @@ function prepareFixture(name) {
|
||||
);
|
||||
chmodExecutable(path.join(fakeBin, 'systemctl'));
|
||||
chmodExecutable(path.join(fakeBin, 'curl'));
|
||||
chmodExecutable(path.join(fakeBin, 'sleep'));
|
||||
chmodExecutable(path.join(fakeBin, 'cp'));
|
||||
chmodExecutable(path.join(fakeBin, 'sudo'));
|
||||
|
||||
@@ -1543,29 +1579,33 @@ function runDeploy(fixture, options = {}) {
|
||||
fixture.sourceDir,
|
||||
'scripts/deploy/production-api-deploy.sh',
|
||||
);
|
||||
const args = [
|
||||
deployScript,
|
||||
'--source-dir',
|
||||
fixture.sourceDir,
|
||||
'--version',
|
||||
options.version ?? fixture.version,
|
||||
'--release-root',
|
||||
options.releaseRoot ?? fixture.releaseRoot,
|
||||
'--current-link',
|
||||
options.currentLink ?? fixture.currentLink,
|
||||
'--service',
|
||||
'genarrative-api.service',
|
||||
'--health-url',
|
||||
'http://127.0.0.1:18082/readyz',
|
||||
'--api-env-file',
|
||||
options.apiEnvFile ?? fixture.apiEnvFile,
|
||||
'--database',
|
||||
'genarrative-prod',
|
||||
'--spacetime-server-url',
|
||||
'http://127.0.0.1:3101',
|
||||
];
|
||||
if (options.requirePingoraGateway) {
|
||||
args.push('--require-pingora-gateway');
|
||||
}
|
||||
return spawnSync(
|
||||
'bash',
|
||||
[
|
||||
deployScript,
|
||||
'--source-dir',
|
||||
fixture.sourceDir,
|
||||
'--version',
|
||||
options.version ?? fixture.version,
|
||||
'--release-root',
|
||||
options.releaseRoot ?? fixture.releaseRoot,
|
||||
'--current-link',
|
||||
options.currentLink ?? fixture.currentLink,
|
||||
'--service',
|
||||
'genarrative-api.service',
|
||||
'--health-url',
|
||||
'http://127.0.0.1:18082/readyz',
|
||||
'--api-env-file',
|
||||
options.apiEnvFile ?? fixture.apiEnvFile,
|
||||
'--database',
|
||||
'genarrative-prod',
|
||||
'--spacetime-server-url',
|
||||
'http://127.0.0.1:3101',
|
||||
],
|
||||
args,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
@@ -1578,6 +1618,7 @@ function runDeploy(fixture, options = {}) {
|
||||
options.pingoraDirectEntry === true ? 'true' : 'false',
|
||||
FAKE_PINGORA_ENV_FILE: fixture.pingoraEnvFile,
|
||||
FAKE_PINGORA_STATE_FILE: fixture.pingoraStateFile,
|
||||
FAKE_CURL_FAIL: options.curlFails === true ? 'true' : 'false',
|
||||
FAKE_CREATE_RELEASE_DURING_COPY:
|
||||
options.createReleaseDuringCopy === true ? 'true' : 'false',
|
||||
FAKE_RELEASE_ROOT: fixture.releaseRoot,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user