This commit is contained in:
2026-07-01 20:21:51 +08:00
parent aea1513836
commit ef6cc18fb2
8 changed files with 178 additions and 20 deletions
+18 -1
View File
@@ -21,4 +21,21 @@ WX_MCH_ID=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
# 阿里云 OSSOSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
OSS_ENABLED=false
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_REGION=oss-cn-hangzhou
# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
OSS_CDN_BASE=
# 直传对象前缀(默认 uploads)
OSS_UPLOAD_PREFIX=uploads
# 直传凭证有效期(秒,默认 900
OSS_UPLOAD_EXPIRE_SECONDS=900
# 单文件大小上限(字节,默认 10MB)
OSS_MAX_UPLOAD_BYTES=10485760
@@ -2,3 +2,4 @@ export const SMS_PROVIDER = 'SMS_PROVIDER';
export const PAY_PROVIDER = 'PAY_PROVIDER';
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
export const OSS_PROVIDER = 'OSS_PROVIDER';
@@ -7,15 +7,19 @@ import { PayWechatProvider } from './pay/pay.wechat.provider';
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
import { WechatApiProvider } from './wechat/wechat.api.provider';
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
import { OssMockProvider } from './oss/oss.mock.provider';
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
import {
SMS_PROVIDER,
PAY_PROVIDER,
DELIVERY_PROVIDER,
WECHAT_PROVIDER,
OSS_PROVIDER,
} from './integrations.constants';
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
import type { IWechatProvider } from './wechat/wechat.interface';
import type { IPayProvider } from './pay/pay.interface';
import type { IOssProvider } from './oss/oss.interface';
@Module({
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
@@ -42,9 +46,19 @@ import type { IPayProvider } from './pay/pay.interface';
inject: [PayMockProvider, PayWechatProvider],
},
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
OssMockProvider,
OssAliyunProvider,
{
provide: OSS_PROVIDER,
useFactory: (mock: OssMockProvider, aliyun: OssAliyunProvider): IOssProvider => {
const cfg = loadAppConfig();
return cfg.ossEnabled && aliyun.isEnabled() ? aliyun : mock;
},
inject: [OssMockProvider, OssAliyunProvider],
},
SmsMockProvider,
DeliveryMockProvider,
],
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER],
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER],
})
export class IntegrationsModule {}
@@ -0,0 +1,69 @@
import { createHmac } from 'crypto';
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { IOssProvider, OssUploadTokenInput, OssUploadTokenResult } from './oss.interface';
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
@Injectable()
export class OssAliyunProvider implements IOssProvider {
private readonly accessKeyId = process.env.OSS_ACCESS_KEY_ID ?? '';
private readonly accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET ?? '';
private readonly bucket = process.env.OSS_BUCKET ?? '';
private readonly region = process.env.OSS_REGION ?? 'oss-cn-hangzhou';
private readonly cdnBase = process.env.OSS_CDN_BASE ?? '';
private readonly uploadPrefix = (process.env.OSS_UPLOAD_PREFIX ?? 'uploads').replace(/\/$/, '');
private readonly expireSeconds = Number(process.env.OSS_UPLOAD_EXPIRE_SECONDS ?? DEFAULT_EXPIRE_SECONDS);
private readonly maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
isEnabled() {
return !!(this.accessKeyId && this.accessKeySecret && this.bucket);
}
buildPublicUrl(ossKey: string) {
const key = ossKey.replace(/^\//, '');
if (this.cdnBase) {
return `${this.cdnBase.replace(/\/$/, '')}/${key}`;
}
return `https://${this.bucket}.${this.region}.aliyuncs.com/${key}`;
}
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
if (!this.isEnabled()) {
throw new Error('OSS credentials are not configured');
}
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
const dir = `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`;
const ossKey = `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
const host = `https://${this.bucket}.${this.region}.aliyuncs.com`;
const policy = {
expiration: expireAt.toISOString(),
conditions: [
['content-length-range', 0, this.maxUploadBytes],
['eq', '$bucket', this.bucket],
['starts-with', '$key', dir],
],
};
const policyBase64 = Buffer.from(JSON.stringify(policy)).toString('base64');
const signature = createHmac('sha1', this.accessKeySecret).update(policyBase64).digest('base64');
return {
bucket: this.bucket,
region: this.region,
ossKey,
url: this.buildPublicUrl(ossKey),
mock: false,
expireAt: expireAt.toISOString(),
mediaType: dto.mediaType,
bizType: dto.bizType,
host,
policy: policyBase64,
signature,
accessKeyId: this.accessKeyId,
};
}
}
@@ -0,0 +1,30 @@
export interface OssUploadTokenInput {
bizType: string;
mediaType: string;
fileName: string;
}
export interface OssUploadTokenResult {
bucket: string;
region: string;
ossKey: string;
url: string;
mock: boolean;
expireAt: string;
mediaType: string;
bizType: string;
/** OSS PostObject 直传 hostmock 时为空 */
host?: string;
/** Base64 policymock 时为空 */
policy?: string;
/** HMAC-SHA1 签名,mock 时为空 */
signature?: string;
/** RAM AccessKeyIdmock 时为空 */
accessKeyId?: string;
}
export interface IOssProvider {
isEnabled(): boolean;
getUploadToken(input: OssUploadTokenInput): OssUploadTokenResult;
buildPublicUrl(ossKey: string): string;
}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { IOssProvider, OssUploadTokenInput, OssUploadTokenResult } from './oss.interface';
@Injectable()
export class OssMockProvider implements IOssProvider {
private readonly bucket = process.env.OSS_BUCKET || 'mock-dukang';
private readonly region = process.env.OSS_REGION || 'oss-cn-hangzhou';
private readonly cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local';
isEnabled() {
return false;
}
buildPublicUrl(ossKey: string) {
return `${this.cdnBase.replace(/\/$/, '')}/${ossKey.replace(/^\//, '')}`;
}
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
const key = `uploads/${dto.bizType.toLowerCase()}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
return {
bucket: this.bucket,
region: this.region,
ossKey: key,
url: this.buildPublicUrl(key),
mock: true,
expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
mediaType: dto.mediaType,
bizType: dto.bizType,
};
}
}
@@ -1,31 +1,22 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type { ResourceListQueryDto } from './dto/common-query.dto';
import type { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto';
@Injectable()
export class ResourceService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
) {}
getUploadToken(dto: UploadTokenDto) {
const bucket = process.env.OSS_BUCKET || 'mock-dukang';
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
const key = `uploads/${dto.bizType.toLowerCase()}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
const cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local';
return {
bucket,
region: process.env.OSS_REGION || 'oss-cn-hangzhou',
ossKey: key,
url: `${cdnBase}/${key}`,
mock: true,
expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
mediaType: dto.mediaType,
bizType: dto.bizType,
};
return this.oss.getUploadToken(dto);
}
async register(dto: RegisterResourceDto) {
@@ -37,7 +28,7 @@ export class ResourceService {
mediaType: dto.mediaType as ResourceMediaType,
ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang',
ossKey: dto.ossKey,
url: dto.url,
url: dto.url || this.oss.buildPublicUrl(dto.ossKey),
fileName: dto.fileName,
sortOrder: dto.sortOrder ?? 0,
},