forked from wangziqi/gongxue-base
feat: add object storage signing providers
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
|
||||
- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。
|
||||
- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。
|
||||
- 真实 OSS/COS/Supabase Storage 上传下载签名还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
||||
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
||||
- Taro 跨端前端还没开始 scaffold。
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
- `docs/refactor/backend-progress.md`
|
||||
- `docs/refactor/backend-handoff-roadmap.md`
|
||||
- `docs/refactor/content-import-contract.md`
|
||||
- `docs/refactor/object-storage.md`
|
||||
- `docs/refactor/next-development-todo.md`
|
||||
- `docs/refactor/blueprint-coverage.md`
|
||||
- `docs/refactor/api-structure.md`
|
||||
@@ -162,7 +163,7 @@ npm run check:refactor
|
||||
|
||||
优先继续补:
|
||||
|
||||
1. 真实对象存储 adapter:阿里云 OSS、腾讯云 COS 或 Supabase Storage。
|
||||
1. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||
2. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||
3. Supabase Auth/JWT 正式鉴权和生产 RLS 验证。
|
||||
4. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/storage-js": "^2.108.2",
|
||||
"ali-oss": "^6.23.0",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
|
||||
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envBoolean, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
|
||||
|
||||
export interface ApiConfig {
|
||||
nodeEnv: string;
|
||||
@@ -13,6 +13,26 @@ export interface ApiConfig {
|
||||
authSmsCooldownSeconds: number;
|
||||
authSessionTtlSeconds: number;
|
||||
platformAdminApiKey: string;
|
||||
storageDefaultProvider: string;
|
||||
storageDefaultBucket: string;
|
||||
storagePublicBaseUrl: string;
|
||||
storageMaxUploadBytes: number;
|
||||
storageAllowedMimePrefixes: string[];
|
||||
storageAllowedMimeTypes: string[];
|
||||
storageRequireTenantPrefix: boolean;
|
||||
aliyunOssRegion: string;
|
||||
aliyunOssEndpoint: string;
|
||||
aliyunOssAccessKeyId: string;
|
||||
aliyunOssAccessKeySecret: string;
|
||||
aliyunOssStsToken: string;
|
||||
aliyunOssInternal: boolean;
|
||||
tencentCosRegion: string;
|
||||
tencentCosAppId: string;
|
||||
tencentCosSecretId: string;
|
||||
tencentCosSecretKey: string;
|
||||
tencentCosSecurityToken: string;
|
||||
supabaseStorageUrl: string;
|
||||
supabaseStorageServiceKey: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
@@ -33,5 +53,43 @@ export const config: ApiConfig = {
|
||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
|
||||
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
|
||||
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
|
||||
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
|
||||
storageMaxUploadBytes: envNumber('STORAGE_MAX_UPLOAD_BYTES', 1024 * 1024 * 500),
|
||||
storageAllowedMimePrefixes: envList('STORAGE_ALLOWED_MIME_PREFIXES', 'image/,video/,audio/'),
|
||||
storageAllowedMimeTypes: envList(
|
||||
'STORAGE_ALLOWED_MIME_TYPES',
|
||||
[
|
||||
'application/pdf',
|
||||
'application/json',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/octet-stream',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
].join(','),
|
||||
),
|
||||
storageRequireTenantPrefix: envBoolean('STORAGE_REQUIRE_TENANT_PREFIX', true),
|
||||
aliyunOssRegion: envString('ALIYUN_OSS_REGION', ''),
|
||||
aliyunOssEndpoint: envString('ALIYUN_OSS_ENDPOINT', ''),
|
||||
aliyunOssAccessKeyId: envString('ALIYUN_OSS_ACCESS_KEY_ID', ''),
|
||||
aliyunOssAccessKeySecret: envString('ALIYUN_OSS_ACCESS_KEY_SECRET', ''),
|
||||
aliyunOssStsToken: envString('ALIYUN_OSS_STS_TOKEN', ''),
|
||||
aliyunOssInternal: envBoolean('ALIYUN_OSS_INTERNAL', false),
|
||||
tencentCosRegion: envString('TENCENT_COS_REGION', ''),
|
||||
tencentCosAppId: envString('TENCENT_COS_APP_ID', ''),
|
||||
tencentCosSecretId: envString('TENCENT_COS_SECRET_ID', ''),
|
||||
tencentCosSecretKey: envString('TENCENT_COS_SECRET_KEY', ''),
|
||||
tencentCosSecurityToken: envString('TENCENT_COS_SECURITY_TOKEN', ''),
|
||||
supabaseStorageUrl: envString('SUPABASE_STORAGE_URL', ''),
|
||||
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
|
||||
isProduction,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
|
||||
interface CatalogAssetRow {
|
||||
id: string;
|
||||
@@ -25,25 +26,6 @@ function hasUserContext(ctx: RequestContext) {
|
||||
return !!optionalUserId(ctx);
|
||||
}
|
||||
|
||||
function signedDownload(asset: CatalogAssetRow, 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 hasActiveMembership(tenantId: string, userId: string) {
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
@@ -208,6 +190,14 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
download: signedDownload(asset, 900),
|
||||
download: await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec: 900,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
452
apps/api/src/features/storage/service.ts
Normal file
452
apps/api/src/features/storage/service.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import crypto from 'node:crypto';
|
||||
import OSS from 'ali-oss';
|
||||
import { StorageClient } from '@supabase/storage-js';
|
||||
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 interface AssetLocation {
|
||||
provider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
cdnUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadSignInput {
|
||||
tenantId: string;
|
||||
provider: StorageProviderName;
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number | null;
|
||||
expiresInSec: number;
|
||||
upsert?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadSignInput {
|
||||
tenantId: string;
|
||||
provider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
cdnUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
expiresInSec: number;
|
||||
}
|
||||
|
||||
export interface SignedStorageUrl {
|
||||
provider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
method: StorageHttpMethod;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
expiresAt: string;
|
||||
expiresInSec: number;
|
||||
signatureMode: string;
|
||||
}
|
||||
|
||||
const REAL_STORAGE_PROVIDERS = new Set<StorageProviderName>(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
|
||||
const SUPPORTED_STORAGE_PROVIDERS = new Set<StorageProviderName>([
|
||||
'external_url',
|
||||
'supabase_storage',
|
||||
'aliyun_oss',
|
||||
'tencent_cos',
|
||||
'qiniu_kodo',
|
||||
'local_dev',
|
||||
]);
|
||||
const UPLOADABLE_PROVIDERS = new Set<StorageProviderName>(['local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos']);
|
||||
const SAFE_OBJECT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$/;
|
||||
|
||||
function nowSeconds() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function expiresAt(expiresInSec: number) {
|
||||
return new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
}
|
||||
|
||||
function assertProvider(provider: string): asserts provider is StorageProviderName {
|
||||
if (!SUPPORTED_STORAGE_PROVIDERS.has(provider as StorageProviderName)) {
|
||||
throw new HttpError(400, `Unsupported storage provider: ${provider}`, 'UNSUPPORTED_STORAGE_PROVIDER');
|
||||
}
|
||||
}
|
||||
|
||||
function requireConfigured(condition: unknown, provider: StorageProviderName, missing: string) {
|
||||
if (!condition) {
|
||||
throw new HttpError(500, `${provider} is not configured: ${missing}`, 'STORAGE_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalObjectKey(objectKey: string) {
|
||||
return objectKey.replace(/^\/+/, '').replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function configuredDefaultStorageProvider(): StorageProviderName {
|
||||
const provider = config.storageDefaultProvider.trim() || 'local_dev';
|
||||
assertProvider(provider);
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function configuredDefaultStorageBucket() {
|
||||
return config.storageDefaultBucket.trim() || 'tenant-assets';
|
||||
}
|
||||
|
||||
export function validateObjectKey(tenantId: string, objectKey: string) {
|
||||
const clean = canonicalObjectKey(objectKey);
|
||||
if (!clean || clean.includes('..') || clean.includes('\\') || clean.includes('%2f') || clean.includes('%2F')) {
|
||||
throw new HttpError(400, 'Invalid objectKey', 'INVALID_OBJECT_KEY');
|
||||
}
|
||||
if (!SAFE_OBJECT_KEY_RE.test(clean)) {
|
||||
throw new HttpError(400, 'objectKey contains unsafe characters', 'INVALID_OBJECT_KEY');
|
||||
}
|
||||
if (config.storageRequireTenantPrefix && !clean.startsWith(`${tenantId}/`)) {
|
||||
throw new HttpError(400, 'objectKey must be scoped by tenantId prefix', 'OBJECT_KEY_TENANT_PREFIX_REQUIRED');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
export function validateMimeType(mimeType: string) {
|
||||
const normalized = mimeType.trim().toLowerCase();
|
||||
if (!normalized || normalized.length > 160 || normalized.includes('\r') || normalized.includes('\n')) {
|
||||
throw new HttpError(400, 'Invalid mimeType', 'INVALID_MIME_TYPE');
|
||||
}
|
||||
const exact = config.storageAllowedMimeTypes.map(item => item.toLowerCase());
|
||||
const prefixes = config.storageAllowedMimePrefixes.map(item => item.toLowerCase());
|
||||
if (!exact.includes(normalized) && !prefixes.some(prefix => normalized.startsWith(prefix))) {
|
||||
throw new HttpError(400, `mimeType is not allowed: ${normalized}`, 'MIME_TYPE_NOT_ALLOWED');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateFileSize(fileSizeBytes: number | null) {
|
||||
if (fileSizeBytes === null) return null;
|
||||
if (!Number.isSafeInteger(fileSizeBytes) || fileSizeBytes < 0) {
|
||||
throw new HttpError(400, 'fileSizeBytes must be a non-negative safe integer', 'INVALID_FILE_SIZE');
|
||||
}
|
||||
if (fileSizeBytes > config.storageMaxUploadBytes) {
|
||||
throw new HttpError(400, 'file exceeds STORAGE_MAX_UPLOAD_BYTES', 'FILE_TOO_LARGE');
|
||||
}
|
||||
return fileSizeBytes;
|
||||
}
|
||||
|
||||
export function normalizeStorageProvider(value: string | null | undefined, fallback?: StorageProviderName): StorageProviderName {
|
||||
const provider = (value?.trim() || fallback || configuredDefaultStorageProvider()) as StorageProviderName;
|
||||
assertProvider(provider);
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function assertUploadProvider(provider: StorageProviderName) {
|
||||
if (!UPLOADABLE_PROVIDERS.has(provider)) {
|
||||
throw new HttpError(400, `${provider} does not support managed upload signing`, 'UPLOAD_PROVIDER_NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWritableLocation(location: AssetLocation) {
|
||||
if (REAL_STORAGE_PROVIDERS.has(location.provider) && (!location.bucket || !location.objectKey)) {
|
||||
throw new HttpError(400, `${location.provider} asset requires bucket and objectKey`, 'ASSET_OBJECT_LOCATION_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
function localSignedUrl(input: {
|
||||
provider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
method: StorageHttpMethod;
|
||||
expiresInSec: number;
|
||||
headers?: Record<string, string>;
|
||||
}): 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)}`;
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: input.method,
|
||||
url,
|
||||
headers: input.headers || {},
|
||||
expiresAt: expires,
|
||||
expiresInSec: input.expiresInSec,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function signAliyunOss(input: {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
method: StorageHttpMethod;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
expiresInSec: number;
|
||||
}): Promise<SignedStorageUrl> {
|
||||
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,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const options: Record<string, unknown> = {
|
||||
expires: input.expiresInSec,
|
||||
method: input.method,
|
||||
};
|
||||
if (input.method === 'PUT' && input.mimeType) {
|
||||
options['Content-Type'] = input.mimeType;
|
||||
headers['content-type'] = input.mimeType;
|
||||
}
|
||||
if (input.method === 'GET' && input.fileName) {
|
||||
options.response = {
|
||||
'content-disposition': `attachment; filename="${encodeURIComponent(input.fileName)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const url = client.signatureUrl(input.objectKey, options);
|
||||
return {
|
||||
provider: 'aliyun_oss',
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: input.method,
|
||||
url,
|
||||
headers,
|
||||
expiresAt: expiresAt(input.expiresInSec),
|
||||
expiresInSec: input.expiresInSec,
|
||||
signatureMode: 'aliyun-oss-signature-url-v1',
|
||||
};
|
||||
}
|
||||
|
||||
function cosEncodePath(objectKey: string) {
|
||||
return objectKey
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function hmacSha1Hex(key: string | Buffer, value: string) {
|
||||
return crypto.createHmac('sha1', key).update(value).digest('hex');
|
||||
}
|
||||
|
||||
function sha1Hex(value: string) {
|
||||
return crypto.createHash('sha1').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function cosHost(bucket: string) {
|
||||
requireConfigured(config.tencentCosRegion, 'tencent_cos', 'TENCENT_COS_REGION');
|
||||
const bucketWithAppId = config.tencentCosAppId && !bucket.endsWith(`-${config.tencentCosAppId}`)
|
||||
? `${bucket}-${config.tencentCosAppId}`
|
||||
: bucket;
|
||||
return `${bucketWithAppId}.cos.${config.tencentCosRegion}.myqcloud.com`;
|
||||
}
|
||||
|
||||
function signTencentCos(input: {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
method: StorageHttpMethod;
|
||||
mimeType?: string;
|
||||
expiresInSec: number;
|
||||
}): SignedStorageUrl {
|
||||
requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID');
|
||||
requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY');
|
||||
const host = cosHost(input.bucket);
|
||||
const start = nowSeconds();
|
||||
const end = start + input.expiresInSec;
|
||||
const keyTime = `${start};${end}`;
|
||||
const method = input.method.toLowerCase();
|
||||
const pathname = `/${cosEncodePath(input.objectKey)}`;
|
||||
const signedHeaders: Record<string, string> = { host };
|
||||
if (input.method === 'PUT' && input.mimeType) signedHeaders['content-type'] = input.mimeType;
|
||||
const headerKeys = Object.keys(signedHeaders).sort();
|
||||
const headerList = headerKeys.join(';');
|
||||
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 httpString = `${method}\n${pathname}\n${httpParameters}\n${httpHeaders}\n`;
|
||||
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`;
|
||||
const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime);
|
||||
const signature = hmacSha1Hex(signKey, stringToSign);
|
||||
const query = new URLSearchParams();
|
||||
query.set('q-sign-algorithm', 'sha1');
|
||||
query.set('q-ak', config.tencentCosSecretId);
|
||||
query.set('q-sign-time', keyTime);
|
||||
query.set('q-key-time', keyTime);
|
||||
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);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (input.method === 'PUT' && input.mimeType) headers['content-type'] = input.mimeType;
|
||||
|
||||
return {
|
||||
provider: 'tencent_cos',
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: input.method,
|
||||
url: `https://${host}${pathname}?${query.toString()}`,
|
||||
headers,
|
||||
expiresAt: expiresAt(input.expiresInSec),
|
||||
expiresInSec: input.expiresInSec,
|
||||
signatureMode: 'tencent-cos-signature-url-v5',
|
||||
};
|
||||
}
|
||||
|
||||
function supabaseStorageClient() {
|
||||
requireConfigured(config.supabaseStorageUrl, 'supabase_storage', 'SUPABASE_STORAGE_URL');
|
||||
requireConfigured(config.supabaseStorageServiceKey, 'supabase_storage', 'SUPABASE_STORAGE_SERVICE_KEY');
|
||||
return new StorageClient(config.supabaseStorageUrl.replace(/\/+$/, ''), {
|
||||
apikey: config.supabaseStorageServiceKey,
|
||||
authorization: `Bearer ${config.supabaseStorageServiceKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function signSupabaseStorageUpload(input: UploadSignInput): Promise<SignedStorageUrl> {
|
||||
const response = await supabaseStorageClient().from(input.bucket).createSignedUploadUrl(input.objectKey, {
|
||||
upsert: input.upsert === true,
|
||||
});
|
||||
if (response.error || !response.data) {
|
||||
throw new HttpError(502, response.error?.message || 'Supabase Storage upload signing failed', 'STORAGE_SIGN_FAILED');
|
||||
}
|
||||
return {
|
||||
provider: 'supabase_storage',
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'PUT',
|
||||
url: response.data.signedUrl,
|
||||
headers: { 'content-type': input.mimeType },
|
||||
expiresAt: expiresAt(7200),
|
||||
expiresInSec: 7200,
|
||||
signatureMode: 'supabase-storage-signed-upload-url',
|
||||
};
|
||||
}
|
||||
|
||||
async function signSupabaseStorageDownload(input: DownloadSignInput): Promise<SignedStorageUrl> {
|
||||
if (!input.bucket || !input.objectKey) {
|
||||
throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
|
||||
}
|
||||
const response = await supabaseStorageClient().from(input.bucket).createSignedUrl(input.objectKey, input.expiresInSec, {
|
||||
download: input.fileName || true,
|
||||
});
|
||||
if (response.error || !response.data) {
|
||||
throw new HttpError(502, response.error?.message || 'Supabase Storage download signing failed', 'STORAGE_SIGN_FAILED');
|
||||
}
|
||||
return {
|
||||
provider: 'supabase_storage',
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'GET',
|
||||
url: response.data.signedUrl,
|
||||
headers: {},
|
||||
expiresAt: expiresAt(input.expiresInSec),
|
||||
expiresInSec: input.expiresInSec,
|
||||
signatureMode: 'supabase-storage-signed-url',
|
||||
};
|
||||
}
|
||||
|
||||
export async function signStorageUpload(input: UploadSignInput): Promise<SignedStorageUrl> {
|
||||
assertUploadProvider(input.provider);
|
||||
validateObjectKey(input.tenantId, input.objectKey);
|
||||
const mimeType = validateMimeType(input.mimeType);
|
||||
validateFileSize(input.fileSizeBytes);
|
||||
|
||||
if (input.provider === 'local_dev') {
|
||||
return localSignedUrl({
|
||||
provider: input.provider,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'PUT',
|
||||
expiresInSec: input.expiresInSec,
|
||||
headers: { 'content-type': mimeType },
|
||||
});
|
||||
}
|
||||
if (input.provider === 'aliyun_oss') {
|
||||
return signAliyunOss({
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'PUT',
|
||||
mimeType,
|
||||
expiresInSec: input.expiresInSec,
|
||||
});
|
||||
}
|
||||
if (input.provider === 'tencent_cos') {
|
||||
return signTencentCos({
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'PUT',
|
||||
mimeType,
|
||||
expiresInSec: input.expiresInSec,
|
||||
});
|
||||
}
|
||||
return signSupabaseStorageUpload({ ...input, mimeType });
|
||||
}
|
||||
|
||||
export async function signStorageDownload(input: DownloadSignInput): Promise<SignedStorageUrl> {
|
||||
if (input.cdnUrl) {
|
||||
return {
|
||||
provider: input.provider,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'GET',
|
||||
url: input.cdnUrl,
|
||||
headers: {},
|
||||
expiresAt: expiresAt(input.expiresInSec),
|
||||
expiresInSec: input.expiresInSec,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
if (!input.objectKey) {
|
||||
throw new HttpError(400, 'Asset requires objectKey or cdnUrl', 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
validateObjectKey(input.tenantId, input.objectKey);
|
||||
|
||||
if (input.provider === 'local_dev' || input.provider === 'qiniu_kodo') {
|
||||
return localSignedUrl({
|
||||
provider: input.provider,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'GET',
|
||||
expiresInSec: input.expiresInSec,
|
||||
});
|
||||
}
|
||||
if (input.provider === 'aliyun_oss') {
|
||||
if (!input.bucket) throw new HttpError(400, 'Aliyun OSS asset requires bucket', 'ASSET_OBJECT_LOCATION_REQUIRED');
|
||||
return signAliyunOss({
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'GET',
|
||||
fileName: input.fileName,
|
||||
expiresInSec: input.expiresInSec,
|
||||
});
|
||||
}
|
||||
if (input.provider === 'tencent_cos') {
|
||||
if (!input.bucket) throw new HttpError(400, 'Tencent COS asset requires bucket', 'ASSET_OBJECT_LOCATION_REQUIRED');
|
||||
return signTencentCos({
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
method: 'GET',
|
||||
expiresInSec: input.expiresInSec,
|
||||
});
|
||||
}
|
||||
if (input.provider === 'supabase_storage') {
|
||||
return signSupabaseStorageDownload(input);
|
||||
}
|
||||
throw new HttpError(400, `${input.provider} asset requires cdnUrl`, 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import { intParam, readJsonBody, requiredString, stringParam } from '../../core/
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
import {
|
||||
assertUploadProvider,
|
||||
configuredDefaultStorageBucket,
|
||||
assertWritableLocation,
|
||||
configuredDefaultStorageProvider,
|
||||
normalizeStorageProvider,
|
||||
signStorageDownload,
|
||||
signStorageUpload,
|
||||
validateFileSize,
|
||||
validateMimeType,
|
||||
validateObjectKey,
|
||||
type StorageProviderName,
|
||||
} from '../storage/service.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'];
|
||||
@@ -45,25 +58,6 @@ function safeFileName(fileName: string) {
|
||||
.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 }>(
|
||||
@@ -164,7 +158,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
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 storageProvider = choice(
|
||||
body.storageProvider,
|
||||
STORAGE_PROVIDERS,
|
||||
nullableString(body.cdnUrl) ? 'external_url' : configuredDefaultStorageProvider(),
|
||||
'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);
|
||||
@@ -178,6 +177,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
const entryId = nullableUuid(body.entryId);
|
||||
const contentNodeId = nullableUuid(body.contentNodeId);
|
||||
|
||||
const cleanObjectKey = objectKey ? validateObjectKey(auth.tenantId, objectKey) : null;
|
||||
const mimeType = nullableString(body.mimeType);
|
||||
if (mimeType) validateMimeType(mimeType);
|
||||
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
|
||||
assertWritableLocation({ provider: storageProvider, bucket, objectKey: cleanObjectKey, cdnUrl });
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
@@ -261,15 +266,15 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
cleanObjectKey,
|
||||
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),
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
nullableString(body.checksumSha256),
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
@@ -307,35 +312,48 @@ export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
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 storageProvider = normalizeStorageProvider(nullableString(body.storageProvider), configuredDefaultStorageProvider());
|
||||
assertUploadProvider(storageProvider);
|
||||
const bucket = nullableString(body.bucket) || configuredDefaultStorageBucket();
|
||||
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();
|
||||
const mimeType = validateMimeType(nullableString(body.mimeType) || 'application/octet-stream');
|
||||
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
|
||||
const cleanObjectKey = validateObjectKey(auth.tenantId, objectKey);
|
||||
const upload = await signStorageUpload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: storageProvider,
|
||||
bucket,
|
||||
objectKey: cleanObjectKey,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
expiresInSec,
|
||||
upsert: body.upsert === true,
|
||||
});
|
||||
|
||||
await recordAssetAudit(auth, 'content.asset.upload_signed', null, {
|
||||
provider: storageProvider,
|
||||
bucket,
|
||||
objectKey: cleanObjectKey,
|
||||
assetType,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
upload,
|
||||
assetDraft: {
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
objectKey: cleanObjectKey,
|
||||
fileName,
|
||||
mimeType: nullableString(body.mimeType),
|
||||
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
checksumSha256: nullableString(body.checksumSha256),
|
||||
},
|
||||
};
|
||||
@@ -371,6 +389,14 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
|
||||
return {
|
||||
item: asset,
|
||||
download: placeholderSignedUrl(asset, expiresInSec),
|
||||
download: await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
24
apps/api/src/types/ali-oss.d.ts
vendored
Normal file
24
apps/api/src/types/ali-oss.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'ali-oss' {
|
||||
interface ClientOptions {
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
stsToken?: string;
|
||||
bucket?: string;
|
||||
internal?: boolean;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
interface SignatureUrlOptions {
|
||||
expires?: number;
|
||||
method?: string;
|
||||
response?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default class OSS {
|
||||
constructor(options: ClientOptions);
|
||||
signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
|
||||
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、上传/下载签名占位 | 阿里云 OSS/腾讯 COS/Supabase Storage 真实签名、PDF 预览 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
|
||||
| 会员与订单 | 基础完成 | 下单、手动确认、激活码兑换、权益发放 | 微信支付、支付宝、退款、webhook 验签和幂等 |
|
||||
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
|
||||
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
||||
@@ -76,7 +76,7 @@
|
||||
### P0:上云测试和前端主链路前必须处理
|
||||
|
||||
- 生产鉴权:用 Supabase Auth/JWT 或服务端 session 替换 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。
|
||||
- 对象存储:接入阿里云 OSS、腾讯云 COS 或 Supabase Storage,完成上传、下载、PDF 预览、视频播放签名。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;继续完成上传后对象校验、PDF 预览、视频播放签名、防盗链和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。
|
||||
- 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:建立 `apps/taro`,先完成租户解析、首页、题库、背单词、知识手册、个人中心主链路。
|
||||
|
||||
@@ -175,13 +175,13 @@ GET /api/tenant-admin/audit-logs
|
||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
||||
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限;真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
|
||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。
|
||||
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
|
||||
- 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,真实 OSS/COS/Supabase Storage 签名。
|
||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||
4. 增加真实支付 provider:XPay、微信支付、支付宝,并完善 webhook 幂等。
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
- 做一轮真实 JWT + RLS 回归测试。
|
||||
|
||||
2. 对象存储
|
||||
- 接阿里云 OSS、腾讯云 COS 或 Supabase Storage。
|
||||
- 完成上传签名、下载签名、PDF 预览地址、视频播放签名。
|
||||
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
||||
- 继续补上传后对象存在性校验、PDF 预览地址、视频播放签名、防盗链、水印和 worker 校验。
|
||||
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
||||
|
||||
3. 真实导入 dry-run
|
||||
@@ -167,7 +167,7 @@
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 先把当前后端代码推到 Gitea。
|
||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,跑 `check:refactor` 的远程等价测试。
|
||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
||||
5. 并行补对象存储、真实登录、支付 adapter。
|
||||
|
||||
108
docs/refactor/object-storage.md
Normal file
108
docs/refactor/object-storage.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 对象存储接入说明
|
||||
|
||||
更新时间:2026-06-22
|
||||
|
||||
## 目标
|
||||
|
||||
题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。
|
||||
|
||||
已接入的 provider:
|
||||
|
||||
- `local_dev`:本地开发占位签名,便于前后端联调。
|
||||
- `aliyun_oss`:阿里云 OSS 官方 Node.js SDK `signatureUrl`。
|
||||
- `tencent_cos`:腾讯云 COS XML API V5 签名 URL,服务端用 Node `crypto` 实现,避免引入当前 COS Node SDK 的高危依赖。
|
||||
- `supabase_storage`:Supabase Storage 官方 `createSignedUploadUrl` / `createSignedUrl` 语义。
|
||||
- `external_url`:外部公开或厂商托管 URL,只允许作为已管理资源的下载地址,不支持后端直传签名。
|
||||
|
||||
## API
|
||||
|
||||
后台申请上传签名:
|
||||
|
||||
```text
|
||||
POST /api/tenant-content/assets/sign-upload
|
||||
```
|
||||
|
||||
后台登记资源:
|
||||
|
||||
```text
|
||||
PUT /api/tenant-content/assets
|
||||
```
|
||||
|
||||
学生端下载:
|
||||
|
||||
```text
|
||||
GET /api/catalog/assets/download?assetId=...
|
||||
```
|
||||
|
||||
后台管理员下载:
|
||||
|
||||
```text
|
||||
POST /api/tenant-content/assets/sign-download
|
||||
```
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 只有租户内容维护权限用户可以申请上传签名。
|
||||
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
|
||||
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
|
||||
- 上传会校验 MIME 类型和文件大小。
|
||||
- 下载必须先经过 API 权限判断,再下发短期签名 URL。
|
||||
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
|
||||
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
|
||||
|
||||
## 环境变量
|
||||
|
||||
通用:
|
||||
|
||||
```text
|
||||
STORAGE_DEFAULT_PROVIDER=local_dev
|
||||
STORAGE_DEFAULT_BUCKET=tenant-assets
|
||||
STORAGE_PUBLIC_BASE_URL=
|
||||
STORAGE_MAX_UPLOAD_BYTES=524288000
|
||||
STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/
|
||||
STORAGE_ALLOWED_MIME_TYPES=application/pdf,application/json,application/zip,application/x-zip-compressed,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream,text/plain,text/markdown,text/csv
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
```
|
||||
|
||||
阿里云 OSS:
|
||||
|
||||
```text
|
||||
ALIYUN_OSS_REGION=oss-cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||||
ALIYUN_OSS_STS_TOKEN=
|
||||
ALIYUN_OSS_INTERNAL=false
|
||||
```
|
||||
|
||||
腾讯云 COS:
|
||||
|
||||
```text
|
||||
TENCENT_COS_REGION=ap-shanghai
|
||||
TENCENT_COS_APP_ID=
|
||||
TENCENT_COS_SECRET_ID=
|
||||
TENCENT_COS_SECRET_KEY=
|
||||
TENCENT_COS_SECURITY_TOKEN=
|
||||
```
|
||||
|
||||
Supabase Storage:
|
||||
|
||||
```text
|
||||
SUPABASE_STORAGE_URL=https://your-project.supabase.co/storage/v1
|
||||
SUPABASE_STORAGE_SERVICE_KEY=
|
||||
```
|
||||
|
||||
## 生产建议
|
||||
|
||||
- 阿里云和腾讯云生产环境优先用 STS/临时密钥或 RAM/CAM 最小权限账号。
|
||||
- bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。
|
||||
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
||||
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
||||
- 视频资源建议后续接入转码、水印、防盗链、播放日志和播放次数扣减。
|
||||
- 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`。
|
||||
|
||||
## 官方依据
|
||||
|
||||
- 阿里云 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 和服务端权限控制。
|
||||
804
package-lock.json
generated
804
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103';
|
||||
const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
|
||||
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
|
||||
const START_SERVER = process.argv.includes('--start-server');
|
||||
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
|
||||
|
||||
const ids = {
|
||||
region: '00000000-0000-0000-0000-000000000301',
|
||||
@@ -633,6 +634,81 @@ async function testTenantContentAssetsAndImports() {
|
||||
});
|
||||
assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft');
|
||||
assert.ok(upload.upload?.objectKey?.includes(MAIN_TENANT_ID), 'upload object key should be tenant scoped');
|
||||
assert.equal(upload.upload?.signatureMode, 'local-placeholder', 'local signer should be explicit in test env');
|
||||
assert.equal(upload.upload?.headers?.['content-type'], 'application/pdf', 'upload signer should bind content-type');
|
||||
assert.ok(!JSON.stringify(upload).includes('ACCESS_KEY'), 'upload signer must not leak cloud credentials');
|
||||
|
||||
if (ENABLE_REAL_STORAGE_SIGN_TESTS) {
|
||||
const tencentUpload = await request('/api/tenant-content/assets/sign-upload', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: 'cos-resource.pdf',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'tencent_cos',
|
||||
bucket: process.env.TEST_TENCENT_COS_BUCKET || 'tenant-assets',
|
||||
objectKey: `${MAIN_TENANT_ID}/pdf/cos-resource.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
});
|
||||
assert.ok(tencentUpload.upload?.url?.includes('q-sign-algorithm=sha1'), 'Tencent COS signed URL should expose XML V5 query params');
|
||||
assert.ok(!tencentUpload.upload?.url?.includes('SecretKey'), 'Tencent COS signed URL must not leak SecretKey');
|
||||
}
|
||||
|
||||
const unsafeUpload = await request('/api/tenant-content/assets/sign-upload', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: 'unsafe.pdf',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'local_dev',
|
||||
objectKey: '../escape.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(unsafeUpload.code, 'INVALID_OBJECT_KEY', 'upload signer should reject path traversal object keys');
|
||||
|
||||
const crossTenantUpload = await request('/api/tenant-content/assets/sign-upload', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: 'cross-tenant.pdf',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'local_dev',
|
||||
objectKey: `${PARTNER_TENANT_ID}/pdf/cross-tenant.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(crossTenantUpload.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'upload object key should stay in current tenant prefix');
|
||||
|
||||
const deniedMimeUpload = await request('/api/tenant-content/assets/sign-upload', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: 'script.html',
|
||||
assetType: 'document',
|
||||
storageProvider: 'local_dev',
|
||||
mimeType: 'text/html',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(deniedMimeUpload.code, 'MIME_TYPE_NOT_ALLOWED', 'upload signer should reject unsupported mime types');
|
||||
|
||||
const deniedLargeUpload = await request('/api/tenant-content/assets/sign-upload', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: 'huge-video.mp4',
|
||||
assetType: 'video',
|
||||
storageProvider: 'local_dev',
|
||||
mimeType: 'video/mp4',
|
||||
fileSizeBytes: 1024 * 1024 * 1024,
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(deniedLargeUpload.code, 'FILE_TOO_LARGE', 'upload signer should enforce max upload size');
|
||||
|
||||
const asset = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -679,6 +755,50 @@ async function testTenantContentAssetsAndImports() {
|
||||
assert.equal(download.item?.id, asset.item.id, 'svip student should receive asset download');
|
||||
assert.equal(download.access?.svip, true, 'asset download should report svip access');
|
||||
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
|
||||
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
|
||||
|
||||
const localAsset = 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: upload.assetDraft.objectKey,
|
||||
fileName: upload.assetDraft.fileName,
|
||||
mimeType: upload.assetDraft.mimeType,
|
||||
fileSizeBytes: upload.assetDraft.fileSizeBytes,
|
||||
visibility: 'tenant',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
|
||||
|
||||
const localDownload = await request('/api/catalog/assets/download', {
|
||||
query: { assetId: localAsset.item.id },
|
||||
});
|
||||
assert.equal(localDownload.download?.method, 'GET', 'object asset download should sign GET');
|
||||
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 invalidObjectAsset = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
title: '非法对象键',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'local_dev',
|
||||
bucket: 'tenant-assets',
|
||||
objectKey: `${PARTNER_TENANT_ID}/pdf/unsafe.pdf`,
|
||||
fileName: 'unsafe.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
visibility: 'tenant',
|
||||
status: 'active',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidObjectAsset.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'asset upsert should reject cross-tenant object keys');
|
||||
|
||||
const partnerAssetList = await request('/api/tenant-content/assets', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
|
||||
Reference in New Issue
Block a user