feat(api): upload verify, forbid-overwrite copy, idempotent complete

Task 19: POST /uploads/{id}/renew re-signs the SAME staging key for pending
sessions (uploadEnabled=false -> 409 ALBUM_UPLOAD_DISABLED; non-owner/unknown/
removed-member -> 403 AUTH_FORBIDDEN, indistinguishable per contract) and
slides the session expiry — renew is the documented recovery path for an
expired credential. POST /uploads/{id}/complete verifies the staging object
out-of-transaction first (head: missing / byte-count mismatch / contentType
mismatch — the server-side backstop for presign not signing content-type),
then copyIfAbsent's it to the finals key with forbid-overwrite semantics,
then in ONE transaction updates photo(bytes/uploadedAt/uploaded), claims the
session pending->confirmed via conditional updateMany (concurrent completes:
exactly one winner creates the photo_jobs row + upload.complete audit), and
enqueues QUEUE_PHOTO_PROCESS post-commit (jobId derived from photoId for
dedupe; failure only logs — Task 21 sweep compensates on photo_jobs). Verify
failures are request-level 400s: the session stays pending and retryable.
Repeat complete on a confirmed session re-runs copyIfAbsent (created:false,
staging already cleaned is fine) and returns the current processingState —
no second job/audit/enqueue (A03). GET /uploads lists the caller's own
sessions newest-first with (createdAt,id) keyset pagination and the joined
photo processingState/visibility. Both producer and test Queue instances now
quit their caller-owned ioredis connection (BullMQ does not take ownership),
otherwise jest hangs on open handles.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 23:00:19 +08:00
parent a2a3ccd340
commit f5f1855b58
4 changed files with 850 additions and 14 deletions
+469
View File
@@ -18,6 +18,7 @@ import { Test } from '@nestjs/testing';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { Queue } from 'bullmq';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
@@ -424,3 +425,471 @@ describe('uploads init (e2e)', () => {
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});
// ── Task 19:renew / complete / history / 队列入队 ──
describe('uploads renew & complete & history (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
/** 测试侧独立 Queue 实例(同一 Redis):peek photo-process 队列;beforeAll 清空。 */
let queue: Queue;
/** 自建 ioredis 连接(BullMQ 不接管所有权,close 后须自行断开,否则 jest 挂起)。 */
let queueConnection: import('ioredis').default | null = null;
let ownerId: string;
let memberId: string;
let removedId: string;
let outsiderToken: string;
let ownerToken: string;
let memberToken: string;
let removedToken: string;
let albumId: string;
/** 各用例捕获的会话锚点(clientTaskId → 会话/照片 id)。 */
let r1: { uploadId: string; photoId: string; objectKey: string };
let cBytes: { uploadId: string; photoId: string };
let cHappy: { uploadId: string; photoId: string; objectKey: string };
const initAs = async (
token: string,
clientTaskId: string,
overrides: Record<string, unknown> = {},
): Promise<{ uploadId: string; photoId: string; objectKey: string; credential: { url: string; method: string; headers: Record<string, string> } }> => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.send({
albumId,
clientTaskId,
filename: 'a.jpg',
bytes: 1000,
contentType: 'image/jpeg',
...overrides,
});
expect(res.status).toBe(201);
return res.body;
};
const renew = (token: string, uploadId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/${uploadId}/renew`)
.set('Authorization', `Bearer ${token}`);
const complete = (token: string, uploadId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/${uploadId}/complete`)
.set('Authorization', `Bearer ${token}`);
const listUploads = (token: string, query?: Record<string, string | number>) =>
request(app.getHttpServer())
.get(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
/** 按凭证实写 staging(覆盖默认 content-type 以构造核验失败用例)。 */
const putStaging = async (
credential: { url: string; method: string; headers: Record<string, string> },
bytes: number,
contentType?: string,
): Promise<boolean> => {
const res = await fetch(credential.url, {
method: credential.method,
headers:
contentType === undefined
? credential.headers
: { 'Content-Type': contentType },
body: new Uint8Array(Buffer.alloc(bytes, 7)),
});
return res.ok;
};
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const mkUser = (account: string) =>
prisma.users.create({
data: { account, passwordHash: 'x', displayName: account },
select: { id: true },
});
ownerId = (await mkUser('rc-owner')).id;
memberId = (await mkUser('rc-member')).id;
removedId = (await mkUser('rc-removed')).id;
const outsiderId = (await mkUser('rc-outsider')).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
ownerToken = await mkToken(ownerId);
memberToken = await mkToken(memberId);
removedToken = await mkToken(removedId);
outsiderToken = await mkToken(outsiderId);
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '完成核验相册' },
{ locale: 'en', title: 'Complete' },
],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T02:00:00.000Z',
});
expect(res.status).toBe(201);
albumId = res.body.id as string;
await prisma.album_members.createMany({
data: [
{ albumId, userId: memberId, role: 'collaborator' },
{ albumId, userId: removedId, role: 'collaborator' },
],
});
// 测试侧队列实例:与生产者同一 Redis/队列名(契约常量),先清空历史。
const { QUEUE_PHOTO_PROCESS } = await import('@photo/contracts');
const IORedis = (await import('ioredis')).default;
queueConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queue = new Queue(QUEUE_PHOTO_PROCESS, { connection: queueConnection });
await queue.obliterate({ force: true });
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
await queue.close();
// BullMQ 对外部传入的连接不接管生命周期:queue.close 不 disconnect 它。
await queueConnection?.quit().catch(() => undefined);
});
it('renew → 200 新凭证同 objectKey;会话续期仍 pending', async () => {
const init = await initAs(memberToken, 'R1');
r1 = init;
const res = await renew(memberToken, init.uploadId);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS);
expect(res.body.uploadId).toBe(init.uploadId);
expect(res.body.photoId).toBe(init.photoId);
expect(res.body.objectKey).toBe(init.objectKey);
expect(res.body.credential.method).toBe('PUT');
expect(res.body.credential.headers['Content-Type']).toBe('image/jpeg');
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
// renew 滑动续期会话 expiresAt(契约:凭证过期 → renew 再试)。
expect(session?.expiresAt.getTime()).toBeGreaterThan(Date.now() + 800_000);
});
it('renew 守卫:uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED;非本人/未知会话/被移除成员 → 403', async () => {
// 相册关闭上传:续签被拒(§4.9),owner 亦同(开关是相册级运营状态)。
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: false },
});
try {
const disabled = await renew(memberToken, r1.uploadId);
expect(disabled.status).toBe(409);
expect(disabled.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED);
} finally {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: true },
});
}
// 被移除成员:先建会话(此时仍是成员),移除后 renew/complete 双拦截(A02)。
const removedSession = await initAs(removedToken, 'C-removed');
await prisma.album_members.delete({
where: { albumId_userId: { albumId, userId: removedId } },
});
const removedRenew = await renew(removedToken, removedSession.uploadId);
expect(removedRenew.status).toBe(403);
expect(removedRenew.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const removedComplete = await complete(removedToken, removedSession.uploadId);
expect(removedComplete.status).toBe(403);
expect(removedComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
// 非本人会话与未知会话同码 403(契约:不区分以避免枚举)。
const notOwner = await renew(ownerToken, r1.uploadId);
expect(notOwner.status).toBe(403);
expect(notOwner.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const unknown = await renew(memberToken, 'no-such-upload');
expect(unknown.status).toBe(403);
expect(unknown.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
});
it('complete:未上传 → 400 UPLOAD_VERIFY_FAILED,会话保持 pending 可重试', async () => {
const init = await initAs(memberToken, 'C-before');
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
});
it('complete:字节数不符 → 400;补传正确字节数后 complete 成功(请求级失败不毒化会话)', async () => {
const init = await initAs(memberToken, 'C-bytes');
cBytes = { uploadId: init.uploadId, photoId: init.photoId };
expect(await putStaging(init.credential, 500)).toBe(true); // 申明 1000 实传 500
const mismatch = await complete(memberToken, init.uploadId);
expect(mismatch.status).toBe(400);
expect(mismatch.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
// 重试路径:重传正确字节数(同 staging 键覆盖写)→ complete 通过。
expect(await putStaging(init.credential, 1000)).toBe(true);
const retry = await complete(memberToken, init.uploadId);
expect(retry.status).toBe(200);
expect(retry.body.processingState).toBe('uploaded');
});
it('complete:contentType 不符(presign 不签名 content-type,服务端兜底)→ 400', async () => {
const init = await initAs(memberToken, 'C-type');
// 声明 image/jpeg,实传 application/octet-stream:PUT 成功(签名不覆盖该头),
// complete 时 head().contentType 与申明值比对兜底。
expect(await putStaging(init.credential, 1000, 'application/octet-stream')).toBe(true);
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
});
it('complete 成功 → 200 uploaded;photo/session/photo_jobs/audit 同事务落库;重复 complete 幂等(A03)', async () => {
const init = await initAs(memberToken, 'C-happy');
cHappy = { uploadId: init.uploadId, photoId: init.photoId, objectKey: init.objectKey };
expect(await putStaging(init.credential, 1000)).toBe(true);
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(['photoId', 'processingState']);
expect(res.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' });
// DB 四处一致性。
const photo = await prisma.photos.findUnique({ where: { id: init.photoId } });
expect(photo).toMatchObject({
bytes: 1000,
uploadedAt: expect.any(Date),
processingState: 'uploaded',
visibility: 'unpublished',
});
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('confirmed');
const jobs = await prisma.photo_jobs.findMany({
where: { photoId: init.photoId },
});
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({ type: 'process', state: 'pending' });
const audits = await prisma.audit_logs.findMany({
where: { action: 'upload.complete', resourceId: init.photoId },
});
expect(audits).toHaveLength(1);
expect(audits[0]?.actorId).toBe(memberId);
// 幂等重复 complete:同结果,不产生第二个 job/audit。
const again = await complete(memberToken, init.uploadId);
expect(again.status).toBe(200);
expect(again.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' });
expect(
await prisma.photo_jobs.count({ where: { photoId: init.photoId } }),
).toBe(1);
expect(
await prisma.audit_logs.count({
where: { action: 'upload.complete', resourceId: init.photoId },
}),
).toBe(1);
});
it('受控复制核验:originals 与 staging 并存且字节一致;删除 staging 后 re-complete 仍 200(copyIfAbsent created:false 路径)', async () => {
const { createStorageFromEnv } = await import('@photo/storage');
const storage = createStorageFromEnv();
const photo = await prisma.photos.findUnique({ where: { id: cHappy.photoId } });
expect(photo?.objectKey).toBe(
`photo-live/dev/albums/${albumId}/originals/${cHappy.photoId}.jpg`,
);
const stagingHead = await storage.head(cHappy.objectKey);
const finalHead = await storage.head(photo?.objectKey as string);
expect(stagingHead?.bytes).toBe(1000);
expect(finalHead?.bytes).toBe(1000);
// 手工删除 staging(模拟生命周期清理/超时)后重复 complete:originals 已在,
// copyIfAbsent 幂等返回 created:false,不再依赖 staging 源。
const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
try {
await s3.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: cHappy.objectKey,
}),
);
} finally {
await s3.destroy();
}
expect(await storage.head(cHappy.objectKey)).toBeNull();
const res = await complete(memberToken, cHappy.uploadId);
expect(res.status).toBe(200);
expect(res.body.processingState).toBe('uploaded');
expect((await storage.head(photo?.objectKey as string))?.bytes).toBe(1000);
});
it('GET /uploads:本人会话新在前,photo 状态内嵌,游标翻页,跨用户隔离', async () => {
// 回写互不相同的 createdAt,排序断言与写入时序解耦(同 members 列表先例)。
const order: Array<{ taskId: string; at: string }> = [
{ taskId: 'R1', at: '2026-09-12T08:00:00.000Z' }, // 最旧
{ taskId: 'C-before', at: '2026-09-12T09:00:00.000Z' },
{ taskId: 'C-bytes', at: '2026-09-12T10:00:00.000Z' },
{ taskId: 'C-type', at: '2026-09-12T11:00:00.000Z' },
{ taskId: 'C-happy', at: '2026-09-12T12:00:00.000Z' }, // 最新
];
for (const { taskId, at } of order) {
await prisma.upload_sessions.updateMany({
where: { userId: memberId, clientTaskId: taskId },
data: { createdAt: new Date(at) },
});
}
const page1 = await listUploads(memberToken, { limit: 2 });
expect(page1.status).toBe(200);
expect(
page1.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['C-happy', 'C-type']);
expect(Object.keys(page1.body.items[0]).sort()).toEqual([
'albumId',
'clientTaskId',
'contentType',
'createdAt',
'expectedBytes',
'photo',
'state',
'uploadId',
]);
expect(Object.keys(page1.body.items[0].photo).sort()).toEqual([
'photoId',
'processingState',
'visibility',
]);
// confirmed 会话内嵌服务端进度;pending 会话保持 pending_upload/unpublished。
expect(page1.body.items[0]).toMatchObject({
state: 'confirmed',
albumId,
expectedBytes: 1000,
contentType: 'image/jpeg',
photo: {
photoId: cHappy.photoId,
processingState: 'uploaded',
visibility: 'unpublished',
},
});
expect(page1.body.items[1]).toMatchObject({
state: 'pending',
photo: { processingState: 'pending_upload', visibility: 'unpublished' },
});
expect(page1.body.hasMore).toBe(true);
expect(typeof page1.body.nextCursor).toBe('string');
const page2 = await listUploads(memberToken, {
limit: 2,
cursor: page1.body.nextCursor,
});
expect(page2.status).toBe(200);
expect(
page2.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['C-bytes', 'C-before']);
expect(page2.body.hasMore).toBe(true);
const page3 = await listUploads(memberToken, {
limit: 2,
cursor: page2.body.nextCursor,
});
expect(
page3.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['R1']);
expect(page3.body.hasMore).toBe(false);
expect(page3.body.nextCursor).toBeNull();
// 跨用户隔离:removed(仅有已被拦截的会话)与 outsider 看不到 member 会话。
const outsiderList = await listUploads(outsiderToken);
expect(outsiderList.status).toBe(200);
expect(outsiderList.body.items).toHaveLength(0);
const removedList = await listUploads(removedToken);
expect(removedList.status).toBe(200);
expect(removedList.body.items).toHaveLength(1); // 仅自己的 C-removed
// 非法 cursor → 400。
const bad = await listUploads(memberToken, { cursor: 'garbage' });
expect(bad.status).toBe(400);
expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('BullMQ 入队:complete 后 photo-process 队列恰有一条对应 job(重复 complete 不重复入队)', async () => {
// C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId
// 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。
await new Promise((resolve) => setTimeout(resolve, 200));
const jobs = await queue.getJobs(
['waiting', 'delayed', 'active', 'completed', 'failed'],
0,
-1,
);
const byPhoto = new Map<string, number>();
for (const job of jobs) {
const photoId = (job.data as { photoId?: string }).photoId ?? '(none)';
byPhoto.set(photoId, (byPhoto.get(photoId) ?? 0) + 1);
}
expect(byPhoto.get(cHappy.photoId)).toBe(1);
expect(byPhoto.get(cBytes.photoId)).toBe(1);
// 会话归属成员的全部完成(两次 complete 幂等重复不重复入队)——队列中不
// 存在其他 member 照片之外的 job。
expect(byPhoto.size).toBe(2);
});
});