diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts index ac37219e..8cb6bc69 100644 --- a/apps/api/src/features/catalog/assets.ts +++ b/apps/api/src/features/catalog/assets.ts @@ -13,9 +13,13 @@ interface CatalogAssetRow { fileName: string | null; cdnUrl: string | null; previewUrl: string | null; + previewObjectKey: string | null; + mimeType: string | null; visibility: string; regionId: string | null; subjectId: string | null; + uploadStatus: string; + previewStatus: string; } async function hasActiveMembership(tenantId: string, userId: string) { @@ -79,6 +83,17 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) { return { userId, svip: true }; } +function assetPreviewable(asset: CatalogAssetRow) { + const mimeType = asset.mimeType?.split(';')[0]?.trim().toLowerCase() || ''; + return asset.assetType === 'pdf' || mimeType === 'application/pdf' || asset.assetType === 'image' || mimeType.startsWith('image/'); +} + +function assertPublishedAsset(asset: CatalogAssetRow) { + if (asset.objectKey && asset.uploadStatus !== 'verified') { + throw new HttpError(409, 'Asset upload has not been verified', 'ASSET_UPLOAD_NOT_VERIFIED'); + } +} + export async function assetsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const limit = intParam(ctx, 'limit', 100, 500); @@ -128,6 +143,7 @@ export async function assetsRoute(ctx: RequestContext) { title, category as "categoryLabel", description, file_name as "fileName", preview_url as "previewUrl", mime_type as "mimeType", file_size_bytes as "fileSizeBytes", + upload_status as "uploadStatus", preview_status as "previewStatus", visibility, region_id as "regionId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", entry_id as "entryId", content_node_id as "contentNodeId", @@ -155,8 +171,10 @@ export async function assetDownloadRoute(ctx: RequestContext) { ` select id, asset_type as "assetType", storage_provider as "storageProvider", bucket, object_key as "objectKey", title, file_name as "fileName", - cdn_url as "cdnUrl", preview_url as "previewUrl", visibility, - region_id as "regionId", subject_id as "subjectId" + cdn_url as "cdnUrl", preview_url as "previewUrl", + preview_object_key as "previewObjectKey", mime_type as "mimeType", + visibility, region_id as "regionId", subject_id as "subjectId", + upload_status as "uploadStatus", preview_status as "previewStatus" from public.content_assets where tenant_id = $1 and id = $2 and status = 'active' limit 1 @@ -169,6 +187,7 @@ export async function assetDownloadRoute(ctx: RequestContext) { } const access = await assertAssetAccess(ctx, asset); + assertPublishedAsset(asset); await query( 'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2', [tenantId, assetId], @@ -181,6 +200,8 @@ export async function assetDownloadRoute(ctx: RequestContext) { title: asset.title, fileName: asset.fileName, previewUrl: asset.previewUrl, + uploadStatus: asset.uploadStatus, + previewStatus: asset.previewStatus, visibility: asset.visibility, }, access, @@ -192,6 +213,63 @@ export async function assetDownloadRoute(ctx: RequestContext) { cdnUrl: asset.cdnUrl, fileName: asset.fileName, expiresInSec: 900, + disposition: 'attachment', + }), + }; +} + +export async function assetPreviewRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || ''; + if (!assetId) { + throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD'); + } + + const asset = await queryOne( + ` + select id, asset_type as "assetType", storage_provider as "storageProvider", + bucket, object_key as "objectKey", title, file_name as "fileName", + cdn_url as "cdnUrl", preview_url as "previewUrl", + preview_object_key as "previewObjectKey", mime_type as "mimeType", + visibility, region_id as "regionId", subject_id as "subjectId", + upload_status as "uploadStatus", preview_status as "previewStatus" + from public.content_assets + where tenant_id = $1 and id = $2 and status = 'active' + limit 1 + `, + [tenantId, assetId], + ); + + if (!asset) { + throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND'); + } + if (!assetPreviewable(asset)) { + throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED'); + } + + const access = await assertAssetAccess(ctx, asset); + assertPublishedAsset(asset); + + return { + item: { + id: asset.id, + assetType: asset.assetType, + title: asset.title, + fileName: asset.fileName, + previewUrl: asset.previewUrl, + previewStatus: asset.previewStatus, + visibility: asset.visibility, + }, + access, + preview: await signStorageDownload({ + tenantId, + provider: asset.storageProvider as StorageProviderName, + bucket: asset.bucket, + objectKey: asset.previewObjectKey || asset.objectKey, + cdnUrl: asset.previewUrl || asset.cdnUrl, + fileName: asset.fileName || asset.title || 'preview.pdf', + expiresInSec: 900, + disposition: 'inline', }), }; } diff --git a/apps/api/src/features/catalog/index.ts b/apps/api/src/features/catalog/index.ts index 43815a24..3cebf571 100644 --- a/apps/api/src/features/catalog/index.ts +++ b/apps/api/src/features/catalog/index.ts @@ -1,5 +1,5 @@ import type { RouteDefinition } from '../../core/router.js'; -import { assetDownloadRoute, assetsRoute } from './assets.js'; +import { assetDownloadRoute, assetPreviewRoute, assetsRoute } from './assets.js'; import { collectionQuestionsRoute, contentEntriesRoute, @@ -46,6 +46,7 @@ export const catalogRoutes: RouteDefinition[] = [ ['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute], ['GET', '/api/catalog/assets', assetsRoute], ['GET', '/api/catalog/assets/download', assetDownloadRoute], + ['GET', '/api/catalog/assets/preview', assetPreviewRoute], ['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute], ['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute], ['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute], diff --git a/apps/api/src/features/storage/service.ts b/apps/api/src/features/storage/service.ts index fd669bbd..842e5ad6 100644 --- a/apps/api/src/features/storage/service.ts +++ b/apps/api/src/features/storage/service.ts @@ -5,7 +5,8 @@ import { config } from '../../core/config.js'; import { HttpError } from '../../core/http.js'; export type StorageProviderName = 'external_url' | 'supabase_storage' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'local_dev'; -export type StorageHttpMethod = 'GET' | 'PUT'; +export type StorageHttpMethod = 'GET' | 'PUT' | 'HEAD'; +export type StorageContentDisposition = 'attachment' | 'inline'; export interface AssetLocation { provider: StorageProviderName; @@ -34,6 +35,7 @@ export interface DownloadSignInput { cdnUrl?: string | null; fileName?: string | null; expiresInSec: number; + disposition?: StorageContentDisposition; } export interface SignedStorageUrl { @@ -48,6 +50,30 @@ export interface SignedStorageUrl { signatureMode: string; } +export interface HeadStorageObjectInput { + tenantId: string; + provider: StorageProviderName; + bucket: string | null; + objectKey: string | null; + declaredMimeType?: string | null; + declaredFileSizeBytes?: number | null; + declaredChecksumSha256?: string | null; +} + +export interface StorageObjectMetadata { + provider: StorageProviderName; + bucket: string | null; + objectKey: string | null; + exists: boolean; + sizeBytes: number | null; + mimeType: string | null; + checksumSha256: string | null; + etag: string | null; + lastModified: string | null; + rawHeaders: Record; + verificationSource: string; +} + const REAL_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']); const SUPPORTED_STORAGE_PROVIDERS = new Set([ 'external_url', @@ -156,14 +182,15 @@ function localSignedUrl(input: { objectKey: string | null; method: StorageHttpMethod; expiresInSec: number; + disposition?: StorageContentDisposition; headers?: Record; }): SignedStorageUrl { const expires = expiresAt(input.expiresInSec); const base = config.storagePublicBaseUrl.replace(/\/+$/, ''); const path = `${encodeURIComponent(input.bucket || 'default')}/${encodeURI(input.objectKey || 'missing')}`; const url = base - ? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}` - : `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}`; + ? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}` + : `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}`; return { provider: input.provider, @@ -185,6 +212,7 @@ async function signAliyunOss(input: { mimeType?: string; fileName?: string | null; expiresInSec: number; + disposition?: StorageContentDisposition; }): Promise { requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID'); requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET'); @@ -212,7 +240,7 @@ async function signAliyunOss(input: { } if (input.method === 'GET' && input.fileName) { options.response = { - 'content-disposition': `attachment; filename="${encodeURIComponent(input.fileName)}"`, + 'content-disposition': `${input.disposition || 'attachment'}; filename="${encodeURIComponent(input.fileName)}"`, }; } @@ -259,6 +287,7 @@ function signTencentCos(input: { method: StorageHttpMethod; mimeType?: string; expiresInSec: number; + responseContentDisposition?: string; }): SignedStorageUrl { requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID'); requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY'); @@ -275,10 +304,16 @@ function signTencentCos(input: { const httpHeaders = headerKeys .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`) .join('&'); - const urlParamList = config.tencentCosSecurityToken ? 'x-cos-security-token' : ''; - const httpParameters = config.tencentCosSecurityToken - ? `x-cos-security-token=${encodeURIComponent(config.tencentCosSecurityToken)}` - : ''; + const signedQuery: Record = {}; + if (config.tencentCosSecurityToken) signedQuery['x-cos-security-token'] = config.tencentCosSecurityToken; + if (input.method === 'GET' && input.responseContentDisposition) { + signedQuery['response-content-disposition'] = input.responseContentDisposition; + } + const queryKeys = Object.keys(signedQuery).sort(); + const urlParamList = queryKeys.join(';'); + const httpParameters = queryKeys + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedQuery[key])}`) + .join('&'); const httpString = `${method}\n${pathname}\n${httpParameters}\n${httpHeaders}\n`; const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`; const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime); @@ -291,7 +326,7 @@ function signTencentCos(input: { query.set('q-header-list', headerList); query.set('q-url-param-list', urlParamList); query.set('q-signature', signature); - if (config.tencentCosSecurityToken) query.set('x-cos-security-token', config.tencentCosSecurityToken); + for (const key of queryKeys) query.set(key, signedQuery[key]); const headers: Record = {}; if (input.method === 'PUT' && input.mimeType) headers['content-type'] = input.mimeType; @@ -342,9 +377,10 @@ async function signSupabaseStorageDownload(input: DownloadSignInput): Promise) { + const normalized: Record = {}; + if (headers instanceof Headers) { + headers.forEach((value, key) => { + normalized[key.toLowerCase()] = value; + }); + return normalized; + } + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || value === null) continue; + normalized[key.toLowerCase()] = Array.isArray(value) ? String(value[0] || '') : String(value); + } + return normalized; +} + +function numberHeader(headers: Record, key: string) { + const value = Number(headers[key]); + return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null; +} + +function firstHeader(headers: Record, keys: string[]) { + for (const key of keys) { + const value = headers[key.toLowerCase()]; + if (value) return value; + } + return null; +} + +function cleanEtag(value: string | null) { + return value ? value.replace(/^"+|"+$/g, '') : null; +} + +function metadataFromHeaders(input: { + provider: StorageProviderName; + bucket: string | null; + objectKey: string | null; + headers: Record; + verificationSource: string; +}): StorageObjectMetadata { + const checksumSha256 = firstHeader(input.headers, [ + 'x-oss-meta-sha256', + 'x-oss-meta-checksum-sha256', + 'x-cos-meta-sha256', + 'x-cos-meta-checksum-sha256', + 'x-amz-meta-sha256', + 'x-amz-meta-checksum-sha256', + ]); + + return { + provider: input.provider, + bucket: input.bucket, + objectKey: input.objectKey, + exists: true, + sizeBytes: numberHeader(input.headers, 'content-length'), + mimeType: firstHeader(input.headers, ['content-type']), + checksumSha256: checksumSha256?.trim().toLowerCase() || null, + etag: cleanEtag(firstHeader(input.headers, ['etag'])), + lastModified: firstHeader(input.headers, ['last-modified']), + rawHeaders: input.headers, + verificationSource: input.verificationSource, + }; +} + +async function headAliyunOssObject(input: HeadStorageObjectInput): Promise { + if (!input.bucket || !input.objectKey) { + throw new HttpError(400, 'Aliyun OSS asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED'); + } + requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID'); + requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET'); + requireConfigured(config.aliyunOssRegion || config.aliyunOssEndpoint, 'aliyun_oss', 'ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT'); + + const client = new OSS({ + region: config.aliyunOssRegion || undefined, + endpoint: config.aliyunOssEndpoint || undefined, + accessKeyId: config.aliyunOssAccessKeyId, + accessKeySecret: config.aliyunOssAccessKeySecret, + stsToken: config.aliyunOssStsToken || undefined, + bucket: input.bucket, + internal: config.aliyunOssInternal, + secure: true, + }); + + try { + const response = await client.head(input.objectKey); + const headers = normalizeHeaderMap(response.res?.headers || response); + return metadataFromHeaders({ + provider: 'aliyun_oss', + bucket: input.bucket, + objectKey: input.objectKey, + headers, + verificationSource: 'aliyun-oss-head-object', + }); + } catch (error) { + const status = Number((error as { status?: number; statusCode?: number }).status || (error as { statusCode?: number }).statusCode || 0); + if (status === 404) { + throw new HttpError(409, 'Uploaded object was not found in Aliyun OSS', 'STORAGE_OBJECT_NOT_FOUND'); + } + throw error; + } +} + +async function headTencentCosObject(input: HeadStorageObjectInput): Promise { + if (!input.bucket || !input.objectKey) { + throw new HttpError(400, 'Tencent COS asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED'); + } + const signed = signTencentCos({ + bucket: input.bucket, + objectKey: input.objectKey, + method: 'HEAD', + expiresInSec: 60, + }); + const response = await fetch(signed.url, { method: 'HEAD' }); + if (response.status === 404) { + throw new HttpError(409, 'Uploaded object was not found in Tencent COS', 'STORAGE_OBJECT_NOT_FOUND'); + } + if (!response.ok) { + throw new HttpError(502, `Tencent COS object metadata check failed: ${response.status}`, 'STORAGE_HEAD_FAILED'); + } + return metadataFromHeaders({ + provider: 'tencent_cos', + bucket: input.bucket, + objectKey: input.objectKey, + headers: normalizeHeaderMap(response.headers), + verificationSource: 'tencent-cos-head-object', + }); +} + +async function headSupabaseStorageObject(input: HeadStorageObjectInput): Promise { + if (!input.bucket || !input.objectKey) { + throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED'); + } + const parts = input.objectKey.split('/'); + const name = parts.pop() || ''; + const folder = parts.join('/'); + const response = await supabaseStorageClient().from(input.bucket).list(folder || undefined, { + search: name, + limit: 20, + }); + if (response.error) { + throw new HttpError(502, response.error.message || 'Supabase Storage metadata check failed', 'STORAGE_HEAD_FAILED'); + } + const file = response.data?.find(item => item.name === name); + if (!file) { + throw new HttpError(409, 'Uploaded object was not found in Supabase Storage', 'STORAGE_OBJECT_NOT_FOUND'); + } + const metadata = (file as { metadata?: Record }).metadata || {}; + const headers: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) headers[key.toLowerCase()] = String(value); + } + const size = Number(metadata.size ?? metadata.contentLength); + return { + provider: 'supabase_storage', + bucket: input.bucket, + objectKey: input.objectKey, + exists: true, + sizeBytes: Number.isFinite(size) && size >= 0 ? Math.trunc(size) : null, + mimeType: typeof metadata.mimetype === 'string' ? metadata.mimetype : typeof metadata.contentType === 'string' ? metadata.contentType : null, + checksumSha256: typeof metadata.sha256 === 'string' ? metadata.sha256.trim().toLowerCase() : null, + etag: typeof metadata.eTag === 'string' ? metadata.eTag : typeof metadata.etag === 'string' ? metadata.etag : null, + lastModified: typeof file.updated_at === 'string' ? file.updated_at : null, + rawHeaders: headers, + verificationSource: 'supabase-storage-list-metadata', + }; +} + +export async function headStorageObject(input: HeadStorageObjectInput): Promise { + if (!input.objectKey) { + throw new HttpError(400, 'Asset requires objectKey', 'ASSET_LOCATION_REQUIRED'); + } + const objectKey = validateObjectKey(input.tenantId, input.objectKey); + const fileSizeBytes = input.declaredFileSizeBytes === undefined + ? null + : validateFileSize(input.declaredFileSizeBytes); + const mimeType = input.declaredMimeType ? validateMimeType(input.declaredMimeType) : null; + const checksumSha256 = input.declaredChecksumSha256?.trim().toLowerCase() || null; + + if (input.provider === 'local_dev') { + return { + provider: input.provider, + bucket: input.bucket, + objectKey, + exists: true, + sizeBytes: fileSizeBytes, + mimeType, + checksumSha256, + etag: checksumSha256, + lastModified: new Date().toISOString(), + rawHeaders: {}, + verificationSource: 'local-dev-declared-metadata', + }; + } + if (input.provider === 'aliyun_oss') { + return headAliyunOssObject({ ...input, objectKey }); + } + if (input.provider === 'tencent_cos') { + return headTencentCosObject({ ...input, objectKey }); + } + if (input.provider === 'supabase_storage') { + return headSupabaseStorageObject({ ...input, objectKey }); + } + throw new HttpError(400, `${input.provider} does not support upload confirmation`, 'UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED'); +} diff --git a/apps/api/src/features/tenant-content/assets.ts b/apps/api/src/features/tenant-content/assets.ts index acd5f6d5..e3fd77df 100644 --- a/apps/api/src/features/tenant-content/assets.ts +++ b/apps/api/src/features/tenant-content/assets.ts @@ -9,6 +9,7 @@ import { configuredDefaultStorageBucket, assertWritableLocation, configuredDefaultStorageProvider, + headStorageObject, normalizeStorageProvider, signStorageDownload, signStorageUpload, @@ -22,6 +23,8 @@ const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'l const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev']; const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private']; const ASSET_STATUSES = ['draft', 'active', 'archived']; +const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed']; +const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed']; interface AssetRow { id: string; @@ -30,12 +33,20 @@ interface AssetRow { storageProvider: string; bucket: string | null; objectKey: string | null; + previewObjectKey: string | null; title: string | null; fileName: string | null; cdnUrl: string | null; previewUrl: string | null; + mimeType: string | null; + fileSizeBytes: number | null; + checksumSha256: string | null; visibility: string; status: string; + uploadStatus: string; + verifiedSizeBytes: number | null; + verifiedChecksumSha256: string | null; + previewStatus: string; } function choice(value: unknown, allowed: string[], fallback: string, label: string) { @@ -58,6 +69,89 @@ function safeFileName(fileName: string) { .slice(0, 160) || 'asset'; } +function isManagedObjectProvider(provider: StorageProviderName) { + return provider === 'local_dev' || provider === 'supabase_storage' || provider === 'aliyun_oss' || provider === 'tencent_cos'; +} + +function checksumSha256(value: unknown) { + const raw = nullableString(value); + const checksum = raw ? raw.toLowerCase() : null; + if (!checksum) return null; + if (!/^[a-f0-9]{64}$/.test(checksum)) { + throw new HttpError(400, 'checksumSha256 must be a lowercase hex SHA-256 digest', 'INVALID_CHECKSUM'); + } + return checksum; +} + +function canonicalMime(value: string | null) { + return value?.split(';')[0]?.trim().toLowerCase() || null; +} + +function nullableNumber(value: unknown) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; +} + +function assetPreviewable(asset: Pick) { + const mime = canonicalMime(asset.mimeType); + return asset.assetType === 'pdf' || mime === 'application/pdf' || asset.assetType === 'image' || Boolean(mime?.startsWith('image/')); +} + +async function existingAssetForUpsert(tenantId: string, id: string | null) { + if (!id) return null; + return queryOne( + ` + select id, tenant_id as "tenantId", asset_type as "assetType", + storage_provider as "storageProvider", bucket, object_key as "objectKey", + preview_object_key as "previewObjectKey", title, file_name as "fileName", + cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", + file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + visibility, status, upload_status as "uploadStatus", + verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + preview_status as "previewStatus" + from public.content_assets + where tenant_id = $1 and id = $2 + limit 1 + `, + [tenantId, id], + ); +} + +function sameObjectLocation(existing: AssetRow | null, provider: StorageProviderName, bucket: string | null, objectKey: string | null) { + return Boolean( + existing && + existing.storageProvider === provider && + (existing.bucket || null) === (bucket || null) && + (existing.objectKey || null) === (objectKey || null), + ); +} + +function objectUploadStatus(input: { + existing: AssetRow | null; + provider: StorageProviderName; + bucket: string | null; + objectKey: string | null; + cdnUrl: string | null; +}) { + if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required'; + if (sameObjectLocation(input.existing, input.provider, input.bucket, input.objectKey)) { + return input.existing?.uploadStatus || 'pending'; + } + return 'pending'; +} + +function assertPublishableManagedObject(status: string, uploadStatus: string, provider: StorageProviderName, objectKey: string | null) { + if (status === 'active' && objectKey && isManagedObjectProvider(provider) && uploadStatus !== 'verified') { + throw new HttpError( + 409, + 'Managed storage asset must be confirmed before publishing', + 'ASSET_UPLOAD_CONFIRMATION_REQUIRED', + ); + } +} + async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) { if (!id) return; const row = await queryOne<{ id: string }>( @@ -135,6 +229,12 @@ export async function assetsAdminRoute(ctx: RequestContext) { description, file_name as "fileName", cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + upload_status as "uploadStatus", verified_at as "verifiedAt", + verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + verification_details as "verificationDetails", + preview_object_key as "previewObjectKey", preview_status as "previewStatus", + security_flags as "securityFlags", visibility, is_public as "isPublic", region_id as "regionId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", entry_id as "entryId", content_node_id as "contentNodeId", @@ -165,11 +265,11 @@ export async function upsertAssetRoute(ctx: RequestContext) { 'storageProvider', ) as StorageProviderName; const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility'); - const status = choice(body.status, ASSET_STATUSES, 'active', 'status'); const bucket = nullableString(body.bucket); const objectKey = nullableString(body.objectKey); const cdnUrl = nullableString(body.cdnUrl); const title = requiredString(body, 'title'); + const existing = await existingAssetForUpsert(auth.tenantId, nullableString(body.id)); const regionId = nullableUuid(body.regionId); const subjectId = nullableUuid(body.subjectId); const categoryId = nullableUuid(body.categoryId); @@ -178,10 +278,29 @@ export async function upsertAssetRoute(ctx: RequestContext) { const contentNodeId = nullableUuid(body.contentNodeId); const cleanObjectKey = objectKey ? validateObjectKey(auth.tenantId, objectKey) : null; + const previewObjectKey = nullableString(body.previewObjectKey) + ? validateObjectKey(auth.tenantId, requiredString(body, 'previewObjectKey')) + : null; const mimeType = nullableString(body.mimeType); if (mimeType) validateMimeType(mimeType); const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0)); + const checksum = checksumSha256(body.checksumSha256); assertWritableLocation({ provider: storageProvider, bucket, objectKey: cleanObjectKey, cdnUrl }); + const nextUploadStatus = objectUploadStatus({ + existing, + provider: storageProvider, + bucket, + objectKey: cleanObjectKey, + cdnUrl, + }); + const nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl) + ? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus') + : 'none'; + const statusFallback = cleanObjectKey && isManagedObjectProvider(storageProvider) && nextUploadStatus !== 'verified' + ? 'draft' + : 'active'; + const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status'); + assertPublishableManagedObject(status, nextUploadStatus, storageProvider, cleanObjectKey); if (status === 'active' && !cdnUrl && !objectKey) { throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED'); @@ -199,7 +318,8 @@ export async function upsertAssetRoute(ctx: RequestContext) { insert into public.content_assets ( id, tenant_id, legacy_id, asset_key, asset_type, storage_provider, bucket, object_key, title, category, description, file_name, cdn_url, - preview_url, mime_type, file_size_bytes, checksum_sha256, visibility, + preview_url, mime_type, file_size_bytes, checksum_sha256, + upload_status, preview_object_key, preview_status, visibility, is_public, region_id, subject_id, category_id, node_id, entry_id, content_node_id, status, sort_order, access_rules, metadata, created_by, updated_by, source @@ -208,9 +328,10 @@ export async function upsertAssetRoute(ctx: RequestContext) { coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, - $19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid, - $25::uuid, $26, - $27, $28::jsonb, $29::jsonb, $30, $30, $31 + $19, $20, $21, + $22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid, + $28::uuid, $29, + $30, $31::jsonb, $32::jsonb, $33, $33, $34 ) on conflict (id) do update set legacy_id = excluded.legacy_id, @@ -228,6 +349,37 @@ export async function upsertAssetRoute(ctx: RequestContext) { mime_type = excluded.mime_type, file_size_bytes = excluded.file_size_bytes, checksum_sha256 = excluded.checksum_sha256, + upload_status = excluded.upload_status, + verified_at = case + when public.content_assets.storage_provider = excluded.storage_provider + and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '') + and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '') + then public.content_assets.verified_at + else null + end, + verified_by = case + when public.content_assets.storage_provider = excluded.storage_provider + and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '') + and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '') + then public.content_assets.verified_by + else null + end, + verified_size_bytes = case + when public.content_assets.storage_provider = excluded.storage_provider + and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '') + and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '') + then public.content_assets.verified_size_bytes + else null + end, + verified_checksum_sha256 = case + when public.content_assets.storage_provider = excluded.storage_provider + and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '') + and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '') + then public.content_assets.verified_checksum_sha256 + else null + end, + preview_object_key = excluded.preview_object_key, + preview_status = excluded.preview_status, visibility = excluded.visibility, is_public = excluded.is_public, region_id = excluded.region_id, @@ -250,6 +402,12 @@ export async function upsertAssetRoute(ctx: RequestContext) { description, file_name as "fileName", cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + upload_status as "uploadStatus", verified_at as "verifiedAt", + verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + verification_details as "verificationDetails", + preview_object_key as "previewObjectKey", preview_status as "previewStatus", + security_flags as "securityFlags", visibility, is_public as "isPublic", region_id as "regionId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", entry_id as "entryId", content_node_id as "contentNodeId", @@ -275,7 +433,10 @@ export async function upsertAssetRoute(ctx: RequestContext) { nullableString(body.previewUrl), mimeType, fileSizeBytes, - nullableString(body.checksumSha256), + checksum, + nextUploadStatus, + previewObjectKey, + nextPreviewStatus, visibility, visibility === 'public', regionId, @@ -307,6 +468,153 @@ export async function upsertAssetRoute(ctx: RequestContext) { return { item }; } +export async function confirmAssetUploadRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx); + const assetId = requiredString(body, 'assetId'); + + const asset = await queryOne( + ` + select id, tenant_id as "tenantId", asset_type as "assetType", + storage_provider as "storageProvider", bucket, object_key as "objectKey", + preview_object_key as "previewObjectKey", title, file_name as "fileName", + cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", + file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + visibility, status, upload_status as "uploadStatus", + verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + preview_status as "previewStatus" + from public.content_assets + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, assetId], + ); + + if (!asset) throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND'); + const provider = asset.storageProvider as StorageProviderName; + if (!asset.objectKey || !isManagedObjectProvider(provider)) { + throw new HttpError(400, 'Asset does not require upload confirmation', 'UPLOAD_CONFIRM_NOT_REQUIRED'); + } + + const declaredFileSize = body.fileSizeBytes === undefined + ? asset.fileSizeBytes + : validateFileSize(intValue(body.fileSizeBytes, 0)); + const registeredFileSize = nullableNumber(asset.fileSizeBytes); + const normalizedDeclaredFileSize = nullableNumber(declaredFileSize); + const declaredMimeType = nullableString(body.mimeType) || asset.mimeType; + const declaredChecksum = checksumSha256(body.checksumSha256) || asset.checksumSha256; + + const metadata = await headStorageObject({ + tenantId: auth.tenantId, + provider, + bucket: asset.bucket, + objectKey: asset.objectKey, + declaredFileSizeBytes: normalizedDeclaredFileSize, + declaredMimeType, + declaredChecksumSha256: declaredChecksum, + }); + + const issues: string[] = []; + if (registeredFileSize !== null && body.fileSizeBytes !== undefined && normalizedDeclaredFileSize !== registeredFileSize) { + issues.push('declared_file_size_changed'); + } + if (asset.mimeType && body.mimeType !== undefined && canonicalMime(declaredMimeType) !== canonicalMime(asset.mimeType)) { + issues.push('declared_mime_type_changed'); + } + if (asset.checksumSha256 && body.checksumSha256 !== undefined && declaredChecksum !== asset.checksumSha256) { + issues.push('declared_checksum_changed'); + } + if (normalizedDeclaredFileSize !== null && metadata.sizeBytes !== null && normalizedDeclaredFileSize !== metadata.sizeBytes) { + issues.push('file_size_mismatch'); + } + const expectedMime = declaredMimeType ? canonicalMime(validateMimeType(declaredMimeType)) : null; + const observedMime = canonicalMime(metadata.mimeType); + if (expectedMime && observedMime && expectedMime !== observedMime) { + issues.push('mime_type_mismatch'); + } + if (declaredChecksum && metadata.checksumSha256 && declaredChecksum !== metadata.checksumSha256) { + issues.push('checksum_mismatch'); + } + + const verificationDetails = { + metadata, + declared: { + fileSizeBytes: normalizedDeclaredFileSize, + mimeType: expectedMime, + checksumSha256: declaredChecksum, + }, + issues, + checksumVerified: Boolean(declaredChecksum && metadata.checksumSha256 && declaredChecksum === metadata.checksumSha256), + checksumUnavailable: Boolean(declaredChecksum && !metadata.checksumSha256), + }; + + if (issues.length) { + await query( + ` + update public.content_assets + set upload_status = 'failed', + verification_details = $3::jsonb, + security_flags = jsonb_set(coalesce(security_flags, '{}'::jsonb), '{uploadVerificationFailed}', 'true'::jsonb, true), + updated_by = $4, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId], + ); + throw new HttpError(409, `Upload verification failed: ${issues.join(', ')}`, 'UPLOAD_VERIFICATION_FAILED'); + } + + const publish = body.publish === true; + const item = await queryOne( + ` + update public.content_assets + set upload_status = 'verified', + verified_at = now(), + verified_by = $3, + verified_size_bytes = coalesce($4::bigint, verified_size_bytes), + verified_checksum_sha256 = coalesce($5, verified_checksum_sha256), + file_size_bytes = coalesce(file_size_bytes, $4::bigint), + mime_type = coalesce(mime_type, $6), + checksum_sha256 = coalesce(checksum_sha256, $5), + verification_details = $7::jsonb, + status = case when $8::boolean and status <> 'archived' then 'active' else status end, + updated_by = $3, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, asset_type as "assetType", storage_provider as "storageProvider", + bucket, object_key as "objectKey", title, file_name as "fileName", + mime_type as "mimeType", file_size_bytes as "fileSizeBytes", + checksum_sha256 as "checksumSha256", visibility, status, + upload_status as "uploadStatus", verified_at as "verifiedAt", + verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + verification_details as "verificationDetails" + `, + [ + auth.tenantId, + assetId, + auth.userId, + metadata.sizeBytes ?? normalizedDeclaredFileSize, + metadata.checksumSha256 ?? declaredChecksum, + observedMime || expectedMime, + JSON.stringify(verificationDetails), + publish, + ], + ); + + await recordAssetAudit(auth, 'content.asset.upload_confirmed', assetId, { + provider, + bucket: asset.bucket, + objectKey: asset.objectKey, + publish, + checksumVerified: verificationDetails.checksumVerified, + checksumUnavailable: verificationDetails.checksumUnavailable, + }); + + return { item, metadata, verification: verificationDetails }; +} + export async function signAssetUploadRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); @@ -369,8 +677,14 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) { ` select id, tenant_id as "tenantId", asset_type as "assetType", storage_provider as "storageProvider", bucket, object_key as "objectKey", + preview_object_key as "previewObjectKey", title, file_name as "fileName", cdn_url as "cdnUrl", - preview_url as "previewUrl", visibility, status + preview_url as "previewUrl", mime_type as "mimeType", + file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + visibility, status, upload_status as "uploadStatus", + verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + preview_status as "previewStatus" from public.content_assets where tenant_id = $1 and id = $2 limit 1 @@ -397,6 +711,61 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) { cdnUrl: asset.cdnUrl, fileName: asset.fileName, expiresInSec, + disposition: 'attachment', + }), + }; +} + +export async function signAssetPreviewAdminRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx); + const assetId = requiredString(body, 'assetId'); + const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600); + + const asset = await queryOne( + ` + select id, tenant_id as "tenantId", asset_type as "assetType", + storage_provider as "storageProvider", bucket, object_key as "objectKey", + preview_object_key as "previewObjectKey", title, file_name as "fileName", + cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", + file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", + visibility, status, upload_status as "uploadStatus", + verified_size_bytes as "verifiedSizeBytes", + verified_checksum_sha256 as "verifiedChecksumSha256", + preview_status as "previewStatus" + from public.content_assets + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, assetId], + ); + + if (!asset) throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND'); + if (!assetPreviewable(asset)) { + throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED'); + } + + const objectKey = asset.previewObjectKey || asset.objectKey; + const cdnUrl = asset.previewUrl || asset.cdnUrl; + const fileName = asset.fileName || asset.title || 'preview.pdf'; + + return { + item: { + id: asset.id, + assetType: asset.assetType, + title: asset.title, + fileName: asset.fileName, + previewStatus: asset.previewStatus, + }, + preview: await signStorageDownload({ + tenantId: auth.tenantId, + provider: asset.storageProvider as StorageProviderName, + bucket: asset.bucket, + objectKey, + cdnUrl, + fileName, + expiresInSec, + disposition: 'inline', }), }; } diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 289df91a..cbd4e7f8 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -1,7 +1,9 @@ import type { RouteDefinition } from '../../core/router.js'; import { assetsAdminRoute, + confirmAssetUploadRoute, signAssetDownloadAdminRoute, + signAssetPreviewAdminRoute, signAssetUploadRoute, upsertAssetRoute, } from './assets.js'; @@ -73,7 +75,9 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-content/assets', assetsAdminRoute], ['PUT', '/api/tenant-content/assets', upsertAssetRoute], ['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute], + ['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute], ['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute], + ['POST', '/api/tenant-content/assets/sign-preview', signAssetPreviewAdminRoute], ['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute], ['POST', '/api/tenant-content/imports/questions', importQuestionsRoute], ['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute], diff --git a/apps/api/src/types/ali-oss.d.ts b/apps/api/src/types/ali-oss.d.ts index 6d3b8ff4..fa54659e 100644 --- a/apps/api/src/types/ali-oss.d.ts +++ b/apps/api/src/types/ali-oss.d.ts @@ -17,8 +17,17 @@ declare module 'ali-oss' { [key: string]: unknown; } + interface HeadObjectResult { + res?: { + headers?: Record; + status?: number; + }; + [key: string]: unknown; + } + export default class OSS { constructor(options: ClientOptions); signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string; + head(name: string): Promise; } } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 0c005eaa..2cae0bf8 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -91,8 +91,9 @@ | 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider | | 腾讯 COS 签名 | 可联调 | `tencent_cos` provider | | Supabase Storage 签名 | 可联调 | `supabase_storage` provider | -| PDF 预览/防盗链/水印 | 待补齐 | 商用上线前补齐 | -| 上传后对象校验 | 待补齐 | 需 worker 或 API 回调确认 size/hash/mime | +| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 | +| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名 | +| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前补 worker、CDN 防盗链、动态水印和安全扫描 | ## 订单、会员、营销 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 697d18d0..afec58ff 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -32,7 +32,7 @@ | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路已有;缺退款/对账/补偿任务和前端收银台体验 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | -| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 | +| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | | 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 | @@ -66,7 +66,7 @@ | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 | | 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 | | CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 | -| 对象存储配置 | 部分覆盖 | 系统 env provider 已有;租户级存储策略、上传后校验待补 | +| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认和预览下载签名已有;租户级存储策略、CDN/水印/杀毒待补 | ## 平台 SaaS 后台功能 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index cb0f2a2c..fbea6aa9 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -40,7 +40,8 @@ 2. 对象存储 - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 - - 继续补上传后对象存在性校验、PDF 预览地址、视频深度防盗链、动态水印和 worker 校验。 + - 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。 + - 继续补视频深度防盗链、动态水印、worker 复检、杀毒扫描、CDN 刷新和对象生命周期策略。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 3. 真实导入 dry-run diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md index 7a2fd3d5..f3a8cec6 100644 --- a/docs/refactor/object-storage.md +++ b/docs/refactor/object-storage.md @@ -1,6 +1,6 @@ # 对象存储接入说明 -更新时间:2026-06-22 +更新时间:2026-06-29 ## 目标 @@ -22,18 +22,30 @@ POST /api/tenant-content/assets/sign-upload ``` -后台登记资源: +后台登记资源草稿: ```text PUT /api/tenant-content/assets ``` +后台确认上传并发布: + +```text +POST /api/tenant-content/assets/confirm-upload +``` + 学生端下载: ```text GET /api/catalog/assets/download?assetId=... ``` +学生端预览: + +```text +GET /api/catalog/assets/preview?assetId=... +``` + 学生端视频播放: ```text @@ -46,12 +58,51 @@ POST /api/videos/play POST /api/tenant-content/assets/sign-download ``` +后台管理员预览: + +```text +POST /api/tenant-content/assets/sign-preview +``` + +## 标准上传流程 + +后台前端上传 PDF、图片、视频或资料包时必须走下面流程: + +1. 调用 `POST /api/tenant-content/assets/sign-upload` 申请短期上传 URL。 +2. 前端使用返回的 `upload.url` 和 `upload.headers` 直传对象存储。 +3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。 +4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。 +5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active`、`uploadStatus=verified`。 +6. 学生端只能下载或预览 `active + verified` 的托管对象资源。 + +确认上传示例: + +```json +{ + "assetId": "00000000-0000-0000-0000-000000000000", + "fileSizeBytes": 4096, + "mimeType": "application/pdf", + "checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "publish": true +} +``` + +注意: + +- `local_dev` 用声明的元数据模拟对象 HEAD,方便本地和集成测试。 +- `aliyun_oss` 使用 OSS 对象 HEAD 元数据。 +- `tencent_cos` 使用 COS HEAD Object 预签名请求读取元数据。 +- `supabase_storage` 使用 Storage list metadata 做最小存在性/大小校验。 +- 如果 provider 无法返回 SHA-256,后端会记录 `checksumUnavailable=true`,生产建议上传时写入对象自定义元数据,例如 `x-oss-meta-sha256` 或 `x-cos-meta-sha256`。 + ## 安全规则 - 只有租户内容维护权限用户可以申请上传签名。 - `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。 - 禁止 `..`、反斜杠、编码斜杠等危险 object key。 - 上传会校验 MIME 类型和文件大小。 +- 托管对象资源未确认前不能发布为 `active`,学生端不可下载。 +- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。 - 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。 - 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。 - `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。 @@ -105,10 +156,10 @@ SUPABASE_STORAGE_SERVICE_KEY= - 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。 - 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。 - 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。 -- 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`。 +- 大文件上传已经支持 API 即时确认;后续可增加 worker 做异步复检、杀毒、转码、水印和 CDN 刷新。 ## 官方依据 -- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传或下载生成带过期时间的签名 URL。 -- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL。 -- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS 和服务端权限控制。 +- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传/下载生成带过期时间的签名 URL,并可通过对象 HEAD 读取元数据。 +- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL 和 HEAD Object。 +- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS、服务端权限控制和资源台账。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 0ad26325..011dae3c 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -167,7 +167,7 @@ tenant::theme | 单词收藏 | `/api/learning/vocabulary/favorites` | | 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` | | 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` | -| 资料下载 | `/api/catalog/assets`、`/api/catalog/assets/download` | +| 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` | | 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` | | 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` | @@ -438,6 +438,57 @@ GET /api/learning/vocabulary/review-plan?unitId=&reviewLimit=30&newLimit - 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。 - 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。 +## 资料上传、预览和下载契约 + +学生端资料只读取目录、预览和下载签名,不接触对象存储真实密钥,也不自行拼接私有 bucket 地址。 + +学生端展示资料列表: + +```http +GET /api/catalog/assets?assetType=pdf®ionId=&includeLocked=true +``` + +学生端 PDF/图片预览: + +```http +GET /api/catalog/assets/preview?assetId= +``` + +学生端下载: + +```http +GET /api/catalog/assets/download?assetId= +``` + +前端处理规则: + +- `preview.url` 是短期 inline URL,只给预览组件使用,不持久化。 +- `download.url` 是短期 attachment URL,只给下载动作使用。 +- `ASSET_SVIP_REQUIRED`:提示开通对应地区/科目权益。 +- `ASSET_UPLOAD_NOT_VERIFIED`:展示“资料正在处理中”,并上报前端日志。 +- `ASSET_PREVIEW_NOT_SUPPORTED`:隐藏预览按钮,仅保留下载或提示不支持预览。 +- `previewUrl` 字段只作为公开/托管预览提示,不代表可以绕过接口直接访问。 + +租户后台上传资料必须走五步: + +```text +sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> sign-preview 验收 +``` + +后台上传确认: + +```json +{ + "assetId": "", + "fileSizeBytes": 4096, + "mimeType": "application/pdf", + "checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "publish": true +} +``` + +托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`,学生端不会看到。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`,后台必须展示失败原因并允许重新上传,不能前端强行改为已发布。 + ## 考试倒计时、签到积分和反馈 首页可用 `GET /api/catalog/exam-dates?regionId=` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 531a783a..ff183cfe 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1850,6 +1850,8 @@ async function testTenantContentAssetsAndImports() { assetType: 'pdf', storageProvider: 'local_dev', mimeType: 'application/pdf', + fileSizeBytes: 4096, + checksumSha256: 'a'.repeat(64), }, }); assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft'); @@ -1989,11 +1991,34 @@ async function testTenantContentAssetsAndImports() { fileName: upload.assetDraft.fileName, mimeType: upload.assetDraft.mimeType, fileSizeBytes: upload.assetDraft.fileSizeBytes, + checksumSha256: upload.assetDraft.checksumSha256, visibility: 'tenant', - status: 'active', }, }); assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key'); + assert.equal(localAsset.item?.status, 'draft', 'managed object asset should stay draft before upload confirmation'); + assert.equal(localAsset.item?.uploadStatus, 'pending', 'managed object asset should be pending before upload confirmation'); + + const unconfirmedDownload = await request('/api/catalog/assets/download', { + query: { assetId: localAsset.item.id }, + expectStatus: 404, + }); + assert.equal(unconfirmedDownload.code, 'ASSET_NOT_FOUND', 'unconfirmed draft asset should not be downloadable by students'); + + const confirmLocal = await request('/api/tenant-content/assets/confirm-upload', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + assetId: localAsset.item.id, + fileSizeBytes: upload.assetDraft.fileSizeBytes, + checksumSha256: upload.assetDraft.checksumSha256, + mimeType: 'application/pdf', + publish: true, + }, + }); + assert.equal(confirmLocal.item?.status, 'active', 'confirmed managed asset should publish when requested'); + assert.equal(confirmLocal.item?.uploadStatus, 'verified', 'confirm upload should mark asset verified'); + assert.equal(confirmLocal.item?.verifiedChecksumSha256, 'a'.repeat(64), 'confirm upload should persist checksum'); const localDownload = await request('/api/catalog/assets/download', { query: { assetId: localAsset.item.id }, @@ -2002,6 +2027,50 @@ async function testTenantContentAssetsAndImports() { assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer'); assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket'); + const localPreview = await request('/api/catalog/assets/preview', { + query: { assetId: localAsset.item.id }, + }); + assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET'); + assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer'); + assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition'); + + const adminPreview = await request('/api/tenant-content/assets/sign-preview', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { assetId: localAsset.item.id }, + }); + assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition'); + + const failedAsset = await request('/api/tenant-content/assets', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + title: '集成测试校验失败资料', + assetType: 'pdf', + storageProvider: 'local_dev', + bucket: upload.assetDraft.bucket, + objectKey: `${MAIN_TENANT_ID}/pdf/failed-${Date.now()}.pdf`, + fileName: 'failed.pdf', + mimeType: 'application/pdf', + fileSizeBytes: 100, + checksumSha256: 'b'.repeat(64), + visibility: 'tenant', + }, + }); + const failedConfirm = await request('/api/tenant-content/assets/confirm-upload', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + assetId: failedAsset.item.id, + fileSizeBytes: 101, + checksumSha256: 'b'.repeat(64), + mimeType: 'application/pdf', + publish: true, + }, + expectStatus: 409, + }); + assert.equal(failedConfirm.code, 'UPLOAD_VERIFICATION_FAILED', 'confirm upload should reject mismatched declared metadata'); + const invalidObjectAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/supabase/migrations/202606290008_content_asset_upload_verification.sql b/supabase/migrations/202606290008_content_asset_upload_verification.sql new file mode 100644 index 00000000..f89ff521 --- /dev/null +++ b/supabase/migrations/202606290008_content_asset_upload_verification.sql @@ -0,0 +1,73 @@ +alter table public.content_assets + add column if not exists upload_status text not null default 'not_required', + add column if not exists verified_at timestamptz, + add column if not exists verified_by uuid references public.platform_users(id) on delete set null, + add column if not exists verified_size_bytes bigint, + add column if not exists verified_checksum_sha256 text, + add column if not exists verification_details jsonb not null default '{}'::jsonb, + add column if not exists preview_object_key text, + add column if not exists preview_status text not null default 'none', + add column if not exists security_flags jsonb not null default '{}'::jsonb; + +update public.content_assets +set upload_status = case + when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null + then 'verified' + else 'not_required' + end, + verified_at = case + when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null + then coalesce(verified_at, updated_at, created_at, now()) + else verified_at + end, + verified_size_bytes = case + when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null + then coalesce(verified_size_bytes, file_size_bytes) + else verified_size_bytes + end, + verified_checksum_sha256 = case + when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null + then coalesce(verified_checksum_sha256, checksum_sha256) + else verified_checksum_sha256 + end, + preview_status = case + when preview_url is not null and preview_url <> '' then 'ready' + else preview_status + end +where upload_status = 'not_required' + or verified_at is null + or preview_status = 'none'; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'content_assets_upload_status_check') then + alter table public.content_assets + add constraint content_assets_upload_status_check + check (upload_status in ('not_required', 'pending', 'verified', 'failed')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'content_assets_preview_status_check') then + alter table public.content_assets + add constraint content_assets_preview_status_check + check (preview_status in ('none', 'pending', 'ready', 'failed')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'content_assets_verified_size_check') then + alter table public.content_assets + add constraint content_assets_verified_size_check + check (verified_size_bytes is null or verified_size_bytes >= 0); + end if; + + if not exists (select 1 from pg_constraint where conname = 'content_assets_verified_checksum_check') then + alter table public.content_assets + add constraint content_assets_verified_checksum_check + check (verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'); + end if; +end $$; + +create index if not exists idx_content_assets_upload_status + on public.content_assets(tenant_id, upload_status, status, updated_at desc); + +create index if not exists idx_content_assets_preview + on public.content_assets(tenant_id, preview_status) + where preview_status <> 'none';