feat(media): A6 任务中心 API、SSE 进度与队列骨架
ci / 质量门禁 (push) Successful in 6m4s

背景:A6-BE-01..05 与 A6-INF-01,打通「提交 → 队列 → SSE 进度 → 完成/失败 → 取消 / 仅重试失败项」闭环,为 B3 / M1 的真实任务提供统一协议。
改动:
- API media 模块:POST/GET /media-tasks、详情、SSE(Last-Event-ID 续推 + 15s 心跳 + 800ms 落库轮询)、取消(只影响未开始项)、仅重试失败项;Idempotency-Key 24h(异步提交 202;同 key 同 body 返回同一任务且不重复审计,不同 body 40902);范围外统一 404;
- 队列骨架:BullMQ 5 九队列(5 组独立启停)、Outbox dispatcher(Redis 锁 + FOR UPDATE SKIP LOCKED + jobId 幂等 + 指数退避/死信)、worker 按 QUEUE 启停、demo 演练处理器(事务 + 行锁 + 取消/重试/幂等)、优雅退出与心跳;
- 监控:GET /api/v1/health/queues、GET /api/v1/metrics(Prometheus 0.0.4)、infra/monitoring 告警规则与接入说明;
- 通用:AuditInterceptor 支持幂等重放 skipAudit(默认行为不变)、AppModule 接线、.env.example 的 QUEUE 语义、bullmq 5.81.5 / worker @prisma/client / worker 覆盖率依赖。
影响面:OpenAPI 新增 6 个端点(openapi:diff 无破坏性变更);API 进程新增 outbox dispatcher 与 metrics/health 路由;worker 新增 BullMQ 消费;未改旧审计导出链路与既有接口。
回滚:回滚提交并停止 worker 新队列消费;task/outbox 新表数据不影响旧功能,可按需保留。
验证:api 单测 257、集成 92(连跑 2 次)、worker 27、shared 197;typecheck / lint / format:check / build / openapi:diff 全绿;本地真实 worker 启用 9 队列心跳,/health/queues、/metrics 实测,/media-tasks 未登录 401。
This commit is contained in:
Stev_Wang committed 2026-11-04 21:52:35 +08:00
1 parent 4a29cf815e
commit 633765f10f
70 files changed
+8674 -45

No files matched your search

@@ -0,0 +1,102 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { SystemConfigService } from '../platform/system-config/system-config.service';
import { estimateTask, MediaTaskEstimator } from './media-task-estimator';
function expectCode(fn: () => unknown, code: ErrorCode): void {
try {
fn();
} catch (error) {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
return;
}
throw new Error('预期抛出 AppException');
}
const CONFIG = { maxItems: 20, costPerItem: 0.02, secondsPerItem: 3 };
describe('任务预估纯计算(R-26:上限 / 单价来自系统配置)', () => {
it('返回条数 / 成本(2 位小数)/ 耗时', () => {
expect(estimateTask('demo', 5, CONFIG)).toEqual({
estimatedItems: 5,
estimatedCost: 0.1,
estimatedSeconds: 15,
});
// 0.02 * 3 = 0.06 不产生浮点尾巴
expect(estimateTask('demo', 3, CONFIG).estimatedCost).toBe(0.06);
expect(estimateTask('demo', 20, CONFIG).estimatedCost).toBe(0.4);
});
it('项数超过配置上限 → 40001(业务上限,不是 schema 硬上限)', () => {
expectCode(() => estimateTask('demo', 21, CONFIG), ErrorCode.PARAM_INVALID);
expectCode(
() => estimateTask('demo', 101, { ...CONFIG, maxItems: 100 }),
ErrorCode.PARAM_INVALID,
);
});
it('非法项数 / 非法配置 → 40001', () => {
expectCode(() => estimateTask('demo', 0, CONFIG), ErrorCode.PARAM_INVALID);
expectCode(() => estimateTask('demo', 2.5, CONFIG), ErrorCode.PARAM_INVALID);
expectCode(() => estimateTask('demo', 1, { ...CONFIG, maxItems: 0 }), ErrorCode.PARAM_INVALID);
expectCode(
() => estimateTask('demo', 1, { ...CONFIG, costPerItem: Number.NaN }),
ErrorCode.PARAM_INVALID,
);
expectCode(
() => estimateTask('demo', 1, { ...CONFIG, costPerItem: -1 }),
ErrorCode.PARAM_INVALID,
);
expectCode(
() => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: 0 }),
ErrorCode.PARAM_INVALID,
);
expectCode(
() => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: Number.POSITIVE_INFINITY }),
ErrorCode.PARAM_INVALID,
);
});
});
describe('MediaTaskEstimator(读 SystemConfigService)', () => {
function estimatorWith(values: readonly number[]): {
estimator: MediaTaskEstimator;
getNumber: ReturnType<typeof vi.fn>;
} {
const getNumber = vi.fn();
for (const value of values) getNumber.mockResolvedValueOnce(value);
const config = { getNumber } as unknown as SystemConfigService;
return { estimator: new MediaTaskEstimator(config), getNumber };
}
it('按配置计算预估', async () => {
const { estimator, getNumber } = estimatorWith([20, 0.02, 3]);
await expect(estimator.estimate('demo', 5)).resolves.toEqual({
estimatedItems: 5,
estimatedCost: 0.1,
estimatedSeconds: 15,
});
expect(getNumber).toHaveBeenNthCalledWith(1, 'task.demo.max_items');
expect(getNumber).toHaveBeenNthCalledWith(2, 'task.estimate.demo_cost_per_item');
expect(getNumber).toHaveBeenNthCalledWith(3, 'task.estimate.demo_seconds_per_item');
});
it('读配置失败(缺预估字段)→ 40001,不允许静默 0', async () => {
const getNumber = vi.fn().mockRejectedValue(new Error('system_configs 不可用'));
const config = { getNumber } as unknown as SystemConfigService;
const estimator = new MediaTaskEstimator(config);
await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({
code: ErrorCode.PARAM_INVALID,
});
});
it('配置值越界(超过上限)→ 40001', async () => {
const { estimator } = estimatorWith([4, 0.02, 3]);
await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({
code: ErrorCode.PARAM_INVALID,
});
});
});