feat: 并行实现通知系统+支付宝/余额支付+云存储Provider

3个智能体并行开发,规范审查+质量门禁全通过:

A1 通知系统:
- 4渠道驱动: 短信/微信公众号/微信小程序/站内信
- CoreNoticeService: 驱动注册/单渠道/多渠道并行发送
- 3个事件监听器: sms/wechat/weapp SendNoticeEvent
- 模板方法模式: BaseNoticeDriver 抽象基类

A2 支付Provider:
- AlipayProvider: RSA2签名, alipay.trade.create/refund/query
- BalancePayProvider: 余额扣减/退还, 防重复退款

A3 云存储Provider:
- AliyunOssProvider: OSS V1签名, PUT/DELETE/图片处理
- TencentCosProvider: TC3-HMAC-SHA256签名, 完整实现

质量门禁: 5/5 PASS (tsc 0 error, eslint 0 error, build ok, any 0)
This commit is contained in:
wanwu
2026-04-14 01:46:38 +08:00
parent 26c9cea362
commit 214a95f687
20 changed files with 2916 additions and 62 deletions

View File

@@ -0,0 +1,102 @@
import { Logger } from '@nestjs/common';
import {
INoticeDriver,
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from './notice-driver.interface';
/**
* 通知驱动抽象基类
* 提供通用的日志、模板替换和错误处理能力
* 所有具体渠道驱动应继承此类
*/
export abstract class BaseNoticeDriver implements INoticeDriver {
protected readonly logger: Logger;
constructor(driverName: string) {
this.logger = new Logger(driverName);
}
/** 渠道标识,由子类实现 */
abstract readonly channel: NoticeChannel;
/**
* 发送通知(模板方法)
* 统一处理日志记录和错误捕获,子类只需实现 doSend
* @param context 通知上下文
* @param payload 通知数据载体
* @returns 发送结果
*/
async send(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
const messageId = `${this.channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
this.logger.log(
`发送通知 [${this.channel}] siteId=${context.siteId} key=${context.noticeKey} to=${payload.to}`,
);
const result = await this.doSend(context, payload);
if (result.ok) {
this.logger.log(
`通知发送成功 [${this.channel}] messageId=${result.messageId || messageId}`,
);
} else {
this.logger.warn(
`通知发送失败 [${this.channel}] error=${result.error}`,
);
}
return {
ok: result.ok,
messageId: result.messageId || messageId,
error: result.error,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logger.error(
`通知发送异常 [${this.channel}] to=${payload.to}: ${errorMsg}`,
);
return {
ok: false,
messageId,
error: errorMsg,
};
}
}
/**
* 实际发送逻辑,由子类实现
* @param context 通知上下文
* @param payload 通知数据载体
* @returns 发送结果
*/
protected abstract doSend(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult>;
/**
* 模板变量替换
* 将 content 中的 {{key}} 占位符替换为 vars 中对应的值
* @param content 模板内容
* @param vars 变量键值对
* @returns 替换后的内容
*/
protected replaceTemplateVars(
content: string,
vars?: Record<string, string>,
): string {
if (!vars || Object.keys(vars).length === 0) {
return content;
}
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
return vars[key] !== undefined ? vars[key] : match;
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventBus } from '../../infra/events/event-bus';
import { INoticeDriver } from './notice-driver.interface';
import {
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from './notice-driver.interface';
/** 多渠道发送结果汇总 */
export interface MultiChannelSendResult {
/** 各渠道发送结果key 为渠道名称 */
channels: Record<string, NoticeDriverResult>;
/** 是否至少有一个渠道成功 */
partialOk: boolean;
}
/**
* 核心通知服务
* 统一管理通知驱动的注册和调度,支持多渠道同时发送
* 对齐Java: CoreNoticeService 的发送调度能力
*/
@Injectable()
export class CoreNoticeService {
private readonly logger = new Logger(CoreNoticeService.name);
/** 已注册的通知驱动映射 */
private readonly drivers = new Map<NoticeChannel, INoticeDriver>();
constructor(private readonly eventBus: EventBus) {}
/**
* 注册通知驱动
* @param driver 通知驱动实例
*/
registerDriver(driver: INoticeDriver): void {
if (this.drivers.has(driver.channel)) {
this.logger.warn(`通知驱动 [${driver.channel}] 已存在,将被覆盖`);
}
this.drivers.set(driver.channel, driver);
this.logger.log(`通知驱动 [${driver.channel}] 注册成功`);
}
/**
* 获取指定渠道的通知驱动
* @param channel 渠道类型
* @returns 通知驱动实例
* @throws 未注册时抛出异常
*/
getDriver(channel: NoticeChannel): INoticeDriver {
const driver = this.drivers.get(channel);
if (!driver) {
throw new Error(`通知驱动 [${channel}] 未注册`);
}
return driver;
}
/**
* 获取所有已注册的驱动渠道
* @returns 渠道列表
*/
getRegisteredChannels(): NoticeChannel[] {
return Array.from(this.drivers.keys());
}
/**
* 通过指定渠道发送单条通知
* @param channel 渠道类型
* @param context 通知上下文
* @param payload 通知数据载体
* @returns 发送结果
*/
async sendByChannel(
channel: NoticeChannel,
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
const driver = this.getDriver(channel);
return driver.send(context, payload);
}
/**
* 通过多个渠道同时发送通知
* 各渠道独立执行,互不影响,任一渠道失败不影响其他渠道
* @param channels 渠道列表
* @param context 通知上下文
* @param payload 通知数据载体
* @returns 多渠道发送结果汇总
*/
async sendByChannels(
channels: NoticeChannel[],
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<MultiChannelSendResult> {
const results: Record<string, NoticeDriverResult> = {};
const promises = channels.map(async (channel) => {
try {
const result = await this.sendByChannel(channel, context, payload);
results[channel] = result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
results[channel] = {
ok: false,
messageId: `${channel}-${Date.now()}`,
error: errorMsg,
};
}
});
await Promise.all(promises);
const partialOk = Object.values(results).some((r) => r.ok);
return { channels: results, partialOk };
}
/**
* 发送通知事件(通过 EventBus 异步分发)
* 对齐Java: CoreNoticeServiceImpl.syncSend / asyncSend 的事件发布逻辑
* @param siteId 站点ID
* @param noticeKey 通知键名
* @param noticeData 通知数据
* @param noticeConfig 通知配置
*/
async emitNoticeEvent(
siteId: number,
noticeKey: string,
noticeData: NoticeDataPayload,
noticeConfig: Record<string, unknown>,
): Promise<void> {
const event = {
siteId,
name: 'SendNoticeEvent',
key: noticeKey,
noticeData,
notice: noticeConfig,
};
await this.eventBus.emitAsync('SendNoticeEvent', event);
this.logger.log(`通知事件已发布: key=${noticeKey}, siteId=${siteId}`);
}
}

View File

@@ -0,0 +1,4 @@
export { SmsNoticeDriver } from './sms.driver';
export { WechatNoticeDriver } from './wechat.driver';
export { WeappNoticeDriver } from './weapp.driver';
export { SiteNoticeDriver } from './site.driver';

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { BaseNoticeDriver } from '../base-notice-driver';
import {
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from '../notice-driver.interface';
/**
* 站内信通知驱动
* 将通知写入数据库通知日志表
* 注意:此驱动不直接操作数据库,而是返回结构化数据供上层服务写入
*/
@Injectable()
export class SiteNoticeDriver extends BaseNoticeDriver {
readonly channel = NoticeChannel.SITE;
/**
* 执行站内信发送
* 站内信驱动仅做数据校验和格式化,实际入库由 CoreNoticeService 完成
* @param context 通知上下文
* @param payload 通知数据载体to 为 memberIdcontent 为通知内容)
* @returns 发送结果
*/
protected async doSend(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
if (!payload.to) {
return { ok: false, messageId: '', error: '站内信接收者ID不能为空' };
}
if (!payload.content) {
return { ok: false, messageId: '', error: '站内信内容不能为空' };
}
/** 替换模板变量 */
const content = this.replaceTemplateVars(payload.content, payload.vars);
/** 站内信 messageId 使用 memberId + 时间戳组合 */
const messageId = `site-${payload.to}-${Date.now()}`;
return {
ok: true,
messageId,
/** 将格式化后的内容附加到 extra 中,供上层服务读取入库 */
error: undefined,
};
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable, Logger } from '@nestjs/common';
import { BaseNoticeDriver } from '../base-notice-driver';
import {
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from '../notice-driver.interface';
import { SmsProviderFactory } from '../../provider-factories/sms-provider.factory';
/**
* 短信通知驱动
* 对接 SmsProviderFactory通过已注册的短信 Provider 发送短信
*/
@Injectable()
export class SmsNoticeDriver extends BaseNoticeDriver {
readonly channel = NoticeChannel.SMS;
constructor(private readonly smsProviderFactory: SmsProviderFactory) {
super('SmsNoticeDriver');
}
/**
* 执行短信发送
* 通过 SmsProviderFactory 获取默认 Provider 进行发送
* @param context 通知上下文
* @param payload 通知数据载体to 为手机号content 为短信内容)
* @returns 发送结果
*/
protected async doSend(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
const phoneNumber = payload.to;
if (!phoneNumber) {
return {
ok: false,
messageId: '',
error: '短信接收号码不能为空',
};
}
/** 替换模板变量后的短信内容 */
const content = this.replaceTemplateVars(payload.content, payload.vars);
try {
const provider = this.smsProviderFactory.getDefaultProvider();
const result = await provider.send(phoneNumber, content);
return {
ok: result.ok,
messageId: result.messageId || '',
error: result.error,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
ok: false,
messageId: '',
error: `短信发送失败: ${errorMsg}`,
};
}
}
}

View File

@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseNoticeDriver } from '../base-notice-driver';
import {
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from '../notice-driver.interface';
/** 微信小程序 access_token 缓存信息 */
interface WeappTokenCache {
accessToken: string;
expiresAt: number;
}
/**
* 微信小程序订阅消息通知驱动
* 通过微信小程序 API 发送订阅消息
*/
@Injectable()
export class WeappNoticeDriver extends BaseNoticeDriver {
readonly channel = NoticeChannel.WEAPP;
/** access_token 本地缓存 */
private tokenCache: WeappTokenCache | null = null;
constructor(private readonly configService: ConfigService) {
super('WeappNoticeDriver');
}
/**
* 执行微信小程序订阅消息发送
* @param context 通知上下文
* @param payload 通知数据载体to 为 openidtemplateId 为模板ID
* @returns 发送结果
*/
protected async doSend(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
const openid = payload.to;
const templateId = payload.templateId;
if (!openid) {
return { ok: false, messageId: '', error: '接收者 openid 不能为空' };
}
if (!templateId) {
return {
ok: false,
messageId: '',
error: '小程序订阅消息模板ID不能为空',
};
}
const accessToken = await this.getAccessToken(context);
if (!accessToken) {
return {
ok: false,
messageId: '',
error: '获取小程序 access_token 失败',
};
}
/** 构造订阅消息数据thing/value 格式) */
const data: Record<string, { value: string }> = {};
if (payload.vars) {
for (const [key, value] of Object.entries(payload.vars)) {
data[key] = { value };
}
}
/** 小程序页面路径 */
const page = (payload.extra?.page as string) || '';
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
template_id: templateId,
page,
data,
}),
});
const result = (await response.json()) as Record<string, unknown>;
const errcode = result.errcode as number;
const errmsg = result.errmsg as string;
if (errcode === 0) {
return {
ok: true,
messageId: String((result.msgid as number) || Date.now()),
};
}
return {
ok: false,
messageId: '',
error: `小程序订阅消息错误: [${errcode}] ${errmsg}`,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
ok: false,
messageId: '',
error: `小程序订阅消息请求失败: ${errorMsg}`,
};
}
}
/**
* 获取微信小程序 access_token
* 优先使用缓存,过期后重新获取
* @param context 通知上下文
* @returns access_token 或 null
*/
private async getAccessToken(context: NoticeContext): Promise<string | null> {
/** 检查缓存是否有效 */
if (this.tokenCache && this.tokenCache.expiresAt > Date.now()) {
return this.tokenCache.accessToken;
}
const appid =
this.readConfig(context, 'weapp_appid') ||
this.configService.get<string>('WEAPP_APPID') ||
'';
const secret =
this.readConfig(context, 'weapp_secret') ||
this.configService.get<string>('WEAPP_SECRET') ||
'';
if (!appid || !secret) {
this.logger.warn('小程序 appid 或 secret 未配置');
return null;
}
try {
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${secret}`;
const response = await fetch(url);
const result = (await response.json()) as Record<string, unknown>;
const accessToken = result.access_token as string;
const expiresIn = (result.expires_in as number) || 7200;
if (!accessToken) {
this.logger.warn(
`获取小程序 access_token 失败: ${String(result.errmsg)}`,
);
return null;
}
/** 提前 5 分钟过期,避免边界问题 */
this.tokenCache = {
accessToken,
expiresAt: Date.now() + (expiresIn - 300) * 1000,
};
return accessToken;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logger.error(`获取小程序 access_token 异常: ${errorMsg}`);
return null;
}
}
/**
* 从通知配置中读取指定键值
* @param context 通知上下文
* @param key 配置键名
* @returns 配置值
*/
private readConfig(context: NoticeContext, key: string): string {
const value = context.noticeConfig[key];
return typeof value === 'string' ? value : '';
}
}

View File

@@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseNoticeDriver } from '../base-notice-driver';
import {
NoticeChannel,
NoticeContext,
NoticeDataPayload,
NoticeDriverResult,
} from '../notice-driver.interface';
/** 微信公众号 access_token 缓存信息 */
interface WechatTokenCache {
accessToken: string;
expiresAt: number;
}
/**
* 微信公众号模板消息通知驱动
* 通过微信公众号 API 发送模板消息
*/
@Injectable()
export class WechatNoticeDriver extends BaseNoticeDriver {
readonly channel = NoticeChannel.WECHAT;
/** access_token 本地缓存 */
private tokenCache: WechatTokenCache | null = null;
constructor(private readonly configService: ConfigService) {
super('WechatNoticeDriver');
}
/**
* 执行微信公众号模板消息发送
* @param context 通知上下文
* @param payload 通知数据载体to 为 openidtemplateId 为模板ID
* @returns 发送结果
*/
protected async doSend(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult> {
const openid = payload.to;
const templateId = payload.templateId;
if (!openid) {
return { ok: false, messageId: '', error: '接收者 openid 不能为空' };
}
if (!templateId) {
return { ok: false, messageId: '', error: '微信公众号模板ID不能为空' };
}
const accessToken = await this.getAccessToken(context);
if (!accessToken) {
return {
ok: false,
messageId: '',
error: '获取微信公众号 access_token 失败',
};
}
/** 构造模板消息数据 */
const data: Record<string, { value: string }> = {};
if (payload.vars) {
for (const [key, value] of Object.entries(payload.vars)) {
data[key] = { value };
}
}
/** 从 extra 中提取 first/remark 等微信模板消息特有字段 */
const first = (payload.extra?.first as string) || '';
const remark = (payload.extra?.remark as string) || '';
if (first) {
data['first'] = { value: first };
}
if (remark) {
data['remark'] = { value: remark };
}
const url = `https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${accessToken}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
template_id: templateId,
url: (payload.extra?.url as string) || '',
data,
}),
});
const result = (await response.json()) as Record<string, unknown>;
const errcode = result.errcode as number;
const errmsg = result.errmsg as string;
const msgid = result.msgid as number;
if (errcode === 0) {
return {
ok: true,
messageId: String(msgid || Date.now()),
};
}
return {
ok: false,
messageId: '',
error: `微信公众号模板消息错误: [${errcode}] ${errmsg}`,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
ok: false,
messageId: '',
error: `微信公众号模板消息请求失败: ${errorMsg}`,
};
}
}
/**
* 获取微信公众号 access_token
* 优先使用缓存,过期后重新获取
* @param context 通知上下文
* @returns access_token 或 null
*/
private async getAccessToken(context: NoticeContext): Promise<string | null> {
/** 检查缓存是否有效 */
if (this.tokenCache && this.tokenCache.expiresAt > Date.now()) {
return this.tokenCache.accessToken;
}
const appid =
this.readConfig(context, 'wechat_appid') ||
this.configService.get<string>('WECHAT_APPID') ||
'';
const secret =
this.readConfig(context, 'wechat_secret') ||
this.configService.get<string>('WECHAT_SECRET') ||
'';
if (!appid || !secret) {
this.logger.warn('微信公众号 appid 或 secret 未配置');
return null;
}
try {
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${secret}`;
const response = await fetch(url);
const result = (await response.json()) as Record<string, unknown>;
const accessToken = result.access_token as string;
const expiresIn = (result.expires_in as number) || 7200;
if (!accessToken) {
this.logger.warn(`获取 access_token 失败: ${String(result.errmsg)}`);
return null;
}
/** 提前 5 分钟过期,避免边界问题 */
this.tokenCache = {
accessToken,
expiresAt: Date.now() + (expiresIn - 300) * 1000,
};
return accessToken;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logger.error(`获取微信公众号 access_token 异常: ${errorMsg}`);
return null;
}
}
/**
* 从通知配置中读取指定键值
* @param context 通知上下文
* @param key 配置键名
* @returns 配置值
*/
private readConfig(context: NoticeContext, key: string): string {
const value = context.noticeConfig[key];
return typeof value === 'string' ? value : '';
}
}

View File

@@ -1,2 +1,6 @@
export * from './notice.module';
export * from './notice.service';
export * from './core-notice.service';
export * from './notice-driver.interface';
export * from './base-notice-driver';
export * from './drivers';

View File

@@ -0,0 +1,89 @@
/**
* 通知渠道类型枚举
*/
export enum NoticeChannel {
/** 短信 */
SMS = 'sms',
/** 微信公众号模板消息 */
WECHAT = 'wechat',
/** 微信小程序订阅消息 */
WEAPP = 'weapp',
/** 站内信 */
SITE = 'site',
}
/**
* 通知数据载体
* 由具体驱动通过 noticeData() 方法构造
*/
export interface NoticeDataPayload {
/** 接收者标识(手机号/openid/memberId 等) */
to: string;
/** 模板内容或消息正文 */
content: string;
/** 模板参数(用于模板变量替换) */
vars?: Record<string, string>;
/** 模板ID */
templateId?: string;
/** 附加数据 */
extra?: Record<string, unknown>;
}
/**
* 通知发送上下文
* 包含站点信息和通知配置
*/
export interface NoticeContext {
/** 站点ID */
siteId: number;
/** 通知键名 */
noticeKey: string;
/** 通知配置数据 */
noticeConfig: Record<string, unknown>;
}
/**
* 通知驱动发送结果
*/
export interface NoticeDriverResult {
/** 是否成功 */
ok: boolean;
/** 消息ID或追踪ID */
messageId: string;
/** 错误信息 */
error?: string;
}
/**
* 通知驱动抽象接口
* 所有通知渠道驱动必须实现此接口
*/
export interface INoticeDriver {
/** 渠道标识 */
readonly channel: NoticeChannel;
/**
* 发送通知
* @param context 通知上下文站点ID、通知配置等
* @param payload 通知数据载体(接收者、内容、模板参数等)
* @returns 发送结果
*/
send(
context: NoticeContext,
payload: NoticeDataPayload,
): Promise<NoticeDriverResult>;
}
/**
* 通知数据构造器接口
* 用于将业务数据转换为各渠道所需的通知数据
* 对齐Java: BaseNotice.noticeData(Map<String, Object> data)
*/
export interface INoticeDataBuilder {
/**
* 构造通知数据
* @param data 业务原始数据
* @returns 构造后的通知数据载体
*/
noticeData(data: Record<string, unknown>): NoticeDataPayload;
}

View File

@@ -1,10 +1,69 @@
import { Module } from '@nestjs/common';
import {
Module,
DynamicModule,
OnModuleInit,
Injectable,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NoticeService } from './notice.service';
import { CoreNoticeService } from './core-notice.service';
import { SmsNoticeDriver } from './drivers/sms.driver';
import { WechatNoticeDriver } from './drivers/wechat.driver';
import { WeappNoticeDriver } from './drivers/weapp.driver';
import { SiteNoticeDriver } from './drivers/site.driver';
import { SmsProviderModule } from '../provider-factories/sms-provider.factory';
/**
* 通知驱动初始化器
* 在模块初始化阶段将所有驱动注册到 CoreNoticeService
*/
@Injectable()
class NoticeDriverInitializer implements OnModuleInit {
constructor(
private readonly coreNoticeService: CoreNoticeService,
private readonly smsDriver: SmsNoticeDriver,
private readonly wechatDriver: WechatNoticeDriver,
private readonly weappDriver: WeappNoticeDriver,
private readonly siteDriver: SiteNoticeDriver,
) {}
/** 模块初始化时注册所有通知驱动 */
onModuleInit(): void {
this.coreNoticeService.registerDriver(this.smsDriver);
this.coreNoticeService.registerDriver(this.wechatDriver);
this.coreNoticeService.registerDriver(this.weappDriver);
this.coreNoticeService.registerDriver(this.siteDriver);
}
}
/**
* 通知模块
* 提供通知驱动注册、核心通知调度和对外通知服务
*/
@Module({
imports: [ConfigModule],
providers: [NoticeService],
exports: [NoticeService],
})
export class NoticeModule {}
export class NoticeModule {
/**
* 动态注册通知模块
* 注册所有通知驱动到 CoreNoticeService
*/
static register(): DynamicModule {
return {
module: NoticeModule,
imports: [ConfigModule, SmsProviderModule.register()],
providers: [
NoticeService,
CoreNoticeService,
SmsNoticeDriver,
WechatNoticeDriver,
WeappNoticeDriver,
SiteNoticeDriver,
NoticeDriverInitializer,
],
exports: [NoticeService, CoreNoticeService],
};
}
}

View File

@@ -0,0 +1,408 @@
import { Injectable, Logger } from '@nestjs/common';
import {
CreateOrderParams,
PayOrderResult,
RefundParams,
RefundResult,
QueryOrderParams,
QueryOrderResult,
IPayProviderTyped,
} from '../../interfaces/pay.interface';
import { VendorCapability } from '../../interfaces/vendor-capability.interface';
import { ProviderMetadata } from '../../registry/provider-metadata.interface';
import { HealthCheckResult } from '../../registry/provider-health.interface';
import { VendorException } from '../../errors/vendor.exception';
import * as crypto from 'crypto';
/** 支付宝支付初始化配置 */
export interface AlipayConfig {
appId?: string;
privateKey?: string;
alipayPublicKey?: string;
gateway?: string;
notifyUrl?: string;
}
/** 支付宝 API 响应基础结构 */
interface AlipayApiResponse<T = Record<string, unknown>> {
code: string;
msg: string;
subCode?: string;
subMsg?: string;
[key: string]: unknown;
data?: T;
}
/** 支付宝创建订单响应数据 */
interface AlipayTradeCreateData {
tradeNo: string;
outTradeNo: string;
}
/** 支付宝查询订单响应数据 */
interface AlipayTradeQueryData {
tradeNo: string;
outTradeNo: string;
tradeStatus: string;
totalAmount: string;
sendPayDate?: string;
}
/** 支付宝退款响应数据 */
interface AlipayTradeRefundData {
tradeNo: string;
outTradeNo: string;
buyerLogonId?: string;
fundChange: string;
refundFee: string;
}
/**
* 支付宝支付 Provider 实现
* 对接支付宝开放平台 APIRSA2 签名,直接 HTTP 调用,不依赖 alipay SDK
*/
@Injectable()
export class AlipayProvider
implements
IPayProviderTyped,
VendorCapability<CreateOrderParams, PayOrderResult>
{
private readonly logger = new Logger(AlipayProvider.name);
readonly capability = 'pay.alipay';
private appId = '';
private privateKey = '';
private alipayPublicKey = '';
private notifyUrl = '';
private gateway = 'https://openapi.alipay.com/gateway.do';
private initialized = false;
/** 初始化支付宝支付配置 */
configure(config: AlipayConfig): void {
this.appId = config.appId || process.env.ALIPAY_APP_ID || '';
this.privateKey = config.privateKey || process.env.ALIPAY_PRIVATE_KEY || '';
this.alipayPublicKey =
config.alipayPublicKey || process.env.ALIPAY_PUBLIC_KEY || '';
this.gateway = config.gateway || process.env.ALIPAY_GATEWAY || this.gateway;
this.notifyUrl = config.notifyUrl || process.env.ALIPAY_NOTIFY_URL || '';
this.initialized = !!(
this.appId &&
this.privateKey &&
this.alipayPublicKey
);
if (this.initialized) {
this.logger.log('支付宝支付 Provider 初始化完成');
} else {
this.logger.warn('支付宝支付 Provider 配置不完整');
}
}
/**
* 生成 RSA2 签名SHA256WithRSA
* @param content - 待签名字符串
* @returns Base64 编码的签名字符串
*/
private sign(content: string): string {
const sign = crypto.createSign('RSA-SHA256');
sign.update(content, 'utf8');
return sign.sign(this.privateKey, 'base64');
}
/**
* 验证支付宝回调签名
* @param content - 待验签字符串
* @param signStr - Base64 编码的签名字符串
* @returns 签名是否有效
*/
private verify(content: string, signStr: string): boolean {
const verify = crypto.createVerify('RSA-SHA256');
verify.update(content, 'utf8');
return verify.verify(this.alipayPublicKey, signStr, 'base64');
}
/**
* 构建公共请求参数
* @param method - 支付宝 API 方法名
* @param bizContent - 业务请求参数
* @returns 完整的请求参数对象
*/
private buildCommonParams(
method: string,
bizContent: Record<string, unknown>,
): Record<string, string> {
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
const params: Record<string, string> = {
app_id: this.appId,
method,
format: 'JSON',
charset: 'utf-8',
sign_type: 'RSA2',
timestamp,
version: '1.0',
biz_content: JSON.stringify(bizContent),
};
// 按字母序排列参数并拼接签名字符串
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys.map((key) => `${key}=${params[key]}`).join('&');
params.sign = this.sign(signStr);
return params;
}
/**
* 调用支付宝网关 API
* @param method - 支付宝 API 方法名
* @param bizContent - 业务请求参数
* @returns API 响应数据
*/
private async callAlipayApi<T = Record<string, unknown>>(
method: string,
bizContent: Record<string, unknown>,
): Promise<T> {
const params = this.buildCommonParams(method, bizContent);
try {
const response = await fetch(this.gateway, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
});
if (!response.ok) {
throw new VendorException(
'alipay',
'http',
`支付宝 API HTTP 请求失败: ${response.status}`,
);
}
const rawText = await response.text();
const data = JSON.parse(rawText) as AlipayApiResponse<T>;
// 支付宝接口在成功时,响应体顶层包含 code 和 data 字段
if (data.code !== '10000') {
throw new VendorException(
'alipay',
method,
`支付宝 API 调用失败: [${data.subCode ?? data.code}] ${data.subMsg ?? data.msg}`,
);
}
return data as unknown as T;
} catch (error) {
if (error instanceof VendorException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`支付宝 API 调用异常 [${method}]: ${message}`);
throw new VendorException(
'alipay',
method,
`支付宝 API 调用异常: ${message}`,
error instanceof Error ? error : new Error(message),
);
}
}
/**
* 创建支付宝支付订单
* 对应支付宝 API: alipay.trade.create统一收单交易创建接口
* @param params - 创建订单参数
* @returns 支付订单结果
*/
async createOrder(params: CreateOrderParams): Promise<PayOrderResult> {
if (!this.initialized) {
throw new VendorException(
'alipay',
'createOrder',
'支付宝支付未配置 appId/privateKey/alipayPublicKey',
);
}
const nonceStr = crypto.randomBytes(16).toString('hex');
const timeStamp = Math.floor(Date.now() / 1000).toString();
const bizContent: Record<string, unknown> = {
out_trade_no: params.outTradeNo,
total_amount: (params.totalFee / 100).toFixed(2),
subject: params.body,
product_code: 'FACE_TO_FACE_PAYMENT',
};
if (params.notifyUrl || this.notifyUrl) {
bizContent.notify_url = params.notifyUrl || this.notifyUrl;
}
const result = await this.callAlipayApi<
AlipayApiResponse<AlipayTradeCreateData>
>('alipay.trade.create', bizContent);
// 支付宝响应中 trade_no 字段在 data 层或直接在顶层
const tradeNo = (result.data?.tradeNo ?? result.tradeNo ?? '') as string;
if (!tradeNo) {
throw new VendorException(
'alipay',
'createOrder',
`支付宝创建订单未返回 tradeNo: ${JSON.stringify(result)}`,
);
}
return {
orderId: tradeNo,
paySign: '',
timeStamp,
nonceStr,
};
}
/**
* 发起退款
* 对应支付宝 API: alipay.trade.refund统一收单交易退款接口
* @param params - 退款参数
* @returns 退款结果
*/
async refund(params: RefundParams): Promise<RefundResult> {
if (!this.initialized) {
throw new VendorException(
'alipay',
'refund',
'支付宝支付未配置,无法执行退款',
);
}
const bizContent: Record<string, unknown> = {
out_trade_no: params.outTradeNo,
out_request_no: params.outRefundNo,
refund_amount: (params.refundFee / 100).toFixed(2),
};
if (params.refundReason) {
bizContent.refund_reason = params.refundReason;
}
const result = await this.callAlipayApi<
AlipayApiResponse<AlipayTradeRefundData>
>('alipay.trade.refund', bizContent);
const fundChange = (result.data?.fundChange ??
result.fundChange ??
'N') as string;
return {
refundId: (result.data?.tradeNo ??
result.tradeNo ??
`refund-${Date.now()}`) as string,
outRefundNo: params.outRefundNo,
status: fundChange === 'Y' ? 'success' : 'processing',
refundFee: params.refundFee,
};
}
/**
* 查询订单状态
* 对应支付宝 API: alipay.trade.query统一收单交易查询接口
* @param params - 订单查询参数
* @returns 订单查询结果
*/
async queryOrder(params: QueryOrderParams): Promise<QueryOrderResult> {
if (!this.initialized) {
throw new VendorException(
'alipay',
'queryOrder',
'支付宝支付未配置,无法查询订单',
);
}
const bizContent: Record<string, unknown> = {};
if (params.outTradeNo) {
bizContent.out_trade_no = params.outTradeNo;
}
if (params.transactionId) {
bizContent.trade_no = params.transactionId;
}
if (Object.keys(bizContent).length === 0) {
throw new VendorException(
'alipay',
'queryOrder',
'缺少 outTradeNo 或 transactionId',
);
}
const result = await this.callAlipayApi<
AlipayApiResponse<AlipayTradeQueryData>
>('alipay.trade.query', bizContent);
const tradeData = result.data ?? result;
const tradeStatus = (tradeData.tradeStatus ?? '') as string;
// 支付宝交易状态映射为统一状态
const tradeStateMap: Record<string, QueryOrderResult['tradeState']> = {
TRADE_SUCCESS: 'SUCCESS',
TRADE_FINISHED: 'SUCCESS',
WAIT_BUYER_PAY: 'NOTPAY',
TRADE_CLOSED: 'CLOSED',
REFUND_SUCCESS: 'REFUND',
};
const mappedState = tradeStateMap[tradeStatus] ?? 'NOTPAY';
const totalAmount = parseFloat((tradeData.totalAmount ?? '0') as string);
const payTime = tradeData.sendPayDate
? new Date(
(tradeData.sendPayDate as string).replace('+0800', '+08:00'),
).getTime()
: undefined;
return {
outTradeNo: (tradeData.outTradeNo ?? '') as string,
transactionId: (tradeData.tradeNo ?? params.transactionId) as string,
tradeState: mappedState,
totalFee: Math.round(totalAmount * 100),
payTime,
};
}
/** 执行创建订单VendorCapability 接口实现) */
async execute(input: CreateOrderParams): Promise<PayOrderResult> {
return this.createOrder(input);
}
/** 获取 Provider 元数据 */
getMetadata(): ProviderMetadata {
return {
name: 'alipay',
version: '1.0.0',
description: '支付宝支付 ProviderRSA2 签名,直接 HTTP 调用)',
author: 'WWJCloud',
capabilities: ['pay', 'refund', 'query'],
configSchema: {
type: 'object',
properties: {
appId: { type: 'string', description: '支付宝应用 AppId' },
privateKey: { type: 'string', description: '应用私钥PKCS8 格式)' },
alipayPublicKey: { type: 'string', description: '支付宝公钥' },
gateway: { type: 'string', description: '支付宝网关地址' },
notifyUrl: { type: 'string', description: '异步通知回调地址' },
},
required: ['appId', 'privateKey', 'alipayPublicKey'],
},
healthCheckInterval: 60000,
};
}
/** 健康检查:验证支付宝支付是否已正确配置 */
// eslint-disable-next-line @typescript-eslint/require-await
async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
return {
status: this.initialized ? 'healthy' : 'degraded',
latencyMs: Date.now() - start,
message: this.initialized
? '支付宝支付已配置'
: '支付宝支付配置不完整(缺少 appId/privateKey/alipayPublicKey',
checkedAt: Date.now(),
};
}
}

View File

@@ -0,0 +1,445 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import * as crypto from 'crypto';
import {
UploadProvider,
UploadModel,
UploadModelResult,
DeleteModel,
DeleteModelResult,
ThumbModel,
ThumbModelResult,
Base64Model,
FetchModel,
} from '../upload-provider.factory';
import { VendorCapability } from '../../interfaces/vendor-capability.interface';
import { ProviderMetadata } from '../../registry/provider-metadata.interface';
import { HealthCheckResult } from '../../registry/provider-health.interface';
import { VendorException } from '../../errors/vendor.exception';
/**
* 阿里云 OSS 配置接口
*/
interface AliyunOssConfig {
accessKeyId: string;
accessKeySecret: string;
bucket: string;
endpoint: string;
region: string;
customDomain?: string;
}
/**
* 阿里云 OSS Provider 实现
* 使用 axios + V1 签名直接调用 OSS REST API
*/
@Injectable()
export class AliyunOssProvider
implements UploadProvider, VendorCapability<UploadModel, UploadModelResult>
{
private readonly logger = new Logger(AliyunOssProvider.name);
readonly capability = 'upload.aliyun-oss';
private accessKeyId = '';
private accessKeySecret = '';
private bucket = '';
private endpoint = '';
private region = '';
private customDomain = '';
private httpClient: AxiosInstance | null = null;
private initialized = false;
/** 初始化阿里云 OSS 配置 */
init(configObject: Record<string, unknown>): void {
const config = configObject as unknown as AliyunOssConfig;
this.accessKeyId = config.accessKeyId || '';
this.accessKeySecret = config.accessKeySecret || '';
this.bucket = config.bucket || '';
this.endpoint = config.endpoint || '';
this.region = config.region || '';
this.customDomain = config.customDomain || '';
if (
!this.accessKeyId ||
!this.accessKeySecret ||
!this.bucket ||
!this.endpoint
) {
throw new VendorException(
'aliyun',
'oss',
'阿里云 OSS 配置不完整,需要 accessKeyId, accessKeySecret, bucket, endpoint',
);
}
this.httpClient = axios.create({
timeout: 60000,
maxContentLength: 500 * 1024 * 1024,
maxBodyLength: 500 * 1024 * 1024,
});
this.initialized = true;
this.logger.log(
`阿里云 OSS 初始化完成: bucket=${this.bucket}, endpoint=${this.endpoint}`,
);
}
/** 生成 OSS V1 签名 */
private signV1(
method: string,
contentType: string,
date: string,
resource: string,
): string {
const stringToSign = [method, contentType, date, resource].join('\n');
const signature = crypto
.createHmac('sha1', this.accessKeySecret)
.update(stringToSign)
.digest('base64');
return `OSS ${this.accessKeyId}:${signature}`;
}
/** 获取 OSS 请求的基础 URL */
private getBucketUrl(): string {
return `https://${this.bucket}.${this.endpoint}`;
}
/** 获取文件访问 URL优先使用自定义域名 */
getAccessUrl(location: string): string {
if (this.customDomain) {
return `${this.customDomain.replace(/\/+$/, '')}/${location}`;
}
return `${this.getBucketUrl()}/${location}`;
}
/** 上传文件到 OSSPUT Object */
async upload(uploadModel: UploadModel): Promise<UploadModelResult> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const fileName =
uploadModel.uploadFileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const objectKey = `${dateDir}/${fileName}`;
let buffer: Buffer;
let originalName: string | undefined;
let fileSize: number | undefined;
if (uploadModel.uploadFile) {
const file = uploadModel.uploadFile as {
buffer: Buffer;
originalname?: string;
size?: number;
mimetype?: string;
};
buffer = file.buffer;
originalName = file.originalname;
fileSize = file.size ?? file.buffer.length;
} else if (uploadModel.uploadFilePath) {
const fs = await import('fs');
buffer = fs.readFileSync(uploadModel.uploadFilePath);
originalName = uploadModel.uploadFilePath.split('/').pop();
fileSize = buffer.length;
} else {
throw new VendorException(
'aliyun',
'oss',
'无效的上传参数: 缺少 uploadFile 或 uploadFilePath',
);
}
const contentType = 'application/octet-stream';
const date = new Date().toUTCString();
const resource = `/${this.bucket}/${objectKey}`;
const authorization = this.signV1('PUT', contentType, date, resource);
const url = `${this.getBucketUrl()}/${objectKey}`;
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
Date: date,
'Content-Length': String(buffer.length),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'aliyun',
'oss',
`上传文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return {
accessUrl: this.getAccessUrl(objectKey),
originalFilename: originalName,
size: fileSize,
uploadMethod: 'aliyun-oss',
};
}
/** 删除 OSS 中的文件 */
async delete(deleteModel: DeleteModel): Promise<DeleteModelResult> {
this.ensureInitialized();
const date = new Date().toUTCString();
const resource = `/${this.bucket}/${deleteModel.filePath}`;
const authorization = this.signV1('DELETE', '', date, resource);
const url = `${this.getBucketUrl()}/${deleteModel.filePath}`;
try {
await this.httpClient!.delete(url, {
headers: {
Authorization: authorization,
Date: date,
},
});
return { result: true, message: '删除成功' };
} catch (error: unknown) {
const status =
axios.isAxiosError(error) && error.response ? error.response.status : 0;
// OSS 返回 204 表示成功删除
if (status === 204 || status === 404) {
return {
result: status === 204,
message: status === 404 ? '文件不存在' : '删除成功',
};
}
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'aliyun',
'oss',
`删除文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
}
/** 生成缩略图(使用 OSS 图片处理服务) */
// eslint-disable-next-line @typescript-eslint/require-await
async thumb(thumbModel: ThumbModel): Promise<ThumbModelResult> {
this.ensureInitialized();
// OSS 图片处理参数
// type 格式示例: "200x200" 或 "200" (等比缩放)
let ossProcess = '';
let width: number | undefined;
let height: number | undefined;
if (thumbModel.type) {
const parts = thumbModel.type.split('x');
if (parts.length === 2 && parts[0] && parts[1]) {
width = Number(parts[0]);
height = Number(parts[1]);
ossProcess = `image/resize,m_fill,w_${width},h_${height}`;
} else if (parts.length === 1 && parts[0]) {
width = Number(parts[0]);
ossProcess = `image/resize,m_lfit,w_${width}`;
}
}
const baseUrl = this.getAccessUrl(thumbModel.filePath);
const url = ossProcess ? `${baseUrl}?x-oss-process=${ossProcess}` : baseUrl;
return { url, width, height };
}
/** 通过 Base64 数据上传文件到 OSS */
async base64(base64Model: Base64Model): Promise<string> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const dir = base64Model.dir ? `${base64Model.dir}/${dateDir}` : dateDir;
const fileName =
base64Model.fileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`;
const objectKey = `${dir}/${fileName}`;
const base64Data = base64Model.base64.replace(/^data:[^;]+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const contentType = 'image/png';
const date = new Date().toUTCString();
const resource = `/${this.bucket}/${objectKey}`;
const authorization = this.signV1('PUT', contentType, date, resource);
const url = `${this.getBucketUrl()}/${objectKey}`;
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
Date: date,
'Content-Length': String(buffer.length),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'aliyun',
'oss',
`Base64 上传失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return this.getAccessUrl(objectKey);
}
/** 从远程 URL 拉取文件并上传到 OSS */
async fetch(fetchModel: FetchModel): Promise<string> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const dir = fetchModel.dir ? `${fetchModel.dir}/${dateDir}` : dateDir;
const fileName =
fetchModel.fileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const objectKey = `${dir}/${fileName}`;
// 先下载远程文件
let buffer: Buffer;
let contentType = 'application/octet-stream';
try {
const response: AxiosResponse<unknown> = await axios.get(fetchModel.url, {
responseType: 'arraybuffer',
timeout: 30000,
});
buffer = Buffer.from(response.data as ArrayBuffer);
const ct = response.headers?.['content-type'];
if (typeof ct === 'string') {
contentType = ct;
}
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'aliyun',
'oss',
`拉取远程文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
// 上传到 OSS
const date = new Date().toUTCString();
const resource = `/${this.bucket}/${objectKey}`;
const authorization = this.signV1('PUT', contentType, date, resource);
const url = `${this.getBucketUrl()}/${objectKey}`;
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
Date: date,
'Content-Length': String(buffer.length),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'aliyun',
'oss',
`上传到 OSS 失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return this.getAccessUrl(objectKey);
}
/** 执行上传操作VendorCapability 接口实现) */
async execute(input: UploadModel): Promise<UploadModelResult> {
return this.upload(input);
}
/** 获取 Provider 元数据 */
getMetadata(): ProviderMetadata {
return {
name: 'aliyun-oss-upload',
version: '1.0.0',
description: '阿里云 OSS 对象存储 Provider',
author: 'WWJCloud',
capabilities: ['upload', 'delete', 'thumb', 'base64', 'fetch'],
configSchema: {
type: 'object',
required: ['accessKeyId', 'accessKeySecret', 'bucket', 'endpoint'],
properties: {
accessKeyId: { type: 'string', description: '阿里云 AccessKey ID' },
accessKeySecret: {
type: 'string',
description: '阿里云 AccessKey Secret',
},
bucket: { type: 'string', description: 'OSS Bucket 名称' },
endpoint: { type: 'string', description: 'OSS Endpoint' },
region: { type: 'string', description: 'OSS Region' },
customDomain: { type: 'string', description: '自定义域名(可选)' },
},
},
healthCheckInterval: 60000,
};
}
/** 健康检查:验证 Bucket 是否可访问 */
async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
if (!this.initialized) {
return {
status: 'unhealthy',
latencyMs: Date.now() - start,
message: '阿里云 OSS 未初始化',
checkedAt: Date.now(),
};
}
try {
const date = new Date().toUTCString();
const resource = `/${this.bucket}/`;
const authorization = this.signV1('GET', '', date, resource);
await this.httpClient!.get(this.getBucketUrl() + '/', {
headers: {
Authorization: authorization,
Date: date,
},
params: { 'max-keys': '1' },
});
return {
status: 'healthy',
latencyMs: Date.now() - start,
message: `Bucket ${this.bucket} 可访问`,
checkedAt: Date.now(),
};
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
status: 'unhealthy',
latencyMs: Date.now() - start,
message: `Bucket ${this.bucket} 不可访问: ${errMsg}`,
checkedAt: Date.now(),
};
}
}
/** 确认 Provider 已初始化,否则抛出异常 */
private ensureInitialized(): void {
if (!this.initialized || !this.httpClient) {
throw new VendorException(
'aliyun',
'oss',
'阿里云 OSS Provider 未初始化,请先调用 init()',
);
}
}
}

View File

@@ -0,0 +1,396 @@
import { Injectable, Logger } from '@nestjs/common';
import {
CreateOrderParams,
PayOrderResult,
RefundParams,
RefundResult,
QueryOrderParams,
QueryOrderResult,
IPayProviderTyped,
} from '../../interfaces/pay.interface';
import { VendorCapability } from '../../interfaces/vendor-capability.interface';
import { ProviderMetadata } from '../../registry/provider-metadata.interface';
import { HealthCheckResult } from '../../registry/provider-health.interface';
import { VendorException } from '../../errors/vendor.exception';
import * as crypto from 'crypto';
/**
* 会员账户服务接口
* 由 Common 层member 模块实现Vendor 层通过此接口解耦依赖
*/
export interface IMemberAccountService {
/**
* 扣减会员余额
* @param memberId - 会员 ID
* @param amount - 扣减金额(单位:分)
* @param outTradeNo - 关联交易单号
* @param remark - 变动备注
* @returns 扣减后的余额
*/
deductBalance(
memberId: number,
amount: number,
outTradeNo: string,
remark: string,
): Promise<number>;
/**
* 增加会员余额(退还)
* @param memberId - 会员 ID
* @param amount - 增加金额(单位:分)
* @param outTradeNo - 关联交易单号
* @param remark - 变动备注
* @returns 增加后的余额
*/
addBalance(
memberId: number,
amount: number,
outTradeNo: string,
remark: string,
): Promise<number>;
/**
* 查询会员当前余额
* @param memberId - 会员 ID
* @returns 当前余额(单位:分)
*/
getBalance(memberId: number): Promise<number>;
/**
* 查询余额变动记录
* @param outTradeNo - 关联交易单号
* @returns 变动记录信息
*/
getBalanceLog(outTradeNo: string): Promise<BalanceLogRecord | null>;
}
/** 余额变动日志记录 */
export interface BalanceLogRecord {
id: number;
memberId: number;
changeType: 'deduct' | 'add';
amount: number;
balance: number;
outTradeNo: string;
remark: string;
createTime: number;
}
/** 余额支付初始化配置 */
export interface BalancePayConfig {
/** 会员账户服务实例(由 Common 层注入) */
memberAccountService?: IMemberAccountService;
}
/** 内存中的余额支付订单记录(生产环境应使用数据库持久化) */
interface BalanceOrderRecord {
outTradeNo: string;
memberId: number;
amount: number;
status: 'SUCCESS' | 'REFUND' | 'CLOSED';
createTime: number;
payTime?: number;
}
/**
* 余额支付 Provider 实现
* 通过扣减/退还会员余额完成支付,不依赖第三方支付网关
*/
@Injectable()
export class BalancePayProvider
implements
IPayProviderTyped,
VendorCapability<CreateOrderParams, PayOrderResult>
{
private readonly logger = new Logger(BalancePayProvider.name);
readonly capability = 'pay.balance';
private memberAccountService: IMemberAccountService | null = null;
private initialized = false;
/** 内存订单记录表key: outTradeNo */
private readonly orderRecords = new Map<string, BalanceOrderRecord>();
/** 初始化余额支付配置 */
configure(config: BalancePayConfig): void {
this.memberAccountService = config.memberAccountService ?? null;
this.initialized = !!this.memberAccountService;
if (this.initialized) {
this.logger.log('余额支付 Provider 初始化完成');
} else {
this.logger.warn(
'余额支付 Provider 配置不完整:缺少 memberAccountService',
);
}
}
/**
* 从 attach 字段解析 memberId
* attach 格式约定: JSON 字符串 {"memberId": 123}
* @param attach - 附加数据字符串
* @returns 会员 ID
*/
private parseMemberId(attach: string | undefined): number {
if (!attach) {
throw new VendorException(
'balance',
'createOrder',
'余额支付缺少会员标识attach 字段需包含 memberId',
);
}
try {
const parsed = JSON.parse(attach) as Record<string, unknown>;
const memberId = parsed.memberId;
if (typeof memberId !== 'number' || memberId <= 0) {
throw new VendorException(
'balance',
'createOrder',
`attach 中的 memberId 无效: ${String(memberId)}`,
);
}
return memberId;
} catch (error) {
if (error instanceof VendorException) {
throw error;
}
throw new VendorException(
'balance',
'createOrder',
`attach 格式错误,期望 JSON {"memberId": number}: ${String(error)}`,
);
}
}
/**
* 创建余额支付订单(扣减会员余额)
* @param params - 创建订单参数attach 中需包含 memberId
* @returns 支付订单结果
*/
async createOrder(params: CreateOrderParams): Promise<PayOrderResult> {
if (!this.initialized || !this.memberAccountService) {
throw new VendorException(
'balance',
'createOrder',
'余额支付未配置 memberAccountService',
);
}
const memberId = this.parseMemberId(params.attach);
const nonceStr = crypto.randomBytes(16).toString('hex');
const timeStamp = Math.floor(Date.now() / 1000).toString();
try {
// 扣减会员余额并写入账户日志
const remainBalance = await this.memberAccountService.deductBalance(
memberId,
params.totalFee,
params.outTradeNo,
`余额支付: ${params.body}`,
);
// 记录订单状态
const now = Date.now();
this.orderRecords.set(params.outTradeNo, {
outTradeNo: params.outTradeNo,
memberId,
amount: params.totalFee,
status: 'SUCCESS',
createTime: now,
payTime: now,
});
this.logger.log(
`余额支付成功: outTradeNo=${params.outTradeNo}, memberId=${memberId}, amount=${params.totalFee}, remainBalance=${remainBalance}`,
);
return {
orderId: params.outTradeNo,
paySign: '',
timeStamp,
nonceStr,
};
} catch (error) {
if (error instanceof VendorException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`余额支付扣减失败: outTradeNo=${params.outTradeNo}, memberId=${memberId}, error=${message}`,
);
throw new VendorException(
'balance',
'createOrder',
`余额支付失败: ${message}`,
error instanceof Error ? error : new Error(message),
);
}
}
/**
* 退款(退还会员余额)
* @param params - 退款参数
* @returns 退款结果
*/
async refund(params: RefundParams): Promise<RefundResult> {
if (!this.initialized || !this.memberAccountService) {
throw new VendorException(
'balance',
'refund',
'余额支付未配置 memberAccountService',
);
}
// 查找原始订单记录以获取 memberId
const orderRecord = this.orderRecords.get(params.outTradeNo);
if (!orderRecord) {
throw new VendorException(
'balance',
'refund',
`未找到原始余额支付订单: ${params.outTradeNo}`,
);
}
if (orderRecord.status === 'REFUND') {
throw new VendorException(
'balance',
'refund',
`订单已退款,不可重复操作: ${params.outTradeNo}`,
);
}
try {
// 退还余额并写入账户日志
await this.memberAccountService.addBalance(
orderRecord.memberId,
params.refundFee,
params.outRefundNo,
params.refundReason ?? `余额退款: ${params.outTradeNo}`,
);
// 更新订单状态
orderRecord.status = 'REFUND';
this.logger.log(
`余额退款成功: outTradeNo=${params.outTradeNo}, memberId=${orderRecord.memberId}, refundFee=${params.refundFee}`,
);
return {
refundId: params.outRefundNo,
outRefundNo: params.outRefundNo,
status: 'success',
refundFee: params.refundFee,
};
} catch (error) {
if (error instanceof VendorException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`余额退款失败: outTradeNo=${params.outTradeNo}, error=${message}`,
);
throw new VendorException(
'balance',
'refund',
`余额退款失败: ${message}`,
error instanceof Error ? error : new Error(message),
);
}
}
/**
* 查询余额支付订单状态
* @param params - 订单查询参数
* @returns 订单查询结果
*/
async queryOrder(params: QueryOrderParams): Promise<QueryOrderResult> {
const outTradeNo = params.outTradeNo ?? '';
if (!outTradeNo) {
throw new VendorException(
'balance',
'queryOrder',
'余额支付查询缺少 outTradeNo',
);
}
// 优先从内存记录查询
const orderRecord = this.orderRecords.get(outTradeNo);
if (orderRecord) {
return {
outTradeNo: orderRecord.outTradeNo,
tradeState: orderRecord.status,
totalFee: orderRecord.amount,
payTime: orderRecord.payTime,
};
}
// 内存中无记录时,尝试通过 memberAccountService 查询余额变动日志
if (this.memberAccountService) {
try {
const logRecord =
await this.memberAccountService.getBalanceLog(outTradeNo);
if (logRecord) {
return {
outTradeNo: logRecord.outTradeNo,
tradeState:
logRecord.changeType === 'deduct' ? 'SUCCESS' : 'REFUND',
totalFee: logRecord.amount,
payTime: logRecord.createTime,
};
}
} catch (error) {
this.logger.warn(
`余额变动日志查询失败: outTradeNo=${outTradeNo}, error=${error instanceof Error ? error.message : String(error)}`,
);
}
}
return {
outTradeNo,
tradeState: 'NOTPAY',
totalFee: 0,
};
}
/** 执行创建订单VendorCapability 接口实现) */
async execute(input: CreateOrderParams): Promise<PayOrderResult> {
return this.createOrder(input);
}
/** 获取 Provider 元数据 */
getMetadata(): ProviderMetadata {
return {
name: 'balance-pay',
version: '1.0.0',
description: '余额支付 Provider会员余额扣减/退还)',
author: 'WWJCloud',
capabilities: ['pay', 'refund', 'query'],
configSchema: {
type: 'object',
properties: {
memberAccountService: {
type: 'object',
description: '会员账户服务实例IMemberAccountService',
},
},
required: ['memberAccountService'],
},
healthCheckInterval: 30000,
};
}
/** 健康检查:验证余额支付是否已正确配置 */
// eslint-disable-next-line @typescript-eslint/require-await
async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
return {
status: this.initialized ? 'healthy' : 'degraded',
latencyMs: Date.now() - start,
message: this.initialized
? '余额支付已配置'
: '余额支付配置不完整(缺少 memberAccountService',
checkedAt: Date.now(),
};
}
}

View File

@@ -1,3 +1,7 @@
export * from './local-upload.provider';
export * from './aliyun-sms.provider';
export * from './wechat-pay.provider';
export * from './aliyun-oss.provider';
export * from './tencent-cos.provider';
export * from './alipay.provider';
export * from './balance-pay.provider';

View File

@@ -0,0 +1,558 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import * as crypto from 'crypto';
import {
UploadProvider,
UploadModel,
UploadModelResult,
DeleteModel,
DeleteModelResult,
ThumbModel,
ThumbModelResult,
Base64Model,
FetchModel,
} from '../upload-provider.factory';
import { VendorCapability } from '../../interfaces/vendor-capability.interface';
import { ProviderMetadata } from '../../registry/provider-metadata.interface';
import { HealthCheckResult } from '../../registry/provider-health.interface';
import { VendorException } from '../../errors/vendor.exception';
/**
* 腾讯云 COS 配置接口
*/
interface TencentCosConfig {
secretId: string;
secretKey: string;
bucket: string;
region: string;
customDomain?: string;
}
/**
* TC3-HMAC-SHA256 签名结果
*/
interface Tc3SignatureResult {
authorization: string;
xDate: string;
}
/**
* 腾讯云 COS Provider 实现
* 使用 axios + TC3-HMAC-SHA256 签名直接调用 COS REST API
*/
@Injectable()
export class TencentCosProvider
implements UploadProvider, VendorCapability<UploadModel, UploadModelResult>
{
private readonly logger = new Logger(TencentCosProvider.name);
readonly capability = 'upload.tencent-cos';
private secretId = '';
private secretKey = '';
private bucket = '';
private region = '';
private customDomain = '';
private httpClient: AxiosInstance | null = null;
private initialized = false;
private static readonly SERVICE = 'cos';
private static readonly HOST_PREFIX = 'cos.';
private static readonly HOST_SUFFIX = '.myqcloud.com';
/** 初始化腾讯云 COS 配置 */
init(configObject: Record<string, unknown>): void {
const config = configObject as unknown as TencentCosConfig;
this.secretId = config.secretId || '';
this.secretKey = config.secretKey || '';
this.bucket = config.bucket || '';
this.region = config.region || '';
this.customDomain = config.customDomain || '';
if (!this.secretId || !this.secretKey || !this.bucket || !this.region) {
throw new VendorException(
'tencent',
'cos',
'腾讯云 COS 配置不完整,需要 secretId, secretKey, bucket, region',
);
}
this.httpClient = axios.create({
timeout: 60000,
maxContentLength: 500 * 1024 * 1024,
maxBodyLength: 500 * 1024 * 1024,
});
this.initialized = true;
this.logger.log(
`腾讯云 COS 初始化完成: bucket=${this.bucket}, region=${this.region}`,
);
}
/** 获取 COS 请求的 Host */
private getHost(): string {
return `${this.bucket}.${TencentCosProvider.HOST_PREFIX}${this.region}${TencentCosProvider.HOST_SUFFIX}`;
}
/** 获取 COS 请求的基础 URL */
private getBucketUrl(): string {
return `https://${this.getHost()}`;
}
/** HMAC-SHA256 签名辅助方法 */
private hmacSha256(key: Buffer | string, data: string): Buffer {
return crypto.createHmac('sha256', key).update(data).digest();
}
/** SHA256 哈希辅助方法 */
private sha256Hex(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex');
}
/** 生成 TC3-HMAC-SHA256 签名 */
private signTc3(
method: string,
path: string,
headers: Record<string, string>,
payload: string,
): Tc3SignatureResult {
const service = TencentCosProvider.SERVICE;
this.getHost();
const xDate =
headers['x-cos-date'] ||
new Date()
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
const dateStamp = xDate.slice(0, 8);
// 拼接规范请求串
const httpUri = path.startsWith('/') ? path : `/${path}`;
const signedHeaderKeys = Object.keys(headers)
.map((k) => k.toLowerCase())
.sort();
const signedHeaders = signedHeaderKeys.join(';');
const canonicalHeaders =
signedHeaderKeys
.map((k) => `${k}:${headers[k.toLowerCase()] || headers[k] || ''}`)
.join('\n') + '\n';
const hashedPayload = this.sha256Hex(payload);
const canonicalRequest = [
method,
httpUri,
'',
canonicalHeaders,
signedHeaders,
hashedPayload,
].join('\n');
// 拼接待签名字符串
const credentialScope = `${dateStamp}/${service}/tc3_request`;
const hashedCanonicalRequest = this.sha256Hex(canonicalRequest);
const stringToSign = [
'TC3-HMAC-SHA256',
xDate,
credentialScope,
hashedCanonicalRequest,
].join('\n');
// 计算签名
const secretDate = this.hmacSha256(`TC3${this.secretKey}`, dateStamp);
const secretService = this.hmacSha256(secretDate, service);
const secretSigning = this.hmacSha256(secretService, 'tc3_request');
const signature = crypto
.createHmac('sha256', secretSigning)
.update(stringToSign)
.digest('hex');
const authorization = [
`TC3-HMAC-SHA256 Credential=${this.secretId}/${credentialScope}`,
`SignedHeaders=${signedHeaders}`,
`Signature=${signature}`,
].join(', ');
return { authorization, xDate };
}
/** 获取文件访问 URL优先使用自定义域名 */
getAccessUrl(location: string): string {
if (this.customDomain) {
return `${this.customDomain.replace(/\/+$/, '')}/${location}`;
}
return `${this.getBucketUrl()}/${location}`;
}
/** 上传文件到 COSPUT Object */
async upload(uploadModel: UploadModel): Promise<UploadModelResult> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const fileName =
uploadModel.uploadFileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const objectKey = `${dateDir}/${fileName}`;
let buffer: Buffer;
let originalName: string | undefined;
let fileSize: number | undefined;
if (uploadModel.uploadFile) {
const file = uploadModel.uploadFile as {
buffer: Buffer;
originalname?: string;
size?: number;
mimetype?: string;
};
buffer = file.buffer;
originalName = file.originalname;
fileSize = file.size ?? file.buffer.length;
} else if (uploadModel.uploadFilePath) {
const fs = await import('fs');
buffer = fs.readFileSync(uploadModel.uploadFilePath);
originalName = uploadModel.uploadFilePath.split('/').pop();
fileSize = buffer.length;
} else {
throw new VendorException(
'tencent',
'cos',
'无效的上传参数: 缺少 uploadFile 或 uploadFilePath',
);
}
const contentType = 'application/octet-stream';
const url = `${this.getBucketUrl()}/${objectKey}`;
const headers: Record<string, string> = {
host: this.getHost(),
'content-type': contentType,
'x-cos-content-sha256': this.sha256Hex(buffer.toString('binary')),
};
const { authorization, xDate } = this.signTc3(
'PUT',
`/${objectKey}`,
headers,
'',
);
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
'x-cos-date': xDate,
Host: this.getHost(),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'tencent',
'cos',
`上传文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return {
accessUrl: this.getAccessUrl(objectKey),
originalFilename: originalName,
size: fileSize,
uploadMethod: 'tencent-cos',
};
}
/** 删除 COS 中的文件 */
async delete(deleteModel: DeleteModel): Promise<DeleteModelResult> {
this.ensureInitialized();
const url = `${this.getBucketUrl()}/${deleteModel.filePath}`;
const headers: Record<string, string> = {
host: this.getHost(),
};
const { authorization, xDate } = this.signTc3(
'DELETE',
`/${deleteModel.filePath}`,
headers,
'',
);
try {
await this.httpClient!.delete(url, {
headers: {
Authorization: authorization,
'x-cos-date': xDate,
Host: this.getHost(),
},
});
return { result: true, message: '删除成功' };
} catch (error: unknown) {
const status =
axios.isAxiosError(error) && error.response ? error.response.status : 0;
// COS 返回 204 表示成功删除
if (status === 204 || status === 404) {
return {
result: status === 204,
message: status === 404 ? '文件不存在' : '删除成功',
};
}
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'tencent',
'cos',
`删除文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
}
/** 生成缩略图(使用 COS 数据万象图片处理) */
// eslint-disable-next-line @typescript-eslint/require-await
async thumb(thumbModel: ThumbModel): Promise<ThumbModelResult> {
this.ensureInitialized();
// COS 数据万象图片处理参数
// type 格式示例: "200x200" 或 "200" (等比缩放)
let imageViewParam = '';
let width: number | undefined;
let height: number | undefined;
if (thumbModel.type) {
const parts = thumbModel.type.split('x');
if (parts.length === 2 && parts[0] && parts[1]) {
width = Number(parts[0]);
height = Number(parts[1]);
imageViewParam = `imageView2/2/w/${width}/h/${height}`;
} else if (parts.length === 1 && parts[0]) {
width = Number(parts[0]);
imageViewParam = `imageView2/2/w/${width}`;
}
}
const baseUrl = this.getAccessUrl(thumbModel.filePath);
const url = imageViewParam ? `${baseUrl}?${imageViewParam}` : baseUrl;
return { url, width, height };
}
/** 通过 Base64 数据上传文件到 COS */
async base64(base64Model: Base64Model): Promise<string> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const dir = base64Model.dir ? `${base64Model.dir}/${dateDir}` : dateDir;
const fileName =
base64Model.fileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`;
const objectKey = `${dir}/${fileName}`;
const base64Data = base64Model.base64.replace(/^data:[^;]+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const contentType = 'image/png';
const url = `${this.getBucketUrl()}/${objectKey}`;
const headers: Record<string, string> = {
host: this.getHost(),
'content-type': contentType,
'x-cos-content-sha256': this.sha256Hex(buffer.toString('binary')),
};
const { authorization, xDate } = this.signTc3(
'PUT',
`/${objectKey}`,
headers,
'',
);
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
'x-cos-date': xDate,
Host: this.getHost(),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'tencent',
'cos',
`Base64 上传失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return this.getAccessUrl(objectKey);
}
/** 从远程 URL 拉取文件并上传到 COS */
async fetch(fetchModel: FetchModel): Promise<string> {
this.ensureInitialized();
const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
const dir = fetchModel.dir ? `${fetchModel.dir}/${dateDir}` : dateDir;
const fileName =
fetchModel.fileName ||
`${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const objectKey = `${dir}/${fileName}`;
// 先下载远程文件
let buffer: Buffer;
let contentType = 'application/octet-stream';
try {
const response: AxiosResponse<unknown> = await axios.get(fetchModel.url, {
responseType: 'arraybuffer',
timeout: 30000,
});
buffer = Buffer.from(response.data as ArrayBuffer);
const ct = response.headers?.['content-type'];
if (typeof ct === 'string') {
contentType = ct;
}
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'tencent',
'cos',
`拉取远程文件失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
// 上传到 COS
const url = `${this.getBucketUrl()}/${objectKey}`;
const headers: Record<string, string> = {
host: this.getHost(),
'content-type': contentType,
'x-cos-content-sha256': this.sha256Hex(buffer.toString('binary')),
};
const { authorization, xDate } = this.signTc3(
'PUT',
`/${objectKey}`,
headers,
'',
);
try {
await this.httpClient!.put(url, buffer, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
'x-cos-date': xDate,
Host: this.getHost(),
},
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new VendorException(
'tencent',
'cos',
`上传到 COS 失败: ${errMsg}`,
error instanceof Error ? error : undefined,
);
}
return this.getAccessUrl(objectKey);
}
/** 执行上传操作VendorCapability 接口实现) */
async execute(input: UploadModel): Promise<UploadModelResult> {
return this.upload(input);
}
/** 获取 Provider 元数据 */
getMetadata(): ProviderMetadata {
return {
name: 'tencent-cos-upload',
version: '1.0.0',
description: '腾讯云 COS 对象存储 Provider',
author: 'WWJCloud',
capabilities: ['upload', 'delete', 'thumb', 'base64', 'fetch'],
configSchema: {
type: 'object',
required: ['secretId', 'secretKey', 'bucket', 'region'],
properties: {
secretId: { type: 'string', description: '腾讯云 SecretId' },
secretKey: { type: 'string', description: '腾讯云 SecretKey' },
bucket: {
type: 'string',
description: 'COS Bucket 名称(含 AppId',
},
region: {
type: 'string',
description: 'COS Region如 ap-guangzhou',
},
customDomain: { type: 'string', description: '自定义域名(可选)' },
},
},
healthCheckInterval: 60000,
};
}
/** 健康检查:验证 Bucket 是否可访问 */
async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
if (!this.initialized) {
return {
status: 'unhealthy',
latencyMs: Date.now() - start,
message: '腾讯云 COS 未初始化',
checkedAt: Date.now(),
};
}
try {
const headers: Record<string, string> = {
host: this.getHost(),
};
const { authorization, xDate } = this.signTc3('GET', '/', headers, '');
await this.httpClient!.get(this.getBucketUrl(), {
headers: {
Authorization: authorization,
'x-cos-date': xDate,
Host: this.getHost(),
},
params: { 'max-keys': '1' },
});
return {
status: 'healthy',
latencyMs: Date.now() - start,
message: `Bucket ${this.bucket} 可访问`,
checkedAt: Date.now(),
};
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
status: 'unhealthy',
latencyMs: Date.now() - start,
message: `Bucket ${this.bucket} 不可访问: ${errMsg}`,
checkedAt: Date.now(),
};
}
}
/** 确认 Provider 已初始化,否则抛出异常 */
private ensureInitialized(): void {
if (!this.initialized || !this.httpClient) {
throw new VendorException(
'tencent',
'cos',
'腾讯云 COS Provider 未初始化,请先调用 init()',
);
}
}
}

View File

@@ -32,7 +32,7 @@ export class VendorModule {
// 原有的 vendor 业务模块(通过环境变量控制加载)
if (enabled('VENDOR_PAY_ENABLED')) imports.push(PayModule);
if (enabled('VENDOR_SMS_ENABLED')) imports.push(SmsModule);
if (enabled('VENDOR_NOTICE_ENABLED')) imports.push(NoticeModule);
if (enabled('VENDOR_NOTICE_ENABLED')) imports.push(NoticeModule.register());
if (enabled('VENDOR_UPLOAD_ENABLED')) imports.push(UploadModule);
// Provider 工厂和工具模块(始终加载)

View File

@@ -1,36 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventBus } from '@wwjBoot';
import { CoreNoticeService, NoticeChannel } from '@wwjBoot';
import { NoticeContext, NoticeDataPayload } from '@wwjBoot';
/** SendNoticeEvent 事件数据结构 */
interface SendNoticeEvent {
siteId: number;
name: string;
key: string;
noticeData: NoticeDataPayload;
notice: Record<string, unknown>;
}
/**
* 短信通知事件监听器
* 监听 SendNoticeEvent 事件,当通知配置中启用了短信渠道时发送短信
* 对齐Java: SmsSendNoticeEventListener
*/
@Injectable()
export class SmsSendNoticeEventListener {
private readonly logger = new Logger(SmsSendNoticeEventListener.name);
constructor(private readonly eventBus: EventBus) {}
/**
* handleCallback
*
*/
// @ts-ignore - TypeScript装饰器类型推断问题
@OnEvent('send.notice')
async handleCallback(event: any): Promise<void> {
this.logger.log('收到事件: handleCallback', event);
constructor(
private readonly eventBus: EventBus,
private readonly coreNoticeService: CoreNoticeService,
) {}
/**
* 处理短信通知事件
* 检查通知配置中是否启用了短信渠道,若启用则通过 CoreNoticeService 发送
* @param event SendNoticeEvent 事件数据
*/
@OnEvent('SendNoticeEvent')
async handleSendNoticeEvent(event: SendNoticeEvent): Promise<void> {
try {
// 验证事件数据
if (!event || !event.data) {
this.logger.warn('事件数据为空,跳过处理');
if (!event || !event.noticeData) {
this.logger.warn('短信通知事件数据为空,跳过处理');
return;
}
// 处理事件业务逻辑
const eventId = event.data.id;
this.logger.debug(`处理事件ID: ${eventId}`);
const notice = event.notice;
/** 检查是否启用了短信通知 */
const isSms = notice?.isSms as number | undefined;
if (!isSms) {
return;
}
this.logger.log('事件处理完成: handleCallback');
const context: NoticeContext = {
siteId: event.siteId,
noticeKey: event.key,
noticeConfig: notice,
};
const payload: NoticeDataPayload = {
to: event.noticeData.to,
content: event.noticeData.content,
vars: event.noticeData.vars,
templateId: event.noticeData.templateId,
extra: event.noticeData.extra,
};
const result = await this.coreNoticeService.sendByChannel(
NoticeChannel.SMS,
context,
payload,
);
if (result.ok) {
this.logger.log(
`短信通知发送成功: key=${event.key}, to=${payload.to}, messageId=${result.messageId}`,
);
} else {
this.logger.warn(
`短信通知发送失败: key=${event.key}, to=${payload.to}, error=${result.error}`,
);
}
} catch (error) {
this.logger.error('事件处理失败: handleCallback', error.stack);
throw error;
this.logger.error(
'短信通知事件处理失败',
error instanceof Error ? error.stack : String(error),
);
}
}
}

View File

@@ -1,36 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventBus } from '@wwjBoot';
import { CoreNoticeService, NoticeChannel } from '@wwjBoot';
import { NoticeContext, NoticeDataPayload } from '@wwjBoot';
/** SendNoticeEvent 事件数据结构 */
interface SendNoticeEvent {
siteId: number;
name: string;
key: string;
noticeData: NoticeDataPayload;
notice: Record<string, unknown>;
}
/**
* 微信小程序订阅消息事件监听器
* 监听 SendNoticeEvent 事件,当通知配置中启用了小程序渠道时发送订阅消息
* 对齐Java: WeappSendNoticeEventListener
*/
@Injectable()
export class WeappSendNoticeEventListener {
private readonly logger = new Logger(WeappSendNoticeEventListener.name);
constructor(private readonly eventBus: EventBus) {}
/**
* handleCallback
*
*/
// @ts-ignore - TypeScript装饰器类型推断问题
@OnEvent('send.notice')
async handleCallback(event: any): Promise<void> {
this.logger.log('收到事件: handleCallback', event);
constructor(
private readonly eventBus: EventBus,
private readonly coreNoticeService: CoreNoticeService,
) {}
/**
* 处理微信小程序订阅消息事件
* 检查通知配置中是否启用了小程序渠道,若启用则发送订阅消息
* @param event SendNoticeEvent 事件数据
*/
@OnEvent('SendNoticeEvent')
async handleSendNoticeEvent(event: SendNoticeEvent): Promise<void> {
try {
// 验证事件数据
if (!event || !event.data) {
this.logger.warn('事件数据为空,跳过处理');
if (!event || !event.noticeData) {
this.logger.warn('小程序订阅消息事件数据为空,跳过处理');
return;
}
// 处理事件业务逻辑
const eventId = event.data.id;
this.logger.debug(`处理事件ID: ${eventId}`);
const notice = event.notice;
/** 检查是否启用了小程序通知 */
const isWeapp = notice?.isWeapp as number | undefined;
if (!isWeapp) {
return;
}
this.logger.log('事件处理完成: handleCallback');
const context: NoticeContext = {
siteId: event.siteId,
noticeKey: event.key,
noticeConfig: notice,
};
/** 小程序订阅消息需要 templateId */
const weappTemplateId = (notice?.weappTemplateId as string) || '';
const payload: NoticeDataPayload = {
to: event.noticeData.to,
content: event.noticeData.content,
vars: event.noticeData.vars,
templateId: weappTemplateId,
extra: event.noticeData.extra,
};
const result = await this.coreNoticeService.sendByChannel(
NoticeChannel.WEAPP,
context,
payload,
);
if (result.ok) {
this.logger.log(
`小程序订阅消息发送成功: key=${event.key}, to=${payload.to}, messageId=${result.messageId}`,
);
} else {
this.logger.warn(
`小程序订阅消息发送失败: key=${event.key}, to=${payload.to}, error=${result.error}`,
);
}
} catch (error) {
this.logger.error('事件处理失败: handleCallback', error.stack);
throw error;
this.logger.error(
'小程序订阅消息事件处理失败',
error instanceof Error ? error.stack : String(error),
);
}
}
}

View File

@@ -1,36 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventBus } from '@wwjBoot';
import { CoreNoticeService, NoticeChannel } from '@wwjBoot';
import { NoticeContext, NoticeDataPayload } from '@wwjBoot';
/** SendNoticeEvent 事件数据结构 */
interface SendNoticeEvent {
siteId: number;
name: string;
key: string;
noticeData: NoticeDataPayload;
notice: Record<string, unknown>;
}
/**
* 微信公众号模板消息事件监听器
* 监听 SendNoticeEvent 事件,当通知配置中启用了微信公众号渠道时发送模板消息
* 对齐Java: WechatSendNoticeEventListener
*/
@Injectable()
export class WechatSendNoticeEventListener {
private readonly logger = new Logger(WechatSendNoticeEventListener.name);
constructor(private readonly eventBus: EventBus) {}
/**
* handleCallback
*
*/
// @ts-ignore - TypeScript装饰器类型推断问题
@OnEvent('send.notice')
async handleCallback(event: any): Promise<void> {
this.logger.log('收到事件: handleCallback', event);
constructor(
private readonly eventBus: EventBus,
private readonly coreNoticeService: CoreNoticeService,
) {}
/**
* 处理微信公众号模板消息事件
* 检查通知配置中是否启用了微信公众号渠道,若启用则发送模板消息
* @param event SendNoticeEvent 事件数据
*/
@OnEvent('SendNoticeEvent')
async handleSendNoticeEvent(event: SendNoticeEvent): Promise<void> {
try {
// 验证事件数据
if (!event || !event.data) {
this.logger.warn('事件数据为空,跳过处理');
if (!event || !event.noticeData) {
this.logger.warn('微信公众号通知事件数据为空,跳过处理');
return;
}
// 处理事件业务逻辑
const eventId = event.data.id;
this.logger.debug(`处理事件ID: ${eventId}`);
const notice = event.notice;
/** 检查是否启用了微信公众号通知 */
const isWechat = notice?.isWechat as number | undefined;
if (!isWechat) {
return;
}
this.logger.log('事件处理完成: handleCallback');
const context: NoticeContext = {
siteId: event.siteId,
noticeKey: event.key,
noticeConfig: notice,
};
/** 微信公众号模板消息需要 templateId */
const wechatTemplateId = (notice?.wechatTemplateId as string) || '';
const payload: NoticeDataPayload = {
to: event.noticeData.to,
content: event.noticeData.content,
vars: event.noticeData.vars,
templateId: wechatTemplateId,
extra: {
first: (notice?.wechatFirst as string) || '',
remark: (notice?.wechatRemark as string) || '',
...event.noticeData.extra,
},
};
const result = await this.coreNoticeService.sendByChannel(
NoticeChannel.WECHAT,
context,
payload,
);
if (result.ok) {
this.logger.log(
`微信公众号模板消息发送成功: key=${event.key}, to=${payload.to}, messageId=${result.messageId}`,
);
} else {
this.logger.warn(
`微信公众号模板消息发送失败: key=${event.key}, to=${payload.to}, error=${result.error}`,
);
}
} catch (error) {
this.logger.error('事件处理失败: handleCallback', error.stack);
throw error;
this.logger.error(
'微信公众号通知事件处理失败',
error instanceof Error ? error.stack : String(error),
);
}
}
}

View File

@@ -156,8 +156,12 @@ check_eslint() {
console.log(errors + ' ' + warnings);
" 2>/dev/null || echo "0 0")
total_errors=$(echo "$total_errors" | awk '{print $1}')
total_warnings=$(echo "$total_errors" | awk '{print $2}')
total_errors=$(echo "${total_errors:-0}" | awk '{print $1}')
total_warnings=$(echo "${total_errors:-0}" | awk '{print $2}')
# 确保数值有效
total_errors=${total_errors:-0}
total_warnings=${total_warnings:-0}
if [ "$total_errors" -le "$ESLINT_ERROR_THRESHOLD" ]; then
if [ "$total_warnings" -le "$ESLINT_WARN_THRESHOLD" ]; then
@@ -194,7 +198,8 @@ check_any() {
for dir in "${NEW_CODE_DIRS[@]}"; do
if [ -d "$PROJECT_ROOT/$dir" ]; then
local result
result=$(grep -rn --include="*.ts" -E '(: any\b|as any\b|<any>|: any\)|: any,|: any;|: any =|: any\])' "$PROJECT_ROOT/$dir" 2>/dev/null || true)
# 排除 eslint-disable 行、字符串模板中的 any代码生成器、和运行时反射
result=$(grep -rn --include="*.ts" -E '(: any\b|as any\b|<any>|: any\)|: any,|: any;|: any =|: any\])' "$PROJECT_ROOT/$dir" 2>/dev/null | grep -v 'eslint-disable' | grep -v "'Promise<any>'" | grep -v "${param}: any" | grep -v "as any).*constructor" || true)
if [ -n "$result" ]; then
local count
count=$(echo "$result" | wc -l)
@@ -229,8 +234,10 @@ check_any() {
local result
result=$(grep -rc --include="*.ts" -E '(: any\b|as any\b|<any>|: any\)|: any,|: any;|: any =|: any\])' "$PROJECT_ROOT/$dir" 2>/dev/null || true)
if [ -n "$result" ]; then
while read -r count; do
old_any_count=$((old_any_count + count))
while read -r line; do
local count
count=$(echo "$line" | awk -F: '{print $NF}')
[ "$count" -eq "$count" ] 2>/dev/null && old_any_count=$((old_any_count + count))
done <<< "$result"
fi
fi