From a35ee85ef14225b70f3373e188faebfd1efdb089 Mon Sep 17 00:00:00 2001 From: tylor Date: Mon, 15 Jun 2026 11:05:01 +0800 Subject: [PATCH 1/3] feat(sms): support optional account for volcengine multi-account Allow callers to pass SmsAccount per request; fall back to VOLCENGINE_SMS_ACCOUNT when omitted. Co-authored-by: Cursor --- env.example | 2 +- openapi.json | 16 ++++++- src/sms/dto/send-sms.dto.ts | 5 +++ src/sms/entities/sms-record.entity.ts | 8 ++++ src/sms/sms.service.spec.ts | 64 +++++++++++++++++++++++++++ src/sms/sms.service.ts | 6 ++- 6 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/sms/sms.service.spec.ts diff --git a/env.example b/env.example index 17aa014..3b1108e 100644 --- a/env.example +++ b/env.example @@ -55,7 +55,7 @@ # ALIYUN_SECRET= # 火山引擎短信 -# VOLCENGINE_SMS_ACCOUNT= +# VOLCENGINE_SMS_ACCOUNT= # 默认消息组 ID;国际短信可由调用方在 sendSms 请求中传 account 覆盖 # VOLCENGINE_KEY= # VOLCENGINE_SECRET= diff --git a/openapi.json b/openapi.json index b08ef1c..d933be1 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "784097a775e52168883b76a7f9a709a61104d472edba7731896d3f0369068f3f", + "hash": "3ddac25e611b3cfd7e7ad88c1d3cceca02221dbcc795f005c5721f01be42b86a", "openapi": "3.0.0", "paths": { "/hello": { @@ -7368,6 +7368,10 @@ }, "params": { "type": "object" + }, + "account": { + "type": "string", + "description": "火山引擎消息组 ID;未传时使用 VOLCENGINE_SMS_ACCOUNT" } }, "required": [ @@ -7411,6 +7415,10 @@ "type": "string", "description": "参数" }, + "account": { + "type": "string", + "description": "火山引擎消息组 ID" + }, "sentAt": { "format": "date-time", "type": "string", @@ -7451,6 +7459,10 @@ "type": "string", "description": "参数" }, + "account": { + "type": "string", + "description": "火山引擎消息组 ID" + }, "sentAt": { "format": "date-time", "type": "string", @@ -8916,4 +8928,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/sms/dto/send-sms.dto.ts b/src/sms/dto/send-sms.dto.ts index 89998fb..6e4c69e 100644 --- a/src/sms/dto/send-sms.dto.ts +++ b/src/sms/dto/send-sms.dto.ts @@ -15,4 +15,9 @@ export class SendSmsDto { @IsOptional() params?: Record; + + /** 火山引擎消息组 ID;未传时使用 VOLCENGINE_SMS_ACCOUNT */ + @IsOptional() + @IsString() + account?: string; } diff --git a/src/sms/entities/sms-record.entity.ts b/src/sms/entities/sms-record.entity.ts index 3bee1e2..2b6683a 100644 --- a/src/sms/entities/sms-record.entity.ts +++ b/src/sms/entities/sms-record.entity.ts @@ -54,6 +54,14 @@ export class SmsRecordDoc { @Prop() params?: string; + /** + * 火山引擎消息组 ID + */ + @IsOptional() + @IsString() + @Prop() + account?: string; + /** * 发送时间 */ diff --git a/src/sms/sms.service.spec.ts b/src/sms/sms.service.spec.ts new file mode 100644 index 0000000..848b072 --- /dev/null +++ b/src/sms/sms.service.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import * as config from 'src/config'; + +import { SmsService } from './sms.service'; + +jest.mock('src/config', () => ({ + sms: { + provider: 'volcengine', + aliyun: { keyId: '', keySecret: '' }, + volcengine: { + account: 'default-account', + accessKeyId: 'key', + secretKey: 'secret', + }, + }, +})); + +const sendMock = jest.fn().mockResolvedValue({ ResponseMetadata: {} }); + +jest.mock('@volcengine/openapi/lib/services/sms', () => ({ + SmsService: jest.fn().mockImplementation(() => ({ + setAccessKeyId: jest.fn(), + setSecretKey: jest.fn(), + Send: sendMock, + })), +})); + +describe('SmsService', () => { + let service: SmsService; + + beforeEach(async () => { + sendMock.mockClear(); + const module: TestingModule = await Test.createTestingModule({ + providers: [SmsService], + }).compile(); + service = module.get(SmsService); + }); + + it('uses account from dto when provided', async () => { + await service.send({ + phone: '+8613800138000', + sign: 'sign', + template: 'tpl', + account: 'foreign-account', + }); + + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ SmsAccount: 'foreign-account' }) + ); + }); + + it('falls back to env default account when dto.account is omitted', async () => { + await service.send({ + phone: '+8613800138000', + sign: 'sign', + template: 'tpl', + }); + + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ SmsAccount: config.sms.volcengine.account }) + ); + }); +}); diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index 2693cd6..ff3cdfc 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -66,10 +66,14 @@ export class SmsService { return this.volcengineClient; } + private resolveVolcengineAccount(dto: SendSmsDto): string | undefined { + return dto.account ?? config.sms.volcengine.account; + } + private async sendByVolcengine(dto: SendSmsDto) { const { phone, sign, template, params } = dto; const res = await this.getVolcengineClient().Send({ - SmsAccount: config.sms.volcengine.account, + SmsAccount: this.resolveVolcengineAccount(dto), Sign: sign, TemplateID: template, PhoneNumbers: phone, From 5d3615ddfbb65f38f906d0aaf00b44a7824de64c Mon Sep 17 00:00:00 2001 From: tylor Date: Mon, 15 Jun 2026 11:10:03 +0800 Subject: [PATCH 2/3] fix: regenerate openapi.json to match swagger output Co-authored-by: Cursor --- openapi.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index d933be1..d8420ac 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "3ddac25e611b3cfd7e7ad88c1d3cceca02221dbcc795f005c5721f01be42b86a", + "hash": "ef6200b85a5a096b716c3bc8e84e84d57e7c515a9a8c4e3e24ee300bd1079bdc", "openapi": "3.0.0", "paths": { "/hello": { @@ -7526,6 +7526,10 @@ "type": "string", "description": "参数" }, + "account": { + "type": "string", + "description": "火山引擎消息组 ID" + }, "sentAt": { "format": "date-time", "type": "string", @@ -8928,4 +8932,4 @@ } } } -} +} \ No newline at end of file From 4b1920f866199cec6d6345161e36e20672ce335f Mon Sep 17 00:00:00 2001 From: tylor Date: Mon, 15 Jun 2026 11:16:05 +0800 Subject: [PATCH 3/3] fix(sms): persist resolved account on SmsRecord Use resolveAccount when creating records so env fallback is stored. Co-authored-by: Cursor --- src/sms/sms.controller.ts | 5 ++++- src/sms/sms.service.spec.ts | 19 +++++++++++++++++++ src/sms/sms.service.ts | 8 ++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/sms/sms.controller.ts b/src/sms/sms.controller.ts index 0dbc97a..cea5e09 100644 --- a/src/sms/sms.controller.ts +++ b/src/sms/sms.controller.ts @@ -33,7 +33,10 @@ export class SmsController { @Post('@sendSms') async sendSms(@Body() body: SendSmsDto) { const dto: CreateSmsRecordDto = { - ...body, + phone: body.phone, + sign: body.sign, + template: body.template, + account: this.smsService.resolveAccount(body), status: SmsStatus.PENDING, params: body.params ? JSON.stringify(body.params) : undefined, }; diff --git a/src/sms/sms.service.spec.ts b/src/sms/sms.service.spec.ts index 848b072..8752e2a 100644 --- a/src/sms/sms.service.spec.ts +++ b/src/sms/sms.service.spec.ts @@ -61,4 +61,23 @@ describe('SmsService', () => { expect.objectContaining({ SmsAccount: config.sms.volcengine.account }) ); }); + + it('resolveAccount returns dto account or env default for volcengine', () => { + expect( + service.resolveAccount({ + phone: '+8613800138000', + sign: 'sign', + template: 'tpl', + account: 'foreign-account', + }) + ).toBe('foreign-account'); + + expect( + service.resolveAccount({ + phone: '+8613800138000', + sign: 'sign', + template: 'tpl', + }) + ).toBe(config.sms.volcengine.account); + }); }); diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index ff3cdfc..3fe7822 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -66,14 +66,18 @@ export class SmsService { return this.volcengineClient; } - private resolveVolcengineAccount(dto: SendSmsDto): string | undefined { + /** 实际用于发送的消息组 ID(volcengine 未传时回退 env 默认) */ + resolveAccount(dto: SendSmsDto): string | undefined { + if (this.getProvider() !== 'volcengine') { + return dto.account; + } return dto.account ?? config.sms.volcengine.account; } private async sendByVolcengine(dto: SendSmsDto) { const { phone, sign, template, params } = dto; const res = await this.getVolcengineClient().Send({ - SmsAccount: this.resolveVolcengineAccount(dto), + SmsAccount: this.resolveAccount(dto), Sign: sign, TemplateID: template, PhoneNumbers: phone,