Merge remote-tracking branch 'origin/master' into feat/video-BGfilter

This commit is contained in:
2026-07-08 13:40:52 +00:00
430 changed files with 10078 additions and 3356 deletions
+19
View File
@@ -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',
+20
View File
@@ -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[];
+7
View File
@@ -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}
+2
View File
@@ -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',
+2
View File
@@ -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
+10
View File
@@ -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;
+2
View File
@@ -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. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。
+1 -1
View File
@@ -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')
+6 -1
View File
@@ -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()
+1
View File
@@ -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",
+2
View File
@@ -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 = {
+167
View File
@@ -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)}`);
}
}
+142 -101
View File
@@ -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