前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、 迁移脚本、测试与自托管 docker-compose 示例。 开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读 publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为 VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注 Apache-2.0。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12594 lines
509 KiB
TypeScript
12594 lines
509 KiB
TypeScript
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||
import { createHash, randomUUID } from 'node:crypto';
|
||
import { readFileSync, readdirSync } from 'node:fs';
|
||
import { expect, test, type Browser, type BrowserContext, type Page, type Request } from '@playwright/test';
|
||
import sharp from 'sharp';
|
||
|
||
const primaryAccount = {
|
||
email: 'integration-admin@lindyson.test',
|
||
password: 'IntegrationAdmin2026!',
|
||
};
|
||
const secondaryAccount = {
|
||
email: 'integration-owner-b@lindyson.test',
|
||
password: 'IntegrationOwnerB2026!',
|
||
};
|
||
const composeProject = process.env.FULLSTACK_COMPOSE_PROJECT ?? 'lindyson-integration';
|
||
// 由 ffmpeg 生成并经 ffprobe 验证的 1 秒 H.264/MP4(16x16,1538 bytes),不是伪造文件头。
|
||
const mp4Base64 = 'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMVbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAj90cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAABAAAAAQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAG3bWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABYm1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAASJzdGJsAAAAvnN0c2QAAAAAAAAAAQAAAK5hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAABAAEABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANGF2Y0MBZAAK/+EAF2dkAAqs2V7ARAAAAwAEAAADAAg8SJZYAQAGaOvjyyLA/fj4AAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAABXoAAAAAAAAABhzdHRzAAAAAAAAAAEAAAABAABAAAAAABxzdHNjAAAAAAAAAAEAAAABAAAAAQAAAAEAAAAUc3RzegAAAAAAAAK9AAAAAQAAABRzdGNvAAAAAAAAAAEAAANFAAAAYnVkdGEAAABabWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAtaWxzdAAAACWpdG9vAAAAHWRhdGEAAAABAAAAAExhdmY2Mi4xMi4xMDIAAAAIZnJlZQAAAsVtZGF0AAACpQYF//+h3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE2NCByMzEwOCAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjMgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz0xIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2FkYXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj0xIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAEGWIhAAV//73ye/Apuvb34E=';
|
||
// sharp 生成的 512x512 透明无损 WebP(52 bytes),用于满足向导只读取 ready WebP 缩略图的边界。
|
||
const promptWizardThumbnailWebpBase64 = 'UklGRiwAAABXRUJQVlA4TB8AAAAv/8F/EAcQEREQCCT7e89QRP8z/vOf//znP//5z/8BAA==';
|
||
|
||
test.describe.configure({ mode: 'serial' });
|
||
|
||
test('Migration 081 在真实 PostgreSQL 16 上忽略零行语句并保持批量写入一次递增', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/081_ignore_empty_admin_pagination_writes.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const announcementIds = [randomUUID(), randomUUID()];
|
||
const billingRunIds = [randomUUID(), randomUUID()];
|
||
|
||
postgresRollbackScript(`
|
||
${migration}
|
||
${migration}
|
||
|
||
INSERT INTO admin_pagination_versions (resource, version, updated_at)
|
||
VALUES ('admin-announcements-v1-published-desc', 100, now())
|
||
ON CONFLICT (resource) DO UPDATE SET version = 100, updated_at = now();
|
||
UPDATE admin_cost_pagination_versions
|
||
SET version = 100, updated_at = now()
|
||
WHERE resource IN ('admin:cost-video', 'admin:provider-billing');
|
||
|
||
DO $migration081_trackers$
|
||
DECLARE
|
||
source_count integer;
|
||
tracker_count integer;
|
||
BEGIN
|
||
SELECT count(*) INTO source_count
|
||
FROM pg_trigger
|
||
WHERE NOT tgisinternal
|
||
AND tgfoid IN (
|
||
'admin_pagination_bump_trigger()'::regprocedure,
|
||
'bump_admin_cost_pagination_versions()'::regprocedure
|
||
);
|
||
SELECT count(*) INTO tracker_count
|
||
FROM pg_trigger
|
||
WHERE NOT tgisinternal
|
||
AND tgfoid = 'admin_pagination_capture_statement_counters()'::regprocedure;
|
||
IF source_count <> 54 OR tracker_count <> 54 THEN
|
||
RAISE EXCEPTION 'Migration 081 BEFORE/AFTER 配对不完整:source=%, tracker=%',
|
||
source_count, tracker_count;
|
||
END IF;
|
||
IF (
|
||
SELECT count(*) FROM pg_trigger
|
||
WHERE NOT tgisinternal
|
||
AND tgfoid = 'admin_pagination_bump_users_update()'::regprocedure
|
||
) <> 1 THEN
|
||
RAISE EXCEPTION 'Migration 081 用户 transition-table 更新触发器数量异常';
|
||
END IF;
|
||
END
|
||
$migration081_trackers$;
|
||
|
||
INSERT INTO announcements (id, title, content)
|
||
SELECT '${announcementIds[0]}'::uuid, 'zero', 'zero' WHERE false;
|
||
UPDATE announcements SET title = 'zero' WHERE id = '${announcementIds[0]}';
|
||
DELETE FROM announcements WHERE id = '${announcementIds[0]}';
|
||
|
||
DO $migration081_core_zero_before$
|
||
DECLARE current_version bigint;
|
||
BEGIN
|
||
SELECT version INTO current_version FROM admin_pagination_versions
|
||
WHERE resource = 'admin-announcements-v1-published-desc';
|
||
IF current_version <> 100 THEN
|
||
RAISE EXCEPTION 'Migration 081 核心零行 I/U/D 误递增:%', current_version;
|
||
END IF;
|
||
END
|
||
$migration081_core_zero_before$;
|
||
|
||
INSERT INTO announcements (id, title, content) VALUES
|
||
('${announcementIds[0]}', 'Migration 081 A', 'A'),
|
||
('${announcementIds[1]}', 'Migration 081 B', 'B');
|
||
UPDATE announcements
|
||
SET updated_at = updated_at + interval '1 microsecond'
|
||
WHERE id IN ('${announcementIds[0]}', '${announcementIds[1]}');
|
||
UPDATE announcements SET title = title WHERE false;
|
||
INSERT INTO announcements (id, title, content)
|
||
VALUES ('${announcementIds[0]}', 'conflict ignored', 'conflict ignored')
|
||
ON CONFLICT (id) DO NOTHING;
|
||
INSERT INTO announcements (id, title, content)
|
||
VALUES ('${announcementIds[0]}', 'conflict filtered', 'conflict filtered')
|
||
ON CONFLICT (id) DO UPDATE
|
||
SET title = EXCLUDED.title
|
||
WHERE false;
|
||
|
||
DO $migration081_core_insert$
|
||
DECLARE current_version bigint;
|
||
BEGIN
|
||
SELECT version INTO current_version FROM admin_pagination_versions
|
||
WHERE resource = 'admin-announcements-v1-published-desc';
|
||
IF current_version <> 101 THEN
|
||
RAISE EXCEPTION 'Migration 081 核心批量 INSERT 或零行语句错误:%',
|
||
current_version;
|
||
END IF;
|
||
END
|
||
$migration081_core_insert$;
|
||
|
||
INSERT INTO announcements (id, title, content)
|
||
VALUES ('${announcementIds[0]}', 'upsert updated', 'upsert updated')
|
||
ON CONFLICT (id) DO UPDATE
|
||
SET title = EXCLUDED.title;
|
||
|
||
DO $migration081_core_upsert$
|
||
DECLARE current_version bigint;
|
||
BEGIN
|
||
SELECT version INTO current_version FROM admin_pagination_versions
|
||
WHERE resource = 'admin-announcements-v1-published-desc';
|
||
IF current_version <> 102 THEN
|
||
RAISE EXCEPTION 'Migration 081 实际 UPSERT 的 INSERT/UPDATE 事件未合并为一次递增:%',
|
||
current_version;
|
||
END IF;
|
||
END
|
||
$migration081_core_upsert$;
|
||
|
||
UPDATE announcements
|
||
SET title = title || ' updated'
|
||
WHERE id IN ('${announcementIds[0]}', '${announcementIds[1]}');
|
||
UPDATE announcements SET title = title WHERE false;
|
||
DELETE FROM announcements WHERE id = '00000000-0000-0000-0000-000000000000';
|
||
|
||
DO $migration081_core_update$
|
||
DECLARE current_version bigint;
|
||
BEGIN
|
||
SELECT version INTO current_version FROM admin_pagination_versions
|
||
WHERE resource = 'admin-announcements-v1-published-desc';
|
||
IF current_version <> 103 THEN
|
||
RAISE EXCEPTION 'Migration 081 核心批量 UPDATE 未精确递增一次:%', current_version;
|
||
END IF;
|
||
END
|
||
$migration081_core_update$;
|
||
|
||
DELETE FROM announcements
|
||
WHERE id IN ('${announcementIds[0]}', '${announcementIds[1]}');
|
||
DELETE FROM announcements WHERE id = '${announcementIds[0]}';
|
||
|
||
DO $migration081_core_delete$
|
||
DECLARE current_version bigint;
|
||
BEGIN
|
||
SELECT version INTO current_version FROM admin_pagination_versions
|
||
WHERE resource = 'admin-announcements-v1-published-desc';
|
||
IF current_version <> 104 THEN
|
||
RAISE EXCEPTION 'Migration 081 核心批量 DELETE 未精确递增一次:%', current_version;
|
||
END IF;
|
||
END
|
||
$migration081_core_delete$;
|
||
|
||
INSERT INTO provider_billing_sync_runs (id, provider, kind, status)
|
||
SELECT '${billingRunIds[0]}'::uuid, 'migration-081', 'balance', 'running'
|
||
WHERE false;
|
||
UPDATE provider_billing_sync_runs SET status = status
|
||
WHERE id = '${billingRunIds[0]}';
|
||
DELETE FROM provider_billing_sync_runs WHERE id = '${billingRunIds[0]}';
|
||
|
||
DO $migration081_cost_zero_before$
|
||
DECLARE version_snapshot text;
|
||
BEGIN
|
||
SELECT string_agg(resource || ':' || version, ',' ORDER BY resource)
|
||
INTO version_snapshot
|
||
FROM admin_cost_pagination_versions
|
||
WHERE resource IN ('admin:cost-video', 'admin:provider-billing');
|
||
IF version_snapshot <> 'admin:cost-video:100,admin:provider-billing:100' THEN
|
||
RAISE EXCEPTION 'Migration 081 成本零行 I/U/D 误递增:%', version_snapshot;
|
||
END IF;
|
||
END
|
||
$migration081_cost_zero_before$;
|
||
|
||
INSERT INTO provider_billing_sync_runs (id, provider, kind, status) VALUES
|
||
('${billingRunIds[0]}', 'migration-081', 'balance', 'running'),
|
||
('${billingRunIds[1]}', 'migration-081', 'balance', 'running');
|
||
UPDATE provider_billing_sync_runs
|
||
SET page_count = page_count + 1
|
||
WHERE id IN ('${billingRunIds[0]}', '${billingRunIds[1]}');
|
||
UPDATE provider_billing_sync_runs SET status = status WHERE false;
|
||
|
||
DO $migration081_cost_insert$
|
||
DECLARE version_snapshot text;
|
||
BEGIN
|
||
SELECT string_agg(resource || ':' || version, ',' ORDER BY resource)
|
||
INTO version_snapshot
|
||
FROM admin_cost_pagination_versions
|
||
WHERE resource IN ('admin:cost-video', 'admin:provider-billing');
|
||
IF version_snapshot <> 'admin:cost-video:101,admin:provider-billing:101' THEN
|
||
RAISE EXCEPTION 'Migration 081 成本批量 INSERT 或无关 UPDATE 后零行 UPDATE 错误:%',
|
||
version_snapshot;
|
||
END IF;
|
||
END
|
||
$migration081_cost_insert$;
|
||
|
||
UPDATE provider_billing_sync_runs
|
||
SET status = 'success', completed_at = now()
|
||
WHERE id IN ('${billingRunIds[0]}', '${billingRunIds[1]}');
|
||
UPDATE provider_billing_sync_runs SET status = status WHERE false;
|
||
DELETE FROM provider_billing_sync_runs
|
||
WHERE id = '00000000-0000-0000-0000-000000000000';
|
||
|
||
DO $migration081_cost_update$
|
||
DECLARE version_snapshot text;
|
||
BEGIN
|
||
SELECT string_agg(resource || ':' || version, ',' ORDER BY resource)
|
||
INTO version_snapshot
|
||
FROM admin_cost_pagination_versions
|
||
WHERE resource IN ('admin:cost-video', 'admin:provider-billing');
|
||
IF version_snapshot <> 'admin:cost-video:102,admin:provider-billing:102' THEN
|
||
RAISE EXCEPTION 'Migration 081 成本批量 UPDATE 未精确递增一次:%', version_snapshot;
|
||
END IF;
|
||
END
|
||
$migration081_cost_update$;
|
||
|
||
DELETE FROM provider_billing_sync_runs
|
||
WHERE id IN ('${billingRunIds[0]}', '${billingRunIds[1]}');
|
||
DELETE FROM provider_billing_sync_runs WHERE id = '${billingRunIds[0]}';
|
||
|
||
DO $migration081_cost_delete$
|
||
DECLARE version_snapshot text;
|
||
BEGIN
|
||
SELECT string_agg(resource || ':' || version, ',' ORDER BY resource)
|
||
INTO version_snapshot
|
||
FROM admin_cost_pagination_versions
|
||
WHERE resource IN ('admin:cost-video', 'admin:provider-billing');
|
||
IF version_snapshot <> 'admin:cost-video:103,admin:provider-billing:103' THEN
|
||
RAISE EXCEPTION 'Migration 081 成本批量 DELETE 未精确递增一次:%', version_snapshot;
|
||
END IF;
|
||
END
|
||
$migration081_cost_delete$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 062 可在真实 PostgreSQL 16 上重复执行并锁定支付返回来源', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/062_payment_attempt_return_origin.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', is_nullable, column_default)
|
||
FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'payment_attempts'
|
||
AND column_name = 'return_origin';
|
||
`)).toBe(`NO|'https://www.lindyson.com'::text`);
|
||
|
||
const orderId = randomUUID();
|
||
const attemptId = randomUUID();
|
||
const suffix = randomUUID();
|
||
postgresRollbackScript(`
|
||
INSERT INTO orders (
|
||
id, user_id, kind, plan_name, amount, billing, status
|
||
)
|
||
SELECT '${orderId}', id, 'main', 'Migration 062', 1, 'one-time', 'pending'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
|
||
INSERT INTO payment_attempts (
|
||
id, order_id, attempt, idempotency_key, request_hash,
|
||
merchant_order_no, amount, surface, way_code, return_origin, status, terminal_at
|
||
) VALUES (
|
||
'${attemptId}', '${orderId}', 1, 'migration-062-${suffix}',
|
||
'migration-062-${suffix}', 'M062${suffix.replaceAll('-', '')}',
|
||
1, 'mobile', 'ALI_WAP', 'https://www.lindyson.cn', 'manual-review', now()
|
||
);
|
||
|
||
DO $migration062$
|
||
DECLARE stored_origin text;
|
||
BEGIN
|
||
SELECT return_origin INTO stored_origin
|
||
FROM payment_attempts WHERE id = '${attemptId}';
|
||
IF stored_origin IS DISTINCT FROM 'https://www.lindyson.cn' THEN
|
||
RAISE EXCEPTION 'Migration 062 未保留 return_origin:%', stored_origin;
|
||
END IF;
|
||
|
||
BEGIN
|
||
UPDATE payment_attempts
|
||
SET return_origin = 'https://www.lindyson.com'
|
||
WHERE id = '${attemptId}';
|
||
RAISE EXCEPTION 'Migration 062 允许篡改 return_origin';
|
||
EXCEPTION
|
||
WHEN raise_exception THEN
|
||
IF SQLERRM = 'Migration 062 允许篡改 return_origin' THEN
|
||
RAISE;
|
||
END IF;
|
||
IF SQLERRM NOT LIKE 'payment_attempts 业务身份不可修改:attempt=%' THEN
|
||
RAISE;
|
||
END IF;
|
||
END;
|
||
END
|
||
$migration062$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 073 重跑时保留人工调整的 Seedance 2.5 双档积分与开放状态', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/073_seedance_25_generation.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresRollbackScript(`
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video'
|
||
AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 25
|
||
AND resolution IN ('480p', '720p');
|
||
|
||
${migration}
|
||
|
||
UPDATE consumption_settings
|
||
SET credits = 777,
|
||
reference_video_credits = 333,
|
||
available = true
|
||
WHERE kind = 'video'
|
||
AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 5
|
||
AND resolution = '480p';
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
DO $migration073$
|
||
DECLARE
|
||
seedance_25_count integer;
|
||
adjusted_credits integer;
|
||
adjusted_reference_video_credits integer;
|
||
adjusted_available boolean;
|
||
BEGIN
|
||
SELECT COUNT(*)
|
||
INTO seedance_25_count
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
|
||
IF seedance_25_count <> 10 THEN
|
||
RAISE EXCEPTION 'Migration 073 重跑后 Seedance 2.5 组合数不是 10:%', seedance_25_count;
|
||
END IF;
|
||
|
||
SELECT credits, reference_video_credits, available
|
||
INTO adjusted_credits, adjusted_reference_video_credits, adjusted_available
|
||
FROM consumption_settings
|
||
WHERE kind = 'video'
|
||
AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 5
|
||
AND resolution = '480p';
|
||
|
||
IF adjusted_credits <> 777
|
||
OR adjusted_reference_video_credits <> 333
|
||
OR adjusted_available IS DISTINCT FROM true THEN
|
||
RAISE EXCEPTION
|
||
'Migration 073 重跑覆盖了人工配置:credits=%, reference_video_credits=%, available=%',
|
||
adjusted_credits, adjusted_reference_video_credits, adjusted_available;
|
||
END IF;
|
||
END
|
||
$migration073$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 075 在真实 PostgreSQL 16 上动态推导 25 秒双档价格、传递全量开关并幂等审计', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/075_seedance_25_duration_25.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresRollbackScript(`
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
DELETE FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
|
||
UPDATE consumption_settings
|
||
SET available = true
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
UPDATE consumption_settings
|
||
SET credits = CASE resolution WHEN '480p' THEN 107 WHEN '720p' THEN 229 END
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 15 AND resolution IN ('480p', '720p');
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
DO $migration075_open$
|
||
DECLARE
|
||
total_count integer;
|
||
available_count integer;
|
||
audit_count integer;
|
||
price_480 integer;
|
||
reference_price_480 integer;
|
||
price_720 integer;
|
||
reference_price_720 integer;
|
||
audit_detail jsonb;
|
||
BEGIN
|
||
SELECT COUNT(*), COUNT(*) FILTER (WHERE available)
|
||
INTO total_count, available_count
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
|
||
SELECT
|
||
MAX(credits) FILTER (WHERE resolution = '480p'),
|
||
MAX(reference_video_credits) FILTER (WHERE resolution = '480p'),
|
||
MAX(credits) FILTER (WHERE resolution = '720p'),
|
||
MAX(reference_video_credits) FILTER (WHERE resolution = '720p')
|
||
INTO price_480, reference_price_480, price_720, reference_price_720
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
|
||
SELECT COUNT(*), MAX(detail)::jsonb
|
||
INTO audit_count, audit_detail
|
||
FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
|
||
IF total_count <> 12 OR available_count <> 12
|
||
OR price_480 <> 179 OR reference_price_480 <> 108
|
||
OR price_720 <> 382 OR reference_price_720 <> 230
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'Migration 075 全开推导失败:total=%, available=%, 480=%/%, 720=%/%',
|
||
total_count, available_count, price_480, reference_price_480,
|
||
price_720, reference_price_720;
|
||
END IF;
|
||
|
||
IF audit_count <> 1
|
||
OR audit_detail->>'migration' <> '075_seedance_25_duration_25.sql'
|
||
OR audit_detail->>'insertedCount' <> '2'
|
||
OR audit_detail->>'previousAvailableCount' <> '10'
|
||
OR audit_detail->>'newAvailableCount' <> '12'
|
||
OR audit_detail->>'preservedExistingPricesAndAvailability' <> 'true'
|
||
OR audit_detail->'prices'->'480p'->>'credits' <> '179'
|
||
OR audit_detail->'prices'->'480p'->>'referenceVideoCredits' <> '108'
|
||
OR audit_detail->'prices'->'720p'->>'credits' <> '382'
|
||
OR audit_detail->'prices'->'720p'->>'referenceVideoCredits' <> '230'
|
||
THEN
|
||
RAISE EXCEPTION 'Migration 075 审计快照不完整:count=%, detail=%', audit_count, audit_detail;
|
||
END IF;
|
||
END
|
||
$migration075_open$;
|
||
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
DELETE FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
UPDATE consumption_settings
|
||
SET available = false
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
UPDATE consumption_settings
|
||
SET credits = CASE resolution WHEN '480p' THEN 86 WHEN '720p' THEN 122 END
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 15 AND resolution IN ('480p', '720p');
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
DO $migration075_closed$
|
||
DECLARE
|
||
total_count integer;
|
||
available_count integer;
|
||
audit_count integer;
|
||
price_snapshot text;
|
||
BEGIN
|
||
SELECT COUNT(*), COUNT(*) FILTER (WHERE available)
|
||
INTO total_count, available_count
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
SELECT string_agg(
|
||
resolution || ':' || credits || '/' || reference_video_credits,
|
||
',' ORDER BY resolution
|
||
)
|
||
INTO price_snapshot
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
SELECT COUNT(*) INTO audit_count
|
||
FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
|
||
IF total_count <> 12 OR available_count <> 0
|
||
OR price_snapshot <> '480p:144/87,720p:204/123'
|
||
OR audit_count <> 1
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'Migration 075 全关推导或幂等审计失败:total=%, available=%, prices=%, audits=%',
|
||
total_count, available_count, price_snapshot, audit_count;
|
||
END IF;
|
||
END
|
||
$migration075_closed$;
|
||
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
DELETE FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
INSERT INTO consumption_settings (
|
||
kind, model, model_label, duration_seconds, resolution,
|
||
credits, reference_video_credits, available
|
||
) VALUES (
|
||
'video', 'Seedance 2.5', 'Seedance 2.5', 25, '480p', 999, 555, false
|
||
);
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
DO $migration075_partial_existing$
|
||
DECLARE
|
||
price_snapshot text;
|
||
audit_count integer;
|
||
inserted_count text;
|
||
BEGIN
|
||
SELECT string_agg(
|
||
resolution || ':' || credits || '/' || reference_video_credits,
|
||
',' ORDER BY resolution
|
||
)
|
||
INTO price_snapshot
|
||
FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
SELECT COUNT(*), (MAX(detail)::jsonb)->>'insertedCount'
|
||
INTO audit_count, inserted_count
|
||
FROM audit_logs
|
||
WHERE action = '新增 Seedance 2.5 25秒档' AND target = 'Seedance 2.5:25s';
|
||
|
||
IF price_snapshot <> '480p:999/555,720p:204/123'
|
||
OR audit_count <> 1 OR inserted_count <> '1'
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'Migration 075 未保留已有 25 秒人工价或单行补齐审计错误:prices=%, audits=%, inserted=%',
|
||
price_snapshot, audit_count, inserted_count;
|
||
END IF;
|
||
END
|
||
$migration075_partial_existing$;
|
||
`);
|
||
|
||
const partialFailure = postgresRollbackScriptFailure(`
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
UPDATE consumption_settings
|
||
SET available = false
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
UPDATE consumption_settings
|
||
SET available = true
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 5 AND resolution = '480p';
|
||
${migration}
|
||
`);
|
||
expect(partialFailure).toContain('旧 10 档必须全开或全关');
|
||
|
||
const malformedFailure = postgresRollbackScriptFailure(`
|
||
DELETE FROM consumption_settings
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5' AND duration_seconds = 25;
|
||
UPDATE consumption_settings
|
||
SET available = false
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5';
|
||
UPDATE consumption_settings
|
||
SET credits = 0
|
||
WHERE kind = 'video' AND model = 'Seedance 2.5'
|
||
AND duration_seconds = 5 AND resolution = '480p';
|
||
${migration}
|
||
`);
|
||
expect(malformedFailure).toContain('积分必须全部为正整数');
|
||
});
|
||
|
||
test('Migration 074 在同一 task 原地重试后保持各 attempt 生成流水快照不可变', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/074_credit_ledger_generation_snapshots.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
expect(postgresScalar(
|
||
"SELECT count(*) FROM schema_migrations WHERE id = '074_credit_ledger_generation_snapshots.sql';",
|
||
)).toBe('1');
|
||
|
||
const taskId = randomUUID();
|
||
const hold0Id = randomUUID();
|
||
const refund0Id = randomUUID();
|
||
const spend1Id = randomUUID();
|
||
const orderId = randomUUID();
|
||
const orderLedgerId = randomUUID();
|
||
const suffix = randomUUID();
|
||
postgresRollbackScript(`
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, processing_stage, prompt, config,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, main_credits_used, addon_credits_used
|
||
) VALUES (
|
||
'${taskId}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.5', 'queued', 'queued', 'Migration 074 attempt 0',
|
||
'{"durationSeconds":15,"resolution":"720p","referenceAssets":[]}'::jsonb,
|
||
122, 122, 0, 0, 'held', 0, 122, 0
|
||
);
|
||
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_job_id, business_event_key
|
||
) VALUES (
|
||
'${hold0Id}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'hold', 'main', 'held', -122, 500,
|
||
'Seedance 2.5 生成预扣', 'system', '首次执行预扣', '${taskId}',
|
||
'generation-hold:${taskId}:0'
|
||
);
|
||
|
||
UPDATE credit_ledger
|
||
SET settlement_status = 'refunded', note = '供应商终态失败,释放预扣'
|
||
WHERE id = '${hold0Id}';
|
||
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_job_id, business_event_key
|
||
) VALUES (
|
||
'${refund0Id}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'refund', 'main', 'refunded', 122, 622,
|
||
'生成任务退回', 'system', '首次执行失败退款', '${taskId}',
|
||
'generation-failure-refund:${taskId}:0'
|
||
);
|
||
|
||
UPDATE generation_tasks
|
||
SET attempt = 1,
|
||
model = 'Seedance 2.0',
|
||
status = 'queued',
|
||
processing_stage = 'queued',
|
||
prompt = 'Migration 074 attempt 1',
|
||
config = '{"durationSeconds":5,"resolution":"480p","referenceAssets":[{"type":"video","role":"reference_video"}]}'::jsonb,
|
||
credits = 49,
|
||
reserved_credits = 49,
|
||
settled_credits = 0,
|
||
refunded_credits = 0,
|
||
credit_status = 'held',
|
||
main_credits_used = 49,
|
||
addon_credits_used = 0
|
||
WHERE id = '${taskId}';
|
||
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_job_id, business_event_key
|
||
) VALUES (
|
||
'${spend1Id}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'hold', 'main', 'held', -49, 573,
|
||
'Seedance 2.0 生成预扣', 'system', '第二次执行预扣', '${taskId}',
|
||
'generation-hold:${taskId}:1'
|
||
);
|
||
UPDATE credit_ledger
|
||
SET type = 'spend', settlement_status = 'settled', note = '第二次执行成功'
|
||
WHERE id = '${spend1Id}';
|
||
|
||
INSERT INTO orders (
|
||
id, user_id, kind, plan_name, amount, billing, status
|
||
) SELECT '${orderId}', id, 'addon', 'Migration 074 订单退款', 1, 'one-time', 'refunded'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_order_id, business_event_key
|
||
) VALUES (
|
||
'${orderLedgerId}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'refund', 'addon', 'refunded', 1, 574,
|
||
'订单退款', 'system', '不得混入生成流水快照', '${orderId}',
|
||
'order-refund:migration-074:${suffix}'
|
||
);
|
||
|
||
DO $migration074$
|
||
DECLARE
|
||
old_refund jsonb;
|
||
new_spend jsonb;
|
||
order_snapshot jsonb;
|
||
BEGIN
|
||
SELECT generation_snapshot INTO old_refund
|
||
FROM credit_ledger WHERE id = '${refund0Id}';
|
||
IF old_refund IS NULL
|
||
OR old_refund->>'attempt' <> '0'
|
||
OR old_refund->>'model' <> 'Seedance 2.5'
|
||
OR old_refund->>'durationSeconds' <> '15'
|
||
OR old_refund->>'resolution' <> '720p'
|
||
OR old_refund->>'hasReferenceVideo' <> 'false'
|
||
OR old_refund->>'reservedCredits' <> '122'
|
||
OR old_refund->>'settledCredits' <> '0'
|
||
OR old_refund->>'refundedCredits' <> '122'
|
||
OR old_refund->>'taskStatus' <> 'failed'
|
||
OR old_refund->>'creditStatus' <> 'refunded' THEN
|
||
RAISE EXCEPTION 'attempt 0 退款快照被 attempt 1 当前任务污染:%', old_refund;
|
||
END IF;
|
||
|
||
SELECT generation_snapshot INTO new_spend
|
||
FROM credit_ledger WHERE id = '${spend1Id}';
|
||
IF new_spend IS NULL
|
||
OR new_spend->>'attempt' <> '1'
|
||
OR new_spend->>'model' <> 'Seedance 2.0'
|
||
OR new_spend->>'durationSeconds' <> '5'
|
||
OR new_spend->>'resolution' <> '480p'
|
||
OR new_spend->>'hasReferenceVideo' <> 'true'
|
||
OR new_spend->>'reservedCredits' <> '49'
|
||
OR new_spend->>'settledCredits' <> '49'
|
||
OR new_spend->>'refundedCredits' <> '0'
|
||
OR new_spend->>'taskStatus' <> 'completed'
|
||
OR new_spend->>'creditStatus' <> 'settled' THEN
|
||
RAISE EXCEPTION 'attempt 1 实扣快照错误:%', new_spend;
|
||
END IF;
|
||
|
||
SELECT generation_snapshot INTO order_snapshot
|
||
FROM credit_ledger WHERE id = '${orderLedgerId}';
|
||
IF order_snapshot IS NOT NULL THEN
|
||
RAISE EXCEPTION '订单退款被错误写入生成流水快照:%', order_snapshot;
|
||
END IF;
|
||
|
||
BEGIN
|
||
UPDATE credit_ledger
|
||
SET generation_snapshot = jsonb_set(generation_snapshot, '{attempt}', '9'::jsonb)
|
||
WHERE id = '${refund0Id}';
|
||
RAISE EXCEPTION '允许直接篡改生成流水快照';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许直接篡改生成流水快照' THEN RAISE; END IF;
|
||
IF SQLERRM NOT LIKE 'credit_ledger.generation_snapshot 不允许直接修改:%' THEN RAISE; END IF;
|
||
END;
|
||
|
||
BEGIN
|
||
UPDATE credit_ledger
|
||
SET type = 'spend', settlement_status = 'settled'
|
||
WHERE id = '${refund0Id}';
|
||
RAISE EXCEPTION '允许把退款流水改写为实扣';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许把退款流水改写为实扣' THEN RAISE; END IF;
|
||
IF SQLERRM NOT LIKE '生成积分流水只允许从预扣收口为实扣或退回:%' THEN RAISE; END IF;
|
||
END;
|
||
END
|
||
$migration074$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 063 可重跑并锁定 Seedream checkpoint、临时 URL 与逐任务价格证据', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/063_seedream_image_generation.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM information_schema.columns
|
||
WHERE table_schema = 'public' AND table_name = 'generation_provider_runs'
|
||
AND column_name IN (
|
||
'provider_request_id', 'provider_response_checkpoint', 'provider_response_url',
|
||
'provider_response_observed_at', 'provider_response_url_expires_at'
|
||
);
|
||
`)).toBe('5');
|
||
|
||
const taskId = randomUUID();
|
||
const runId = randomUUID();
|
||
const usageId = randomUUID();
|
||
const operationId = `seedream-sync:${taskId}:0`;
|
||
const requestId = `fullstack-seedream-${randomUUID()}`;
|
||
postgresRollbackScript(`
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, prompt, config, provider_task_id,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, provider_task_attempt, processing_stage, created_at, started_at, updated_at
|
||
) VALUES (
|
||
'${taskId}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'image', 'Seedream 5.0 Pro', 'processing', 'Migration 063 checkpoint',
|
||
'{"resolution":"1K","referenceAssets":[]}'::jsonb, '${operationId}',
|
||
14, 14, 0, 0, 'held', 0, 0, 'provider-succeeded', now(), now(), now()
|
||
);
|
||
|
||
INSERT INTO generation_provider_runs (
|
||
id, task_id, task_attempt, provider, request_hash, submission_key, status,
|
||
provider_task_id, output_object_key, thumbnail_object_key, staging_object_key,
|
||
provider_request_id, provider_response_checkpoint, provider_response_url,
|
||
provider_response_observed_at, provider_response_url_expires_at
|
||
) VALUES (
|
||
'${runId}', '${taskId}', 0, 'volcengine-ark', repeat('a', 64),
|
||
'${taskId}:0', 'provider-succeeded', '${operationId}',
|
||
'generated/image/fullstack/${taskId}/output.png',
|
||
'generated/image/fullstack/${taskId}/thumbnail.webp',
|
||
'generated-staging/image/fullstack/${taskId}',
|
||
'${requestId}',
|
||
'{"version":1,"model":"doubao-seedream-5-0-pro-260628","created":null,"size":"1024x1024","usage":{"generatedImages":1,"outputTokens":12,"totalTokens":12}}'::jsonb,
|
||
'https://temporary.example.test/output.png', now(), now() + interval '24 hours'
|
||
);
|
||
UPDATE generation_tasks SET active_provider_run_id = '${runId}' WHERE id = '${taskId}';
|
||
|
||
DO $migration063_checkpoint$
|
||
BEGIN
|
||
BEGIN
|
||
UPDATE generation_provider_runs
|
||
SET provider_request_id = 'tampered-request'
|
||
WHERE id = '${runId}';
|
||
RAISE EXCEPTION '允许篡改 provider_request_id';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许篡改 provider_request_id' THEN RAISE; END IF;
|
||
END;
|
||
BEGIN
|
||
UPDATE generation_provider_runs
|
||
SET provider_response_checkpoint = '{"version":2}'::jsonb
|
||
WHERE id = '${runId}';
|
||
RAISE EXCEPTION '允许篡改 checkpoint';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许篡改 checkpoint' THEN RAISE; END IF;
|
||
END;
|
||
END
|
||
$migration063_checkpoint$;
|
||
|
||
UPDATE generation_provider_runs
|
||
SET provider_response_url = NULL
|
||
WHERE id = '${runId}';
|
||
DO $migration063_url$
|
||
BEGIN
|
||
BEGIN
|
||
UPDATE generation_provider_runs
|
||
SET provider_response_url = 'https://temporary.example.test/restored.png'
|
||
WHERE id = '${runId}';
|
||
RAISE EXCEPTION '允许恢复已清除 URL';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许恢复已清除 URL' THEN RAISE; END IF;
|
||
END;
|
||
END
|
||
$migration063_url$;
|
||
|
||
INSERT INTO image_provider_usage_records (
|
||
id, task_id, provider_run_id, attempt, provider, model,
|
||
provider_operation_id, provider_request_id, status,
|
||
request_evidence_status, evidence_status,
|
||
reference_image_count, additional_reference_image_count,
|
||
output_width, output_height, output_pixels, output_tier,
|
||
reference_unit_price_cny, reference_price_cny,
|
||
output_pixel_threshold, small_output_price_cny, large_output_price_cny,
|
||
output_price_cny, estimated_cost_cny, currency, pricing_status,
|
||
price_version, pricing_source_url, generated_images, output_tokens, total_tokens,
|
||
provider_terminal_at, usage_observed_at
|
||
) VALUES (
|
||
'${usageId}', '${taskId}', '${runId}', 0, 'volcengine-ark', 'Seedream 5.0 Pro',
|
||
'${operationId}', '${requestId}', 'success', 'provider-id', 'known',
|
||
2, 1, 1024, 1024, 1048576, 'small',
|
||
0.020000, 0.020000, 2360000, 0.300000, 0.600000,
|
||
0.300000, 0.320000, 'CNY', 'priced',
|
||
'volcengine-seedream-5.0-pro-list-2026-07-27',
|
||
'https://docs.volcengine.com/docs/82379/1544106?lang=zh',
|
||
1, 12, 12, now(), now()
|
||
);
|
||
|
||
DO $migration063_evidence$
|
||
BEGIN
|
||
BEGIN
|
||
UPDATE image_provider_usage_records SET estimated_cost_cny = 9.990000
|
||
WHERE id = '${usageId}';
|
||
RAISE EXCEPTION '允许更新图片成本证据';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许更新图片成本证据' THEN RAISE; END IF;
|
||
END;
|
||
BEGIN
|
||
DELETE FROM image_provider_usage_records WHERE id = '${usageId}';
|
||
RAISE EXCEPTION '允许删除图片成本证据';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许删除图片成本证据' THEN RAISE; END IF;
|
||
END;
|
||
BEGIN
|
||
UPDATE image_provider_pricing_rules SET small_output_price_cny = 9.990000
|
||
WHERE provider = 'volcengine-ark' AND model = 'Seedream 5.0 Pro';
|
||
RAISE EXCEPTION '允许更新图片价格规则';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许更新图片价格规则' THEN RAISE; END IF;
|
||
END;
|
||
BEGIN
|
||
DELETE FROM image_provider_pricing_rules
|
||
WHERE provider = 'volcengine-ark' AND model = 'Seedream 5.0 Pro';
|
||
RAISE EXCEPTION '允许删除图片价格规则';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许删除图片价格规则' THEN RAISE; END IF;
|
||
END;
|
||
END
|
||
$migration063_evidence$;
|
||
`);
|
||
|
||
postgresRollbackScript(`
|
||
UPDATE consumption_settings
|
||
SET available = true
|
||
WHERE kind = 'image' AND model = 'Seedream 5.0 Pro' AND resolution = '1K';
|
||
${migration}
|
||
DO $migration063_available$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM consumption_settings
|
||
WHERE kind = 'image' AND model = 'Seedream 5.0 Pro'
|
||
AND resolution = '1K' AND credits = 14 AND available = true
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 063 重跑关闭了已开放的 1K 档';
|
||
END IF;
|
||
END
|
||
$migration063_available$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 064/065/066 可在真实 PostgreSQL 16 上重跑并锁定真人权益、披露与审计不变量', () => {
|
||
const portraitMigration = readFileSync(
|
||
new URL('../../apps/api/migrations/064_real_portrait_library.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const privacyMigration = readFileSync(
|
||
new URL('../../apps/api/migrations/065_real_portrait_privacy_withheld.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const legalDisclosureMigration = readFileSync(
|
||
new URL('../../apps/api/migrations/066_real_portrait_legal_disclosure_snapshot.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresScript(portraitMigration);
|
||
postgresScript(portraitMigration);
|
||
postgresScript(privacyMigration);
|
||
postgresScript(privacyMigration);
|
||
postgresScript(legalDisclosureMigration);
|
||
postgresScript(legalDisclosureMigration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT string_agg(
|
||
concat_ws(':', code, portrait_enabled::text,
|
||
coalesce(portrait_profile_limit::text, 'unlimited'),
|
||
portrait_active_limit::text, portrait_assets_per_actor::text),
|
||
'|' ORDER BY code
|
||
)
|
||
FROM plans
|
||
WHERE code IN ('free', 'pro', 'enterprise');
|
||
`)).toBe('enterprise:true:unlimited:10:2|free:true:1:1:1|pro:true:5:3:2');
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM information_schema.tables
|
||
WHERE table_schema = 'public'
|
||
AND table_name = ANY (ARRAY[
|
||
'real_portrait_actors', 'real_portrait_authorizations',
|
||
'real_portrait_invites', 'real_portrait_verification_sessions',
|
||
'real_portrait_materializations', 'real_portrait_assets',
|
||
'real_portrait_capacity_reservations', 'real_portrait_activation_queue',
|
||
'user_portrait_quota_states', 'portrait_entitlement_reconcile_outbox',
|
||
'real_portrait_upload_sessions', 'real_portrait_remote_operations',
|
||
'generation_real_portrait_references', 'real_portrait_events',
|
||
'real_portrait_subject_access_tokens', 'ai_clean_export_audits'
|
||
]);
|
||
`)).toBe('16');
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'real_portrait_authorizations'
|
||
AND is_nullable = 'NO'
|
||
AND column_name = ANY (ARRAY[
|
||
'controller_name', 'processor_name', 'privacy_contact',
|
||
'privacy_policy_url', 'processing_scope', 'retention_rule'
|
||
]);
|
||
`)).toBe('6');
|
||
|
||
expect(postgresScalar(`
|
||
SELECT position('privacy-withheld' IN pg_get_constraintdef(oid)) > 0
|
||
FROM pg_constraint
|
||
WHERE conname = 'generation_tasks_processing_stage_check';
|
||
`)).toBe('t');
|
||
|
||
const actorId = randomUUID();
|
||
postgresRollbackScript(`
|
||
UPDATE plans
|
||
SET portrait_enabled = true,
|
||
portrait_profile_limit = 7,
|
||
portrait_active_limit = 4,
|
||
portrait_assets_per_actor = 2
|
||
WHERE code = 'pro' AND kind = 'main';
|
||
|
||
${portraitMigration}
|
||
|
||
DO $portrait_replay$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM plans
|
||
WHERE code = 'pro' AND portrait_profile_limit = 7 AND portrait_active_limit = 4
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 064 重跑覆盖了管理员配置';
|
||
END IF;
|
||
|
||
BEGIN
|
||
UPDATE plans
|
||
SET portrait_enabled = true,
|
||
portrait_profile_limit = 1,
|
||
portrait_active_limit = 2,
|
||
portrait_assets_per_actor = 1
|
||
WHERE code = 'free';
|
||
RAISE EXCEPTION '允许激活额度超过有限档案额度';
|
||
EXCEPTION WHEN check_violation THEN
|
||
NULL;
|
||
END;
|
||
|
||
IF EXISTS (SELECT 1 FROM plans WHERE kind = 'addon') THEN
|
||
BEGIN
|
||
UPDATE plans
|
||
SET portrait_enabled = true,
|
||
portrait_profile_limit = 1,
|
||
portrait_active_limit = 1,
|
||
portrait_assets_per_actor = 1
|
||
WHERE id = (SELECT id FROM plans WHERE kind = 'addon' LIMIT 1);
|
||
RAISE EXCEPTION '允许加购包获得真人权益';
|
||
EXCEPTION WHEN check_violation THEN
|
||
NULL;
|
||
END;
|
||
END IF;
|
||
END
|
||
$portrait_replay$;
|
||
|
||
INSERT INTO real_portrait_actors (id, user_id, alias)
|
||
SELECT '${actorId}', id, '全栈审计演员'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
|
||
INSERT INTO real_portrait_events (
|
||
user_id, actor_id, event_type, initiator_type, initiator_user_id, metadata
|
||
)
|
||
SELECT id, '${actorId}', 'fullstack-audit', 'admin', id, '{"source":"fullstack"}'::jsonb
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
|
||
DO $portrait_audit$
|
||
BEGIN
|
||
BEGIN
|
||
UPDATE real_portrait_events
|
||
SET metadata = '{"tampered":true}'::jsonb
|
||
WHERE actor_id = '${actorId}';
|
||
RAISE EXCEPTION '允许修改真人不可变审计';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = '允许修改真人不可变审计' THEN RAISE; END IF;
|
||
END;
|
||
END
|
||
$portrait_audit$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 048 可在真实 PostgreSQL 16 上重复执行且保留缩略图与分页不变量', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/048_paged_loading_and_asset_thumbnails.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conname IN (
|
||
'assets_thumbnail_status_check',
|
||
'assets_thumbnail_applicability_check',
|
||
'assets_ready_thumbnail_check',
|
||
'assets_thumbnail_error_check'
|
||
);
|
||
`)).toBe('4');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_indexes
|
||
WHERE schemaname = 'public'
|
||
AND indexname IN (
|
||
'idx_assets_user_folder_cursor',
|
||
'idx_assets_user_source_cursor',
|
||
'idx_tickets_status_cursor',
|
||
'idx_audit_logs_risk_cursor',
|
||
'idx_generation_tasks_user_status_cursor',
|
||
'idx_orders_user_status_cursor',
|
||
'idx_invoice_requests_user_status_cursor',
|
||
'idx_announcements_published_cursor',
|
||
'idx_announcements_admin_search_trgm'
|
||
);
|
||
`)).toBe('9');
|
||
});
|
||
|
||
test('Migration 049 可重放且保留管理员修改过的套餐保留期', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/049_plan_asset_retention.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgres(`UPDATE plans SET asset_retention_days = 47 WHERE code = 'pro';`);
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'free';`)).toBe('7');
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'pro';`)).toBe('47');
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'enterprise';`)).toBe('90');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM pg_constraint
|
||
WHERE conname IN (
|
||
'plans_asset_retention_days_check',
|
||
'plans_asset_retention_by_kind_check',
|
||
'asset_retention_sweep_runs_state_check'
|
||
);
|
||
`)).toBe('3');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM pg_indexes
|
||
WHERE schemaname = 'public'
|
||
AND indexname IN ('idx_assets_retention_sweep', 'idx_asset_retention_sweep_runs_claimable');
|
||
`)).toBe('2');
|
||
|
||
postgres(`UPDATE plans SET asset_retention_days = 30 WHERE code = 'pro';`);
|
||
});
|
||
|
||
test('Migration 067 写入分级保留期、可安全重放并锁定真人套餐正整数保留期', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/067_portrait_plan_asset_retention.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'free';`)).toBe('7');
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'pro';`)).toBe('30');
|
||
expect(postgresScalar(`SELECT asset_retention_days FROM plans WHERE code = 'enterprise';`)).toBe('90');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM pg_constraint
|
||
WHERE conname = 'plans_portrait_asset_retention_check';
|
||
`)).toBe('1');
|
||
|
||
postgresRollbackScript(`
|
||
ALTER TABLE plans DROP CONSTRAINT IF EXISTS plans_portrait_asset_retention_check;
|
||
|
||
INSERT INTO plans (
|
||
code, name, kind, billing, credits, asset_retention_days,
|
||
portrait_enabled, portrait_profile_limit, portrait_active_limit,
|
||
portrait_assets_per_actor
|
||
) VALUES
|
||
('basic', '基础版迁移夹具', 'main', 'both', 0, 0, false, 0, 0, 0),
|
||
('ultra', '旗舰版迁移夹具', 'main', 'both', 0, 0, false, 0, 0, 0)
|
||
ON CONFLICT (code) DO UPDATE
|
||
SET kind = 'main',
|
||
asset_retention_days = 0,
|
||
portrait_enabled = false,
|
||
portrait_profile_limit = 0,
|
||
portrait_active_limit = 0,
|
||
portrait_assets_per_actor = 0;
|
||
|
||
${migration}
|
||
|
||
DO $migration067_defaults$
|
||
BEGIN
|
||
IF (
|
||
SELECT string_agg(concat_ws(':', code, asset_retention_days::text), '|' ORDER BY code)
|
||
FROM plans
|
||
WHERE code IN ('free', 'basic', 'pro', 'ultra', 'enterprise')
|
||
) IS DISTINCT FROM 'basic:15|enterprise:90|free:7|pro:30|ultra:60' THEN
|
||
RAISE EXCEPTION 'Migration 067 未写入确认的标准套餐保留期';
|
||
END IF;
|
||
END
|
||
$migration067_defaults$;
|
||
|
||
UPDATE plans SET asset_retention_days = 21 WHERE code = 'basic';
|
||
${migration}
|
||
|
||
DO $migration067_replay$
|
||
BEGIN
|
||
IF (SELECT asset_retention_days FROM plans WHERE code = 'basic') IS DISTINCT FROM 21 THEN
|
||
RAISE EXCEPTION 'Migration 067 重放覆盖了管理员配置';
|
||
END IF;
|
||
|
||
BEGIN
|
||
UPDATE plans SET asset_retention_days = 0 WHERE code = 'pro';
|
||
RAISE EXCEPTION 'Migration 067 允许真人库套餐永久保留';
|
||
EXCEPTION WHEN check_violation THEN
|
||
NULL;
|
||
END;
|
||
END
|
||
$migration067_replay$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 052 可在真实 PostgreSQL 16 上重放并保留版本化阶梯价格', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/052_prompt_wizard_cost_ledger.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM prompt_wizard_model_pricing_rules
|
||
WHERE provider = 'volcengine-ark'
|
||
AND model = 'doubao-seed-2-0-lite-260428'
|
||
AND service_tier = 'default'
|
||
AND price_version = 'volcengine-list-2026-07-23';
|
||
`)).toBe('3');
|
||
expect(postgresScalar(`
|
||
SELECT string_agg(
|
||
pricing_tier || ':' || input_price_per_million_cny::text || '/'
|
||
|| cached_input_price_per_million_cny::text || '/'
|
||
|| output_price_per_million_cny::text,
|
||
',' ORDER BY min_input_tokens
|
||
)
|
||
FROM prompt_wizard_model_pricing_rules
|
||
WHERE price_version = 'volcengine-list-2026-07-23';
|
||
`)).toBe('0-32k:0.600000/0.120000/3.600000,32k-128k:0.900000/0.180000/5.400000,128k-256k:1.800000/0.360000/10.800000');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM pg_constraint
|
||
WHERE conname IN (
|
||
'prompt_wizard_provider_requests_owner_shape_check',
|
||
'prompt_wizard_provider_requests_plan_shape_check',
|
||
'prompt_wizard_provider_requests_terminal_shape_check',
|
||
'prompt_wizard_provider_requests_cost_shape_check'
|
||
);
|
||
`)).toBe('4');
|
||
});
|
||
|
||
test('Migration 053 可重放并把价格快照来源统一到官方中文价格页', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/053_prompt_wizard_pricing_source_url.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
postgres(`
|
||
UPDATE prompt_wizard_model_pricing_rules
|
||
SET source_url = 'https://www.volcengine.com/docs/82379/1544106'
|
||
WHERE price_version = 'volcengine-list-2026-07-23';
|
||
`);
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM prompt_wizard_model_pricing_rules
|
||
WHERE price_version = 'volcengine-list-2026-07-23'
|
||
AND source_url = 'https://docs.volcengine.com/docs/82379/1544106?lang=zh';
|
||
`)).toBe('3');
|
||
});
|
||
|
||
test('Migration 054 可重放并允许产品档案修复写入独立成本操作', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/054_prompt_wizard_product_profile_repair.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
try {
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_operation_check'
|
||
AND pg_get_constraintdef(oid) LIKE '%repair-product-profile%';
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_owner_shape_check'
|
||
AND pg_get_constraintdef(oid) LIKE '%repair-product-profile%'
|
||
AND pg_get_constraintdef(oid) LIKE '%batch_id IS NULL%';
|
||
`)).toBe('1');
|
||
} finally {
|
||
restorePromptWizardV6SchemaAfterHistoricalMigrationReplay();
|
||
}
|
||
});
|
||
|
||
test('Migration 055 可重放并将单方案格式修复绑定到批次和方案', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/055_prompt_wizard_draft_format_repair.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
try {
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_operation_check'
|
||
AND pg_get_constraintdef(oid) LIKE '%repair-draft-format%';
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_owner_shape_check'
|
||
AND pg_get_constraintdef(oid) LIKE '%repair-draft-format%'
|
||
AND pg_get_constraintdef(oid) LIKE '%batch_id IS NOT NULL%';
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_plan_shape_check'
|
||
AND pg_get_constraintdef(oid) LIKE '%repair-draft-format%'
|
||
AND pg_get_constraintdef(oid) LIKE '%plan_index IS NOT NULL%';
|
||
`)).toBe('1');
|
||
} finally {
|
||
restorePromptWizardV6SchemaAfterHistoricalMigrationReplay();
|
||
}
|
||
});
|
||
|
||
test('Migration 056 可重放并分别约束产品格式恢复与单方案内容修复的归属', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/056_prompt_wizard_bounded_repair_operations.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
|
||
try {
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
const operationConstraint = postgresScalar(`
|
||
SELECT pg_get_constraintdef(oid)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_operation_check';
|
||
`);
|
||
for (const operation of [
|
||
'analyze-product',
|
||
'repair-product-chinese',
|
||
'repair-product-profile',
|
||
'repair-product-format',
|
||
'generate-drafts',
|
||
'repair-draft-format',
|
||
'repair-draft-content',
|
||
]) {
|
||
expect(operationConstraint).toContain(operation);
|
||
}
|
||
|
||
const ownerConstraint = postgresScalar(`
|
||
SELECT pg_get_constraintdef(oid)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_owner_shape_check';
|
||
`);
|
||
expect(ownerConstraint).toContain('repair-product-format');
|
||
expect(ownerConstraint).toContain('repair-draft-content');
|
||
expect(ownerConstraint).toContain('batch_id IS NULL');
|
||
expect(ownerConstraint).toContain('batch_id IS NOT NULL');
|
||
|
||
const planConstraint = postgresScalar(`
|
||
SELECT pg_get_constraintdef(oid)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_provider_requests'::regclass
|
||
AND conname = 'prompt_wizard_provider_requests_plan_shape_check';
|
||
`);
|
||
expect(planConstraint).toContain('repair-product-format');
|
||
expect(planConstraint).toContain('repair-draft-content');
|
||
expect(planConstraint).toContain('plan_index IS NULL');
|
||
expect(planConstraint).toContain('plan_index IS NOT NULL');
|
||
|
||
const fixture = {
|
||
userId: randomUUID(),
|
||
runId: randomUUID(),
|
||
batchId: randomUUID(),
|
||
imageId: randomUUID(),
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
VALUES (
|
||
'${fixture.userId}', 'migration-056-${fixture.userId}@lindyson.test',
|
||
'fullstack-only', 'Migration 056 约束测试用户'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, image_asset_ids, product_profile
|
||
) VALUES (
|
||
'${fixture.runId}', '${fixture.userId}', 'ready',
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, image_asset_ids,
|
||
product_profile, creative_brief, video_config, failure_reason
|
||
) VALUES (
|
||
'${fixture.batchId}', '${fixture.runId}', '${fixture.userId}', 1,
|
||
'migration-056-${fixture.batchId}', 'migration-056', 0,
|
||
'failed', true, 0, 'none', ARRAY['${fixture.imageId}'::uuid],
|
||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, 'Migration 056 约束测试'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status, error_code, completed_at
|
||
) VALUES
|
||
(
|
||
'migration-056-product-${fixture.runId}', '${fixture.runId}', NULL,
|
||
'${fixture.userId}', 'repair-product-format',
|
||
1, 1, NULL, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428', 'default',
|
||
'failed', 'FULLSTACK_FIXTURE', now()
|
||
),
|
||
(
|
||
'migration-056-draft-${fixture.batchId}', '${fixture.runId}', '${fixture.batchId}',
|
||
'${fixture.userId}', 'repair-draft-content',
|
||
1, 1, 1, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428', 'default',
|
||
'failed', 'FULLSTACK_FIXTURE', now()
|
||
);
|
||
`);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT string_agg(operation || ':' || coalesce(plan_index::text, 'null'), ',' ORDER BY operation)
|
||
FROM prompt_wizard_provider_requests
|
||
WHERE run_id = '${fixture.runId}';
|
||
`)).toBe('repair-draft-content:1,repair-product-format:null');
|
||
|
||
const invalidRows = [
|
||
{
|
||
key: 'product-with-batch',
|
||
operation: 'repair-product-format',
|
||
batchId: `'${fixture.batchId}'`,
|
||
planIndex: 'NULL',
|
||
constraint: 'prompt_wizard_provider_requests_owner_shape_check',
|
||
},
|
||
{
|
||
key: 'product-with-plan',
|
||
operation: 'repair-product-format',
|
||
batchId: 'NULL',
|
||
planIndex: '0',
|
||
constraint: 'prompt_wizard_provider_requests_plan_shape_check',
|
||
},
|
||
{
|
||
key: 'draft-without-batch',
|
||
operation: 'repair-draft-content',
|
||
batchId: 'NULL',
|
||
planIndex: '0',
|
||
constraint: 'prompt_wizard_provider_requests_owner_shape_check',
|
||
},
|
||
{
|
||
key: 'draft-without-plan',
|
||
operation: 'repair-draft-content',
|
||
batchId: `'${fixture.batchId}'`,
|
||
planIndex: 'NULL',
|
||
constraint: 'prompt_wizard_provider_requests_plan_shape_check',
|
||
},
|
||
];
|
||
for (const row of invalidRows) {
|
||
postgres(`
|
||
DO $migration056$
|
||
DECLARE failed_constraint text;
|
||
BEGIN
|
||
BEGIN
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier
|
||
) VALUES (
|
||
'migration-056-invalid-${row.key}-${fixture.runId}',
|
||
'${fixture.runId}', ${row.batchId}, '${fixture.userId}', '${row.operation}',
|
||
1, 1, ${row.planIndex}, 1,
|
||
'volcengine-ark', 'doubao-seed-2-0-lite-260428', 'default'
|
||
);
|
||
RAISE EXCEPTION 'Migration 056 非法归属被数据库接受:${row.key}';
|
||
EXCEPTION WHEN check_violation THEN
|
||
GET STACKED DIAGNOSTICS failed_constraint = CONSTRAINT_NAME;
|
||
IF failed_constraint <> '${row.constraint}' THEN
|
||
RAISE EXCEPTION 'Migration 056 命中了错误约束:期望 %,实际 %',
|
||
'${row.constraint}', failed_constraint;
|
||
END IF;
|
||
END;
|
||
END
|
||
$migration056$;
|
||
`);
|
||
}
|
||
} finally {
|
||
restorePromptWizardV6SchemaAfterHistoricalMigrationReplay();
|
||
}
|
||
});
|
||
|
||
test('Migration 059 历史重放不会绕过 Migration 069 的 V4 独占约束', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/059_prompt_wizard_two_plan_batches.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const fixture = {
|
||
userId: randomUUID(),
|
||
runId: randomUUID(),
|
||
imageId: randomUUID(),
|
||
legacyBatchId: randomUUID(),
|
||
};
|
||
|
||
try {
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_batches'::regclass
|
||
AND conname = 'prompt_wizard_batches_v3_only_completed_shape_check';
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT position(
|
||
'selected_concept_ids[1]<>selected_concept_ids[2]'
|
||
IN replace(pg_get_constraintdef(oid), ' ', '')
|
||
) > 0
|
||
FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_batches'::regclass
|
||
AND conname = 'prompt_wizard_batches_v3_only_completed_shape_check';
|
||
`)).toBe('t');
|
||
|
||
postgresRollbackScript(`
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
VALUES (
|
||
'${fixture.userId}', 'migration-059-${fixture.userId}@lindyson.test',
|
||
'fullstack-only', 'Migration 059 约束测试用户'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, image_asset_ids, product_profile
|
||
) VALUES (
|
||
'${fixture.runId}', '${fixture.userId}', 'ready',
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb
|
||
);
|
||
|
||
DO $migration059$
|
||
DECLARE failed_constraint text;
|
||
BEGIN
|
||
BEGIN
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, image_asset_ids,
|
||
product_profile, creative_brief, video_config, plans
|
||
) VALUES (
|
||
'${fixture.legacyBatchId}', '${fixture.runId}', '${fixture.userId}', 1,
|
||
'migration-059-legacy-${fixture.legacyBatchId}', 'migration-059-legacy', 0,
|
||
'completed', true, 0, 'none', ARRAY['${fixture.imageId}'::uuid],
|
||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '[{}, {}]'::jsonb
|
||
);
|
||
RAISE EXCEPTION 'Migration 059 重放错误恢复了旧版已完成批次';
|
||
EXCEPTION WHEN check_violation THEN
|
||
GET STACKED DIAGNOSTICS failed_constraint = CONSTRAINT_NAME;
|
||
IF failed_constraint <> 'prompt_wizard_batches_v3_only_completed_shape_check' THEN
|
||
RAISE EXCEPTION 'Migration 059 旧批次命中了错误约束:%', failed_constraint;
|
||
END IF;
|
||
END;
|
||
END
|
||
$migration059$;
|
||
`);
|
||
} finally {
|
||
restorePromptWizardV6SchemaAfterHistoricalMigrationReplay();
|
||
}
|
||
});
|
||
|
||
test('Migration 071 在真实 PostgreSQL 上覆盖 070 升级、空库 bootstrap、失败回滚与完整重放门禁', () => {
|
||
const migrationDirectory = new URL('../../apps/api/migrations/', import.meta.url);
|
||
const migration071 = readFileSync(
|
||
new URL('071_prompt_wizard_v6_three_routes.sql', migrationDirectory),
|
||
'utf8',
|
||
);
|
||
const through070 = readdirSync(migrationDirectory)
|
||
.filter(file => /^\d{3}_[a-z0-9_]+\.sql$/u.test(file) && file < '071_')
|
||
.sort()
|
||
.map(file => ({
|
||
id: file,
|
||
sql: readFileSync(new URL(file, migrationDirectory), 'utf8'),
|
||
}));
|
||
const suffix = randomUUID().replaceAll('-', '').slice(0, 16);
|
||
const bootstrapDatabase = `m071_bootstrap_${suffix}`;
|
||
const upgradeDatabase = `m071_upgrade_${suffix}`;
|
||
|
||
postgres(`CREATE DATABASE ${bootstrapDatabase};`);
|
||
try {
|
||
postgresDatabaseMigrationSequence(bootstrapDatabase, through070);
|
||
expect(postgresDatabaseScalar(bootstrapDatabase, `
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('true|3');
|
||
|
||
postgres(`CREATE DATABASE ${upgradeDatabase} WITH TEMPLATE ${bootstrapDatabase};`);
|
||
try {
|
||
postgresDatabase(upgradeDatabase, `
|
||
UPDATE prompt_wizard_settings SET available = false WHERE singleton_id = 1;
|
||
CREATE TABLE prompt_wizard_route_sets (unexpected_partial integer);
|
||
`);
|
||
|
||
const rollbackFailure = postgresDatabaseScriptFailure(upgradeDatabase, migration071);
|
||
expect(rollbackFailure).toContain('column "status" does not exist');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('false|3');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT count(*) FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'prompt_wizard_route_sets'
|
||
AND column_name = 'unexpected_partial';
|
||
`)).toBe('1');
|
||
|
||
postgresDatabase(upgradeDatabase, 'DROP TABLE prompt_wizard_route_sets;');
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('false|4');
|
||
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT count(*) FROM prompt_wizard_route_sets;
|
||
`)).toBe('0');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT
|
||
(SELECT count(*) FROM pg_constraint
|
||
WHERE conrelid = 'prompt_wizard_route_sets'::regclass)::text || '|' ||
|
||
(SELECT count(*) FROM pg_index
|
||
WHERE indrelid = 'prompt_wizard_route_sets'::regclass)::text || '|' ||
|
||
(SELECT count(*) FROM pg_trigger
|
||
WHERE tgrelid = 'prompt_wizard_route_sets'::regclass
|
||
AND NOT tgisinternal)::text;
|
||
`)).toBe('13|5|1');
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ADD CONSTRAINT migration071_unexpected_route_set_check CHECK (false);
|
||
`);
|
||
const extraConstraintFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(extraConstraintFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
DROP CONSTRAINT migration071_unexpected_route_set_check;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
CREATE INDEX migration071_unexpected_route_set_idx
|
||
ON prompt_wizard_route_sets(user_id);
|
||
`);
|
||
const extraIndexFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(extraIndexFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(
|
||
upgradeDatabase,
|
||
'DROP INDEX migration071_unexpected_route_set_idx;',
|
||
);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
CREATE TRIGGER trg_migration071_unexpected_route_set
|
||
BEFORE INSERT ON prompt_wizard_route_sets
|
||
FOR EACH ROW EXECUTE FUNCTION validate_prompt_wizard_route_set();
|
||
`);
|
||
const extraTriggerFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(extraTriggerFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP TRIGGER trg_migration071_unexpected_route_set
|
||
ON prompt_wizard_route_sets;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
DROP CONSTRAINT prompt_wizard_route_sets_pending_shape_check;
|
||
ALTER TABLE prompt_wizard_settings
|
||
ADD CONSTRAINT prompt_wizard_route_sets_pending_shape_check CHECK (true);
|
||
`);
|
||
const misplacedConstraintFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(misplacedConstraintFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT
|
||
count(*) FILTER (
|
||
WHERE conrelid = 'prompt_wizard_settings'::regclass
|
||
)::text || '|' ||
|
||
count(*) FILTER (
|
||
WHERE conrelid = 'prompt_wizard_route_sets'::regclass
|
||
)::text
|
||
FROM pg_constraint
|
||
WHERE conname = 'prompt_wizard_route_sets_pending_shape_check';
|
||
`)).toBe('1|0');
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_settings
|
||
DROP CONSTRAINT prompt_wizard_route_sets_pending_shape_check;
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ADD CONSTRAINT prompt_wizard_route_sets_pending_shape_check CHECK (
|
||
status NOT IN ('queued', 'processing', 'failed')
|
||
OR content_purged_at IS NOT NULL
|
||
OR routes IS NULL
|
||
);
|
||
DO $signature$
|
||
DECLARE definition_hash text;
|
||
BEGIN
|
||
SELECT md5(pg_get_constraintdef(oid, true)) INTO STRICT definition_hash
|
||
FROM pg_constraint
|
||
WHERE conname = 'prompt_wizard_route_sets_pending_shape_check'
|
||
AND conrelid = 'prompt_wizard_route_sets'::regclass;
|
||
EXECUTE format(
|
||
'COMMENT ON CONSTRAINT prompt_wizard_route_sets_pending_shape_check ON prompt_wizard_route_sets IS %L',
|
||
'migration071-definition:' || definition_hash
|
||
);
|
||
END
|
||
$signature$;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
const pendingConstraintSignature = postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT obj_description(oid, 'pg_constraint')
|
||
FROM pg_constraint
|
||
WHERE conname = 'prompt_wizard_route_sets_pending_shape_check'
|
||
AND conrelid = 'prompt_wizard_route_sets'::regclass;
|
||
`);
|
||
expect(pendingConstraintSignature).toMatch(/^migration071-definition:[0-9a-f]{32}$/u);
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
DROP CONSTRAINT prompt_wizard_route_sets_pending_shape_check;
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ADD CONSTRAINT prompt_wizard_route_sets_pending_shape_check CHECK (true);
|
||
COMMENT ON CONSTRAINT prompt_wizard_route_sets_pending_shape_check
|
||
ON prompt_wizard_route_sets IS '${pendingConstraintSignature}';
|
||
`);
|
||
const weakenedConstraintFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(weakenedConstraintFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
DROP CONSTRAINT prompt_wizard_route_sets_pending_shape_check;
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ADD CONSTRAINT prompt_wizard_route_sets_pending_shape_check CHECK (
|
||
status NOT IN ('queued', 'processing', 'failed')
|
||
OR content_purged_at IS NOT NULL
|
||
OR routes IS NULL
|
||
);
|
||
DO $signature$
|
||
DECLARE definition_hash text;
|
||
BEGIN
|
||
SELECT md5(pg_get_constraintdef(oid, true)) INTO STRICT definition_hash
|
||
FROM pg_constraint
|
||
WHERE conname = 'prompt_wizard_route_sets_pending_shape_check'
|
||
AND conrelid = 'prompt_wizard_route_sets'::regclass;
|
||
EXECUTE format(
|
||
'COMMENT ON CONSTRAINT prompt_wizard_route_sets_pending_shape_check ON prompt_wizard_route_sets IS %L',
|
||
'migration071-definition:' || definition_hash
|
||
);
|
||
END
|
||
$signature$;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
const routeTriggerSignature = postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT obj_description(oid, 'pg_trigger')
|
||
FROM pg_trigger
|
||
WHERE tgname = 'trg_validate_prompt_wizard_route_set'
|
||
AND tgrelid = 'prompt_wizard_route_sets'::regclass
|
||
AND NOT tgisinternal;
|
||
`);
|
||
expect(routeTriggerSignature).toMatch(/^migration071-definition:[0-9a-f]{32}$/u);
|
||
postgresDatabase(upgradeDatabase, `
|
||
CREATE OR REPLACE FUNCTION migration071_wrong_route_trigger()
|
||
RETURNS trigger LANGUAGE plpgsql AS $wrong$
|
||
BEGIN
|
||
RETURN NEW;
|
||
END
|
||
$wrong$;
|
||
DROP TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_route_sets;
|
||
CREATE TRIGGER trg_validate_prompt_wizard_route_set
|
||
BEFORE INSERT ON prompt_wizard_route_sets
|
||
FOR EACH ROW EXECUTE FUNCTION migration071_wrong_route_trigger();
|
||
COMMENT ON TRIGGER trg_validate_prompt_wizard_route_set
|
||
ON prompt_wizard_route_sets IS '${routeTriggerSignature}';
|
||
`);
|
||
const triggerDefinitionFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(triggerDefinitionFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_route_sets;
|
||
DROP FUNCTION migration071_wrong_route_trigger();
|
||
CREATE TRIGGER trg_validate_prompt_wizard_route_set
|
||
BEFORE INSERT OR UPDATE ON prompt_wizard_route_sets
|
||
FOR EACH ROW EXECUTE FUNCTION validate_prompt_wizard_route_set();
|
||
DO $signature$
|
||
DECLARE definition_hash text;
|
||
BEGIN
|
||
SELECT md5(pg_get_triggerdef(oid, true)) INTO STRICT definition_hash
|
||
FROM pg_trigger
|
||
WHERE tgname = 'trg_validate_prompt_wizard_route_set'
|
||
AND tgrelid = 'prompt_wizard_route_sets'::regclass
|
||
AND NOT tgisinternal;
|
||
EXECUTE format(
|
||
'COMMENT ON TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_route_sets IS %L',
|
||
'migration071-definition:' || definition_hash
|
||
);
|
||
END
|
||
$signature$;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(
|
||
upgradeDatabase,
|
||
'ALTER TABLE prompt_wizard_route_sets ADD COLUMN unexpected_partial integer;',
|
||
);
|
||
const columnDriftFailure = postgresDatabaseScriptFailure(upgradeDatabase, migration071);
|
||
expect(columnDriftFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT count(*) FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'prompt_wizard_route_sets'
|
||
AND column_name = 'unexpected_partial';
|
||
`)).toBe('1');
|
||
postgresDatabase(
|
||
upgradeDatabase,
|
||
'ALTER TABLE prompt_wizard_route_sets DROP COLUMN unexpected_partial;',
|
||
);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_settings
|
||
ALTER COLUMN engine_version SET DEFAULT (4::smallint);
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_settings
|
||
ALTER COLUMN engine_version SET DEFAULT (3::smallint);
|
||
`);
|
||
const defaultDriftFailure = postgresDatabaseScriptFailure(upgradeDatabase, migration071);
|
||
expect(defaultDriftFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_settings
|
||
ALTER COLUMN engine_version SET DEFAULT (4::smallint);
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ALTER COLUMN status SET DEFAULT 'processing';
|
||
`);
|
||
const routeDefaultDriftFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(routeDefaultDriftFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
ALTER TABLE prompt_wizard_route_sets
|
||
ALTER COLUMN status SET DEFAULT 'queued';
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_route_sets;
|
||
CREATE TRIGGER trg_validate_prompt_wizard_route_set
|
||
BEFORE INSERT OR UPDATE ON prompt_wizard_settings
|
||
FOR EACH ROW EXECUTE FUNCTION validate_prompt_wizard_route_set();
|
||
`);
|
||
const misplacedTriggerFailure = postgresDatabaseScriptFailure(upgradeDatabase, migration071);
|
||
expect(misplacedTriggerFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT
|
||
count(*) FILTER (
|
||
WHERE tgrelid = 'prompt_wizard_settings'::regclass
|
||
)::text || '|' ||
|
||
count(*) FILTER (
|
||
WHERE tgrelid = 'prompt_wizard_route_sets'::regclass
|
||
)::text
|
||
FROM pg_trigger
|
||
WHERE tgname = 'trg_validate_prompt_wizard_route_set'
|
||
AND NOT tgisinternal;
|
||
`)).toBe('1|0');
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_settings;
|
||
CREATE TRIGGER trg_validate_prompt_wizard_route_set
|
||
BEFORE INSERT OR UPDATE ON prompt_wizard_route_sets
|
||
FOR EACH ROW EXECUTE FUNCTION validate_prompt_wizard_route_set();
|
||
DO $signature$
|
||
DECLARE definition_hash text;
|
||
BEGIN
|
||
SELECT md5(pg_get_triggerdef(oid, true)) INTO STRICT definition_hash
|
||
FROM pg_trigger
|
||
WHERE tgname = 'trg_validate_prompt_wizard_route_set'
|
||
AND tgrelid = 'prompt_wizard_route_sets'::regclass
|
||
AND NOT tgisinternal;
|
||
EXECUTE format(
|
||
'COMMENT ON TRIGGER trg_validate_prompt_wizard_route_set ON prompt_wizard_route_sets IS %L',
|
||
'migration071-definition:' || definition_hash
|
||
);
|
||
END
|
||
$signature$;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
const providerIndexSignature = postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT obj_description(index_relation.oid, 'pg_class')
|
||
FROM pg_class index_relation
|
||
JOIN pg_index index_definition
|
||
ON index_definition.indexrelid = index_relation.oid
|
||
WHERE index_relation.relname = 'idx_prompt_wizard_provider_requests_route_set_created'
|
||
AND index_definition.indrelid = 'prompt_wizard_provider_requests'::regclass;
|
||
`);
|
||
expect(providerIndexSignature).toMatch(/^migration071-definition:[0-9a-f]{32}$/u);
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP INDEX idx_prompt_wizard_provider_requests_route_set_created;
|
||
CREATE INDEX idx_prompt_wizard_provider_requests_route_set_created
|
||
ON prompt_wizard_provider_requests(route_set_id, created_at ASC, id ASC)
|
||
WHERE false;
|
||
COMMENT ON INDEX idx_prompt_wizard_provider_requests_route_set_created
|
||
IS '${providerIndexSignature}';
|
||
`);
|
||
const indexDefinitionFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(indexDefinitionFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
postgresDatabase(upgradeDatabase, `
|
||
DROP INDEX idx_prompt_wizard_provider_requests_route_set_created;
|
||
CREATE INDEX idx_prompt_wizard_provider_requests_route_set_created
|
||
ON prompt_wizard_provider_requests(route_set_id, created_at ASC, id ASC)
|
||
WHERE route_set_id IS NOT NULL;
|
||
DO $signature$
|
||
DECLARE definition_hash text;
|
||
BEGIN
|
||
SELECT md5(pg_get_indexdef(index_relation.oid, 0, true))
|
||
INTO STRICT definition_hash
|
||
FROM pg_class index_relation
|
||
JOIN pg_index index_definition
|
||
ON index_definition.indexrelid = index_relation.oid
|
||
WHERE index_relation.relname = 'idx_prompt_wizard_provider_requests_route_set_created'
|
||
AND index_definition.indrelid = 'prompt_wizard_provider_requests'::regclass;
|
||
EXECUTE format(
|
||
'COMMENT ON INDEX idx_prompt_wizard_provider_requests_route_set_created IS %L',
|
||
'migration071-definition:' || definition_hash
|
||
);
|
||
END
|
||
$signature$;
|
||
`);
|
||
postgresDatabaseScript(upgradeDatabase, migration071);
|
||
|
||
postgresDatabase(
|
||
upgradeDatabase,
|
||
`DROP INDEX idx_prompt_wizard_provider_requests_route_set_created;
|
||
CREATE INDEX idx_prompt_wizard_provider_requests_route_set_created
|
||
ON prompt_wizard_settings(updated_at)
|
||
WHERE available = false;`,
|
||
);
|
||
const driftFailure = postgresDatabaseScriptFailure(upgradeDatabase, migration071);
|
||
expect(driftFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
expect(postgresDatabaseScalar(upgradeDatabase, `
|
||
SELECT available::text || '|' || engine_version::text || '|' ||
|
||
(EXISTS (
|
||
SELECT 1
|
||
FROM pg_class index_relation
|
||
JOIN pg_index index_definition ON index_definition.indexrelid = index_relation.oid
|
||
WHERE index_relation.relname = 'idx_prompt_wizard_provider_requests_route_set_created'
|
||
AND index_definition.indrelid = 'prompt_wizard_settings'::regclass
|
||
))::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('false|4|true');
|
||
|
||
postgresDatabase(upgradeDatabase, `
|
||
CREATE OR REPLACE FUNCTION validate_prompt_wizard_batch_route_set()
|
||
RETURNS trigger LANGUAGE plpgsql AS $wrong$
|
||
BEGIN
|
||
RETURN NEW;
|
||
END
|
||
$wrong$;
|
||
`);
|
||
const functionDefinitionFailure = postgresDatabaseScriptFailure(
|
||
upgradeDatabase,
|
||
migration071,
|
||
);
|
||
expect(functionDefinitionFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
} finally {
|
||
postgres(`DROP DATABASE IF EXISTS ${upgradeDatabase} WITH (FORCE);`);
|
||
}
|
||
|
||
postgresDatabase(bootstrapDatabase, `
|
||
INSERT INTO users (email, password_hash, user_name)
|
||
VALUES ('migration-071-nonempty-${suffix}@lindyson.test', 'fullstack-only', '非空 bootstrap 门禁');
|
||
`);
|
||
const nonemptyBootstrapFailure = postgresDatabaseScriptFailure(
|
||
bootstrapDatabase,
|
||
migration071,
|
||
);
|
||
expect(nonemptyBootstrapFailure).toContain('Migration 071 拒绝配置或结构漂移');
|
||
expect(postgresDatabaseScalar(bootstrapDatabase, `
|
||
SELECT available::text || '|' || engine_version::text || '|' ||
|
||
(to_regclass('public.prompt_wizard_route_sets') IS NULL)::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('true|3|true');
|
||
postgresDatabase(
|
||
bootstrapDatabase,
|
||
`DELETE FROM users WHERE email = 'migration-071-nonempty-${suffix}@lindyson.test';`,
|
||
);
|
||
postgresDatabaseScript(bootstrapDatabase, migration071);
|
||
expect(postgresDatabaseScalar(bootstrapDatabase, `
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings;
|
||
`)).toBe('false|4');
|
||
} finally {
|
||
postgres(`DROP DATABASE IF EXISTS ${bootstrapDatabase} WITH (FORCE);`);
|
||
}
|
||
});
|
||
|
||
test('Migration 071 可重放、保留历史 V4/V5 正文与不可变账本并锁定新写引擎 4', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/071_prompt_wizard_v6_three_routes.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const fixture = {
|
||
userId: randomUUID(),
|
||
historicalRunId: randomUUID(),
|
||
newRunId: randomUUID(),
|
||
rejectedRunId: randomUUID(),
|
||
batchId: randomUUID(),
|
||
imageId: randomUUID(),
|
||
providerRequestId: randomUUID(),
|
||
terminalCalibrationRequestId: randomUUID(),
|
||
routeSetId: randomUUID(),
|
||
v6BatchId: randomUUID(),
|
||
firstRouteId: randomUUID(),
|
||
secondRouteId: randomUUID(),
|
||
thirdRouteId: randomUUID(),
|
||
ledgerId: randomUUID(),
|
||
auditId: randomUUID(),
|
||
firstPlanId: randomUUID(),
|
||
secondPlanId: randomUUID(),
|
||
};
|
||
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
|
||
postgresRollbackScript(`
|
||
ALTER TABLE prompt_wizard_runs DISABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
VALUES (
|
||
'${fixture.userId}', 'migration-071-${fixture.userId}@lindyson.test',
|
||
'fullstack-only', 'Migration 071 历史保留测试用户'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile,
|
||
production_brief, evidence_graph, engine_version
|
||
) VALUES (
|
||
'${fixture.historicalRunId}', '${fixture.userId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{"name":"V5 历史产品正文"}'::jsonb,
|
||
'{"audience":"历史受众"}'::jsonb,
|
||
'{"evidence":[{"text":"图片可见证据"}]}'::jsonb, 3
|
||
);
|
||
ALTER TABLE prompt_wizard_runs ENABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, image_asset_ids,
|
||
product_profile, creative_brief, video_config, plans,
|
||
strategy_signatures, failure_reason
|
||
) VALUES (
|
||
'${fixture.batchId}', '${fixture.historicalRunId}', '${fixture.userId}', 1,
|
||
'migration-071-${fixture.batchId}', 'migration-071-history', 1,
|
||
'failed', true, 0, 'none', ARRAY['${fixture.imageId}'::uuid],
|
||
'{"name":"V5 历史批次产品"}'::jsonb, '{"direction":"历史方向"}'::jsonb,
|
||
'{"model":"Seedance 2.0 Mini"}'::jsonb,
|
||
'[{"id":"${fixture.firstPlanId}","deliveryVersion":5,"title":"历史方案一"},{"id":"${fixture.secondPlanId}","deliveryVersion":5,"title":"历史方案二"}]'::jsonb,
|
||
'["history-signature"]'::jsonb, 'V5 历史失败正文'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
id, request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status,
|
||
input_tokens, cached_input_tokens, output_tokens,
|
||
pricing_rule_id, price_version, pricing_tier,
|
||
input_price_per_million_cny, cached_input_price_per_million_cny,
|
||
output_price_per_million_cny, cost_cny, cost_status, completed_at
|
||
) VALUES (
|
||
'${fixture.providerRequestId}', 'migration-071-provider-${fixture.providerRequestId}',
|
||
'${fixture.historicalRunId}', '${fixture.batchId}', '${fixture.userId}',
|
||
'generate-drafts', 1, 0, 0, 1, 'volcengine-ark',
|
||
'doubao-seed-2-0-lite-260428', 'default', 'succeeded',
|
||
100, 10, 20,
|
||
'volcengine-doubao-seed-2-0-lite-260428-default-2026-07-23-0-32k',
|
||
'volcengine-list-2026-07-23', '0-32k',
|
||
0.600000, 0.120000, 3.600000, 0.000127, 'known', now()
|
||
);
|
||
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_prompt_batch_id, business_event_key
|
||
) VALUES (
|
||
'${fixture.ledgerId}', '${fixture.userId}', 'adjust', 'system', 'none',
|
||
0, 0, 'Migration 071 积分正文保留测试', 'system', 'V5 历史积分正文',
|
||
'${fixture.batchId}', 'migration-071-ledger-${fixture.ledgerId}'
|
||
);
|
||
|
||
INSERT INTO audit_logs (
|
||
id, admin_email, module, action, target, risk, detail
|
||
) VALUES (
|
||
'${fixture.auditId}', 'migration-071@lindyson.test', 'prompt-wizard',
|
||
'v6-history-fixture', '${fixture.historicalRunId}', 'low', 'V5 历史审计正文'
|
||
);
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile
|
||
) VALUES (
|
||
'${fixture.newRunId}', '${fixture.userId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_route_sets (
|
||
id, run_id, user_id, idempotency_key, request_hash, run_revision,
|
||
status, routes, attempt_count, started_at, completed_at
|
||
) VALUES (
|
||
'${fixture.routeSetId}', '${fixture.newRunId}', '${fixture.userId}',
|
||
'migration-071-routes-${fixture.routeSetId}', 'migration-071-routes', 1,
|
||
'completed',
|
||
'[{"id":"${fixture.firstRouteId}","role":"proof-conversion"},{"id":"${fixture.secondRouteId}","role":"problem-hook"},{"id":"${fixture.thirdRouteId}","role":"lifestyle-seeding"}]'::jsonb,
|
||
1, now(), now()
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, image_asset_ids,
|
||
product_profile, creative_brief, video_config, route_set_id, attempt_count
|
||
) VALUES (
|
||
'${fixture.v6BatchId}', '${fixture.newRunId}', '${fixture.userId}', 1,
|
||
'migration-071-v6-${fixture.v6BatchId}', 'migration-071-v6', 1,
|
||
'processing', true, 0, 'none', ARRAY['${fixture.imageId}'::uuid],
|
||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '${fixture.routeSetId}', 1
|
||
);
|
||
|
||
DO $migration071_terminal_owner_rejections$
|
||
DECLARE failed_constraint text;
|
||
BEGIN
|
||
BEGIN
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status
|
||
) VALUES (
|
||
'migration-071-reject-v5-round3-${fixture.batchId}',
|
||
'${fixture.historicalRunId}', '${fixture.batchId}', '${fixture.userId}',
|
||
'repair-draft-content', 1, 3, 1, 1, 'volcengine-ark',
|
||
'doubao-seed-2-0-lite-260428', 'default', 'started'
|
||
);
|
||
RAISE EXCEPTION 'Migration 071 允许历史 V5 批次使用第三轮成本身份';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = 'Migration 071 允许历史 V5 批次使用第三轮成本身份' THEN
|
||
RAISE;
|
||
END IF;
|
||
IF position('第三轮只允许引擎 4 的 V6 批次' IN SQLERRM) = 0 THEN
|
||
RAISE EXCEPTION 'Migration 071 历史 V5 round3 命中了错误异常:%', SQLERRM;
|
||
END IF;
|
||
END;
|
||
|
||
BEGIN
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status
|
||
) VALUES (
|
||
'migration-071-reject-v6-round4-${fixture.v6BatchId}',
|
||
'${fixture.newRunId}', '${fixture.v6BatchId}', '${fixture.userId}',
|
||
'repair-draft-content', 1, 4, 1, 1, 'volcengine-ark',
|
||
'doubao-seed-2-0-lite-260428', 'default', 'started'
|
||
);
|
||
RAISE EXCEPTION 'Migration 071 允许 V6 批次使用第四轮成本身份';
|
||
EXCEPTION WHEN check_violation THEN
|
||
GET STACKED DIAGNOSTICS failed_constraint = CONSTRAINT_NAME;
|
||
IF failed_constraint <> 'prompt_wizard_provider_requests_repair_round_check' THEN
|
||
RAISE EXCEPTION 'Migration 071 V6 round4 命中了错误约束:%', failed_constraint;
|
||
END IF;
|
||
END;
|
||
END
|
||
$migration071_terminal_owner_rejections$;
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
id, request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status
|
||
) VALUES (
|
||
'${fixture.terminalCalibrationRequestId}',
|
||
'migration-071-terminal-calibration-${fixture.terminalCalibrationRequestId}',
|
||
'${fixture.newRunId}', '${fixture.v6BatchId}', '${fixture.userId}',
|
||
'repair-draft-content', 1, 3, 1, 1, 'volcengine-ark',
|
||
'doubao-seed-2-0-lite-260428', 'default', 'started'
|
||
);
|
||
|
||
DO $migration071_assertions$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_runs
|
||
WHERE id = '${fixture.historicalRunId}'
|
||
AND engine_version = 3 AND status = 'ready'
|
||
AND product_profile = '{"name":"V5 历史产品正文"}'::jsonb
|
||
AND production_brief = '{"audience":"历史受众"}'::jsonb
|
||
AND evidence_graph = '{"evidence":[{"text":"图片可见证据"}]}'::jsonb
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 改写或归档了历史引擎 3 run';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_batches
|
||
WHERE id = '${fixture.batchId}'
|
||
AND status = 'failed'
|
||
AND product_profile = '{"name":"V5 历史批次产品"}'::jsonb
|
||
AND creative_brief = '{"direction":"历史方向"}'::jsonb
|
||
AND video_config = '{"model":"Seedance 2.0 Mini"}'::jsonb
|
||
AND plans = '[{"id":"${fixture.firstPlanId}","deliveryVersion":5,"title":"历史方案一"},{"id":"${fixture.secondPlanId}","deliveryVersion":5,"title":"历史方案二"}]'::jsonb
|
||
AND strategy_signatures = '["history-signature"]'::jsonb
|
||
AND failure_reason = 'V5 历史失败正文'
|
||
AND content_purged_at IS NULL
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 改写了历史 V5 batch 正文';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_provider_requests
|
||
WHERE id = '${fixture.providerRequestId}'
|
||
AND request_status = 'succeeded'
|
||
AND input_tokens = 100 AND cached_input_tokens = 10 AND output_tokens = 20
|
||
AND pricing_rule_id = 'volcengine-doubao-seed-2-0-lite-260428-default-2026-07-23-0-32k'
|
||
AND cost_cny = 0.000127 AND cost_status = 'known'
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 改写了历史 Provider 成本账本';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_provider_requests
|
||
WHERE id = '${fixture.terminalCalibrationRequestId}'
|
||
AND operation = 'repair-draft-content'
|
||
AND repair_round = 3 AND plan_index = 1
|
||
AND run_id = '${fixture.newRunId}'
|
||
AND batch_id = '${fixture.v6BatchId}'
|
||
AND request_status = 'started'
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 未允许 V6 终局口播校准使用第三轮成本身份';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM credit_ledger
|
||
WHERE id = '${fixture.ledgerId}'
|
||
AND source = 'Migration 071 积分正文保留测试'
|
||
AND note = 'V5 历史积分正文'
|
||
AND related_prompt_batch_id = '${fixture.batchId}'
|
||
) OR NOT EXISTS (
|
||
SELECT 1 FROM audit_logs
|
||
WHERE id = '${fixture.auditId}'
|
||
AND action = 'v6-history-fixture'
|
||
AND detail = 'V5 历史审计正文'
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 改写或删除了历史积分、审计正文';
|
||
END IF;
|
||
|
||
IF (SELECT engine_version FROM prompt_wizard_settings WHERE singleton_id = 1) <> 4 THEN
|
||
RAISE EXCEPTION 'Migration 071 未固定全局引擎 4';
|
||
END IF;
|
||
END
|
||
$migration071_assertions$;
|
||
|
||
DO $migration071_new_writes$
|
||
BEGIN
|
||
IF (SELECT engine_version FROM prompt_wizard_runs WHERE id = '${fixture.newRunId}') <> 4 THEN
|
||
RAISE EXCEPTION 'Migration 071 新任务未默认使用引擎 4';
|
||
END IF;
|
||
|
||
BEGIN
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile, engine_version
|
||
) VALUES (
|
||
'${fixture.rejectedRunId}', '${fixture.userId}', 'analyzing', 0,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, 3
|
||
);
|
||
RAISE EXCEPTION 'Migration 071 允许新增引擎 3 run';
|
||
EXCEPTION WHEN raise_exception THEN
|
||
IF SQLERRM = 'Migration 071 允许新增引擎 3 run' THEN RAISE; END IF;
|
||
END;
|
||
|
||
BEGIN
|
||
UPDATE prompt_wizard_settings SET engine_version = 3 WHERE singleton_id = 1;
|
||
RAISE EXCEPTION 'Migration 071 允许全局回退到引擎 3';
|
||
EXCEPTION WHEN check_violation THEN
|
||
NULL;
|
||
END;
|
||
END
|
||
$migration071_new_writes$;
|
||
`);
|
||
});
|
||
|
||
test('Migration 071 不归档、不阻断运行中的 V4/V5 任务与冻结积分', () => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/071_prompt_wizard_v6_three_routes.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const fixture = {
|
||
userId: randomUUID(),
|
||
v4RunId: randomUUID(),
|
||
v5RunId: randomUUID(),
|
||
imageId: randomUUID(),
|
||
conceptSetId: randomUUID(),
|
||
directionSetId: randomUUID(),
|
||
heldBatchId: randomUUID(),
|
||
lotId: randomUUID(),
|
||
allocationId: randomUUID(),
|
||
v4ProviderRequestId: randomUUID(),
|
||
v5ProviderRequestId: randomUUID(),
|
||
};
|
||
|
||
postgresRollbackScript(`
|
||
ALTER TABLE prompt_wizard_runs DISABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
VALUES (
|
||
'${fixture.userId}', 'migration-071-active-${fixture.userId}@lindyson.test',
|
||
'fullstack-only', 'Migration 071 历史队列测试用户'
|
||
);
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile,
|
||
production_brief, evidence_graph, engine_version
|
||
) VALUES (
|
||
'${fixture.v4RunId}', '${fixture.userId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb,
|
||
'{}'::jsonb, '{"evidence":[]}'::jsonb, 3
|
||
), (
|
||
'${fixture.v5RunId}', '${fixture.userId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb,
|
||
'{}'::jsonb, '{"evidence":[]}'::jsonb, 3
|
||
);
|
||
ALTER TABLE prompt_wizard_runs ENABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
|
||
INSERT INTO prompt_wizard_concept_sets (
|
||
id, run_id, user_id, idempotency_key, request_hash, run_revision,
|
||
status, attempt_count, started_at
|
||
) VALUES (
|
||
'${fixture.conceptSetId}', '${fixture.v4RunId}', '${fixture.userId}',
|
||
'migration-071-v4-${fixture.conceptSetId}', 'migration-071-v4', 1,
|
||
'processing', 1, now()
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_use_direction_sets (
|
||
id, run_id, user_id, idempotency_key, request_hash, run_revision,
|
||
status, attempt_count, started_at
|
||
) VALUES (
|
||
'${fixture.directionSetId}', '${fixture.v5RunId}', '${fixture.userId}',
|
||
'migration-071-v5-${fixture.directionSetId}', 'migration-071-v5', 1,
|
||
'processing', 1, now()
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, main_credits_used, addon_credits_used,
|
||
image_asset_ids, product_profile, creative_brief, video_config, attempt_count
|
||
) VALUES (
|
||
'${fixture.heldBatchId}', '${fixture.v5RunId}', '${fixture.userId}', 1,
|
||
'migration-071-held-${fixture.heldBatchId}', 'migration-071-held', 1,
|
||
'processing', false, 1, 'held', 1, 0,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, 1
|
||
);
|
||
|
||
INSERT INTO credit_lots (
|
||
id, user_id, bucket, source_type, source_event_key,
|
||
original_amount, available_amount, held_amount
|
||
) VALUES (
|
||
'${fixture.lotId}', '${fixture.userId}', 'main', 'legacy',
|
||
'migration-071-lot-${fixture.lotId}', 1, 0, 1
|
||
);
|
||
|
||
INSERT INTO credit_allocations (
|
||
id, user_id, lot_id, business_event_key, purpose, status, amount,
|
||
related_prompt_batch_id
|
||
) VALUES (
|
||
'${fixture.allocationId}', '${fixture.userId}', '${fixture.lotId}',
|
||
'migration-071-allocation-${fixture.allocationId}', 'prompt-wizard', 'held', 1,
|
||
'${fixture.heldBatchId}'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
id, request_key, run_id, user_id, operation,
|
||
job_attempt, repair_round, transport_attempt,
|
||
provider, model, service_tier, request_status
|
||
) VALUES (
|
||
'${fixture.v4ProviderRequestId}',
|
||
'migration-071-v4-provider-${fixture.v4ProviderRequestId}',
|
||
'${fixture.v4RunId}', '${fixture.userId}', 'generate-concepts',
|
||
1, 0, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428', 'default', 'started'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
id, request_key, run_id, use_direction_set_id, user_id, operation,
|
||
job_attempt, repair_round, transport_attempt,
|
||
provider, model, service_tier, request_status
|
||
) VALUES (
|
||
'${fixture.v5ProviderRequestId}',
|
||
'use-direction-set:${fixture.directionSetId}:migration-071-${fixture.v5ProviderRequestId}',
|
||
'${fixture.v5RunId}', '${fixture.directionSetId}', '${fixture.userId}',
|
||
'generate-use-directions', 1, 0, 1,
|
||
'volcengine-ark', 'doubao-seed-2-0-lite-260428', 'default', 'started'
|
||
);
|
||
|
||
${migration}
|
||
${migration}
|
||
|
||
DO $migration071_active_assertions$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_runs
|
||
WHERE id = '${fixture.v4RunId}' AND engine_version = 3 AND status = 'ready'
|
||
) OR NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_runs
|
||
WHERE id = '${fixture.v5RunId}' AND engine_version = 3 AND status = 'ready'
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 归档或改写了运行中的 V4/V5 run';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_concept_sets
|
||
WHERE id = '${fixture.conceptSetId}' AND status = 'processing' AND content_purged_at IS NULL
|
||
) OR NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_use_direction_sets
|
||
WHERE id = '${fixture.directionSetId}' AND status = 'processing' AND content_purged_at IS NULL
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 阻断或清理了运行中的 V4/V5 规划任务';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM prompt_wizard_batches
|
||
WHERE id = '${fixture.heldBatchId}'
|
||
AND status = 'processing' AND credit_status = 'held'
|
||
) OR NOT EXISTS (
|
||
SELECT 1 FROM credit_allocations
|
||
WHERE id = '${fixture.allocationId}' AND status = 'held'
|
||
) OR NOT EXISTS (
|
||
SELECT 1 FROM credit_lots
|
||
WHERE id = '${fixture.lotId}' AND held_amount = 1
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 改写或释放了历史冻结积分';
|
||
END IF;
|
||
END
|
||
$migration071_active_assertions$;
|
||
|
||
UPDATE prompt_wizard_concept_sets
|
||
SET attempt_count = attempt_count + 1, updated_at = now()
|
||
WHERE id = '${fixture.conceptSetId}';
|
||
UPDATE prompt_wizard_use_direction_sets
|
||
SET attempt_count = attempt_count + 1, updated_at = now()
|
||
WHERE id = '${fixture.directionSetId}';
|
||
UPDATE prompt_wizard_batches
|
||
SET attempt_count = attempt_count + 1, updated_at = now()
|
||
WHERE id = '${fixture.heldBatchId}';
|
||
UPDATE prompt_wizard_provider_requests
|
||
SET request_status = 'succeeded', completed_at = now()
|
||
WHERE id IN ('${fixture.v4ProviderRequestId}', '${fixture.v5ProviderRequestId}');
|
||
|
||
DO $migration071_queue_continues$
|
||
BEGIN
|
||
IF (SELECT attempt_count FROM prompt_wizard_concept_sets WHERE id = '${fixture.conceptSetId}') <> 2
|
||
OR (SELECT attempt_count FROM prompt_wizard_use_direction_sets WHERE id = '${fixture.directionSetId}') <> 2
|
||
OR (SELECT attempt_count FROM prompt_wizard_batches WHERE id = '${fixture.heldBatchId}') <> 2
|
||
OR (
|
||
SELECT count(*) FROM prompt_wizard_provider_requests
|
||
WHERE id IN ('${fixture.v4ProviderRequestId}', '${fixture.v5ProviderRequestId}')
|
||
AND request_status = 'succeeded' AND completed_at IS NOT NULL
|
||
) <> 2
|
||
THEN
|
||
RAISE EXCEPTION 'Migration 071 后历史 V4/V5 worker 无法继续推进';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM credit_allocations
|
||
WHERE id = '${fixture.allocationId}' AND status = 'held'
|
||
) THEN
|
||
RAISE EXCEPTION 'Migration 071 后历史 worker 推进时冻结积分被意外释放';
|
||
END IF;
|
||
END
|
||
$migration071_queue_continues$;
|
||
|
||
SET CONSTRAINTS ALL IMMEDIATE;
|
||
`);
|
||
});
|
||
|
||
|
||
test('真实 PostgreSQL 验证 V6 到期冻结积分失败全额退款且重复释放幂等', () => {
|
||
const fixture = {
|
||
imageId: randomUUID(),
|
||
v6UserId: randomUUID(),
|
||
v5UserId: randomUUID(),
|
||
v6RunId: randomUUID(),
|
||
v5RunId: randomUUID(),
|
||
v6BatchId: randomUUID(),
|
||
v5BatchId: randomUUID(),
|
||
v6LotId: randomUUID(),
|
||
v5LotId: randomUUID(),
|
||
v6AllocationId: randomUUID(),
|
||
v5AllocationId: randomUUID(),
|
||
v6HoldLedgerId: randomUUID(),
|
||
v5HoldLedgerId: randomUUID(),
|
||
};
|
||
const setupSql = `
|
||
SET CONSTRAINTS ALL DEFERRED;
|
||
|
||
INSERT INTO users (
|
||
id, email, password_hash, user_name, main_credits, addon_credits, total_credits,
|
||
plan_reset_at
|
||
) VALUES
|
||
(
|
||
'${fixture.v6UserId}', 'prompt-refund-v6-${fixture.v6UserId}@lindyson.test',
|
||
'fullstack-only', 'V6 到期积分退款测试用户', 0, 0, 0, current_date + 7
|
||
),
|
||
(
|
||
'${fixture.v5UserId}', 'prompt-refund-v5-${fixture.v5UserId}@lindyson.test',
|
||
'fullstack-only', 'V5 到期积分退款测试用户', 0, 0, 0, current_date + 7
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile, engine_version
|
||
) VALUES (
|
||
'${fixture.v6RunId}', '${fixture.v6UserId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, 4
|
||
);
|
||
|
||
ALTER TABLE prompt_wizard_runs DISABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, product_profile, engine_version
|
||
) VALUES (
|
||
'${fixture.v5RunId}', '${fixture.v5UserId}', 'ready', 1,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, 3
|
||
);
|
||
ALTER TABLE prompt_wizard_runs ENABLE TRIGGER trg_enforce_prompt_wizard_run_engine_version;
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, main_credits_used, addon_credits_used,
|
||
image_asset_ids, product_profile, creative_brief, video_config, attempt_count
|
||
) VALUES
|
||
(
|
||
'${fixture.v6BatchId}', '${fixture.v6RunId}', '${fixture.v6UserId}', 1,
|
||
'prompt-refund-v6-${fixture.v6BatchId}', 'prompt-refund-v6', 1,
|
||
'processing', false, 1, 'held', 1, 0,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, 1
|
||
),
|
||
(
|
||
'${fixture.v5BatchId}', '${fixture.v5RunId}', '${fixture.v5UserId}', 1,
|
||
'prompt-refund-v5-${fixture.v5BatchId}', 'prompt-refund-v5', 1,
|
||
'processing', false, 1, 'held', 1, 0,
|
||
ARRAY['${fixture.imageId}'::uuid], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, 1
|
||
);
|
||
|
||
INSERT INTO credit_lots (
|
||
id, user_id, bucket, source_type, source_event_key,
|
||
original_amount, available_amount, held_amount, expires_at
|
||
) VALUES
|
||
(
|
||
'${fixture.v6LotId}', '${fixture.v6UserId}', 'main', 'subscription-cycle',
|
||
'prompt-refund-original-v6-${fixture.v6LotId}', 1, 0, 1, now() - interval '1 day'
|
||
),
|
||
(
|
||
'${fixture.v5LotId}', '${fixture.v5UserId}', 'main', 'subscription-cycle',
|
||
'prompt-refund-original-v5-${fixture.v5LotId}', 1, 0, 1, now() - interval '1 day'
|
||
);
|
||
|
||
INSERT INTO credit_allocations (
|
||
id, user_id, lot_id, business_event_key, purpose, status, amount,
|
||
related_prompt_batch_id
|
||
) VALUES
|
||
(
|
||
'${fixture.v6AllocationId}', '${fixture.v6UserId}', '${fixture.v6LotId}',
|
||
'prompt-wizard-hold:${fixture.v6BatchId}', 'prompt-wizard', 'held', 1,
|
||
'${fixture.v6BatchId}'
|
||
),
|
||
(
|
||
'${fixture.v5AllocationId}', '${fixture.v5UserId}', '${fixture.v5LotId}',
|
||
'prompt-wizard-hold:${fixture.v5BatchId}', 'prompt-wizard', 'held', 1,
|
||
'${fixture.v5BatchId}'
|
||
);
|
||
|
||
INSERT INTO credit_ledger (
|
||
id, user_id, type, bucket, settlement_status, amount, balance,
|
||
source, operator, note, related_prompt_batch_id, business_event_key
|
||
) VALUES
|
||
(
|
||
'${fixture.v6HoldLedgerId}', '${fixture.v6UserId}', 'hold', 'main', 'held', -1, 0,
|
||
'提示词方案生成预扣', 'system', 'V6 生成期间到期测试冻结',
|
||
'${fixture.v6BatchId}', 'prompt-wizard-hold:${fixture.v6BatchId}'
|
||
),
|
||
(
|
||
'${fixture.v5HoldLedgerId}', '${fixture.v5UserId}', 'hold', 'main', 'held', -1, 0,
|
||
'提示词方案生成预扣', 'system', 'V5 生成期间到期测试冻结',
|
||
'${fixture.v5BatchId}', 'prompt-wizard-hold:${fixture.v5BatchId}'
|
||
);
|
||
|
||
SET CONSTRAINTS ALL IMMEDIATE;
|
||
SET CONSTRAINTS ALL DEFERRED;
|
||
`;
|
||
const countSql = `
|
||
SELECT
|
||
(SELECT count(*)::integer FROM credit_lots WHERE user_id = $1) AS v6_lots,
|
||
(SELECT count(*)::integer FROM credit_ledger WHERE related_prompt_batch_id = $2) AS v6_ledgers,
|
||
(SELECT count(*)::integer FROM credit_lots WHERE user_id = $3) AS v5_lots,
|
||
(SELECT count(*)::integer FROM credit_ledger WHERE related_prompt_batch_id = $4) AS v5_ledgers
|
||
`;
|
||
const snapshotSql = `
|
||
SELECT jsonb_build_object(
|
||
'v6User', (
|
||
SELECT jsonb_build_object(
|
||
'main', main_credits, 'addon', addon_credits, 'total', total_credits,
|
||
'planResetAt', plan_reset_at::text
|
||
) FROM users WHERE id = $1::uuid
|
||
),
|
||
'v5User', (
|
||
SELECT jsonb_build_object(
|
||
'main', main_credits, 'addon', addon_credits, 'total', total_credits
|
||
) FROM users WHERE id = $2::uuid
|
||
),
|
||
'v6OriginalLot', (
|
||
SELECT jsonb_build_object(
|
||
'available', available_amount, 'held', held_amount, 'expired', expired_amount
|
||
) FROM credit_lots WHERE id = $3::uuid
|
||
),
|
||
'v5OriginalLot', (
|
||
SELECT jsonb_build_object(
|
||
'available', available_amount, 'held', held_amount, 'expired', expired_amount
|
||
) FROM credit_lots WHERE id = $4::uuid
|
||
),
|
||
'v6Replacement', (
|
||
SELECT jsonb_build_object(
|
||
'count', count(*),
|
||
'sourceType', max(source_type),
|
||
'bucket', max(bucket),
|
||
'original', COALESCE(sum(original_amount), 0),
|
||
'available', COALESCE(sum(available_amount), 0),
|
||
'held', COALESCE(sum(held_amount), 0),
|
||
'consumed', COALESCE(sum(consumed_amount), 0),
|
||
'expired', COALESCE(sum(expired_amount), 0),
|
||
'reversed', COALESCE(sum(reversed_amount), 0),
|
||
'expiresAt', max(expires_at)::date::text
|
||
)
|
||
FROM credit_lots
|
||
WHERE source_event_key = 'prompt-wizard-refund-replacement:'
|
||
|| $7::uuid::text || ':' || $5::uuid::text
|
||
),
|
||
'v5Replacement', (
|
||
SELECT jsonb_build_object('count', count(*))
|
||
FROM credit_lots
|
||
WHERE source_event_key = 'prompt-wizard-refund-replacement:'
|
||
|| $8::uuid::text || ':' || $6::uuid::text
|
||
),
|
||
'v6Allocation', (
|
||
SELECT jsonb_build_object('status', status, 'amount', amount)
|
||
FROM credit_allocations WHERE id = $5::uuid
|
||
),
|
||
'v5Allocation', (
|
||
SELECT jsonb_build_object('status', status, 'amount', amount)
|
||
FROM credit_allocations WHERE id = $6::uuid
|
||
),
|
||
'v6Batch', (
|
||
SELECT jsonb_build_object(
|
||
'status', status, 'creditStatus', credit_status, 'credits', credits,
|
||
'mainUsed', main_credits_used, 'addonUsed', addon_credits_used
|
||
) FROM prompt_wizard_batches WHERE id = $7::uuid
|
||
),
|
||
'v5Batch', (
|
||
SELECT jsonb_build_object(
|
||
'status', status, 'creditStatus', credit_status, 'credits', credits,
|
||
'mainUsed', main_credits_used, 'addonUsed', addon_credits_used
|
||
) FROM prompt_wizard_batches WHERE id = $8::uuid
|
||
),
|
||
'v6Ledger', (
|
||
SELECT jsonb_agg(jsonb_build_object(
|
||
'type', type, 'settlement', settlement_status, 'amount', amount,
|
||
'balance', balance, 'eventKey', business_event_key, 'note', note
|
||
) ORDER BY type)
|
||
FROM credit_ledger WHERE related_prompt_batch_id = $7::uuid
|
||
),
|
||
'v5Ledger', (
|
||
SELECT jsonb_agg(jsonb_build_object(
|
||
'type', type, 'settlement', settlement_status, 'amount', amount,
|
||
'balance', balance, 'eventKey', business_event_key, 'note', note
|
||
) ORDER BY type)
|
||
FROM credit_ledger WHERE related_prompt_batch_id = $8::uuid
|
||
)
|
||
) AS snapshot
|
||
`;
|
||
const encode = (value: string) => Buffer.from(value, 'utf8').toString('base64');
|
||
const script = `
|
||
const { releasePromptWizardCreditsWithClient } = await import('./apps/api/dist/domain/promptWizardCredits.js');
|
||
const { pool } = await import('./apps/api/dist/db/pool.js');
|
||
const fixture = ${JSON.stringify(fixture)};
|
||
const decode = value => Buffer.from(value, 'base64').toString('utf8');
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
await client.query(decode('${encode(setupSql)}'));
|
||
const v6First = await releasePromptWizardCreditsWithClient(
|
||
client, fixture.v6UserId, fixture.v6BatchId
|
||
);
|
||
// release 只处理账务;这里镜像 failPromptWizardBatch 在同一事务中的批次状态收敛。
|
||
await client.query(
|
||
"UPDATE prompt_wizard_batches SET status = 'failed', credit_status = 'refunded', failure_reason = 'V6 fullstack failure', completed_at = now(), updated_at = now() WHERE id = $1",
|
||
[fixture.v6BatchId]
|
||
);
|
||
const v5First = await releasePromptWizardCreditsWithClient(
|
||
client, fixture.v5UserId, fixture.v5BatchId
|
||
);
|
||
await client.query(
|
||
"UPDATE prompt_wizard_batches SET status = 'failed', credit_status = 'refunded', failure_reason = 'V5 fullstack failure', completed_at = now(), updated_at = now() WHERE id = $1",
|
||
[fixture.v5BatchId]
|
||
);
|
||
const countParams = [
|
||
fixture.v6UserId, fixture.v6BatchId, fixture.v5UserId, fixture.v5BatchId
|
||
];
|
||
const beforeRepeat = (await client.query(decode('${encode(countSql)}'), countParams)).rows[0];
|
||
const v6Second = await releasePromptWizardCreditsWithClient(
|
||
client, fixture.v6UserId, fixture.v6BatchId
|
||
);
|
||
const v5Second = await releasePromptWizardCreditsWithClient(
|
||
client, fixture.v5UserId, fixture.v5BatchId
|
||
);
|
||
const afterRepeat = (await client.query(decode('${encode(countSql)}'), countParams)).rows[0];
|
||
await client.query('SET CONSTRAINTS ALL IMMEDIATE');
|
||
const snapshotParams = [
|
||
fixture.v6UserId, fixture.v5UserId, fixture.v6LotId, fixture.v5LotId,
|
||
fixture.v6AllocationId, fixture.v5AllocationId, fixture.v6BatchId, fixture.v5BatchId
|
||
];
|
||
const snapshot = (await client.query(
|
||
decode('${encode(snapshotSql)}'), snapshotParams
|
||
)).rows[0].snapshot;
|
||
console.log('PROMPT_REFUND_RESULTS=' + JSON.stringify({
|
||
v6First, v6Second, v5First, v5Second, beforeRepeat, afterRepeat, snapshot
|
||
}));
|
||
} finally {
|
||
await client.query('ROLLBACK');
|
||
client.release();
|
||
await pool.end();
|
||
}
|
||
`;
|
||
const output = dockerComposeOutput(
|
||
'run', '--rm', '--no-deps', 'worker',
|
||
'node', '--input-type=module', '--eval', script,
|
||
);
|
||
const resultLine = output.split('\n').find(line => line.startsWith('PROMPT_REFUND_RESULTS='));
|
||
expect(resultLine).toBeTruthy();
|
||
const execution = JSON.parse(resultLine!.slice('PROMPT_REFUND_RESULTS='.length));
|
||
|
||
expect(execution.v6First).toEqual({ returnedCredits: 1, expiredCredits: 0, reissuedCredits: 1 });
|
||
expect(execution.v6Second).toEqual(execution.v6First);
|
||
expect(execution.v5First).toEqual({ returnedCredits: 0, expiredCredits: 1, reissuedCredits: 0 });
|
||
expect(execution.v5Second).toEqual(execution.v5First);
|
||
expect(execution.beforeRepeat).toEqual({ v6_lots: 2, v6_ledgers: 2, v5_lots: 1, v5_ledgers: 2 });
|
||
expect(execution.afterRepeat).toEqual(execution.beforeRepeat);
|
||
|
||
const snapshot = execution.snapshot;
|
||
expect(snapshot.v6User).toMatchObject({ main: 1, addon: 0, total: 1 });
|
||
expect(snapshot.v6Replacement).toEqual({
|
||
count: 1,
|
||
sourceType: 'admin-adjustment',
|
||
bucket: 'main',
|
||
original: 1,
|
||
available: 1,
|
||
held: 0,
|
||
consumed: 0,
|
||
expired: 0,
|
||
reversed: 0,
|
||
expiresAt: snapshot.v6User.planResetAt,
|
||
});
|
||
expect(snapshot.v6OriginalLot).toEqual({ available: 0, held: 0, expired: 1 });
|
||
expect(snapshot.v6Allocation).toEqual({ status: 'expired', amount: 1 });
|
||
expect(snapshot.v6Batch).toEqual({
|
||
status: 'failed', creditStatus: 'refunded', credits: 1, mainUsed: 1, addonUsed: 0,
|
||
});
|
||
expect(snapshot.v6Ledger).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
type: 'hold', settlement: 'refunded', amount: -1, balance: 0,
|
||
eventKey: `prompt-wizard-hold:${fixture.v6BatchId}`,
|
||
note: expect.stringContaining('等额补偿批次恢复'),
|
||
}),
|
||
expect.objectContaining({
|
||
type: 'refund', settlement: 'refunded', amount: 1, balance: 1,
|
||
eventKey: `prompt-wizard-refund:${fixture.v6BatchId}`,
|
||
note: expect.stringContaining('已全额退回'),
|
||
}),
|
||
]));
|
||
|
||
expect(snapshot.v5User).toEqual({ main: 0, addon: 0, total: 0 });
|
||
expect(snapshot.v5Replacement).toEqual({ count: 0 });
|
||
expect(snapshot.v5OriginalLot).toEqual({ available: 0, held: 0, expired: 1 });
|
||
expect(snapshot.v5Allocation).toEqual({ status: 'expired', amount: 1 });
|
||
expect(snapshot.v5Batch).toEqual({
|
||
status: 'failed', creditStatus: 'refunded', credits: 1, mainUsed: 1, addonUsed: 0,
|
||
});
|
||
expect(snapshot.v5Ledger).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
type: 'hold', settlement: 'refunded', amount: -1, balance: 0,
|
||
note: expect.stringContaining('已过期的积分未恢复'),
|
||
}),
|
||
expect.objectContaining({
|
||
type: 'refund', settlement: 'refunded', amount: 0, balance: 0,
|
||
eventKey: `prompt-wizard-refund:${fixture.v5BatchId}`,
|
||
note: expect.stringContaining('已过期积分未重新计入账户'),
|
||
}),
|
||
]));
|
||
});
|
||
|
||
test('Migration 076 在真实 PostgreSQL 上可重放并以六条 manifest 幂等归集 Lindyson Admin 运营成本', () => {
|
||
const migrationDirectory = new URL('../../apps/api/migrations/', import.meta.url);
|
||
const migration076 = readFileSync(
|
||
new URL('076_provider_operational_usage_ledger.sql', migrationDirectory),
|
||
'utf8',
|
||
);
|
||
const migration080 = readFileSync(
|
||
new URL('080_exact_admin_cost_pagination.sql', migrationDirectory),
|
||
'utf8',
|
||
);
|
||
const through075 = readdirSync(migrationDirectory)
|
||
.filter(file => /^\d{3}_[a-z0-9_]+\.sql$/u.test(file) && file < '076_')
|
||
.sort()
|
||
.map(file => ({
|
||
id: file,
|
||
sql: readFileSync(new URL(file, migrationDirectory), 'utf8'),
|
||
}));
|
||
const frozenRawManifest = readFileSync(new URL(
|
||
'../../apps/api/fixtures/provider-operational-usage/seedance-25-shadow-20260811.json',
|
||
import.meta.url,
|
||
));
|
||
const manifest = JSON.parse(frozenRawManifest.toString()) as {
|
||
manifestVersion: string;
|
||
manifestId: string;
|
||
generatedAt: string;
|
||
records: Array<{
|
||
costOwnerUserId: string;
|
||
sourceRunId: string;
|
||
sourceItemKey: string;
|
||
providerTaskId: string | null;
|
||
evidencePath: string;
|
||
evidenceSha256: string;
|
||
valuation: { evidenceSha256: string };
|
||
[key: string]: unknown;
|
||
}>;
|
||
};
|
||
const ownerUserId = manifest.records[0]?.costOwnerUserId;
|
||
expect(ownerUserId).toBe('00000000-0000-4000-8000-00000000ad01');
|
||
expect(manifest.records).toHaveLength(6);
|
||
|
||
const suffix = randomUUID().replaceAll('-', '').slice(0, 16);
|
||
const database = `m076_operational_${suffix}`;
|
||
const manifestObjectKey = `fullstack/provider-operational-usage/${suffix}.json`;
|
||
const conflictObjectKey = `fullstack/provider-operational-usage/${suffix}-conflict.json`;
|
||
const missingOriginalManifestObjectKey =
|
||
`fullstack/provider-operational-usage/${suffix}-missing-original.json`;
|
||
const missingOriginalApprovalObjectKey =
|
||
`fullstack/provider-operational-usage/${suffix}-missing-original-approval.json`;
|
||
const evidenceObjectKey = `fullstack/provider-operational-usage/${suffix}.evidence.ndjson`;
|
||
const evidenceBytes = Buffer.from(JSON.stringify({
|
||
kind: 'fullstack-provider-operational-usage-evidence/v1',
|
||
source: 'synthetic-isolated-test-only',
|
||
suffix,
|
||
}) + '\n');
|
||
const evidenceSha256 = createHash('sha256').update(evidenceBytes).digest('hex');
|
||
for (const record of manifest.records) {
|
||
record.evidencePath = `/app/storage/${evidenceObjectKey}`;
|
||
record.evidenceSha256 = evidenceSha256;
|
||
record.valuation.evidenceSha256 = evidenceSha256;
|
||
}
|
||
const rawManifest = Buffer.from(JSON.stringify(manifest, null, 2));
|
||
const databaseUrl = `postgres://lindyson:lindyson@postgres:5432/${database}`;
|
||
const manifestPath = `/app/storage/${manifestObjectKey}`;
|
||
const approvedSha256 = createHash('sha256').update(rawManifest).digest('hex');
|
||
const runImporter = (...args: string[]) => dockerComposeOutput(
|
||
'exec', '--no-TTY',
|
||
'-e', `DATABASE_URL=${databaseUrl}`,
|
||
'-e', `PROVIDER_OPERATIONAL_USAGE_COST_OWNER_USER_ID=${ownerUserId}`,
|
||
'api',
|
||
'node', 'apps/api/dist/tools/importProviderOperationalUsage.js',
|
||
'--manifest', manifestPath, ...args,
|
||
);
|
||
|
||
postgres(`CREATE DATABASE ${database};`);
|
||
writeApiStorageObject(manifestObjectKey, rawManifest.toString('base64'));
|
||
writeApiStorageObject(evidenceObjectKey, evidenceBytes.toString('base64'));
|
||
try {
|
||
postgresDatabaseMigrationSequence(database, through075);
|
||
postgresDatabaseScript(database, migration076);
|
||
postgresDatabaseScript(database, migration076);
|
||
// 下方直接调用当前成本报表 domain;其精准快照协议由 080 提供版本基础。
|
||
// 只补成本分页迁移,避免把与本场景无关的 077–079 行为混入 076 重放验证。
|
||
postgresDatabaseScript(database, migration080);
|
||
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT
|
||
(SELECT count(*) FROM pg_class
|
||
WHERE relkind = 'r'
|
||
AND relname IN (
|
||
'provider_operational_usage_records',
|
||
'provider_operational_usage_external_id_registry',
|
||
'provider_operational_usage_valuations',
|
||
'provider_bill_attribution_events'
|
||
))::text || '|' ||
|
||
(SELECT count(*) FROM pg_trigger
|
||
WHERE NOT tgisinternal
|
||
AND tgname IN (
|
||
'provider_operational_usage_records_immutable',
|
||
'provider_operational_usage_external_id_registry_immutable',
|
||
'provider_operational_usage_valuations_immutable',
|
||
'provider_bill_attribution_events_immutable',
|
||
'provider_bill_detail_revisions_immutable'
|
||
))::text;
|
||
`)).toBe('4|5');
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT count(*) FROM admin_cost_pagination_versions;
|
||
`)).toBe('4');
|
||
|
||
postgresDatabase(database, `
|
||
INSERT INTO users (
|
||
id, email, password_hash, user_name, role, status
|
||
) VALUES (
|
||
'${ownerUserId}', 'admin@lindyson.com', 'fullstack-only',
|
||
'Lindyson Admin', 'admin', 'active'
|
||
);
|
||
`);
|
||
|
||
for (const [sourceItemKey, evidenceAttestation] of [
|
||
['null-attestation', 'NULL'],
|
||
['empty-attestation', "'{}'::jsonb"],
|
||
] as const) {
|
||
const invalidAttestationFailure = postgresDatabaseScriptFailure(database, `
|
||
INSERT INTO provider_operational_usage_records (
|
||
manifest_version, manifest_id, manifest_sha256,
|
||
provider, service_type, model, operation, cost_owner_user_id,
|
||
purpose, initiator_type, source_run_id, source_item_key,
|
||
status, provider_reached, occurred_at, local_accounting_date,
|
||
usage_evidence, evidence_path, evidence_sha256,
|
||
evidence_status, evidence_attestation, recorded_at
|
||
) VALUES (
|
||
'lindyson.provider-operational-usage/v1',
|
||
'fullstack-invalid-attestation-${suffix}', '${'8'.repeat(64)}',
|
||
'volcengine-ark', 'video', 'Seedance 2.5', 'video-generation',
|
||
'${ownerUserId}', 'provider-diagnostic', 'maintenance-script',
|
||
'fullstack-invalid-attestation-${suffix}', '${sourceItemKey}',
|
||
'success', true, '2026-01-01 00:00:00+00', '2026-01-01',
|
||
'{"source":"fullstack-invalid-attestation"}'::jsonb,
|
||
'/fullstack/invalid-attestation/evidence.ndjson', '${'7'.repeat(64)}',
|
||
'admin-confirmed-missing-original', ${evidenceAttestation}, clock_timestamp()
|
||
);
|
||
`);
|
||
expect(invalidAttestationFailure)
|
||
.toContain('provider_operational_usage_evidence_status_check');
|
||
}
|
||
|
||
const dryRun = parseLastJsonLine(runImporter()) as Record<string, unknown>;
|
||
expect(dryRun).toMatchObject({
|
||
event: 'provider-operational-usage-import-dry-run',
|
||
dryRun: true,
|
||
recordCount: 6,
|
||
pricedRecordCount: 6,
|
||
insertedRecordCount: 0,
|
||
unchangedRecordCount: 0,
|
||
});
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT count(*) FROM provider_operational_usage_records;
|
||
`)).toBe('0');
|
||
|
||
const firstApply = parseLastJsonLine(runImporter(
|
||
'--apply', '--manifest-sha256', approvedSha256,
|
||
)) as Record<string, unknown>;
|
||
expect(firstApply).toMatchObject({
|
||
event: 'provider-operational-usage-import-applied',
|
||
dryRun: false,
|
||
recordCount: 6,
|
||
insertedRecordCount: 6,
|
||
unchangedRecordCount: 0,
|
||
});
|
||
|
||
const secondApply = parseLastJsonLine(runImporter(
|
||
'--apply', '--manifest-sha256', approvedSha256,
|
||
)) as Record<string, unknown>;
|
||
expect(secondApply).toMatchObject({
|
||
event: 'provider-operational-usage-import-applied',
|
||
dryRun: false,
|
||
recordCount: 6,
|
||
insertedRecordCount: 0,
|
||
unchangedRecordCount: 6,
|
||
});
|
||
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT
|
||
(SELECT count(*) FROM provider_operational_usage_records
|
||
WHERE manifest_sha256 = '${approvedSha256}')::text || '|' ||
|
||
(SELECT count(*) FROM provider_operational_usage_valuations valuation
|
||
JOIN provider_operational_usage_records usage
|
||
ON usage.id = valuation.operational_usage_record_id
|
||
WHERE usage.manifest_sha256 = '${approvedSha256}')::text || '|' ||
|
||
(SELECT coalesce(sum(valuation.estimated_cost_cny), 0)::numeric(20,6)
|
||
FROM provider_operational_usage_valuations valuation
|
||
JOIN provider_operational_usage_records usage
|
||
ON usage.id = valuation.operational_usage_record_id
|
||
WHERE usage.manifest_sha256 = '${approvedSha256}')::text || '|' ||
|
||
(SELECT count(*) FROM audit_logs
|
||
WHERE target = 'manifest-sha256:${approvedSha256}')::text || '|' ||
|
||
(SELECT count(*) FROM generation_tasks)::text || '|' ||
|
||
(SELECT count(*) FROM credit_ledger)::text;
|
||
`)).toBe('6|6|63.037310|1|0|0');
|
||
|
||
const missingOriginalManifest = structuredClone(manifest);
|
||
missingOriginalManifest.manifestId = `fullstack-missing-original-${suffix}`;
|
||
missingOriginalManifest.generatedAt = '2026-08-12T00:00:00.000Z';
|
||
const missingOriginalRecord = structuredClone(manifest.records[0]!);
|
||
missingOriginalRecord.sourceRunId = `fullstack-missing-original-${suffix}`;
|
||
missingOriginalRecord.sourceItemKey = 'admin-confirmed-cost-only';
|
||
missingOriginalRecord.providerTaskId = null;
|
||
missingOriginalRecord.providerOperationId = null;
|
||
missingOriginalRecord.providerRequestId = null;
|
||
missingOriginalRecord.evidencePath =
|
||
`/app/storage/fullstack/provider-operational-usage/${suffix}-unavailable.ndjson`;
|
||
missingOriginalManifest.records = [missingOriginalRecord];
|
||
const rawMissingOriginalManifest = Buffer.from(
|
||
JSON.stringify(missingOriginalManifest, null, 2),
|
||
);
|
||
const missingOriginalManifestSha256 = createHash('sha256')
|
||
.update(rawMissingOriginalManifest)
|
||
.digest('hex');
|
||
const missingOriginalApproval = {
|
||
approvalVersion: 'lindyson.provider-operational-usage-missing-evidence-approval/v1',
|
||
approvalId: `fullstack-missing-original-${suffix}-admin-confirmation-v1`,
|
||
manifestSha256: missingOriginalManifestSha256,
|
||
approvedByUserId: ownerUserId,
|
||
approvedAt: '2026-08-12T02:32:09.000Z',
|
||
reason: 'historical-original-evidence-unavailable',
|
||
note: '全栈隔离测试:管理员确认缺少的历史原始证据只计入 Admin 列表价运营成本。',
|
||
decision: {
|
||
countAsAdminOperationalCost: true,
|
||
allowProviderExternalIdClaims: false,
|
||
allowOfficialBillAttribution: false,
|
||
},
|
||
records: [{
|
||
provider: missingOriginalRecord.provider,
|
||
sourceRunId: missingOriginalRecord.sourceRunId,
|
||
sourceItemKey: missingOriginalRecord.sourceItemKey,
|
||
evidencePath: missingOriginalRecord.evidencePath,
|
||
evidenceSha256: missingOriginalRecord.evidenceSha256,
|
||
}],
|
||
};
|
||
const rawMissingOriginalApproval = Buffer.from(
|
||
JSON.stringify(missingOriginalApproval, null, 2),
|
||
);
|
||
const missingOriginalApprovalSha256 = createHash('sha256')
|
||
.update(rawMissingOriginalApproval)
|
||
.digest('hex');
|
||
writeApiStorageObject(
|
||
missingOriginalManifestObjectKey,
|
||
rawMissingOriginalManifest.toString('base64'),
|
||
);
|
||
writeApiStorageObject(
|
||
missingOriginalApprovalObjectKey,
|
||
rawMissingOriginalApproval.toString('base64'),
|
||
);
|
||
const missingOriginalApply = parseLastJsonLine(dockerComposeOutput(
|
||
'exec', '--no-TTY',
|
||
'-e', `DATABASE_URL=${databaseUrl}`,
|
||
'-e', `PROVIDER_OPERATIONAL_USAGE_COST_OWNER_USER_ID=${ownerUserId}`,
|
||
'api',
|
||
'node', 'apps/api/dist/tools/importProviderOperationalUsage.js',
|
||
'--manifest', `/app/storage/${missingOriginalManifestObjectKey}`,
|
||
'--admin-confirm-missing-original-evidence',
|
||
`/app/storage/${missingOriginalApprovalObjectKey}`,
|
||
'--admin-confirm-missing-original-evidence-sha256', missingOriginalApprovalSha256,
|
||
'--apply', '--manifest-sha256', missingOriginalManifestSha256,
|
||
)) as Record<string, unknown>;
|
||
expect(missingOriginalApply).toMatchObject({
|
||
event: 'provider-operational-usage-import-applied',
|
||
dryRun: false,
|
||
recordCount: 1,
|
||
pricedRecordCount: 1,
|
||
verifiedEvidenceRecordCount: 0,
|
||
adminConfirmedMissingOriginalRecordCount: 1,
|
||
adminConfirmationSha256: missingOriginalApprovalSha256,
|
||
insertedRecordCount: 1,
|
||
unchangedRecordCount: 0,
|
||
});
|
||
const missingOriginalSecondApply = parseLastJsonLine(dockerComposeOutput(
|
||
'exec', '--no-TTY',
|
||
'-e', `DATABASE_URL=${databaseUrl}`,
|
||
'-e', `PROVIDER_OPERATIONAL_USAGE_COST_OWNER_USER_ID=${ownerUserId}`,
|
||
'api',
|
||
'node', 'apps/api/dist/tools/importProviderOperationalUsage.js',
|
||
'--manifest', `/app/storage/${missingOriginalManifestObjectKey}`,
|
||
'--admin-confirm-missing-original-evidence',
|
||
`/app/storage/${missingOriginalApprovalObjectKey}`,
|
||
'--admin-confirm-missing-original-evidence-sha256', missingOriginalApprovalSha256,
|
||
'--apply', '--manifest-sha256', missingOriginalManifestSha256,
|
||
)) as Record<string, unknown>;
|
||
expect(missingOriginalSecondApply).toMatchObject({
|
||
event: 'provider-operational-usage-import-applied',
|
||
insertedRecordCount: 0,
|
||
unchangedRecordCount: 1,
|
||
adminConfirmedMissingOriginalRecordCount: 1,
|
||
});
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT
|
||
(SELECT count(*) FROM provider_operational_usage_records
|
||
WHERE manifest_sha256 = '${missingOriginalManifestSha256}'
|
||
AND evidence_status = 'admin-confirmed-missing-original'
|
||
AND historical_call_fingerprint_sha256 ~ '^[0-9a-f]{64}$'
|
||
AND evidence_attestation->>'manifestSha256' = '${missingOriginalManifestSha256}')::text || '|' ||
|
||
(SELECT coalesce(sum(valuation.estimated_cost_cny), 0)::numeric(20,6)
|
||
FROM provider_operational_usage_valuations valuation
|
||
JOIN provider_operational_usage_records usage
|
||
ON usage.id = valuation.operational_usage_record_id
|
||
WHERE usage.manifest_sha256 = '${missingOriginalManifestSha256}')::text || '|' ||
|
||
(SELECT count(*) FROM provider_bill_attribution_events attribution
|
||
JOIN provider_operational_usage_records usage
|
||
ON usage.id = attribution.operational_usage_record_id
|
||
WHERE usage.manifest_sha256 = '${missingOriginalManifestSha256}')::text || '|' ||
|
||
(SELECT count(*) FROM audit_logs
|
||
WHERE target = 'manifest-sha256:${missingOriginalManifestSha256}'
|
||
AND detail::jsonb->>'adminConfirmationSha256' = '${missingOriginalApprovalSha256}')::text;
|
||
`)).toBe('1|3.390590|0|1');
|
||
|
||
const billRunId = randomUUID();
|
||
const linkedBillDetailId = randomUUID();
|
||
const fuzzyBillDetailId = randomUUID();
|
||
const linkedRevisionId = randomUUID();
|
||
const fuzzyRevisionId = randomUUID();
|
||
const attributionEventId = randomUUID();
|
||
const operationalUsageRecordId = postgresDatabaseScalar(database, `
|
||
SELECT id::text
|
||
FROM provider_operational_usage_records
|
||
WHERE manifest_sha256 = '${approvedSha256}'
|
||
ORDER BY occurred_at, id
|
||
LIMIT 1;
|
||
`);
|
||
const linkedPayload = JSON.stringify({
|
||
provider: 'volcengine-ark',
|
||
sourceDetailKey: `fullstack-linked-${suffix}`,
|
||
billPeriod: '2026-08',
|
||
expenseDate: '2026-08-11',
|
||
product: 'ark_bd',
|
||
productZh: '豆包大模型',
|
||
billingMode: '后付费',
|
||
billCategory: '消费',
|
||
instanceNo: null,
|
||
configName: 'Seedance 2.5 exact-token candidate',
|
||
usageCount: '48437.000000',
|
||
usageUnit: 'tokens',
|
||
deductionCount: '0.000000',
|
||
originalBillAmountCny: '3.390590',
|
||
preferentialBillAmountCny: '0.100000',
|
||
discountBillAmountCny: '0.200000',
|
||
couponAmountCny: '0.300000',
|
||
payableAmountCny: '2.790590',
|
||
paidAmountCny: '2.000000',
|
||
unpaidAmountCny: '0.790590',
|
||
roundAmountCny: '0.000000',
|
||
currency: 'CNY',
|
||
recognizedModel: 'Seedance 2.5',
|
||
billLineType: 'usage',
|
||
});
|
||
const fuzzyPayload = JSON.stringify({
|
||
...JSON.parse(linkedPayload) as Record<string, unknown>,
|
||
sourceDetailKey: `fullstack-fuzzy-${suffix}`,
|
||
configName: 'Seedance 2.5 fuzzy-only exact-token candidate',
|
||
preferentialBillAmountCny: '0.000000',
|
||
discountBillAmountCny: '0.000000',
|
||
couponAmountCny: '0.000000',
|
||
payableAmountCny: '3.390590',
|
||
paidAmountCny: '3.390590',
|
||
unpaidAmountCny: '0.000000',
|
||
});
|
||
postgresDatabase(database, `
|
||
INSERT INTO provider_billing_sync_runs (
|
||
id, provider, kind, status, window_start, window_end,
|
||
page_count, row_count, started_at, completed_at, is_final
|
||
) VALUES (
|
||
'${billRunId}', 'volcengine-ark', 'bill', 'success',
|
||
'2026-08-11', '2026-08-11', 1, 2,
|
||
clock_timestamp() - interval '10 seconds',
|
||
clock_timestamp() - interval '9 seconds', true
|
||
);
|
||
|
||
INSERT INTO provider_bill_detail_revisions (
|
||
id, provider, source_detail_key, sync_run_id, payload_hash,
|
||
raw_payload, normalized_payload, normalization_version, observed_at
|
||
) VALUES
|
||
(
|
||
'${linkedRevisionId}', 'volcengine-ark', 'fullstack-linked-${suffix}',
|
||
'${billRunId}', '${'a'.repeat(64)}', '{}'::jsonb,
|
||
'${linkedPayload.replaceAll("'", "''")}'::jsonb,
|
||
'fullstack-v1', clock_timestamp() - interval '8 seconds'
|
||
),
|
||
(
|
||
'${fuzzyRevisionId}', 'volcengine-ark', 'fullstack-fuzzy-${suffix}',
|
||
'${billRunId}', '${'b'.repeat(64)}', '{}'::jsonb,
|
||
'${fuzzyPayload.replaceAll("'", "''")}'::jsonb,
|
||
'fullstack-v1', clock_timestamp() - interval '8 seconds'
|
||
);
|
||
|
||
INSERT INTO provider_bill_details (
|
||
id, provider, source_detail_key, bill_period, expense_date,
|
||
product, product_zh, billing_mode, bill_category, config_name,
|
||
usage_count, usage_unit, deduction_count,
|
||
original_bill_amount_cny, preferential_bill_amount_cny,
|
||
discount_bill_amount_cny, coupon_amount_cny, payable_amount_cny,
|
||
paid_amount_cny, unpaid_amount_cny, round_amount_cny, currency,
|
||
recognized_model, is_final, active, raw_payload, last_seen_run_id,
|
||
synced_at, bill_line_type, normalization_version, current_revision_id
|
||
) VALUES
|
||
(
|
||
'${linkedBillDetailId}', 'volcengine-ark', 'fullstack-linked-${suffix}',
|
||
'2026-08', '2026-08-11', 'ark_bd', '豆包大模型', '后付费', '消费',
|
||
'Seedance 2.5 exact-token candidate', 48437, 'tokens', 0,
|
||
3.390590, 0.100000, 0.200000, 0.300000, 2.790590,
|
||
2.000000, 0.790590, 0, 'CNY', 'Seedance 2.5', true, true,
|
||
'{}'::jsonb, '${billRunId}', clock_timestamp() - interval '8 seconds',
|
||
'usage', 'fullstack-v1', '${linkedRevisionId}'
|
||
),
|
||
(
|
||
'${fuzzyBillDetailId}', 'volcengine-ark', 'fullstack-fuzzy-${suffix}',
|
||
'2026-08', '2026-08-11', 'ark_bd', '豆包大模型', '后付费', '消费',
|
||
'Seedance 2.5 fuzzy-only exact-token candidate', 48437, 'tokens', 0,
|
||
3.390590, 0, 0, 0, 3.390590,
|
||
3.390590, 0, 0, 'CNY', 'Seedance 2.5', true, true,
|
||
'{}'::jsonb, '${billRunId}', clock_timestamp() - interval '8 seconds',
|
||
'usage', 'fullstack-v1', '${fuzzyRevisionId}'
|
||
);
|
||
|
||
INSERT INTO provider_bill_sync_run_items (
|
||
sync_run_id, bill_detail_revision_id, source_detail_key, created_at
|
||
) VALUES
|
||
(
|
||
'${billRunId}', '${linkedRevisionId}', 'fullstack-linked-${suffix}',
|
||
clock_timestamp() - interval '7 seconds'
|
||
),
|
||
(
|
||
'${billRunId}', '${fuzzyRevisionId}', 'fullstack-fuzzy-${suffix}',
|
||
clock_timestamp() - interval '7 seconds'
|
||
);
|
||
|
||
INSERT INTO provider_bill_attribution_events (
|
||
id, attribution_key, event_type, operational_usage_record_id,
|
||
provider_bill_detail_id, provider_bill_detail_revision_id,
|
||
allocated_billable_tokens, allocated_usage_count, allocated_cost_cny,
|
||
currency, confidence, evidence, evidence_sha256, recorded_at
|
||
) VALUES (
|
||
'${attributionEventId}', 'fullstack-attribution-${suffix}', 'link',
|
||
'${operationalUsageRecordId}', '${linkedBillDetailId}', '${linkedRevisionId}',
|
||
48437, 48437, 2.790590, 'CNY', 'direct',
|
||
'{"source":"fullstack-explicit-link"}'::jsonb, '${'c'.repeat(64)}',
|
||
clock_timestamp()
|
||
);
|
||
`);
|
||
|
||
const missingOriginalUsageRecordId = postgresDatabaseScalar(database, `
|
||
SELECT id::text
|
||
FROM provider_operational_usage_records
|
||
WHERE manifest_sha256 = '${missingOriginalManifestSha256}';
|
||
`);
|
||
const missingOriginalAttributionFailure = postgresDatabaseScriptFailure(database, `
|
||
INSERT INTO provider_bill_attribution_events (
|
||
attribution_key, event_type, operational_usage_record_id,
|
||
provider_bill_detail_id, provider_bill_detail_revision_id,
|
||
allocated_billable_tokens, allocated_usage_count, allocated_cost_cny,
|
||
currency, confidence, evidence, evidence_sha256, recorded_at
|
||
) VALUES (
|
||
'fullstack-missing-original-forbidden-${suffix}', 'link',
|
||
'${missingOriginalUsageRecordId}', '${linkedBillDetailId}', '${linkedRevisionId}',
|
||
48437, 48437, 2.790590, 'CNY', 'direct',
|
||
'{"source":"fullstack-forbidden-missing-original-link"}'::jsonb,
|
||
'${'9'.repeat(64)}', clock_timestamp()
|
||
);
|
||
`);
|
||
expect(missingOriginalAttributionFailure)
|
||
.toContain('管理员确认的缺文件历史运营调用只能计入列表价成本,不得归属官方账单');
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT count(*)
|
||
FROM provider_bill_attribution_events attribution
|
||
JOIN provider_operational_usage_records usage
|
||
ON usage.id = attribution.operational_usage_record_id
|
||
WHERE usage.manifest_sha256 = '${missingOriginalManifestSha256}';
|
||
`)).toBe('0');
|
||
|
||
const attributionRecordedAt = postgresDatabaseScalar(database, `
|
||
SELECT to_char(
|
||
recorded_at AT TIME ZONE 'UTC',
|
||
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
||
)
|
||
FROM provider_bill_attribution_events
|
||
WHERE id = '${attributionEventId}';
|
||
`);
|
||
const attributionRecordedMs = Date.parse(attributionRecordedAt);
|
||
expect(attributionRecordedMs).not.toBeNaN();
|
||
const beforeAttributionAsOf = new Date(attributionRecordedMs - 1).toISOString();
|
||
const afterAttributionAsOf = new Date(attributionRecordedMs + 1).toISOString();
|
||
const operationalReadAsOf = new Date().toISOString();
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT count(*)
|
||
FROM provider_operational_usage_records
|
||
WHERE local_accounting_date = '2026-08-12'
|
||
AND recorded_at <= '${operationalReadAsOf}'::timestamptz;
|
||
`)).toBe('7');
|
||
const reportingScript = `
|
||
import { pool } from './apps/api/dist/db/pool.js';
|
||
import {
|
||
getAdminProviderBillingOverview,
|
||
listAdminProviderBillingDetails,
|
||
listAdminProviderOperationalUsage
|
||
} from './apps/api/dist/domain/providerBillingReporting.js';
|
||
try {
|
||
const before = await listAdminProviderBillingDetails(pool, {
|
||
range: 'date', date: '2026-08-11', asOf: '${beforeAttributionAsOf}', limit: 20
|
||
});
|
||
const after = await listAdminProviderBillingDetails(pool, {
|
||
range: 'date', date: '2026-08-11', asOf: '${afterAttributionAsOf}', limit: 20
|
||
});
|
||
const overview = await getAdminProviderBillingOverview(pool, {
|
||
range: 'date', date: '2026-08-11', asOf: '${afterAttributionAsOf}'
|
||
});
|
||
const operations = await listAdminProviderOperationalUsage(pool, {
|
||
range: 'date', date: '2026-08-12', asOf: '${operationalReadAsOf}', limit: 20
|
||
});
|
||
console.log('PROVIDER_BILLING_REPORTS=' + JSON.stringify({
|
||
before, after, overview, operations
|
||
}));
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
`;
|
||
const reportingOutput = dockerComposeOutput(
|
||
'exec', '--no-TTY', '-e', `DATABASE_URL=${databaseUrl}`, 'api',
|
||
'node', '--input-type=module', '--eval', reportingScript,
|
||
);
|
||
const reportingLine = reportingOutput.split('\n')
|
||
.find(line => line.startsWith('PROVIDER_BILLING_REPORTS='));
|
||
expect(reportingLine).toBeTruthy();
|
||
const reporting = JSON.parse(
|
||
reportingLine!.slice('PROVIDER_BILLING_REPORTS='.length),
|
||
) as {
|
||
before: { items: Array<{ sourceDetailKey: string; attributionStatus: string }> };
|
||
after: { items: Array<{ sourceDetailKey: string; attributionStatus: string }> };
|
||
overview: {
|
||
summary: { originalBillAmountCny: string; payableAmountCny: string };
|
||
byAttribution: Array<{ attributionStatus: string; lineCount: number }>;
|
||
};
|
||
operations: {
|
||
items: Array<{
|
||
id: string;
|
||
localAccountingDate: string;
|
||
evidenceStatus: string | null;
|
||
officialSourceDetailKey: string | null;
|
||
officialAttributionCount: number;
|
||
}>;
|
||
};
|
||
};
|
||
expect(reporting.before.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-linked-${suffix}`,
|
||
attributionStatus: 'unattributed',
|
||
}),
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-fuzzy-${suffix}`,
|
||
attributionStatus: 'unattributed',
|
||
}),
|
||
]));
|
||
expect(reporting.after.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-linked-${suffix}`,
|
||
attributionStatus: 'admin-operational',
|
||
}),
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-fuzzy-${suffix}`,
|
||
attributionStatus: 'unattributed',
|
||
}),
|
||
]));
|
||
expect(reporting.overview.summary).toMatchObject({
|
||
originalBillAmountCny: '6.781180',
|
||
payableAmountCny: '6.181180',
|
||
});
|
||
expect(reporting.overview.byAttribution).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ attributionStatus: 'admin-operational', lineCount: 1 }),
|
||
expect.objectContaining({ attributionStatus: 'unattributed', lineCount: 1 }),
|
||
]));
|
||
expect(reporting.operations.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
id: operationalUsageRecordId,
|
||
localAccountingDate: '2026-08-12',
|
||
officialSourceDetailKey: `fullstack-linked-${suffix}`,
|
||
officialAttributionCount: 1,
|
||
}),
|
||
expect.objectContaining({
|
||
id: missingOriginalUsageRecordId,
|
||
evidenceStatus: 'admin-confirmed-missing-original',
|
||
officialSourceDetailKey: null,
|
||
officialAttributionCount: 0,
|
||
}),
|
||
]));
|
||
|
||
const linkedLegacyUsageId = randomUUID();
|
||
const fuzzyLegacyUsageId = randomUUID();
|
||
// 两套历史账本允许相同 UUID;这是没有真实外部 ID 时唯一可证明的同一事实。
|
||
// 不使用 manifest 中的 SHA 前缀做模糊去重。
|
||
const duplicateLegacyUsageId = operationalUsageRecordId;
|
||
const linkedLegacyCaseId = randomUUID();
|
||
const fuzzyLegacyCaseId = randomUUID();
|
||
const linkedLegacyLinkId = randomUUID();
|
||
const fuzzyLegacyLinkId = randomUUID();
|
||
postgresDatabase(database, `
|
||
INSERT INTO provider_usage_records (
|
||
id, provider, model, task_id, initiated_by_admin_user_id,
|
||
status, provider_task_id, completed_at, usage_observed_at,
|
||
local_accounting_date, created_at
|
||
) VALUES
|
||
(
|
||
'${linkedLegacyUsageId}', 'volcengine-ark', 'Seedance 2.0', NULL,
|
||
'${ownerUserId}', 'success', 'fullstack-legacy-linked-${suffix}',
|
||
'2026-08-12 01:00:00+00', '2026-08-12 01:00:00+00',
|
||
'2026-08-12', '2026-08-12 01:00:01+00'
|
||
),
|
||
(
|
||
'${fuzzyLegacyUsageId}', 'volcengine-ark', 'Seedance 2.0', NULL,
|
||
'${ownerUserId}', 'success', 'fullstack-legacy-fuzzy-${suffix}',
|
||
'2026-08-12 01:01:00+00', '2026-08-12 01:01:00+00',
|
||
'2026-08-12', '2026-08-12 01:01:01+00'
|
||
),
|
||
(
|
||
'${duplicateLegacyUsageId}', 'volcengine-ark', 'Seedance 2.5', NULL,
|
||
'${ownerUserId}', 'success', NULL,
|
||
'2026-08-12 01:02:00+00', '2026-08-12 01:02:00+00',
|
||
'2026-08-12', '2026-08-12 01:02:01+00'
|
||
);
|
||
|
||
INSERT INTO provider_reconciliation_cases (
|
||
id, provider, case_key, bill_period, case_type, status, root_cause,
|
||
confidence, token_adjustment, cost_adjustment_cny,
|
||
official_expense_date, local_accounting_date, evidence, evidence_hash
|
||
) VALUES
|
||
(
|
||
'${linkedLegacyCaseId}', 'volcengine-ark', 'fullstack-legacy-linked-${suffix}',
|
||
'2026-08', 'other', 'approved', 'legacy-admin-direct-explicit-link',
|
||
'direct', 48437, 2.790590, '2026-08-11', '2026-08-12',
|
||
'{"source":"fullstack-legacy-admin-direct"}'::jsonb, '${'d'.repeat(64)}'
|
||
),
|
||
(
|
||
'${fuzzyLegacyCaseId}', 'volcengine-ark', 'fullstack-legacy-fuzzy-${suffix}',
|
||
'2026-08', 'other', 'approved', 'legacy-admin-direct-explicit-link',
|
||
'direct', 48437, 3.390590, '2026-08-11', '2026-08-12',
|
||
'{"source":"fullstack-legacy-admin-direct"}'::jsonb, '${'e'.repeat(64)}'
|
||
);
|
||
|
||
INSERT INTO provider_reconciliation_case_links (
|
||
id, case_id, usage_record_id, bill_detail_id,
|
||
bill_detail_revision_id, allocated_tokens, allocated_cost_cny
|
||
) VALUES
|
||
(
|
||
'${linkedLegacyLinkId}', '${linkedLegacyCaseId}', '${linkedLegacyUsageId}',
|
||
'${linkedBillDetailId}', '${linkedRevisionId}', 48437, 2.790590
|
||
),
|
||
(
|
||
'${fuzzyLegacyLinkId}', '${fuzzyLegacyCaseId}', '${fuzzyLegacyUsageId}',
|
||
'${fuzzyBillDetailId}', '${fuzzyRevisionId}', 48437, 3.390590
|
||
);
|
||
`);
|
||
const legacyAdminReadAsOf = new Date().toISOString();
|
||
const legacyAdminReportingScript = `
|
||
import { pool } from './apps/api/dist/db/pool.js';
|
||
import {
|
||
getAdminProviderBillingOverview,
|
||
listAdminProviderBillingDetails,
|
||
listAdminProviderOperationalUsage
|
||
} from './apps/api/dist/domain/providerBillingReporting.js';
|
||
try {
|
||
const details = await listAdminProviderBillingDetails(pool, {
|
||
range: 'date', date: '2026-08-11', asOf: '${legacyAdminReadAsOf}', limit: 20
|
||
});
|
||
const overview = await getAdminProviderBillingOverview(pool, {
|
||
range: 'date', date: '2026-08-11', asOf: '${legacyAdminReadAsOf}'
|
||
});
|
||
const operations = await listAdminProviderOperationalUsage(pool, {
|
||
range: 'date', date: '2026-08-12', asOf: '${legacyAdminReadAsOf}', limit: 20
|
||
});
|
||
console.log('LEGACY_ADMIN_BILLING_REPORTS=' + JSON.stringify({ details, overview, operations }));
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
`;
|
||
const legacyAdminOutput = dockerComposeOutput(
|
||
'exec', '--no-TTY', '-e', `DATABASE_URL=${databaseUrl}`, 'api',
|
||
'node', '--input-type=module', '--eval', legacyAdminReportingScript,
|
||
);
|
||
const legacyAdminLine = legacyAdminOutput.split('\n')
|
||
.find(line => line.startsWith('LEGACY_ADMIN_BILLING_REPORTS='));
|
||
expect(legacyAdminLine).toBeTruthy();
|
||
const legacyAdminReporting = JSON.parse(
|
||
legacyAdminLine!.slice('LEGACY_ADMIN_BILLING_REPORTS='.length),
|
||
) as {
|
||
details: {
|
||
items: Array<{
|
||
sourceDetailKey: string;
|
||
attributionStatus: string;
|
||
attributionEvidenceId: string | null;
|
||
}>;
|
||
};
|
||
overview: {
|
||
summary: { lineCount: number };
|
||
byAttribution: Array<{ attributionStatus: string; lineCount: number }>;
|
||
};
|
||
operations: {
|
||
items: Array<{
|
||
id: string;
|
||
origin: string;
|
||
evidenceSha256: string | null;
|
||
manifestSha256: string | null;
|
||
}>;
|
||
};
|
||
};
|
||
expect(legacyAdminReporting.details.items).toHaveLength(2);
|
||
expect(legacyAdminReporting.details.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-linked-${suffix}`,
|
||
attributionStatus: 'admin-operational',
|
||
attributionEvidenceId: attributionEventId,
|
||
}),
|
||
expect.objectContaining({
|
||
sourceDetailKey: `fullstack-fuzzy-${suffix}`,
|
||
attributionStatus: 'admin-operational',
|
||
attributionEvidenceId: fuzzyLegacyLinkId,
|
||
}),
|
||
]));
|
||
expect(legacyAdminReporting.overview.summary.lineCount).toBe(2);
|
||
expect(legacyAdminReporting.overview.byAttribution).toEqual([
|
||
expect.objectContaining({ attributionStatus: 'admin-operational', lineCount: 2 }),
|
||
]);
|
||
expect(legacyAdminReporting.operations.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
id: linkedLegacyUsageId,
|
||
origin: 'legacy-admin-direct',
|
||
evidenceSha256: null,
|
||
manifestSha256: null,
|
||
}),
|
||
expect.objectContaining({
|
||
id: fuzzyLegacyUsageId,
|
||
origin: 'legacy-admin-direct',
|
||
evidenceSha256: null,
|
||
manifestSha256: null,
|
||
}),
|
||
]));
|
||
expect(legacyAdminReporting.operations.items.filter(item => item.id === duplicateLegacyUsageId)).toEqual([
|
||
expect.objectContaining({ origin: 'manifest-v1' }),
|
||
]);
|
||
|
||
const updateFailure = postgresDatabaseScriptFailure(database, `
|
||
UPDATE provider_operational_usage_records
|
||
SET operation = 'tampered'
|
||
WHERE manifest_sha256 = '${approvedSha256}';
|
||
`);
|
||
expect(updateFailure).toContain('运营调用、估值和账单归属均为不可变事实');
|
||
const deleteFailure = postgresDatabaseScriptFailure(database, `
|
||
DELETE FROM provider_operational_usage_valuations
|
||
WHERE operational_usage_record_id IN (
|
||
SELECT id FROM provider_operational_usage_records
|
||
WHERE manifest_sha256 = '${approvedSha256}'
|
||
);
|
||
`);
|
||
expect(deleteFailure).toContain('运营调用、估值和账单归属均为不可变事实');
|
||
const revisionUpdateFailure = postgresDatabaseScriptFailure(database, `
|
||
UPDATE provider_bill_detail_revisions
|
||
SET normalized_payload = '{"tampered":true}'::jsonb
|
||
WHERE id = '${linkedRevisionId}';
|
||
`);
|
||
expect(revisionUpdateFailure).toContain('运营调用、估值和账单归属均为不可变事实');
|
||
const revisionDeleteFailure = postgresDatabaseScriptFailure(database, `
|
||
DELETE FROM provider_bill_detail_revisions
|
||
WHERE id = '${fuzzyRevisionId}';
|
||
`);
|
||
expect(revisionDeleteFailure).toContain('运营调用、估值和账单归属均为不可变事实');
|
||
|
||
const concurrentExternalId = `fullstack-cross-slot-${suffix}`;
|
||
const concurrentRecordA = randomUUID();
|
||
const concurrentRecordB = randomUUID();
|
||
const concurrentInsert = (
|
||
recordId: string,
|
||
sourceItemKey: string,
|
||
identifierColumn: 'provider_task_id' | 'provider_operation_id',
|
||
) => `
|
||
BEGIN ISOLATION LEVEL REPEATABLE READ;
|
||
SELECT count(*) FROM provider_operational_usage_records;
|
||
SELECT pg_sleep(0.2);
|
||
INSERT INTO provider_operational_usage_records (
|
||
id, manifest_version, manifest_id, manifest_sha256,
|
||
provider, service_type, model, operation, cost_owner_user_id,
|
||
purpose, initiator_type, source_run_id, source_item_key,
|
||
${identifierColumn}, status, provider_reached, occurred_at,
|
||
local_accounting_date, usage_evidence, evidence_path, evidence_sha256,
|
||
evidence_status, recorded_at
|
||
) VALUES (
|
||
'${recordId}', 'lindyson.provider-operational-usage/v1',
|
||
'fullstack-cross-slot-${suffix}', '${'f'.repeat(64)}',
|
||
'volcengine-ark', 'video', 'Seedance 2.5', 'video-generation',
|
||
'${ownerUserId}', 'provider-diagnostic', 'maintenance-script',
|
||
'fullstack-cross-slot-${suffix}', '${sourceItemKey}',
|
||
'${concurrentExternalId}', 'success', true, '2026-01-01 00:00:00+00',
|
||
'2026-01-01', '{}'::jsonb, '/fullstack/cross-slot/evidence.ndjson',
|
||
'${'e'.repeat(64)}', 'verified', clock_timestamp()
|
||
);
|
||
SELECT pg_sleep(0.5);
|
||
COMMIT;
|
||
`;
|
||
const sqlA = Buffer.from(concurrentInsert(concurrentRecordA, 'task-slot', 'provider_task_id'))
|
||
.toString('base64');
|
||
const sqlB = Buffer.from(concurrentInsert(concurrentRecordB, 'operation-slot', 'provider_operation_id'))
|
||
.toString('base64');
|
||
const concurrentOutput = dockerComposeOutput(
|
||
'exec', '--no-TTY', 'postgres', 'sh', '-c', `
|
||
set +e
|
||
printf '%s' '${sqlA}' | base64 -d | psql -v ON_ERROR_STOP=1 -U lindyson -d '${database}' >/tmp/m076-a-${suffix}.log 2>&1 &
|
||
a_pid=$!
|
||
printf '%s' '${sqlB}' | base64 -d | psql -v ON_ERROR_STOP=1 -U lindyson -d '${database}' >/tmp/m076-b-${suffix}.log 2>&1 &
|
||
b_pid=$!
|
||
wait "$a_pid"; a_status=$?
|
||
wait "$b_pid"; b_status=$?
|
||
printf '%s|%s\n' "$a_status" "$b_status"
|
||
`,
|
||
).trim();
|
||
expect(concurrentOutput.split('|').sort()).toEqual(['0', '3']);
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT
|
||
(SELECT count(*) FROM provider_operational_usage_records
|
||
WHERE source_run_id = 'fullstack-cross-slot-${suffix}')::text || '|' ||
|
||
(SELECT count(*) FROM provider_operational_usage_external_id_registry
|
||
WHERE provider = 'volcengine-ark'
|
||
AND external_id = '${concurrentExternalId}')::text;
|
||
`)).toBe('1|1');
|
||
const registryMutationFailure = postgresDatabaseScriptFailure(database, `
|
||
UPDATE provider_operational_usage_external_id_registry
|
||
SET identifier_kind = 'request'
|
||
WHERE provider = 'volcengine-ark' AND external_id = '${concurrentExternalId}';
|
||
`);
|
||
expect(registryMutationFailure).toContain('运营调用、估值和账单归属均为不可变事实');
|
||
|
||
const conflictManifest = structuredClone(manifest);
|
||
conflictManifest.manifestId = `fullstack-rollback-${suffix}`;
|
||
const newRecord = structuredClone(manifest.records[0]!);
|
||
newRecord.sourceRunId = `fullstack-rollback-${suffix}`;
|
||
newRecord.sourceItemKey = 'inserted-before-conflict';
|
||
newRecord.providerTaskId = `fullstack-rollback-new-${suffix}`;
|
||
const conflictingRecord = structuredClone(manifest.records[1]!);
|
||
conflictingRecord.providerTaskId = `fullstack-rollback-conflict-${suffix}`;
|
||
conflictManifest.records = [newRecord, conflictingRecord];
|
||
const rawConflictManifest = Buffer.from(JSON.stringify(conflictManifest, null, 2));
|
||
const conflictSha256 = createHash('sha256').update(rawConflictManifest).digest('hex');
|
||
writeApiStorageObject(conflictObjectKey, rawConflictManifest.toString('base64'));
|
||
const conflictFailure = dockerComposeFailureOutput(
|
||
'exec', '--no-TTY',
|
||
'-e', `DATABASE_URL=${databaseUrl}`,
|
||
'-e', `PROVIDER_OPERATIONAL_USAGE_COST_OWNER_USER_ID=${ownerUserId}`,
|
||
'api',
|
||
'node', 'apps/api/dist/tools/importProviderOperationalUsage.js',
|
||
'--manifest', `/app/storage/${conflictObjectKey}`,
|
||
'--apply', '--manifest-sha256', conflictSha256,
|
||
);
|
||
expect(conflictFailure).toContain('PROVIDER_OPERATIONAL_USAGE_SOURCE_CONFLICT');
|
||
expect(postgresDatabaseScalar(database, `
|
||
SELECT
|
||
(SELECT count(*) FROM provider_operational_usage_records)::text || '|' ||
|
||
(SELECT count(*) FROM provider_operational_usage_records
|
||
WHERE source_run_id = 'fullstack-rollback-${suffix}')::text || '|' ||
|
||
(SELECT count(*) FROM audit_logs
|
||
WHERE target = 'manifest-sha256:${conflictSha256}')::text;
|
||
`)).toBe('8|0|0');
|
||
} finally {
|
||
removeApiStorageObject(manifestObjectKey);
|
||
removeApiStorageObject(conflictObjectKey);
|
||
removeApiStorageObject(missingOriginalManifestObjectKey);
|
||
removeApiStorageObject(missingOriginalApprovalObjectKey);
|
||
removeApiStorageObject(evidenceObjectKey);
|
||
postgres(`DROP DATABASE IF EXISTS ${database} WITH (FORCE);`);
|
||
}
|
||
});
|
||
|
||
test('Migration 060 可重放并把三次 Provider 用量计入管理员普通用户汇总', async ({ browser }) => {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/060_admin_direct_provider_usage.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const ids = [randomUUID(), randomUUID(), randomUUID()];
|
||
const providerTaskIds = [
|
||
`fullstack-admin-usage-${randomUUID()}`,
|
||
`fullstack-admin-usage-${randomUUID()}`,
|
||
`fullstack-admin-usage-${randomUUID()}`,
|
||
];
|
||
|
||
postgresScript(migration);
|
||
postgresScript(migration);
|
||
postgres(`
|
||
INSERT INTO provider_usage_records (
|
||
id, provider, model, task_id, initiated_by_admin_user_id, status,
|
||
raw_units, deducted_units, completed_at, usage_unit, attempt,
|
||
provider_task_id, completion_tokens, total_tokens, billable_tokens,
|
||
billable_basis, resolution, has_video_input, price_version,
|
||
unit_price_per_million, estimated_cost, currency, pricing_status,
|
||
usage_evidence_origin, pricing_rule_status, pricing_input_class,
|
||
pricing_resolution_band, attribution_confidence, provider_terminal_at,
|
||
usage_observed_at, local_accounting_date, accounting_date_origin, created_at
|
||
)
|
||
SELECT
|
||
source.id, 'volcengine-ark', 'Seedance 2.0', NULL, admin.id, 'success',
|
||
245025, 245025, source.completed_at, 'tokens', 0,
|
||
source.provider_task_id, 245025, 245025, 245025,
|
||
'completion_tokens', '1080p', false, 'volcengine-seedance-2026-07-10',
|
||
51, 12.496275, 'CNY', 'priced',
|
||
'provider-reported', 'priced', 'without-video',
|
||
'1080p', 'direct', source.completed_at,
|
||
source.completed_at, '2026-07-26', 'usage-observed', '2026-07-25 20:00:00+00'
|
||
FROM users admin
|
||
CROSS JOIN (VALUES
|
||
('${ids[0]}'::uuid, '${providerTaskIds[0]}'::text, '2026-07-25 19:32:26+00'::timestamptz),
|
||
('${ids[1]}'::uuid, '${providerTaskIds[1]}'::text, '2026-07-25 19:32:43+00'::timestamptz),
|
||
('${ids[2]}'::uuid, '${providerTaskIds[2]}'::text, '2026-07-25 19:33:45+00'::timestamptz)
|
||
) source(id, provider_task_id, completed_at)
|
||
WHERE admin.email = '${primaryAccount.email}';
|
||
`);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*) || '|' || sum(billable_tokens) || '|' || sum(estimated_cost)
|
||
FROM provider_usage_records
|
||
WHERE id IN ('${ids.join("','")}');
|
||
`)).toBe('3|735075|37.488825');
|
||
expect(() => postgres(`
|
||
INSERT INTO provider_usage_records (
|
||
provider, model, initiated_by_admin_user_id, status, provider_task_id
|
||
)
|
||
SELECT 'volcengine-ark', 'Seedance 2.0', id, 'success', '${providerTaskIds[0]}'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
`)).toThrow();
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const asOf = encodeURIComponent(new Date().toISOString());
|
||
const search = encodeURIComponent(primaryAccount.email);
|
||
const range = `range=date&date=2026-07-26&asOf=${asOf}&search=${search}&limit=20`;
|
||
const users = await api(session.page, `/api/admin/api-usage/users?${range}`);
|
||
const attempts = await api(session.page, `/api/admin/api-usage/attempts?${range}`);
|
||
const tasks = await api(session.page, `/api/admin/api-usage/tasks?${range}`);
|
||
|
||
expect(users.status).toBe(200);
|
||
expect(users.body.items).toEqual([expect.objectContaining({
|
||
email: primaryAccount.email,
|
||
taskCount: 0,
|
||
operationalCallCount: 3,
|
||
successfulTasks: 0,
|
||
failedTasks: 0,
|
||
canceledTasks: 0,
|
||
settledCredits: 0,
|
||
refundedCredits: 0,
|
||
billableTokens: 735075,
|
||
estimatedCostCny: '37.488825',
|
||
businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '37.488825',
|
||
knownAccountCostCny: '37.488825',
|
||
operationalUnpricedCallCount: 0,
|
||
incompleteTaskCount: 0,
|
||
averageTaskCostCny: null,
|
||
lastTaskAt: '-',
|
||
})]);
|
||
expect(attempts.status).toBe(200);
|
||
expect(attempts.body.items).toHaveLength(3);
|
||
expect(attempts.body.items).toEqual(expect.arrayContaining(providerTaskIds.map(providerTaskId => expect.objectContaining({
|
||
taskId: null,
|
||
origin: 'admin-direct',
|
||
providerTaskId,
|
||
email: primaryAccount.email,
|
||
billableTokens: 245025,
|
||
estimatedCostCny: '12.496275',
|
||
}))));
|
||
expect(tasks.status).toBe(200);
|
||
expect(tasks.body.items).toHaveLength(0);
|
||
|
||
const oldSnapshot = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/users?range=date&date=2026-07-26&asOf=2026-07-25T19%3A59%3A59.000Z&search=${search}&limit=20`,
|
||
);
|
||
expect(oldSnapshot.status).toBe(200);
|
||
expect(oldSnapshot.body.items).toHaveLength(0);
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`DELETE FROM provider_usage_records WHERE id IN ('${ids.join("','")}');`);
|
||
}
|
||
});
|
||
|
||
test('真实 PostgreSQL 中并发扫描只认领一次,并按免费、付费、到期与永久策略分批清理', () => {
|
||
const marker = randomUUID();
|
||
const ids = {
|
||
freeUser: randomUUID(),
|
||
paidUser: randomUUID(),
|
||
expiredUser: randomUUID(),
|
||
foreverUser: randomUUID(),
|
||
freeImageFolder: randomUUID(),
|
||
freeUploadFolder: randomUUID(),
|
||
paidImageFolder: randomUUID(),
|
||
expiredVideoFolder: randomUUID(),
|
||
foreverImageFolder: randomUUID(),
|
||
freeOld: randomUUID(),
|
||
paidYoung: randomUUID(),
|
||
expiredOld: randomUUID(),
|
||
foreverOld: randomUUID(),
|
||
uploadedOld: randomUUID(),
|
||
freshAi: randomUUID(),
|
||
alreadyDeleted: randomUUID(),
|
||
legacyDeleting: randomUUID(),
|
||
};
|
||
const permanentPlanCode = `retention-forever-${marker}`;
|
||
const sharedObjectKey = `fullstack/retention/${marker}/shared.bin`;
|
||
const futureClaimToken = randomUUID();
|
||
const allAssetIds = [
|
||
ids.freeOld, ids.paidYoung, ids.expiredOld, ids.foreverOld,
|
||
ids.uploadedOld, ids.freshAi, ids.alreadyDeleted, ids.legacyDeleting,
|
||
];
|
||
|
||
dockerCompose('stop', 'worker');
|
||
try {
|
||
postgres(`
|
||
DELETE FROM asset_retention_sweep_runs;
|
||
|
||
INSERT INTO asset_retention_sweep_runs (
|
||
business_date, status, claim_token, lease_expires_at, attempt_count
|
||
) VALUES (
|
||
date '2099-01-01', 'running', '${futureClaimToken}'::uuid, now() - interval '1 minute', 1
|
||
);
|
||
|
||
INSERT INTO plans (
|
||
code, name, name_en, kind, billing, description, monthly_price,
|
||
yearly_price, credits, storage_label, tiktok_accounts, cta_label,
|
||
status, features, asset_retention_days
|
||
) VALUES (
|
||
'${permanentPlanCode}', '全栈永久保留', 'Fullstack Forever', 'main', 'both',
|
||
'全栈保留期测试', 0, 0, 0, '0', '0', '测试', 'active', '[]'::jsonb, 0
|
||
);
|
||
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id, plan_expires_at)
|
||
VALUES
|
||
('${ids.freeUser}', 'retention-free-${marker}@lindyson.test', 'test', '保留期免费用户', NULL, NULL),
|
||
('${ids.paidUser}', 'retention-paid-${marker}@lindyson.test', 'test', '保留期付费用户',
|
||
(SELECT id FROM plans WHERE code = 'pro'), date '2020-02-01'),
|
||
('${ids.expiredUser}', 'retention-expired-${marker}@lindyson.test', 'test', '保留期到期用户',
|
||
(SELECT id FROM plans WHERE code = 'pro'), date '2020-01-10'),
|
||
('${ids.foreverUser}', 'retention-forever-${marker}@lindyson.test', 'test', '保留期永久用户',
|
||
(SELECT id FROM plans WHERE code = '${permanentPlanCode}'), NULL);
|
||
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES
|
||
('${ids.freeImageFolder}', '${ids.freeUser}', 'AI 图片', 'ai-image', true),
|
||
('${ids.freeUploadFolder}', '${ids.freeUser}', '已上传图片', 'uploaded-image', true),
|
||
('${ids.paidImageFolder}', '${ids.paidUser}', 'AI 图片', 'ai-image', true),
|
||
('${ids.expiredVideoFolder}', '${ids.expiredUser}', 'AI 视频', 'ai-video', true),
|
||
('${ids.foreverImageFolder}', '${ids.foreverUser}', 'AI 图片', 'ai-image', true);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key,
|
||
size_bytes, duration_seconds, deletion_status, deleted_at,
|
||
thumbnail_status, thumbnail_updated_at, created_at, updated_at
|
||
) VALUES
|
||
('${ids.freeOld}', '${ids.freeUser}', '${ids.freeImageFolder}', '免费超期图片',
|
||
'image', 'ai-image', '${sharedObjectKey}', 'fullstack/retention/${marker}/free-thumb.webp',
|
||
10, NULL, 'active', NULL, 'ready', timestamptz '2020-01-01 03:00:00+08',
|
||
timestamptz '2020-01-01 03:00:00+08', timestamptz '2020-01-01 03:00:00+08'),
|
||
('${ids.expiredOld}', '${ids.expiredUser}', '${ids.expiredVideoFolder}', '到期套餐超期视频',
|
||
'video', 'ai-video', '${sharedObjectKey}', 'fullstack/retention/${marker}/expired-thumb.jpg',
|
||
10, 1, 'active', NULL, 'ready', timestamptz '2019-12-20 03:00:00+08',
|
||
timestamptz '2019-12-20 03:00:00+08', timestamptz '2019-12-20 03:00:00+08'),
|
||
('${ids.paidYoung}', '${ids.paidUser}', '${ids.paidImageFolder}', '付费套餐未超期图片',
|
||
'image', 'ai-image', 'fullstack/retention/${marker}/paid.webp', 'fullstack/retention/${marker}/paid-thumb.webp',
|
||
10, NULL, 'active', NULL, 'ready', timestamptz '2019-12-20 03:00:00+08',
|
||
timestamptz '2019-12-20 03:00:00+08', timestamptz '2019-12-20 03:00:00+08'),
|
||
('${ids.foreverOld}', '${ids.foreverUser}', '${ids.foreverImageFolder}', '永久保留历史图片',
|
||
'image', 'ai-image', 'fullstack/retention/${marker}/forever.webp', 'fullstack/retention/${marker}/forever-thumb.webp',
|
||
10, NULL, 'active', NULL, 'ready', timestamptz '2010-01-01 03:00:00+08',
|
||
timestamptz '2010-01-01 03:00:00+08', timestamptz '2010-01-01 03:00:00+08'),
|
||
('${ids.uploadedOld}', '${ids.freeUser}', '${ids.freeUploadFolder}', '历史上传图片',
|
||
'image', 'uploaded-image', 'fullstack/retention/${marker}/upload.webp', 'fullstack/retention/${marker}/upload-thumb.webp',
|
||
10, NULL, 'active', NULL, 'ready', timestamptz '2010-01-01 03:00:00+08',
|
||
timestamptz '2010-01-01 03:00:00+08', timestamptz '2010-01-01 03:00:00+08'),
|
||
('${ids.freshAi}', '${ids.freeUser}', '${ids.freeImageFolder}', '免费版未超期图片',
|
||
'image', 'ai-image', 'fullstack/retention/${marker}/fresh.webp', 'fullstack/retention/${marker}/fresh-thumb.webp',
|
||
10, NULL, 'active', NULL, 'ready', timestamptz '2020-01-05 03:00:00+08',
|
||
timestamptz '2020-01-05 03:00:00+08', timestamptz '2020-01-05 03:00:00+08'),
|
||
('${ids.alreadyDeleted}', '${ids.freeUser}', '${ids.freeImageFolder}', '已删除历史图片',
|
||
'image', 'ai-image', 'fullstack/retention/${marker}/deleted.webp', 'fullstack/retention/${marker}/deleted-thumb.webp',
|
||
10, NULL, 'deleted', timestamptz '2019-01-02 03:00:00+08', 'ready', timestamptz '2019-01-01 03:00:00+08',
|
||
timestamptz '2019-01-01 03:00:00+08', timestamptz '2019-01-02 03:00:00+08'),
|
||
('${ids.legacyDeleting}', '${ids.paidUser}', '${ids.paidImageFolder}', '旧代码遗留删除中共享图片',
|
||
'image', 'ai-image', '${sharedObjectKey}', 'fullstack/retention/${marker}/legacy-thumb.webp',
|
||
10, NULL, 'deleting', NULL, 'ready', timestamptz '2019-01-01 03:00:00+08',
|
||
timestamptz '2019-01-01 03:00:00+08', timestamptz '2019-01-01 03:00:00+08');
|
||
`);
|
||
|
||
const script = `
|
||
const retention = await import('./apps/api/dist/domain/assetRetentionService.js');
|
||
const deletion = await import('./apps/api/dist/domain/assetDeletionService.js');
|
||
const { pool } = await import('./apps/api/dist/db/pool.js');
|
||
try {
|
||
retention.resetAssetRetentionSweepMemoryForTests();
|
||
const results = await Promise.all([
|
||
retention.runDailyAssetRetentionSweep({ now: new Date('2020-01-09T19:01:00.000Z'), batchSize: 1, bypassMemoryCache: true }),
|
||
retention.runDailyAssetRetentionSweep({ now: new Date('2020-01-09T19:01:00.000Z'), batchSize: 1, bypassMemoryCache: true })
|
||
]);
|
||
const outbox = await deletion.processObjectDeletionOutbox(10);
|
||
console.log('RETENTION_RESULTS=' + JSON.stringify({ results, outbox }));
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
`;
|
||
const output = dockerComposeOutput(
|
||
'run', '--rm', '--no-deps', 'worker',
|
||
'node', '--input-type=module', '--eval', script,
|
||
);
|
||
const resultLine = output.split('\n').find(line => line.startsWith('RETENTION_RESULTS='));
|
||
expect(resultLine).toBeTruthy();
|
||
const execution = JSON.parse(resultLine!.slice('RETENTION_RESULTS='.length)) as {
|
||
results: Array<{
|
||
businessDate: string;
|
||
status: string;
|
||
scanned: number;
|
||
scheduled: number;
|
||
}>;
|
||
outbox: { completed: number; skipped: number; failed: number };
|
||
};
|
||
const { results } = execution;
|
||
expect(results.filter(result => result.status === 'completed')).toHaveLength(1);
|
||
expect(results.filter(result => ['already-running', 'already-completed'].includes(result.status))).toHaveLength(1);
|
||
expect(results.find(result => result.status === 'completed')).toMatchObject({
|
||
businessDate: '2020-01-10', scanned: 2, scheduled: 2,
|
||
});
|
||
expect(execution.outbox).toEqual({ completed: 3, skipped: 0, failed: 0 });
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', status, scanned_count::text, scheduled_count::text, attempt_count::text)
|
||
FROM asset_retention_sweep_runs WHERE business_date = date '2020-01-10';
|
||
`)).toBe('completed|2|2|1');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', status, claim_token::text, attempt_count::text)
|
||
FROM asset_retention_sweep_runs WHERE business_date = date '2099-01-01';
|
||
`)).toBe(`running|${futureClaimToken}|1`);
|
||
expect(postgresScalar(`
|
||
SELECT string_agg(id::text || ':' || deletion_status, ',' ORDER BY id)
|
||
FROM assets WHERE id IN ('${ids.freeOld}'::uuid, '${ids.expiredOld}'::uuid);
|
||
`)).toBe([
|
||
`${ids.freeOld}:deleted`, `${ids.expiredOld}:deleted`,
|
||
].sort().join(','));
|
||
expect(postgresScalar(`
|
||
SELECT deletion_status FROM assets WHERE id = '${ids.legacyDeleting}'::uuid;
|
||
`)).toBe('deleting');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM assets
|
||
WHERE id IN (
|
||
'${ids.paidYoung}'::uuid, '${ids.foreverOld}'::uuid,
|
||
'${ids.uploadedOld}'::uuid, '${ids.freshAi}'::uuid
|
||
) AND deletion_status = 'active';
|
||
`)).toBe('4');
|
||
expect(postgresScalar(`
|
||
SELECT array_length(asset_ids, 1)::text
|
||
FROM object_deletion_outbox WHERE object_key = '${sharedObjectKey}';
|
||
`)).toBe('2');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM object_deletion_outbox
|
||
WHERE object_key LIKE 'fullstack/retention/${marker}/%';
|
||
`)).toBe('3');
|
||
} finally {
|
||
postgres(`
|
||
DELETE FROM object_deletion_outbox WHERE object_key LIKE 'fullstack/retention/${marker}/%';
|
||
DELETE FROM assets WHERE id IN (${allAssetIds.map(id => `'${id}'::uuid`).join(', ')});
|
||
DELETE FROM asset_folders WHERE id IN (
|
||
'${ids.freeImageFolder}'::uuid, '${ids.freeUploadFolder}'::uuid,
|
||
'${ids.paidImageFolder}'::uuid, '${ids.expiredVideoFolder}'::uuid,
|
||
'${ids.foreverImageFolder}'::uuid
|
||
);
|
||
DELETE FROM users WHERE id IN (
|
||
'${ids.freeUser}'::uuid, '${ids.paidUser}'::uuid,
|
||
'${ids.expiredUser}'::uuid, '${ids.foreverUser}'::uuid
|
||
);
|
||
DELETE FROM plans WHERE code = '${permanentPlanCode}';
|
||
DELETE FROM asset_retention_sweep_runs
|
||
WHERE business_date IN (date '2020-01-10', date '2099-01-01');
|
||
`);
|
||
dockerCompose('start', 'worker');
|
||
}
|
||
});
|
||
|
||
test('真实 API、PostgreSQL、Redis 与 HttpOnly Cookie 会话可用', async ({ browser }) => {
|
||
const { context, page } = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const me = await api(page, '/api/auth/me');
|
||
expect(me.status).toBe(200);
|
||
expect(me.body.user.role).toBe('admin');
|
||
expect(await page.evaluate(() => document.cookie)).not.toContain('lindyson_session');
|
||
|
||
const overview = await api(page, '/api/admin/overview');
|
||
expect(overview.status).toBe(200);
|
||
expect(overview.body.summary.timezone).toBe('Asia/Shanghai');
|
||
|
||
const logout = await api(page, '/api/auth/logout', { method: 'POST' });
|
||
expect(logout.status).toBe(200);
|
||
expect((await api(page, '/api/auth/me')).status).toBe(401);
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
});
|
||
|
||
test('COM 与 CN 共用业务数据但保持独立 Host-only 会话和搜索边界', async ({ browser, request }) => {
|
||
const comContext = await browser.newContext({ baseURL: 'http://com.localhost:8080' });
|
||
const cnContext = await browser.newContext({ baseURL: 'http://cn.localhost:8081' });
|
||
const comPage = await comContext.newPage();
|
||
const cnPage = await cnContext.newPage();
|
||
|
||
try {
|
||
await Promise.all([comPage.goto('/'), cnPage.goto('/')]);
|
||
|
||
const comLogin = await api(comPage, '/api/auth/login', {
|
||
method: 'POST',
|
||
body: primaryAccount,
|
||
});
|
||
expect(comLogin.status).toBe(200);
|
||
expect((await api(cnPage, '/api/auth/me')).status).toBe(401);
|
||
|
||
const cnLogin = await api(cnPage, '/api/auth/login', {
|
||
method: 'POST',
|
||
body: primaryAccount,
|
||
});
|
||
expect(cnLogin.status).toBe(200);
|
||
expect(cnLogin.body.user.id).toBe(comLogin.body.user.id);
|
||
|
||
const [comCredits, cnCredits] = await Promise.all([
|
||
api(comPage, '/api/account/credits'),
|
||
api(cnPage, '/api/account/credits'),
|
||
]);
|
||
expect(comCredits.status).toBe(200);
|
||
expect(cnCredits.status).toBe(200);
|
||
expect(cnCredits.body).toEqual(comCredits.body);
|
||
|
||
const comCookie = (await comContext.cookies()).find(cookie => cookie.name === 'lindyson_session');
|
||
const cnCookie = (await cnContext.cookies()).find(cookie => cookie.name === 'lindyson_session');
|
||
expect(comCookie).toMatchObject({ domain: 'com.localhost', httpOnly: true, sameSite: 'Lax' });
|
||
expect(cnCookie).toMatchObject({ domain: 'cn.localhost', httpOnly: true, sameSite: 'Lax' });
|
||
expect(comCookie?.domain).not.toBe(cnCookie?.domain);
|
||
|
||
const [
|
||
comVersion, cnVersion, comHome, cnHome, comApp, cnApp,
|
||
comPortraitConsent, cnPortraitConsent, cnRobots, cnSitemap,
|
||
] = await Promise.all([
|
||
request.get('http://127.0.0.1:8080/version.json'),
|
||
request.get('http://127.0.0.1:8081/version.json'),
|
||
request.get('http://127.0.0.1:8080/'),
|
||
request.get('http://127.0.0.1:8081/'),
|
||
request.get('http://127.0.0.1:8080/workspace'),
|
||
request.get('http://127.0.0.1:8081/workspace'),
|
||
request.get('http://127.0.0.1:8080/portrait-consent/invite#token=must-not-reach-server'),
|
||
request.get('http://127.0.0.1:8081/portrait-consent/invite#token=must-not-reach-server'),
|
||
request.get('http://127.0.0.1:8081/robots.txt'),
|
||
request.get('http://127.0.0.1:8081/sitemap.xml'),
|
||
]);
|
||
expect(comVersion.status()).toBe(200);
|
||
expect(cnVersion.status()).toBe(200);
|
||
expect(await cnVersion.json()).toEqual(await comVersion.json());
|
||
expect(await cnHome.text()).toBe(await comHome.text());
|
||
expect(await cnApp.text()).toBe(await comApp.text());
|
||
expect(cnHome.headers()['x-robots-tag']).toBe('noindex, nofollow');
|
||
expect(cnApp.headers()['x-robots-tag']).toBe('noindex, nofollow');
|
||
expect(comPortraitConsent.status()).toBe(200);
|
||
expect(cnPortraitConsent.status()).toBe(200);
|
||
expect(comPortraitConsent.headers()['referrer-policy']).toBe('no-referrer');
|
||
expect(cnPortraitConsent.headers()['referrer-policy']).toBe('no-referrer');
|
||
expect(comPortraitConsent.headers()['cache-control']).toBe('no-store');
|
||
expect(cnPortraitConsent.headers()['cache-control']).toBe('no-store');
|
||
expect(await comPortraitConsent.text()).not.toContain('must-not-reach-server');
|
||
expect(await cnPortraitConsent.text()).not.toContain('must-not-reach-server');
|
||
expect(cnRobots.status()).toBe(200);
|
||
expect(await cnRobots.text()).toBe('User-agent: *\nDisallow: /\n');
|
||
expect(cnSitemap.status()).toBe(404);
|
||
} finally {
|
||
await Promise.all([comContext.close(), cnContext.close()]);
|
||
}
|
||
});
|
||
|
||
test('提示词向导 V6 在真实队列中完成自动三路线、免费首批、付费换批、幂等与恢复', async ({ browser }) => {
|
||
test.setTimeout(180_000);
|
||
const userId = randomUUID();
|
||
const testAccount = {
|
||
email: `prompt-wizard-v6-${userId}@lindyson.test`,
|
||
password: primaryAccount.password,
|
||
};
|
||
const assetId = randomUUID();
|
||
const folderId = randomUUID();
|
||
const thumbnailObjectKey = `fullstack/prompt-wizard-v6/${assetId}-thumbnail-512.webp`;
|
||
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
SELECT '${userId}', '${testAccount.email}', password_hash, '提示词向导 V6 全栈专用用户'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
`);
|
||
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const primaryCreditsBefore = await readCredits(admin.page);
|
||
const seededCredits = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-prompt-v6-credit-${randomUUID()}` },
|
||
body: {
|
||
userId,
|
||
mainDelta: 0,
|
||
addonDelta: 3,
|
||
note: '全栈测试:准备 V6 换一批积分',
|
||
},
|
||
});
|
||
expect(seededCredits.status).toBe(200);
|
||
expect(seededCredits.body.user).toMatchObject({ mainCredits: 0, addonCredits: 3, credits: 3 });
|
||
|
||
const session = await createAuthenticatedPage(browser, testAccount);
|
||
writeApiStorageObject(thumbnailObjectKey, promptWizardThumbnailWebpBase64);
|
||
postgres(`
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES ('${folderId}', '${userId}', '全栈提示词向导 V6 图片', 'uploaded-image', false);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key, size_bytes,
|
||
deletion_status, thumbnail_status, thumbnail_updated_at, created_at, updated_at
|
||
) VALUES (
|
||
'${assetId}', '${userId}', '${folderId}', '全栈 V6 开放式耳机.png', 'image', 'uploaded-image',
|
||
'fullstack/prompt-wizard-v6/${assetId}.png', '${thumbnailObjectKey}', 128,
|
||
'active', 'ready', now(), now(), now()
|
||
);
|
||
`);
|
||
|
||
try {
|
||
expect(postgresScalar(`
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings WHERE singleton_id = 1;
|
||
`)).toBe('false|4');
|
||
postgres(`
|
||
UPDATE prompt_wizard_settings
|
||
SET available = true, updated_at = now()
|
||
WHERE singleton_id = 1 AND engine_version = 4 AND available = false;
|
||
`);
|
||
expect(postgresScalar(`
|
||
SELECT available::text || '|' || engine_version::text
|
||
FROM prompt_wizard_settings WHERE singleton_id = 1;
|
||
`)).toBe('true|4');
|
||
|
||
const creditsBefore = await readCredits(session.page);
|
||
const createdRun = await api(session.page, '/api/ai/prompt-wizard/runs', {
|
||
method: 'POST',
|
||
body: { imageAssetIds: [assetId], referenceMode: 'general' },
|
||
});
|
||
expect(createdRun.status).toBe(202);
|
||
expect(createdRun.body.run).toMatchObject({
|
||
engineVersion: 4,
|
||
status: 'analyzing',
|
||
revision: 0,
|
||
imageAssetIds: [assetId],
|
||
});
|
||
|
||
const analyzedRun = await waitForPromptWizardRun(session.page, createdRun.body.run.id);
|
||
expect(analyzedRun).toMatchObject({ engineVersion: 4, status: 'ready', revision: 1 });
|
||
expect(analyzedRun.productProfile.visibleFacts).toEqual(['已收到 1 张产品图片']);
|
||
expect(analyzedRun.evidenceGraph.reliableEvidenceIds.length).toBeGreaterThan(0);
|
||
|
||
// 用户修正识别结果后必须自动作废旧 revision 的路线并预生成新三路线。
|
||
const correctedProfile = {
|
||
...analyzedRun.productProfile,
|
||
productName: '全栈测试开放式蓝牙耳机',
|
||
category: '电子产品 > 音频 > 开放式耳机',
|
||
colors: '',
|
||
materials: '',
|
||
structure: '以参考图中可见外观、耳挂和充电仓结构为准',
|
||
usage: '按参考图可见的耳挂方向正确佩戴',
|
||
suggestedAudience: '重视舒适佩戴和环境感知的成年用户',
|
||
suggestedScenarios: '日常通勤、桌面工作与轻量运动',
|
||
visibleFacts: ['图片中可见耳挂式耳机与圆形充电仓'],
|
||
needsConfirmation: [],
|
||
userConfirmedClaims: '',
|
||
consistencyConstraints: '保持参考图中耳机、耳挂、充电仓和可见部件一致',
|
||
};
|
||
const correctedRunResponse = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}`,
|
||
{
|
||
method: 'PATCH',
|
||
body: { revision: analyzedRun.revision, productProfile: correctedProfile },
|
||
},
|
||
);
|
||
expect(correctedRunResponse.status).toBe(200);
|
||
const correctedRun = correctedRunResponse.body.run;
|
||
expect(correctedRun).toMatchObject({ engineVersion: 4, status: 'ready', revision: 2 });
|
||
expect(correctedRun.productProfile.colors).toBe('');
|
||
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT status
|
||
FROM prompt_wizard_route_sets
|
||
WHERE run_id = '${analyzedRun.id}'::uuid AND run_revision = ${correctedRun.revision}
|
||
ORDER BY created_at DESC LIMIT 1;
|
||
`), { timeout: 45_000, intervals: [250, 500, 1_000] }).toBe('completed');
|
||
expect(postgresScalar(`
|
||
SELECT jsonb_array_length(routes)::text
|
||
FROM prompt_wizard_route_sets
|
||
WHERE run_id = '${analyzedRun.id}'::uuid AND run_revision = ${correctedRun.revision}
|
||
AND status = 'completed';
|
||
`)).toBe('3');
|
||
|
||
const retiredConcept = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/concept-sets`,
|
||
{ method: 'POST', body: {} },
|
||
);
|
||
expect(retiredConcept.status).toBe(410);
|
||
const retiredDirections = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/use-direction-sets`,
|
||
{ method: 'POST', body: {} },
|
||
);
|
||
expect(retiredDirections.status).toBe(410);
|
||
|
||
const videoConfig = {
|
||
model: 'seedance-2.0-mini',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
};
|
||
const invalidLegacyBatch = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-v6-invalid-${randomUUID()}`,
|
||
revision: correctedRun.revision,
|
||
expectedGenerationCredits: 1,
|
||
voiceoverLanguage: '英语',
|
||
videoConfig,
|
||
useDirectionSetId: randomUUID(),
|
||
},
|
||
},
|
||
);
|
||
expect(invalidLegacyBatch.status).toBe(400);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore);
|
||
|
||
const firstBatchKey = `fullstack-prompt-v6-first-${randomUUID()}`;
|
||
const firstPayload = {
|
||
idempotencyKey: firstBatchKey,
|
||
revision: correctedRun.revision,
|
||
expectedGenerationCredits: 1,
|
||
videoConfig,
|
||
};
|
||
const firstBatchRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: firstPayload },
|
||
);
|
||
expect(firstBatchRequest.status).toBe(202);
|
||
expect(firstBatchRequest.body.batch).toMatchObject({
|
||
status: 'queued',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
});
|
||
expect(firstBatchRequest.body.batch.routeSetId).toBeTruthy();
|
||
|
||
const firstBatch = await waitForPromptWizardBatch(session.page, firstBatchRequest.body.batch.id);
|
||
expect(firstBatch).toMatchObject({
|
||
status: 'completed',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
});
|
||
expect(firstBatch.plans).toHaveLength(3);
|
||
expect(firstBatch.plans.map((plan: { role: string }) => plan.role)).toEqual([
|
||
'proof-conversion',
|
||
'problem-hook',
|
||
'lifestyle-seeding',
|
||
]);
|
||
expect(new Set(firstBatch.plans.map((plan: { prompt: string }) => plan.prompt)).size).toBe(3);
|
||
expect(firstBatch.plans.every((plan: {
|
||
deliveryVersion: number;
|
||
audioMode: string;
|
||
prompt: string;
|
||
shots: Array<{ role: string; voiceover: string; voiceoverZh: string }>;
|
||
claimBasisTags: Array<{ basis: string }>;
|
||
productionWarnings: string[];
|
||
}) => (
|
||
plan.deliveryVersion === 6
|
||
&& plan.audioMode === 'sound-design-only'
|
||
&& plan.shots.length === 2
|
||
&& plan.shots.map(shot => shot.role).join('|') === 'hook|proof-resolution'
|
||
&& plan.prompt.includes('@图片1')
|
||
&& /[㐀-鿿]/u.test(plan.prompt)
|
||
&& plan.prompt.includes('无对白、无人声、无旁白;仅使用音乐、环境声和真实商品操作拟音。')
|
||
&& !plan.prompt.includes('旁白(')
|
||
&& plan.shots.every(shot => shot.voiceover === '' && shot.voiceoverZh === '')
|
||
&& plan.productionWarnings.every(warning => !plan.prompt.includes(warning))
|
||
&& plan.claimBasisTags.every(tag => ['visible', 'user-confirmed', 'category-inferred'].includes(tag.basis))
|
||
&& !/proof-conversion|problem-hook|lifestyle-seeding/u.test(plan.prompt)
|
||
))).toBe(true);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM credit_ledger
|
||
WHERE related_prompt_batch_id = '${firstBatch.id}'::uuid;
|
||
`)).toBe('0');
|
||
|
||
const replayedFirst = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: firstPayload },
|
||
);
|
||
expect(replayedFirst.status).toBe(202);
|
||
expect(replayedFirst.body.batch).toEqual(firstBatch);
|
||
|
||
const retiredRefinement = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/batches/${firstBatch.id}/plans/${firstBatch.plans[0].id}/refinements`,
|
||
{ method: 'POST', body: { action: 'stronger-hook' } },
|
||
);
|
||
expect(retiredRefinement.status).toBe(410);
|
||
|
||
const refreshPayload = {
|
||
...firstPayload,
|
||
idempotencyKey: `fullstack-prompt-v6-refresh-${randomUUID()}`,
|
||
};
|
||
const refreshRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: refreshPayload },
|
||
);
|
||
expect(refreshRequest.status).toBe(202);
|
||
expect(refreshRequest.body.batch).toMatchObject({
|
||
status: 'queued',
|
||
isFree: false,
|
||
credits: 1,
|
||
creditStatus: 'held',
|
||
});
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
|
||
// 模拟关闭向导后重开:resume 必须优先返回活动批次,不能让用户再创建一批。
|
||
const activeResume = await api(session.page, '/api/ai/prompt-wizard/resume');
|
||
expect(activeResume.status).toBe(200);
|
||
expect(activeResume.body.run.id).toBe(analyzedRun.id);
|
||
expect(activeResume.body.batch.id).toBe(refreshRequest.body.batch.id);
|
||
expect(['queued', 'processing']).toContain(activeResume.body.batch.status);
|
||
|
||
const refreshedBatch = await waitForPromptWizardBatch(session.page, refreshRequest.body.batch.id);
|
||
expect(refreshedBatch).toMatchObject({
|
||
status: 'completed',
|
||
isFree: false,
|
||
credits: 1,
|
||
creditStatus: 'settled',
|
||
});
|
||
expect(refreshedBatch.plans).toHaveLength(3);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', type, settlement_status, amount::text)
|
||
FROM credit_ledger WHERE related_prompt_batch_id = '${refreshedBatch.id}'::uuid;
|
||
`)).toBe('spend|settled|-1');
|
||
|
||
const replayedRefresh = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: refreshPayload },
|
||
);
|
||
expect(replayedRefresh.status).toBe(202);
|
||
expect(replayedRefresh.body.batch).toEqual(refreshedBatch);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM credit_ledger
|
||
WHERE related_prompt_batch_id = '${refreshedBatch.id}'::uuid;
|
||
`)).toBe('1');
|
||
|
||
const completedResume = await api(session.page, '/api/ai/prompt-wizard/resume');
|
||
expect(completedResume.status).toBe(200);
|
||
expect(completedResume.body.run.id).toBe(analyzedRun.id);
|
||
expect(completedResume.body.batch.id).toBe(refreshedBatch.id);
|
||
expect(completedResume.body.batch.plans).toHaveLength(3);
|
||
|
||
const financialSnapshotBeforePurge = postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT count(*) FROM credit_allocations WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_allocations WHERE user_id = '${userId}'::uuid)
|
||
);
|
||
`);
|
||
const providerSnapshotBeforePurge = postgresScalar(`
|
||
SELECT concat_ws('|', count(*), coalesce(sum(input_tokens), 0),
|
||
coalesce(sum(output_tokens), 0), coalesce(sum(cost_cny), 0))
|
||
FROM prompt_wizard_provider_requests WHERE user_id = '${userId}'::uuid;
|
||
`);
|
||
|
||
postgres(`
|
||
UPDATE prompt_wizard_runs SET expires_at = now() - interval '1 minute'
|
||
WHERE id = '${analyzedRun.id}'::uuid;
|
||
`);
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM prompt_wizard_runs
|
||
WHERE id = '${analyzedRun.id}'::uuid AND status = 'archived'
|
||
AND product_profile IS NULL AND evidence_graph IS NULL),
|
||
(SELECT count(*) FROM prompt_wizard_batches
|
||
WHERE run_id = '${analyzedRun.id}'::uuid
|
||
AND plans IS NULL AND content_purged_at IS NOT NULL),
|
||
(SELECT count(*) FROM prompt_wizard_route_sets
|
||
WHERE run_id = '${analyzedRun.id}'::uuid
|
||
AND routes IS NULL AND content_purged_at IS NOT NULL)
|
||
);
|
||
`), { timeout: 45_000, intervals: [1_000, 2_000, 3_000] }).toBe('1|2|2');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT count(*) FROM credit_allocations WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_allocations WHERE user_id = '${userId}'::uuid)
|
||
);
|
||
`)).toBe(financialSnapshotBeforePurge);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', count(*), coalesce(sum(input_tokens), 0),
|
||
coalesce(sum(output_tokens), 0), coalesce(sum(cost_cny), 0))
|
||
FROM prompt_wizard_provider_requests WHERE user_id = '${userId}'::uuid;
|
||
`)).toBe(providerSnapshotBeforePurge);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(await readCredits(admin.page)).toBe(primaryCreditsBefore);
|
||
} finally {
|
||
postgres(`
|
||
UPDATE prompt_wizard_settings
|
||
SET available = false, updated_at = now()
|
||
WHERE singleton_id = 1 AND engine_version = 4;
|
||
`);
|
||
await session.context.close();
|
||
await admin.context.close();
|
||
postgres(`DELETE FROM assets WHERE id = '${assetId}'::uuid;`);
|
||
postgres(`DELETE FROM asset_folders WHERE id = '${folderId}'::uuid;`);
|
||
removeApiStorageObject(thumbnailObjectKey);
|
||
}
|
||
});
|
||
|
||
test.skip('历史 V5 使用方向、双方案与单方案微调全栈链路(写入接口已退役,仅保留原始验收记录)', async ({ browser }) => {
|
||
// 该用例故意覆盖 9 个顺序的异步节点,且隔离环境将
|
||
// Fake Provider 单次延迟固定为 8 秒以验证状态恢复。只放宽本条
|
||
// 业务链路的总时限,各状态轮询仍保留更短的独立超时。
|
||
test.setTimeout(210_000);
|
||
const userId = randomUUID();
|
||
const testAccount = {
|
||
email: `prompt-wizard-${userId}@lindyson.test`,
|
||
password: primaryAccount.password,
|
||
};
|
||
const assetId = randomUUID();
|
||
const folderId = randomUUID();
|
||
const thumbnailObjectKey = `fullstack/prompt-wizard/${assetId}-thumbnail-512.webp`;
|
||
|
||
// 使用专用零套餐账户隔离真实计费,避免永久流水改变 primaryAccount 的余额并污染后续用例。
|
||
// 密码哈希仅复用本套件固定测试密码;该账户和不可变财务历史随一次性 Docker volume 销毁。
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
SELECT '${userId}', '${testAccount.email}', password_hash, '提示词向导全栈专用用户'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
`);
|
||
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const primaryCreditsBefore = await readCredits(admin.page);
|
||
const seededCredits = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-prompt-credit-seed-${randomUUID()}` },
|
||
body: {
|
||
userId,
|
||
mainDelta: 0,
|
||
addonDelta: 3,
|
||
note: '全栈测试:准备提示词方案付费换批积分',
|
||
},
|
||
});
|
||
expect(seededCredits.status).toBe(200);
|
||
expect(seededCredits.body.user).toMatchObject({
|
||
mainCredits: 0,
|
||
addonCredits: 3,
|
||
credits: 3,
|
||
});
|
||
|
||
const session = await createAuthenticatedPage(browser, testAccount);
|
||
|
||
// 本用例验证的是向导 API、Redis 与 worker 链路;上传状态机已有独立全链路用例。
|
||
// fake provider 不解码图片内容,但向导仍会严格校验 ready WebP 并读取共享存储中的真实对象大小。
|
||
writeApiStorageObject(thumbnailObjectKey, promptWizardThumbnailWebpBase64);
|
||
postgres(`
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES ('${folderId}', '${userId}', '全栈提示词向导图片', 'uploaded-image', false);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key, size_bytes,
|
||
deletion_status, thumbnail_status, thumbnail_updated_at, created_at, updated_at
|
||
) VALUES (
|
||
'${assetId}', '${userId}', '${folderId}', '全栈提示词向导产品.png', 'image', 'uploaded-image',
|
||
'fullstack/prompt-wizard/${assetId}.png', '${thumbnailObjectKey}', 128,
|
||
'active', 'ready', now(), now(), now()
|
||
);
|
||
`);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT engine_version::text
|
||
FROM prompt_wizard_settings
|
||
WHERE singleton_id = 1;
|
||
`)).toBe('3');
|
||
|
||
try {
|
||
const rejectedEngineRollback = await api(admin.page, '/api/admin/prompt-wizard-settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-prompt-engine-v2-${randomUUID()}` },
|
||
body: { engineVersion: 2 },
|
||
});
|
||
expect(rejectedEngineRollback.status).toBe(400);
|
||
|
||
const creditsBefore = await readCredits(session.page);
|
||
const runCountBeforeInvalidRequest = postgresScalar(`
|
||
SELECT count(*) FROM prompt_wizard_runs WHERE user_id = '${userId}'::uuid;
|
||
`);
|
||
const firstLastRequest = await api(session.page, '/api/ai/prompt-wizard/runs', {
|
||
method: 'POST',
|
||
body: { imageAssetIds: [assetId], referenceMode: 'first-last' },
|
||
});
|
||
expect(firstLastRequest.status).toBe(400);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM prompt_wizard_runs WHERE user_id = '${userId}'::uuid;
|
||
`)).toBe(runCountBeforeInvalidRequest);
|
||
|
||
const createdRun = await api(session.page, '/api/ai/prompt-wizard/runs', {
|
||
method: 'POST',
|
||
body: { imageAssetIds: [assetId], referenceMode: 'general' },
|
||
});
|
||
expect(createdRun.status).toBe(202);
|
||
expect(createdRun.body.run).toMatchObject({
|
||
status: 'analyzing',
|
||
revision: 0,
|
||
imageAssetIds: [assetId],
|
||
});
|
||
const analyzedRun = await waitForPromptWizardRun(session.page, createdRun.body.run.id);
|
||
expect(analyzedRun).toMatchObject({ status: 'ready', revision: 1 });
|
||
expect(analyzedRun.productProfile.visibleFacts).toEqual(['已收到 1 张产品图片']);
|
||
expect(analyzedRun.productProfile.colors).toBe('');
|
||
|
||
const productProfile = {
|
||
...analyzedRun.productProfile,
|
||
productName: '全栈测试便携产品',
|
||
category: '便携配件',
|
||
colors: '黑色',
|
||
materials: '哑光材质',
|
||
structure: '单体结构,外形以参考图为准',
|
||
logo: '无可确认的品牌标识',
|
||
usage: '按参考图所示的方向手持使用',
|
||
suggestedAudience: '需要便携日常用品的成年用户',
|
||
suggestedScenarios: '日常通勤与桌面使用',
|
||
visibleFacts: ['图片中存在一个黑色产品主体'],
|
||
needsConfirmation: [],
|
||
userConfirmedClaims: '仅使用用户已确认的便携属性,不使用价格或性能数字',
|
||
consistencyConstraints: '保持黑色哑光外观、单体结构和所有可见部件不变',
|
||
};
|
||
const creativeBrief = {
|
||
targetLanguage: '英语',
|
||
direction: 'problem-solution',
|
||
marketingGoal: 'conversion',
|
||
creativeIntensity: 'balanced',
|
||
};
|
||
const videoConfig = {
|
||
model: 'seedance-2.0-mini',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
};
|
||
const productionBrief = {
|
||
primaryPurchaseReason: '黑色哑光外观和便携单体结构',
|
||
appearanceMode: 'hands-only',
|
||
availableScenes: ['日常通勤', '桌面使用'],
|
||
availableAssets: ['产品参考图片', '手部素材'],
|
||
};
|
||
const configuredRun = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}`,
|
||
{
|
||
method: 'PATCH',
|
||
body: {
|
||
revision: analyzedRun.revision,
|
||
productProfile,
|
||
creativeBrief,
|
||
videoConfig,
|
||
productionBrief,
|
||
},
|
||
},
|
||
);
|
||
expect(configuredRun.status).toBe(200);
|
||
expect(configuredRun.body.run).toMatchObject({
|
||
status: 'ready',
|
||
revision: 2,
|
||
creativeBrief,
|
||
videoConfig,
|
||
productionBrief,
|
||
});
|
||
expect(configuredRun.body.run.productProfile.colors).toBe('');
|
||
expect(configuredRun.body.run.evidenceGraph.reliableEvidenceIds.length).toBeGreaterThan(0);
|
||
|
||
const retiredConceptSetRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/concept-sets`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-retired-concepts-${randomUUID()}`,
|
||
revision: configuredRun.body.run.revision,
|
||
},
|
||
},
|
||
);
|
||
expect(retiredConceptSetRequest.status).toBe(410);
|
||
expect(retiredConceptSetRequest.body.message).toBe('提示词流程已升级,请刷新后重新选择使用方向。');
|
||
|
||
const useDirectionSetRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/use-direction-sets`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-use-directions-${randomUUID()}`,
|
||
revision: configuredRun.body.run.revision,
|
||
},
|
||
},
|
||
);
|
||
expect(useDirectionSetRequest.status).toBe(202);
|
||
expect(useDirectionSetRequest.body.useDirectionSet).toMatchObject({ status: 'queued' });
|
||
const useDirectionSet = await waitForPromptWizardUseDirectionSet(
|
||
session.page,
|
||
useDirectionSetRequest.body.useDirectionSet.id,
|
||
);
|
||
expect(useDirectionSet.directions.length).toBeGreaterThanOrEqual(2);
|
||
expect(useDirectionSet.directions.length).toBeLessThanOrEqual(3);
|
||
expect(useDirectionSet.directions.every((direction: {
|
||
purchaseReason: string;
|
||
proofAction: string;
|
||
evidenceIds: string[];
|
||
sceneOptions: unknown[];
|
||
recommendedSceneId: string;
|
||
}) => (
|
||
direction.purchaseReason.length > 0
|
||
&& direction.proofAction.length > 0
|
||
&& direction.evidenceIds.length > 0
|
||
&& direction.sceneOptions.length >= 2
|
||
&& direction.sceneOptions.some((scene: { id: string }) => scene.id === direction.recommendedSceneId)
|
||
))).toBe(true);
|
||
const selectedUseDirection = useDirectionSet.directions[0];
|
||
const selectedScene = selectedUseDirection.sceneOptions.find((scene: { id: string }) => (
|
||
scene.id === selectedUseDirection.recommendedSceneId
|
||
));
|
||
expect(selectedScene).toBeTruthy();
|
||
|
||
const firstBatchKey = `fullstack-prompt-first-${randomUUID()}`;
|
||
const firstBatchRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: firstBatchKey,
|
||
revision: configuredRun.body.run.revision,
|
||
expectedGenerationCredits: 1,
|
||
useDirectionSetId: useDirectionSet.id,
|
||
selectedUseDirectionId: selectedUseDirection.id,
|
||
selectedSceneId: selectedScene.id,
|
||
targetLanguage: '英语',
|
||
videoConfig,
|
||
},
|
||
},
|
||
);
|
||
expect(firstBatchRequest.status).toBe(202);
|
||
expect(firstBatchRequest.body.batch).toMatchObject({
|
||
status: 'queued',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
useDirectionSetId: useDirectionSet.id,
|
||
selectedUseDirectionId: selectedUseDirection.id,
|
||
selectedSceneId: selectedScene.id,
|
||
});
|
||
const firstBatch = await waitForPromptWizardBatch(
|
||
session.page,
|
||
firstBatchRequest.body.batch.id,
|
||
);
|
||
expect(firstBatch).toMatchObject({
|
||
status: 'completed',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
});
|
||
expect(firstBatch.plans).toHaveLength(2);
|
||
expect(new Set(firstBatch.plans.map((plan: { prompt: string }) => plan.prompt)).size).toBe(2);
|
||
expect(firstBatch.plans.every((plan: { prompt: string }) => plan.prompt.length <= 3_800)).toBe(true);
|
||
const forbiddenProductionPromptPatterns = [
|
||
['禁止事项区块', /【禁止事项】/u],
|
||
['视频规格区块', /【视频规格】/u],
|
||
['独立颜色字段', /^颜色:/mu],
|
||
['独立产品名称字段', /^产品名称:/mu],
|
||
['独立品类字段', /^品类:/mu],
|
||
['画面文字字段', /^画面文字(?:([^)]+))?[::]/mu],
|
||
['重复句末标点', /(?:。。|\.。|;。)/u],
|
||
] as const;
|
||
const invalidProductionPrompts = firstBatch.plans.flatMap((plan: { prompt: string }, planIndex: number) => (
|
||
forbiddenProductionPromptPatterns
|
||
.filter(([, pattern]) => pattern.test(plan.prompt))
|
||
.map(([label]) => ({ planIndex, label }))
|
||
));
|
||
expect(invalidProductionPrompts).toEqual([]);
|
||
expect(firstBatch.plans.every((plan: {
|
||
name: string;
|
||
hook: string;
|
||
narrative: string;
|
||
primaryScene: string;
|
||
cameraAndPacing: string;
|
||
differenceTags: string[];
|
||
prompt: string;
|
||
deliveryVersion: number;
|
||
variant: string;
|
||
useDirection: {
|
||
id: string;
|
||
purchaseReason: string;
|
||
proofAction: string;
|
||
evidenceIds: string[];
|
||
};
|
||
selectedScene: { id: string | null; title: string; environment: string; mainAction: string };
|
||
shots: Array<{
|
||
role: string;
|
||
location: string;
|
||
subject: string;
|
||
shotSize: string;
|
||
action: string;
|
||
cameraMovement: string;
|
||
lightingAndRealism: string;
|
||
voiceover: string;
|
||
voiceoverZh: string;
|
||
foley: string;
|
||
editingCue: string;
|
||
evidenceRefs: string[];
|
||
productionNote: string;
|
||
}>;
|
||
productionWarnings: string[];
|
||
}) => {
|
||
const chineseFields = [
|
||
plan.name,
|
||
plan.hook,
|
||
plan.narrative,
|
||
plan.primaryScene,
|
||
plan.cameraAndPacing,
|
||
...plan.differenceTags,
|
||
];
|
||
const visualLines = Array.from(plan.prompt.matchAll(/^画面:(.+)$/gmu), match => match[1] ?? '');
|
||
const voiceoverLines = Array.from(plan.prompt.matchAll(/^旁白(英语):(.+)$/gmu), match => match[1] ?? '');
|
||
return chineseFields.every(value => /[\u3400-\u9fff]/u.test(value))
|
||
&& plan.deliveryVersion === 5
|
||
&& ['reliable', 'hook'].includes(plan.variant)
|
||
&& plan.useDirection.id === selectedUseDirection.id
|
||
&& plan.useDirection.purchaseReason === selectedUseDirection.purchaseReason
|
||
&& plan.useDirection.proofAction === selectedUseDirection.proofAction
|
||
&& plan.useDirection.evidenceIds.length > 0
|
||
&& plan.selectedScene.id === selectedScene.id
|
||
&& plan.selectedScene.title === selectedScene.title
|
||
&& plan.shots.length === 3
|
||
&& plan.shots.map(shot => shot.role).join('|') === 'hook|proof-resolution|cta'
|
||
&& plan.shots.every(shot => (
|
||
[
|
||
shot.location,
|
||
shot.subject,
|
||
shot.shotSize,
|
||
shot.action,
|
||
shot.cameraMovement,
|
||
shot.lightingAndRealism,
|
||
shot.foley,
|
||
shot.editingCue,
|
||
].every(value => /[\u3400-\u9fff]/u.test(value))
|
||
&& (!shot.productionNote || /[\u3400-\u9fff]/u.test(shot.productionNote))
|
||
&& /[A-Za-z]/u.test(shot.voiceover)
|
||
&& /[\u3400-\u9fff]/u.test(shot.voiceoverZh)
|
||
&& !plan.prompt.includes(shot.voiceoverZh)
|
||
&& (!shot.productionNote || !plan.prompt.includes(shot.productionNote))
|
||
))
|
||
&& plan.shots[1]!.evidenceRefs.length > 0
|
||
&& !('alternativeHooks' in plan)
|
||
&& !('translationSegments' in plan)
|
||
&& !('concept' in plan)
|
||
&& !('strategy' in plan)
|
||
&& plan.productionWarnings.every(value => /[\u3400-\u9fff]/u.test(value))
|
||
&& visualLines.length > 0
|
||
&& visualLines.every(value => /[\u3400-\u9fff]/u.test(value))
|
||
&& voiceoverLines.length > 0
|
||
&& voiceoverLines.every(value => /[A-Za-z]/u.test(value));
|
||
})).toBe(true);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM credit_ledger
|
||
WHERE related_prompt_batch_id = '${firstBatch.id}'::uuid;
|
||
`)).toBe('0');
|
||
|
||
const sourcePlan = firstBatch.plans[0];
|
||
const untouchedPlan = structuredClone(firstBatch.plans[1]);
|
||
const refinementRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/batches/${firstBatch.id}/plans/${sourcePlan.id}/refinements`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-refinement-${randomUUID()}`,
|
||
action: 'stronger-hook',
|
||
},
|
||
},
|
||
);
|
||
expect(refinementRequest.status).toBe(202);
|
||
expect(refinementRequest.body.revision).toMatchObject({
|
||
status: 'queued',
|
||
isFree: true,
|
||
sourcePlanId: sourcePlan.id,
|
||
planIndex: 0,
|
||
});
|
||
const refinedPlan = await waitForPromptWizardPlanRevision(
|
||
session.page,
|
||
refinementRequest.body.revision.id,
|
||
);
|
||
expect(refinedPlan).toMatchObject({
|
||
status: 'completed',
|
||
isFree: true,
|
||
sourcePlanId: sourcePlan.id,
|
||
planIndex: 0,
|
||
});
|
||
expect(refinedPlan.plan.deliveryVersion).toBe(5);
|
||
expect(refinedPlan.useDirectionId).toBe(selectedUseDirection.id);
|
||
expect(refinedPlan.plan.id).not.toBe(sourcePlan.id);
|
||
const unchangedBatch = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/batches/${firstBatch.id}`,
|
||
);
|
||
expect(unchangedBatch.status).toBe(200);
|
||
expect(unchangedBatch.body.batch.plans[1]).toEqual(untouchedPlan);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore);
|
||
|
||
const secondRefinement = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/batches/${firstBatch.id}/plans/${firstBatch.plans[1].id}/refinements`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-refinement-used-${randomUUID()}`,
|
||
action: 'natural-voiceover',
|
||
},
|
||
},
|
||
);
|
||
expect(secondRefinement.status).toBe(409);
|
||
expect(secondRefinement.body.code).toBe('PROMPT_WIZARD_FREE_REFINEMENT_USED');
|
||
|
||
// “换一批”继续复用用户已经确认的同一使用方向和场景,只重新扩写 A/B 两套表现方式。
|
||
const refreshBatchKey = `fullstack-prompt-refresh-${randomUUID()}`;
|
||
const refreshPayload = {
|
||
idempotencyKey: refreshBatchKey,
|
||
revision: configuredRun.body.run.revision,
|
||
expectedGenerationCredits: 1,
|
||
useDirectionSetId: useDirectionSet.id,
|
||
selectedUseDirectionId: selectedUseDirection.id,
|
||
selectedSceneId: selectedScene.id,
|
||
targetLanguage: '英语',
|
||
videoConfig,
|
||
};
|
||
const refreshBatchRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: refreshPayload },
|
||
);
|
||
expect(refreshBatchRequest.status).toBe(202);
|
||
expect(refreshBatchRequest.body.batch).toMatchObject({
|
||
status: 'queued',
|
||
isFree: false,
|
||
credits: 1,
|
||
creditStatus: 'held',
|
||
});
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
|
||
const refreshedBatch = await waitForPromptWizardBatch(
|
||
session.page,
|
||
refreshBatchRequest.body.batch.id,
|
||
);
|
||
expect(refreshedBatch).toMatchObject({
|
||
status: 'completed',
|
||
isFree: false,
|
||
credits: 1,
|
||
creditStatus: 'settled',
|
||
});
|
||
expect(refreshedBatch.plans).toHaveLength(2);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', type, settlement_status, amount::text)
|
||
FROM credit_ledger
|
||
WHERE related_prompt_batch_id = '${refreshedBatch.id}'::uuid;
|
||
`)).toBe('spend|settled|-1');
|
||
|
||
const replayedRefresh = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${analyzedRun.id}/batches`,
|
||
{ method: 'POST', body: refreshPayload },
|
||
);
|
||
expect(replayedRefresh.status).toBe(202);
|
||
expect(replayedRefresh.body.batch).toEqual(refreshedBatch);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM credit_ledger
|
||
WHERE related_prompt_batch_id = '${refreshedBatch.id}'::uuid;
|
||
`)).toBe('1');
|
||
|
||
// 关闭弹窗在前端的等价语义是丢弃旧 run;重新打开必须创建全新 run,不读取旧批次历史。
|
||
const reopenedRunRequest = await api(session.page, '/api/ai/prompt-wizard/runs', {
|
||
method: 'POST',
|
||
body: { imageAssetIds: [assetId], referenceMode: 'general' },
|
||
});
|
||
expect(reopenedRunRequest.status).toBe(202);
|
||
expect(reopenedRunRequest.body.run.id).not.toBe(analyzedRun.id);
|
||
const reopenedRun = await waitForPromptWizardRun(session.page, reopenedRunRequest.body.run.id);
|
||
const reopenedConfigured = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${reopenedRun.id}`,
|
||
{
|
||
method: 'PATCH',
|
||
body: {
|
||
revision: reopenedRun.revision,
|
||
productProfile,
|
||
creativeBrief,
|
||
videoConfig,
|
||
productionBrief,
|
||
},
|
||
},
|
||
);
|
||
expect(reopenedConfigured.status).toBe(200);
|
||
|
||
const reopenedUseDirectionSetRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${reopenedRun.id}/use-direction-sets`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-reopen-use-directions-${randomUUID()}`,
|
||
revision: reopenedConfigured.body.run.revision,
|
||
},
|
||
},
|
||
);
|
||
expect(reopenedUseDirectionSetRequest.status).toBe(202);
|
||
const reopenedUseDirectionSet = await waitForPromptWizardUseDirectionSet(
|
||
session.page,
|
||
reopenedUseDirectionSetRequest.body.useDirectionSet.id,
|
||
);
|
||
const reopenedDirection = reopenedUseDirectionSet.directions[0];
|
||
const reopenedCustomScene = {
|
||
title: '全栈自定义桌面演示',
|
||
environment: '自然光桌面上的全栈自定义拍摄区域',
|
||
mainAction: reopenedDirection.proofAction,
|
||
};
|
||
|
||
const reopenedBatchRequest = await api(
|
||
session.page,
|
||
`/api/ai/prompt-wizard/runs/${reopenedRun.id}/batches`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-prompt-reopen-${randomUUID()}`,
|
||
revision: reopenedConfigured.body.run.revision,
|
||
expectedGenerationCredits: 1,
|
||
useDirectionSetId: reopenedUseDirectionSet.id,
|
||
selectedUseDirectionId: reopenedDirection.id,
|
||
customScene: reopenedCustomScene,
|
||
targetLanguage: '英语',
|
||
videoConfig,
|
||
},
|
||
},
|
||
);
|
||
expect(reopenedBatchRequest.status).toBe(202);
|
||
expect(reopenedBatchRequest.body.batch).toMatchObject({
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
selectedSceneId: null,
|
||
customScene: reopenedCustomScene,
|
||
});
|
||
const reopenedBatch = await waitForPromptWizardBatch(
|
||
session.page,
|
||
reopenedBatchRequest.body.batch.id,
|
||
);
|
||
expect(reopenedBatch).toMatchObject({
|
||
status: 'completed',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
});
|
||
expect(reopenedBatch.plans).toHaveLength(2);
|
||
expect(reopenedBatch.plans.every((plan: { deliveryVersion: number }) => (
|
||
plan.deliveryVersion === 5
|
||
))).toBe(true);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
expect(await readCredits(admin.page)).toBe(primaryCreditsBefore);
|
||
|
||
const financialSnapshotBeforePurge = postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT count(*) FROM credit_allocations WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_allocations WHERE user_id = '${userId}'::uuid)
|
||
);
|
||
`);
|
||
const providerSnapshotBeforePurge = postgresScalar(`
|
||
SELECT concat_ws('|', count(*), coalesce(sum(input_tokens), 0),
|
||
coalesce(sum(output_tokens), 0), coalesce(sum(cost_cny), 0))
|
||
FROM prompt_wizard_provider_requests
|
||
WHERE user_id = '${userId}'::uuid;
|
||
`);
|
||
|
||
// 同时过期一个预设场景 run 与一个自定义场景 run,真实 worker 维护任务必须
|
||
// 清空业务正文而不触发 070 shape check/trigger,也不得改动不可变财务证据。
|
||
postgres(`
|
||
UPDATE prompt_wizard_runs
|
||
SET expires_at = now() - interval '1 minute'
|
||
WHERE id IN ('${analyzedRun.id}'::uuid, '${reopenedRun.id}'::uuid);
|
||
`);
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM prompt_wizard_use_direction_sets
|
||
WHERE id IN ('${useDirectionSet.id}'::uuid, '${reopenedUseDirectionSet.id}'::uuid)
|
||
AND directions IS NULL AND content_purged_at IS NOT NULL),
|
||
(SELECT count(*) FROM prompt_wizard_batches
|
||
WHERE run_id IN ('${analyzedRun.id}'::uuid, '${reopenedRun.id}'::uuid)
|
||
AND plans IS NULL AND content_purged_at IS NOT NULL)
|
||
);
|
||
`), { timeout: 45_000, intervals: [1_000, 2_000, 3_000] }).toBe('2|3');
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM prompt_wizard_runs
|
||
WHERE id IN ('${analyzedRun.id}'::uuid, '${reopenedRun.id}'::uuid)
|
||
AND status = 'archived' AND product_profile IS NULL
|
||
AND evidence_graph IS NULL),
|
||
(SELECT count(*) FROM prompt_wizard_batches
|
||
WHERE id = '${firstBatch.id}'::uuid
|
||
AND selected_scene_id = '${selectedScene.id}'::uuid
|
||
AND custom_scene IS NULL AND plans IS NULL),
|
||
(SELECT count(*) FROM prompt_wizard_batches
|
||
WHERE id = '${reopenedBatch.id}'::uuid
|
||
AND selected_scene_id IS NULL AND custom_scene IS NULL AND plans IS NULL)
|
||
);
|
||
`)).toBe('2|1|1');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM prompt_wizard_use_direction_sets
|
||
WHERE id IN ('${useDirectionSet.id}'::uuid, '${reopenedUseDirectionSet.id}'::uuid)
|
||
AND directions IS NULL AND clarification_question IS NULL
|
||
AND failure_reason IS NULL AND content_purged_at IS NOT NULL),
|
||
(SELECT count(*) FROM prompt_wizard_plan_revisions
|
||
WHERE id = '${refinedPlan.id}'::uuid AND plan IS NULL
|
||
AND content_purged_at IS NOT NULL)
|
||
);
|
||
`)).toBe('2|1');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_ledger WHERE user_id = '${userId}'::uuid),
|
||
(SELECT count(*) FROM credit_allocations WHERE user_id = '${userId}'::uuid),
|
||
(SELECT coalesce(sum(amount), 0) FROM credit_allocations WHERE user_id = '${userId}'::uuid)
|
||
);
|
||
`)).toBe(financialSnapshotBeforePurge);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', count(*), coalesce(sum(input_tokens), 0),
|
||
coalesce(sum(output_tokens), 0), coalesce(sum(cost_cny), 0))
|
||
FROM prompt_wizard_provider_requests
|
||
WHERE user_id = '${userId}'::uuid;
|
||
`)).toBe(providerSnapshotBeforePurge);
|
||
expect(await readCredits(session.page)).toBe(creditsBefore - 1);
|
||
} finally {
|
||
await session.context.close();
|
||
await admin.context.close();
|
||
// 计费流水与积分分配是不可变历史,不为测试清理而绕过数据库保护。
|
||
postgres(`DELETE FROM assets WHERE id = '${assetId}'::uuid;`);
|
||
postgres(`DELETE FROM asset_folders WHERE id = '${folderId}'::uuid;`);
|
||
removeApiStorageObject(thumbnailObjectKey);
|
||
}
|
||
});
|
||
|
||
test('管理员修改套餐会在真实账户生效,带入标准主积分并保留加购积分', async ({ browser }) => {
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const affectedUser = await createAuthenticatedPage(browser, secondaryAccount);
|
||
try {
|
||
const usersResult = await api(
|
||
admin.page,
|
||
`/api/admin/users?search=${encodeURIComponent(secondaryAccount.email)}&limit=20`,
|
||
);
|
||
expect(usersResult.status).toBe(200);
|
||
const initialUser = usersResult.body.items.find(
|
||
(item: { email: string }) => item.email === secondaryAccount.email,
|
||
);
|
||
expect(initialUser).toBeTruthy();
|
||
expect(initialUser).toMatchObject({
|
||
addonCredits: 0,
|
||
});
|
||
|
||
const plansResult = await api(admin.page, '/api/admin/plans');
|
||
expect(plansResult.status).toBe(200);
|
||
const initialPlan = plansResult.body.items.find(
|
||
(item: { id: string }) => item.id === initialUser.planId,
|
||
);
|
||
expect(initialPlan).toBeTruthy();
|
||
expect(initialUser.mainCredits).toBe(initialPlan.credits);
|
||
const targetPlan = plansResult.body.items.find(
|
||
(item: { id: string; code: string; kind: string; status: string }) => (
|
||
item.id !== initialUser.planId
|
||
&& item.code !== 'free'
|
||
&& item.kind === 'main'
|
||
&& item.status === 'active'
|
||
),
|
||
);
|
||
expect(targetPlan).toBeTruthy();
|
||
|
||
const seededCredits = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-credit-seed-${randomUUID()}` },
|
||
body: {
|
||
userId: initialUser.id,
|
||
mainDelta: 0,
|
||
addonDelta: 25,
|
||
note: '全栈测试:准备套餐切换前积分',
|
||
},
|
||
});
|
||
expect(seededCredits.status).toBe(200);
|
||
expect(seededCredits.body.user).toMatchObject({
|
||
mainCredits: initialUser.mainCredits,
|
||
addonCredits: 25,
|
||
credits: initialUser.mainCredits + 25,
|
||
});
|
||
|
||
const billingDate = postgresScalar(`
|
||
SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD');
|
||
`);
|
||
const resetDate = postgresScalar(`
|
||
SELECT to_char((now() AT TIME ZONE 'Asia/Shanghai')::date + 30, 'YYYY-MM-DD');
|
||
`);
|
||
const expiresDate = postgresScalar(`
|
||
SELECT to_char((now() AT TIME ZONE 'Asia/Shanghai')::date + 365, 'YYYY-MM-DD');
|
||
`);
|
||
const billingCycle = targetPlan.billing === 'yearly' ? 'yearly' : 'monthly';
|
||
const subscriptionKey = `fullstack-subscription-${randomUUID()}`;
|
||
const paidPayload = {
|
||
planId: targetPlan.id,
|
||
billingCycle,
|
||
planResetAt: resetDate,
|
||
planExpiresAt: expiresDate,
|
||
note: '全栈测试:切换到另一启用主套餐',
|
||
expected: {
|
||
planId: seededCredits.body.user.planId,
|
||
billingCycle: seededCredits.body.user.billingCycle,
|
||
planCycleStartedAt: seededCredits.body.user.planCycleStartedAt,
|
||
planResetAt: seededCredits.body.user.planResetAt,
|
||
planExpiresAt: seededCredits.body.user.planExpiresAt,
|
||
activeSubscriptionOrderId: seededCredits.body.user.activeSubscriptionOrderId,
|
||
mainCredits: seededCredits.body.user.mainCredits,
|
||
},
|
||
expectedPlanCredits: targetPlan.credits,
|
||
};
|
||
const paidResult = await api(
|
||
admin.page,
|
||
`/api/admin/users/${initialUser.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': subscriptionKey },
|
||
body: paidPayload,
|
||
},
|
||
);
|
||
expect(paidResult.status).toBe(200);
|
||
expect(paidResult.body.user).toMatchObject({
|
||
planId: targetPlan.id,
|
||
planCode: targetPlan.code,
|
||
planName: targetPlan.name,
|
||
billingCycle,
|
||
mainCredits: targetPlan.credits,
|
||
addonCredits: 25,
|
||
credits: targetPlan.credits + 25,
|
||
totalCredits: targetPlan.credits + 25,
|
||
planCycleStartedAt: billingDate,
|
||
planResetAt: resetDate,
|
||
planExpiresAt: expiresDate,
|
||
});
|
||
expect(paidResult.body.entry).toMatchObject({
|
||
bucket: 'main',
|
||
amount: targetPlan.credits - initialUser.mainCredits,
|
||
balance: targetPlan.credits + 25,
|
||
});
|
||
|
||
const replay = await api(
|
||
admin.page,
|
||
`/api/admin/users/${initialUser.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': subscriptionKey },
|
||
body: paidPayload,
|
||
},
|
||
);
|
||
expect(replay.status).toBe(200);
|
||
expect(replay.idempotencyReplayed).toBe('true');
|
||
expect(replay.body).toEqual(paidResult.body);
|
||
|
||
const userProfile = await api(affectedUser.page, '/api/auth/me');
|
||
expect(userProfile.status).toBe(200);
|
||
expect(userProfile.body.user).toMatchObject({
|
||
planCode: targetPlan.code,
|
||
planName: targetPlan.name,
|
||
billingCycle,
|
||
mainCredits: targetPlan.credits,
|
||
addonCredits: 25,
|
||
credits: targetPlan.credits + 25,
|
||
totalCredits: targetPlan.credits + 25,
|
||
planResetAt: resetDate,
|
||
planExpiresAt: expiresDate,
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
COALESCE(sum(available_amount) FILTER (WHERE bucket = 'main'), 0)::text,
|
||
COALESCE(sum(available_amount) FILTER (WHERE bucket = 'addon'), 0)::text
|
||
)
|
||
FROM credit_lots
|
||
WHERE user_id = '${initialUser.id}'::uuid;
|
||
`)).toBe(`${targetPlan.credits}|25`);
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM audit_logs
|
||
WHERE target = '${initialUser.id}' AND action = '调整用户套餐';
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM orders
|
||
WHERE user_id = '${initialUser.id}'::uuid;
|
||
`)).toBe('0');
|
||
|
||
const freeResult = await api(
|
||
admin.page,
|
||
`/api/admin/users/${initialUser.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-subscription-free-${randomUUID()}` },
|
||
body: {
|
||
planId: null,
|
||
billingCycle: null,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
note: '全栈测试:切换免费版并验证清零规则',
|
||
expected: {
|
||
planId: paidResult.body.user.planId,
|
||
billingCycle: paidResult.body.user.billingCycle,
|
||
planCycleStartedAt: paidResult.body.user.planCycleStartedAt,
|
||
planResetAt: paidResult.body.user.planResetAt,
|
||
planExpiresAt: paidResult.body.user.planExpiresAt,
|
||
activeSubscriptionOrderId: paidResult.body.user.activeSubscriptionOrderId,
|
||
mainCredits: paidResult.body.user.mainCredits,
|
||
},
|
||
expectedPlanCredits: 0,
|
||
},
|
||
},
|
||
);
|
||
expect(freeResult.status).toBe(200);
|
||
expect(freeResult.body.user).toMatchObject({
|
||
planId: null,
|
||
planCode: null,
|
||
planName: null,
|
||
mainCredits: 0,
|
||
addonCredits: 25,
|
||
credits: 25,
|
||
totalCredits: 25,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
});
|
||
const freeProfile = await api(affectedUser.page, '/api/auth/me');
|
||
expect(freeProfile.status).toBe(200);
|
||
expect(freeProfile.body.user).toMatchObject({
|
||
planCode: null,
|
||
planName: null,
|
||
mainCredits: 0,
|
||
addonCredits: 25,
|
||
credits: 25,
|
||
totalCredits: 25,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
});
|
||
|
||
const restoredResult = await api(
|
||
admin.page,
|
||
`/api/admin/users/${initialUser.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-subscription-restore-${randomUUID()}` },
|
||
body: {
|
||
planId: initialPlan.id,
|
||
billingCycle: initialUser.billingCycle,
|
||
planResetAt: initialUser.planResetAt,
|
||
planExpiresAt: initialUser.planExpiresAt,
|
||
note: '全栈测试:恢复引导账号初始套餐',
|
||
expected: {
|
||
planId: freeResult.body.user.planId,
|
||
billingCycle: freeResult.body.user.billingCycle,
|
||
planCycleStartedAt: freeResult.body.user.planCycleStartedAt,
|
||
planResetAt: freeResult.body.user.planResetAt,
|
||
planExpiresAt: freeResult.body.user.planExpiresAt,
|
||
activeSubscriptionOrderId: freeResult.body.user.activeSubscriptionOrderId,
|
||
mainCredits: freeResult.body.user.mainCredits,
|
||
},
|
||
expectedPlanCredits: initialPlan.credits,
|
||
},
|
||
},
|
||
);
|
||
expect(restoredResult.status).toBe(200);
|
||
expect(restoredResult.body.user).toMatchObject({
|
||
planId: initialPlan.id,
|
||
planCode: initialPlan.code,
|
||
planName: initialPlan.name,
|
||
billingCycle: initialUser.billingCycle,
|
||
mainCredits: initialPlan.credits,
|
||
addonCredits: 25,
|
||
credits: initialPlan.credits + 25,
|
||
totalCredits: initialPlan.credits + 25,
|
||
planCycleStartedAt: billingDate,
|
||
planResetAt: initialUser.planResetAt,
|
||
planExpiresAt: initialUser.planExpiresAt,
|
||
});
|
||
|
||
const cleanupCredits = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-credit-cleanup-${randomUUID()}` },
|
||
body: {
|
||
userId: initialUser.id,
|
||
mainDelta: 0,
|
||
addonDelta: -25,
|
||
note: '全栈测试:清理加购积分',
|
||
},
|
||
});
|
||
expect(cleanupCredits.status).toBe(200);
|
||
expect(cleanupCredits.body.user).toMatchObject({
|
||
planId: initialPlan.id,
|
||
mainCredits: initialPlan.credits,
|
||
addonCredits: 0,
|
||
credits: initialPlan.credits,
|
||
});
|
||
} finally {
|
||
await affectedUser.context.close();
|
||
await admin.context.close();
|
||
}
|
||
});
|
||
|
||
test('用户仪表盘在真实 PostgreSQL 上返回连续30个北京时间自然日并补零', async ({ browser }) => {
|
||
const ids = {
|
||
outsideWindow: randomUUID(),
|
||
windowStart: randomUUID(),
|
||
yesterday: randomUUID(),
|
||
today: randomUUID(),
|
||
};
|
||
const taskIds = Object.values(ids);
|
||
const today = postgresScalar(`
|
||
SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD');
|
||
`);
|
||
const yesterday = postgresScalar(`
|
||
SELECT to_char((now() AT TIME ZONE 'Asia/Shanghai')::date - 1, 'YYYY-MM-DD');
|
||
`);
|
||
const windowStart = postgresScalar(`
|
||
SELECT to_char((now() AT TIME ZONE 'Asia/Shanghai')::date - 29, 'YYYY-MM-DD');
|
||
`);
|
||
const windowSecondDay = postgresScalar(`
|
||
SELECT to_char((now() AT TIME ZONE 'Asia/Shanghai')::date - 28, 'YYYY-MM-DD');
|
||
`);
|
||
|
||
postgres(`
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, prompt, config,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, provider_task_attempt, processing_stage,
|
||
created_at, completed_at, updated_at
|
||
) VALUES
|
||
(
|
||
'${ids.outsideWindow}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0', 'completed', '仪表盘窗口外任务', '{}'::jsonb,
|
||
1, 1, 1, 0, 'settled', 0, 0, 'completed',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '30 days' + interval '12 hours') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '30 days' + interval '12 hours 1 minute') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '30 days' + interval '12 hours 1 minute') AT TIME ZONE 'Asia/Shanghai'
|
||
),
|
||
(
|
||
'${ids.windowStart}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0', 'completed', '仪表盘窗口首日任务', '{}'::jsonb,
|
||
1, 1, 1, 0, 'settled', 0, 0, 'completed',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '29 days' + interval '5 minutes') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '29 days' + interval '6 minutes') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '29 days' + interval '6 minutes') AT TIME ZONE 'Asia/Shanghai'
|
||
),
|
||
(
|
||
'${ids.yesterday}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0', 'completed', '仪表盘昨日边界任务', '{}'::jsonb,
|
||
1, 1, 1, 0, 'settled', 0, 0, 'completed',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '1 second') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '500 milliseconds') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') - interval '500 milliseconds') AT TIME ZONE 'Asia/Shanghai'
|
||
),
|
||
(
|
||
'${ids.today}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'image', 'Seedream 5.0 Pro', 'completed', '仪表盘今日边界任务', '{}'::jsonb,
|
||
1, 1, 1, 0, 'settled', 0, 0, 'completed',
|
||
date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') + interval '1 minute') AT TIME ZONE 'Asia/Shanghai',
|
||
(date_trunc('day', now() AT TIME ZONE 'Asia/Shanghai') + interval '1 minute') AT TIME ZONE 'Asia/Shanghai'
|
||
);
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const dashboard = await api(session.page, '/api/account/dashboard-summary');
|
||
expect(dashboard.status).toBe(200);
|
||
expect(dashboard.body.timezone).toBe('Asia/Shanghai');
|
||
expect(dashboard.body.trend).toHaveLength(30);
|
||
expect(dashboard.body.trend[0]).toEqual({ day: windowStart, videos: 1, images: 0 });
|
||
expect(dashboard.body.trend[1]).toEqual({ day: windowSecondDay, videos: 0, images: 0 });
|
||
expect(dashboard.body.trend[28]).toEqual({ day: yesterday, videos: 1, images: 0 });
|
||
expect(dashboard.body.trend[29]).toEqual({ day: today, videos: 0, images: 1 });
|
||
expect(dashboard.body.modelUsage).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ model: 'Seedream 5.0 Pro', count: 1 }),
|
||
]));
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`
|
||
DELETE FROM generation_tasks
|
||
WHERE id IN (${taskIds.map(id => `'${id}'::uuid`).join(', ')});
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('新注册用户在真实数据库中创建为零积分且无套餐关联的免费账户', async ({ browser }) => {
|
||
const verificationId = randomUUID();
|
||
const email = `fullstack-new-${randomUUID()}@lindyson.test`;
|
||
const userName = '全栈新用户';
|
||
const verificationCode = '135790';
|
||
const verificationSalt = 'fullstack-registration-salt';
|
||
const verificationHash = createHash('sha256')
|
||
.update(`${verificationSalt}:${verificationCode}`)
|
||
.digest('hex');
|
||
postgres(`
|
||
INSERT INTO registration_verification_codes (
|
||
id, email, user_name, code_hash, code_salt, expires_at
|
||
) VALUES (
|
||
'${verificationId}', '${email}', '${userName}',
|
||
'${verificationHash}', '${verificationSalt}', now() + interval '10 minutes'
|
||
);
|
||
`);
|
||
|
||
const context = await browser.newContext();
|
||
const page = await context.newPage();
|
||
try {
|
||
await page.goto('/');
|
||
const registration = await api(page, '/api/auth/register', {
|
||
method: 'POST',
|
||
body: {
|
||
userName,
|
||
email,
|
||
password: 'FullstackUser2026!',
|
||
verificationId,
|
||
verificationCode,
|
||
},
|
||
});
|
||
|
||
expect(registration.status).toBe(200);
|
||
expect(registration.body.user).toMatchObject({
|
||
email,
|
||
userName,
|
||
planCode: null,
|
||
planName: null,
|
||
mainCredits: 0,
|
||
addonCredits: 0,
|
||
credits: 0,
|
||
totalCredits: 0,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
coalesce(plan_id::text, 'NULL'),
|
||
main_credits::text,
|
||
addon_credits::text,
|
||
total_credits::text,
|
||
coalesce(plan_reset_at::text, 'NULL'),
|
||
coalesce(plan_expires_at::text, 'NULL')
|
||
)
|
||
FROM users WHERE email = '${email}';
|
||
`)).toBe('NULL|0|0|0|NULL|NULL');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM credit_ledger
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${email}');
|
||
`)).toBe('0');
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
});
|
||
|
||
test('成本看盘在真实 PostgreSQL 上按任务口径聚合并支持自选周期与高级对账', async ({ browser }) => {
|
||
const ids = {
|
||
oldBillingRun: '10000000-0000-4000-8000-000000000001',
|
||
newBillingRun: '10000000-0000-4000-8000-000000000002',
|
||
oldReconciliationRun: '10000000-0000-4000-8000-000000000011',
|
||
newReconciliationRun: '10000000-0000-4000-8000-000000000012',
|
||
oldBucket: '10000000-0000-4000-8000-000000000021',
|
||
newBucket: '10000000-0000-4000-8000-000000000022',
|
||
newMiniBucket: '10000000-0000-4000-8000-000000000023',
|
||
olderTask: '10000000-0000-4000-8000-000000000031',
|
||
newerTask: '10000000-0000-4000-8000-000000000032',
|
||
olderAttemptOne: '10000000-0000-4000-8000-000000000041',
|
||
olderAttemptTwo: '10000000-0000-4000-8000-000000000042',
|
||
newerAttempt: '10000000-0000-4000-8000-000000000043',
|
||
fastRevision: '10000000-0000-4000-8000-000000000051',
|
||
miniRevision: '10000000-0000-4000-8000-000000000052',
|
||
};
|
||
postgres(`
|
||
INSERT INTO provider_billing_sync_runs (
|
||
id, provider, kind, status, window_start, window_end,
|
||
page_count, row_count, started_at, completed_at, is_final
|
||
) VALUES
|
||
('${ids.oldBillingRun}', 'volcengine-ark', 'bill', 'success', '2026-07-01', '2026-07-01',
|
||
1, 1, now() - interval '3 minutes', now() - interval '3 minutes', false),
|
||
('${ids.newBillingRun}', 'volcengine-ark', 'bill', 'success', '2026-07-01', '2026-07-01',
|
||
1, 2, now() - interval '1 minute', now() - interval '1 minute', false);
|
||
|
||
INSERT INTO provider_bill_detail_revisions (
|
||
id, provider, source_detail_key, sync_run_id, payload_hash, raw_payload,
|
||
normalized_payload, normalization_version, observed_at
|
||
) VALUES
|
||
(
|
||
'${ids.fastRevision}', 'volcengine-ark', 'fullstack-fast-2026-07-01',
|
||
'${ids.newBillingRun}', repeat('e', 64), '{}'::jsonb,
|
||
jsonb_build_object(
|
||
'billPeriod', '2026-07', 'expenseDate', '2026-07-01', 'product', 'ark_bd',
|
||
'billLineType', 'usage', 'recognizedModel', 'Seedance 2.0 Fast',
|
||
'seedanceCandidate', true, 'pricingResolution', '480P/720P',
|
||
'pricingInputHasVideo', false, 'normalizedUnitPricePerMillion', 37,
|
||
'billableTokens', 200, 'deductionCount', 200,
|
||
'listEquivalentCostCny', 0.007400, 'originalBillAmountCny', 0,
|
||
'preferentialBillAmountCny', 0, 'discountBillAmountCny', 0,
|
||
'couponAmountCny', 0, 'payableAmountCny', 0, 'paidAmountCny', 0,
|
||
'unpaidAmountCny', 0, 'currency', 'CNY', 'instanceNo', 'fullstack-fast'
|
||
),
|
||
'fullstack-v1', now() - interval '1 minute'
|
||
),
|
||
(
|
||
'${ids.miniRevision}', 'volcengine-ark', 'fullstack-mini-2026-07-01',
|
||
'${ids.newBillingRun}', repeat('f', 64), '{}'::jsonb,
|
||
jsonb_build_object(
|
||
'billPeriod', '2026-07', 'expenseDate', '2026-07-01', 'product', 'ark_bd',
|
||
'billLineType', 'usage', 'recognizedModel', 'Seedance 2.0 Mini',
|
||
'seedanceCandidate', true, 'pricingResolution', '480P/720P',
|
||
'pricingInputHasVideo', false, 'normalizedUnitPricePerMillion', 23,
|
||
'billableTokens', 100, 'deductionCount', 100,
|
||
'listEquivalentCostCny', 0.002300, 'originalBillAmountCny', 0,
|
||
'preferentialBillAmountCny', 0, 'discountBillAmountCny', 0,
|
||
'couponAmountCny', 0, 'payableAmountCny', 0, 'paidAmountCny', 0,
|
||
'unpaidAmountCny', 0, 'currency', 'CNY', 'instanceNo', 'fullstack-mini'
|
||
),
|
||
'fullstack-v1', now() - interval '1 minute'
|
||
);
|
||
|
||
INSERT INTO provider_bill_sync_run_items (
|
||
sync_run_id, bill_detail_revision_id, source_detail_key
|
||
) VALUES
|
||
('${ids.newBillingRun}', '${ids.fastRevision}', 'fullstack-fast-2026-07-01'),
|
||
('${ids.newBillingRun}', '${ids.miniRevision}', 'fullstack-mini-2026-07-01');
|
||
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, prompt, config, provider_task_id,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, provider_task_attempt, processing_stage, created_at, started_at,
|
||
completed_at, updated_at
|
||
) VALUES
|
||
(
|
||
'${ids.olderTask}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0 Fast', 'completed', '全栈成本聚合旧任务',
|
||
'{"resolution":"480p"}'::jsonb, 'fullstack-provider-fast',
|
||
40, 40, 40, 0, 'settled', 1, 1, 'completed',
|
||
'2026-07-01 09:00:00+08', '2026-07-01 09:01:00+08',
|
||
'2026-07-01 09:05:00+08', '2026-07-01 09:05:00+08'
|
||
),
|
||
(
|
||
'${ids.newerTask}',
|
||
(SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'video', 'Seedance 2.0 Mini', 'completed', '全栈成本聚合新任务',
|
||
'{"resolution":"480p"}'::jsonb, 'fullstack-provider-mini',
|
||
10, 10, 10, 0, 'settled', 0, 0, 'completed',
|
||
'2026-07-01 10:00:00+08', '2026-07-01 10:01:00+08',
|
||
'2026-07-01 10:05:00+08', '2026-07-01 10:05:00+08'
|
||
);
|
||
|
||
INSERT INTO provider_usage_records (
|
||
id, provider, model, task_id, attempt, provider_task_id, status,
|
||
raw_units, deducted_units, usage_unit, completed_at, completion_tokens,
|
||
total_tokens, billable_tokens, billable_basis, resolution, has_video_input,
|
||
price_version, unit_price_per_million, estimated_cost, currency, pricing_status,
|
||
usage_evidence_origin, pricing_rule_status, pricing_input_class,
|
||
pricing_resolution_band, attribution_confidence, provider_terminal_at,
|
||
usage_observed_at, local_accounting_date, accounting_date_origin, created_at
|
||
) VALUES
|
||
(
|
||
'${ids.olderAttemptOne}', 'volcengine-ark', 'Seedance 2.0 Fast',
|
||
'${ids.olderTask}', 0, 'fullstack-provider-fast-0', 'success',
|
||
80, 80, 'tokens', '2026-07-01 09:03:00+08', 80, 80, 80,
|
||
'completion_tokens', '480p', false, 'volcengine-seedance-2026-07-10',
|
||
37, 0.002960, 'CNY', 'priced', 'provider-reported', 'priced',
|
||
'without-video', '480p-720p', 'direct', '2026-07-01 09:03:00+08',
|
||
'2026-07-01 09:03:00+08', '2026-07-01', 'provider-terminal',
|
||
'2026-07-01 09:03:00+08'
|
||
),
|
||
(
|
||
'${ids.olderAttemptTwo}', 'volcengine-ark', 'Seedance 2.0 Fast',
|
||
'${ids.olderTask}', 1, 'fullstack-provider-fast-1', 'success',
|
||
120, 120, 'tokens', '2026-07-01 09:04:00+08', 120, 120, 120,
|
||
'completion_tokens', '480p', false, 'volcengine-seedance-2026-07-10',
|
||
37, 0.004440, 'CNY', 'priced', 'provider-reported', 'priced',
|
||
'without-video', '480p-720p', 'direct', '2026-07-01 09:04:00+08',
|
||
'2026-07-01 09:04:00+08', '2026-07-01', 'provider-terminal',
|
||
'2026-07-01 09:04:00+08'
|
||
),
|
||
(
|
||
'${ids.newerAttempt}', 'volcengine-ark', 'Seedance 2.0 Mini',
|
||
'${ids.newerTask}', 0, 'fullstack-provider-mini-0', 'success',
|
||
100, 100, 'tokens', '2026-07-01 10:04:00+08', 100, 100, 100,
|
||
'completion_tokens', '480p', false, 'volcengine-seedance-2026-07-10',
|
||
23, 0.002300, 'CNY', 'priced', 'provider-reported', 'priced',
|
||
'without-video', '480p-720p', 'direct', '2026-07-01 10:04:00+08',
|
||
'2026-07-01 10:04:00+08', '2026-07-01', 'provider-terminal',
|
||
'2026-07-01 10:04:00+08'
|
||
);
|
||
|
||
INSERT INTO provider_reconciliation_runs (
|
||
id, provider, bill_period, window_start, window_end, official_sync_run_id,
|
||
local_cutoff, normalization_version, official_snapshot_hash, local_snapshot_hash,
|
||
bill_state, match_state, coverage_state, official_tokens, official_cost_cny,
|
||
residual_tokens, residual_cost_cny, created_at
|
||
) VALUES
|
||
('${ids.oldReconciliationRun}', 'volcengine-ark', '2026-07', '2026-07-01', '2026-07-01',
|
||
'${ids.oldBillingRun}', now() - interval '3 minutes', 'fullstack-v1', repeat('a', 64), repeat('b', 64),
|
||
'provisional', 'residual', 'complete', 100, 0.002300, 100, 0.002300, now() - interval '3 minutes'),
|
||
('${ids.newReconciliationRun}', 'volcengine-ark', '2026-07', '2026-07-01', '2026-07-01',
|
||
'${ids.newBillingRun}', now() - interval '1 minute', 'fullstack-v1', repeat('c', 64), repeat('d', 64),
|
||
'provisional', 'raw-matched', 'complete', 300, 0.009700, 0, 0, now() - interval '1 minute');
|
||
|
||
INSERT INTO provider_reconciliation_buckets (
|
||
id, reconciliation_run_id, provider, bill_period, expense_date, bill_line_type,
|
||
product, recognized_model, pricing_resolution_band, pricing_input_class,
|
||
unit_price_per_million, currency, raw_local_tokens, raw_local_cost_cny,
|
||
adjusted_local_tokens, adjusted_local_cost_cny, official_tokens,
|
||
official_cost_cny, residual_tokens, residual_cost_cny, status
|
||
) VALUES
|
||
('${ids.oldBucket}', '${ids.oldReconciliationRun}', 'volcengine-ark', '2026-07', '2026-07-01',
|
||
'usage', 'ark_bd', 'Seedance 2.0 Mini', '480p-720p', 'without-video', 23, 'CNY',
|
||
0, 0, 0, 0, 100, 0.002300, 100, 0.002300, 'residual'),
|
||
('${ids.newBucket}', '${ids.newReconciliationRun}', 'volcengine-ark', '2026-07', '2026-07-01',
|
||
'usage', 'ark_bd', 'Seedance 2.0 Fast', '480p-720p', 'without-video', 37, 'CNY',
|
||
200, 0.007400, 200, 0.007400, 200, 0.007400, 0, 0, 'raw-matched'),
|
||
('${ids.newMiniBucket}', '${ids.newReconciliationRun}', 'volcengine-ark', '2026-07', '2026-07-01',
|
||
'usage', 'ark_bd', 'Seedance 2.0 Mini', '480p-720p', 'without-video', 23, 'CNY',
|
||
100, 0.002300, 100, 0.002300, 100, 0.002300, 0, 0, 'raw-matched');
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const range = 'range=custom&from=2026-07-01&to=2026-07-01';
|
||
try {
|
||
const dashboard = await api(session.page, `/api/admin/api-usage?${range}`);
|
||
expect(dashboard.status).toBe(200);
|
||
expect(dashboard.body.selection).toMatchObject({
|
||
range: 'custom', rangeStart: '2026-07-01', rangeEnd: '2026-07-01', timezone: 'Asia/Shanghai',
|
||
});
|
||
const snapshotRange = `${range}&asOf=${encodeURIComponent(dashboard.body.asOf)}`;
|
||
|
||
expect(dashboard.body.operations).toMatchObject({
|
||
taskCount: 2,
|
||
userCount: 2,
|
||
settledCredits: 50,
|
||
heldCredits: 0,
|
||
refundedCredits: 0,
|
||
estimatedCostCny: '0.009700',
|
||
estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0,
|
||
officialEquivalentCostCny: '0.009700',
|
||
differenceCny: '0.000000',
|
||
officialPayableCny: '0.000000',
|
||
reconciliationStatus: 'matched',
|
||
});
|
||
expect(dashboard.body.operations.models).toEqual([
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.5', priceVersions: [], taskCount: 0,
|
||
operationalCallCount: 0, settledCredits: 0, heldCredits: 0, refundedCredits: 0,
|
||
estimatedCostCny: '0.000000', businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '0.000000', knownAccountCostCny: '0.000000',
|
||
operationalUnpricedCallCount: 0, estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0, averageTaskCostCny: null,
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0', priceVersions: [], taskCount: 0,
|
||
operationalCallCount: 0, settledCredits: 0, heldCredits: 0, refundedCredits: 0,
|
||
estimatedCostCny: '0.000000', businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '0.000000', knownAccountCostCny: '0.000000',
|
||
operationalUnpricedCallCount: 0, estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0, averageTaskCostCny: null,
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0 Fast', taskCount: 1, settledCredits: 40,
|
||
operationalCallCount: 0, estimatedCostCny: '0.007400',
|
||
businessEstimatedCostCny: '0.007400', operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.007400', operationalUnpricedCallCount: 0,
|
||
estimateCompleteness: 'complete', averageTaskCostCny: '0.007400',
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0 Mini', taskCount: 1, settledCredits: 10,
|
||
operationalCallCount: 0, estimatedCostCny: '0.002300',
|
||
businessEstimatedCostCny: '0.002300', operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.002300', operationalUnpricedCallCount: 0,
|
||
estimateCompleteness: 'complete', averageTaskCostCny: '0.002300',
|
||
}),
|
||
]);
|
||
|
||
const tasks = await api(session.page, `/api/admin/api-usage/tasks?${snapshotRange}&limit=20`);
|
||
const users = await api(session.page, `/api/admin/api-usage/users?${snapshotRange}&limit=20`);
|
||
const attempts = await api(session.page, `/api/admin/api-usage/attempts?${snapshotRange}&limit=20`);
|
||
const settlement = await api(session.page, `/api/admin/api-usage/settlement?${snapshotRange}&limit=20`);
|
||
for (const response of [tasks, users, attempts, settlement]) expect(response.status).toBe(200);
|
||
|
||
expect(tasks.body.items.map((item: { id: string }) => item.id)).toEqual([
|
||
ids.newerTask,
|
||
ids.olderTask,
|
||
]);
|
||
const taskCreatedAt = tasks.body.items.map((item: { createdAt: string }) => item.createdAt);
|
||
expect(taskCreatedAt).toEqual([...taskCreatedAt].sort((left, right) => right.localeCompare(left)));
|
||
expect(tasks.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.newerTask, taskId: ids.newerTask, model: 'Seedance 2.0 Mini',
|
||
settledCredits: 10, attemptCount: 1, billableTokens: 100,
|
||
estimatedCostCny: '0.002300', estimateCompleteness: 'complete', costFinalized: true,
|
||
}),
|
||
expect.objectContaining({
|
||
id: ids.olderTask, taskId: ids.olderTask, model: 'Seedance 2.0 Fast',
|
||
settledCredits: 40, attemptCount: 2, billableTokens: 200,
|
||
estimatedCostCny: '0.007400', estimateCompleteness: 'complete', costFinalized: true,
|
||
}),
|
||
]);
|
||
expect(attempts.body.items).toHaveLength(3);
|
||
expect(attempts.body.items.filter(
|
||
(item: { taskId: string }) => item.taskId === ids.olderTask,
|
||
)).toHaveLength(2);
|
||
|
||
const taskCostMicros = tasks.body.items.reduce(
|
||
(sum: bigint, item: { estimatedCostCny: string }) => sum + parseCnyMicros(item.estimatedCostCny),
|
||
0n,
|
||
);
|
||
const taskCredits = tasks.body.items.reduce(
|
||
(sum: number, item: { settledCredits: number }) => sum + item.settledCredits,
|
||
0,
|
||
);
|
||
const userCostMicros = users.body.items.reduce(
|
||
(sum: bigint, item: { estimatedCostCny: string }) => sum + parseCnyMicros(item.estimatedCostCny),
|
||
0n,
|
||
);
|
||
const userCredits = users.body.items.reduce(
|
||
(sum: number, item: { settledCredits: number }) => sum + item.settledCredits,
|
||
0,
|
||
);
|
||
expect(users.body.items).toHaveLength(2);
|
||
expect(users.body.items.reduce(
|
||
(sum: number, item: { taskCount: number }) => sum + item.taskCount,
|
||
0,
|
||
)).toBe(dashboard.body.operations.taskCount);
|
||
expect(taskCostMicros).toBe(parseCnyMicros(dashboard.body.operations.estimatedCostCny));
|
||
expect(userCostMicros).toBe(taskCostMicros);
|
||
expect(taskCredits).toBe(dashboard.body.operations.settledCredits);
|
||
expect(userCredits).toBe(taskCredits);
|
||
|
||
for (const amount of [
|
||
dashboard.body.operations.estimatedCostCny,
|
||
dashboard.body.operations.officialEquivalentCostCny,
|
||
dashboard.body.operations.differenceCny,
|
||
dashboard.body.operations.officialPayableCny,
|
||
...dashboard.body.operations.models.map((item: { estimatedCostCny: string }) => item.estimatedCostCny),
|
||
...tasks.body.items.map((item: { estimatedCostCny: string }) => item.estimatedCostCny),
|
||
...users.body.items.map((item: { estimatedCostCny: string }) => item.estimatedCostCny),
|
||
...attempts.body.items.map((item: { estimatedCostCny: string }) => item.estimatedCostCny),
|
||
]) expect(String(amount)).toMatch(/^-?\d+\.\d{6}$/);
|
||
|
||
const reconciliation = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/reconciliation?${snapshotRange}&limit=20`,
|
||
);
|
||
expect(reconciliation.status).toBe(200);
|
||
expect(reconciliation.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.newBucket,
|
||
model: 'Seedance 2.0 Fast',
|
||
officialTokens: 200,
|
||
residualTokens: 0,
|
||
}),
|
||
expect.objectContaining({
|
||
id: ids.newMiniBucket,
|
||
model: 'Seedance 2.0 Mini',
|
||
officialTokens: 100,
|
||
residualTokens: 0,
|
||
}),
|
||
]);
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`
|
||
DELETE FROM provider_usage_records
|
||
WHERE id IN ('${ids.olderAttemptOne}', '${ids.olderAttemptTwo}', '${ids.newerAttempt}');
|
||
DELETE FROM generation_tasks
|
||
WHERE id IN ('${ids.olderTask}', '${ids.newerTask}');
|
||
DELETE FROM provider_reconciliation_runs
|
||
WHERE id IN ('${ids.oldReconciliationRun}', '${ids.newReconciliationRun}');
|
||
DELETE FROM provider_bill_sync_run_items
|
||
WHERE sync_run_id IN ('${ids.oldBillingRun}', '${ids.newBillingRun}');
|
||
-- Migration 076 后官方 revision 不可变;其所属同步 run 随隔离全栈库销毁。
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('多模态成本看盘在真实 PostgreSQL 上隔离逐请求、历史聚合定价与免费付费批次', async ({ browser }) => {
|
||
const ids = {
|
||
knownRun: randomUUID(),
|
||
reconciledRun: randomUUID(),
|
||
historicalRun: randomUUID(),
|
||
parentRun: randomUUID(),
|
||
geminiRun: randomUUID(),
|
||
olderGeminiRun: randomUUID(),
|
||
mixedRun: randomUUID(),
|
||
terminalUnknownRun: randomUUID(),
|
||
inFlightUnknownRun: randomUUID(),
|
||
freeBatch: randomUUID(),
|
||
paidBatch: randomUUID(),
|
||
geminiBatch: randomUUID(),
|
||
referenceAsset: randomUUID(),
|
||
requests: Array.from({ length: 10 }, () => randomUUID()),
|
||
};
|
||
const lowRuleId = 'volcengine-doubao-seed-2-0-lite-260428-default-2026-07-23-0-32k';
|
||
|
||
postgres(`
|
||
INSERT INTO prompt_wizard_runs (
|
||
id, user_id, status, revision, image_asset_ids, failure_reason,
|
||
provider_model, provider_input_tokens, provider_output_tokens, attempt_count,
|
||
started_at, completed_at, expires_at, created_at, updated_at
|
||
) VALUES
|
||
(
|
||
'${ids.reconciledRun}', (SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'failed', 0, ARRAY['${ids.referenceAsset}'::uuid], '全栈成本测试:供应商未返回 usage',
|
||
'doubao-seed-2-0-lite-260428', NULL, NULL, 1,
|
||
'2026-07-23 17:10:01+08', '2026-07-23 17:10:20+08', '2099-01-01 00:00:00+08',
|
||
'2026-07-23 17:10:00+08', '2026-07-23 17:10:20+08'
|
||
),
|
||
(
|
||
'${ids.knownRun}', (SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'failed', 0, ARRAY['${ids.referenceAsset}'::uuid], '全栈成本测试:修复请求失败',
|
||
'doubao-seed-2-0-lite-260428', 150, 15, 1,
|
||
'2026-07-23 19:00:01+08', '2026-07-23 19:00:20+08', '2099-01-01 00:00:00+08',
|
||
'2026-07-23 19:00:00+08', '2026-07-23 19:00:20+08'
|
||
),
|
||
(
|
||
'${ids.historicalRun}', (SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'archived', 1, '{}'::uuid[], NULL,
|
||
'doubao-seed-2-0-lite-260428', 900, 120, 1,
|
||
'2026-07-23 19:10:01+08', '2026-07-23 19:10:20+08', '2026-07-23 19:11:00+08',
|
||
'2026-07-23 19:10:00+08', '2026-07-23 19:11:00+08'
|
||
),
|
||
(
|
||
'${ids.parentRun}', (SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'ready', 1, ARRAY['${ids.referenceAsset}'::uuid], NULL,
|
||
'doubao-seed-2-0-lite-260428', NULL, NULL, 1,
|
||
'2026-07-23 19:20:01+08', '2026-07-23 19:20:20+08', '2099-01-01 00:00:00+08',
|
||
'2026-07-23 19:20:00+08', '2026-07-23 19:20:20+08'
|
||
),
|
||
(
|
||
'${ids.geminiRun}', (SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'ready', 1, ARRAY['${ids.referenceAsset}'::uuid], NULL,
|
||
'gemini-3.5-flash', 700, 70, 1,
|
||
'2026-07-23 18:00:01+08', '2026-07-23 18:00:20+08', '2099-01-01 00:00:00+08',
|
||
'2026-07-23 18:00:00+08', '2026-07-23 18:00:20+08'
|
||
),
|
||
(
|
||
'${ids.olderGeminiRun}', (SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'archived', 1, '{}'::uuid[], NULL,
|
||
'gemini-3.5-flash', 800, 80, 1,
|
||
'1999-01-01 00:00:01+08', '1999-01-01 00:00:20+08', '1999-01-01 00:01:00+08',
|
||
'1999-01-01 00:00:00+08', '1999-01-01 00:01:00+08'
|
||
),
|
||
(
|
||
'${ids.mixedRun}', (SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'failed', 0, ARRAY['${ids.referenceAsset}'::uuid], '全栈成本测试:混合模型 owner',
|
||
'doubao-seed-2-0-lite-260428', 999, 99, 1,
|
||
'2026-07-23 18:30:01+08', '2026-07-23 18:30:20+08', '2099-01-01 00:00:00+08',
|
||
'2026-07-23 18:30:00+08', '2026-07-23 18:30:20+08'
|
||
),
|
||
(
|
||
'${ids.terminalUnknownRun}', (SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'archived', 1, '{}'::uuid[], NULL,
|
||
NULL, 600, 60, 1,
|
||
'2026-07-23 18:10:01+08', '2026-07-23 18:10:20+08', '2026-07-23 18:11:00+08',
|
||
'2026-07-23 18:10:00+08', '2026-07-23 18:11:00+08'
|
||
),
|
||
(
|
||
'${ids.inFlightUnknownRun}', (SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
'analyzing', 0, ARRAY['${ids.referenceAsset}'::uuid], NULL,
|
||
NULL, NULL, NULL, 0,
|
||
'2026-07-23 18:20:01+08', NULL, '2099-01-01 00:00:00+08',
|
||
'2026-07-23 18:20:00+08', '2026-07-23 18:20:01+08'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_batches (
|
||
id, run_id, user_id, ordinal, idempotency_key, request_hash, run_revision,
|
||
status, is_free, credits, credit_status, main_credits_used, addon_credits_used,
|
||
image_asset_ids, product_profile, creative_brief, video_config, plans,
|
||
provider_model, provider_input_tokens, provider_output_tokens, attempt_count,
|
||
content_purged_at, started_at, completed_at, created_at, updated_at
|
||
) VALUES
|
||
(
|
||
'${ids.freeBatch}', '${ids.parentRun}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
1, 'fullstack-mm-free-${ids.freeBatch}', 'fullstack-mm-free', 1,
|
||
'completed', true, 0, 'none', 0, 0,
|
||
'{}'::uuid[], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, NULL,
|
||
'doubao-seed-2-0-lite-260428', 30, 3, 1,
|
||
'2026-07-23 19:31:00+08', '2026-07-23 19:30:01+08',
|
||
'2026-07-23 19:30:20+08', '2026-07-23 19:30:00+08', '2026-07-23 19:31:00+08'
|
||
),
|
||
(
|
||
'${ids.paidBatch}', '${ids.parentRun}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
2, 'fullstack-mm-paid-${ids.paidBatch}', 'fullstack-mm-paid', 1,
|
||
'completed', false, 1, 'settled', 1, 0,
|
||
'{}'::uuid[], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, NULL,
|
||
'doubao-seed-2-0-lite-260428', NULL, NULL, 1,
|
||
'2026-07-23 19:41:00+08', '2026-07-23 19:40:01+08',
|
||
'2026-07-23 19:40:20+08', '2026-07-23 19:40:00+08', '2026-07-23 19:41:00+08'
|
||
),
|
||
(
|
||
'${ids.geminiBatch}', '${ids.geminiRun}',
|
||
(SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
1, 'fullstack-mm-gemini-${ids.geminiBatch}', 'fullstack-mm-gemini', 1,
|
||
'completed', false, 9, 'settled', 9, 0,
|
||
'{}'::uuid[], '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, NULL,
|
||
'gemini-3.5-flash', 3000, 300, 1,
|
||
'2026-07-23 18:06:00+08', '2026-07-23 18:05:01+08',
|
||
'2026-07-23 18:05:20+08', '2026-07-23 18:05:00+08', '2026-07-23 18:06:00+08'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_requests (
|
||
id, request_key, run_id, batch_id, user_id, operation,
|
||
job_attempt, repair_round, plan_index, transport_attempt,
|
||
provider, model, service_tier, request_status, provider_response_id, http_status,
|
||
input_tokens, cached_input_tokens, output_tokens,
|
||
pricing_rule_id, price_version, pricing_tier,
|
||
input_price_per_million_cny, cached_input_price_per_million_cny,
|
||
output_price_per_million_cny, cost_cny, cost_status,
|
||
started_at, completed_at, created_at
|
||
) VALUES
|
||
(
|
||
'${ids.requests[0]}', 'fullstack-mm-${ids.requests[0]}', '${ids.knownRun}', NULL,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'analyze-product', 1, 0, NULL, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'succeeded', 'fullstack-mm-analysis', 200, 100, 20, 10,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000086, 'known',
|
||
'2026-07-23 19:00:02+08', '2026-07-23 19:00:08+08', '2026-07-23 19:00:02+08'
|
||
),
|
||
(
|
||
'${ids.requests[1]}', 'fullstack-mm-${ids.requests[1]}', '${ids.knownRun}', NULL,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'repair-product-chinese', 1, 0, NULL, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'failed', 'fullstack-mm-chinese-repair', 200, 50, 0, 5,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000048, 'known',
|
||
'2026-07-23 19:00:09+08', '2026-07-23 19:00:15+08', '2026-07-23 19:00:09+08'
|
||
),
|
||
(
|
||
'${ids.requests[2]}', 'fullstack-mm-${ids.requests[2]}', '${ids.parentRun}', '${ids.freeBatch}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'generate-drafts', 1, 0, 0, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'failed', NULL, 503, 10, 0, 1,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000010, 'known',
|
||
'2026-07-23 19:30:02+08', '2026-07-23 19:30:05+08', '2026-07-23 19:30:02+08'
|
||
),
|
||
(
|
||
'${ids.requests[3]}', 'fullstack-mm-${ids.requests[3]}', '${ids.parentRun}', '${ids.freeBatch}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'generate-drafts', 1, 0, 0, 2, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'succeeded', 'fullstack-mm-transport-retry', 200, 20, 0, 2,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000019, 'known',
|
||
'2026-07-23 19:30:06+08', '2026-07-23 19:30:10+08', '2026-07-23 19:30:06+08'
|
||
),
|
||
(
|
||
'${ids.requests[4]}', 'fullstack-mm-${ids.requests[4]}', '${ids.parentRun}', '${ids.paidBatch}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'generate-drafts', 1, 0, 0, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'succeeded', 'fullstack-mm-paid-1', 200, 100, 0, 10,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000096, 'known',
|
||
'2026-07-23 19:40:02+08', '2026-07-23 19:40:08+08', '2026-07-23 19:40:02+08'
|
||
),
|
||
(
|
||
'${ids.requests[5]}', 'fullstack-mm-${ids.requests[5]}', '${ids.parentRun}', '${ids.paidBatch}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'generate-drafts', 1, 0, 1, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'succeeded', 'fullstack-mm-paid-2', 200, 100, 0, 10,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000096, 'known',
|
||
'2026-07-23 19:40:03+08', '2026-07-23 19:40:09+08', '2026-07-23 19:40:03+08'
|
||
),
|
||
(
|
||
'${ids.requests[6]}', 'fullstack-mm-${ids.requests[6]}', '${ids.parentRun}', '${ids.paidBatch}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'generate-drafts', 1, 0, 2, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'failed', NULL, 500, NULL, NULL, NULL,
|
||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'unknown',
|
||
'2026-07-23 19:40:04+08', '2026-07-23 19:40:10+08', '2026-07-23 19:40:04+08'
|
||
),
|
||
(
|
||
'${ids.requests[7]}', 'fullstack-mm-${ids.requests[7]}', '${ids.mixedRun}', NULL,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'analyze-product', 1, 0, NULL, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'succeeded', 'fullstack-mm-mixed-target', 200, 10, 0, 1,
|
||
'${lowRuleId}', 'volcengine-list-2026-07-23', '0-32k', 0.600000, 0.120000,
|
||
3.600000, 0.000010, 'known',
|
||
'2026-07-23 18:30:02+08', '2026-07-23 18:30:08+08', '2026-07-23 18:30:02+08'
|
||
),
|
||
(
|
||
'${ids.requests[8]}', 'fullstack-mm-${ids.requests[8]}', '${ids.mixedRun}', NULL,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'repair-product-chinese', 1, 1, NULL, 1, 'volcengine-ark', 'aaa-legacy-model',
|
||
'default', 'succeeded', 'fullstack-mm-mixed-legacy', 200, 999, 0, 99,
|
||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'unknown',
|
||
'2026-07-23 18:30:09+08', '2026-07-23 18:30:15+08', '2026-07-23 18:30:09+08'
|
||
),
|
||
(
|
||
'${ids.requests[9]}', 'fullstack-mm-${ids.requests[9]}', '${ids.reconciledRun}', NULL,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'analyze-product', 1, 0, NULL, 1, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
'default', 'failed', NULL, 503, NULL, NULL, NULL,
|
||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'unknown',
|
||
'2026-07-23 17:10:02+08', '2026-07-23 17:10:08+08', '2026-07-23 17:10:02+08'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_provider_request_reconciliations (
|
||
request_id, reconciliation_status,
|
||
effective_input_tokens, effective_cached_input_tokens,
|
||
effective_output_tokens, effective_cost_cny,
|
||
evidence_source, evidence_source_url,
|
||
official_window_start, official_window_end,
|
||
official_request_count, official_input_tokens, official_output_tokens, official_total_tokens,
|
||
local_known_request_count, local_known_input_tokens, local_known_output_tokens,
|
||
local_known_total_tokens, observed_at, reconciled_at, reconciled_by, evidence_note, created_at
|
||
) VALUES (
|
||
'${ids.requests[9]}', 'confirmed-zero', 0, 0, 0, 0.000000,
|
||
'volcengine-ark-hourly-usage',
|
||
'https://console.volcengine.com/ark/region:ark+cn-beijing/usage',
|
||
'2026-07-23 17:00:00+08', '2026-07-23 18:00:00+08',
|
||
0, 0, 0, 0, 0, 0, 0, 0,
|
||
'2026-07-23 18:01:00+08', '2026-07-23 18:05:00+08',
|
||
'fullstack-test', '官方小时桶与本地已知请求均为零,确认该失败请求未产生用量。',
|
||
'2026-07-23 18:05:00+08'
|
||
);
|
||
|
||
INSERT INTO prompt_wizard_historical_operation_cost_evidence (
|
||
analysis_run_id, batch_id, provider, model,
|
||
input_tokens, cached_input_tokens, output_tokens,
|
||
input_tier_min_exclusive, input_tier_max_inclusive,
|
||
input_rate_cny_per_million, cached_input_rate_cny_per_million,
|
||
output_rate_cny_per_million, pricing_version, pricing_source_url,
|
||
cost_cny, calculation_basis, evidence_source,
|
||
observed_at, priced_at, priced_by, note, created_at
|
||
) VALUES (
|
||
'${ids.historicalRun}', NULL, 'volcengine-ark', 'doubao-seed-2-0-lite-260428',
|
||
900, 0, 120, NULL, 32000,
|
||
0.600000, 0.120000, 3.600000,
|
||
'volcengine-list-2026-07-23',
|
||
'https://docs.volcengine.com/docs/82379/1544106?lang=zh',
|
||
0.000972, 'historical-operation-aggregate-token',
|
||
'全栈测试:官方模型日范围 CacheTokensHit=0 + 本地 operation 聚合 Token',
|
||
'2026-07-23 19:14:00+08', '2026-07-23 19:15:00+08',
|
||
'fullstack-test', '仅按历史 operation 聚合 Token 与官方列表价折算,不伪造逐请求拆分。',
|
||
'2026-07-23 19:15:00+08'
|
||
);
|
||
|
||
DO $$
|
||
BEGIN
|
||
BEGIN
|
||
UPDATE prompt_wizard_historical_operation_cost_evidence
|
||
SET note = '篡改测试'
|
||
WHERE analysis_run_id = '${ids.historicalRun}';
|
||
RAISE EXCEPTION '测试失败:历史成本证据更新未被拒绝';
|
||
EXCEPTION WHEN OTHERS THEN
|
||
IF position('禁止修改' IN SQLERRM) = 0 THEN RAISE; END IF;
|
||
END;
|
||
|
||
BEGIN
|
||
DELETE FROM prompt_wizard_historical_operation_cost_evidence
|
||
WHERE analysis_run_id = '${ids.historicalRun}';
|
||
RAISE EXCEPTION '测试失败:历史成本证据删除未被拒绝';
|
||
EXCEPTION WHEN OTHERS THEN
|
||
IF position('禁止删除' IN SQLERRM) = 0 THEN RAISE; END IF;
|
||
END;
|
||
END;
|
||
$$;
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const range = 'range=custom&from=2026-07-23&to=2026-07-23';
|
||
// 固定在夹具全部写入后的业务快照,避免真实 worker 按当前墙钟恢复历史 analyzing 任务。
|
||
const overviewAsOf = encodeURIComponent('2026-07-23T12:00:00.000Z');
|
||
try {
|
||
const overview = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/overview?${range}&asOf=${overviewAsOf}`,
|
||
);
|
||
expect(overview.status).toBe(200);
|
||
expect(overview.body.summary).toMatchObject({
|
||
analysisCount: 6,
|
||
batchCount: 2,
|
||
userCount: 2,
|
||
settledCredits: 1,
|
||
refundedCredits: 0,
|
||
knownInputTokens: 1290,
|
||
knownCachedInputTokens: 20,
|
||
knownOutputTokens: 159,
|
||
knownCostCny: '0.001337',
|
||
providerRequestCount: 9,
|
||
reconciledZeroRequestCount: 1,
|
||
unknownUsageRequestCount: 1,
|
||
unpricedRequestCount: 0,
|
||
incompleteOperationCount: 3,
|
||
historicalEvidenceOperationCount: 0,
|
||
historicalPricedOperationCount: 1,
|
||
operationalCallCount: 0,
|
||
operationalUnpricedCallCount: 0,
|
||
businessEstimatedCostCny: '0.001337',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.001337',
|
||
estimateCompleteness: 'partial',
|
||
});
|
||
expect(overview.body.models).toEqual([
|
||
expect.objectContaining({
|
||
id: 'doubao-seed-2-0-lite-260428',
|
||
model: 'Doubao Seed 2.0 Lite',
|
||
settledCredits: 1,
|
||
knownCostCny: '0.001337',
|
||
reconciledZeroRequestCount: 1,
|
||
historicalEvidenceOperationCount: 0,
|
||
historicalPricedOperationCount: 1,
|
||
operationalCallCount: 0,
|
||
operationalUnpricedCallCount: 0,
|
||
businessEstimatedCostCny: '0.001337',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.001337',
|
||
estimateCompleteness: 'partial',
|
||
}),
|
||
]);
|
||
const snapshotRange = `${range}&asOf=${encodeURIComponent(overview.body.asOf)}`;
|
||
|
||
const beforeReconciliation = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${range}`
|
||
+ `&asOf=${encodeURIComponent('2026-07-23T09:30:00.000Z')}`
|
||
+ `&position=first&limit=20&search=${ids.reconciledRun}`,
|
||
);
|
||
expect(beforeReconciliation.status).toBe(200);
|
||
expect(beforeReconciliation.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.reconciledRun,
|
||
providerRequestCount: 1,
|
||
reconciledZeroRequestCount: 0,
|
||
unknownUsageRequestCount: 1,
|
||
knownInputTokens: null,
|
||
knownCachedInputTokens: null,
|
||
knownOutputTokens: null,
|
||
knownCostCny: null,
|
||
estimateCompleteness: 'unavailable',
|
||
usageEvidence: 'ledger',
|
||
}),
|
||
]);
|
||
|
||
const afterReconciliation = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}`
|
||
+ `&position=first&limit=20&search=${ids.reconciledRun}`,
|
||
);
|
||
expect(afterReconciliation.status).toBe(200);
|
||
expect(afterReconciliation.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.reconciledRun,
|
||
providerRequestCount: 1,
|
||
reconciledZeroRequestCount: 1,
|
||
unknownUsageRequestCount: 0,
|
||
knownInputTokens: 0,
|
||
knownCachedInputTokens: 0,
|
||
knownOutputTokens: 0,
|
||
knownCostCny: '0.000000',
|
||
estimateCompleteness: 'complete',
|
||
usageEvidence: 'ledger-reconciled',
|
||
}),
|
||
]);
|
||
|
||
const beforeHistoricalPricing = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${range}`
|
||
+ `&asOf=${encodeURIComponent('2026-07-23T11:14:30.000Z')}`
|
||
+ `&position=first&limit=20&search=${ids.historicalRun}`,
|
||
);
|
||
expect(beforeHistoricalPricing.status).toBe(200);
|
||
expect(beforeHistoricalPricing.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.historicalRun,
|
||
providerRequestCount: 0,
|
||
knownInputTokens: 900,
|
||
knownCachedInputTokens: null,
|
||
knownOutputTokens: 120,
|
||
knownCostCny: null,
|
||
estimateCompleteness: 'unavailable',
|
||
usageEvidence: 'historical',
|
||
priceVersion: null,
|
||
pricingSourceUrl: null,
|
||
}),
|
||
]);
|
||
|
||
const allRange = await api(session.page, '/api/admin/api-usage/multimodal/overview?range=all');
|
||
expect(allRange.status).toBe(200);
|
||
expect(allRange.body.selection.rangeStart).not.toBe('1999-01-01');
|
||
|
||
const firstPage = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=2`,
|
||
);
|
||
expect(firstPage.status).toBe(200);
|
||
expect(firstPage.body.items.map((item: { id: string }) => item.id)).toEqual([
|
||
ids.paidBatch,
|
||
ids.freeBatch,
|
||
]);
|
||
expect(firstPage.body.pagination.hasNext).toBe(true);
|
||
expect(firstPage.body.items).toEqual([
|
||
expect.objectContaining({
|
||
operationType: 'plan-batch', isFree: false, settledCredits: 1,
|
||
providerRequestCount: 3, unknownUsageRequestCount: 1,
|
||
knownCostCny: '0.000192', estimateCompleteness: 'partial', usageEvidence: 'ledger',
|
||
}),
|
||
expect.objectContaining({
|
||
operationType: 'plan-batch', isFree: true, settledCredits: 0,
|
||
providerRequestCount: 2, knownCostCny: '0.000029',
|
||
estimateCompleteness: 'complete', usageEvidence: 'ledger',
|
||
}),
|
||
]);
|
||
|
||
const inFlightAsOf = encodeURIComponent('2026-07-23T11:30:15.000Z');
|
||
const inFlight = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${range}&asOf=${inFlightAsOf}&position=first&limit=20&status=processing&search=${ids.freeBatch}`,
|
||
);
|
||
expect(inFlight.status).toBe(200);
|
||
expect(inFlight.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.freeBatch,
|
||
status: 'processing',
|
||
providerRequestCount: 2,
|
||
unknownUsageRequestCount: 0,
|
||
knownCostCny: '0.000029',
|
||
estimateCompleteness: 'partial',
|
||
}),
|
||
]);
|
||
|
||
const filter = new URLSearchParams({
|
||
range: 'custom', from: '2026-07-23', to: '2026-07-23', asOf: overview.body.asOf,
|
||
position: 'first', limit: '20', operationType: 'analysis', status: 'failed',
|
||
search: ids.knownRun,
|
||
});
|
||
const failedAnalysis = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${filter.toString()}`,
|
||
);
|
||
expect(failedAnalysis.status).toBe(200);
|
||
expect(failedAnalysis.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.knownRun, operationType: 'analysis', status: 'failed',
|
||
providerRequestCount: 2, knownCostCny: '0.000134',
|
||
estimateCompleteness: 'complete', usageEvidence: 'ledger',
|
||
}),
|
||
]);
|
||
|
||
const mixedModelOwner = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=20&search=${ids.mixedRun}`,
|
||
);
|
||
expect(mixedModelOwner.status).toBe(200);
|
||
expect(mixedModelOwner.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.mixedRun,
|
||
model: 'Doubao Seed 2.0 Lite',
|
||
providerRequestCount: 1,
|
||
knownInputTokens: 10,
|
||
knownOutputTokens: 1,
|
||
knownCostCny: '0.000010',
|
||
}),
|
||
]);
|
||
|
||
const excludedGemini = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=20&search=${ids.geminiRun}`,
|
||
);
|
||
expect(excludedGemini.status).toBe(200);
|
||
expect(excludedGemini.body.items).toEqual([]);
|
||
|
||
const excludedTerminalUnknown = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=20&search=${ids.terminalUnknownRun}`,
|
||
);
|
||
expect(excludedTerminalUnknown.status).toBe(200);
|
||
expect(excludedTerminalUnknown.body.items).toEqual([]);
|
||
|
||
const retainedInFlightUnknown = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=20&search=${ids.inFlightUnknownRun}`,
|
||
);
|
||
expect(retainedInFlightUnknown.status).toBe(200);
|
||
expect(retainedInFlightUnknown.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.inFlightUnknownRun,
|
||
model: 'Doubao Seed 2.0 Lite',
|
||
status: 'analyzing',
|
||
providerRequestCount: 0,
|
||
knownCostCny: null,
|
||
estimateCompleteness: 'unavailable',
|
||
}),
|
||
]);
|
||
|
||
const archived = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/operations?${snapshotRange}&position=first&limit=20&status=archived`,
|
||
);
|
||
expect(archived.status).toBe(200);
|
||
expect(archived.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.historicalRun, knownInputTokens: 900, knownOutputTokens: 120,
|
||
knownCachedInputTokens: 0, knownCostCny: '0.000972',
|
||
providerRequestCount: 0, estimateCompleteness: 'complete',
|
||
usageEvidence: 'historical-aggregate-priced',
|
||
priceVersion: 'volcengine-list-2026-07-23',
|
||
pricingSourceUrl: 'https://docs.volcengine.com/docs/82379/1544106?lang=zh',
|
||
}),
|
||
]);
|
||
|
||
const users = await api(
|
||
session.page,
|
||
`/api/admin/api-usage/multimodal/users?${snapshotRange}&position=first&limit=20`,
|
||
);
|
||
expect(users.status).toBe(200);
|
||
expect(users.body.items).toHaveLength(2);
|
||
expect(users.body.items).toContainEqual(expect.objectContaining({
|
||
email: primaryAccount.email,
|
||
analysisCount: 4,
|
||
batchCount: 2,
|
||
settledCredits: 1,
|
||
knownCostCny: '0.000365',
|
||
reconciledZeroRequestCount: 1,
|
||
historicalEvidenceOperationCount: 0,
|
||
historicalPricedOperationCount: 0,
|
||
operationalCallCount: 0,
|
||
operationalUnpricedCallCount: 0,
|
||
businessEstimatedCostCny: '0.000365',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.000365',
|
||
estimateCompleteness: 'partial',
|
||
}));
|
||
expect(users.body.items).toContainEqual(expect.objectContaining({
|
||
email: secondaryAccount.email,
|
||
analysisCount: 2,
|
||
batchCount: 0,
|
||
knownCostCny: '0.000972',
|
||
reconciledZeroRequestCount: 0,
|
||
historicalEvidenceOperationCount: 0,
|
||
historicalPricedOperationCount: 1,
|
||
operationalCallCount: 0,
|
||
operationalUnpricedCallCount: 0,
|
||
businessEstimatedCostCny: '0.000972',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.000972',
|
||
estimateCompleteness: 'partial',
|
||
}));
|
||
|
||
const videoOverview = await api(session.page, `/api/admin/api-usage/overview?${snapshotRange}`);
|
||
expect(videoOverview.status).toBe(200);
|
||
expect(videoOverview.body.operations.taskCount).toBe(0);
|
||
} finally {
|
||
await session.context.close();
|
||
// 成本账本终态由数据库触发器保护,测试也不得为清理而删除审计证据;
|
||
// 本套件使用一次性 Docker volume,完整环境结束后统一销毁。
|
||
}
|
||
});
|
||
|
||
test('零成本案件在真实 PostgreSQL 上从待终账切换为审计确认且不补写原始 Token', async ({ browser }) => {
|
||
const ids = {
|
||
task: '20000000-0000-4000-8000-000000000031',
|
||
usage: '20000000-0000-4000-8000-000000000041',
|
||
reconciliationCase: '20000000-0000-4000-8000-000000000051',
|
||
linkOne: '20000000-0000-4000-8000-000000000061',
|
||
linkTwo: '20000000-0000-4000-8000-000000000062',
|
||
};
|
||
postgres(`
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, prompt, config,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, provider_task_attempt, processing_stage, failure_category,
|
||
failure_reason, created_at, started_at, completed_at, updated_at
|
||
) VALUES (
|
||
'${ids.task}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0 Fast', 'failed', '全栈零成本终账案件',
|
||
'{"resolution":"720p"}'::jsonb,
|
||
48, 48, 0, 48, 'refunded', 0, NULL, 'failed', 'provider-failed',
|
||
'历史失败回执已过查询期',
|
||
'2026-07-10 09:00:00+08', '2026-07-10 09:01:00+08',
|
||
'2026-07-10 09:05:00+08', '2026-07-10 09:05:00+08'
|
||
);
|
||
|
||
INSERT INTO provider_usage_records (
|
||
id, provider, model, task_id, attempt, provider_task_id, status,
|
||
raw_units, deducted_units, usage_unit, completed_at, completion_tokens,
|
||
total_tokens, billable_tokens, billable_basis, resolution, has_video_input,
|
||
price_version, unit_price_per_million, estimated_cost, currency, pricing_status,
|
||
usage_evidence_origin, pricing_rule_status, pricing_input_class,
|
||
pricing_resolution_band, attribution_confidence, provider_terminal_at,
|
||
usage_observed_at, local_accounting_date, accounting_date_origin, error, created_at
|
||
) VALUES (
|
||
'${ids.usage}', 'volcengine-ark', 'Seedance 2.0 Fast', '${ids.task}', -1,
|
||
NULL, 'failed', NULL, NULL, 'tokens', '2026-07-10 09:05:00+08',
|
||
NULL, NULL, NULL, NULL, '720p', false, NULL, NULL, NULL, 'CNY',
|
||
'legacy-inferred', 'unavailable', 'rule-missing', 'without-video',
|
||
'480p-720p', 'unallocated', NULL, '2026-07-10 09:05:00+08',
|
||
'2026-07-10', 'legacy-completed', '历史失败回执已过查询期',
|
||
'2026-07-10 09:05:00+08'
|
||
);
|
||
|
||
INSERT INTO provider_reconciliation_cases (
|
||
id, provider, case_key, bill_period, case_type, status, root_cause,
|
||
confidence, token_adjustment, cost_adjustment_cny,
|
||
official_expense_date, local_accounting_date, evidence, evidence_hash
|
||
) VALUES (
|
||
'${ids.reconciliationCase}', 'volcengine-ark', 'fullstack-zero-cost:${ids.task}',
|
||
'2026-07', 'missing-local-usage', 'open', 'provider-terminal-usage-unavailable',
|
||
'high', 0, 0, '2026-07-10', '2026-07-10',
|
||
jsonb_build_object(
|
||
'resolutionKind', 'zero-cost', 'taskId', '${ids.task}',
|
||
'usageRecordId', '${ids.usage}',
|
||
'review', jsonb_build_object('status', 'pending-final-bill')
|
||
),
|
||
repeat('7', 64)
|
||
);
|
||
|
||
INSERT INTO provider_reconciliation_case_links (
|
||
id, case_id, usage_record_id, allocated_tokens, allocated_cost_cny
|
||
) VALUES
|
||
('${ids.linkOne}', '${ids.reconciliationCase}', '${ids.usage}', 0, 0),
|
||
('${ids.linkTwo}', '${ids.reconciliationCase}', '${ids.usage}', 0, 0);
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const range = 'range=custom&from=2026-07-10&to=2026-07-10';
|
||
try {
|
||
const pendingOverview = await api(session.page, `/api/admin/api-usage/overview?${range}`);
|
||
const pendingTasks = await api(session.page, `/api/admin/api-usage/tasks?${range}&limit=20`);
|
||
const pendingAttempts = await api(session.page, `/api/admin/api-usage/attempts?${range}&limit=20`);
|
||
for (const response of [pendingOverview, pendingTasks, pendingAttempts]) {
|
||
expect(response.status).toBe(200);
|
||
}
|
||
expect(pendingOverview.body.operations).toMatchObject({
|
||
taskCount: 1,
|
||
estimatedCostCny: '0.000000',
|
||
incompleteTaskCount: 1,
|
||
pendingFinalBillTaskCount: 1,
|
||
approvedZeroCostTaskCount: 0,
|
||
});
|
||
expect(pendingTasks.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.task,
|
||
costResolutionStatus: 'pending-final-bill',
|
||
costResolutionCaseId: ids.reconciliationCase,
|
||
missingUsageAttempts: 1,
|
||
costFinalized: false,
|
||
}),
|
||
]);
|
||
expect(pendingAttempts.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.usage,
|
||
completionTokens: null,
|
||
totalTokens: null,
|
||
billableTokens: null,
|
||
estimatedCostCny: null,
|
||
usageEvidenceOrigin: 'unavailable',
|
||
costResolutionStatus: 'pending-final-bill',
|
||
costResolutionCaseId: ids.reconciliationCase,
|
||
costResolutionCaseStatus: 'open',
|
||
}),
|
||
]);
|
||
|
||
postgres(`
|
||
UPDATE provider_reconciliation_cases
|
||
SET status = 'approved',
|
||
evidence = jsonb_set(
|
||
evidence,
|
||
'{review}',
|
||
'{"status":"approved-zero","reason":"全栈终账确认"}'::jsonb,
|
||
true
|
||
)
|
||
WHERE id = '${ids.reconciliationCase}';
|
||
`);
|
||
|
||
const approvedOverview = await api(session.page, `/api/admin/api-usage/overview?${range}`);
|
||
const approvedTasks = await api(session.page, `/api/admin/api-usage/tasks?${range}&limit=20`);
|
||
const approvedAttempts = await api(session.page, `/api/admin/api-usage/attempts?${range}&limit=20`);
|
||
for (const response of [approvedOverview, approvedTasks, approvedAttempts]) {
|
||
expect(response.status).toBe(200);
|
||
}
|
||
expect(approvedOverview.body.operations).toMatchObject({
|
||
taskCount: 1,
|
||
estimatedCostCny: '0.000000',
|
||
estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0,
|
||
pendingFinalBillTaskCount: 0,
|
||
approvedZeroCostTaskCount: 1,
|
||
});
|
||
expect(approvedTasks.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.task,
|
||
billableTokens: 0,
|
||
estimatedCostCny: '0.000000',
|
||
estimateCompleteness: 'complete',
|
||
costResolutionStatus: 'approved-zero',
|
||
costResolutionCaseId: ids.reconciliationCase,
|
||
missingUsageAttempts: 0,
|
||
costFinalized: true,
|
||
}),
|
||
]);
|
||
expect(approvedAttempts.body.items).toEqual([
|
||
expect.objectContaining({
|
||
id: ids.usage,
|
||
completionTokens: null,
|
||
totalTokens: null,
|
||
billableTokens: null,
|
||
estimatedCostCny: null,
|
||
usageEvidenceOrigin: 'unavailable',
|
||
costResolutionStatus: 'approved-zero',
|
||
costResolutionCaseId: ids.reconciliationCase,
|
||
costResolutionCaseStatus: 'approved',
|
||
}),
|
||
]);
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`
|
||
DELETE FROM provider_reconciliation_cases WHERE id = '${ids.reconciliationCase}';
|
||
DELETE FROM provider_usage_records WHERE id = '${ids.usage}';
|
||
DELETE FROM generation_tasks WHERE id = '${ids.task}';
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('成本报表以一万任务三次 attempt 验证分页、积分去重和性能门槛', async ({ browser }) => {
|
||
const billingRunId = randomUUID();
|
||
const billingRevisionId = randomUUID();
|
||
const day = postgresScalar(
|
||
"SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD');",
|
||
);
|
||
const marker = `fullstack-perf-${randomUUID()}-`;
|
||
|
||
postgres(`
|
||
DELETE FROM provider_usage_records WHERE provider_task_id LIKE '${marker}%';
|
||
DELETE FROM generation_tasks WHERE prompt LIKE '全栈性能任务-%';
|
||
INSERT INTO provider_billing_sync_runs (
|
||
id, provider, kind, status, window_start, window_end,
|
||
page_count, row_count, started_at, completed_at, is_final
|
||
) VALUES (
|
||
'${billingRunId}', 'volcengine-ark', 'bill', 'success', '${day}', '${day}',
|
||
1, 1, now() - interval '1 second', now(), false
|
||
);
|
||
|
||
INSERT INTO provider_bill_detail_revisions (
|
||
id, provider, source_detail_key, sync_run_id, payload_hash, raw_payload,
|
||
normalized_payload, normalization_version, observed_at
|
||
) VALUES (
|
||
'${billingRevisionId}', 'volcengine-ark', '${marker}bill-${day}',
|
||
'${billingRunId}', repeat('9', 64), '{}'::jsonb,
|
||
jsonb_build_object(
|
||
'billPeriod', substring('${day}' from 1 for 7),
|
||
'expenseDate', '${day}', 'product', 'ark_bd', 'billLineType', 'usage',
|
||
'recognizedModel', 'Seedance 2.0 Mini', 'seedanceCandidate', true,
|
||
'pricingResolution', '480P/720P', 'pricingInputHasVideo', false,
|
||
'normalizedUnitPricePerMillion', 23, 'billableTokens', 30000000,
|
||
'deductionCount', 30000000, 'listEquivalentCostCny', 690.000000,
|
||
'originalBillAmountCny', 0, 'preferentialBillAmountCny', 0,
|
||
'discountBillAmountCny', 0, 'couponAmountCny', 0,
|
||
'payableAmountCny', 0, 'paidAmountCny', 0, 'unpaidAmountCny', 0,
|
||
'currency', 'CNY', 'instanceNo', '${marker}instance'
|
||
),
|
||
'fullstack-performance-v1', now()
|
||
);
|
||
|
||
INSERT INTO provider_bill_sync_run_items (
|
||
sync_run_id, bill_detail_revision_id, source_detail_key
|
||
) VALUES (
|
||
'${billingRunId}', '${billingRevisionId}', '${marker}bill-${day}'
|
||
);
|
||
|
||
WITH generated_tasks AS (
|
||
SELECT task_number,
|
||
md5('${marker}task-' || task_number)::uuid AS task_id,
|
||
now() - task_number * interval '1 millisecond' AS occurred_at
|
||
FROM generate_series(1, 10000) task_number
|
||
)
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, prompt, config, provider_task_id,
|
||
credits, reserved_credits, settled_credits, refunded_credits, credit_status,
|
||
attempt, provider_task_attempt, processing_stage, created_at, started_at,
|
||
completed_at, updated_at
|
||
)
|
||
SELECT generated.task_id,
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0 Mini', 'completed',
|
||
'全栈性能任务-' || generated.task_number,
|
||
'{"resolution":"480p"}'::jsonb,
|
||
'${marker}task-' || generated.task_number,
|
||
10, 10, 10, 0, 'settled', 2, 2, 'completed',
|
||
generated.occurred_at, generated.occurred_at,
|
||
generated.occurred_at, generated.occurred_at
|
||
FROM generated_tasks generated;
|
||
|
||
INSERT INTO provider_usage_records (
|
||
id, provider, model, task_id, attempt, provider_task_id, status,
|
||
raw_units, deducted_units, usage_unit, completed_at, completion_tokens,
|
||
total_tokens, billable_tokens, billable_basis, resolution, has_video_input,
|
||
price_version, unit_price_per_million, estimated_cost, currency, pricing_status,
|
||
usage_evidence_origin, pricing_rule_status, pricing_input_class,
|
||
pricing_resolution_band, attribution_confidence, provider_terminal_at,
|
||
usage_observed_at, local_accounting_date, accounting_date_origin, created_at
|
||
)
|
||
SELECT md5(
|
||
'${marker}attempt-' || task_number || '-' || attempt_number
|
||
)::uuid,
|
||
'volcengine-ark', 'Seedance 2.0 Mini', task.id, attempt_number,
|
||
'${marker}task-' || task_number || '-attempt-' || attempt_number,
|
||
'success', 1000, 1000, 'tokens', task.created_at,
|
||
1000, 1000, 1000, 'completion_tokens', '480p', false,
|
||
'volcengine-seedance-2026-07-10', 23, 0.023000, 'CNY', 'priced',
|
||
'provider-reported', 'priced', 'without-video', '480p-720p',
|
||
'direct', task.created_at, task.created_at, '${day}',
|
||
'provider-terminal', task.created_at
|
||
FROM generate_series(1, 10000) task_number
|
||
CROSS JOIN generate_series(0, 2) attempt_number
|
||
JOIN generation_tasks task
|
||
ON task.id = md5('${marker}task-' || task_number)::uuid;
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const range = `range=custom&from=${day}&to=${day}`;
|
||
try {
|
||
const overview = await api(session.page, `/api/admin/api-usage/overview?${range}`);
|
||
expect(overview.status).toBe(200);
|
||
expect(overview.body.operations).toMatchObject({
|
||
taskCount: 10000,
|
||
userCount: 1,
|
||
settledCredits: 100000,
|
||
refundedCredits: 0,
|
||
estimatedCostCny: '690.000000',
|
||
estimateCompleteness: 'complete',
|
||
officialEquivalentCostCny: '690.000000',
|
||
differenceCny: '0.000000',
|
||
officialPayableCny: '0.000000',
|
||
reconciliationStatus: 'matched',
|
||
});
|
||
expect(overview.body.models).toEqual([
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.5', priceVersions: [], taskCount: 0,
|
||
operationalCallCount: 0, settledCredits: 0, heldCredits: 0, refundedCredits: 0,
|
||
estimatedCostCny: '0.000000', businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '0.000000', knownAccountCostCny: '0.000000',
|
||
operationalUnpricedCallCount: 0, estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0, averageTaskCostCny: null,
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0', priceVersions: [], taskCount: 0,
|
||
operationalCallCount: 0, settledCredits: 0, heldCredits: 0, refundedCredits: 0,
|
||
estimatedCostCny: '0.000000', businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '0.000000', knownAccountCostCny: '0.000000',
|
||
operationalUnpricedCallCount: 0, estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0, averageTaskCostCny: null,
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0 Fast', priceVersions: [], taskCount: 0,
|
||
operationalCallCount: 0, settledCredits: 0, heldCredits: 0, refundedCredits: 0,
|
||
estimatedCostCny: '0.000000', businessEstimatedCostCny: '0.000000',
|
||
operationalEstimatedCostCny: '0.000000', knownAccountCostCny: '0.000000',
|
||
operationalUnpricedCallCount: 0, estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0, averageTaskCostCny: null,
|
||
}),
|
||
expect.objectContaining({
|
||
model: 'Seedance 2.0 Mini',
|
||
taskCount: 10000,
|
||
operationalCallCount: 0,
|
||
settledCredits: 100000,
|
||
estimatedCostCny: '690.000000',
|
||
businessEstimatedCostCny: '690.000000',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '690.000000',
|
||
operationalUnpricedCallCount: 0,
|
||
estimateCompleteness: 'complete',
|
||
incompleteTaskCount: 0,
|
||
averageTaskCostCny: '0.069000',
|
||
}),
|
||
]);
|
||
|
||
const snapshotRange = `${range}&asOf=${encodeURIComponent(overview.body.asOf)}`;
|
||
const tasksPath = `/api/admin/api-usage/tasks?${snapshotRange}&limit=50`;
|
||
const usersPath = `/api/admin/api-usage/users?${snapshotRange}&limit=50`;
|
||
const tasks = await api(session.page, tasksPath);
|
||
const users = await api(session.page, usersPath);
|
||
expect(tasks.status).toBe(200);
|
||
expect(users.status).toBe(200);
|
||
expect(tasks.body.items).toHaveLength(50);
|
||
expect(tasks.body.pagination).toMatchObject({
|
||
limit: 50,
|
||
hasPrevious: false,
|
||
hasNext: true,
|
||
previousCursor: null,
|
||
nextCursor: expect.any(String),
|
||
});
|
||
expect(tasks.body.pagination).not.toHaveProperty('hasMore');
|
||
expect(new Set(tasks.body.items.map((item: { id: string }) => item.id)).size).toBe(50);
|
||
expect(tasks.body.items.every((item: {
|
||
attemptCount: number;
|
||
settledCredits: number;
|
||
billableTokens: number;
|
||
estimatedCostCny: string;
|
||
}) => (
|
||
item.attemptCount === 3
|
||
&& item.settledCredits === 10
|
||
&& item.billableTokens === 3000
|
||
&& item.estimatedCostCny === '0.069000'
|
||
))).toBe(true);
|
||
const createdAt = tasks.body.items.map((item: { createdAt: string }) => item.createdAt);
|
||
expect(createdAt).toEqual([...createdAt].sort((left, right) => right.localeCompare(left)));
|
||
const nextTasks = await api(
|
||
session.page,
|
||
`${tasksPath}&position=next&cursor=${encodeURIComponent(tasks.body.pagination.nextCursor)}`,
|
||
);
|
||
expect(nextTasks.status).toBe(200);
|
||
expect(nextTasks.body.pagination).toMatchObject({
|
||
hasPrevious: true,
|
||
previousCursor: expect.any(String),
|
||
});
|
||
const firstTaskIds = new Set(tasks.body.items.map((item: { id: string }) => item.id));
|
||
expect(nextTasks.body.items.every((item: { id: string }) => !firstTaskIds.has(item.id))).toBe(true);
|
||
const lastTasks = await api(session.page, `${tasksPath}&position=last`);
|
||
expect(lastTasks.status).toBe(200);
|
||
expect(lastTasks.body.pagination).toMatchObject({ hasNext: false, nextCursor: null });
|
||
const lastCreatedAt = lastTasks.body.items.map((item: { createdAt: string }) => item.createdAt);
|
||
expect(lastCreatedAt).toEqual([...lastCreatedAt].sort((left, right) => right.localeCompare(left)));
|
||
expect(users.body.items).toEqual([
|
||
expect.objectContaining({
|
||
taskCount: 10000,
|
||
settledCredits: 100000,
|
||
refundedCredits: 0,
|
||
billableTokens: 30000000,
|
||
estimatedCostCny: '690.000000',
|
||
}),
|
||
]);
|
||
|
||
const paths = {
|
||
overview: `/api/admin/api-usage/overview?${snapshotRange}`,
|
||
tasks: tasksPath,
|
||
users: usersPath,
|
||
};
|
||
for (const path of Object.values(paths)) {
|
||
expect((await timedApi(session.page, path)).status).toBe(200);
|
||
}
|
||
const timings: Record<string, number[]> = {};
|
||
for (const [name, path] of Object.entries(paths)) {
|
||
timings[name] = [];
|
||
for (let sample = 0; sample < 5; sample += 1) {
|
||
const measured = await timedApi(session.page, path);
|
||
expect(measured.status).toBe(200);
|
||
timings[name].push(measured.elapsedMs);
|
||
}
|
||
}
|
||
const p95 = Object.fromEntries(
|
||
Object.entries(timings).map(([name, values]) => [name, percentile(values, 0.95)]),
|
||
) as Record<string, number>;
|
||
expect(p95.overview).toBeLessThan(500);
|
||
expect(p95.tasks).toBeLessThan(500);
|
||
expect(p95.users).toBeLessThan(500);
|
||
|
||
const coverageExecutionMs = postgresExplainExecutionMs(
|
||
buildCoverageBenchmarkSql(overview.body.asOf),
|
||
);
|
||
expect(coverageExecutionMs).toBeLessThan(100);
|
||
await test.info().attach('cost-report-performance.json', {
|
||
body: Buffer.from(JSON.stringify({ fixture: '10000x3', timings, p95, coverageExecutionMs }, null, 2)),
|
||
contentType: 'application/json',
|
||
});
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`
|
||
DELETE FROM provider_usage_records WHERE provider_task_id LIKE '${marker}%';
|
||
DELETE FROM generation_tasks WHERE prompt LIKE '全栈性能任务-%';
|
||
DELETE FROM provider_bill_sync_run_items WHERE sync_run_id = '${billingRunId}'::uuid;
|
||
-- 官方 revision 及其 sync run 由本次短生命周期全栈数据库统一销毁。
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('后台公告写操作在真实 PostgreSQL 中按请求键幂等', async ({ browser }) => {
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const suffix = randomUUID();
|
||
const createdIds = new Set<string>();
|
||
const sequentialCreateKey = `fullstack-announcement-create-${suffix}`;
|
||
const concurrentCreateKey = `fullstack-announcement-concurrent-${suffix}`;
|
||
const sequentialDeleteKey = `fullstack-announcement-delete-${suffix}`;
|
||
const sequentialPayload = {
|
||
title: `全链路幂等公告-${suffix}`,
|
||
content: '验证相同请求键的顺序重放只产生一次业务副作用。',
|
||
pinned: false,
|
||
};
|
||
|
||
try {
|
||
const firstCreate = await api(session.page, '/api/admin/announcements', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': sequentialCreateKey },
|
||
body: sequentialPayload,
|
||
});
|
||
expect(firstCreate.status).toBe(200);
|
||
createdIds.add(firstCreate.body.announcement.id);
|
||
|
||
const replayedCreate = await api(session.page, '/api/admin/announcements', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': sequentialCreateKey },
|
||
body: sequentialPayload,
|
||
});
|
||
expect(replayedCreate.status).toBe(200);
|
||
expect(replayedCreate.idempotencyReplayed).toBe('true');
|
||
expect(replayedCreate.body).toEqual(firstCreate.body);
|
||
|
||
const conflictingCreate = await api(session.page, '/api/admin/announcements', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': sequentialCreateKey },
|
||
body: { ...sequentialPayload, content: '同一个键不得执行不同的公告请求。' },
|
||
});
|
||
expect(conflictingCreate.status).toBe(409);
|
||
expect(conflictingCreate.body.code).toBe('IDEMPOTENCY_CONFLICT');
|
||
|
||
const crossActionConflict = await api(
|
||
session.page,
|
||
`/api/admin/announcements/${firstCreate.body.announcement.id}`,
|
||
{ method: 'DELETE', headers: { 'Idempotency-Key': sequentialCreateKey } },
|
||
);
|
||
expect(crossActionConflict.status).toBe(409);
|
||
expect(crossActionConflict.body.code).toBe('IDEMPOTENCY_CONFLICT');
|
||
|
||
const concurrentPayload = {
|
||
title: `全链路并发幂等公告-${suffix}`,
|
||
content: '验证并发请求只能创建一条公告。',
|
||
pinned: false,
|
||
};
|
||
const concurrentOptions = {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': concurrentCreateKey },
|
||
body: concurrentPayload,
|
||
};
|
||
const concurrentCreates = await Promise.all([
|
||
api(session.page, '/api/admin/announcements', concurrentOptions),
|
||
api(session.page, '/api/admin/announcements', concurrentOptions),
|
||
]);
|
||
expect(concurrentCreates.map(result => result.status)).toEqual([200, 200]);
|
||
for (const result of concurrentCreates) createdIds.add(result.body.announcement.id);
|
||
expect(new Set(concurrentCreates.map(result => result.body.announcement.id)).size).toBe(1);
|
||
expect(concurrentCreates.filter(result => result.idempotencyReplayed === 'true')).toHaveLength(1);
|
||
const concurrentAnnouncementId = concurrentCreates[0].body.announcement.id;
|
||
|
||
const announcements = await api(session.page, '/api/admin/announcements');
|
||
expect(announcements.status).toBe(200);
|
||
expect(announcements.body.items.filter(
|
||
(item: { id: string }) => item.id === firstCreate.body.announcement.id,
|
||
)).toHaveLength(1);
|
||
expect(announcements.body.items.filter(
|
||
(item: { id: string }) => item.id === concurrentAnnouncementId,
|
||
)).toHaveLength(1);
|
||
|
||
const firstDelete = await api(
|
||
session.page,
|
||
`/api/admin/announcements/${firstCreate.body.announcement.id}`,
|
||
{ method: 'DELETE', headers: { 'Idempotency-Key': sequentialDeleteKey } },
|
||
);
|
||
expect(firstDelete.status).toBe(200);
|
||
createdIds.delete(firstCreate.body.announcement.id);
|
||
|
||
const replayedDelete = await api(
|
||
session.page,
|
||
`/api/admin/announcements/${firstCreate.body.announcement.id}`,
|
||
{ method: 'DELETE', headers: { 'Idempotency-Key': sequentialDeleteKey } },
|
||
);
|
||
expect(replayedDelete.status).toBe(200);
|
||
expect(replayedDelete.idempotencyReplayed).toBe('true');
|
||
expect(replayedDelete.body).toEqual(firstDelete.body);
|
||
|
||
const afterDelete = await api(session.page, '/api/admin/announcements');
|
||
expect(afterDelete.body.items.some(
|
||
(item: { id: string }) => item.id === firstCreate.body.announcement.id,
|
||
)).toBe(false);
|
||
|
||
const auditLogs = await api(session.page, '/api/admin/audit-logs?limit=50');
|
||
expect(auditLogs.status).toBe(200);
|
||
expect(auditLogs.body.items.filter((item: { action: string; target: string }) => (
|
||
item.action === '发布公告' && item.target === firstCreate.body.announcement.id
|
||
))).toHaveLength(1);
|
||
expect(auditLogs.body.items.filter((item: { action: string; target: string }) => (
|
||
item.action === '发布公告' && item.target === concurrentAnnouncementId
|
||
))).toHaveLength(1);
|
||
expect(auditLogs.body.items.filter((item: { action: string; target: string }) => (
|
||
item.action === '删除公告' && item.target === firstCreate.body.announcement.id
|
||
))).toHaveLength(1);
|
||
} finally {
|
||
await Promise.all([...createdIds].map((id, index) => api(
|
||
session.page,
|
||
`/api/admin/announcements/${id}`,
|
||
{ method: 'DELETE', headers: { 'Idempotency-Key': `fullstack-announcement-cleanup-${suffix}-${index}` } },
|
||
)));
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('完成上传可安全重试,且用户与管理员素材下载保持权限和状态边界', async ({ browser }) => {
|
||
// Fullstack 启动脚本借用管理员初始化命令创建第二个固定登录账号;本用例显式
|
||
// 降为普通用户,确保 403 与跨所有者边界由真实角色验证,而不是只比较账号 ID。
|
||
postgres(`UPDATE users SET role = 'user' WHERE email = '${secondaryAccount.email}';`);
|
||
const owner = await createAuthenticatedPage(browser, secondaryAccount);
|
||
const other = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
// 使用仓库内真实可完整解码的 PNG;只有文件头或损坏 CRC 的旧夹具会被
|
||
// 上传 worker 的 EOF 解码校验正确拒绝,不能据此放松生产媒体校验。
|
||
const pngBase64 = readFileSync('src/imports/alipay-logo.png').toString('base64');
|
||
const sizeBytes = Buffer.from(pngBase64, 'base64').length;
|
||
const signatureResult = await api(owner.page, '/api/assets/upload-signature', {
|
||
method: 'POST',
|
||
body: {
|
||
filename: 'integration.png', contentType: 'image/png', sizeBytes,
|
||
source: 'uploaded-image', folderId: null, idempotencyKey: `fullstack-upload-image-${Date.now()}`,
|
||
},
|
||
});
|
||
expect(signatureResult.status).toBe(200);
|
||
const signature = signatureResult.body.signature;
|
||
expect(signature).toMatchObject({ driver: 'local', method: 'PUT' });
|
||
|
||
const uploadStatus = await uploadSignedBytes(owner.page, signature, pngBase64);
|
||
expect(uploadStatus).toBe(204);
|
||
|
||
const completeBody = { uploadSessionId: signature.sessionId };
|
||
const [firstCompletion, retriedCompletion] = await Promise.all([
|
||
api(owner.page, '/api/assets/complete-upload', {
|
||
method: 'POST',
|
||
body: completeBody,
|
||
}),
|
||
api(owner.page, '/api/assets/complete-upload', {
|
||
method: 'POST',
|
||
body: completeBody,
|
||
}),
|
||
]);
|
||
expect(firstCompletion.status).toBe(200);
|
||
expect(retriedCompletion.status).toBe(200);
|
||
expect(retriedCompletion.body.session.id).toBe(firstCompletion.body.session.id);
|
||
await expect.poll(async () => {
|
||
const current = await api(owner.page, `/api/assets/upload-sessions/${signature.sessionId}`);
|
||
if (['failed', 'canceled', 'expired'].includes(current.body.session.status)) {
|
||
throw new Error(`图片上传会话异常结束:${JSON.stringify(current.body.session)}`);
|
||
}
|
||
return current.body.session.status;
|
||
}, { timeout: 30_000 }).toBe('completed');
|
||
const completedImage = await api(owner.page, `/api/assets/upload-sessions/${signature.sessionId}`);
|
||
expect(completedImage.body.session.asset).toBeTruthy();
|
||
|
||
const ownerFolders = await api(owner.page, '/api/assets/folders?source=uploaded-image');
|
||
const ownerRootFolder = ownerFolders.body.items.find(
|
||
(folder: { isSystem: boolean }) => folder.isSystem,
|
||
);
|
||
expect(ownerRootFolder?.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||
expect(completedImage.body.session.asset.folderId).toBe(ownerRootFolder.id);
|
||
|
||
const otherFolders = await api(other.page, '/api/assets/folders?source=uploaded-image');
|
||
const otherRootFolder = otherFolders.body.items.find(
|
||
(folder: { isSystem: boolean }) => folder.isSystem,
|
||
);
|
||
expect(otherRootFolder?.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||
expect(otherRootFolder.id).not.toBe(ownerRootFolder.id);
|
||
|
||
const assetId = completedImage.body.session.asset.id;
|
||
const ordinaryUserAdminDownload = await api(owner.page, `/api/admin/assets/${assetId}/download`, {
|
||
method: 'POST',
|
||
});
|
||
expect(ordinaryUserAdminDownload.status).toBe(403);
|
||
|
||
const downloadRequestedAtBeforeAdmin = postgresScalar(`
|
||
SELECT coalesce(download_requested_at::text, 'NULL')
|
||
FROM assets WHERE id = '${assetId}'::uuid;
|
||
`);
|
||
expect(downloadRequestedAtBeforeAdmin).toBe('NULL');
|
||
|
||
const adminDownload = await api(other.page, `/api/admin/assets/${assetId}/download`, {
|
||
method: 'POST',
|
||
});
|
||
expect(adminDownload.status).toBe(200);
|
||
expect(adminDownload.body).toEqual({
|
||
download: {
|
||
url: expect.any(String),
|
||
filename: 'integration.png',
|
||
},
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT coalesce(download_requested_at::text, 'NULL')
|
||
FROM assets WHERE id = '${assetId}'::uuid;
|
||
`)).toBe(downloadRequestedAtBeforeAdmin);
|
||
|
||
const adminOrdinaryDownload = await api(other.page, `/api/assets/${assetId}/download`, {
|
||
method: 'POST',
|
||
});
|
||
expect(adminOrdinaryDownload.status).toBe(404);
|
||
expect(await fetchStatus(owner.page, `/api/assets/${assetId}/download`)).toBe(200);
|
||
expect(await fetchStatus(other.page, `/api/assets/${assetId}/download`)).toBe(404);
|
||
expect(postgresScalar(`
|
||
SELECT (download_requested_at IS NOT NULL)::text
|
||
FROM assets WHERE id = '${assetId}'::uuid;
|
||
`)).toBe('true');
|
||
|
||
const crossAccountPatch = await api(other.page, `/api/assets/${assetId}`, {
|
||
method: 'PATCH',
|
||
body: { name: '不应被修改' },
|
||
});
|
||
expect(crossAccountPatch.status).toBe(404);
|
||
|
||
const crossUserFolderPatch = await api(owner.page, `/api/assets/${assetId}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: otherRootFolder.id },
|
||
});
|
||
expect(crossUserFolderPatch.status).toBe(400);
|
||
|
||
const ordinaryFolder = await api(owner.page, '/api/assets/folders', {
|
||
method: 'POST',
|
||
body: { name: '全链路普通目录', source: 'uploaded-image' },
|
||
});
|
||
expect(ordinaryFolder.status).toBe(200);
|
||
const ordinaryFolderId = ordinaryFolder.body.folder.id;
|
||
|
||
const movedToOrdinaryFolder = await api(owner.page, `/api/assets/${assetId}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: ordinaryFolderId },
|
||
});
|
||
expect(movedToOrdinaryFolder.body.asset.folderId).toBe(ordinaryFolderId);
|
||
|
||
const movedToRoot = await api(owner.page, `/api/assets/${assetId}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: null },
|
||
});
|
||
expect(movedToRoot.body.asset.folderId).toBe(ownerRootFolder.id);
|
||
|
||
const bulkMovedToOrdinaryFolder = await api(owner.page, '/api/assets/bulk-move', {
|
||
method: 'POST',
|
||
body: { assetIds: [assetId], folderId: ordinaryFolderId },
|
||
});
|
||
expect(bulkMovedToOrdinaryFolder.body.items[0].folderId).toBe(ordinaryFolderId);
|
||
|
||
const bulkMovedToRoot = await api(owner.page, '/api/assets/bulk-move', {
|
||
method: 'POST',
|
||
body: { assetIds: [assetId], folderId: null },
|
||
});
|
||
expect(bulkMovedToRoot.body.items[0].folderId).toBe(ownerRootFolder.id);
|
||
|
||
const mp4SizeBytes = Buffer.from(mp4Base64, 'base64').length;
|
||
expect(mp4SizeBytes).toBe(1538);
|
||
const videoSignatureResult = await api(owner.page, '/api/assets/upload-signature', {
|
||
method: 'POST',
|
||
body: {
|
||
filename: 'integration.mp4', contentType: 'video/mp4', sizeBytes: mp4SizeBytes,
|
||
source: 'uploaded-video', folderId: null, idempotencyKey: `fullstack-upload-video-${Date.now()}`,
|
||
},
|
||
});
|
||
expect(videoSignatureResult.status).toBe(200);
|
||
const videoSignature = videoSignatureResult.body.signature;
|
||
expect(videoSignature).toMatchObject({ driver: 'local', method: 'PUT' });
|
||
expect(await uploadSignedBytes(owner.page, videoSignature, mp4Base64)).toBe(204);
|
||
|
||
const completedVideo = await api(owner.page, '/api/assets/complete-upload', {
|
||
method: 'POST',
|
||
body: { uploadSessionId: videoSignature.sessionId },
|
||
});
|
||
expect(completedVideo.status).toBe(200);
|
||
await expect.poll(async () => {
|
||
const current = await api(owner.page, `/api/assets/upload-sessions/${videoSignature.sessionId}`);
|
||
if (['failed', 'canceled', 'expired'].includes(current.body.session.status)) {
|
||
throw new Error(`视频上传会话异常结束:${JSON.stringify(current.body.session)}`);
|
||
}
|
||
return current.body.session.status;
|
||
}, { timeout: 30_000 }).toBe('completed');
|
||
const finalizedVideo = await api(owner.page, `/api/assets/upload-sessions/${videoSignature.sessionId}`);
|
||
expect(finalizedVideo.body.session.asset).toMatchObject({
|
||
type: 'video',
|
||
source: 'uploaded-video',
|
||
sizeBytes: mp4SizeBytes,
|
||
width: 16,
|
||
height: 16,
|
||
});
|
||
expect(Number(finalizedVideo.body.session.asset.durationSeconds)).toBeCloseTo(1, 1);
|
||
expect(await fetchStatus(owner.page, `/api/assets/${finalizedVideo.body.session.asset.id}/download`)).toBe(200);
|
||
} finally {
|
||
await Promise.all([owner.context.close(), other.context.close()]);
|
||
postgres(`UPDATE users SET role = 'admin' WHERE email = '${secondaryAccount.email}';`);
|
||
}
|
||
});
|
||
|
||
test('素材文件夹删除在真实 PostgreSQL 中支持两种内容动作、墓碑幂等与缺主目录原子回滚', async ({ browser }) => {
|
||
test.setTimeout(90_000);
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `asset-folder-delete-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
const ids = {
|
||
audioRoot: randomUUID(),
|
||
videoRoot: randomUUID(),
|
||
aiVideoRoot: randomUUID(),
|
||
aiImageRoot: randomUUID(),
|
||
imageRoot: randomUUID(),
|
||
moveFolder: randomUUID(),
|
||
deleteFolder: randomUUID(),
|
||
missingRootFolder: randomUUID(),
|
||
historicalTombstone: randomUUID(),
|
||
completedSession: randomUUID(),
|
||
moveAssetOne: randomUUID(),
|
||
moveAssetTwo: randomUUID(),
|
||
deleteAssetOne: randomUUID(),
|
||
deleteAssetTwo: randomUUID(),
|
||
missingRootAsset: randomUUID(),
|
||
};
|
||
const objectPrefix = `fullstack/asset-folder-delete/${marker}`;
|
||
let session: Awaited<ReturnType<typeof createAuthenticatedPage>> | undefined;
|
||
|
||
try {
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}'::uuid, '${account.email}', password_hash,
|
||
'全栈素材目录删除用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES
|
||
('${ids.aiVideoRoot}'::uuid, '${account.id}'::uuid, 'AI 视频', 'ai-video', true),
|
||
('${ids.aiImageRoot}'::uuid, '${account.id}'::uuid, 'AI 图片', 'ai-image', true),
|
||
('${ids.videoRoot}'::uuid, '${account.id}'::uuid, '已上传视频', 'uploaded-video', true),
|
||
('${ids.imageRoot}'::uuid, '${account.id}'::uuid, '已上传图片', 'uploaded-image', true),
|
||
('${ids.audioRoot}'::uuid, '${account.id}'::uuid, '已上传音频', 'uploaded-audio', true),
|
||
('${ids.moveFolder}'::uuid, '${account.id}'::uuid, '迁回主目录', 'uploaded-audio', false),
|
||
('${ids.deleteFolder}'::uuid, '${account.id}'::uuid, '连同素材删除', 'uploaded-audio', false),
|
||
('${ids.missingRootFolder}'::uuid, '${account.id}'::uuid, '缺失主目录回滚', 'uploaded-video', false);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key,
|
||
size_bytes, duration_seconds, deletion_status, thumbnail_status, created_at, updated_at
|
||
) VALUES
|
||
('${ids.moveAssetOne}'::uuid, '${account.id}'::uuid, '${ids.moveFolder}'::uuid,
|
||
'迁移音频一', 'audio', 'uploaded-audio', '${objectPrefix}/move-1.mp3', NULL,
|
||
32, 1, 'active', 'not-required', now(), now()),
|
||
('${ids.moveAssetTwo}'::uuid, '${account.id}'::uuid, '${ids.moveFolder}'::uuid,
|
||
'迁移音频二', 'audio', 'uploaded-audio', '${objectPrefix}/move-2.mp3', NULL,
|
||
32, 1, 'active', 'not-required', now(), now()),
|
||
('${ids.deleteAssetOne}'::uuid, '${account.id}'::uuid, '${ids.deleteFolder}'::uuid,
|
||
'删除音频一', 'audio', 'uploaded-audio', '${objectPrefix}/delete-1.mp3', NULL,
|
||
32, 1, 'active', 'not-required', now(), now()),
|
||
('${ids.deleteAssetTwo}'::uuid, '${account.id}'::uuid, '${ids.deleteFolder}'::uuid,
|
||
'删除音频二', 'audio', 'uploaded-audio', '${objectPrefix}/delete-2.mp3', NULL,
|
||
32, 1, 'active', 'not-required', now(), now()),
|
||
('${ids.missingRootAsset}'::uuid, '${account.id}'::uuid, '${ids.missingRootFolder}'::uuid,
|
||
'缺主目录视频', 'video', 'uploaded-video', '${objectPrefix}/missing-root.mp4',
|
||
'${objectPrefix}/missing-root.webp', 32, 1, 'active', 'ready', now(), now());
|
||
|
||
INSERT INTO upload_sessions (
|
||
id, user_id, object_key, filename, content_type, max_size_bytes,
|
||
status, asset_id, expires_at, completed_at,
|
||
idempotency_key, request_hash, source, asset_type, asset_name, folder_id,
|
||
final_object_key, actual_size_bytes, sealed_at, sealed_size_bytes,
|
||
retryable, attempt, processing_stage, retention_until, started_at
|
||
) VALUES (
|
||
'${ids.completedSession}'::uuid, '${account.id}'::uuid,
|
||
'${objectPrefix}/completed-staging.mp3', 'move-1.mp3', 'audio/mpeg', 32,
|
||
'completed', '${ids.moveAssetOne}'::uuid, now() + interval '10 minutes', now(),
|
||
'fullstack-folder-delete-completed-${marker}', 'fullstack-folder-delete-completed-${marker}',
|
||
'uploaded-audio', 'audio', '迁移音频一', '${ids.moveFolder}'::uuid,
|
||
'${objectPrefix}/move-1.mp3', 32, now(), 32,
|
||
false, 1, 'completed', now() + interval '1 day', now()
|
||
);
|
||
|
||
-- 只写入 027 时代已有列,验证 077 为历史墓碑补上的真实默认语义。
|
||
INSERT INTO asset_folder_deletion_records (user_id, folder_id)
|
||
VALUES ('${account.id}'::uuid, '${ids.historicalTombstone}'::uuid);
|
||
`);
|
||
|
||
session = await createAuthenticatedPage(browser, account);
|
||
// 本用例只走 API;离开首页可避免长时间加载的媒体响应在 context.close 时
|
||
// 与 Playwright trace 数据流同时被中断,造成与业务无关的 zip/stream 截断。
|
||
await session.page.goto('/api/ready');
|
||
const createPendingSession = async (folderId: string, suffix: string, source: 'uploaded-audio' | 'uploaded-video') => {
|
||
const audio = source === 'uploaded-audio';
|
||
const result = await api(session!.page, '/api/assets/upload-signature', {
|
||
method: 'POST',
|
||
body: {
|
||
filename: `${suffix}.${audio ? 'mp3' : 'mp4'}`,
|
||
contentType: audio ? 'audio/mpeg' : 'video/mp4',
|
||
sizeBytes: 32,
|
||
source,
|
||
folderId,
|
||
idempotencyKey: `fullstack-folder-delete-${marker}-${suffix}`,
|
||
},
|
||
});
|
||
expect(result.status).toBe(200);
|
||
expect(result.body.signature?.sessionId).toMatch(/^[0-9a-f-]{36}$/i);
|
||
return String(result.body.signature.sessionId);
|
||
};
|
||
|
||
const pendingSessionId = await createPendingSession(ids.moveFolder, 'pending', 'uploaded-audio');
|
||
const processingSessionId = await createPendingSession(ids.moveFolder, 'processing', 'uploaded-audio');
|
||
const retryableSessionId = await createPendingSession(ids.moveFolder, 'retryable', 'uploaded-audio');
|
||
postgres(`
|
||
UPDATE upload_sessions
|
||
SET status = 'processing', processing_stage = 'sealing',
|
||
lease_token = gen_random_uuid(), lease_expires_at = now() + interval '10 minutes',
|
||
heartbeat_at = now(), updated_at = now()
|
||
WHERE id = '${processingSessionId}'::uuid;
|
||
UPDATE upload_sessions
|
||
SET status = 'failed', processing_stage = 'failed', retryable = true,
|
||
retention_until = now() + interval '1 day', updated_at = now()
|
||
WHERE id = '${retryableSessionId}'::uuid;
|
||
`);
|
||
|
||
const terminalMoveFailure = postgresRollbackScriptFailure(`
|
||
UPDATE upload_sessions
|
||
SET folder_id = '${ids.audioRoot}'::uuid
|
||
WHERE id = '${ids.completedSession}'::uuid;
|
||
`);
|
||
expect(terminalMoveFailure).toContain('仅允许可恢复会话迁入同来源主目录或终态会话从普通目录脱钩');
|
||
|
||
const crossSourceMoveFailure = postgresRollbackScriptFailure(`
|
||
UPDATE upload_sessions
|
||
SET folder_id = '${ids.videoRoot}'::uuid
|
||
WHERE id = '${pendingSessionId}'::uuid;
|
||
`);
|
||
expect(crossSourceMoveFailure).toContain('仅允许可恢复会话迁入同来源主目录或终态会话从普通目录脱钩');
|
||
|
||
const moveToRoot = await api(session.page, `/api/assets/folders/${ids.moveFolder}`, { method: 'DELETE' });
|
||
expect(moveToRoot.status).toBe(200);
|
||
expect(moveToRoot.body).toEqual({
|
||
ok: true,
|
||
contentAction: 'move-to-root',
|
||
affectedAssetCount: 2,
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM assets
|
||
WHERE id IN ('${ids.moveAssetOne}'::uuid, '${ids.moveAssetTwo}'::uuid)
|
||
AND folder_id = '${ids.audioRoot}'::uuid AND deletion_status = 'active';
|
||
`)).toBe('2');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM upload_sessions
|
||
WHERE id IN ('${pendingSessionId}'::uuid, '${processingSessionId}'::uuid, '${retryableSessionId}'::uuid)
|
||
AND folder_id = '${ids.audioRoot}'::uuid;
|
||
`)).toBe('3');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', status, folder_id IS NULL)
|
||
FROM upload_sessions WHERE id = '${ids.completedSession}'::uuid;
|
||
`)).toBe('completed|t');
|
||
expect(postgresScalar(`SELECT count(*) FROM asset_folders WHERE id = '${ids.moveFolder}'::uuid;`)).toBe('0');
|
||
|
||
const moveReplay = await api(session.page, `/api/assets/folders/${ids.moveFolder}`, { method: 'DELETE' });
|
||
expect(moveReplay.body).toEqual(moveToRoot.body);
|
||
const explicitMoveReplay = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.moveFolder}?contentAction=move-to-root`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(explicitMoveReplay.body).toEqual(moveToRoot.body);
|
||
const moveConflict = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.moveFolder}?contentAction=delete-assets`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(moveConflict.status).toBe(409);
|
||
expect(moveConflict.body.code).toBe('ASSET_FOLDER_DELETE_ACTION_CONFLICT');
|
||
|
||
const deleteFolderSessionId = await createPendingSession(ids.deleteFolder, 'delete-assets', 'uploaded-audio');
|
||
const deleteAssets = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.deleteFolder}?contentAction=delete-assets`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(deleteAssets.status).toBe(200);
|
||
expect(deleteAssets.body).toEqual({
|
||
ok: true,
|
||
contentAction: 'delete-assets',
|
||
affectedAssetCount: 2,
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM assets
|
||
WHERE id IN ('${ids.deleteAssetOne}'::uuid, '${ids.deleteAssetTwo}'::uuid)
|
||
AND deletion_status = 'active';
|
||
`)).toBe('0');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM assets
|
||
WHERE id IN ('${ids.deleteAssetOne}'::uuid, '${ids.deleteAssetTwo}'::uuid)
|
||
AND folder_id IS NULL;
|
||
`)).toBe('2');
|
||
expect(postgresScalar(`
|
||
SELECT folder_id FROM upload_sessions WHERE id = '${deleteFolderSessionId}'::uuid;
|
||
`)).toBe(ids.audioRoot);
|
||
|
||
const deleteReplay = await api(session.page, `/api/assets/folders/${ids.deleteFolder}`, { method: 'DELETE' });
|
||
expect(deleteReplay.body).toEqual(deleteAssets.body);
|
||
const explicitDeleteReplay = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.deleteFolder}?contentAction=delete-assets`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(explicitDeleteReplay.body).toEqual(deleteAssets.body);
|
||
const deleteConflict = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.deleteFolder}?contentAction=move-to-root`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(deleteConflict.status).toBe(409);
|
||
expect(deleteConflict.body.code).toBe('ASSET_FOLDER_DELETE_ACTION_CONFLICT');
|
||
|
||
const historicalReplay = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.historicalTombstone}`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(historicalReplay.status).toBe(200);
|
||
expect(historicalReplay.body).toEqual({
|
||
ok: true,
|
||
contentAction: 'delete-assets',
|
||
affectedAssetCount: null,
|
||
});
|
||
const historicalConflict = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.historicalTombstone}?contentAction=move-to-root`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(historicalConflict.status).toBe(409);
|
||
expect(historicalConflict.body.code).toBe('ASSET_FOLDER_DELETE_ACTION_CONFLICT');
|
||
|
||
const missingRootSessionId = await createPendingSession(
|
||
ids.missingRootFolder,
|
||
'missing-root',
|
||
'uploaded-video',
|
||
);
|
||
postgres(`DELETE FROM asset_folders WHERE id = '${ids.videoRoot}'::uuid;`);
|
||
const missingRootDelete = await api(
|
||
session.page,
|
||
`/api/assets/folders/${ids.missingRootFolder}`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(missingRootDelete.status).toBe(500);
|
||
expect(missingRootDelete.body.code).toBe('ASSET_ROOT_FOLDER_MISSING');
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM asset_folders WHERE id = '${ids.missingRootFolder}'::uuid),
|
||
(SELECT count(*) FROM assets
|
||
WHERE id = '${ids.missingRootAsset}'::uuid
|
||
AND folder_id = '${ids.missingRootFolder}'::uuid AND deletion_status = 'active'),
|
||
(SELECT count(*) FROM upload_sessions
|
||
WHERE id = '${missingRootSessionId}'::uuid AND folder_id = '${ids.missingRootFolder}'::uuid),
|
||
(SELECT count(*) FROM asset_folder_deletion_records
|
||
WHERE user_id = '${account.id}'::uuid AND folder_id = '${ids.missingRootFolder}'::uuid)
|
||
);
|
||
`)).toBe('1|1|1|0');
|
||
postgres(`
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES ('${ids.videoRoot}'::uuid, '${account.id}'::uuid, '已上传视频', 'uploaded-video', true);
|
||
`);
|
||
} finally {
|
||
await session?.context.close();
|
||
postgres(`
|
||
DELETE FROM object_deletion_outbox
|
||
WHERE object_key LIKE '${objectPrefix}/%'
|
||
OR object_key IN (
|
||
SELECT object_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
UNION SELECT final_object_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
UNION SELECT thumbnail_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
);
|
||
DELETE FROM upload_sessions WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM assets WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM asset_folder_deletion_records WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM asset_folders WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM users WHERE id = '${account.id}'::uuid;
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('文件夹删除与未完成上传会话创建在真实 PostgreSQL 中按目录到会话锁序列化', async ({ browser }) => {
|
||
test.setTimeout(60_000);
|
||
const marker = randomUUID();
|
||
const lockKey = `fullstack-folder-upload-lock-${marker}`;
|
||
const idempotencyKey = `fullstack-folder-upload-concurrency-${marker}`;
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `asset-folder-lock-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
const audioRootId = randomUUID();
|
||
const folderId = randomUUID();
|
||
const assetId = randomUUID();
|
||
const objectKey = `fullstack/asset-folder-lock/${marker}/existing.mp3`;
|
||
const sqlSuffix = marker.replaceAll('-', '').slice(0, 16);
|
||
const triggerName = `fullstack_gate_upload_${sqlSuffix}`;
|
||
const functionName = `fullstack_gate_upload_fn_${sqlSuffix}`;
|
||
let uploader: Awaited<ReturnType<typeof createAuthenticatedPage>> | undefined;
|
||
let deleter: Awaited<ReturnType<typeof createAuthenticatedPage>> | undefined;
|
||
let lockHolder: Awaited<ReturnType<typeof holdPostgresAdvisoryLock>> | undefined;
|
||
|
||
try {
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}'::uuid, '${account.email}', password_hash,
|
||
'全栈素材目录锁用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
INSERT INTO asset_folders (user_id, name, source, is_system)
|
||
VALUES
|
||
('${account.id}'::uuid, 'AI 视频', 'ai-video', true),
|
||
('${account.id}'::uuid, 'AI 图片', 'ai-image', true),
|
||
('${account.id}'::uuid, '已上传视频', 'uploaded-video', true),
|
||
('${account.id}'::uuid, '已上传图片', 'uploaded-image', true);
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES
|
||
('${audioRootId}'::uuid, '${account.id}'::uuid, '已上传音频', 'uploaded-audio', true),
|
||
('${folderId}'::uuid, '${account.id}'::uuid, '并发上传目录', 'uploaded-audio', false);
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key,
|
||
size_bytes, duration_seconds, deletion_status, thumbnail_status
|
||
) VALUES (
|
||
'${assetId}'::uuid, '${account.id}'::uuid, '${folderId}'::uuid,
|
||
'并发既有音频', 'audio', 'uploaded-audio', '${objectKey}',
|
||
32, 1, 'active', 'not-required'
|
||
);
|
||
|
||
CREATE OR REPLACE FUNCTION ${functionName}()
|
||
RETURNS trigger
|
||
LANGUAGE plpgsql
|
||
AS $fullstack_gate$
|
||
BEGIN
|
||
IF NEW.idempotency_key = '${idempotencyKey}' THEN
|
||
PERFORM pg_advisory_xact_lock(hashtextextended('${lockKey}', 0));
|
||
END IF;
|
||
RETURN NEW;
|
||
END
|
||
$fullstack_gate$;
|
||
CREATE TRIGGER ${triggerName}
|
||
BEFORE INSERT ON upload_sessions
|
||
FOR EACH ROW EXECUTE FUNCTION ${functionName}();
|
||
`);
|
||
|
||
uploader = await createAuthenticatedPage(browser, account);
|
||
deleter = await createAuthenticatedPage(browser, account);
|
||
await Promise.all([uploader.page.goto('/api/ready'), deleter.page.goto('/api/ready')]);
|
||
lockHolder = await holdPostgresAdvisoryLock(lockKey);
|
||
const signaturePromise = api(uploader.page, '/api/assets/upload-signature', {
|
||
method: 'POST',
|
||
body: {
|
||
filename: `concurrent-${marker}.mp3`,
|
||
contentType: 'audio/mpeg',
|
||
sizeBytes: 32,
|
||
source: 'uploaded-audio',
|
||
folderId,
|
||
idempotencyKey,
|
||
},
|
||
});
|
||
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT count(*) FROM pg_stat_activity
|
||
WHERE datname = 'lindyson'
|
||
AND wait_event_type = 'Lock'
|
||
AND wait_event = 'advisory'
|
||
AND query ILIKE '%INSERT INTO upload_sessions%';
|
||
`), { timeout: 10_000, intervals: [50, 100, 200] }).toBe('1');
|
||
|
||
const deletePromise = api(deleter.page, `/api/assets/folders/${folderId}`, { method: 'DELETE' });
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT count(*) FROM pg_stat_activity
|
||
WHERE datname = 'lindyson'
|
||
AND wait_event_type = 'Lock'
|
||
AND query ILIKE '%FROM asset_folders%FOR UPDATE%';
|
||
`), { timeout: 10_000, intervals: [50, 100, 200] }).toBe('1');
|
||
|
||
await lockHolder.release();
|
||
lockHolder = undefined;
|
||
const [signature, deletion] = await Promise.all([signaturePromise, deletePromise]);
|
||
|
||
expect(signature.status).toBe(200);
|
||
expect(signature.body.signature?.sessionId).toMatch(/^[0-9a-f-]{36}$/i);
|
||
expect(deletion.status).toBe(200);
|
||
expect(deletion.body).toEqual({
|
||
ok: true,
|
||
contentAction: 'move-to-root',
|
||
affectedAssetCount: 1,
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM asset_folders WHERE id = '${folderId}'::uuid),
|
||
(SELECT count(*) FROM assets
|
||
WHERE id = '${assetId}'::uuid AND folder_id = '${audioRootId}'::uuid),
|
||
(SELECT count(*) FROM upload_sessions
|
||
WHERE id = '${signature.body.signature.sessionId}'::uuid
|
||
AND folder_id = '${audioRootId}'::uuid AND status = 'pending')
|
||
);
|
||
`)).toBe('0|1|1');
|
||
} finally {
|
||
await lockHolder?.release();
|
||
postgres(`
|
||
DROP TRIGGER IF EXISTS ${triggerName} ON upload_sessions;
|
||
DROP FUNCTION IF EXISTS ${functionName}();
|
||
`);
|
||
await Promise.all([uploader?.context.close(), deleter?.context.close()]);
|
||
postgres(`
|
||
DELETE FROM object_deletion_outbox
|
||
WHERE object_key = '${objectKey}'
|
||
OR object_key IN (
|
||
SELECT object_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
UNION SELECT final_object_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
UNION SELECT thumbnail_key FROM upload_sessions WHERE user_id = '${account.id}'::uuid
|
||
);
|
||
DELETE FROM upload_sessions WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM assets WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM asset_folder_deletion_records WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM asset_folders WHERE user_id = '${account.id}'::uuid;
|
||
DELETE FROM users WHERE id = '${account.id}'::uuid;
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('历史生成参考素材迁移会清除跨用户存储凭证且可重复执行', async ({ browser }) => {
|
||
const ids = {
|
||
ownReference: randomUUID(),
|
||
victimReference: randomUUID(),
|
||
deletedReference: randomUUID(),
|
||
legacyReference: randomUUID(),
|
||
legacyRootReference: randomUUID(),
|
||
outputAsset: randomUUID(),
|
||
task: randomUUID(),
|
||
};
|
||
const ownObjectKey = `fullstack/reference-security/${ids.ownReference}.png`;
|
||
const ownThumbnailKey = `fullstack/reference-security/${ids.ownReference}-thumb.jpg`;
|
||
const victimObjectKey = `fullstack/reference-security/${ids.victimReference}.png`;
|
||
const victimThumbnailKey = `fullstack/reference-security/${ids.victimReference}-thumb.jpg`;
|
||
const deletedObjectKey = `fullstack/reference-security/${ids.deletedReference}.png`;
|
||
const legacyExternalUrl = 'https://legacy.example.test/reference.png';
|
||
const fixedUpdatedAt = '2026-01-02 03:04:05+08';
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
|
||
try {
|
||
postgres(`
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key,
|
||
size_bytes, duration_seconds, deletion_status, deleted_at, created_at, updated_at
|
||
) VALUES
|
||
(
|
||
'${ids.ownReference}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
(SELECT id FROM asset_folders
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')
|
||
AND source = 'uploaded-image' AND is_system = true),
|
||
'数据库自有参考图', 'image', 'uploaded-image', '${ownObjectKey}',
|
||
'${ownThumbnailKey}', 321, NULL, 'active', NULL, now(), '${fixedUpdatedAt}'
|
||
),
|
||
(
|
||
'${ids.victimReference}',
|
||
(SELECT id FROM users WHERE email = '${secondaryAccount.email}'),
|
||
(SELECT id FROM asset_folders
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${secondaryAccount.email}')
|
||
AND source = 'uploaded-image' AND is_system = true),
|
||
'其他用户私有参考图', 'image', 'uploaded-image', '${victimObjectKey}',
|
||
'${victimThumbnailKey}', 654, NULL, 'active', NULL, now(), '${fixedUpdatedAt}'
|
||
),
|
||
(
|
||
'${ids.deletedReference}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
NULL,
|
||
'已删除参考图', 'image', 'uploaded-image', '${deletedObjectKey}',
|
||
NULL, 111, NULL, 'deleted', now(), now(), '${fixedUpdatedAt}'
|
||
);
|
||
|
||
INSERT INTO generation_tasks (
|
||
id, user_id, kind, model, status, processing_stage, claim_token, lease_expires_at,
|
||
prompt, config, credits, created_at, updated_at
|
||
) VALUES (
|
||
'${ids.task}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
'video', 'Seedance 2.0', 'processing', 'prepared', gen_random_uuid(),
|
||
now() + interval '10 minutes', '历史参考素材迁移测试',
|
||
jsonb_build_object(
|
||
'mode', 'general',
|
||
'aspectRatio', '9:16',
|
||
'resolution', '720p',
|
||
'durationSeconds', 5,
|
||
'generateAudio', true,
|
||
'referenceAssets', jsonb_build_array(
|
||
jsonb_build_object(
|
||
'id', '伪造 ID', 'name', '伪造名称', 'thumbnail', 'https://victim.example.test/thumb',
|
||
'objectKey', '${ownObjectKey}', 'thumbnailKey', '${victimThumbnailKey}',
|
||
'publicUrl', 'https://victim.example.test/object', 'type', 'video',
|
||
'role', 'reference_image', 'durationSeconds', 999, 'sizeBytes', 999
|
||
),
|
||
jsonb_build_object(
|
||
'id', '${ids.victimReference}', 'name', '跨用户素材', 'thumbnail', 'blob:unsafe',
|
||
'objectKey', '${victimObjectKey}', 'thumbnailKey', '${victimThumbnailKey}',
|
||
'publicUrl', 'https://victim.example.test/private', 'type', 'image'
|
||
),
|
||
jsonb_build_object(
|
||
'id', '${ids.deletedReference}', 'name', '失效素材',
|
||
'objectKey', '${deletedObjectKey}', 'url', 'https://victim.example.test/deleted',
|
||
'type', 'image'
|
||
),
|
||
jsonb_build_object(
|
||
'id', '${ids.legacyReference}', 'name', '旧版外链', 'thumbnail', '${legacyExternalUrl}',
|
||
'thumbnailKey', '${victimThumbnailKey}', 'publicUrl', '${legacyExternalUrl}',
|
||
'type', 'image'
|
||
)
|
||
)
|
||
),
|
||
0, now(), '${fixedUpdatedAt}'
|
||
);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, size_bytes,
|
||
generation_meta, deletion_status, created_at, updated_at
|
||
) VALUES (
|
||
'${ids.outputAsset}',
|
||
(SELECT id FROM users WHERE email = '${primaryAccount.email}'),
|
||
(SELECT id FROM asset_folders
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')
|
||
AND source = 'ai-video' AND is_system = true),
|
||
'带历史引用的生成视频', 'video', 'ai-video',
|
||
'fullstack/reference-security/${ids.outputAsset}.mp4', 1024,
|
||
jsonb_build_object(
|
||
'kind', 'video',
|
||
'model', 'Seedance 2.0',
|
||
'prompt', '历史参考素材迁移测试',
|
||
'credits', 0,
|
||
'config', (SELECT config FROM generation_tasks WHERE id = '${ids.task}'),
|
||
'referenceImages', jsonb_build_array(
|
||
jsonb_build_object(
|
||
'id', '伪造图片 ID', 'name', '伪造图片名', 'thumbnail', 'blob:unsafe',
|
||
'objectKey', '${ownObjectKey}', 'thumbnailKey', '${victimThumbnailKey}',
|
||
'publicUrl', 'https://victim.example.test/image', 'type', 'video'
|
||
),
|
||
jsonb_build_object(
|
||
'id', '${ids.legacyRootReference}', 'name', '根级旧版外链',
|
||
'thumbnail', '${legacyExternalUrl}', 'publicUrl', '${legacyExternalUrl}'
|
||
)
|
||
)
|
||
),
|
||
'active', now(), '${fixedUpdatedAt}'
|
||
);
|
||
`);
|
||
|
||
const migrationSql = readFileSync(
|
||
'apps/api/migrations/043_secure_generation_asset_references.sql',
|
||
'utf8',
|
||
);
|
||
expect(() => postgresScript(migrationSql)).toThrow();
|
||
expect(postgresScalar(`
|
||
SELECT config::text FROM generation_tasks WHERE id = '${ids.task}';
|
||
`)).toContain(victimThumbnailKey);
|
||
postgres(`
|
||
UPDATE generation_tasks
|
||
SET status = 'failed', processing_stage = 'failed',
|
||
credit_status = 'refunded', settled_credits = 0, refunded_credits = 0,
|
||
claim_token = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||
WHERE id = '${ids.task}';
|
||
`);
|
||
postgresScript(migrationSql);
|
||
|
||
const taskConfig = JSON.parse(postgresScalar(`
|
||
SELECT config::text FROM generation_tasks WHERE id = '${ids.task}';
|
||
`));
|
||
expect(taskConfig.referenceAssets).toEqual([
|
||
{
|
||
id: ids.ownReference,
|
||
name: '数据库自有参考图',
|
||
thumbnail: '',
|
||
objectKey: ownObjectKey,
|
||
thumbnailKey: ownThumbnailKey,
|
||
type: 'image',
|
||
role: 'reference_image',
|
||
sizeBytes: 321,
|
||
},
|
||
{
|
||
id: ids.victimReference,
|
||
name: '跨用户素材',
|
||
thumbnail: '',
|
||
type: 'image',
|
||
},
|
||
{
|
||
id: ids.deletedReference,
|
||
name: '失效素材',
|
||
thumbnail: '',
|
||
type: 'image',
|
||
},
|
||
{
|
||
id: ids.legacyReference,
|
||
name: '旧版外链',
|
||
thumbnail: legacyExternalUrl,
|
||
publicUrl: legacyExternalUrl,
|
||
type: 'image',
|
||
},
|
||
]);
|
||
expect(JSON.stringify(taskConfig)).not.toContain(victimThumbnailKey);
|
||
expect(JSON.stringify(taskConfig)).not.toContain('victim.example.test');
|
||
|
||
const generationMeta = JSON.parse(postgresScalar(`
|
||
SELECT generation_meta::text FROM assets WHERE id = '${ids.outputAsset}';
|
||
`));
|
||
expect(generationMeta.config).toEqual(taskConfig);
|
||
expect(generationMeta.referenceImages).toEqual([
|
||
{
|
||
id: ids.ownReference,
|
||
name: '数据库自有参考图',
|
||
thumbnail: '',
|
||
objectKey: ownObjectKey,
|
||
thumbnailKey: ownThumbnailKey,
|
||
type: 'image',
|
||
sizeBytes: 321,
|
||
},
|
||
{
|
||
id: ids.legacyRootReference,
|
||
name: '根级旧版外链',
|
||
thumbnail: legacyExternalUrl,
|
||
publicUrl: legacyExternalUrl,
|
||
type: 'image',
|
||
},
|
||
]);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT updated_at::text FROM generation_tasks WHERE id = '${ids.task}';
|
||
`)).toBe('2026-01-01 19:04:05+00');
|
||
expect(postgresScalar(`
|
||
SELECT updated_at::text FROM assets WHERE id = '${ids.outputAsset}';
|
||
`)).toBe('2026-01-01 19:04:05+00');
|
||
|
||
const firstHash = postgresScalar(`
|
||
SELECT md5(task.config::text || asset.generation_meta::text)
|
||
FROM generation_tasks task
|
||
CROSS JOIN assets asset
|
||
WHERE task.id = '${ids.task}' AND asset.id = '${ids.outputAsset}';
|
||
`);
|
||
postgresScript(migrationSql);
|
||
expect(postgresScalar(`
|
||
SELECT md5(task.config::text || asset.generation_meta::text)
|
||
FROM generation_tasks task
|
||
CROSS JOIN assets asset
|
||
WHERE task.id = '${ids.task}' AND asset.id = '${ids.outputAsset}';
|
||
`)).toBe(firstHash);
|
||
|
||
const removedLegacyList = await api(session.page, '/api/assets');
|
||
expect(removedLegacyList.status).toBe(404);
|
||
|
||
const detail = await api(session.page, `/api/assets/${ids.outputAsset}`);
|
||
expect(detail.status).toBe(200);
|
||
const output = detail.body.asset;
|
||
expect(JSON.stringify(output)).not.toContain(victimObjectKey);
|
||
expect(JSON.stringify(output)).not.toContain(victimThumbnailKey);
|
||
expect(JSON.stringify(output)).not.toContain(ownObjectKey);
|
||
expect(JSON.stringify(output)).not.toContain(ownThumbnailKey);
|
||
expect(JSON.stringify(output)).not.toContain('victim.example.test');
|
||
expect(JSON.stringify(output)).not.toContain('legacy.example.test');
|
||
expect(output.generation.config.referenceAssets[0]).toEqual({
|
||
id: ids.ownReference,
|
||
name: '数据库自有参考图',
|
||
type: 'image',
|
||
role: 'reference_image',
|
||
previewUrl: null,
|
||
sizeBytes: 321,
|
||
});
|
||
} finally {
|
||
postgres(`
|
||
DELETE FROM generation_tasks WHERE id = '${ids.task}';
|
||
DELETE FROM assets WHERE id IN (
|
||
'${ids.outputAsset}', '${ids.ownReference}', '${ids.victimReference}', '${ids.deletedReference}'
|
||
);
|
||
`);
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('批量生成幂等重试返回原任务且余额只预扣一次', async ({ browser }) => {
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const beforeCredits = await readCredits(session.page);
|
||
const body = {
|
||
idempotencyKey: 'integration-batch-idempotency-0001',
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '验证批量生成幂等性的商品展示视频',
|
||
config: { aspectRatio: '9:16', durationSeconds: 5, resolution: '480p' },
|
||
}],
|
||
};
|
||
|
||
const first = await api(session.page, '/api/ai/video-tasks/batch', { method: 'POST', body });
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.tasks).toHaveLength(1);
|
||
const creditsAfterFirst = await readCredits(session.page);
|
||
|
||
const retry = await api(session.page, '/api/ai/video-tasks/batch', { method: 'POST', body });
|
||
expect(retry.status).toBe(200);
|
||
expect(retry.body.tasks).toHaveLength(1);
|
||
expect(retry.body.tasks[0].id).toBe(first.body.tasks[0].id);
|
||
const creditsAfterRetry = await readCredits(session.page);
|
||
|
||
expect(creditsAfterFirst).toBe(beforeCredits - Number(first.body.tasks[0].credits));
|
||
expect(creditsAfterRetry).toBe(creditsAfterFirst);
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, first.body.tasks[0].id)).status,
|
||
{ timeout: 30_000 },
|
||
).toBe('completed');
|
||
} finally {
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('生成任务失败退款后管理员原地重试成功,旧 attempt 流水快照不漂移', async ({ browser }) => {
|
||
test.setTimeout(45_000);
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const beforeCredits = await readCredits(session.page);
|
||
const created = await api(session.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `integration-generation-failure-${randomUUID()}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '验证供应商终态失败后退回全部预扣积分',
|
||
config: { aspectRatio: '9:16', durationSeconds: 5, resolution: '480p' },
|
||
}],
|
||
},
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const task = created.body.tasks[0];
|
||
const reservedCredits = Number(task.reservedCredits);
|
||
expect(task).toMatchObject({ status: 'queued', creditStatus: 'held' });
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - reservedCredits);
|
||
|
||
const canceled = await api(session.page, `/api/ai/tasks/${task.id}/cancel`, { method: 'POST' });
|
||
expect(canceled.status).toBe(404);
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, task.id)).status,
|
||
{ timeout: 30_000, intervals: [500, 1_000, 2_000] },
|
||
).toBe('failed');
|
||
|
||
const failedTask = await readTask(session.page, task.id);
|
||
expect(failedTask).toMatchObject({
|
||
status: 'failed',
|
||
creditStatus: 'refunded',
|
||
settledCredits: 0,
|
||
refundedCredits: reservedCredits,
|
||
assetId: null,
|
||
});
|
||
expect(await readCredits(session.page)).toBe(beforeCredits);
|
||
|
||
const ledger = await api(session.page, '/api/account/credit-ledger?limit=50');
|
||
expect(ledger.status).toBe(200);
|
||
const taskEntries = ledger.body.items.filter(
|
||
(item: { relatedJobId: string | null }) => item.relatedJobId === task.id,
|
||
);
|
||
expect(taskEntries).toHaveLength(2);
|
||
expect(taskEntries).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({
|
||
type: 'hold',
|
||
settlementStatus: 'refunded',
|
||
amount: -reservedCredits,
|
||
generationSnapshot: expect.objectContaining({
|
||
attempt: 0,
|
||
model: 'Seedance 2.0',
|
||
durationSeconds: 5,
|
||
resolution: '480p',
|
||
reservedCredits,
|
||
settledCredits: 0,
|
||
refundedCredits: 0,
|
||
taskStatus: 'failed',
|
||
creditStatus: 'refunded',
|
||
}),
|
||
}),
|
||
expect.objectContaining({
|
||
type: 'refund',
|
||
settlementStatus: 'refunded',
|
||
amount: reservedCredits,
|
||
generationSnapshot: expect.objectContaining({
|
||
attempt: 0,
|
||
model: 'Seedance 2.0',
|
||
durationSeconds: 5,
|
||
resolution: '480p',
|
||
reservedCredits,
|
||
settledCredits: 0,
|
||
refundedCredits: reservedCredits,
|
||
taskStatus: 'failed',
|
||
creditStatus: 'refunded',
|
||
}),
|
||
}),
|
||
]));
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', task.status, task.credit_status, task.settled_credits::text,
|
||
task.refunded_credits::text, (task.asset_id IS NULL)::text, run.status)
|
||
FROM generation_tasks task
|
||
JOIN generation_provider_runs run ON run.id = task.active_provider_run_id
|
||
WHERE task.id = '${task.id}'::uuid;
|
||
`)).toBe(`failed|refunded|0|${reservedCredits}|true|provider-failed`);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', count(*)::text, min(status), min(provider))
|
||
FROM provider_usage_records
|
||
WHERE task_id = '${task.id}'::uuid;
|
||
`)).toBe('1|failed|fake-provider');
|
||
|
||
// 测试夹具只移除 Fake Provider 的“失败”触发词;管理员接口仍在同一 task id 上推进 attempt。
|
||
postgres(`
|
||
UPDATE generation_tasks
|
||
SET prompt = '验证管理员原地重试后正常完成', updated_at = now()
|
||
WHERE id = '${task.id}'::uuid;
|
||
`);
|
||
const retried = await api(session.page, `/api/admin/jobs/${task.id}/retry`, {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-generation-retry-${randomUUID()}` },
|
||
});
|
||
expect(retried.status).toBe(200);
|
||
expect(retried.body.task).toMatchObject({
|
||
id: task.id,
|
||
attempt: 1,
|
||
status: 'queued',
|
||
creditStatus: 'held',
|
||
reservedCredits,
|
||
});
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, task.id)).status,
|
||
{ timeout: 30_000, intervals: [500, 1_000, 2_000] },
|
||
).toBe('completed');
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - reservedCredits);
|
||
|
||
const afterRetryLedger = await api(session.page, '/api/account/credit-ledger?limit=50');
|
||
expect(afterRetryLedger.status).toBe(200);
|
||
const afterRetryEntries = afterRetryLedger.body.items.filter(
|
||
(item: { relatedJobId: string | null }) => item.relatedJobId === task.id,
|
||
);
|
||
expect(afterRetryEntries).toHaveLength(3);
|
||
const oldHold = afterRetryEntries.find((item: { type: string; generationSnapshot?: { attempt?: number } | null }) => (
|
||
item.type === 'hold' && item.generationSnapshot?.attempt === 0
|
||
));
|
||
const oldRefund = afterRetryEntries.find((item: { type: string; generationSnapshot?: { attempt?: number } | null }) => (
|
||
item.type === 'refund' && item.generationSnapshot?.attempt === 0
|
||
));
|
||
const newSpend = afterRetryEntries.find((item: { type: string; generationSnapshot?: { attempt?: number } | null }) => (
|
||
item.type === 'spend' && item.generationSnapshot?.attempt === 1
|
||
));
|
||
expect(oldHold).toMatchObject({
|
||
settlementStatus: 'refunded',
|
||
amount: -reservedCredits,
|
||
generationSnapshot: {
|
||
attempt: 0,
|
||
model: 'Seedance 2.0',
|
||
durationSeconds: 5,
|
||
resolution: '480p',
|
||
hasReferenceVideo: false,
|
||
reservedCredits,
|
||
settledCredits: 0,
|
||
refundedCredits: 0,
|
||
taskStatus: 'failed',
|
||
creditStatus: 'refunded',
|
||
},
|
||
});
|
||
expect(oldRefund).toMatchObject({
|
||
settlementStatus: 'refunded',
|
||
amount: reservedCredits,
|
||
generationSnapshot: {
|
||
attempt: 0,
|
||
model: 'Seedance 2.0',
|
||
durationSeconds: 5,
|
||
resolution: '480p',
|
||
hasReferenceVideo: false,
|
||
reservedCredits,
|
||
settledCredits: 0,
|
||
refundedCredits: reservedCredits,
|
||
taskStatus: 'failed',
|
||
creditStatus: 'refunded',
|
||
},
|
||
});
|
||
expect(newSpend).toMatchObject({
|
||
settlementStatus: 'settled',
|
||
amount: -reservedCredits,
|
||
generationSnapshot: {
|
||
attempt: 1,
|
||
model: 'Seedance 2.0',
|
||
durationSeconds: 5,
|
||
resolution: '480p',
|
||
hasReferenceVideo: false,
|
||
reservedCredits,
|
||
settledCredits: reservedCredits,
|
||
refundedCredits: 0,
|
||
taskStatus: 'completed',
|
||
creditStatus: 'settled',
|
||
},
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', count(*)::text, count(*) FILTER (WHERE status = 'failed')::text,
|
||
count(*) FILTER (WHERE status = 'success')::text)
|
||
FROM provider_usage_records
|
||
WHERE task_id = '${task.id}'::uuid;
|
||
`)).toBe('2|1|1');
|
||
} finally {
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('修改密码后当前会话续签,其他旧会话立即失效', async ({ browser }) => {
|
||
const current = await createAuthenticatedPage(browser, primaryAccount);
|
||
const stale = await createAuthenticatedPage(browser, primaryAccount);
|
||
const temporaryPassword = 'IntegrationTemporary2026!';
|
||
let passwordChanged = false;
|
||
|
||
try {
|
||
const changed = await api(current.page, '/api/account/password', {
|
||
method: 'PATCH',
|
||
body: {
|
||
currentPassword: primaryAccount.password,
|
||
newPassword: temporaryPassword,
|
||
},
|
||
});
|
||
expect(changed.status).toBe(200);
|
||
passwordChanged = true;
|
||
|
||
expect((await api(current.page, '/api/auth/me')).status).toBe(200);
|
||
expect((await api(stale.page, '/api/auth/me')).status).toBe(401);
|
||
} finally {
|
||
if (passwordChanged) {
|
||
const restored = await api(current.page, '/api/account/password', {
|
||
method: 'PATCH',
|
||
body: {
|
||
currentPassword: temporaryPassword,
|
||
newPassword: primaryAccount.password,
|
||
},
|
||
});
|
||
expect(restored.status).toBe(200);
|
||
expect((await api(current.page, '/api/auth/me')).status).toBe(200);
|
||
}
|
||
await Promise.all([current.context.close(), stale.context.close()]);
|
||
}
|
||
});
|
||
|
||
test('Worker 异常退出后租约任务会恢复且不会重复扣费', async ({ browser }) => {
|
||
test.setTimeout(100_000);
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
const beforeCredits = await readCredits(session.page);
|
||
const created = await api(session.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: 'integration-worker-crash-0001',
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '验证 Worker 崩溃后恢复的延时视频',
|
||
config: { aspectRatio: '16:9', durationSeconds: 5, resolution: '480p' },
|
||
}],
|
||
},
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const task = created.body.tasks[0];
|
||
const creditsAfterHold = await readCredits(session.page);
|
||
expect(creditsAfterHold).toBe(beforeCredits - Number(task.credits));
|
||
|
||
// 管理后台任务列表是轻量卡片 DTO,不暴露 providerTaskId。直接观察持久化关口,
|
||
// 确保 worker 在供应商已提交、结果尚未返回且尚未结算时被强制终止。
|
||
await expect.poll(
|
||
() => readGenerationSubmissionCheckpoint(task.id),
|
||
{ timeout: 15_000, intervals: [100, 200, 300, 500] },
|
||
).toEqual({
|
||
status: 'processing',
|
||
processingStage: 'submitted',
|
||
providerTaskId: `fake-${task.id}`,
|
||
providerRunStatus: 'submitted',
|
||
providerRunTaskId: `fake-${task.id}`,
|
||
hasProviderResult: false,
|
||
hasAsset: false,
|
||
});
|
||
|
||
dockerCompose('kill', '-s', 'KILL', 'worker');
|
||
dockerCompose('up', '--detach', 'worker');
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, task.id)).status,
|
||
{ timeout: 75_000, intervals: [1_000, 2_000, 3_000, 5_000] },
|
||
).toBe('completed');
|
||
|
||
const completedTask = await readTask(session.page, task.id);
|
||
// attempt 只表示用户/管理员发起的业务重试;worker 崩溃接管只推进 claimEpoch。
|
||
expect(Number(completedTask.attempt)).toBe(Number(task.attempt));
|
||
expect(readGenerationTaskClaimEpoch(task.id)).toBeGreaterThanOrEqual(2);
|
||
expect(await readCredits(session.page)).toBe(creditsAfterHold);
|
||
|
||
const ledger = await api(session.page, '/api/account/credit-ledger?limit=50');
|
||
expect(ledger.status).toBe(200);
|
||
const taskEntries = ledger.body.items.filter(
|
||
(item: { relatedJobId: string | null }) => item.relatedJobId === task.id,
|
||
);
|
||
expect(taskEntries).toHaveLength(1);
|
||
expect(taskEntries[0]).toMatchObject({
|
||
type: 'spend',
|
||
settlementStatus: 'settled',
|
||
amount: -Number(task.credits),
|
||
});
|
||
} finally {
|
||
// 即使断言失败,也保证后续清理前 Worker 已恢复运行。
|
||
dockerCompose('up', '--detach', 'worker');
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('Provider 成功但本地结算失败后会自动恢复且不会二次扣费', async ({ browser }) => {
|
||
test.setTimeout(90_000);
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
let failureTriggerInstalled = false;
|
||
try {
|
||
postgres(`
|
||
CREATE OR REPLACE FUNCTION fullstack_reject_generated_asset()
|
||
RETURNS trigger
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
BEGIN
|
||
IF NEW.object_key LIKE 'generated/video/%' THEN
|
||
RAISE EXCEPTION 'FULLSTACK_SETTLEMENT_FAILURE';
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
DROP TRIGGER IF EXISTS fullstack_reject_generated_asset ON assets;
|
||
CREATE TRIGGER fullstack_reject_generated_asset
|
||
BEFORE INSERT ON assets
|
||
FOR EACH ROW EXECUTE FUNCTION fullstack_reject_generated_asset();
|
||
`);
|
||
failureTriggerInstalled = true;
|
||
|
||
const beforeCredits = await readCredits(session.page);
|
||
const created = await api(session.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: 'integration-settlement-recovery-0001',
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '验证 Provider 成功后的本地结算恢复',
|
||
config: { aspectRatio: '9:16', durationSeconds: 5, resolution: '480p' },
|
||
}],
|
||
},
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const task = created.body.tasks[0];
|
||
const creditsAfterHold = await readCredits(session.page);
|
||
expect(creditsAfterHold).toBe(beforeCredits - Number(task.credits));
|
||
|
||
// 任务列表是轻量卡片 DTO,不携带结算内部字段。直接观察数据库中的持久化
|
||
// 结算关口,确认供应商结果已安全落库后才移除故障触发器。
|
||
await expect.poll(
|
||
() => readGenerationSettlementCheckpoint(task.id),
|
||
{ timeout: 20_000, intervals: [100, 200, 300, 500] },
|
||
).toEqual({
|
||
status: 'processing',
|
||
processingStage: 'settlement-pending',
|
||
providerRunStatus: 'settlement-pending',
|
||
hasTaskProviderResult: true,
|
||
hasProviderRunResult: true,
|
||
hasSettlementError: true,
|
||
hasAsset: false,
|
||
});
|
||
|
||
removeSettlementFailureTrigger();
|
||
failureTriggerInstalled = false;
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, task.id)).status,
|
||
{ timeout: 45_000, intervals: [1_000, 2_000, 3_000, 5_000] },
|
||
).toBe('completed');
|
||
|
||
const completedTask = await readTask(session.page, task.id);
|
||
expect(Number(completedTask.attempt)).toBe(Number(task.attempt));
|
||
expect(readGenerationTaskClaimEpoch(task.id)).toBeGreaterThanOrEqual(2);
|
||
// 用户任务 DTO 不暴露结算内部字段;恢复结果通过后台详情 DTO 核验。
|
||
expect(completedTask).not.toHaveProperty('settlementError');
|
||
const completedJob = await api(session.page, `/api/admin/jobs/${task.id}`);
|
||
expect(completedJob.status).toBe(200);
|
||
expect(completedJob.body.task).toMatchObject({
|
||
status: 'completed',
|
||
processingStage: 'completed',
|
||
settlementError: null,
|
||
assetId: expect.any(String),
|
||
});
|
||
expect(await readCredits(session.page)).toBe(creditsAfterHold);
|
||
|
||
const ledger = await api(session.page, '/api/account/credit-ledger?limit=50');
|
||
const taskEntries = ledger.body.items.filter(
|
||
(item: { relatedJobId: string | null }) => item.relatedJobId === task.id,
|
||
);
|
||
expect(taskEntries).toHaveLength(1);
|
||
expect(taskEntries[0]).toMatchObject({
|
||
type: 'spend',
|
||
settlementStatus: 'settled',
|
||
amount: -Number(task.credits),
|
||
});
|
||
} finally {
|
||
if (failureTriggerInstalled) removeSettlementFailureTrigger();
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('人工发票申请只写一条管理员通知 Outbox 且不会产生用户邮件事件', async ({ browser }) => {
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const orderId = randomUUID();
|
||
const providerTradeId = `fullstack-invoice-${randomUUID()}`;
|
||
const receiveEmail = `invoice-${randomUUID()}@example.com`;
|
||
|
||
postgres(`
|
||
INSERT INTO orders (
|
||
id, user_id, plan_id, kind, plan_name, amount, billing, status,
|
||
invoice_status, credit_status, credit_amount, credit_bucket,
|
||
payment_method, payment_provider, provider_trade_id, paid_at
|
||
)
|
||
SELECT
|
||
'${orderId}'::uuid, u.id, NULL, 'main', '全链路人工发票测试订单', 2900,
|
||
'monthly', 'paid', 'unissued', 'settled', 0, 'system',
|
||
'全链路测试支付', 'manual', '${providerTradeId}', now()
|
||
FROM users u
|
||
WHERE u.email = '${primaryAccount.email}';
|
||
`);
|
||
|
||
try {
|
||
const payload = {
|
||
orderId,
|
||
invoiceTitle: '全链路人工开票测试公司',
|
||
taxId: '91310000FULLSTACK',
|
||
receiveEmail,
|
||
note: '管理员在外部开票系统制作并人工发送。',
|
||
};
|
||
const created = await api(session.page, '/api/invoices', {
|
||
method: 'POST',
|
||
body: payload,
|
||
});
|
||
|
||
expect(created.status).toBe(200);
|
||
expect(created.body.invoice).toMatchObject({
|
||
orderId,
|
||
invoiceTitle: payload.invoiceTitle,
|
||
taxId: payload.taxId,
|
||
receiveEmail,
|
||
status: 'unissued',
|
||
});
|
||
const invoiceId = String(created.body.invoice.id);
|
||
expect(invoiceId).toMatch(/^[0-9a-f-]{36}$/i);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM invoice_notification_outbox
|
||
WHERE invoice_id = '${invoiceId}'::uuid;
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT event_key
|
||
FROM invoice_notification_outbox
|
||
WHERE invoice_id = '${invoiceId}'::uuid;
|
||
`)).toBe(`invoice-requested-admin:${invoiceId}`);
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM notification_events
|
||
WHERE related_invoice_id = '${invoiceId}'::uuid
|
||
AND (
|
||
event_type IN ('invoice-requested', 'invoice-status-changed')
|
||
OR recipient IN ('${primaryAccount.email}', '${receiveEmail}')
|
||
);
|
||
`)).toBe('0');
|
||
|
||
const duplicate = await api(session.page, '/api/invoices', {
|
||
method: 'POST',
|
||
body: payload,
|
||
});
|
||
expect(duplicate.status).toBe(409);
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM invoice_requests
|
||
WHERE order_id = '${orderId}'::uuid;
|
||
`)).toBe('1');
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM invoice_notification_outbox outbox
|
||
JOIN invoice_requests invoice ON invoice.id = outbox.invoice_id
|
||
WHERE invoice.order_id = '${orderId}'::uuid;
|
||
`)).toBe('1');
|
||
} finally {
|
||
postgres(`
|
||
DELETE FROM notification_events
|
||
WHERE related_order_id = '${orderId}'::uuid
|
||
OR related_invoice_id IN (
|
||
SELECT id FROM invoice_requests WHERE order_id = '${orderId}'::uuid
|
||
);
|
||
DELETE FROM invoice_notification_outbox
|
||
WHERE invoice_id IN (
|
||
SELECT id FROM invoice_requests WHERE order_id = '${orderId}'::uuid
|
||
);
|
||
DELETE FROM invoice_requests WHERE order_id = '${orderId}'::uuid;
|
||
DELETE FROM orders WHERE id = '${orderId}'::uuid;
|
||
`);
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('Seedream 图片批次完成文生图、多图融合、跨图框选、资产沿用与独立成本归档', async ({ browser }) => {
|
||
test.setTimeout(120_000);
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
postgres(`
|
||
UPDATE consumption_settings
|
||
SET credits = CASE resolution WHEN '1K' THEN 14 WHEN '2K' THEN 28 END,
|
||
available = true,
|
||
updated_at = now()
|
||
WHERE kind = 'image'
|
||
AND model = 'Seedream 5.0 Pro'
|
||
AND resolution IN ('1K', '2K');
|
||
`);
|
||
|
||
const [firstReference, secondReference] = await Promise.all([
|
||
uploadFullstackImage(session.page, `seedream-first-${randomUUID()}`),
|
||
uploadFullstackImage(session.page, `seedream-second-${randomUUID()}`),
|
||
]);
|
||
expect(firstReference).toMatchObject({
|
||
width: expect.any(Number),
|
||
height: expect.any(Number),
|
||
sizeBytes: expect.any(Number),
|
||
});
|
||
const originalDimensions = `${firstReference.width}x${firstReference.height}`;
|
||
postgres(`
|
||
UPDATE assets
|
||
SET width = NULL, height = NULL
|
||
WHERE id = '${firstReference.id}'::uuid;
|
||
`);
|
||
|
||
const financialSnapshotBeforeRejectedLegacyReference = postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM generation_batches
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM generation_tasks
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM credit_ledger
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM generation_provider_runs run
|
||
JOIN generation_tasks task ON task.id = run.task_id
|
||
WHERE task.user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}'))
|
||
);
|
||
`);
|
||
const creditsBeforeRejectedLegacyReference = await readCredits(session.page);
|
||
const rejectedLegacyReference = await api(session.page, '/api/ai/image-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-seedream-legacy-rejected-${randomUUID()}`,
|
||
requests: [{
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '使用历史参考图生成产品海报',
|
||
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
|
||
references: [{ assetId: firstReference.id, role: 'reference_image' }],
|
||
annotations: [],
|
||
}],
|
||
},
|
||
});
|
||
expect(rejectedLegacyReference.status).toBe(400);
|
||
expect(rejectedLegacyReference.body).toMatchObject({
|
||
code: 'REFERENCE_IMAGE_METADATA_INCOMPLETE',
|
||
});
|
||
expect(await readCredits(session.page)).toBe(creditsBeforeRejectedLegacyReference);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
(SELECT count(*) FROM generation_batches
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM generation_tasks
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM credit_ledger
|
||
WHERE user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}')),
|
||
(SELECT count(*) FROM generation_provider_runs run
|
||
JOIN generation_tasks task ON task.id = run.task_id
|
||
WHERE task.user_id = (SELECT id FROM users WHERE email = '${primaryAccount.email}'))
|
||
);
|
||
`)).toBe(financialSnapshotBeforeRejectedLegacyReference);
|
||
|
||
const metadataDryRun = dockerComposeOutput(
|
||
'exec', '--no-TTY', 'api',
|
||
'npm', '--workspace', 'apps/api', 'run', 'backfill:image-metadata:prod',
|
||
'--', '--dry-run', '--asset-id', firstReference.id,
|
||
);
|
||
expect(metadataDryRun).toContain('"wouldUpdate":1');
|
||
expect(metadataDryRun).toContain('"remainingCount":1');
|
||
|
||
const metadataBackfill = dockerComposeOutput(
|
||
'exec', '--no-TTY', 'api',
|
||
'npm', '--workspace', 'apps/api', 'run', 'backfill:image-metadata:prod',
|
||
'--', '--asset-id', firstReference.id,
|
||
);
|
||
expect(metadataBackfill).toContain('"updated":1');
|
||
expect(metadataBackfill).toContain('"remainingCount":0');
|
||
expect(postgresScalar(`
|
||
SELECT concat(width, 'x', height)
|
||
FROM assets WHERE id = '${firstReference.id}'::uuid;
|
||
`)).toBe(originalDimensions);
|
||
|
||
const metadataReplay = dockerComposeOutput(
|
||
'exec', '--no-TTY', 'api',
|
||
'npm', '--workspace', 'apps/api', 'run', 'backfill:image-metadata:prod',
|
||
'--', '--asset-id', firstReference.id,
|
||
);
|
||
expect(metadataReplay).toContain('"scanned":0');
|
||
expect(metadataReplay).toContain('"remainingCount":0');
|
||
|
||
const beforeCredits = await readCredits(session.page);
|
||
const idempotencyKey = `fullstack-seedream-batch-${randomUUID()}`;
|
||
const batchBody = {
|
||
idempotencyKey,
|
||
requests: [
|
||
{
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '生成一张极简摄影棚中的白色陶瓷杯产品图',
|
||
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
|
||
references: [],
|
||
annotations: [],
|
||
},
|
||
{
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '融合两张参考图的主体与配色,生成现代产品海报',
|
||
config: { resolution: '1K', aspectRatio: '4:3', optimizePromptMode: 'fast' },
|
||
references: [
|
||
{ assetId: firstReference.id, role: 'reference_image' },
|
||
{ assetId: secondReference.id, role: 'reference_image' },
|
||
],
|
||
annotations: [],
|
||
},
|
||
{
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '将 @标记1 的主体放到 @标记2 区域,保持其他内容不变',
|
||
config: { resolution: '2K', aspectRatio: '3:4', optimizePromptMode: 'standard' },
|
||
references: [
|
||
{ assetId: firstReference.id, role: 'reference_image' },
|
||
{ assetId: secondReference.id, role: 'reference_image' },
|
||
],
|
||
annotations: [
|
||
{ token: '@标记1', assetId: firstReference.id, type: 'bbox', x1: 120, y1: 180, x2: 640, y2: 760 },
|
||
{ token: '@标记2', assetId: secondReference.id, type: 'bbox', x1: 118, y1: 331, x2: 933, y2: 871 },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
const created = await api(session.page, '/api/ai/image-tasks/batch', {
|
||
method: 'POST',
|
||
body: batchBody,
|
||
});
|
||
expect(created.status).toBe(200);
|
||
expect(created.body.tasks).toHaveLength(3);
|
||
expect(created.body.tasks.map((task: { kind: string }) => task.kind)).toEqual(['image', 'image', 'image']);
|
||
expect(created.body.tasks.map((task: { credits: number }) => Number(task.credits))).toEqual([14, 14, 28]);
|
||
|
||
const replayed = await api(session.page, '/api/ai/image-tasks/batch', {
|
||
method: 'POST',
|
||
body: batchBody,
|
||
});
|
||
expect(replayed.status).toBe(200);
|
||
expect(replayed.body.tasks.map((task: { id: string }) => task.id)).toEqual(
|
||
created.body.tasks.map((task: { id: string }) => task.id),
|
||
);
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - 56);
|
||
|
||
const taskIds = created.body.tasks.map((task: { id: string }) => task.id);
|
||
await expect.poll(async () => Promise.all(taskIds.map(async (taskId: string) => (
|
||
(await readTask(session.page, taskId)).status
|
||
))), {
|
||
timeout: 60_000,
|
||
intervals: [500, 1_000, 2_000, 3_000],
|
||
}).toEqual(['completed', 'completed', 'completed']);
|
||
|
||
const completedTasks = await Promise.all(taskIds.map((taskId: string) => readTask(session.page, taskId)));
|
||
expect(completedTasks.map(task => task.creditStatus)).toEqual(['settled', 'settled', 'settled']);
|
||
expect(completedTasks.every(task => typeof task.assetId === 'string')).toBe(true);
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - 56);
|
||
|
||
const imageQueue = await api(session.page, '/api/ai/tasks?kind=image&limit=50');
|
||
expect(imageQueue.status).toBe(200);
|
||
expect(taskIds.every((taskId: string) => imageQueue.body.items.some((task: { id: string }) => task.id === taskId))).toBe(true);
|
||
const videoQueue = await api(session.page, '/api/ai/tasks?kind=video&limit=50');
|
||
expect(videoQueue.status).toBe(200);
|
||
expect(taskIds.some((taskId: string) => videoQueue.body.items.some((task: { id: string }) => task.id === taskId))).toBe(false);
|
||
|
||
const generatedAssets = await Promise.all(completedTasks.map(async task => {
|
||
const result = await api(session.page, `/api/assets/${task.assetId}`);
|
||
expect(result.status).toBe(200);
|
||
expect(result.body.asset).toMatchObject({ type: 'image', source: 'ai-image' });
|
||
expect(result.body.asset.thumbnailUrl).toEqual(expect.any(String));
|
||
expect(await fetchStatus(session.page, `/api/assets/${task.assetId}/download`)).toBe(200);
|
||
return result.body.asset;
|
||
}));
|
||
const editedAsset = generatedAssets[2];
|
||
expect(editedAsset.generation).toMatchObject({
|
||
kind: 'image',
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '将 @标记1 的主体放到 @标记2 区域,保持其他内容不变',
|
||
config: {
|
||
resolution: '2K',
|
||
aspectRatio: '3:4',
|
||
optimizePromptMode: 'standard',
|
||
annotations: batchBody.requests[2].annotations,
|
||
},
|
||
});
|
||
expect(editedAsset.generation.config.referenceAssets.map((reference: { id: string }) => reference.id)).toEqual([
|
||
firstReference.id,
|
||
secondReference.id,
|
||
]);
|
||
expect(JSON.stringify(editedAsset)).not.toMatch(/objectKey|thumbnailKey|data:image\//i);
|
||
|
||
const month = postgresScalar(`
|
||
SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM');
|
||
`);
|
||
const imageCostOverview = await api(session.page, `/api/admin/api-usage/image/overview?range=month&month=${month}`);
|
||
expect(imageCostOverview.status).toBe(200);
|
||
expect(imageCostOverview.body.pricing).toMatchObject({
|
||
model: 'Seedream 5.0 Pro',
|
||
firstReferenceImageIncluded: 1,
|
||
additionalReferenceUnitPriceCny: '0.020000',
|
||
outputPixelThreshold: 2_360_000,
|
||
smallOutputPriceCny: '0.300000',
|
||
largeOutputPriceCny: '0.600000',
|
||
});
|
||
expect(imageCostOverview.body.pricing.disclaimer).toContain('官网公开列表价估算');
|
||
expect(imageCostOverview.body.summary).toMatchObject({
|
||
taskCount: 3,
|
||
settledCredits: 56,
|
||
referenceImageCount: 4,
|
||
knownCostTaskCount: 3,
|
||
missingEvidenceTaskCount: 0,
|
||
knownCostCny: '0.940000',
|
||
operationalCallCount: 0,
|
||
operationalUnpricedCallCount: 0,
|
||
businessEstimatedCostCny: '0.940000',
|
||
operationalEstimatedCostCny: '0.000000',
|
||
knownAccountCostCny: '0.940000',
|
||
});
|
||
const imageCostTasks = await api(session.page, `/api/admin/api-usage/image/tasks?range=month&month=${month}&limit=50`);
|
||
expect(imageCostTasks.status).toBe(200);
|
||
const ourCostRows = imageCostTasks.body.items.filter((row: { taskId: string }) => taskIds.includes(row.taskId));
|
||
expect(ourCostRows).toHaveLength(3);
|
||
expect(ourCostRows.map((row: { estimatedCostCny: string }) => row.estimatedCostCny).sort()).toEqual([
|
||
'0.300000',
|
||
'0.320000',
|
||
'0.320000',
|
||
]);
|
||
expect(ourCostRows.every((row: { estimatedCostCny: unknown }) => (
|
||
typeof row.estimatedCostCny === 'string' && /^\d+\.\d{6}$/.test(row.estimatedCostCny)
|
||
))).toBe(true);
|
||
expect(ourCostRows.every((row: { requestEvidenceStatus: string }) => row.requestEvidenceStatus === 'local-only')).toBe(true);
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM provider_usage_records
|
||
WHERE task_id = ANY(ARRAY[${taskIds.map((taskId: string) => `'${taskId}'::uuid`).join(',')}]);
|
||
`)).toBe('0');
|
||
expect(postgresScalar(`
|
||
SELECT count(*) FROM image_provider_usage_records
|
||
WHERE task_id = ANY(ARRAY[${taskIds.map((taskId: string) => `'${taskId}'::uuid`).join(',')}]);
|
||
`)).toBe('3');
|
||
|
||
await session.page.goto('/app/ai-studio');
|
||
await session.page.getByRole('tab', { name: 'AI 图片' }).click();
|
||
const imageQueuePanel = session.page.getByRole('region', { name: '生成记录' });
|
||
await expect(imageQueuePanel).toBeVisible();
|
||
await expect(imageQueuePanel.getByText('已完成')).toHaveCount(3);
|
||
await imageQueuePanel.getByRole('button', { name: '预览图片', exact: true }).first().click();
|
||
await expect(session.page.getByRole('heading', { name: '素材预览' })).toBeVisible();
|
||
await session.page.getByRole('button', { name: '关闭素材预览' }).click();
|
||
|
||
await session.page.getByRole('button', { name: '数字资产', exact: true }).click();
|
||
await expect(session.page.getByRole('heading', { name: '数字资产' })).toBeVisible();
|
||
// 数字资产分类统一使用共享 Studio 标签,并通过明确的 aria-label 播报数量。
|
||
await session.page.getByRole('tab', { name: /^AI图片,(?:\d+个素材|数量暂不可用)$/ }).click();
|
||
await expect(session.page.getByText(/Seedream 5\.0 Pro 图片/).first()).toBeVisible();
|
||
// 生成记录使用只读预览;沿用操作从数字资产的可编辑详情进入。
|
||
await session.page.getByRole('button', { name: /^打开素材 Seedream 5\.0 Pro 图片/ }).first().click();
|
||
await session.page.getByRole('button', { name: '沿用生成', exact: true }).click();
|
||
await expect(session.page.locator('#image-prompt')).not.toHaveValue('');
|
||
} finally {
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('Seedream 模拟 HTTP 200 经过真实 checkpoint、PNG 归档和积分结算链路', async ({ browser }) => {
|
||
test.setTimeout(120_000);
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const isolatedWorkerName = `${composeProject}-seedream-checkpoint-${randomUUID().slice(0, 8)}`;
|
||
let isolatedWorkerId = '';
|
||
let outputObjectKey = '';
|
||
|
||
dockerCompose('stop', 'worker');
|
||
try {
|
||
postgres(`
|
||
UPDATE consumption_settings
|
||
SET credits = 14, available = true, updated_at = now()
|
||
WHERE kind = 'image'
|
||
AND model = 'Seedream 5.0 Pro'
|
||
AND resolution = '1K';
|
||
`);
|
||
expect(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM generation_tasks
|
||
WHERE status IN ('queued', 'processing');
|
||
`)).toBe('0');
|
||
|
||
const beforeCredits = await readCredits(session.page);
|
||
const created = await api(session.page, '/api/ai/image-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-seedream-checkpoint-${randomUUID()}`,
|
||
requests: [{
|
||
model: 'Seedream 5.0 Pro',
|
||
prompt: '验证全栈 checkpoint 落库、归档与结算',
|
||
config: { resolution: '1K', aspectRatio: '1:1', optimizePromptMode: 'standard' },
|
||
references: [],
|
||
annotations: [],
|
||
}],
|
||
},
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const task = created.body.tasks[0];
|
||
expect(task).toMatchObject({ status: 'queued', creditStatus: 'held', credits: 14 });
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - 14);
|
||
|
||
const userId = postgresScalar(`
|
||
SELECT id FROM users WHERE email = '${primaryAccount.email}';
|
||
`);
|
||
outputObjectKey = `generated/image/${userId}/${task.id}/attempt-0/output.png`;
|
||
const png = await sharp({
|
||
create: {
|
||
width: 1024,
|
||
height: 1024,
|
||
channels: 4,
|
||
background: { r: 24, g: 20, b: 36, alpha: 1 },
|
||
},
|
||
}).png().toBuffer();
|
||
writeApiStorageObject(outputObjectKey, png.toString('base64'));
|
||
|
||
isolatedWorkerId = dockerComposeOutput(
|
||
'run', '--detach', '--no-deps', '--name', isolatedWorkerName,
|
||
'-e', 'ARK_API_KEY=fullstack-seedream-key',
|
||
'-e', 'ARK_BASE_URL=http://seedream-mock:4400/api/v3',
|
||
'-e', 'ARK_SEEDREAM_TIMEOUT_MS=10000',
|
||
'-e', 'ARK_DOWNLOAD_TIMEOUT_MS=10000',
|
||
'worker',
|
||
).trim();
|
||
expect(isolatedWorkerId).not.toBe('');
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(session.page, task.id)).status,
|
||
{ timeout: 60_000, intervals: [250, 500, 1_000, 2_000] },
|
||
).toBe('completed');
|
||
|
||
const completed = await readTask(session.page, task.id);
|
||
expect(completed).toMatchObject({
|
||
processingStage: 'completed',
|
||
creditStatus: 'settled',
|
||
settledCredits: 14,
|
||
refundedCredits: 0,
|
||
assetId: expect.any(String),
|
||
});
|
||
expect(await readCredits(session.page)).toBe(beforeCredits - 14);
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
run.provider,
|
||
run.provider_task_id,
|
||
run.provider_request_id,
|
||
run.provider_response_checkpoint->>'version',
|
||
(run.provider_response_url IS NULL)::text,
|
||
(run.provider_response_observed_at IS NOT NULL)::text,
|
||
(run.provider_response_url_expires_at IS NOT NULL)::text
|
||
)
|
||
FROM generation_provider_runs run
|
||
WHERE run.task_id = '${task.id}'::uuid;
|
||
`)).toBe([
|
||
'volcengine-ark',
|
||
`seedream-sync:${task.id}:0`,
|
||
'fullstack-seedream-checkpoint-request',
|
||
'1',
|
||
'true',
|
||
'true',
|
||
'true',
|
||
].join('|'));
|
||
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
provider_request_id, request_evidence_status, evidence_status,
|
||
reference_image_count, additional_reference_image_count,
|
||
output_width, output_height, output_pixels, output_tier,
|
||
estimated_cost_cny, generated_images, output_tokens, total_tokens
|
||
)
|
||
FROM image_provider_usage_records
|
||
WHERE task_id = '${task.id}'::uuid;
|
||
`)).toBe([
|
||
'fullstack-seedream-checkpoint-request', 'provider-id', 'known',
|
||
'0', '0', '1024', '1024', '1048576', 'small',
|
||
'0.300000', '1', '12', '12',
|
||
].join('|'));
|
||
|
||
const asset = await api(session.page, `/api/assets/${completed.assetId}`);
|
||
expect(asset.status).toBe(200);
|
||
expect(asset.body.asset).toMatchObject({
|
||
type: 'image',
|
||
source: 'ai-image',
|
||
width: 1024,
|
||
height: 1024,
|
||
thumbnailUrl: expect.any(String),
|
||
});
|
||
expect(await fetchStatus(session.page, `/api/assets/${completed.assetId}/download`)).toBe(200);
|
||
} finally {
|
||
if (isolatedWorkerId) {
|
||
execFileSync('docker', ['rm', '--force', isolatedWorkerId], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: 'inherit',
|
||
});
|
||
}
|
||
dockerCompose('start', 'worker');
|
||
await session.context.close();
|
||
}
|
||
});
|
||
|
||
test('十万条历史资产使用四向键集分页、直达尾页并满足卡片性能门槛', async ({ browser }) => {
|
||
const folderId = randomUUID();
|
||
const marker = `fullstack-p1-assets-${folderId}`;
|
||
const ownerId = postgresScalar(`
|
||
SELECT id FROM users WHERE email = '${primaryAccount.email}';
|
||
`);
|
||
postgres(`
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system, created_at)
|
||
VALUES (
|
||
'${folderId}'::uuid, '${ownerId}'::uuid,
|
||
'P1 十万资产分页验收', 'uploaded-audio', false, now()
|
||
);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, thumbnail_key,
|
||
size_bytes, duration_seconds, deletion_status, thumbnail_status,
|
||
created_at, updated_at
|
||
)
|
||
SELECT
|
||
CASE
|
||
WHEN asset_number = 24 THEN (
|
||
'ffffffff-ffff-4fff-bfff-' || substr(md5('${marker}-tie-24'), 1, 12)
|
||
)::uuid
|
||
WHEN asset_number = 25 THEN (
|
||
'00000000-0000-4000-8000-' || substr(md5('${marker}-tie-25'), 1, 12)
|
||
)::uuid
|
||
ELSE md5('${marker}-id-' || asset_number)::uuid
|
||
END,
|
||
'${ownerId}'::uuid,
|
||
'${folderId}'::uuid,
|
||
'P1四向分页-' || lpad(asset_number::text, 6, '0'),
|
||
'audio', 'uploaded-audio',
|
||
'${marker}/' || lpad(asset_number::text, 6, '0') || '.mp3',
|
||
NULL, 128, 1, 'active', 'not-required',
|
||
timestamptz '2026-07-17 00:00:00+00'
|
||
- CASE WHEN asset_number = 25 THEN 24 ELSE asset_number END * interval '1 microsecond',
|
||
timestamptz '2026-07-17 00:00:00+00'
|
||
- CASE WHEN asset_number = 25 THEN 24 ELSE asset_number END * interval '1 microsecond'
|
||
FROM generate_series(1, 100000) AS assets_to_create(asset_number);
|
||
|
||
ANALYZE assets;
|
||
`);
|
||
|
||
const session = await createAuthenticatedPage(browser, primaryAccount);
|
||
const pageSize = 24;
|
||
const basePath = `/api/assets/cards?folderId=${folderId}&limit=${pageSize}`;
|
||
try {
|
||
const first = await api(session.page, `${basePath}&position=first`);
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.items).toHaveLength(pageSize);
|
||
expect(first.body.items[0].name).toBe('P1四向分页-000001');
|
||
expect(first.body.items.at(-1).name).toBe('P1四向分页-000024');
|
||
expect(first.body.pagination).toMatchObject({
|
||
position: 'first',
|
||
limit: pageSize,
|
||
hasPrevious: false,
|
||
hasNext: true,
|
||
previousCursor: null,
|
||
currentPage: 1,
|
||
totalPages: 4167,
|
||
totalItems: 100000,
|
||
snapshotCursor: expect.any(String),
|
||
nextCursor: expect.any(String),
|
||
});
|
||
|
||
const next = await api(
|
||
session.page,
|
||
`${basePath}&position=next&cursor=${encodeURIComponent(first.body.pagination.nextCursor)}`,
|
||
);
|
||
expect(next.status).toBe(200);
|
||
expect(next.body.items).toHaveLength(pageSize);
|
||
expect(next.body.items[0].name).toBe('P1四向分页-000025');
|
||
expect(next.body.items.at(-1).name).toBe('P1四向分页-000048');
|
||
expect(next.body.pagination).toMatchObject({
|
||
position: 'next',
|
||
hasPrevious: true,
|
||
hasNext: true,
|
||
currentPage: 2,
|
||
totalPages: 4167,
|
||
totalItems: 100000,
|
||
snapshotCursor: first.body.pagination.snapshotCursor,
|
||
});
|
||
expect(intersection(assetIds(first.body.items), assetIds(next.body.items))).toEqual([]);
|
||
|
||
const previous = await api(
|
||
session.page,
|
||
`${basePath}&position=previous&cursor=${encodeURIComponent(next.body.pagination.previousCursor)}`,
|
||
);
|
||
expect(previous.status).toBe(200);
|
||
expect(assetIds(previous.body.items)).toEqual(assetIds(first.body.items));
|
||
expect(previous.body.pagination).toMatchObject({
|
||
position: 'previous',
|
||
hasPrevious: false,
|
||
hasNext: true,
|
||
currentPage: 1,
|
||
totalPages: 4167,
|
||
totalItems: 100000,
|
||
snapshotCursor: first.body.pagination.snapshotCursor,
|
||
});
|
||
|
||
let observedLastRequests = 0;
|
||
const observeLastRequest = (request: Request) => {
|
||
const url = new URL(request.url());
|
||
if (
|
||
url.pathname === '/api/assets/cards'
|
||
&& url.searchParams.get('folderId') === folderId
|
||
&& url.searchParams.get('position') === 'last'
|
||
) observedLastRequests += 1;
|
||
};
|
||
session.page.on('request', observeLastRequest);
|
||
const last = await api(session.page, `${basePath}&position=last`);
|
||
session.page.off('request', observeLastRequest);
|
||
|
||
expect(last.status).toBe(200);
|
||
expect(observedLastRequests).toBe(1);
|
||
expect(last.body.items).toHaveLength(16);
|
||
expect(last.body.items[0].name).toBe('P1四向分页-099985');
|
||
expect(last.body.items.at(-1).name).toBe('P1四向分页-100000');
|
||
expect(last.body.pagination).toMatchObject({
|
||
position: 'last',
|
||
hasPrevious: true,
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
currentPage: 4167,
|
||
totalPages: 4167,
|
||
totalItems: 100000,
|
||
snapshotCursor: expect.any(String),
|
||
});
|
||
expect(intersection(assetIds(next.body.items), assetIds(last.body.items))).toEqual([]);
|
||
|
||
const beforeLast = await api(
|
||
session.page,
|
||
`${basePath}&position=previous&cursor=${encodeURIComponent(last.body.pagination.previousCursor)}`,
|
||
);
|
||
expect(beforeLast.status).toBe(200);
|
||
expect(beforeLast.body.items).toHaveLength(pageSize);
|
||
expect(beforeLast.body.items[0].name).toBe('P1四向分页-099961');
|
||
expect(beforeLast.body.items.at(-1).name).toBe('P1四向分页-099984');
|
||
expect(beforeLast.body.pagination).toMatchObject({
|
||
position: 'previous',
|
||
hasPrevious: true,
|
||
hasNext: true,
|
||
currentPage: 4166,
|
||
totalPages: 4167,
|
||
totalItems: 100000,
|
||
snapshotCursor: last.body.pagination.snapshotCursor,
|
||
});
|
||
expect(intersection(assetIds(beforeLast.body.items), assetIds(last.body.items))).toEqual([]);
|
||
|
||
const nextBoundary = await readAssetCursorBoundary(first.body.items.at(-1).id);
|
||
const previousBoundary = await readAssetCursorBoundary(next.body.items[0].id);
|
||
const explainQueries = [
|
||
{
|
||
position: 'first',
|
||
sql: buildAssetCardExplainSql({ ownerId, folderId, order: 'DESC' }),
|
||
},
|
||
{
|
||
position: 'next',
|
||
sql: buildAssetCardExplainSql({
|
||
ownerId, folderId, order: 'DESC', comparison: '<', boundary: nextBoundary,
|
||
}),
|
||
},
|
||
{
|
||
position: 'previous',
|
||
sql: buildAssetCardExplainSql({
|
||
ownerId, folderId, order: 'ASC', comparison: '>', boundary: previousBoundary,
|
||
}),
|
||
},
|
||
{
|
||
position: 'last',
|
||
sql: buildAssetCardExplainSql({ ownerId, folderId, order: 'ASC', includeTotalCount: true }),
|
||
},
|
||
];
|
||
for (const query of explainQueries) {
|
||
expect(query.sql, `${query.position} 不得使用 OFFSET`).not.toMatch(/\bOFFSET\b/i);
|
||
const explain = postgresExplain(query.sql);
|
||
const indexNames = collectExplainIndexNames(explain.plan);
|
||
// 此压测的 10 万条数据同属一个文件夹,PostgreSQL 可能合理选择更窄的
|
||
// user cursor 索引;两者均保持 created_at/id 键集顺序,不强制优化器使用某一个等价索引。
|
||
expect(
|
||
indexNames.some(indexName => (
|
||
indexName === 'idx_assets_user_folder_cursor'
|
||
|| indexName === 'idx_assets_user_cursor'
|
||
)),
|
||
`${query.position} 必须通过用户资产键集索引读取,执行计划:${JSON.stringify(explain.plan)}`,
|
||
).toBe(true);
|
||
}
|
||
|
||
await timedApi(session.page, `${basePath}&position=first`);
|
||
await timedApi(session.page, `${basePath}&position=last`);
|
||
const performanceSamples: Array<{ status: number; elapsedMs: number }> = [];
|
||
for (let index = 0; index < 20; index += 1) {
|
||
performanceSamples.push(await timedApi(
|
||
session.page,
|
||
`${basePath}&position=${index % 2 === 0 ? 'first' : 'last'}`,
|
||
));
|
||
}
|
||
expect(performanceSamples.every(sample => sample.status === 200)).toBe(true);
|
||
expect(
|
||
percentile(performanceSamples.map(sample => sample.elapsedMs), 0.95),
|
||
`资产卡片接口耗时样本:${performanceSamples.map(sample => sample.elapsedMs.toFixed(1)).join(', ')}`,
|
||
).toBeLessThan(300);
|
||
} finally {
|
||
await session.context.close();
|
||
postgres(`
|
||
DELETE FROM assets WHERE folder_id = '${folderId}'::uuid;
|
||
DELETE FROM asset_folders WHERE id = '${folderId}'::uuid;
|
||
ANALYZE assets;
|
||
`);
|
||
}
|
||
});
|
||
|
||
test('真人库在真实事务并发下执行 free 1/1、pro 5/3、enterprise 不限/10 配额', async ({ browser }) => {
|
||
const marker = randomUUID();
|
||
const accounts = {
|
||
free: {
|
||
id: randomUUID(),
|
||
email: `portrait-free-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
},
|
||
pro: {
|
||
id: randomUUID(),
|
||
email: `portrait-pro-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
},
|
||
enterprise: {
|
||
id: randomUUID(),
|
||
email: `portrait-enterprise-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
},
|
||
};
|
||
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${accounts.free.id}'::uuid, '${accounts.free.email}', password_hash,
|
||
'全栈免费真人用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}'
|
||
UNION ALL
|
||
SELECT '${accounts.pro.id}'::uuid, '${accounts.pro.email}', password_hash,
|
||
'全栈专业真人用户', (SELECT id FROM plans WHERE code = 'pro' AND kind = 'main')
|
||
FROM users WHERE email = '${secondaryAccount.email}'
|
||
UNION ALL
|
||
SELECT '${accounts.enterprise.id}'::uuid, '${accounts.enterprise.email}', password_hash,
|
||
'全栈企业真人用户', (SELECT id FROM plans WHERE code = 'enterprise' AND kind = 'main')
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
`);
|
||
|
||
const free = await createAuthenticatedPage(browser, accounts.free);
|
||
const pro = await createAuthenticatedPage(browser, accounts.pro);
|
||
const enterprise = await createAuthenticatedPage(browser, accounts.enterprise);
|
||
const other = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
expect(postgresScalar(`
|
||
SELECT coalesce(plan_id::text, 'NULL')
|
||
FROM users WHERE id = '${accounts.free.id}'::uuid;
|
||
`)).toBe('NULL');
|
||
|
||
const initialCapacity = await api(free.page, '/api/real-portrait-capacity');
|
||
expect(initialCapacity.status).toBe(200);
|
||
expect(initialCapacity.body.capacity).toMatchObject({
|
||
sourcePlanCode: 'free',
|
||
entitlements: { enabled: true, profileLimit: 1, activeLimit: 1, assetsPerActor: 1 },
|
||
usage: { profiles: 0, active: 0 },
|
||
});
|
||
expect(initialCapacity.body.capacity.sourcePlanName).toBeTruthy();
|
||
|
||
const createActors = async (page: Page, prefix: string, count: number) => Promise.all(
|
||
Array.from({ length: count }, (_, index) => api(page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: `${prefix}-${index + 1}` },
|
||
})),
|
||
);
|
||
const successes = (results: Awaited<ReturnType<typeof createActors>>) => (
|
||
results.filter(result => result.status === 200).map(result => result.body.actor)
|
||
);
|
||
const expectQuotaFailures = (
|
||
results: Awaited<ReturnType<typeof createActors>>,
|
||
expected: number,
|
||
code: string,
|
||
) => {
|
||
const failures = results.filter(result => result.status !== 200);
|
||
expect(failures).toHaveLength(expected);
|
||
expect(failures.every(result => result.status === 409 && result.body.code === code)).toBe(true);
|
||
};
|
||
|
||
const freeCreates = await createActors(free.page, '免费演员', 2);
|
||
const freeActors = successes(freeCreates);
|
||
expect(freeActors).toHaveLength(1);
|
||
expectQuotaFailures(freeCreates, 1, 'REAL_PORTRAIT_PROFILE_QUOTA_EXCEEDED');
|
||
|
||
const proCreates = await createActors(pro.page, '专业演员', 6);
|
||
const proActors = successes(proCreates);
|
||
expect(proActors).toHaveLength(5);
|
||
expectQuotaFailures(proCreates, 1, 'REAL_PORTRAIT_PROFILE_QUOTA_EXCEEDED');
|
||
|
||
const enterpriseCreates = await createActors(enterprise.page, '企业演员', 11);
|
||
const enterpriseActors = successes(enterpriseCreates);
|
||
expect(enterpriseActors).toHaveLength(11);
|
||
expectQuotaFailures(enterpriseCreates, 0, 'REAL_PORTRAIT_PROFILE_QUOTA_EXCEEDED');
|
||
|
||
const activationBody = {
|
||
purpose: '全栈测试:在授权后用于商业 AI 视频生成',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-portrait-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-v1',
|
||
};
|
||
const activateActors = async (page: Page, actors: Array<{ id: string }>) => Promise.all(
|
||
actors.map(actor => api(page, `/api/real-portrait-actors/${actor.id}/activation-requests`, {
|
||
method: 'POST',
|
||
body: activationBody,
|
||
})),
|
||
);
|
||
|
||
const freeActivations = await activateActors(free.page, freeActors);
|
||
expect(freeActivations.filter(result => result.status === 200)).toHaveLength(1);
|
||
|
||
const proActivations = await activateActors(pro.page, proActors);
|
||
expect(proActivations.filter(result => result.status === 200)).toHaveLength(3);
|
||
expect(proActivations.filter(result => result.body.code === 'REAL_PORTRAIT_ACTIVE_QUOTA_EXCEEDED')).toHaveLength(2);
|
||
|
||
const enterpriseWarmupActivations = await activateActors(
|
||
enterprise.page,
|
||
enterpriseActors.slice(0, 9),
|
||
);
|
||
expect(enterpriseWarmupActivations.filter(result => result.status === 200)).toHaveLength(9);
|
||
|
||
// 激活接口另有每账号每小时 10 次的安全限流。这里重置隔离测试库中的账号维度
|
||
// 计数,让最后两个并发请求真正竞争第 10 个套餐名额,而不是被独立限流遮蔽。
|
||
postgres(`
|
||
DELETE FROM auth_rate_limit_events
|
||
WHERE scope = 'real-portrait-activation:account';
|
||
`);
|
||
const enterpriseLastSlotActivations = await activateActors(
|
||
enterprise.page,
|
||
enterpriseActors.slice(9),
|
||
);
|
||
expect(enterpriseLastSlotActivations.filter(result => result.status === 200)).toHaveLength(1);
|
||
expect(
|
||
enterpriseLastSlotActivations.filter(
|
||
result => result.status === 409
|
||
&& result.body.code === 'REAL_PORTRAIT_ACTIVE_QUOTA_EXCEEDED',
|
||
),
|
||
).toHaveLength(1);
|
||
|
||
const [freeList, proList, enterpriseList] = await Promise.all([
|
||
api(free.page, '/api/real-portrait-actors'),
|
||
api(pro.page, '/api/real-portrait-actors'),
|
||
api(enterprise.page, '/api/real-portrait-actors'),
|
||
]);
|
||
expect(freeList.body.capacity).toMatchObject({
|
||
sourcePlanCode: 'free',
|
||
entitlements: { profileLimit: 1, activeLimit: 1, assetsPerActor: 1 },
|
||
usage: { profiles: 1, active: 1 },
|
||
});
|
||
expect(proList.body.capacity).toMatchObject({
|
||
sourcePlanCode: 'pro',
|
||
entitlements: { profileLimit: 5, activeLimit: 3, assetsPerActor: 2 },
|
||
usage: { profiles: 5, active: 3 },
|
||
});
|
||
expect(enterpriseList.body.capacity).toMatchObject({
|
||
sourcePlanCode: 'enterprise',
|
||
entitlements: { profileLimit: null, activeLimit: 10, assetsPerActor: 2 },
|
||
usage: { profiles: 11, active: 10 },
|
||
});
|
||
|
||
const crossUserRead = await api(other.page, `/api/real-portrait-actors/${freeActors[0].id}`);
|
||
expect(crossUserRead.status).toBe(404);
|
||
expect(crossUserRead.body).not.toHaveProperty('providerGroupId');
|
||
expect(JSON.stringify(freeList.body)).not.toContain('asset://');
|
||
} finally {
|
||
await Promise.all([
|
||
free.context.close(),
|
||
pro.context.close(),
|
||
enterprise.context.close(),
|
||
other.context.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('真人演员从本人授权、官方 H5 回调、素材上传到视频生成,并在本人撤回后立即阻断新任务', async ({ browser }) => {
|
||
test.setTimeout(120_000);
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-lifecycle-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', password_hash,
|
||
'全栈真人链路用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
|
||
INSERT INTO asset_folders (user_id, name, source, is_system)
|
||
VALUES
|
||
('${account.id}', 'AI 视频', 'ai-video', true),
|
||
('${account.id}', 'AI 图片', 'ai-image', true),
|
||
('${account.id}', '已上传视频', 'uploaded-video', true),
|
||
('${account.id}', '已上传图片', 'uploaded-image', true),
|
||
('${account.id}', '已上传音频', 'uploaded-audio', true);
|
||
`);
|
||
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
|
||
try {
|
||
const creditGrant = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-portrait-credit-${marker}` },
|
||
body: {
|
||
userId: account.id,
|
||
mainDelta: 0,
|
||
addonDelta: 100,
|
||
note: '全栈测试:真人演员视频生成积分',
|
||
},
|
||
});
|
||
expect(creditGrant.status).toBe(200);
|
||
expect(creditGrant.body.user).toMatchObject({ addonCredits: 100, credits: 100 });
|
||
|
||
const created = await api(creator.page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: '全栈本人授权演员' },
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const actorId = String(created.body.actor.id);
|
||
expect(created.body.actor).toMatchObject({
|
||
id: actorId,
|
||
lifecycleStatus: 'draft',
|
||
authorizationStatus: 'none',
|
||
planAccessStatus: 'available',
|
||
assets: [],
|
||
});
|
||
|
||
const activation = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/activation-requests`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
purpose: '全栈测试:经本人同意后用于商业 AI 视频生成',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-portrait-consent-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-face-v1',
|
||
},
|
||
},
|
||
);
|
||
expect(activation.status).toBe(200);
|
||
expect(activation.body.activation).toMatchObject({
|
||
actor: { id: actorId, lifecycleStatus: 'invited', authorizationStatus: 'draft' },
|
||
queuePosition: null,
|
||
});
|
||
const invitationUrl = new URL(String(activation.body.activation.invitationUrl));
|
||
expect(invitationUrl.pathname).toBe('/portrait-consent/invite');
|
||
expect(invitationUrl.search).toBe('');
|
||
const inviteToken = new URLSearchParams(invitationUrl.hash.slice(1)).get('token');
|
||
expect(inviteToken).toBeTruthy();
|
||
expect(invitationUrl.pathname).not.toContain(String(inviteToken));
|
||
|
||
const invite = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}`,
|
||
);
|
||
expect(invite.status).toBe(200);
|
||
expect(invite.body.invite).toMatchObject({
|
||
status: 'pending',
|
||
purpose: '全栈测试:经本人同意后用于商业 AI 视频生成',
|
||
commercialUse: true,
|
||
canStartVerification: false,
|
||
portraitNoticeVersion: 'fullstack-portrait-consent-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-face-v1',
|
||
});
|
||
|
||
const consent = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
accepted: true,
|
||
adultConfirmed: true,
|
||
portraitConsent: true,
|
||
sensitiveFaceConsent: true,
|
||
commercialUseConsent: true,
|
||
authorizationDays: 90,
|
||
},
|
||
},
|
||
);
|
||
expect(consent.status).toBe(200);
|
||
expect(consent.body.status).toBe('accepted');
|
||
const manageUrl = new URL(String(consent.body.manageUrl));
|
||
expect(manageUrl.pathname).toBe('/portrait-consent/authorization');
|
||
expect(manageUrl.search).toBe('');
|
||
const manageToken = new URLSearchParams(manageUrl.hash.slice(1)).get('token');
|
||
expect(manageToken).toBeTruthy();
|
||
expect(manageUrl.pathname).not.toContain(String(manageToken));
|
||
|
||
const savedAuthorization = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-authorizations/${encodeURIComponent(String(manageToken))}`,
|
||
);
|
||
expect(savedAuthorization.status).toBe(200);
|
||
expect(savedAuthorization.body.authorization).toMatchObject({
|
||
status: 'pending',
|
||
purpose: '全栈测试:经本人同意后用于商业 AI 视频生成',
|
||
commercialUse: true,
|
||
cleanupStatus: 'not-requested',
|
||
});
|
||
|
||
const verification = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}/start-verification`,
|
||
{ method: 'POST' },
|
||
);
|
||
expect(verification.status).toBe(200);
|
||
expect(verification.body.verification).toMatchObject({
|
||
status: 'verification-ready',
|
||
queuePosition: null,
|
||
});
|
||
const verificationUrl = new URL(String(verification.body.verification.verificationUrl));
|
||
expect(verificationUrl.origin).toBe('https://example.invalid');
|
||
expect(verificationUrl.search).toBe('');
|
||
const verificationFragment = new URLSearchParams(verificationUrl.hash.slice(1));
|
||
const callbackUrl = new URL(String(verificationFragment.get('callbackUrl')));
|
||
const bytedToken = verificationFragment.get('bytedToken');
|
||
expect(callbackUrl.pathname).toMatch(/^\/api\/providers\/volc\/real-portrait-validation\/[^/]+$/);
|
||
expect(bytedToken).toMatch(/^fake-byted-/);
|
||
|
||
callbackUrl.searchParams.set('bytedToken', String(bytedToken));
|
||
callbackUrl.searchParams.set('resultCode', '10000');
|
||
callbackUrl.searchParams.set('algorithmBaseRespCode', '0');
|
||
callbackUrl.searchParams.set('reqMeasureInfoValue', '1');
|
||
callbackUrl.searchParams.set('verify_type', 'real_time');
|
||
const callbackNavigation = await subjectPage.goto(callbackUrl.toString(), {
|
||
waitUntil: 'domcontentloaded',
|
||
});
|
||
expect(callbackNavigation?.status()).toBe(200);
|
||
expect(new URL(subjectPage.url()).pathname).toBe('/portrait-consent/verification-complete');
|
||
await expect(subjectPage.getByRole('heading', { name: '本人认证已完成' })).toBeVisible();
|
||
|
||
await expect.poll(async () => {
|
||
const status = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}/status`,
|
||
);
|
||
expect(status.status).toBe(200);
|
||
return status.body.status;
|
||
}, {
|
||
timeout: 10_000,
|
||
intervals: [100, 250, 500],
|
||
}).toBe('awaiting_upload');
|
||
|
||
const jpeg = await sharp({
|
||
create: {
|
||
width: 512,
|
||
height: 512,
|
||
channels: 3,
|
||
background: { r: 88, g: 121, b: 160 },
|
||
},
|
||
}).jpeg({ quality: 90 }).toBuffer();
|
||
const jpegBase64 = jpeg.toString('base64');
|
||
const jpegSha256 = createHash('sha256').update(jpeg).digest('hex');
|
||
const upload = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/uploads`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
kind: 'primary',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: jpeg.byteLength,
|
||
},
|
||
},
|
||
);
|
||
expect(upload.status).toBe(200);
|
||
expect(upload.body.upload).toMatchObject({
|
||
kind: 'primary',
|
||
status: 'waiting_upload',
|
||
signature: { driver: 'local', method: 'PUT' },
|
||
});
|
||
expect(await uploadSignedBytes(
|
||
creator.page,
|
||
upload.body.upload.signature,
|
||
jpegBase64,
|
||
)).toBe(204);
|
||
|
||
const completedUpload = await api(
|
||
creator.page,
|
||
`/api/real-portrait-uploads/${upload.body.upload.id}/complete`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: jpeg.byteLength,
|
||
width: 512,
|
||
height: 512,
|
||
sha256: jpegSha256,
|
||
},
|
||
},
|
||
);
|
||
expect(completedUpload.status).toBe(200);
|
||
const portraitAssetId = String(completedUpload.body.asset.id);
|
||
|
||
await expect.poll(async () => {
|
||
const actor = await api(creator.page, `/api/real-portrait-actors/${actorId}`);
|
||
expect(actor.status).toBe(200);
|
||
return actor.body.actor;
|
||
}, {
|
||
// Worker 的真人素材协调周期是 15 秒;预留跨过一个完整调度周期的余量,
|
||
// 避免上传刚好发生在本轮扫描之后时把正常异步处理误判为失败。
|
||
timeout: 40_000,
|
||
intervals: [250, 500, 1_000, 2_000],
|
||
}).toMatchObject({
|
||
id: actorId,
|
||
lifecycleStatus: 'active',
|
||
authorizationStatus: 'active',
|
||
assets: [{
|
||
id: portraitAssetId,
|
||
kind: 'primary',
|
||
status: 'active',
|
||
consistencyStatus: 'passed',
|
||
}],
|
||
});
|
||
|
||
const creditsBeforeGeneration = await readCredits(creator.page);
|
||
const generated = await api(creator.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-portrait-generation-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '让已授权演员自然转身并向镜头挥手,背景保持简洁',
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
generateAudio: false,
|
||
},
|
||
references: [],
|
||
realPortrait: { actorId },
|
||
}],
|
||
},
|
||
});
|
||
expect(generated.status).toBe(200);
|
||
expect(generated.body.tasks).toHaveLength(1);
|
||
const generatedTask = generated.body.tasks[0];
|
||
expect(JSON.stringify(generatedTask)).not.toContain('asset://');
|
||
expect(JSON.stringify(generatedTask)).not.toContain('fake-group-');
|
||
expect(JSON.stringify(generatedTask)).not.toContain('fake-asset-');
|
||
expect(await readCredits(creator.page)).toBe(
|
||
creditsBeforeGeneration - Number(generatedTask.credits),
|
||
);
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(creator.page, generatedTask.id)).status,
|
||
{ timeout: 45_000, intervals: [500, 1_000, 2_000] },
|
||
).toBe('completed');
|
||
const completedTask = await readTask(creator.page, generatedTask.id);
|
||
expect(completedTask).toMatchObject({
|
||
status: 'completed',
|
||
creditStatus: 'settled',
|
||
assetId: expect.any(String),
|
||
});
|
||
expect(completedTask.config).not.toHaveProperty('realPortrait');
|
||
expect(JSON.stringify(completedTask)).not.toContain('asset://');
|
||
const settledCredits = await readCredits(creator.page);
|
||
|
||
const revoked = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-authorizations/${encodeURIComponent(String(manageToken))}/revoke`,
|
||
{
|
||
method: 'POST',
|
||
body: { reasonCode: 'subject-withdrawn-fullstack' },
|
||
},
|
||
);
|
||
expect(revoked.status).toBe(200);
|
||
expect(revoked.body.authorization).toMatchObject({
|
||
status: 'revoked',
|
||
cleanupStatus: 'completed',
|
||
});
|
||
|
||
const actorAfterRevoke = await api(creator.page, `/api/real-portrait-actors/${actorId}`);
|
||
expect(actorAfterRevoke.status).toBe(200);
|
||
expect(actorAfterRevoke.body.actor).toMatchObject({
|
||
lifecycleStatus: 'revoked',
|
||
authorizationStatus: 'revoked',
|
||
});
|
||
|
||
const rejected = await api(creator.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-portrait-revoked-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '撤回授权后不得再生成真人视频',
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
},
|
||
references: [],
|
||
realPortrait: { actorId },
|
||
}],
|
||
},
|
||
});
|
||
expect(rejected.status).toBe(409);
|
||
expect(rejected.body.code).toBe('REAL_PORTRAIT_REFERENCE_UNAVAILABLE');
|
||
expect(await readCredits(creator.page)).toBe(settledCredits);
|
||
|
||
const historicalTask = await readTask(creator.page, generatedTask.id);
|
||
expect(historicalTask).toMatchObject({
|
||
status: 'completed',
|
||
creditStatus: 'settled',
|
||
assetId: completedTask.assetId,
|
||
});
|
||
const historicalAsset = await api(creator.page, `/api/assets/${completedTask.assetId}`);
|
||
expect(historicalAsset.status).toBe(404);
|
||
expect(historicalAsset.body).toMatchObject({ message: '素材不存在' });
|
||
} finally {
|
||
await Promise.all([
|
||
creator.context.close(),
|
||
admin.context.close(),
|
||
subjectContext.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('已接受邀请但未开始 H5 时,创建者可释放容量并删除档案', async ({ browser }) => {
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-accepted-release-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', password_hash,
|
||
'全栈真人释放用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
`);
|
||
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
|
||
try {
|
||
const created = await api(creator.page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: '已接受未扫脸演员' },
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const actorId = String(created.body.actor.id);
|
||
|
||
const activation = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/activation-requests`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
purpose: '全栈测试:接受后未扫脸即撤回',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-accepted-release-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-accepted-release-sensitive-v1',
|
||
},
|
||
},
|
||
);
|
||
expect(activation.status).toBe(200);
|
||
const invitationUrl = new URL(String(activation.body.activation.invitationUrl));
|
||
const inviteToken = new URLSearchParams(invitationUrl.hash.slice(1)).get('token');
|
||
expect(inviteToken).toBeTruthy();
|
||
|
||
const consent = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
accepted: true,
|
||
adultConfirmed: true,
|
||
portraitConsent: true,
|
||
sensitiveFaceConsent: true,
|
||
commercialUseConsent: true,
|
||
authorizationDays: 90,
|
||
},
|
||
},
|
||
);
|
||
expect(consent.status).toBe(200);
|
||
expect(consent.body.status).toBe('accepted');
|
||
expect(postgresScalar(`
|
||
SELECT status || '|' || (accepted_at IS NOT NULL)::text
|
||
FROM real_portrait_invites
|
||
WHERE actor_id = '${actorId}'::uuid;
|
||
`)).toBe('accepted|true');
|
||
|
||
const released = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/revoke`,
|
||
{
|
||
method: 'POST',
|
||
body: { reasonCode: 'owner-release-before-h5' },
|
||
},
|
||
);
|
||
expect(released.status).toBe(200);
|
||
expect(released.body.actor).toMatchObject({
|
||
id: actorId,
|
||
lifecycleStatus: 'revoked',
|
||
authorizationStatus: 'revoked',
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT status || '|' || (accepted_at IS NULL)::text
|
||
FROM real_portrait_invites
|
||
WHERE actor_id = '${actorId}'::uuid;
|
||
`)).toBe('cancelled|true');
|
||
|
||
const deleted = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(deleted.status).toBe(200);
|
||
expect(deleted.body).toEqual({ deleted: true });
|
||
|
||
const actors = await api(creator.page, '/api/real-portrait-actors');
|
||
expect(actors.status).toBe(200);
|
||
expect(actors.body.items).toEqual([]);
|
||
expect(actors.body.capacity.usage).toMatchObject({ profiles: 0, active: 0 });
|
||
|
||
} finally {
|
||
await Promise.all([
|
||
creator.context.close(),
|
||
subjectContext.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('已撤回演员即使火山验证查单仍在重试,也可立即软删除且保留晚到资源清理', async ({ browser }) => {
|
||
test.setTimeout(150_000);
|
||
const marker = randomUUID();
|
||
const disabledPlanCode = `portrait-disabled-${marker}`;
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-pending-lookup-delete-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', password_hash,
|
||
'全栈真人查单软删除用户', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';
|
||
|
||
INSERT INTO plans (
|
||
code, name, name_en, kind, billing, credits, status, asset_retention_days,
|
||
portrait_enabled, portrait_profile_limit, portrait_active_limit,
|
||
portrait_assets_per_actor
|
||
) VALUES (
|
||
'${disabledPlanCode}', '全栈真人停用套餐', 'Fullstack Portrait Disabled',
|
||
'main', 'both', 0, 'active', 0, false, 0, 0, 0
|
||
);
|
||
`);
|
||
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
|
||
try {
|
||
const created = await api(creator.page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: '查单中软删除演员' },
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const actorId = String(created.body.actor.id);
|
||
|
||
const activation = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/activation-requests`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
purpose: '全栈测试:验证结果未知时撤回并删除',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-pending-lookup-delete-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-pending-lookup-delete-sensitive-v1',
|
||
},
|
||
},
|
||
);
|
||
expect(activation.status).toBe(200);
|
||
const invitationUrl = new URL(String(activation.body.activation.invitationUrl));
|
||
const inviteToken = new URLSearchParams(invitationUrl.hash.slice(1)).get('token');
|
||
expect(inviteToken).toBeTruthy();
|
||
|
||
const consent = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
accepted: true,
|
||
adultConfirmed: true,
|
||
portraitConsent: true,
|
||
sensitiveFaceConsent: true,
|
||
commercialUseConsent: true,
|
||
authorizationDays: 90,
|
||
},
|
||
},
|
||
);
|
||
expect(consent.status).toBe(200);
|
||
|
||
const verification = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}/start-verification`,
|
||
{ method: 'POST' },
|
||
);
|
||
expect(verification.status).toBe(200);
|
||
expect(verification.body.verification.status).toBe('verification-ready');
|
||
|
||
postgres(`
|
||
UPDATE real_portrait_verification_sessions session
|
||
SET status = 'submission_unknown', updated_at = now()
|
||
WHERE session.actor_id = '${actorId}'::uuid;
|
||
|
||
UPDATE real_portrait_remote_operations operation
|
||
SET status = 'submission_unknown', attempt_count = 3,
|
||
next_retry_at = now() + interval '1 day',
|
||
last_error_code = 'FULLSTACK_PROVIDER_NOT_FOUND', updated_at = now()
|
||
FROM real_portrait_verification_sessions session
|
||
WHERE operation.verification_session_id = session.id
|
||
AND operation.operation_type = 'get_visual_validation_result'
|
||
AND session.actor_id = '${actorId}'::uuid;
|
||
`);
|
||
|
||
const released = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}/revoke`,
|
||
{
|
||
method: 'POST',
|
||
body: { reasonCode: 'owner-release-pending-provider-lookup' },
|
||
},
|
||
);
|
||
expect(released.status).toBe(200);
|
||
expect(released.body.actor).toMatchObject({
|
||
lifecycleStatus: 'revoked',
|
||
authorizationStatus: 'revoked',
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT materialization.status || '|' ||
|
||
(materialization.provider_group_id IS NULL)::text || '|' || reservation.status
|
||
FROM real_portrait_materializations materialization
|
||
JOIN real_portrait_capacity_reservations reservation
|
||
ON reservation.actor_id = materialization.actor_id
|
||
WHERE materialization.actor_id = '${actorId}'::uuid
|
||
ORDER BY reservation.created_at DESC
|
||
LIMIT 1;
|
||
`)).toBe('verifying|true|released');
|
||
|
||
// 模拟生产中的结构化权益关闭和七天宽限到期。查单仍可能返回迟到 Group,
|
||
// 因此 quota 可以完成锁定,但无 Group 物化必须继续停在 verifying。
|
||
postgres(`
|
||
UPDATE users
|
||
SET plan_id = (SELECT id FROM plans WHERE code = '${disabledPlanCode}'),
|
||
plan_expires_at = NULL, updated_at = now()
|
||
WHERE id = '${account.id}'::uuid;
|
||
|
||
UPDATE user_portrait_quota_states
|
||
SET source_plan_id = (SELECT id FROM plans WHERE code = '${disabledPlanCode}'),
|
||
portrait_enabled = false, profile_limit = 0,
|
||
active_limit = 0, assets_per_actor = 0,
|
||
grace_status = 'active', grace_until = now() - interval '1 second',
|
||
retained_actor_ids = '[]'::jsonb, active_actor_ids = '[]'::jsonb,
|
||
updated_at = now()
|
||
WHERE user_id = '${account.id}'::uuid;
|
||
|
||
UPDATE real_portrait_actors
|
||
SET plan_access_status = 'grace', locked_at = NULL, updated_at = now()
|
||
WHERE id = '${actorId}'::uuid;
|
||
`);
|
||
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT concat_ws('|', quota.grace_status, actor.plan_access_status,
|
||
materialization.status,
|
||
(materialization.provider_group_id IS NULL)::text,
|
||
(materialization.remote_deleted_at IS NULL)::text,
|
||
lookup.status)
|
||
FROM user_portrait_quota_states quota
|
||
JOIN real_portrait_actors actor ON actor.user_id = quota.user_id
|
||
JOIN real_portrait_materializations materialization ON materialization.actor_id = actor.id
|
||
JOIN real_portrait_remote_operations lookup
|
||
ON lookup.materialization_id = materialization.id
|
||
AND lookup.operation_type = 'get_visual_validation_result'
|
||
WHERE quota.user_id = '${account.id}'::uuid
|
||
AND actor.id = '${actorId}'::uuid;
|
||
`), {
|
||
timeout: 45_000,
|
||
intervals: [500, 1_000, 2_000],
|
||
}).toBe('completed|plan_locked|verifying|true|true|submission_unknown');
|
||
|
||
const deleted = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${actorId}`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
expect(deleted.status).toBe(200);
|
||
expect(deleted.body).toEqual({ deleted: true });
|
||
expect(postgresScalar(`
|
||
SELECT alias || '|' || (deleted_at IS NOT NULL)::text
|
||
FROM real_portrait_actors
|
||
WHERE id = '${actorId}'::uuid;
|
||
`)).toBe('已删除演员|true');
|
||
expect(postgresScalar(`
|
||
SELECT status
|
||
FROM real_portrait_remote_operations
|
||
WHERE actor_id = '${actorId}'::uuid
|
||
AND operation_type = 'get_visual_validation_result';
|
||
`)).toBe('submission_unknown');
|
||
|
||
const actors = await api(creator.page, '/api/real-portrait-actors');
|
||
expect(actors.status).toBe(200);
|
||
expect(actors.body.items).toEqual([]);
|
||
expect(actors.body.capacity.usage).toMatchObject({ profiles: 0, active: 0 });
|
||
|
||
// 让测试栈内假供应商返回迟到 Group。worker 必须先持久化唯一删除操作,
|
||
// 再完成 Group 删除;这里没有外网或真实 Provider 调用。
|
||
postgres(`
|
||
UPDATE real_portrait_remote_operations
|
||
SET status = 'submission_unknown', next_retry_at = now(), updated_at = now()
|
||
WHERE actor_id = '${actorId}'::uuid
|
||
AND operation_type = 'get_visual_validation_result';
|
||
`);
|
||
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT concat_ws('|', materialization.status,
|
||
(materialization.provider_group_id IS NOT NULL)::text,
|
||
(materialization.remote_deleted_at IS NOT NULL)::text,
|
||
verification_session.status, lookup.status,
|
||
cleanup.status, cleanup.attempt_count::text,
|
||
cleanup.idempotency_key,
|
||
(SELECT count(*)::text
|
||
FROM real_portrait_remote_operations duplicate
|
||
WHERE duplicate.actor_id = '${actorId}'::uuid
|
||
AND duplicate.operation_type = 'expired_verification_delete_group'))
|
||
FROM real_portrait_materializations materialization
|
||
JOIN real_portrait_verification_sessions verification_session
|
||
ON verification_session.actor_id = materialization.actor_id
|
||
AND verification_session.authorization_id = materialization.authorization_id
|
||
JOIN real_portrait_remote_operations lookup
|
||
ON lookup.verification_session_id = verification_session.id
|
||
AND lookup.operation_type = 'get_visual_validation_result'
|
||
JOIN real_portrait_remote_operations cleanup
|
||
ON cleanup.verification_session_id = verification_session.id
|
||
AND cleanup.operation_type = 'expired_verification_delete_group'
|
||
WHERE materialization.actor_id = '${actorId}'::uuid;
|
||
`), {
|
||
timeout: 45_000,
|
||
intervals: [500, 1_000, 2_000],
|
||
}).toMatch(/^deleted\|true\|true\|failed\|completed\|completed\|1\|late-validation-group:[0-9a-f-]+\|1$/);
|
||
|
||
// 重放同一权威结果:completed 删除操作既不能插入第二条,也不能再次 claim 或增加 attempt。
|
||
postgres(`
|
||
UPDATE real_portrait_verification_sessions
|
||
SET status = 'submission_unknown', failure_code = 'FULLSTACK_TEST_REPLAY',
|
||
updated_at = now()
|
||
WHERE actor_id = '${actorId}'::uuid;
|
||
|
||
UPDATE real_portrait_remote_operations
|
||
SET status = 'submission_unknown', next_retry_at = now(), updated_at = now()
|
||
WHERE actor_id = '${actorId}'::uuid
|
||
AND operation_type = 'get_visual_validation_result';
|
||
`);
|
||
|
||
await expect.poll(() => postgresScalar(`
|
||
SELECT concat_ws('|', count(*)::text, max(attempt_count)::text,
|
||
min(status), min(idempotency_key),
|
||
(SELECT status FROM real_portrait_materializations
|
||
WHERE actor_id = '${actorId}'::uuid))
|
||
FROM real_portrait_remote_operations
|
||
WHERE actor_id = '${actorId}'::uuid
|
||
AND operation_type = 'expired_verification_delete_group';
|
||
`), {
|
||
timeout: 45_000,
|
||
intervals: [500, 1_000, 2_000],
|
||
}).toMatch(/^1\|1\|completed\|late-validation-group:[0-9a-f-]+\|deleted$/);
|
||
} finally {
|
||
await Promise.all([
|
||
creator.context.close(),
|
||
subjectContext.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('专业版降级免费版可选择保留演员,宽限到期后释放超额远端资源且再升级必须重新认证', async ({ browser }) => {
|
||
test.setTimeout(240_000);
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-downgrade-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', source.password_hash,
|
||
'全栈真人降级用户', plan.id
|
||
FROM users source
|
||
JOIN plans plan ON plan.code = 'pro' AND plan.kind = 'main'
|
||
WHERE source.email = '${secondaryAccount.email}'
|
||
LIMIT 1;
|
||
|
||
INSERT INTO asset_folders (user_id, name, source, is_system)
|
||
VALUES
|
||
('${account.id}', 'AI 视频', 'ai-video', true),
|
||
('${account.id}', 'AI 图片', 'ai-image', true),
|
||
('${account.id}', '已上传视频', 'uploaded-video', true),
|
||
('${account.id}', '已上传图片', 'uploaded-image', true),
|
||
('${account.id}', '已上传音频', 'uploaded-audio', true);
|
||
`);
|
||
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
|
||
try {
|
||
const first = await activateRealPortraitActorForFullstack(
|
||
creator.page,
|
||
subjectPage,
|
||
`${marker}-retained`,
|
||
'降级后保留演员',
|
||
);
|
||
const second = await activateRealPortraitActorForFullstack(
|
||
creator.page,
|
||
subjectPage,
|
||
`${marker}-released`,
|
||
'降级后释放演员',
|
||
);
|
||
|
||
const beforeDowngrade = await api(creator.page, '/api/real-portrait-capacity');
|
||
expect(beforeDowngrade.status).toBe(200);
|
||
expect(beforeDowngrade.body.capacity).toMatchObject({
|
||
sourcePlanCode: 'pro',
|
||
entitlements: {
|
||
enabled: true,
|
||
profileLimit: 5,
|
||
activeLimit: 3,
|
||
assetsPerActor: 2,
|
||
},
|
||
usage: { profiles: 2, active: 2 },
|
||
});
|
||
|
||
const usersResult = await api(
|
||
admin.page,
|
||
`/api/admin/users?search=${encodeURIComponent(account.email)}&limit=20`,
|
||
);
|
||
expect(usersResult.status).toBe(200);
|
||
const adminUser = usersResult.body.items.find(
|
||
(item: { email: string }) => item.email === account.email,
|
||
);
|
||
expect(adminUser).toBeTruthy();
|
||
expect(adminUser.planCode).toBe('pro');
|
||
|
||
const downgraded = await api(
|
||
admin.page,
|
||
`/api/admin/users/${account.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-portrait-downgrade-${marker}` },
|
||
body: {
|
||
planId: null,
|
||
billingCycle: null,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
note: '全栈测试:真人演员额度降级至免费版',
|
||
expected: {
|
||
planId: adminUser.planId,
|
||
billingCycle: adminUser.billingCycle,
|
||
planCycleStartedAt: adminUser.planCycleStartedAt,
|
||
planResetAt: adminUser.planResetAt,
|
||
planExpiresAt: adminUser.planExpiresAt,
|
||
activeSubscriptionOrderId: adminUser.activeSubscriptionOrderId,
|
||
mainCredits: adminUser.mainCredits,
|
||
},
|
||
expectedPlanCredits: 0,
|
||
},
|
||
},
|
||
);
|
||
expect(downgraded.status).toBe(200);
|
||
expect(downgraded.body.user).toMatchObject({
|
||
id: account.id,
|
||
planId: null,
|
||
planCode: null,
|
||
mainCredits: 0,
|
||
});
|
||
|
||
await expect.poll(async () => {
|
||
const capacity = await api(creator.page, '/api/real-portrait-capacity');
|
||
expect(capacity.status).toBe(200);
|
||
return capacity.body.capacity;
|
||
}, {
|
||
timeout: 30_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toMatchObject({
|
||
sourcePlanCode: 'free',
|
||
entitlements: {
|
||
enabled: true,
|
||
profileLimit: 1,
|
||
activeLimit: 1,
|
||
assetsPerActor: 1,
|
||
},
|
||
usage: { profiles: 2, active: 2 },
|
||
quotaState: {
|
||
graceStatus: 'active',
|
||
graceUntil: expect.any(String),
|
||
},
|
||
});
|
||
|
||
const actorsInGrace = await api(creator.page, '/api/real-portrait-actors');
|
||
expect(actorsInGrace.status).toBe(200);
|
||
expect(actorsInGrace.body.items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ id: first.actorId, planAccessStatus: 'grace' }),
|
||
expect.objectContaining({ id: second.actorId, planAccessStatus: 'grace' }),
|
||
]));
|
||
|
||
const selection = await api(
|
||
creator.page,
|
||
'/api/account/real-portrait-quota-selection',
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
retainedActorIds: [first.actorId],
|
||
activeActorIds: [first.actorId],
|
||
},
|
||
},
|
||
);
|
||
expect(selection.status).toBe(200);
|
||
expect(selection.body.capacity.quotaState).toMatchObject({
|
||
graceStatus: 'reconciling',
|
||
retainedActorIds: [first.actorId],
|
||
activeActorIds: [first.actorId],
|
||
});
|
||
|
||
// 仅推进套餐宽限期时钟;演员锁定、供应商资源删除和状态收敛必须由 worker 完成。
|
||
postgres(`
|
||
UPDATE user_portrait_quota_states
|
||
SET grace_until = now() - interval '1 second'
|
||
WHERE user_id = '${account.id}'::uuid;
|
||
`);
|
||
|
||
await expect.poll(async () => {
|
||
const [capacity, retainedActor, releasedActor] = await Promise.all([
|
||
api(creator.page, '/api/real-portrait-capacity'),
|
||
api(creator.page, `/api/real-portrait-actors/${first.actorId}`),
|
||
api(creator.page, `/api/real-portrait-actors/${second.actorId}`),
|
||
]);
|
||
expect(capacity.status).toBe(200);
|
||
expect(retainedActor.status).toBe(200);
|
||
expect(releasedActor.status).toBe(200);
|
||
return {
|
||
quotaState: capacity.body.capacity.quotaState,
|
||
usage: capacity.body.capacity.usage,
|
||
retained: retainedActor.body.actor,
|
||
released: releasedActor.body.actor,
|
||
};
|
||
}, {
|
||
timeout: 75_000,
|
||
intervals: [500, 1_000, 2_000],
|
||
}).toMatchObject({
|
||
quotaState: {
|
||
graceStatus: 'completed',
|
||
retainedActorIds: [first.actorId],
|
||
activeActorIds: [first.actorId],
|
||
},
|
||
usage: { profiles: 2, active: 1 },
|
||
retained: {
|
||
id: first.actorId,
|
||
planAccessStatus: 'available',
|
||
lifecycleStatus: 'active',
|
||
assets: [{
|
||
id: first.assetId,
|
||
status: 'active',
|
||
}],
|
||
},
|
||
released: {
|
||
id: second.actorId,
|
||
planAccessStatus: 'plan_locked',
|
||
lifecycleStatus: 'evicted',
|
||
assets: [],
|
||
},
|
||
});
|
||
|
||
const creditsBeforeLockedAttempt = await readCredits(creator.page);
|
||
const lockedGeneration = await api(creator.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-portrait-locked-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '套餐锁定后不得使用已释放的真人演员',
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
},
|
||
references: [],
|
||
realPortrait: { actorId: second.actorId },
|
||
}],
|
||
},
|
||
});
|
||
expect(lockedGeneration.status).toBe(409);
|
||
expect(lockedGeneration.body.code).toBe('REAL_PORTRAIT_REFERENCE_UNAVAILABLE');
|
||
expect(await readCredits(creator.page)).toBe(creditsBeforeLockedAttempt);
|
||
|
||
const plansResult = await api(admin.page, '/api/admin/plans');
|
||
expect(plansResult.status).toBe(200);
|
||
const proPlan = plansResult.body.items.find(
|
||
(item: { code: string; kind: string }) => item.code === 'pro' && item.kind === 'main',
|
||
);
|
||
expect(proPlan).toBeTruthy();
|
||
const resetDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1_000)
|
||
.toISOString().slice(0, 10);
|
||
const expiresDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000)
|
||
.toISOString().slice(0, 10);
|
||
const upgraded = await api(
|
||
admin.page,
|
||
`/api/admin/users/${account.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-portrait-upgrade-${marker}` },
|
||
body: {
|
||
planId: proPlan.id,
|
||
billingCycle: proPlan.billing === 'yearly' ? 'yearly' : 'monthly',
|
||
planResetAt: resetDate,
|
||
planExpiresAt: expiresDate,
|
||
note: '全栈测试:真人演员额度恢复专业版',
|
||
expected: {
|
||
planId: downgraded.body.user.planId,
|
||
billingCycle: downgraded.body.user.billingCycle,
|
||
planCycleStartedAt: downgraded.body.user.planCycleStartedAt,
|
||
planResetAt: downgraded.body.user.planResetAt,
|
||
planExpiresAt: downgraded.body.user.planExpiresAt,
|
||
activeSubscriptionOrderId: downgraded.body.user.activeSubscriptionOrderId,
|
||
mainCredits: downgraded.body.user.mainCredits,
|
||
},
|
||
expectedPlanCredits: proPlan.credits,
|
||
},
|
||
},
|
||
);
|
||
expect(upgraded.status).toBe(200);
|
||
expect(upgraded.body.user).toMatchObject({
|
||
id: account.id,
|
||
planId: proPlan.id,
|
||
planCode: 'pro',
|
||
mainCredits: proPlan.credits,
|
||
});
|
||
|
||
await expect.poll(async () => {
|
||
const actor = await api(creator.page, `/api/real-portrait-actors/${second.actorId}`);
|
||
expect(actor.status).toBe(200);
|
||
return actor.body.actor;
|
||
}, {
|
||
timeout: 30_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toMatchObject({
|
||
id: second.actorId,
|
||
planAccessStatus: 'available',
|
||
lifecycleStatus: 'evicted',
|
||
assets: [],
|
||
});
|
||
|
||
const creditsBeforeReactivation = await readCredits(creator.page);
|
||
const stillUnavailable = await api(creator.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-portrait-upgraded-before-consent-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: '升级后但未重新授权,仍不得使用已清理演员',
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
},
|
||
references: [],
|
||
realPortrait: { actorId: second.actorId },
|
||
}],
|
||
},
|
||
});
|
||
expect(stillUnavailable.status).toBe(409);
|
||
expect(stillUnavailable.body.code).toBe('REAL_PORTRAIT_REFERENCE_UNAVAILABLE');
|
||
expect(await readCredits(creator.page)).toBe(creditsBeforeReactivation);
|
||
|
||
const reactivation = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${second.actorId}/activation-requests`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
purpose: '全栈测试:清理后升级必须由真人本人重新授权',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-portrait-reconsent-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-face-reconsent-v1',
|
||
},
|
||
},
|
||
);
|
||
expect(reactivation.status).toBe(200);
|
||
expect(reactivation.body.activation.actor).toMatchObject({
|
||
id: second.actorId,
|
||
planAccessStatus: 'available',
|
||
lifecycleStatus: 'invited',
|
||
authorizationStatus: 'draft',
|
||
assets: [],
|
||
});
|
||
const newInvitationUrl = new URL(String(reactivation.body.activation.invitationUrl));
|
||
const newInviteToken = new URLSearchParams(newInvitationUrl.hash.slice(1)).get('token');
|
||
expect(newInviteToken).toBeTruthy();
|
||
expect(newInviteToken).not.toBe(second.inviteToken);
|
||
|
||
const reconsentInvite = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(newInviteToken))}`,
|
||
);
|
||
expect(reconsentInvite.status).toBe(200);
|
||
expect(reconsentInvite.body.invite).toMatchObject({
|
||
status: 'pending',
|
||
canStartVerification: false,
|
||
purpose: '全栈测试:清理后升级必须由真人本人重新授权',
|
||
portraitNoticeVersion: 'fullstack-portrait-reconsent-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-face-reconsent-v1',
|
||
});
|
||
|
||
const rematerialized = await completeRealPortraitInvitationForFullstack(
|
||
creator.page,
|
||
subjectPage,
|
||
second.actorId,
|
||
String(newInviteToken),
|
||
`${marker}-reconsent`,
|
||
);
|
||
expect(rematerialized.assetId).not.toBe(second.assetId);
|
||
|
||
const reactivatedActor = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${second.actorId}`,
|
||
);
|
||
expect(reactivatedActor.status).toBe(200);
|
||
expect(reactivatedActor.body.actor).toMatchObject({
|
||
id: second.actorId,
|
||
planAccessStatus: 'available',
|
||
lifecycleStatus: 'active',
|
||
authorizationStatus: 'active',
|
||
assets: [{
|
||
id: rematerialized.assetId,
|
||
kind: 'primary',
|
||
status: 'active',
|
||
consistencyStatus: 'passed',
|
||
}],
|
||
});
|
||
|
||
const regenerated = await generateWithRealPortraitForFullstack(
|
||
creator.page,
|
||
second.actorId,
|
||
`${marker}-after-reconsent`,
|
||
'重新授权并重新入库后,演员再次完成一段自然转身的视频',
|
||
);
|
||
expect(regenerated.completedTask).toMatchObject({
|
||
status: 'completed',
|
||
creditStatus: 'settled',
|
||
assetId: expect.any(String),
|
||
});
|
||
} finally {
|
||
await Promise.all([
|
||
creator.context.close(),
|
||
admin.context.close(),
|
||
subjectContext.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('专业版降级免费版未选择演员时按最近成功生成时间自动保留', async ({ browser }) => {
|
||
test.setTimeout(300_000);
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-lru-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', source.password_hash,
|
||
'全栈真人自动保留用户', plan.id
|
||
FROM users source
|
||
JOIN plans plan ON plan.code = 'pro' AND plan.kind = 'main'
|
||
WHERE source.email = '${secondaryAccount.email}'
|
||
LIMIT 1;
|
||
|
||
INSERT INTO asset_folders (user_id, name, source, is_system)
|
||
VALUES
|
||
('${account.id}', 'AI 视频', 'ai-video', true),
|
||
('${account.id}', 'AI 图片', 'ai-image', true),
|
||
('${account.id}', '已上传视频', 'uploaded-video', true),
|
||
('${account.id}', '已上传图片', 'uploaded-image', true),
|
||
('${account.id}', '已上传音频', 'uploaded-audio', true);
|
||
`);
|
||
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
|
||
try {
|
||
const creditGrant = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-portrait-lru-credit-${marker}` },
|
||
body: {
|
||
userId: account.id,
|
||
mainDelta: 0,
|
||
addonDelta: 100,
|
||
note: '全栈测试:验证真人演员最近成功使用自动保留',
|
||
},
|
||
});
|
||
expect(creditGrant.status).toBe(200);
|
||
|
||
const recentlyUsed = await activateRealPortraitActorForFullstack(
|
||
creator.page,
|
||
subjectPage,
|
||
`${marker}-recently-used`,
|
||
'较早创建但最近成功生成的演员',
|
||
);
|
||
const neverUsed = await activateRealPortraitActorForFullstack(
|
||
creator.page,
|
||
subjectPage,
|
||
`${marker}-never-used`,
|
||
'较晚创建但从未生成的演员',
|
||
);
|
||
|
||
await generateWithRealPortraitForFullstack(
|
||
creator.page,
|
||
recentlyUsed.actorId,
|
||
`${marker}-lru-evidence`,
|
||
'为自动保留排序写入一次真实成功生成时间',
|
||
);
|
||
const usedActorBeforeDowngrade = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${recentlyUsed.actorId}`,
|
||
);
|
||
expect(usedActorBeforeDowngrade.status).toBe(200);
|
||
expect(usedActorBeforeDowngrade.body.actor.lastSuccessfulGenerationAt).toEqual(
|
||
expect.any(String),
|
||
);
|
||
const unusedActorBeforeDowngrade = await api(
|
||
creator.page,
|
||
`/api/real-portrait-actors/${neverUsed.actorId}`,
|
||
);
|
||
expect(unusedActorBeforeDowngrade.status).toBe(200);
|
||
expect(unusedActorBeforeDowngrade.body.actor.lastSuccessfulGenerationAt).toBeNull();
|
||
|
||
const usersResult = await api(
|
||
admin.page,
|
||
`/api/admin/users?search=${encodeURIComponent(account.email)}&limit=20`,
|
||
);
|
||
expect(usersResult.status).toBe(200);
|
||
const adminUser = usersResult.body.items.find(
|
||
(item: { email: string }) => item.email === account.email,
|
||
);
|
||
expect(adminUser).toBeTruthy();
|
||
expect(adminUser.planCode).toBe('pro');
|
||
|
||
const downgraded = await api(
|
||
admin.page,
|
||
`/api/admin/users/${account.id}/subscription`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-portrait-lru-downgrade-${marker}` },
|
||
body: {
|
||
planId: null,
|
||
billingCycle: null,
|
||
planResetAt: null,
|
||
planExpiresAt: null,
|
||
note: '全栈测试:不提交选择时验证自动保留顺序',
|
||
expected: {
|
||
planId: adminUser.planId,
|
||
billingCycle: adminUser.billingCycle,
|
||
planCycleStartedAt: adminUser.planCycleStartedAt,
|
||
planResetAt: adminUser.planResetAt,
|
||
planExpiresAt: adminUser.planExpiresAt,
|
||
activeSubscriptionOrderId: adminUser.activeSubscriptionOrderId,
|
||
mainCredits: adminUser.mainCredits,
|
||
},
|
||
expectedPlanCredits: 0,
|
||
},
|
||
},
|
||
);
|
||
expect(downgraded.status).toBe(200);
|
||
|
||
await expect.poll(async () => {
|
||
const capacity = await api(creator.page, '/api/real-portrait-capacity');
|
||
expect(capacity.status).toBe(200);
|
||
return capacity.body.capacity;
|
||
}, {
|
||
timeout: 30_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toMatchObject({
|
||
sourcePlanCode: 'free',
|
||
usage: { profiles: 2, active: 2 },
|
||
quotaState: {
|
||
graceStatus: 'active',
|
||
retainedActorIds: [],
|
||
activeActorIds: [],
|
||
},
|
||
});
|
||
|
||
// 不调用 quota-selection,只推进宽限期,由真实 worker 执行默认排序和远端清理。
|
||
postgres(`
|
||
UPDATE user_portrait_quota_states
|
||
SET grace_until = now() - interval '1 second'
|
||
WHERE user_id = '${account.id}'::uuid;
|
||
`);
|
||
|
||
await expect.poll(async () => {
|
||
const [capacity, usedActor, unusedActor] = await Promise.all([
|
||
api(creator.page, '/api/real-portrait-capacity'),
|
||
api(creator.page, `/api/real-portrait-actors/${recentlyUsed.actorId}`),
|
||
api(creator.page, `/api/real-portrait-actors/${neverUsed.actorId}`),
|
||
]);
|
||
expect(capacity.status).toBe(200);
|
||
expect(usedActor.status).toBe(200);
|
||
expect(unusedActor.status).toBe(200);
|
||
return {
|
||
quotaState: capacity.body.capacity.quotaState,
|
||
usage: capacity.body.capacity.usage,
|
||
usedActor: usedActor.body.actor,
|
||
unusedActor: unusedActor.body.actor,
|
||
};
|
||
}, {
|
||
timeout: 75_000,
|
||
intervals: [500, 1_000, 2_000],
|
||
}).toMatchObject({
|
||
quotaState: {
|
||
graceStatus: 'completed',
|
||
retainedActorIds: [recentlyUsed.actorId],
|
||
activeActorIds: [recentlyUsed.actorId],
|
||
},
|
||
usage: { profiles: 2, active: 1 },
|
||
usedActor: {
|
||
id: recentlyUsed.actorId,
|
||
planAccessStatus: 'available',
|
||
lifecycleStatus: 'active',
|
||
assets: [{
|
||
id: recentlyUsed.assetId,
|
||
status: 'active',
|
||
}],
|
||
},
|
||
unusedActor: {
|
||
id: neverUsed.actorId,
|
||
planAccessStatus: 'plan_locked',
|
||
lifecycleStatus: 'evicted',
|
||
assets: [],
|
||
},
|
||
});
|
||
} finally {
|
||
await Promise.all([
|
||
creator.context.close(),
|
||
admin.context.close(),
|
||
subjectContext.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('真人库全局软上限在并发激活和第二张素材预留时均不超卖', async ({ browser }) => {
|
||
test.setTimeout(300_000);
|
||
const marker = randomUUID();
|
||
const account = {
|
||
id: randomUUID(),
|
||
email: `portrait-capacity-${marker}@lindyson.test`,
|
||
password: secondaryAccount.password,
|
||
};
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', source.password_hash,
|
||
'全栈真人容量并发用户', plan.id
|
||
FROM users source
|
||
JOIN plans plan ON plan.code = 'enterprise' AND plan.kind = 'main'
|
||
WHERE source.email = '${secondaryAccount.email}'
|
||
LIMIT 1;
|
||
`);
|
||
|
||
const ownerA = await createAuthenticatedPage(browser, account);
|
||
const ownerB = await createAuthenticatedPage(browser, account);
|
||
const subjectContextA = await browser.newContext();
|
||
const subjectContextB = await browser.newContext();
|
||
const subjectPageA = await subjectContextA.newPage();
|
||
const subjectPageB = await subjectContextB.newPage();
|
||
await Promise.all([subjectPageA.goto('/'), subjectPageB.goto('/')]);
|
||
const fillerUserIds = new Set<string>();
|
||
|
||
try {
|
||
const created = await Promise.all([
|
||
api(ownerA.page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: '并发容量演员 A' },
|
||
}),
|
||
api(ownerB.page, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias: '并发容量演员 B' },
|
||
}),
|
||
]);
|
||
expect(created.every(result => result.status === 200)).toBe(true);
|
||
const actorIds = created.map(result => String(result.body.actor.id));
|
||
|
||
const initialCapacity = await api(ownerA.page, '/api/real-portrait-capacity');
|
||
expect(initialCapacity.status).toBe(200);
|
||
const initialGlobal = initialCapacity.body.capacity.global;
|
||
expect(initialGlobal.groupSoftLimit).toBeGreaterThanOrEqual(4);
|
||
expect(initialGlobal.assetSoftLimit).toBeGreaterThanOrEqual(4);
|
||
expect(initialGlobal.groupSoftLimit).toBe(initialGlobal.assetSoftLimit);
|
||
const initialGroups = Number(initialGlobal.groupsUsed) + Number(initialGlobal.groupsReserved);
|
||
const initialAssets = Number(initialGlobal.assetsUsed) + Number(initialGlobal.assetsReserved);
|
||
expect(initialGroups).toBe(initialAssets);
|
||
expect(initialGroups).toBeLessThan(initialGlobal.groupSoftLimit);
|
||
|
||
const activationFillerUserId = randomUUID();
|
||
fillerUserIds.add(activationFillerUserId);
|
||
seedRealPortraitCapacityFiller({
|
||
userId: activationFillerUserId,
|
||
email: `portrait-capacity-activation-filler-${marker}@lindyson.test`,
|
||
marker: `${marker}-activation`,
|
||
count: Number(initialGlobal.groupSoftLimit) - initialGroups - 1,
|
||
});
|
||
|
||
const beforeConcurrentActivation = await api(
|
||
ownerA.page,
|
||
'/api/real-portrait-capacity',
|
||
);
|
||
expect(beforeConcurrentActivation.status).toBe(200);
|
||
const beforeActivationGlobal = beforeConcurrentActivation.body.capacity.global;
|
||
expect(
|
||
Number(beforeActivationGlobal.groupsUsed) + Number(beforeActivationGlobal.groupsReserved),
|
||
).toBe(Number(beforeActivationGlobal.groupSoftLimit) - 1);
|
||
expect(
|
||
Number(beforeActivationGlobal.assetsUsed) + Number(beforeActivationGlobal.assetsReserved),
|
||
).toBe(Number(beforeActivationGlobal.assetSoftLimit) - 1);
|
||
|
||
const activationBody = {
|
||
purpose: '全栈测试:并发抢占最后一个真人 Group 与主图 Asset 槽位',
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-capacity-portrait-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-capacity-sensitive-v1',
|
||
};
|
||
const activationResults = await Promise.all([
|
||
api(ownerA.page, `/api/real-portrait-actors/${actorIds[0]}/activation-requests`, {
|
||
method: 'POST',
|
||
body: activationBody,
|
||
}),
|
||
api(ownerB.page, `/api/real-portrait-actors/${actorIds[1]}/activation-requests`, {
|
||
method: 'POST',
|
||
body: activationBody,
|
||
}),
|
||
]);
|
||
expect(activationResults.every(result => result.status === 200)).toBe(true);
|
||
const admittedActivations = activationResults.filter(
|
||
result => Boolean(result.body.activation.invitationUrl),
|
||
);
|
||
const queuedActivations = activationResults.filter(
|
||
result => result.body.activation.invitationUrl === null,
|
||
);
|
||
expect(admittedActivations).toHaveLength(1);
|
||
expect(queuedActivations).toHaveLength(1);
|
||
expect(queuedActivations[0].body.activation.queuePosition).toEqual(expect.any(Number));
|
||
|
||
const afterConcurrentActivation = await api(ownerA.page, '/api/real-portrait-capacity');
|
||
expect(afterConcurrentActivation.status).toBe(200);
|
||
const afterActivationGlobal = afterConcurrentActivation.body.capacity.global;
|
||
expect(
|
||
Number(afterActivationGlobal.groupsUsed) + Number(afterActivationGlobal.groupsReserved),
|
||
).toBe(Number(afterActivationGlobal.groupSoftLimit));
|
||
expect(
|
||
Number(afterActivationGlobal.assetsUsed) + Number(afterActivationGlobal.assetsReserved),
|
||
).toBe(Number(afterActivationGlobal.assetSoftLimit));
|
||
expect(Number(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM real_portrait_capacity_reservations
|
||
WHERE actor_id = ANY(ARRAY[
|
||
'${actorIds[0]}'::uuid,
|
||
'${actorIds[1]}'::uuid
|
||
])
|
||
AND status = 'reserved';
|
||
`))).toBe(1);
|
||
expect(Number(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM real_portrait_activation_queue
|
||
WHERE actor_id = ANY(ARRAY[
|
||
'${actorIds[0]}'::uuid,
|
||
'${actorIds[1]}'::uuid
|
||
])
|
||
AND status = 'waiting';
|
||
`))).toBe(1);
|
||
|
||
const cancelled = await Promise.all([
|
||
api(ownerA.page, `/api/real-portrait-actors/${actorIds[0]}/revoke`, {
|
||
method: 'POST',
|
||
body: { reasonCode: 'fullstack-capacity-phase-reset' },
|
||
}),
|
||
api(ownerB.page, `/api/real-portrait-actors/${actorIds[1]}/revoke`, {
|
||
method: 'POST',
|
||
body: { reasonCode: 'fullstack-capacity-phase-reset' },
|
||
}),
|
||
]);
|
||
expect(cancelled.every(result => result.status === 200)).toBe(true);
|
||
await expect.poll(async () => {
|
||
const capacity = await api(ownerA.page, '/api/real-portrait-capacity');
|
||
expect(capacity.status).toBe(200);
|
||
return {
|
||
groups: Number(capacity.body.capacity.global.groupsUsed)
|
||
+ Number(capacity.body.capacity.global.groupsReserved),
|
||
assets: Number(capacity.body.capacity.global.assetsUsed)
|
||
+ Number(capacity.body.capacity.global.assetsReserved),
|
||
};
|
||
}, {
|
||
timeout: 10_000,
|
||
intervals: [100, 250, 500],
|
||
}).toEqual({
|
||
groups: Number(beforeActivationGlobal.groupSoftLimit) - 1,
|
||
assets: Number(beforeActivationGlobal.assetSoftLimit) - 1,
|
||
});
|
||
|
||
releaseRealPortraitCapacityFiller(activationFillerUserId);
|
||
fillerUserIds.delete(activationFillerUserId);
|
||
|
||
const reactivations = await Promise.all([
|
||
api(ownerA.page, `/api/real-portrait-actors/${actorIds[0]}/activation-requests`, {
|
||
method: 'POST',
|
||
body: {
|
||
...activationBody,
|
||
purpose: '全栈测试:演员 A 重新授权后测试第二张素材容量',
|
||
},
|
||
}),
|
||
api(ownerB.page, `/api/real-portrait-actors/${actorIds[1]}/activation-requests`, {
|
||
method: 'POST',
|
||
body: {
|
||
...activationBody,
|
||
purpose: '全栈测试:演员 B 重新授权后测试第二张素材容量',
|
||
},
|
||
}),
|
||
]);
|
||
expect(reactivations.every(result => result.status === 200)).toBe(true);
|
||
expect(reactivations.every(
|
||
result => Boolean(result.body.activation.invitationUrl),
|
||
)).toBe(true);
|
||
const reactivationTokens = reactivations.map(result => {
|
||
const url = new URL(String(result.body.activation.invitationUrl));
|
||
const token = new URLSearchParams(url.hash.slice(1)).get('token');
|
||
expect(token).toBeTruthy();
|
||
return String(token);
|
||
});
|
||
|
||
const rematerialized = await Promise.all([
|
||
completeRealPortraitInvitationForFullstack(
|
||
ownerA.page,
|
||
subjectPageA,
|
||
actorIds[0],
|
||
reactivationTokens[0],
|
||
`${marker}-capacity-a`,
|
||
),
|
||
completeRealPortraitInvitationForFullstack(
|
||
ownerB.page,
|
||
subjectPageB,
|
||
actorIds[1],
|
||
reactivationTokens[1],
|
||
`${marker}-capacity-b`,
|
||
),
|
||
]);
|
||
expect(rematerialized).toHaveLength(2);
|
||
|
||
const beforeSecondaryFiller = await api(ownerA.page, '/api/real-portrait-capacity');
|
||
expect(beforeSecondaryFiller.status).toBe(200);
|
||
const beforeSecondaryGlobal = beforeSecondaryFiller.body.capacity.global;
|
||
const residentGroups = Number(beforeSecondaryGlobal.groupsUsed)
|
||
+ Number(beforeSecondaryGlobal.groupsReserved);
|
||
const residentAssets = Number(beforeSecondaryGlobal.assetsUsed)
|
||
+ Number(beforeSecondaryGlobal.assetsReserved);
|
||
expect(residentGroups).toBe(residentAssets);
|
||
expect(residentAssets).toBeLessThan(Number(beforeSecondaryGlobal.assetSoftLimit) - 1);
|
||
|
||
const uploadFillerUserId = randomUUID();
|
||
fillerUserIds.add(uploadFillerUserId);
|
||
seedRealPortraitCapacityFiller({
|
||
userId: uploadFillerUserId,
|
||
email: `portrait-capacity-upload-filler-${marker}@lindyson.test`,
|
||
marker: `${marker}-upload`,
|
||
count: Number(beforeSecondaryGlobal.assetSoftLimit) - residentAssets - 1,
|
||
});
|
||
|
||
const beforeConcurrentSecondary = await api(
|
||
ownerA.page,
|
||
'/api/real-portrait-capacity',
|
||
);
|
||
expect(beforeConcurrentSecondary.status).toBe(200);
|
||
expect(
|
||
Number(beforeConcurrentSecondary.body.capacity.global.assetsUsed)
|
||
+ Number(beforeConcurrentSecondary.body.capacity.global.assetsReserved),
|
||
).toBe(Number(beforeConcurrentSecondary.body.capacity.global.assetSoftLimit) - 1);
|
||
|
||
const secondaryUploadBody = {
|
||
kind: 'secondary',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: 1_024,
|
||
};
|
||
const secondaryResults = await Promise.all([
|
||
api(ownerA.page, `/api/real-portrait-actors/${actorIds[0]}/uploads`, {
|
||
method: 'POST',
|
||
body: secondaryUploadBody,
|
||
}),
|
||
api(ownerB.page, `/api/real-portrait-actors/${actorIds[1]}/uploads`, {
|
||
method: 'POST',
|
||
body: secondaryUploadBody,
|
||
}),
|
||
]);
|
||
const admittedUploads = secondaryResults.filter(result => result.status === 200);
|
||
const rejectedUploads = secondaryResults.filter(result => result.status !== 200);
|
||
expect(admittedUploads).toHaveLength(1);
|
||
expect(admittedUploads[0].body.upload).toMatchObject({
|
||
kind: 'secondary',
|
||
status: 'waiting_upload',
|
||
});
|
||
expect(rejectedUploads).toHaveLength(1);
|
||
expect(rejectedUploads[0]).toMatchObject({
|
||
status: 409,
|
||
body: { code: 'REAL_PORTRAIT_CAPACITY_WAIT' },
|
||
});
|
||
|
||
const afterConcurrentSecondary = await api(ownerA.page, '/api/real-portrait-capacity');
|
||
expect(afterConcurrentSecondary.status).toBe(200);
|
||
const afterSecondaryGlobal = afterConcurrentSecondary.body.capacity.global;
|
||
expect(
|
||
Number(afterSecondaryGlobal.assetsUsed) + Number(afterSecondaryGlobal.assetsReserved),
|
||
).toBe(Number(afterSecondaryGlobal.assetSoftLimit));
|
||
expect(
|
||
Number(afterSecondaryGlobal.groupsUsed) + Number(afterSecondaryGlobal.groupsReserved),
|
||
).toBeLessThanOrEqual(Number(afterSecondaryGlobal.groupSoftLimit));
|
||
expect(Number(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM real_portrait_upload_sessions
|
||
WHERE actor_id = ANY(ARRAY[
|
||
'${actorIds[0]}'::uuid,
|
||
'${actorIds[1]}'::uuid
|
||
])
|
||
AND kind = 'secondary'
|
||
AND status = 'waiting_upload'
|
||
AND expires_at > now();
|
||
`))).toBe(1);
|
||
expect(Number(postgresScalar(`
|
||
SELECT count(*)
|
||
FROM real_portrait_capacity_reservations
|
||
WHERE actor_id = ANY(ARRAY[
|
||
'${actorIds[0]}'::uuid,
|
||
'${actorIds[1]}'::uuid
|
||
])
|
||
AND status = 'reserved'
|
||
AND asset_slots = 2;
|
||
`))).toBe(1);
|
||
} finally {
|
||
for (const fillerUserId of fillerUserIds) {
|
||
releaseRealPortraitCapacityFiller(fillerUserId);
|
||
}
|
||
await Promise.all([
|
||
ownerA.context.close(),
|
||
ownerB.context.close(),
|
||
subjectContextA.close(),
|
||
subjectContextB.close(),
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('TK 1.0 管理 API 不能绕过终结器开放,受审计切流后回滚不能复活 V6', async ({ browser }) => {
|
||
const adminA = await createAuthenticatedPage(browser, primaryAccount);
|
||
const adminB = await createAuthenticatedPage(browser, primaryAccount);
|
||
try {
|
||
expect(postgresScalar(`
|
||
SELECT (v6_retired_at IS NULL)::text
|
||
FROM tk_prompt_engine_settings
|
||
WHERE singleton_id = 1;
|
||
`)).toBe('true');
|
||
postgres(`
|
||
UPDATE prompt_wizard_settings
|
||
SET available = true, updated_at = now()
|
||
WHERE singleton_id = 1;
|
||
UPDATE tk_prompt_engine_settings
|
||
SET available = false, updated_at = now()
|
||
WHERE singleton_id = 1;
|
||
`);
|
||
|
||
const [legacyEnable, rejectedTkEnable] = await Promise.all([
|
||
api(adminA.page, '/api/admin/prompt-wizard-settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-v6-concurrent-enable-${randomUUID()}` },
|
||
body: { available: true },
|
||
}),
|
||
api(adminB.page, '/api/admin/tk-prompt-engine-settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-tk-concurrent-enable-${randomUUID()}` },
|
||
body: { available: true },
|
||
}),
|
||
]);
|
||
|
||
expect(legacyEnable.status).toBe(200);
|
||
expect(rejectedTkEnable).toMatchObject({
|
||
status: 409,
|
||
body: { code: 'TK_PROMPT_ENABLE_REQUIRES_FINALIZER' },
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
tk.available::text,
|
||
(tk.v6_retired_at IS NOT NULL)::text,
|
||
legacy.available::text
|
||
)
|
||
FROM tk_prompt_engine_settings tk
|
||
CROSS JOIN prompt_wizard_settings legacy
|
||
WHERE tk.singleton_id = 1 AND legacy.singleton_id = 1;
|
||
`)).toBe('false|false|true');
|
||
|
||
postgres(`
|
||
BEGIN;
|
||
SELECT pg_advisory_xact_lock(hashtextextended('prompt-engine-v1-cutover', 0));
|
||
SELECT singleton_id FROM prompt_wizard_settings WHERE singleton_id = 1 FOR UPDATE;
|
||
SELECT singleton_id FROM tk_prompt_engine_settings WHERE singleton_id = 1 FOR UPDATE;
|
||
UPDATE tk_prompt_engine_settings
|
||
SET available = true,
|
||
v6_retired_at = clock_timestamp(),
|
||
updated_at = clock_timestamp()
|
||
WHERE singleton_id = 1 AND available = false AND v6_retired_at IS NULL;
|
||
UPDATE prompt_wizard_settings
|
||
SET available = false, updated_at = clock_timestamp()
|
||
WHERE singleton_id = 1 AND available = true AND engine_version = 4;
|
||
COMMIT;
|
||
`);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
tk.available::text,
|
||
(tk.v6_retired_at IS NOT NULL)::text,
|
||
legacy.available::text
|
||
)
|
||
FROM tk_prompt_engine_settings tk
|
||
CROSS JOIN prompt_wizard_settings legacy
|
||
WHERE tk.singleton_id = 1 AND legacy.singleton_id = 1;
|
||
`)).toBe('true|true|false');
|
||
|
||
const rollbackTk = await api(adminA.page, '/api/admin/tk-prompt-engine-settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-tk-rollback-${randomUUID()}` },
|
||
body: { available: false },
|
||
});
|
||
expect(rollbackTk.status).toBe(200);
|
||
expect(rollbackTk.body.settings).toMatchObject({ available: false });
|
||
|
||
const rejectedLegacyReopen = await api(adminB.page, '/api/admin/prompt-wizard-settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': `fullstack-v6-reopen-${randomUUID()}` },
|
||
body: { available: true },
|
||
});
|
||
expect(rejectedLegacyReopen).toMatchObject({
|
||
status: 409,
|
||
body: { code: 'PROMPT_WIZARD_WRITE_RETIRED' },
|
||
});
|
||
const retiredWrite = await api(adminA.page, '/api/ai/prompt-wizard/runs', {
|
||
method: 'POST',
|
||
body: {},
|
||
});
|
||
expect(retiredWrite).toMatchObject({
|
||
status: 410,
|
||
body: { code: 'PROMPT_WIZARD_WRITE_RETIRED' },
|
||
});
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', available::text, (v6_retired_at IS NOT NULL)::text)
|
||
FROM tk_prompt_engine_settings
|
||
WHERE singleton_id = 1;
|
||
`)).toBe('false|true');
|
||
expect(postgresRollbackScriptFailure(`
|
||
UPDATE tk_prompt_engine_settings
|
||
SET v6_retired_at = NULL
|
||
WHERE singleton_id = 1;
|
||
`)).toContain('v6_retired_at is immutable after cutover');
|
||
} finally {
|
||
await Promise.all([adminA.context.close(), adminB.context.close()]);
|
||
}
|
||
});
|
||
|
||
test('TK 1.0 通过真实 PostgreSQL、Redis、API、worker 与 Fake Provider 生成三案并应用到视频任务', async ({ browser }) => {
|
||
test.setTimeout(180_000);
|
||
const marker = randomUUID();
|
||
const userId = randomUUID();
|
||
const folderId = randomUUID();
|
||
const assetId = randomUUID();
|
||
const objectKey = `fullstack/tk-prompt-engine/${assetId}.png`;
|
||
const isolatedWorkerName = `${composeProject}-tk-prompt-${marker.slice(0, 8)}`;
|
||
const testAccount = {
|
||
email: `tk-prompt-engine-${marker}@lindyson.test`,
|
||
password: primaryAccount.password,
|
||
};
|
||
let isolatedWorkerId = '';
|
||
let mainWorkerStopped = false;
|
||
let admin: Awaited<ReturnType<typeof createAuthenticatedPage>> | undefined;
|
||
let session: Awaited<ReturnType<typeof createAuthenticatedPage>> | undefined;
|
||
let originalPromptEngineState: {
|
||
tkAvailable: boolean;
|
||
tkV6RetiredAt: string | null;
|
||
tkUpdatedBy: string | null;
|
||
tkUpdatedAt: string;
|
||
legacyAvailable: boolean;
|
||
legacyUpdatedAt: string;
|
||
} | undefined;
|
||
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name)
|
||
SELECT '${userId}', '${testAccount.email}', password_hash, 'TK Prompt Engine 全栈专用用户'
|
||
FROM users WHERE email = '${primaryAccount.email}';
|
||
|
||
INSERT INTO asset_folders (id, user_id, name, source, is_system)
|
||
VALUES ('${folderId}', '${userId}', 'TK Prompt Engine 全栈商品图', 'uploaded-image', false);
|
||
|
||
INSERT INTO assets (
|
||
id, user_id, folder_id, name, type, source, object_key, size_bytes,
|
||
deletion_status, thumbnail_status, created_at, updated_at
|
||
) VALUES (
|
||
'${assetId}', '${userId}', '${folderId}', '全栈商品结构.png', 'image', 'uploaded-image',
|
||
'${objectKey}', 1024, 'active', 'pending', now(), now()
|
||
);
|
||
`);
|
||
writeApiStorageObject(
|
||
objectKey,
|
||
readFileSync('src/imports/alipay-logo.png').toString('base64'),
|
||
);
|
||
|
||
try {
|
||
admin = await createAuthenticatedPage(browser, primaryAccount);
|
||
session = await createAuthenticatedPage(browser, testAccount);
|
||
originalPromptEngineState = JSON.parse(postgresScalar(`
|
||
SELECT json_build_object(
|
||
'tkAvailable', tk.available,
|
||
'tkV6RetiredAt', tk.v6_retired_at,
|
||
'tkUpdatedBy', tk.updated_by,
|
||
'tkUpdatedAt', tk.updated_at,
|
||
'legacyAvailable', legacy.available,
|
||
'legacyUpdatedAt', legacy.updated_at
|
||
)::text
|
||
FROM tk_prompt_engine_settings tk
|
||
CROSS JOIN prompt_wizard_settings legacy
|
||
WHERE tk.singleton_id = 1 AND legacy.singleton_id = 1;
|
||
`));
|
||
|
||
const seededCredits = await api(admin.page, '/api/admin/credits/adjust', {
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': `fullstack-tk-credit-${marker}` },
|
||
body: {
|
||
userId,
|
||
mainDelta: 0,
|
||
addonDelta: 100,
|
||
note: '全栈测试:验证 TK 方案来源创建视频任务',
|
||
},
|
||
});
|
||
expect(seededCredits.status).toBe(200);
|
||
|
||
if (!originalPromptEngineState.tkAvailable) {
|
||
// 单跑时执行正式的一次性切流;与前一管理用例共跑时墓碑已存在且 TK
|
||
// 已回滚关闭,测试 setup 只在同一锁顺序下临时重开 TK,绝不清空墓碑。
|
||
postgres(`
|
||
BEGIN;
|
||
SELECT pg_advisory_xact_lock(hashtextextended('prompt-engine-v1-cutover', 0));
|
||
SELECT singleton_id FROM prompt_wizard_settings WHERE singleton_id = 1 FOR UPDATE;
|
||
SELECT singleton_id FROM tk_prompt_engine_settings WHERE singleton_id = 1 FOR UPDATE;
|
||
UPDATE tk_prompt_engine_settings
|
||
SET available = true,
|
||
v6_retired_at = COALESCE(v6_retired_at, clock_timestamp()),
|
||
updated_at = clock_timestamp()
|
||
WHERE singleton_id = 1 AND available = false;
|
||
UPDATE prompt_wizard_settings
|
||
SET available = false, updated_at = clock_timestamp()
|
||
WHERE singleton_id = 1 AND engine_version = 4;
|
||
COMMIT;
|
||
`);
|
||
}
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', tk.available::text,
|
||
(tk.v6_retired_at IS NOT NULL)::text, legacy.available::text)
|
||
FROM tk_prompt_engine_settings tk
|
||
CROSS JOIN prompt_wizard_settings legacy
|
||
WHERE tk.singleton_id = 1 AND legacy.singleton_id = 1;
|
||
`)).toBe('true|true|false');
|
||
|
||
// 常规 worker 没有测试 Provider 注入入口;先停掉它,避免它抢占 TK job 后
|
||
// 尝试真实 Ark。隔离 worker 使用同一生产镜像、数据库和 Redis 队列。
|
||
dockerCompose('stop', 'worker');
|
||
mainWorkerStopped = true;
|
||
isolatedWorkerId = dockerComposeOutput(
|
||
'run', '--detach', '--no-deps', '--name', isolatedWorkerName,
|
||
'worker', 'node', '--input-type=module', '--eval', tkPromptFakeWorkerScript(),
|
||
).trim();
|
||
expect(isolatedWorkerId).not.toBe('');
|
||
|
||
const settings = await api(session.page, '/api/ai/tk-prompt-engine/settings');
|
||
expect(settings.status).toBe(200);
|
||
expect(settings.body.settings).toMatchObject({ schemaVersion: 1, available: true });
|
||
|
||
const createdRun = await api(session.page, '/api/ai/tk-prompt-engine/runs', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-tk-run-${marker}`,
|
||
imageAssetIds: [assetId],
|
||
},
|
||
});
|
||
expect(createdRun.status).toBe(202);
|
||
expect(createdRun.body.run).toMatchObject({
|
||
schemaVersion: 1,
|
||
status: 'analyzing',
|
||
imageAssetIds: [assetId],
|
||
});
|
||
|
||
await expect.poll(async () => {
|
||
const current = await api(
|
||
session!.page,
|
||
`/api/ai/tk-prompt-engine/runs/${createdRun.body.run.id}`,
|
||
);
|
||
expect(current.status).toBe(200);
|
||
if (current.body.run.status === 'failed') {
|
||
throw new Error(`TK 事实分析失败:${current.body.run.failureReason ?? '未知原因'}`);
|
||
}
|
||
return current.body.run.status;
|
||
}, { timeout: 45_000, intervals: [250, 500, 1_000] }).toBe('ready');
|
||
const analyzed = await api(
|
||
session.page,
|
||
`/api/ai/tk-prompt-engine/runs/${createdRun.body.run.id}`,
|
||
);
|
||
expect(analyzed.body.run.evidenceProfile).toMatchObject({
|
||
schemaVersion: 1,
|
||
productIdentity: { name: '全栈测试商品', category: '家居用品', confidence: 'high' },
|
||
visibleFacts: [{ factId: 'visible-001', sourceImageAssetIds: [assetId] }],
|
||
demonstrableActions: [{
|
||
actionId: 'action-001',
|
||
supportingFactIds: ['visible-001'],
|
||
sourceImageAssetIds: [assetId],
|
||
}],
|
||
});
|
||
|
||
const generationRequest = await api(
|
||
session.page,
|
||
`/api/ai/tk-prompt-engine/runs/${createdRun.body.run.id}/generations`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-tk-generation-${marker}`,
|
||
expectedGenerationCredits: Number(settings.body.settings.generationCredits),
|
||
evidenceReview: {
|
||
productName: '全栈测试商品',
|
||
category: '家居用品',
|
||
rejectedVisibleFactIds: [],
|
||
userConfirmedFacts: [],
|
||
forbiddenClaims: ['不得虚构功效、销量或认证'],
|
||
},
|
||
creativeInput: {
|
||
targetMarket: 'United States',
|
||
targetAudience: '重视清晰商品演示的成年用户',
|
||
verifiedOffer: '',
|
||
appearance: { mode: 'auto' },
|
||
availableScenes: ['真实家居台面'],
|
||
availableAssets: ['商品参考图', '双手'],
|
||
},
|
||
videoConfig: {
|
||
model: 'seedance-2.0',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
},
|
||
},
|
||
},
|
||
);
|
||
expect(generationRequest.status).toBe(202);
|
||
expect(generationRequest.body.generation).toMatchObject({
|
||
status: 'queued',
|
||
isFree: true,
|
||
credits: 0,
|
||
creditStatus: 'none',
|
||
});
|
||
|
||
await expect.poll(async () => {
|
||
const current = await api(
|
||
session!.page,
|
||
`/api/ai/tk-prompt-engine/generations/${generationRequest.body.generation.id}`,
|
||
);
|
||
expect(current.status).toBe(200);
|
||
if (current.body.generation.status === 'failed') {
|
||
throw new Error(`TK 三方案生成失败:${current.body.generation.failureReason ?? '未知原因'}`);
|
||
}
|
||
return current.body.generation.status;
|
||
}, { timeout: 60_000, intervals: [250, 500, 1_000] }).toBe('completed');
|
||
const completedGeneration = (
|
||
await api(
|
||
session.page,
|
||
`/api/ai/tk-prompt-engine/generations/${generationRequest.body.generation.id}`,
|
||
)
|
||
).body.generation;
|
||
expect(completedGeneration.options.map((option: { slot: string }) => option.slot)).toEqual([
|
||
'safe', 'native', 'creative',
|
||
]);
|
||
expect(completedGeneration.options.every((option: {
|
||
status: string;
|
||
plan: { audioMode: string; executionPrompt: string } | null;
|
||
}) => (
|
||
option.status === 'completed'
|
||
&& option.plan?.audioMode === 'sound-design-only'
|
||
&& option.plan.executionPrompt.includes('@图片1')
|
||
))).toBe(true);
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|', run.status, generation.status,
|
||
count(option.id)::text,
|
||
count(option.id) FILTER (WHERE option.status = 'completed')::text)
|
||
FROM tk_prompt_runs run
|
||
JOIN tk_prompt_generations generation ON generation.run_id = run.id
|
||
JOIN tk_prompt_options option ON option.generation_id = generation.id
|
||
WHERE run.id = '${createdRun.body.run.id}'::uuid
|
||
GROUP BY run.status, generation.status;
|
||
`)).toBe('ready|completed|3|3');
|
||
|
||
const resumed = await api(session.page, '/api/ai/tk-prompt-engine/resume');
|
||
expect(resumed.status).toBe(200);
|
||
expect(resumed.body.run.id).toBe(createdRun.body.run.id);
|
||
expect(resumed.body.generation.id).toBe(completedGeneration.id);
|
||
expect(resumed.body.generation.options).toHaveLength(3);
|
||
|
||
// TK 链路已完成后恢复常规 worker,继续验证服务端会按持久化原稿、
|
||
// 视频配置和商品图片来源重新校验并写入规范化 tkPromptSource。
|
||
execFileSync('docker', ['rm', '--force', isolatedWorkerId], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: 'inherit',
|
||
});
|
||
isolatedWorkerId = '';
|
||
dockerCompose('start', 'worker');
|
||
mainWorkerStopped = false;
|
||
|
||
const selectedOption = completedGeneration.options.find(
|
||
(option: { slot: string }) => option.slot === 'safe',
|
||
);
|
||
expect(selectedOption?.plan).toBeTruthy();
|
||
const createdVideo = await api(session.page, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-tk-video-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt: selectedOption.plan.executionPrompt,
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
generateAudio: false,
|
||
},
|
||
references: [{ assetId, role: 'reference_image' }],
|
||
tkPromptSource: {
|
||
runId: createdRun.body.run.id,
|
||
generationId: completedGeneration.id,
|
||
optionId: selectedOption.id,
|
||
schemaVersion: 1,
|
||
// 必须由服务端以持久化原稿重算,客户端值不能成为真源。
|
||
editBucket: 'material',
|
||
},
|
||
}],
|
||
},
|
||
});
|
||
expect(createdVideo.status, JSON.stringify(createdVideo.body)).toBe(200);
|
||
expect(createdVideo.body.tasks).toHaveLength(1);
|
||
const videoTask = createdVideo.body.tasks[0];
|
||
expect(videoTask).toMatchObject({ status: 'queued', creditStatus: 'held' });
|
||
expect(postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
tk_prompt_source->>'runId',
|
||
tk_prompt_source->>'generationId',
|
||
tk_prompt_source->>'optionId',
|
||
tk_prompt_source->>'schemaVersion',
|
||
tk_prompt_source->>'editBucket')
|
||
FROM generation_tasks WHERE id = '${videoTask.id}'::uuid;
|
||
`)).toBe([
|
||
createdRun.body.run.id,
|
||
completedGeneration.id,
|
||
selectedOption.id,
|
||
'1',
|
||
'none',
|
||
].join('|'));
|
||
} finally {
|
||
if (isolatedWorkerId) {
|
||
execFileSync('docker', ['rm', '--force', isolatedWorkerId], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: 'inherit',
|
||
});
|
||
}
|
||
if (mainWorkerStopped) dockerCompose('start', 'worker');
|
||
if (originalPromptEngineState) {
|
||
// v6_retired_at 的不可逆性属于生产业务边界;fullstack teardown 需要恢复
|
||
// grep 单跑前的数据库夹具,故只在一次性测试库会话中临时禁用用户触发器。
|
||
postgres(`
|
||
SET session_replication_role = replica;
|
||
UPDATE tk_prompt_engine_settings
|
||
SET available = ${originalPromptEngineState.tkAvailable},
|
||
v6_retired_at = ${originalPromptEngineState.tkV6RetiredAt
|
||
? `'${originalPromptEngineState.tkV6RetiredAt}'::timestamptz`
|
||
: 'NULL'},
|
||
updated_by = ${originalPromptEngineState.tkUpdatedBy
|
||
? `'${originalPromptEngineState.tkUpdatedBy}'::uuid`
|
||
: 'NULL'},
|
||
updated_at = '${originalPromptEngineState.tkUpdatedAt}'::timestamptz
|
||
WHERE singleton_id = 1;
|
||
SET session_replication_role = origin;
|
||
UPDATE prompt_wizard_settings
|
||
SET available = ${originalPromptEngineState.legacyAvailable},
|
||
updated_at = '${originalPromptEngineState.legacyUpdatedAt}'::timestamptz
|
||
WHERE singleton_id = 1;
|
||
`);
|
||
}
|
||
await Promise.all([
|
||
session?.context.close(),
|
||
admin?.context.close(),
|
||
]);
|
||
removeApiStorageObject(objectKey);
|
||
}
|
||
});
|
||
|
||
function tkPromptFakeWorkerScript() {
|
||
return String.raw`
|
||
import { startTkPromptEngineWorker } from '/app/apps/api/dist/worker/tkPromptEngineWorker.js';
|
||
|
||
const usage = (operation, planId = null) => ({
|
||
operation,
|
||
planId,
|
||
responseId: null,
|
||
model: 'fake-tk-prompt-engine',
|
||
attempts: 1,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
});
|
||
|
||
const strategies = [{
|
||
slot: 'safe',
|
||
labelZh: '最稳',
|
||
objective: 'conversion',
|
||
styleForm: 'scene',
|
||
style: 'scene',
|
||
videoMode: 'scene_demo',
|
||
hookPatternId: 'pain_strike',
|
||
firstFrameId: 'pain_reveal',
|
||
firstFrame: '首帧呈现真实使用不便',
|
||
openingAction: '操作手部立即指向商品可见结构',
|
||
openingLine: '',
|
||
relevanceBeat: '真实台面中的不便与商品动作连续发生',
|
||
productBridge: '沿同一操作进入商品结构自证',
|
||
buyingMotivationId: 'problem_relief',
|
||
buyingMotivation: '缓解真实使用不便',
|
||
proofMethodId: 'visible_operation',
|
||
proofMethod: '现场完成一次可见操作',
|
||
ctaStrategy: '邀请查看当前商品详情',
|
||
requiredMaterials: [],
|
||
allowedFactIds: ['visible-001'],
|
||
}, {
|
||
slot: 'native',
|
||
labelZh: '最原生',
|
||
objective: 'trust',
|
||
styleForm: 'product',
|
||
style: 'product_pov',
|
||
videoMode: 'product_closeup',
|
||
hookPatternId: 'sound_hook',
|
||
firstFrameId: 'sound_sync',
|
||
firstFrame: '商品操作拟音与微距动作同步',
|
||
openingAction: '操作手部按下可见结构并触发卡扣声',
|
||
openingLine: '',
|
||
relevanceBeat: '熟悉的操作声音对应真实使用瞬间',
|
||
productBridge: '音画同步落到结构特写',
|
||
buyingMotivationId: 'confidence',
|
||
buyingMotivation: '通过可见细节建立使用信心',
|
||
proofMethodId: 'structure_closeup',
|
||
proofMethod: '微距展示结构和操作关系',
|
||
ctaStrategy: '邀请用户近看商品细节',
|
||
requiredMaterials: [],
|
||
allowedFactIds: ['visible-001'],
|
||
}, {
|
||
slot: 'creative',
|
||
labelZh: '最抓眼',
|
||
objective: 'attention',
|
||
styleForm: 'drama',
|
||
style: 'drama',
|
||
videoMode: 'scene_demo',
|
||
hookPatternId: 'unexpected',
|
||
firstFrameId: 'structure_reveal',
|
||
firstFrame: '首帧直接执行商品关键动作',
|
||
openingAction: '操作手部直接启动并连续完成可见动作',
|
||
openingLine: '',
|
||
relevanceBeat: '意外动作立刻连接真实使用环境',
|
||
productBridge: '继续同一动作完成结构证明',
|
||
buyingMotivationId: 'curiosity',
|
||
buyingMotivation: '通过结构揭晓满足好奇心',
|
||
proofMethodId: 'context_demo',
|
||
proofMethod: '在真实台面完成场景演示',
|
||
ctaStrategy: '邀请查看商品完整结构',
|
||
requiredMaterials: [],
|
||
allowedFactIds: ['visible-001'],
|
||
}];
|
||
|
||
function optionCandidate(factPack) {
|
||
const fact = factPack.facts.find(item => item.factId === 'visible-001');
|
||
const action = factPack.demonstrableActions.find(item => item.actionId === 'action-001');
|
||
if (!fact || !action) throw new Error('Fake TK Provider 缺少稳定事实或动作');
|
||
return {
|
||
title: '商品结构自证',
|
||
whyItFits: '使用图片可见结构和连续操作建立信任',
|
||
coreInformationPoints: [{
|
||
messageId: 'message-001',
|
||
message: fact.text,
|
||
factIds: [fact.factId],
|
||
}],
|
||
primaryActionId: action.actionId,
|
||
beats: [[0, 1, 'start'], [1, 3, 'progress'], [3, 5, 'complete']].map(
|
||
([startSeconds, endSeconds, actionPhase]) => ({
|
||
startSeconds,
|
||
endSeconds,
|
||
environment: '真实家居台面',
|
||
subject: '商品可见结构与操作手部',
|
||
action: action.text,
|
||
shotSize: '微距特写',
|
||
coreInformationPointIds: ['message-001'],
|
||
actionPhase,
|
||
productImageIndex: 1,
|
||
presenterVisible: false,
|
||
movementId: 'slow_push_in',
|
||
lighting: '柔和真实室内光',
|
||
editingCue: '在商品动作完成点自然切镜',
|
||
soundDesign: '真实商品操作拟音与轻微环境声',
|
||
voiceoverTarget: '',
|
||
voiceoverZh: '',
|
||
factIds: [fact.factId],
|
||
}),
|
||
),
|
||
assumptions: [],
|
||
requiredMaterials: [],
|
||
warnings: [],
|
||
};
|
||
}
|
||
|
||
const provider = {
|
||
async analyzeFacts({ images }) {
|
||
if (!Array.isArray(images) || images.length !== 1) {
|
||
throw new Error('Fake TK Provider 只接受当前测试的一张商品图');
|
||
}
|
||
return {
|
||
value: {
|
||
productName: '全栈测试商品',
|
||
category: '家居用品',
|
||
confidence: 'high',
|
||
visibleFacts: [{
|
||
text: '商品表面有可按压的可见结构',
|
||
sourceImageIndexes: [0],
|
||
confidence: 'high',
|
||
}],
|
||
internalHypotheses: [],
|
||
demonstrableActions: [{
|
||
actionId: 'action-001',
|
||
text: '操作手部连续按下商品可见结构',
|
||
supportingFactIndexes: [0],
|
||
}],
|
||
},
|
||
usage: usage('facts'),
|
||
};
|
||
},
|
||
async planStrategies() {
|
||
return { value: { strategies }, usage: usage('strategies') };
|
||
},
|
||
async generateOption({ factPack, strategy }) {
|
||
return { value: optionCandidate(factPack), usage: usage('option', strategy.slot) };
|
||
},
|
||
async repairOption({ factPack, strategy }) {
|
||
return { value: optionCandidate(factPack), usage: usage('option-repair', strategy.slot) };
|
||
},
|
||
};
|
||
|
||
const worker = startTkPromptEngineWorker({ provider });
|
||
const shutdown = async () => {
|
||
await worker.close();
|
||
process.exit(0);
|
||
};
|
||
process.once('SIGTERM', () => { void shutdown(); });
|
||
process.once('SIGINT', () => { void shutdown(); });
|
||
await new Promise(() => undefined);
|
||
`;
|
||
}
|
||
|
||
async function activateRealPortraitActorForFullstack(
|
||
creatorPage: Page,
|
||
subjectPage: Page,
|
||
marker: string,
|
||
alias: string,
|
||
) {
|
||
const created = await api(creatorPage, '/api/real-portrait-actors', {
|
||
method: 'POST',
|
||
body: { alias },
|
||
});
|
||
expect(created.status).toBe(200);
|
||
const actorId = String(created.body.actor.id);
|
||
|
||
const activation = await api(
|
||
creatorPage,
|
||
`/api/real-portrait-actors/${actorId}/activation-requests`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
purpose: `全栈测试:${alias}经本人同意后用于商业 AI 视频生成`,
|
||
commercialUse: true,
|
||
portraitNoticeVersion: 'fullstack-portrait-consent-v1',
|
||
sensitiveInfoNoticeVersion: 'fullstack-sensitive-face-v1',
|
||
},
|
||
},
|
||
);
|
||
expect(activation.status).toBe(200);
|
||
const invitationUrl = new URL(String(activation.body.activation.invitationUrl));
|
||
const inviteToken = new URLSearchParams(invitationUrl.hash.slice(1)).get('token');
|
||
expect(inviteToken).toBeTruthy();
|
||
|
||
const activated = await completeRealPortraitInvitationForFullstack(
|
||
creatorPage,
|
||
subjectPage,
|
||
actorId,
|
||
String(inviteToken),
|
||
marker,
|
||
);
|
||
|
||
return {
|
||
actorId,
|
||
assetId: activated.assetId,
|
||
inviteToken: String(inviteToken),
|
||
manageUrl: activated.manageUrl,
|
||
};
|
||
}
|
||
|
||
async function completeRealPortraitInvitationForFullstack(
|
||
creatorPage: Page,
|
||
subjectPage: Page,
|
||
actorId: string,
|
||
inviteToken: string,
|
||
marker: string,
|
||
) {
|
||
const consent = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
accepted: true,
|
||
adultConfirmed: true,
|
||
portraitConsent: true,
|
||
sensitiveFaceConsent: true,
|
||
commercialUseConsent: true,
|
||
authorizationDays: 90,
|
||
},
|
||
},
|
||
);
|
||
expect(consent.status).toBe(200);
|
||
|
||
const verification = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}/start-verification`,
|
||
{ method: 'POST' },
|
||
);
|
||
expect(verification.status).toBe(200);
|
||
const verificationUrl = new URL(String(verification.body.verification.verificationUrl));
|
||
const verificationFragment = new URLSearchParams(verificationUrl.hash.slice(1));
|
||
const callbackUrl = new URL(String(verificationFragment.get('callbackUrl')));
|
||
const bytedToken = verificationFragment.get('bytedToken');
|
||
expect(bytedToken).toBeTruthy();
|
||
|
||
callbackUrl.searchParams.set('bytedToken', String(bytedToken));
|
||
callbackUrl.searchParams.set('resultCode', '10000');
|
||
callbackUrl.searchParams.set('algorithmBaseRespCode', '0');
|
||
callbackUrl.searchParams.set('reqMeasureInfoValue', '1');
|
||
callbackUrl.searchParams.set('verify_type', 'real_time');
|
||
const callbackNavigation = await subjectPage.goto(callbackUrl.toString(), {
|
||
waitUntil: 'domcontentloaded',
|
||
});
|
||
expect(callbackNavigation?.status()).toBe(200);
|
||
expect(new URL(subjectPage.url()).pathname).toBe('/portrait-consent/verification-complete');
|
||
await expect(subjectPage.getByRole('heading', { name: '本人认证已完成' })).toBeVisible();
|
||
|
||
await expect.poll(async () => {
|
||
const status = await api(
|
||
subjectPage,
|
||
`/api/public/real-portrait-invites/${encodeURIComponent(String(inviteToken))}/status`,
|
||
);
|
||
expect(status.status).toBe(200);
|
||
return status.body.status;
|
||
}, {
|
||
timeout: 10_000,
|
||
intervals: [100, 250, 500],
|
||
}).toBe('awaiting_upload');
|
||
|
||
const jpeg = await sharp({
|
||
create: {
|
||
width: 512,
|
||
height: 512,
|
||
channels: 3,
|
||
background: { r: 111, g: 132, b: 153 },
|
||
},
|
||
}).jpeg({ quality: 90 }).toBuffer();
|
||
const upload = await api(
|
||
creatorPage,
|
||
`/api/real-portrait-actors/${actorId}/uploads`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
kind: 'primary',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: jpeg.byteLength,
|
||
},
|
||
},
|
||
);
|
||
expect(upload.status).toBe(200);
|
||
expect(await uploadSignedBytes(
|
||
creatorPage,
|
||
upload.body.upload.signature,
|
||
jpeg.toString('base64'),
|
||
)).toBe(204);
|
||
|
||
const completedUpload = await api(
|
||
creatorPage,
|
||
`/api/real-portrait-uploads/${upload.body.upload.id}/complete`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: jpeg.byteLength,
|
||
width: 512,
|
||
height: 512,
|
||
sha256: createHash('sha256').update(jpeg).digest('hex'),
|
||
},
|
||
},
|
||
);
|
||
expect(completedUpload.status).toBe(200);
|
||
const assetId = String(completedUpload.body.asset.id);
|
||
|
||
await expect.poll(async () => {
|
||
const actor = await api(creatorPage, `/api/real-portrait-actors/${actorId}`);
|
||
expect(actor.status).toBe(200);
|
||
return actor.body.actor;
|
||
}, {
|
||
timeout: 40_000,
|
||
intervals: [250, 500, 1_000, 2_000],
|
||
}).toMatchObject({
|
||
id: actorId,
|
||
lifecycleStatus: 'active',
|
||
authorizationStatus: 'active',
|
||
assets: [{
|
||
id: assetId,
|
||
kind: 'primary',
|
||
status: 'active',
|
||
consistencyStatus: 'passed',
|
||
}],
|
||
});
|
||
|
||
return {
|
||
assetId,
|
||
manageUrl: String(consent.body.manageUrl),
|
||
};
|
||
}
|
||
|
||
async function generateWithRealPortraitForFullstack(
|
||
creatorPage: Page,
|
||
actorId: string,
|
||
marker: string,
|
||
prompt: string,
|
||
) {
|
||
const creditsBefore = await readCredits(creatorPage);
|
||
const generated = await api(creatorPage, '/api/ai/video-tasks/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
idempotencyKey: `fullstack-portrait-generation-${marker}`,
|
||
requests: [{
|
||
model: 'Seedance 2.0',
|
||
prompt,
|
||
config: {
|
||
mode: 'general',
|
||
aspectRatio: '9:16',
|
||
resolution: '480p',
|
||
durationSeconds: 5,
|
||
generateAudio: false,
|
||
},
|
||
references: [],
|
||
realPortrait: { actorId },
|
||
}],
|
||
},
|
||
});
|
||
expect(generated.status).toBe(200);
|
||
expect(generated.body.tasks).toHaveLength(1);
|
||
const task = generated.body.tasks[0];
|
||
expect(JSON.stringify(task)).not.toContain('asset://');
|
||
expect(JSON.stringify(task)).not.toContain('fake-group-');
|
||
expect(JSON.stringify(task)).not.toContain('fake-asset-');
|
||
expect(await readCredits(creatorPage)).toBe(creditsBefore - Number(task.credits));
|
||
|
||
await expect.poll(
|
||
async () => (await readTask(creatorPage, task.id)).status,
|
||
{ timeout: 45_000, intervals: [500, 1_000, 2_000] },
|
||
).toBe('completed');
|
||
const completedTask = await readTask(creatorPage, task.id);
|
||
expect(completedTask).toMatchObject({
|
||
status: 'completed',
|
||
creditStatus: 'settled',
|
||
assetId: expect.any(String),
|
||
});
|
||
expect(completedTask.config).not.toHaveProperty('realPortrait');
|
||
expect(JSON.stringify(completedTask)).not.toContain('asset://');
|
||
expect(JSON.stringify(completedTask)).not.toContain('fake-group-');
|
||
expect(JSON.stringify(completedTask)).not.toContain('fake-asset-');
|
||
return { task, completedTask };
|
||
}
|
||
|
||
function seedRealPortraitCapacityFiller(input: {
|
||
userId: string;
|
||
email: string;
|
||
marker: string;
|
||
count: number;
|
||
}) {
|
||
if (!Number.isSafeInteger(input.count) || input.count < 0 || input.count > 45) {
|
||
throw new Error(`真人容量占位数量无效:${input.count}`);
|
||
}
|
||
postgres(`
|
||
INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT CASE WHEN owner_slot = 1 THEN '${input.userId}'::uuid ELSE gen_random_uuid() END,
|
||
CASE
|
||
WHEN owner_slot = 1 THEN '${input.email}'::text
|
||
ELSE 'portrait-capacity-filler-${input.marker}-' || owner_slot::text || '@lindyson.test'
|
||
END,
|
||
source.password_hash,
|
||
'全栈真人容量占位-${input.marker}', plan.id
|
||
FROM users source
|
||
JOIN plans plan ON plan.code = 'enterprise' AND plan.kind = 'main'
|
||
CROSS JOIN generate_series(
|
||
1,
|
||
GREATEST(1, CEIL(${input.count}::numeric / 10)::integer)
|
||
) AS owners(owner_slot)
|
||
WHERE source.email = '${secondaryAccount.email}'
|
||
ORDER BY owner_slot;
|
||
|
||
INSERT INTO real_portrait_actors (
|
||
id, user_id, alias, plan_access_status, lifecycle_status,
|
||
last_successful_generation_at
|
||
)
|
||
SELECT gen_random_uuid(), owner.id,
|
||
'容量占位-${input.marker}-' || slot::text,
|
||
'available', 'active', now()
|
||
FROM generate_series(1, ${input.count}) AS slots(slot)
|
||
JOIN (
|
||
SELECT id,
|
||
row_number() OVER (ORDER BY email)::integer AS owner_slot,
|
||
count(*) OVER()::integer AS owner_count
|
||
FROM users
|
||
WHERE user_name = '全栈真人容量占位-${input.marker}'
|
||
) owner
|
||
ON owner.owner_slot = ((slot - 1) % owner.owner_count) + 1;
|
||
|
||
INSERT INTO real_portrait_authorizations (
|
||
actor_id, version, status, purpose, commercial_use,
|
||
valid_from, valid_until, portrait_notice_version,
|
||
sensitive_info_notice_version, consented_at,
|
||
controller_name, processor_name, privacy_contact, privacy_policy_url,
|
||
processing_scope, retention_rule
|
||
)
|
||
SELECT actor.id, 1, 'active', '全栈测试容量边界既有真人授权', true,
|
||
now(), now() + interval '90 days',
|
||
'fullstack-capacity-filler-v1', 'fullstack-capacity-sensitive-filler-v1', now(),
|
||
'Lindyson 全栈测试主体', '火山方舟测试替身',
|
||
'privacy@lindyson.test', 'https://lindyson.test/privacy',
|
||
'仅用于全栈容量并发测试', '测试结束后立即释放'
|
||
FROM real_portrait_actors actor
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = '全栈真人容量占位-${input.marker}';
|
||
|
||
INSERT INTO real_portrait_materializations (
|
||
actor_id, authorization_id, generation, provider_account_key,
|
||
region, project_name, provider_group_id, status,
|
||
activated_at, last_used_at
|
||
)
|
||
SELECT actor.id, portrait_authorization.id, 1, 'fullstack-capacity-filler',
|
||
'cn-beijing', 'default',
|
||
'fullstack-group-${input.marker}-' || actor.id::text,
|
||
'active', now(), now()
|
||
FROM real_portrait_actors actor
|
||
JOIN real_portrait_authorizations portrait_authorization
|
||
ON portrait_authorization.actor_id = actor.id
|
||
AND portrait_authorization.version = 1
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = '全栈真人容量占位-${input.marker}';
|
||
|
||
INSERT INTO real_portrait_assets (
|
||
actor_id, materialization_id, kind, provider_asset_id,
|
||
status, consistency_status
|
||
)
|
||
SELECT materialization.actor_id, materialization.id, 'primary',
|
||
'fullstack-asset-${input.marker}-' || materialization.actor_id::text,
|
||
'active', 'passed'
|
||
FROM real_portrait_materializations materialization
|
||
JOIN real_portrait_actors actor ON actor.id = materialization.actor_id
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = '全栈真人容量占位-${input.marker}';
|
||
`);
|
||
}
|
||
|
||
function releaseRealPortraitCapacityFiller(userId: string) {
|
||
postgres(`
|
||
DELETE FROM real_portrait_assets
|
||
WHERE actor_id IN (
|
||
SELECT actor.id
|
||
FROM real_portrait_actors actor
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = (
|
||
SELECT user_name FROM users WHERE id = '${userId}'::uuid
|
||
)
|
||
);
|
||
DELETE FROM real_portrait_materializations
|
||
WHERE actor_id IN (
|
||
SELECT actor.id
|
||
FROM real_portrait_actors actor
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = (
|
||
SELECT user_name FROM users WHERE id = '${userId}'::uuid
|
||
)
|
||
);
|
||
DELETE FROM real_portrait_authorizations
|
||
WHERE actor_id IN (
|
||
SELECT actor.id
|
||
FROM real_portrait_actors actor
|
||
JOIN users owner ON owner.id = actor.user_id
|
||
WHERE owner.user_name = (
|
||
SELECT user_name FROM users WHERE id = '${userId}'::uuid
|
||
)
|
||
);
|
||
DELETE FROM real_portrait_actors
|
||
WHERE user_id IN (
|
||
SELECT id
|
||
FROM users
|
||
WHERE user_name = (
|
||
SELECT user_name FROM users WHERE id = '${userId}'::uuid
|
||
)
|
||
);
|
||
DELETE FROM users
|
||
WHERE user_name = (
|
||
SELECT user_name FROM users WHERE id = '${userId}'::uuid
|
||
);
|
||
`);
|
||
}
|
||
|
||
async function createAuthenticatedPage(
|
||
browser: Browser,
|
||
account: { email: string; password: string },
|
||
): Promise<{ context: BrowserContext; page: Page }> {
|
||
// 全栈套件串行复用同一个 Docker/Nginx 来源 IP;隔离测试账号的成功登录不应
|
||
// 因其他用例累计的 IP 桶互相污染。仅清理测试库的 IP 维度,账号限流保持原样。
|
||
postgres(`
|
||
DELETE FROM auth_rate_limit_events
|
||
WHERE scope = 'login:ip';
|
||
`);
|
||
const context = await browser.newContext();
|
||
const page = await context.newPage();
|
||
await page.goto('/');
|
||
const login = await api(page, '/api/auth/login', {
|
||
method: 'POST',
|
||
body: {
|
||
email: account.email,
|
||
password: account.password,
|
||
},
|
||
});
|
||
expect(login.status).toBe(200);
|
||
expect(login.body.user.email).toBe(account.email);
|
||
expect(login.body).not.toHaveProperty('token');
|
||
return { context, page };
|
||
}
|
||
|
||
async function readCredits(page: Page) {
|
||
const result = await api(page, '/api/account/credits');
|
||
expect(result.status).toBe(200);
|
||
return Number(result.body.credits);
|
||
}
|
||
|
||
async function readTask(page: Page, taskId: string) {
|
||
const result = await api(page, `/api/ai/tasks/${taskId}`);
|
||
expect(result.status).toBe(200);
|
||
return result.body.task;
|
||
}
|
||
|
||
async function uploadFullstackImage(page: Page, suffix: string) {
|
||
const base64 = readFileSync('src/imports/alipay-logo.png').toString('base64');
|
||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||
const signatureResult = await api(page, '/api/assets/upload-signature', {
|
||
method: 'POST',
|
||
body: {
|
||
filename: `${suffix}.png`,
|
||
contentType: 'image/png',
|
||
sizeBytes,
|
||
source: 'uploaded-image',
|
||
folderId: null,
|
||
idempotencyKey: `fullstack-upload-${suffix}`,
|
||
},
|
||
});
|
||
expect(signatureResult.status).toBe(200);
|
||
const signature = signatureResult.body.signature;
|
||
expect(signature).toMatchObject({ driver: 'local', method: 'PUT' });
|
||
expect(await uploadSignedBytes(page, signature, base64)).toBe(204);
|
||
|
||
const completed = await api(page, '/api/assets/complete-upload', {
|
||
method: 'POST',
|
||
body: { uploadSessionId: signature.sessionId },
|
||
});
|
||
expect(completed.status).toBe(200);
|
||
await expect.poll(async () => {
|
||
const current = await api(page, `/api/assets/upload-sessions/${signature.sessionId}`);
|
||
if (['failed', 'canceled', 'expired'].includes(current.body.session.status)) {
|
||
throw new Error(`Seedream 参考图上传异常:${JSON.stringify(current.body.session)}`);
|
||
}
|
||
return current.body.session.status;
|
||
}, { timeout: 30_000, intervals: [200, 500, 1_000] }).toBe('completed');
|
||
const finalized = await api(page, `/api/assets/upload-sessions/${signature.sessionId}`);
|
||
expect(finalized.body.session.asset).toMatchObject({ type: 'image', source: 'uploaded-image' });
|
||
return finalized.body.session.asset;
|
||
}
|
||
|
||
async function waitForPromptWizardRun(page: Page, runId: string) {
|
||
await expect.poll(async () => {
|
||
const result = await api(page, `/api/ai/prompt-wizard/runs/${runId}`);
|
||
expect(result.status).toBe(200);
|
||
if (result.body.run.status === 'failed') {
|
||
throw new Error(`提示词向导产品识别失败:${result.body.run.failureReason ?? '未知原因'}`);
|
||
}
|
||
return result.body.run.status;
|
||
}, {
|
||
timeout: 45_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toBe('ready');
|
||
const result = await api(page, `/api/ai/prompt-wizard/runs/${runId}`);
|
||
expect(result.status).toBe(200);
|
||
return result.body.run;
|
||
}
|
||
|
||
async function waitForPromptWizardUseDirectionSet(page: Page, useDirectionSetId: string) {
|
||
await expect.poll(async () => {
|
||
const result = await api(page, `/api/ai/prompt-wizard/use-direction-sets/${useDirectionSetId}`);
|
||
expect(result.status).toBe(200);
|
||
if (result.body.useDirectionSet.status === 'failed') {
|
||
throw new Error(`提示词使用方向失败:${result.body.useDirectionSet.failureReason ?? '未知原因'}`);
|
||
}
|
||
if (result.body.useDirectionSet.status === 'needs-input') {
|
||
throw new Error(`提示词使用方向需要补充信息:${result.body.useDirectionSet.clarificationQuestion ?? '未知原因'}`);
|
||
}
|
||
return result.body.useDirectionSet.status;
|
||
}, {
|
||
timeout: 45_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toBe('completed');
|
||
const result = await api(page, `/api/ai/prompt-wizard/use-direction-sets/${useDirectionSetId}`);
|
||
expect(result.status).toBe(200);
|
||
return result.body.useDirectionSet;
|
||
}
|
||
|
||
async function waitForPromptWizardBatch(page: Page, batchId: string) {
|
||
await expect.poll(async () => {
|
||
const result = await api(page, `/api/ai/prompt-wizard/batches/${batchId}`);
|
||
expect(result.status).toBe(200);
|
||
if (result.body.batch.status === 'failed') {
|
||
const workerLogs = dockerComposeOutput('logs', '--tail', '120', 'worker');
|
||
throw new Error(
|
||
`提示词方案批次失败:${result.body.batch.failureReason ?? '未知原因'}\n${workerLogs}`,
|
||
);
|
||
}
|
||
return result.body.batch.status;
|
||
}, {
|
||
// 3.0 首轮双方案及最多两次按索引内容修复共用该等待窗口;全栈环境
|
||
// 为验证中途终止恢复能力把 Fake Provider 单次延迟固定为 8 秒。
|
||
timeout: 60_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toBe('completed');
|
||
const result = await api(page, `/api/ai/prompt-wizard/batches/${batchId}`);
|
||
expect(result.status).toBe(200);
|
||
return result.body.batch;
|
||
}
|
||
|
||
async function waitForPromptWizardPlanRevision(page: Page, revisionId: string) {
|
||
await expect.poll(async () => {
|
||
const result = await api(page, `/api/ai/prompt-wizard/plan-revisions/${revisionId}`);
|
||
expect(result.status).toBe(200);
|
||
if (result.body.revision.status === 'failed') {
|
||
throw new Error(`提示词单方案微调失败:${result.body.revision.failureReason ?? '未知原因'}`);
|
||
}
|
||
return result.body.revision.status;
|
||
}, {
|
||
timeout: 45_000,
|
||
intervals: [250, 500, 1_000],
|
||
}).toBe('completed');
|
||
const result = await api(page, `/api/ai/prompt-wizard/plan-revisions/${revisionId}`);
|
||
expect(result.status).toBe(200);
|
||
return result.body.revision;
|
||
}
|
||
|
||
function readGenerationTaskClaimEpoch(taskId: string) {
|
||
const rawClaimEpoch = postgresScalar(`
|
||
SELECT claim_epoch::text
|
||
FROM generation_tasks
|
||
WHERE id = '${taskId}'::uuid;
|
||
`);
|
||
const claimEpoch = Number(rawClaimEpoch);
|
||
if (rawClaimEpoch === '' || !Number.isSafeInteger(claimEpoch)) {
|
||
throw new Error(`无法读取生成任务 ${taskId} 的 claim_epoch`);
|
||
}
|
||
return claimEpoch;
|
||
}
|
||
|
||
function readGenerationSubmissionCheckpoint(taskId: string) {
|
||
const raw = postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
task.status,
|
||
task.processing_stage,
|
||
coalesce(task.provider_task_id, '-'),
|
||
coalesce(run.status, '-'),
|
||
coalesce(run.provider_task_id, '-'),
|
||
(task.provider_result IS NOT NULL)::text,
|
||
(task.asset_id IS NOT NULL)::text
|
||
)
|
||
FROM generation_tasks task
|
||
LEFT JOIN generation_provider_runs run ON run.id = task.active_provider_run_id
|
||
WHERE task.id = '${taskId}'::uuid;
|
||
`);
|
||
if (!raw) return null;
|
||
const [
|
||
status,
|
||
processingStage,
|
||
providerTaskId,
|
||
providerRunStatus,
|
||
providerRunTaskId,
|
||
hasProviderResult,
|
||
hasAsset,
|
||
] = raw.split('|');
|
||
return {
|
||
status,
|
||
processingStage,
|
||
providerTaskId,
|
||
providerRunStatus,
|
||
providerRunTaskId,
|
||
hasProviderResult: hasProviderResult === 'true',
|
||
hasAsset: hasAsset === 'true',
|
||
};
|
||
}
|
||
|
||
function readGenerationSettlementCheckpoint(taskId: string) {
|
||
const raw = postgresScalar(`
|
||
SELECT concat_ws('|',
|
||
task.status,
|
||
task.processing_stage,
|
||
coalesce(run.status, '-'),
|
||
(task.provider_result IS NOT NULL)::text,
|
||
(run.provider_result IS NOT NULL)::text,
|
||
(position('FULLSTACK_SETTLEMENT_FAILURE' IN coalesce(task.settlement_error, '')) > 0)::text,
|
||
(task.asset_id IS NOT NULL)::text
|
||
)
|
||
FROM generation_tasks task
|
||
LEFT JOIN generation_provider_runs run ON run.id = task.active_provider_run_id
|
||
WHERE task.id = '${taskId}'::uuid;
|
||
`);
|
||
if (!raw) return null;
|
||
const [
|
||
status,
|
||
processingStage,
|
||
providerRunStatus,
|
||
hasTaskProviderResult,
|
||
hasProviderRunResult,
|
||
hasSettlementError,
|
||
hasAsset,
|
||
] = raw.split('|');
|
||
return {
|
||
status,
|
||
processingStage,
|
||
providerRunStatus,
|
||
hasTaskProviderResult: hasTaskProviderResult === 'true',
|
||
hasProviderRunResult: hasProviderRunResult === 'true',
|
||
hasSettlementError: hasSettlementError === 'true',
|
||
hasAsset: hasAsset === 'true',
|
||
};
|
||
}
|
||
|
||
async function fetchStatus(page: Page, path: string) {
|
||
return page.evaluate(async url => {
|
||
const response = await fetch(url, { credentials: 'include' });
|
||
return response.status;
|
||
}, path);
|
||
}
|
||
|
||
async function uploadSignedBytes(
|
||
page: Page,
|
||
signature: { uploadUrl: string; headers: Record<string, string> },
|
||
base64: string,
|
||
) {
|
||
return page.evaluate(async ({ signature, base64 }) => {
|
||
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
|
||
const response = await fetch(signature.uploadUrl, {
|
||
method: 'PUT',
|
||
headers: signature.headers,
|
||
body: bytes,
|
||
credentials: 'include',
|
||
});
|
||
return response.status;
|
||
}, { signature, base64 });
|
||
}
|
||
|
||
function dockerCompose(...args: string[]) {
|
||
execFileSync('docker', ['compose', '-p', composeProject, ...args], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: 'inherit',
|
||
});
|
||
}
|
||
|
||
function dockerComposeOutput(...args: string[]) {
|
||
return execFileSync('docker', ['compose', '-p', composeProject, ...args], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function dockerComposeFailureOutput(...args: string[]) {
|
||
const result = spawnSync('docker', ['compose', '-p', composeProject, ...args], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
encoding: 'utf8',
|
||
});
|
||
if (result.status === 0) {
|
||
throw new Error(`预期 docker compose ${args.join(' ')} 失败,但命令成功`);
|
||
}
|
||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||
}
|
||
|
||
async function holdPostgresAdvisoryLock(lockKey: string) {
|
||
const applicationName = `fullstack-lock-${lockKey.slice(-24)}`;
|
||
const readyPrefix = 'FULLSTACK_LOCK_READY:';
|
||
const child = spawn('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY', '-e', `PGAPPNAME=${applicationName}`,
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-A', '-t', '-U', 'lindyson', '-d', 'lindyson',
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let resolveReady: ((pid: number) => void) | undefined;
|
||
let rejectReady: ((error: Error) => void) | undefined;
|
||
const ready = new Promise<number>((resolve, reject) => {
|
||
resolveReady = resolve;
|
||
rejectReady = reject;
|
||
});
|
||
const closed = new Promise<void>(resolve => {
|
||
child.once('close', () => resolve());
|
||
});
|
||
child.stdout.on('data', chunk => {
|
||
stdout += String(chunk);
|
||
const match = stdout.match(new RegExp(`${readyPrefix}(\\d+)`));
|
||
if (match?.[1]) resolveReady?.(Number(match[1]));
|
||
});
|
||
child.stderr.on('data', chunk => { stderr += String(chunk); });
|
||
child.once('error', error => rejectReady?.(error));
|
||
child.once('exit', code => {
|
||
if (!stdout.includes(readyPrefix)) {
|
||
rejectReady?.(new Error(`PostgreSQL 锁持有进程提前退出(${code ?? 'unknown'}):${stderr}`));
|
||
}
|
||
});
|
||
child.stdin.end(`
|
||
BEGIN;
|
||
SELECT pg_advisory_xact_lock(hashtextextended('${lockKey}', 0));
|
||
SELECT '${readyPrefix}' || pg_backend_pid()::text;
|
||
SELECT pg_sleep(600);
|
||
ROLLBACK;
|
||
`);
|
||
|
||
const timeout = new Promise<never>((_resolve, reject) => {
|
||
setTimeout(() => reject(new Error('PostgreSQL advisory lock 在 10 秒内未就绪')), 10_000);
|
||
});
|
||
try {
|
||
await Promise.race([ready, timeout]);
|
||
} catch (error) {
|
||
postgres(`
|
||
SELECT pg_terminate_backend(pid)
|
||
FROM pg_stat_activity
|
||
WHERE application_name = '${applicationName}' AND pid <> pg_backend_pid();
|
||
`);
|
||
throw error;
|
||
}
|
||
|
||
let released = false;
|
||
return {
|
||
async release() {
|
||
if (released) return;
|
||
released = true;
|
||
postgres(`
|
||
SELECT pg_terminate_backend(pid)
|
||
FROM pg_stat_activity
|
||
WHERE application_name = '${applicationName}' AND pid <> pg_backend_pid();
|
||
`);
|
||
await Promise.race([
|
||
closed,
|
||
new Promise<void>(resolve => setTimeout(() => {
|
||
child.kill('SIGTERM');
|
||
resolve();
|
||
}, 5_000)),
|
||
]);
|
||
},
|
||
};
|
||
}
|
||
|
||
function parseLastJsonLine(output: string) {
|
||
const line = output.trim().split('\n').reverse().find(candidate => candidate.trim().startsWith('{'));
|
||
if (!line) throw new Error(`命令输出不含 JSON:${output}`);
|
||
return JSON.parse(line) as unknown;
|
||
}
|
||
|
||
function writeApiStorageObject(objectKey: string, base64: string) {
|
||
dockerCompose(
|
||
'exec', '--no-TTY', 'api',
|
||
'node', '--input-type=commonjs', '-e',
|
||
`const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const target = path.join('/app/storage', process.argv[1]);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.writeFileSync(target, Buffer.from(process.argv[2], 'base64'));`,
|
||
objectKey,
|
||
base64,
|
||
);
|
||
}
|
||
|
||
function removeApiStorageObject(objectKey: string) {
|
||
dockerCompose(
|
||
'exec', '--no-TTY', 'api',
|
||
'node', '--input-type=commonjs', '-e',
|
||
`const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
fs.rmSync(path.join('/app/storage', process.argv[1]), { force: true });`,
|
||
objectKey,
|
||
);
|
||
}
|
||
|
||
function postgres(sql: string) {
|
||
dockerCompose(
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', 'lindyson', '-c', sql,
|
||
);
|
||
}
|
||
|
||
function postgresScript(sql: string) {
|
||
execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', 'lindyson',
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: `BEGIN;\n${sql}\nCOMMIT;\n`,
|
||
stdio: ['pipe', 'inherit', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function restorePromptWizardV6SchemaAfterHistoricalMigrationReplay() {
|
||
const migration = readFileSync(
|
||
new URL('../../apps/api/migrations/071_prompt_wizard_v6_three_routes.sql', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const migrationBodyMarker = '-- 新任务只允许引擎 4;历史 2/3 标记保持不可变。';
|
||
const migrationBodyIndex = migration.indexOf(migrationBodyMarker);
|
||
if (migrationBodyIndex < 0) {
|
||
throw new Error('Migration 071 测试恢复起点不存在');
|
||
}
|
||
|
||
// 054/055/056/059 的“历史重放”会在当前主库中重建旧约束。测试结束后
|
||
// 只重放 071 已通过门禁后的目标 DDL,避免旧迁移夹具泄漏到后续用例。
|
||
postgresScript(migration.slice(migrationBodyIndex));
|
||
}
|
||
|
||
function postgresDatabase(database: string, sql: string) {
|
||
execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', database, '-c', sql,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
stdio: ['ignore', 'inherit', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function postgresDatabaseMigrationSequence(
|
||
database: string,
|
||
migrations: Array<{ id: string; sql: string }>,
|
||
) {
|
||
execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', database,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: [
|
||
`BEGIN;
|
||
CREATE TABLE schema_migrations (
|
||
id text PRIMARY KEY,
|
||
applied_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
COMMIT;\n`,
|
||
...migrations.map(({ id, sql }) => (
|
||
`BEGIN;\n${sql}\nINSERT INTO schema_migrations (id) VALUES ('${id}');\nCOMMIT;\n`
|
||
)),
|
||
].join(''),
|
||
stdio: ['pipe', 'inherit', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function postgresDatabaseScript(database: string, sql: string) {
|
||
execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', database,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: `BEGIN;\n${sql}\nCOMMIT;\n`,
|
||
stdio: ['pipe', 'inherit', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function postgresDatabaseScriptFailure(database: string, sql: string) {
|
||
const result = spawnSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', database,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: `BEGIN;\n${sql}\nCOMMIT;\n`,
|
||
encoding: 'utf8',
|
||
});
|
||
if (result.status === 0) {
|
||
throw new Error(`数据库 ${database} 上的预期失败迁移意外成功`);
|
||
}
|
||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||
}
|
||
|
||
function postgresDatabaseScalar(database: string, sql: string) {
|
||
return execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-A', '-t', '-U', 'lindyson', '-d', database, '-c', sql,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
encoding: 'utf8',
|
||
}).trim();
|
||
}
|
||
|
||
function postgresRollbackScript(sql: string) {
|
||
execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', 'lindyson',
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: `BEGIN;\n${sql}\nROLLBACK;\n`,
|
||
stdio: ['pipe', 'inherit', 'inherit'],
|
||
});
|
||
}
|
||
|
||
function postgresRollbackScriptFailure(sql: string) {
|
||
const result = spawnSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-U', 'lindyson', '-d', 'lindyson',
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
input: `BEGIN;\n${sql}\nROLLBACK;\n`,
|
||
encoding: 'utf8',
|
||
});
|
||
if (result.status === 0) {
|
||
throw new Error('主数据库上的预期失败事务意外成功');
|
||
}
|
||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||
}
|
||
|
||
function postgresScalar(sql: string) {
|
||
return execFileSync('docker', [
|
||
'compose', '-p', composeProject,
|
||
'exec', '--no-TTY',
|
||
'postgres',
|
||
'psql', '-v', 'ON_ERROR_STOP=1', '-A', '-t', '-U', 'lindyson', '-d', 'lindyson', '-c', sql,
|
||
], {
|
||
cwd: process.cwd(),
|
||
env: process.env,
|
||
encoding: 'utf8',
|
||
}).trim();
|
||
}
|
||
|
||
function postgresExplainExecutionMs(sql: string) {
|
||
return postgresExplain(sql).executionMs;
|
||
}
|
||
|
||
function postgresExplain(sql: string) {
|
||
const output = postgresScalar(`SET jit = off; EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`);
|
||
const start = output.indexOf('[');
|
||
const end = output.lastIndexOf(']');
|
||
if (start < 0 || end < start) throw new Error(`无法解析 PostgreSQL EXPLAIN JSON:${output}`);
|
||
const plans = JSON.parse(output.slice(start, end + 1)) as Array<Record<string, unknown>>;
|
||
const executionMs = Number(plans[0]?.['Execution Time']);
|
||
if (!Number.isFinite(executionMs)) throw new Error(`EXPLAIN 缺少执行耗时:${output}`);
|
||
const plan = plans[0]?.Plan;
|
||
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
||
throw new Error(`EXPLAIN 缺少执行计划:${output}`);
|
||
}
|
||
return { executionMs, plan: plan as Record<string, unknown> };
|
||
}
|
||
|
||
function collectExplainIndexNames(plan: Record<string, unknown>) {
|
||
const names = new Set<string>();
|
||
const visit = (value: unknown) => {
|
||
if (Array.isArray(value)) {
|
||
value.forEach(visit);
|
||
return;
|
||
}
|
||
if (!value || typeof value !== 'object') return;
|
||
const record = value as Record<string, unknown>;
|
||
if (typeof record['Index Name'] === 'string') names.add(record['Index Name']);
|
||
Object.values(record).forEach(visit);
|
||
};
|
||
visit(plan);
|
||
return [...names].sort();
|
||
}
|
||
|
||
function assetIds(items: Array<{ id: string }>) {
|
||
return items.map(item => item.id);
|
||
}
|
||
|
||
function intersection(left: string[], right: string[]) {
|
||
const rightIds = new Set(right);
|
||
return left.filter(id => rightIds.has(id)).sort();
|
||
}
|
||
|
||
function readAssetCursorBoundary(assetId: string) {
|
||
const timestamp = postgresScalar(`
|
||
SELECT created_at::text FROM assets WHERE id = '${assetId}'::uuid;
|
||
`);
|
||
if (!timestamp) throw new Error(`无法读取资产 ${assetId} 的分页时间`);
|
||
return { id: assetId, timestamp };
|
||
}
|
||
|
||
function buildAssetCardExplainSql(input: {
|
||
ownerId: string;
|
||
folderId: string;
|
||
order: 'ASC' | 'DESC';
|
||
comparison?: '<' | '>';
|
||
boundary?: { id: string; timestamp: string };
|
||
includeTotalCount?: boolean;
|
||
}) {
|
||
const cursorCondition = input.comparison && input.boundary
|
||
? `AND (created_at, id) ${input.comparison} ('${input.boundary.timestamp.replaceAll("'", "''")}'::timestamptz, '${input.boundary.id}'::uuid)`
|
||
: '';
|
||
return `
|
||
SELECT id, name, type, source, folder_id, object_key, thumbnail_key,
|
||
thumbnail_status, size_bytes, duration_seconds, width, height, views,
|
||
downloaded_at, download_requested_at, created_at, updated_at
|
||
${input.includeTotalCount ? ', count(*) OVER()::bigint AS __pagination_total_count' : ''}
|
||
FROM assets
|
||
WHERE user_id = '${input.ownerId}'::uuid
|
||
AND deletion_status = 'active'
|
||
AND folder_id = '${input.folderId}'::uuid
|
||
${cursorCondition}
|
||
ORDER BY created_at ${input.order}, id ${input.order}
|
||
LIMIT 25
|
||
`;
|
||
}
|
||
|
||
// 与 apiCostReporting.readObservedCoverage 保持同一集合式查询形状;单元测试另行防止退回逐日 LATERAL。
|
||
function buildCoverageBenchmarkSql(asOf: string) {
|
||
const safeAsOf = asOf.replaceAll("'", "''");
|
||
return `
|
||
WITH limits AS (
|
||
SELECT ('${safeAsOf}'::timestamptz AT TIME ZONE 'Asia/Shanghai')::date AS today
|
||
), local_usage_days AS (
|
||
SELECT DISTINCT coalesce(
|
||
local_accounting_date,
|
||
(coalesce(completed_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||
) AS start_date
|
||
FROM provider_usage_records
|
||
WHERE provider = 'volcengine-ark'
|
||
AND model = ANY(ARRAY[
|
||
'Seedance 2.0', 'Seedance 2.0 Fast', 'Seedance 2.0 Mini'
|
||
]::text[])
|
||
AND usage_unit = 'tokens' AND currency = 'CNY'
|
||
AND created_at <= '${safeAsOf}'::timestamptz
|
||
), local_manifest_intervals AS (
|
||
SELECT window_start AS start_date, least(window_end, limits.today) AS end_date
|
||
FROM provider_reconciliation_runs, limits
|
||
WHERE provider = 'volcengine-ark'
|
||
AND local_cutoff <= '${safeAsOf}'::timestamptz
|
||
AND window_start <= limits.today
|
||
), local_intervals AS (
|
||
SELECT start_date, start_date AS end_date FROM local_usage_days
|
||
UNION
|
||
SELECT start_date, end_date FROM local_manifest_intervals
|
||
), evidenced_bill_runs AS (
|
||
SELECT sync_run_id AS id FROM provider_bill_sync_run_items
|
||
UNION
|
||
SELECT official_sync_run_id AS id FROM provider_reconciliation_runs
|
||
WHERE official_sync_run_id IS NOT NULL
|
||
), latest_manifests AS (
|
||
SELECT DISTINCT ON (official_sync_run_id) official_sync_run_id, bill_state
|
||
FROM provider_reconciliation_runs
|
||
WHERE official_sync_run_id IS NOT NULL
|
||
ORDER BY official_sync_run_id, created_at DESC, id DESC
|
||
), valid_bill_runs AS (
|
||
SELECT runs.id, runs.window_start, least(runs.window_end, limits.today) AS window_end,
|
||
runs.completed_at, runs.is_final,
|
||
coalesce(
|
||
manifests.bill_state,
|
||
CASE WHEN runs.is_final THEN 'final' ELSE 'provisional' END
|
||
) AS bill_state
|
||
FROM provider_billing_sync_runs runs
|
||
JOIN evidenced_bill_runs evidence ON evidence.id = runs.id
|
||
LEFT JOIN latest_manifests manifests ON manifests.official_sync_run_id = runs.id
|
||
CROSS JOIN limits
|
||
WHERE runs.provider = 'volcengine-ark'
|
||
AND runs.kind = 'bill' AND runs.status = 'success'
|
||
AND runs.completed_at <= '${safeAsOf}'::timestamptz
|
||
AND runs.window_start <= limits.today
|
||
), official_run_day_candidates AS (
|
||
SELECT days.day::date AS day, runs.id, runs.completed_at,
|
||
runs.is_final, runs.bill_state,
|
||
row_number() OVER (
|
||
PARTITION BY days.day::date
|
||
ORDER BY runs.completed_at DESC, runs.id DESC
|
||
) AS run_rank
|
||
FROM valid_bill_runs runs
|
||
CROSS JOIN generate_series(
|
||
runs.window_start, runs.window_end, interval '1 day'
|
||
) days(day)
|
||
), official_run_days AS (
|
||
SELECT day AS start_date, day AS end_date, is_final, bill_state
|
||
FROM official_run_day_candidates
|
||
WHERE run_rank = 1
|
||
), official_detail_days AS (
|
||
SELECT DISTINCT details.expense_date AS start_date,
|
||
details.expense_date AS end_date, details.is_final,
|
||
CASE WHEN details.is_final THEN 'final' ELSE 'provisional' END AS bill_state
|
||
FROM provider_bill_details details
|
||
CROSS JOIN limits
|
||
WHERE details.provider = 'volcengine-ark'
|
||
AND details.product = 'ark_bd' AND details.active = true
|
||
AND details.currency = 'CNY'
|
||
AND details.recognized_model = ANY(ARRAY[
|
||
'Seedance 2.0', 'Seedance 2.0 Fast', 'Seedance 2.0 Mini'
|
||
]::text[])
|
||
AND details.bill_line_type = 'usage'
|
||
AND details.synced_at <= '${safeAsOf}'::timestamptz
|
||
AND details.expense_date <= limits.today
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM official_run_days run_days
|
||
WHERE run_days.start_date = details.expense_date
|
||
)
|
||
), official_intervals AS (
|
||
SELECT * FROM official_run_days
|
||
UNION
|
||
SELECT * FROM official_detail_days
|
||
)
|
||
SELECT (SELECT min(start_date) FROM local_intervals) AS local_start,
|
||
(SELECT max(end_date) FROM local_intervals) AS local_end,
|
||
(SELECT coalesce(
|
||
jsonb_agg(
|
||
jsonb_build_object('start', start_date, 'end', end_date)
|
||
ORDER BY start_date, end_date
|
||
),
|
||
'[]'::jsonb
|
||
) FROM local_intervals) AS local_intervals,
|
||
(SELECT min(start_date) FROM official_intervals) AS official_start,
|
||
(SELECT max(end_date) FROM official_intervals) AS official_end,
|
||
(SELECT coalesce(
|
||
jsonb_agg(
|
||
jsonb_build_object(
|
||
'start', start_date, 'end', end_date,
|
||
'isFinal', is_final, 'billState', bill_state
|
||
)
|
||
ORDER BY start_date, end_date
|
||
),
|
||
'[]'::jsonb
|
||
) FROM official_intervals) AS official_intervals
|
||
`;
|
||
}
|
||
|
||
function removeSettlementFailureTrigger() {
|
||
postgres(`
|
||
DROP TRIGGER IF EXISTS fullstack_reject_generated_asset ON assets;
|
||
DROP FUNCTION IF EXISTS fullstack_reject_generated_asset();
|
||
`);
|
||
}
|
||
|
||
function parseCnyMicros(value: string) {
|
||
expect(value).toMatch(/^-?\d+\.\d{6}$/);
|
||
return BigInt(value.replace('.', ''));
|
||
}
|
||
|
||
async function api(
|
||
page: Page,
|
||
path: string,
|
||
options: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
||
) {
|
||
return page.evaluate(async ({ path, options }) => {
|
||
const response = await fetch(path, {
|
||
method: options.method ?? 'GET',
|
||
headers: {
|
||
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
||
...options.headers,
|
||
},
|
||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||
credentials: 'include',
|
||
});
|
||
const text = await response.text();
|
||
return {
|
||
status: response.status,
|
||
body: text ? JSON.parse(text) : null,
|
||
idempotencyReplayed: response.headers.get('Idempotency-Replayed'),
|
||
};
|
||
}, { path, options });
|
||
}
|
||
|
||
async function timedApi(page: Page, path: string) {
|
||
return page.evaluate(async url => {
|
||
const startedAt = performance.now();
|
||
const response = await fetch(url, { credentials: 'include' });
|
||
await response.arrayBuffer();
|
||
return { status: response.status, elapsedMs: performance.now() - startedAt };
|
||
}, path);
|
||
}
|
||
|
||
function percentile(values: number[], ratio: number) {
|
||
const ordered = [...values].sort((left, right) => left - right);
|
||
return ordered[Math.max(0, Math.ceil(ordered.length * ratio) - 1)] ?? 0;
|
||
}
|
||
|
||
test('真人缩略图使用真实存储、归属鉴权、幂等上传和失效删除队列', async ({ browser }) => {
|
||
test.setTimeout(120_000);
|
||
const migration = readFileSync(new URL('../../apps/api/migrations/088_real_portrait_thumbnails.sql', import.meta.url), 'utf8');
|
||
postgresScript(`${migration}\n${migration}`);
|
||
const marker = randomUUID();
|
||
const account = { id: randomUUID(), email: `portrait-thumbnail-${marker}@lindyson.test`, password: secondaryAccount.password };
|
||
postgres(`INSERT INTO users (id, email, password_hash, user_name, plan_id)
|
||
SELECT '${account.id}', '${account.email}', password_hash, '缩略图测试', NULL::uuid
|
||
FROM users WHERE email = '${secondaryAccount.email}';`);
|
||
const creator = await createAuthenticatedPage(browser, account);
|
||
const outsider = await createAuthenticatedPage(browser, primaryAccount);
|
||
const subjectContext = await browser.newContext();
|
||
const subjectPage = await subjectContext.newPage();
|
||
await subjectPage.goto('/');
|
||
try {
|
||
const { actorId, assetId } = await activateRealPortraitActorForFullstack(creator.page, subjectPage, marker, '缩略图演员');
|
||
const url = `/api/real-portrait-actors/${actorId}/assets/${assetId}/thumbnail`;
|
||
const response = await creator.page.request.get(url);
|
||
expect(response.status()).toBe(200);
|
||
expect(response.headers()['cache-control']).toBe('private, no-store');
|
||
expect(await sharp(await response.body()).metadata()).toMatchObject({ format: 'webp', width: 160, height: 160 });
|
||
expect(await fetchStatus(outsider.page, url)).toBe(404);
|
||
expect(await fetchStatus(subjectPage, url)).toBe(401);
|
||
const beforeKey = postgresScalar(`SELECT thumbnail_object_key FROM real_portrait_assets WHERE id = '${assetId}'`);
|
||
const upload = JSON.parse(postgresScalar(`SELECT row_to_json(s)::text FROM real_portrait_upload_sessions s WHERE asset_id = '${assetId}'`));
|
||
const replay = await api(creator.page, `/api/real-portrait-uploads/${upload.id}/complete`, {
|
||
method: 'POST', body: { mimeType: upload.mime_type, sizeBytes: Number(upload.size_bytes), width: upload.width, height: upload.height, sha256: upload.sha256 },
|
||
});
|
||
expect(replay.status).toBe(200);
|
||
expect(replay.body.asset).toMatchObject({ id: assetId, thumbnailAvailable: true });
|
||
expect(postgresScalar(`SELECT thumbnail_object_key FROM real_portrait_assets WHERE id = '${assetId}'`)).toBe(beforeKey);
|
||
|
||
// 在隔离库中逐项验证拒绝读取及清理;每次恢复状态只用于下一项模拟,不写生产数据。
|
||
const report = parseLastJsonLine(dockerComposeOutput('exec', '--no-TTY', '--workdir', '/app/apps/api', 'api', 'node', '--input-type=module', '-e', `
|
||
import { pool } from './dist/db/pool.js';
|
||
import { readRealPortraitThumbnail, scheduleInvalidRealPortraitThumbnailDeletion } from './dist/domain/realPortraitThumbnails.js';
|
||
const actorId = ${JSON.stringify(actorId)}, assetId = ${JSON.stringify(assetId)}, userId = ${JSON.stringify(account.id)};
|
||
const auth = (await pool.query('SELECT auth.* FROM real_portrait_authorizations auth WHERE actor_id=$1 ORDER BY version DESC LIMIT 1', [actorId])).rows[0];
|
||
const cases = [
|
||
["UPDATE real_portrait_assets SET status='failed' WHERE id=$1", assetId],
|
||
["UPDATE real_portrait_assets SET status='deleting' WHERE id=$1", assetId],
|
||
["UPDATE real_portrait_actors SET lifecycle_status='evicted' WHERE id=$1", actorId],
|
||
["UPDATE real_portrait_authorizations SET status='revoked', revoked_at=now() WHERE id=$1", auth.id],
|
||
["UPDATE real_portrait_authorizations SET valid_from=now()-interval '2 days', valid_until=now()-interval '1 day' WHERE id=$1", auth.id],
|
||
["UPDATE real_portrait_authorizations SET thumbnail_storage_allowed=false WHERE id=$1", auth.id],
|
||
["UPDATE real_portrait_upload_sessions SET status='expired' WHERE asset_id=$1", assetId],
|
||
];
|
||
const results=[];
|
||
for (let i=0; i<cases.length; i++) {
|
||
const key = i === 0 ? ${JSON.stringify(beforeKey)} : 'real-portrait-thumbnails/test-' + assetId + '-' + i;
|
||
await pool.query('UPDATE real_portrait_assets SET thumbnail_object_key=$2, thumbnail_ready=true WHERE id=$1', [assetId,key]);
|
||
await pool.query(cases[i][0], [cases[i][1]]);
|
||
let status=200;
|
||
try { await readRealPortraitThumbnail(userId,actorId,assetId); } catch (error) { status=error.statusCode; }
|
||
await scheduleInvalidRealPortraitThumbnailDeletion();
|
||
const cleared=(await pool.query('SELECT thumbnail_object_key IS NULL AND NOT thumbnail_ready AS cleared FROM real_portrait_assets WHERE id=$1',[assetId])).rows[0].cleared;
|
||
const queued=(await pool.query('SELECT count(*)::int AS count FROM object_deletion_outbox WHERE object_key=$1',[key])).rows[0].count;
|
||
results.push({status,cleared,queued});
|
||
await pool.query("UPDATE real_portrait_assets SET status='active' WHERE id=$1",[assetId]);
|
||
await pool.query("UPDATE real_portrait_upload_sessions SET status='active' WHERE asset_id=$1",[assetId]);
|
||
await pool.query("UPDATE real_portrait_actors SET lifecycle_status='active' WHERE id=$1",[actorId]);
|
||
await pool.query("UPDATE real_portrait_authorizations SET status='active', revoked_at=NULL, valid_from=$2, valid_until=$3, thumbnail_storage_allowed=true WHERE id=$1",[auth.id,auth.valid_from,auth.valid_until]);
|
||
}
|
||
console.log(JSON.stringify({ results }));
|
||
await pool.end();
|
||
`));
|
||
expect(report).toEqual({ results: Array.from({ length: 7 }, () => ({ status: 404, cleared: true, queued: 1 })) });
|
||
await expect.poll(() => postgresScalar(`SELECT status FROM object_deletion_outbox WHERE object_key = '${beforeKey}'`), { timeout: 30_000 }).toBe('completed');
|
||
} finally {
|
||
await creator.context.close();
|
||
await outsider.context.close();
|
||
await subjectContext.close();
|
||
}
|
||
});
|