forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
354
apps/api/src/features/tenant-content/assets.ts
Normal file
354
apps/api/src/features/tenant-content/assets.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
|
||||
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
|
||||
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'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
assetType: string;
|
||||
storageProvider: string;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
title: string | null;
|
||||
fileName: string | null;
|
||||
cdnUrl: string | null;
|
||||
previewUrl: string | null;
|
||||
visibility: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function nullableUuid(value: unknown) {
|
||||
return nullableString(value);
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 160) || 'asset';
|
||||
}
|
||||
|
||||
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
if (asset.cdnUrl) {
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: asset.cdnUrl,
|
||||
expiresAt,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
if (!id) return;
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
|
||||
[tenantId, id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new HttpError(400, `${table} reference is not in this tenant`, code);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
|
||||
await query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const visibility = stringParam(ctx, 'visibility');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetType) {
|
||||
params.push(assetType);
|
||||
filters.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (visibility) {
|
||||
params.push(visibility);
|
||||
filters.push(`visibility = $${params.length}`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`category_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
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",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.content_assets
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
|
||||
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 regionId = nullableUuid(body.regionId);
|
||||
const subjectId = nullableUuid(body.subjectId);
|
||||
const categoryId = nullableUuid(body.categoryId);
|
||||
const nodeId = nullableUuid(body.nodeId);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
|
||||
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
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,
|
||||
is_public, region_id, subject_id, category_id, node_id, status,
|
||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
||||
)
|
||||
values (
|
||||
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,
|
||||
$25, $26::jsonb, $27::jsonb, $28, $28, $29
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
asset_key = excluded.asset_key,
|
||||
asset_type = excluded.asset_type,
|
||||
storage_provider = excluded.storage_provider,
|
||||
bucket = excluded.bucket,
|
||||
object_key = excluded.object_key,
|
||||
title = excluded.title,
|
||||
category = excluded.category,
|
||||
description = excluded.description,
|
||||
file_name = excluded.file_name,
|
||||
cdn_url = excluded.cdn_url,
|
||||
preview_url = excluded.preview_url,
|
||||
mime_type = excluded.mime_type,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
visibility = excluded.visibility,
|
||||
is_public = excluded.is_public,
|
||||
region_id = excluded.region_id,
|
||||
subject_id = excluded.subject_id,
|
||||
category_id = excluded.category_id,
|
||||
node_id = excluded.node_id,
|
||||
status = excluded.status,
|
||||
sort_order = excluded.sort_order,
|
||||
access_rules = excluded.access_rules,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
source = excluded.source,
|
||||
updated_at = now()
|
||||
where public.content_assets.tenant_id = excluded.tenant_id
|
||||
returning id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
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",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.legacyId),
|
||||
nullableString(body.assetKey),
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
title,
|
||||
nullableString(body.categoryLabel) || nullableString(body.category),
|
||||
nullableString(body.description),
|
||||
nullableString(body.fileName),
|
||||
cdnUrl,
|
||||
nullableString(body.previewUrl),
|
||||
nullableString(body.mimeType),
|
||||
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
nullableString(body.checksumSha256),
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
regionId,
|
||||
subjectId,
|
||||
categoryId,
|
||||
nodeId,
|
||||
status,
|
||||
intValue(body.order, 0),
|
||||
jsonObjectValue(body.accessRules),
|
||||
jsonObjectValue(body.metadata),
|
||||
auth.userId,
|
||||
nullableString(body.source) || 'manual',
|
||||
],
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
|
||||
title,
|
||||
assetType,
|
||||
visibility,
|
||||
status,
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const fileName = requiredString(body, 'fileName');
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
|
||||
const bucket = nullableString(body.bucket) || 'tenant-assets';
|
||||
const objectKey =
|
||||
nullableString(body.objectKey) ||
|
||||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
|
||||
return {
|
||||
upload: {
|
||||
provider: storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
method: 'PUT',
|
||||
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
headers: {
|
||||
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
|
||||
},
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
},
|
||||
assetDraft: {
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
fileName,
|
||||
mimeType: nullableString(body.mimeType),
|
||||
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
checksumSha256: nullableString(body.checksumSha256),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetDownloadAdminRoute(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), 86_400);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", 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, status
|
||||
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');
|
||||
}
|
||||
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: asset,
|
||||
download: placeholderSignedUrl(asset, expiresInSec),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user