From ef6cc18fb22bd4a495e4a7575d3f4aef4dd4b8de Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Wed, 1 Jul 2026 20:21:51 +0800 Subject: [PATCH] oss1 --- packages/shared-types/src/config.ts | 3 + server/dukang-api/.env.example | 19 ++++- .../integrations/integrations.constants.ts | 1 + .../src/integrations/integrations.module.ts | 16 ++++- .../integrations/oss/oss.aliyun.provider.ts | 69 +++++++++++++++++++ .../src/integrations/oss/oss.interface.ts | 30 ++++++++ .../src/integrations/oss/oss.mock.provider.ts | 33 +++++++++ .../src/modules/common/resource.service.ts | 27 +++----- 8 files changed, 178 insertions(+), 20 deletions(-) create mode 100644 server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts create mode 100644 server/dukang-api/src/integrations/oss/oss.interface.ts create mode 100644 server/dukang-api/src/integrations/oss/oss.mock.provider.ts diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index 2e78904..7be4ed9 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -7,6 +7,8 @@ export interface AppConfig { wechatAuthEnabled: boolean; wechatPayEnabled: boolean; wxAppId: string; + /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */ + ossEnabled: boolean; } export function loadAppConfig(env?: Record): AppConfig { @@ -23,5 +25,6 @@ export function loadAppConfig(env?: Record): AppConf wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true', wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false', wxAppId: e.WX_APP_ID ?? '', + ossEnabled: e.OSS_ENABLED === 'true', }; } diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 78abcf5..041c185 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -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 \ No newline at end of file +WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay + +# 阿里云 OSS(OSS_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 \ No newline at end of file diff --git a/server/dukang-api/src/integrations/integrations.constants.ts b/server/dukang-api/src/integrations/integrations.constants.ts index e000446..a5b3052 100644 --- a/server/dukang-api/src/integrations/integrations.constants.ts +++ b/server/dukang-api/src/integrations/integrations.constants.ts @@ -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'; diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 267e27f..4b4c154 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -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 {} diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts new file mode 100644 index 0000000..59596f9 --- /dev/null +++ b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts @@ -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, + }; + } +} diff --git a/server/dukang-api/src/integrations/oss/oss.interface.ts b/server/dukang-api/src/integrations/oss/oss.interface.ts new file mode 100644 index 0000000..f422743 --- /dev/null +++ b/server/dukang-api/src/integrations/oss/oss.interface.ts @@ -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 直传 host,mock 时为空 */ + host?: string; + /** Base64 policy,mock 时为空 */ + policy?: string; + /** HMAC-SHA1 签名,mock 时为空 */ + signature?: string; + /** RAM AccessKeyId,mock 时为空 */ + accessKeyId?: string; +} + +export interface IOssProvider { + isEnabled(): boolean; + getUploadToken(input: OssUploadTokenInput): OssUploadTokenResult; + buildPublicUrl(ossKey: string): string; +} diff --git a/server/dukang-api/src/integrations/oss/oss.mock.provider.ts b/server/dukang-api/src/integrations/oss/oss.mock.provider.ts new file mode 100644 index 0000000..d858f28 --- /dev/null +++ b/server/dukang-api/src/integrations/oss/oss.mock.provider.ts @@ -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, + }; + } +} diff --git a/server/dukang-api/src/modules/common/resource.service.ts b/server/dukang-api/src/modules/common/resource.service.ts index c0d099a..a0476f2 100644 --- a/server/dukang-api/src/modules/common/resource.service.ts +++ b/server/dukang-api/src/modules/common/resource.service.ts @@ -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, },