forked from wangziqi/gongxue-base
feat: handle refund notifications
This commit is contained in:
@@ -19,14 +19,14 @@
|
|||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||||
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
||||||
|
|
||||||
还没有达到生产交付的部分:
|
还没有达到生产交付的部分:
|
||||||
|
|
||||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款/查询确认已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款通知 webhook、对账、支付补偿和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款/查询确认/退款通知已完成本地适配;微信网页登录、QQ 登录、手机号换绑、对账、支付补偿和真实生产账号联调还没接完。
|
||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
||||||
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
||||||
- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
|
- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
|
||||||
@@ -193,4 +193,4 @@ npm run check:refactor
|
|||||||
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||||
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||||
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||||
5. 微信网页/QQ 登录、退款通知 webhook、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
5. 微信网页/QQ 登录、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
paymentNotifyRoute,
|
paymentNotifyRoute,
|
||||||
redeemActivationCodeRoute,
|
redeemActivationCodeRoute,
|
||||||
refundRequestsRoute,
|
refundRequestsRoute,
|
||||||
|
refundNotifyRoute,
|
||||||
updateRefundStatusRoute,
|
updateRefundStatusRoute,
|
||||||
} from './routes.js';
|
} from './routes.js';
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ export const commerceRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
|
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
|
||||||
['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute],
|
['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute],
|
||||||
['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute],
|
['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute],
|
||||||
|
['POST', '/api/commerce/refunds/notify/wechat_pay', refundNotifyRoute],
|
||||||
|
['POST', '/api/commerce/refunds/notify/wechat-pay', refundNotifyRoute],
|
||||||
|
['POST', '/api/commerce/refunds/notify/alipay', refundNotifyRoute],
|
||||||
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
|
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
|
||||||
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
||||||
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
|
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export interface PaymentRefundResult {
|
|||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentRefundNotificationResult extends PaymentRefundResult {
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
orderNo: string;
|
||||||
|
refundNo: string;
|
||||||
|
amountCents: number;
|
||||||
|
notifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaymentNotificationResult {
|
export interface PaymentNotificationResult {
|
||||||
provider: PaymentProviderName;
|
provider: PaymentProviderName;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -86,6 +95,11 @@ export interface PaymentProvider {
|
|||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
rawBody: string;
|
rawBody: string;
|
||||||
}): Promise<PaymentNotificationResult>;
|
}): Promise<PaymentNotificationResult>;
|
||||||
|
parseRefundNotification(input: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
rawBody: string;
|
||||||
|
}): Promise<PaymentRefundNotificationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64urlDecode(value: string) {
|
function base64urlDecode(value: string) {
|
||||||
@@ -182,6 +196,50 @@ function randomNonce(size = 16) {
|
|||||||
return crypto.randomBytes(size).toString('base64url');
|
return crypto.randomBytes(size).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyWechatNotification(
|
||||||
|
config: TenantPaymentProviderConfig,
|
||||||
|
input: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
rawBody: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const publicKey = optionalPublicString(config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|
||||||
|
|| requireSecretString(config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
|
||||||
|
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(config, 'allowLocalMock', false);
|
||||||
|
if (publicKey && input.headers['wechatpay-signature']) {
|
||||||
|
const timestamp = input.headers['wechatpay-timestamp'] || '';
|
||||||
|
const nonceHeader = input.headers['wechatpay-nonce'] || '';
|
||||||
|
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
|
||||||
|
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
|
||||||
|
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
|
||||||
|
}
|
||||||
|
} else if (!allowMockSignature) {
|
||||||
|
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptWechatNotificationResource(config: TenantPaymentProviderConfig, body: Record<string, unknown>) {
|
||||||
|
const apiV3Key = requireSecretString(config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
|
||||||
|
const resource = objectValue(body.resource);
|
||||||
|
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
const associatedData = optionalBodyString(resource, 'associated_data');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
|
||||||
|
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
||||||
|
const encrypted = base64urlDecode(ciphertext);
|
||||||
|
const authTag = encrypted.subarray(encrypted.length - 16);
|
||||||
|
const cipherText = encrypted.subarray(0, encrypted.length - 16);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
||||||
|
return safeJson(JSON.parse(buffer.toString('utf8')));
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class WechatPayProvider implements PaymentProvider {
|
class WechatPayProvider implements PaymentProvider {
|
||||||
readonly name = 'wechat_pay' as const;
|
readonly name = 'wechat_pay' as const;
|
||||||
|
|
||||||
@@ -370,39 +428,8 @@ class WechatPayProvider implements PaymentProvider {
|
|||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
rawBody: string;
|
rawBody: string;
|
||||||
}): Promise<PaymentNotificationResult> {
|
}): Promise<PaymentNotificationResult> {
|
||||||
const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|
verifyWechatNotification(this.config, input);
|
||||||
|| requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
|
const decrypted = decryptWechatNotificationResource(this.config, input.body);
|
||||||
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false);
|
|
||||||
if (publicKey && input.headers['wechatpay-signature']) {
|
|
||||||
const timestamp = input.headers['wechatpay-timestamp'] || '';
|
|
||||||
const nonceHeader = input.headers['wechatpay-nonce'] || '';
|
|
||||||
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
|
|
||||||
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
|
|
||||||
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
|
|
||||||
}
|
|
||||||
} else if (!allowMockSignature) {
|
|
||||||
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
|
|
||||||
const resource = objectValue(input.body.resource);
|
|
||||||
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
|
|
||||||
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
|
|
||||||
const associatedData = optionalBodyString(resource, 'associated_data');
|
|
||||||
|
|
||||||
let decrypted: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
|
|
||||||
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
|
||||||
const encrypted = base64urlDecode(ciphertext);
|
|
||||||
const authTag = encrypted.subarray(encrypted.length - 16);
|
|
||||||
const cipherText = encrypted.subarray(0, encrypted.length - 16);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
|
||||||
decrypted = safeJson(JSON.parse(buffer.toString('utf8')));
|
|
||||||
} catch {
|
|
||||||
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID');
|
const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
if (tradeState !== 'SUCCESS') {
|
if (tradeState !== 'SUCCESS') {
|
||||||
@@ -434,6 +461,48 @@ class WechatPayProvider implements PaymentProvider {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parseRefundNotification(input: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
rawBody: string;
|
||||||
|
}): Promise<PaymentRefundNotificationResult> {
|
||||||
|
verifyWechatNotification(this.config, input);
|
||||||
|
const decrypted = decryptWechatNotificationResource(this.config, input.body);
|
||||||
|
const statusText = requiredBodyString(decrypted, 'refund_status', 'PAYMENT_NOTIFICATION_INVALID').toUpperCase();
|
||||||
|
const status = wechatRefundStatus(statusText);
|
||||||
|
const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
const refundNo = requiredBodyString(decrypted, 'out_refund_no', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
const amount = objectValue(decrypted.amount);
|
||||||
|
const amountCents = Number(amount.refund ?? amount.payer_refund);
|
||||||
|
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
||||||
|
throw new HttpError(400, 'WeChat Pay refund notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: this.name,
|
||||||
|
eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'),
|
||||||
|
eventType: optionalBodyString(input.body, 'event_type') || 'REFUND.SUCCESS',
|
||||||
|
orderNo,
|
||||||
|
refundNo,
|
||||||
|
amountCents,
|
||||||
|
status,
|
||||||
|
providerRefundNo: optionalBodyString(decrypted, 'refund_id') || undefined,
|
||||||
|
failureReason: status === 'failed'
|
||||||
|
? optionalBodyString(decrypted, 'refund_status') || 'WeChat refund failed'
|
||||||
|
: undefined,
|
||||||
|
notifiedAt: optionalBodyString(decrypted, 'success_time') || optionalBodyString(input.body, 'create_time') || undefined,
|
||||||
|
raw: {
|
||||||
|
notification: input.body,
|
||||||
|
refund: decrypted,
|
||||||
|
headers: {
|
||||||
|
serial: input.headers['wechatpay-serial'] || '',
|
||||||
|
timestamp: input.headers['wechatpay-timestamp'] || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlipayProvider implements PaymentProvider {
|
class AlipayProvider implements PaymentProvider {
|
||||||
@@ -662,6 +731,56 @@ class AlipayProvider implements PaymentProvider {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parseRefundNotification(input: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
rawBody: string;
|
||||||
|
}): Promise<PaymentRefundNotificationResult> {
|
||||||
|
const alipayPublicKey = normalizePem(
|
||||||
|
requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'),
|
||||||
|
'PUBLIC KEY',
|
||||||
|
);
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(input.body)) {
|
||||||
|
if (typeof value === 'string') params[key] = value;
|
||||||
|
}
|
||||||
|
const sign = params.sign || '';
|
||||||
|
const signType = params.sign_type || 'RSA2';
|
||||||
|
delete params.sign;
|
||||||
|
delete params.sign_type;
|
||||||
|
if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID');
|
||||||
|
if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) {
|
||||||
|
throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = params.out_trade_no;
|
||||||
|
const refundNo = params.out_biz_no || params.out_request_no || params.refund_no;
|
||||||
|
if (!orderNo || !refundNo) throw new HttpError(400, 'Alipay refund notification is missing refund identity', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
const amountCents = optionalYuanToCents(params.refund_fee || params.refund_amount);
|
||||||
|
if (!amountCents || amountCents <= 0) {
|
||||||
|
throw new HttpError(400, 'Alipay refund notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
|
||||||
|
}
|
||||||
|
const status = alipayRefundStatus(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: this.name,
|
||||||
|
eventId: params.notify_id || `${params.trade_no || orderNo}:${refundNo}:${params.notify_time || params.gmt_refund || ''}`,
|
||||||
|
eventType: params.notify_type || params.trade_status || 'REFUND.NOTIFY',
|
||||||
|
orderNo,
|
||||||
|
refundNo,
|
||||||
|
amountCents,
|
||||||
|
status,
|
||||||
|
providerRefundNo: params.trade_no || refundNo,
|
||||||
|
failureReason: status === 'failed'
|
||||||
|
? params.sub_msg || params.refund_status || 'Alipay refund failed'
|
||||||
|
: undefined,
|
||||||
|
notifiedAt: params.gmt_refund || params.notify_time || undefined,
|
||||||
|
raw: {
|
||||||
|
notification: input.body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ManualPaymentProvider implements PaymentProvider {
|
class ManualPaymentProvider implements PaymentProvider {
|
||||||
@@ -690,6 +809,10 @@ class ManualPaymentProvider implements PaymentProvider {
|
|||||||
async parseNotification(): Promise<PaymentNotificationResult> {
|
async parseNotification(): Promise<PaymentNotificationResult> {
|
||||||
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
|
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parseRefundNotification(): Promise<PaymentRefundNotificationResult> {
|
||||||
|
throw new HttpError(501, 'Manual provider does not support refund webhook notifications', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePaymentProvider(value: string): PaymentProviderName {
|
export function normalizePaymentProvider(value: string): PaymentProviderName {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
normalizePaymentProvider,
|
normalizePaymentProvider,
|
||||||
paymentProviderAliases,
|
paymentProviderAliases,
|
||||||
type PaymentProviderName,
|
type PaymentProviderName,
|
||||||
|
type PaymentRefundNotificationResult,
|
||||||
} from './providers.js';
|
} from './providers.js';
|
||||||
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
|
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
|
||||||
import { requireTenantAdmin, requireTenantPermission } from '../tenant-admin/auth.js';
|
import { requireTenantAdmin, requireTenantPermission } from '../tenant-admin/auth.js';
|
||||||
@@ -505,12 +506,35 @@ async function fetchRefundById(client: pg.PoolClient, tenantId: string, refundId
|
|||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRefundByNo(client: pg.PoolClient, tenantId: string, refundNo: string, lock = false) {
|
||||||
|
const result = await client.query<RefundRequestRow>(
|
||||||
|
`
|
||||||
|
select rr.id, rr.refund_no as "refundNo", rr.order_id as "orderId", rr.payment_id as "paymentId",
|
||||||
|
o.order_no as "orderNo", o.status as "orderStatus", o.pay_provider as "paymentProvider",
|
||||||
|
rr.provider, rr.provider_refund_no as "providerRefundNo", rr.status,
|
||||||
|
rr.amount_cents as "amountCents", rr.reason, rr.entitlement_action as "entitlementAction",
|
||||||
|
rr.requested_by as "requestedBy", rr.reviewed_by as "reviewedBy", rr.processed_by as "processedBy",
|
||||||
|
rr.requested_at as "requestedAt", rr.reviewed_at as "reviewedAt", rr.processed_at as "processedAt",
|
||||||
|
rr.succeeded_at as "succeededAt", rr.failed_at as "failedAt", rr.cancelled_at as "cancelledAt",
|
||||||
|
rr.failure_reason as "failureReason", rr.metadata,
|
||||||
|
rr.created_at as "createdAt", rr.updated_at as "updatedAt"
|
||||||
|
from public.commerce_refund_requests rr
|
||||||
|
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
|
||||||
|
where rr.tenant_id = $1 and rr.refund_no = $2
|
||||||
|
limit 1
|
||||||
|
${lock ? 'for update of rr' : ''}
|
||||||
|
`,
|
||||||
|
[tenantId, normalizeRefundNo(refundNo)],
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function applySuccessfulRefund(
|
async function applySuccessfulRefund(
|
||||||
client: pg.PoolClient,
|
client: pg.PoolClient,
|
||||||
input: {
|
input: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
refund: RefundRequestRow;
|
refund: RefundRequestRow;
|
||||||
actorUserId: string;
|
actorUserId?: string | null;
|
||||||
providerRefundNo?: string | null;
|
providerRefundNo?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -1781,6 +1805,50 @@ async function queryRefundFromProvider(
|
|||||||
return { refundResult, nextStatus: 'processing', processResult: {} };
|
return { refundResult, nextStatus: 'processing', processResult: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settleRefundNotification(
|
||||||
|
client: pg.PoolClient,
|
||||||
|
input: {
|
||||||
|
tenantId: string;
|
||||||
|
refund: RefundRequestRow;
|
||||||
|
notification: PaymentRefundNotificationResult;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { refund, notification } = input;
|
||||||
|
if (refund.orderNo !== notification.orderNo) {
|
||||||
|
throw new HttpError(409, 'Refund notification order number mismatch', 'REFUND_NOTIFICATION_ORDER_MISMATCH');
|
||||||
|
}
|
||||||
|
if (refund.amountCents !== notification.amountCents) {
|
||||||
|
throw new HttpError(409, 'Refund notification amount mismatch', 'REFUND_NOTIFICATION_AMOUNT_MISMATCH');
|
||||||
|
}
|
||||||
|
if (refund.provider && normalizePaymentProvider(refund.provider) !== notification.provider) {
|
||||||
|
throw new HttpError(409, 'Refund notification provider mismatch', 'REFUND_NOTIFICATION_PROVIDER_MISMATCH');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refund.status === 'succeeded') {
|
||||||
|
return { nextStatus: 'succeeded', processResult: {}, idempotent: true, eventType: 'provider_notify_idempotent' };
|
||||||
|
}
|
||||||
|
if (['failed', 'rejected', 'cancelled'].includes(refund.status)) {
|
||||||
|
throw new HttpError(409, `Refund status is ${refund.status}`, 'REFUND_STATUS_INVALID');
|
||||||
|
}
|
||||||
|
if (!['approved', 'processing'].includes(refund.status)) {
|
||||||
|
throw new HttpError(409, `Refund status is ${refund.status}`, 'REFUND_STATUS_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.status === 'succeeded') {
|
||||||
|
const processResult = await applySuccessfulRefund(client, {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
refund,
|
||||||
|
actorUserId: null,
|
||||||
|
providerRefundNo: notification.providerRefundNo || null,
|
||||||
|
});
|
||||||
|
return { nextStatus: 'succeeded', processResult, idempotent: false, eventType: 'provider_notify_succeeded' };
|
||||||
|
}
|
||||||
|
if (notification.status === 'failed') {
|
||||||
|
return { nextStatus: 'failed', processResult: {}, idempotent: false, eventType: 'provider_notify_failed' };
|
||||||
|
}
|
||||||
|
return { nextStatus: 'processing', processResult: {}, idempotent: false, eventType: 'provider_notify_processing' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createPaymentRoute(ctx: RequestContext) {
|
export async function createPaymentRoute(ctx: RequestContext) {
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx);
|
||||||
@@ -2089,6 +2157,168 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
|
|||||||
return { item };
|
return { item };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refundNotifyRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const providerName = ctx.url.pathname.split('/').at(-1) || optionalString(Object.fromEntries(ctx.url.searchParams), 'provider');
|
||||||
|
const provider = await loadPaymentProvider(tenantId, providerName);
|
||||||
|
const { rawBody, body } = await readWebhookBody(ctx);
|
||||||
|
const notification = await provider.parseRefundNotification({
|
||||||
|
headers: requestHeaders(ctx),
|
||||||
|
body,
|
||||||
|
rawBody,
|
||||||
|
});
|
||||||
|
const eventKey = `${tenantId}:refund:${notification.eventId}`;
|
||||||
|
|
||||||
|
const item = await transaction(async client => {
|
||||||
|
const eventResult = await client.query<{
|
||||||
|
id: string;
|
||||||
|
processedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
insert into public.payment_events (
|
||||||
|
tenant_id, provider, event_type, event_id, signature_valid, payload
|
||||||
|
)
|
||||||
|
values ($1, $2, $3, $4, true, $5::jsonb)
|
||||||
|
on conflict (provider, event_id)
|
||||||
|
do update set payload = case
|
||||||
|
when public.payment_events.processed_at is null or public.payment_events.error is not null
|
||||||
|
then excluded.payload
|
||||||
|
else public.payment_events.payload
|
||||||
|
end,
|
||||||
|
signature_valid = true
|
||||||
|
returning id, processed_at as "processedAt", error
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
notification.provider,
|
||||||
|
notification.eventType,
|
||||||
|
eventKey,
|
||||||
|
JSON.stringify(notification.raw),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = eventResult.rows[0];
|
||||||
|
if (event.processedAt && !event.error) {
|
||||||
|
return {
|
||||||
|
status: 'processed',
|
||||||
|
idempotent: true,
|
||||||
|
eventId: notification.eventId,
|
||||||
|
refundNo: notification.refundNo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refund = await fetchRefundByNo(client, tenantId, notification.refundNo, true);
|
||||||
|
if (!refund) throw new HttpError(404, 'Refund request not found', 'REFUND_NOT_FOUND');
|
||||||
|
const settled = await settleRefundNotification(client, {
|
||||||
|
tenantId,
|
||||||
|
refund,
|
||||||
|
notification,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settled.idempotent) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
update public.payment_events
|
||||||
|
set payment_id = $2, processed_at = coalesce(processed_at, now()), error = null
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
[event.id, refund.paymentId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: settled.nextStatus,
|
||||||
|
idempotent: true,
|
||||||
|
eventId: notification.eventId,
|
||||||
|
refundNo: notification.refundNo,
|
||||||
|
item: refundPayload(refund),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await client.query<RefundRequestRow>(
|
||||||
|
`
|
||||||
|
update public.commerce_refund_requests
|
||||||
|
set status = $3,
|
||||||
|
provider_refund_no = coalesce($4, provider_refund_no),
|
||||||
|
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then null else processed_by end,
|
||||||
|
processed_at = case when $3 in ('processing', 'succeeded', 'failed') then now() else processed_at end,
|
||||||
|
succeeded_at = case when $3 = 'succeeded' then coalesce(succeeded_at, now()) else succeeded_at end,
|
||||||
|
failed_at = case when $3 = 'failed' then coalesce(failed_at, now()) else failed_at end,
|
||||||
|
failure_reason = case when $3 = 'failed' then $5 else failure_reason end,
|
||||||
|
metadata = coalesce(metadata, '{}'::jsonb) || $6::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
where tenant_id = $1 and id = $2
|
||||||
|
returning id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
refund.id,
|
||||||
|
settled.nextStatus,
|
||||||
|
notification.providerRefundNo || null,
|
||||||
|
notification.failureReason || null,
|
||||||
|
JSON.stringify({
|
||||||
|
lastAction: 'refund_notify',
|
||||||
|
providerResult: notification.raw,
|
||||||
|
processResult: settled.processResult,
|
||||||
|
notifiedAt: notification.notifiedAt || null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const updatedRefund = await fetchRefundById(client, tenantId, updated.rows[0].id, true);
|
||||||
|
if (!updatedRefund) throw new HttpError(500, 'Refund request update failed', 'REFUND_UPDATE_FAILED');
|
||||||
|
|
||||||
|
await recordRefundEvent(client, {
|
||||||
|
tenantId,
|
||||||
|
refundRequestId: refund.id,
|
||||||
|
fromStatus: refund.status,
|
||||||
|
toStatus: settled.nextStatus,
|
||||||
|
eventType: settled.eventType,
|
||||||
|
actorUserId: null,
|
||||||
|
details: {
|
||||||
|
source: 'payment_provider_notify',
|
||||||
|
provider: notification.provider,
|
||||||
|
providerStatus: notification.status,
|
||||||
|
providerRefundNo: notification.providerRefundNo || null,
|
||||||
|
eventId: notification.eventId,
|
||||||
|
notifiedAt: notification.notifiedAt || null,
|
||||||
|
failureReason: notification.failureReason || null,
|
||||||
|
processResult: settled.processResult,
|
||||||
|
idempotent: settled.idempotent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
update public.payment_events
|
||||||
|
set payment_id = $2, processed_at = now(), error = null
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
[event.id, refund.paymentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: settled.nextStatus,
|
||||||
|
idempotent: settled.idempotent,
|
||||||
|
eventId: notification.eventId,
|
||||||
|
refundNo: notification.refundNo,
|
||||||
|
item: refundPayload(updatedRefund),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
update public.payment_events
|
||||||
|
set error = $2
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
[event.id, error instanceof Error ? error.message : 'Unknown refund notification error'],
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item };
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkActivationCodeRoute(ctx: RequestContext) {
|
export async function checkActivationCodeRoute(ctx: RequestContext) {
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx);
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
|||||||
@@ -108,8 +108,8 @@
|
|||||||
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
|
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
|
||||||
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
||||||
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
||||||
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
||||||
| 退款通知/补偿/对账 | 待补齐 | 微信退款通知解密、支付宝异步通知映射、支付补偿任务、对账、异常订单自动处理和退款 worker |
|
| 退款补偿/对账 | 待补齐 | 支付补偿任务、对账、异常订单自动处理和退款 worker |
|
||||||
|
|
||||||
## 租户后台与平台后台
|
## 租户后台与平台后台
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
|
||||||
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
|
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
|
||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
|
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、权益发放 | 退款通知 webhook、对账、支付补偿、异常订单自动处理 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、权益发放 | 对账、支付补偿、异常订单自动处理 |
|
||||||
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
|
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
|
||||||
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
||||||
| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker |
|
| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker |
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
- 退款通知 webhook、对账、支付补偿任务和异常订单自动处理。
|
- 对账、支付补偿任务和异常订单自动处理。
|
||||||
- XPay 或其它实际支付网关 adapter。
|
- XPay 或其它实际支付网关 adapter。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
|
||||||
- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||||
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
||||||
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
||||||
- `/api/commerce/refunds` 和 `/api/commerce/refunds/status` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,并通过 `query_provider_refund` 主动查询确认供应商退款结果,全额退款成功会撤销订单来源权益;退款通知 webhook、对账 worker 后续接入。
|
- `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;对账 worker 后续接入。
|
||||||
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||||
@@ -246,7 +246,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||||
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||||
4. 补微信退款通知 webhook、支付补偿任务、对账、异常订单自动处理和优惠券核销报表;退款查询确认主链路已完成。
|
4. 补支付补偿任务、对账、异常订单自动处理和优惠券核销报表;退款查询确认和退款通知主链路已完成。
|
||||||
5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。
|
5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。
|
||||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照可联调;Excel 导入、公共题库全量/增量版本同步仍需补齐 |
|
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照可联调;Excel 导入、公共题库全量/增量版本同步仍需补齐 |
|
||||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,退款通知 webhook、对账、支付补偿和异常订单自动处理待补 |
|
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||||
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
|
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
|
||||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
|
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
|
||||||
@@ -247,7 +247,7 @@ platform-admin:
|
|||||||
上线前至少还需要完成:
|
上线前至少还需要完成:
|
||||||
|
|
||||||
1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。
|
1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。
|
||||||
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款和退款查询确认的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、退款通知 webhook、对账和支付补偿仍需实现。
|
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。
|
||||||
3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。
|
3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。
|
||||||
4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
|
4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
|
||||||
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
|
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
|
||||||
@@ -263,4 +263,4 @@ platform-admin:
|
|||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
|
4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
|
||||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
||||||
6. 接真实短信、微信网页/QQ 登录、退款通知 webhook、退款对账和补偿任务,并开始 Taro scaffold。
|
6. 接真实短信、微信网页/QQ 登录、退款对账和补偿任务,并开始 Taro scaffold。
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
||||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认和全额退款权益撤销已有;缺退款通知 webhook、对账/补偿任务和前端收银台/售后体验 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook 和全额退款权益撤销已有;缺对账/补偿任务和前端收银台/售后体验 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
|
|
||||||
1. 支付
|
1. 支付
|
||||||
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
||||||
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、部分/全额退款状态、全额退款权益撤销和审计事件。
|
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
|
||||||
- 继续补微信退款通知 webhook、支付补偿任务、对账和异常订单自动处理。
|
- 继续补支付补偿任务、对账和异常订单自动处理。
|
||||||
- 租户自有商户收款和平台代收/服务商模式。
|
- 租户自有商户收款和平台代收/服务商模式。
|
||||||
|
|
||||||
2. 国内登录和短信
|
2. 国内登录和短信
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
7. 订单和营销体验
|
7. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||||
- 继续补退款通知 webhook、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
- 继续补支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
||||||
|
|
||||||
8. 积分和反馈增强
|
8. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
@@ -201,5 +201,5 @@
|
|||||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
||||||
5. 并行补对象存储、真实登录、退款通知 webhook、支付对账和公共题库版本同步 worker。
|
5. 并行补对象存储、真实登录、支付对账和公共题库版本同步 worker。
|
||||||
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -916,6 +916,7 @@ body: {
|
|||||||
requested -> approved -> processing -> succeeded
|
requested -> approved -> processing -> succeeded
|
||||||
requested -> approved -> submit_provider_refund -> processing/succeeded
|
requested -> approved -> submit_provider_refund -> processing/succeeded
|
||||||
processing -> query_provider_refund -> processing/succeeded/failed
|
processing -> query_provider_refund -> processing/succeeded/failed
|
||||||
|
provider refund notify -> processing/succeeded/failed
|
||||||
requested/approved -> rejected
|
requested/approved -> rejected
|
||||||
requested/approved -> cancelled
|
requested/approved -> cancelled
|
||||||
approved/processing -> failed
|
approved/processing -> failed
|
||||||
@@ -930,8 +931,10 @@ approved/processing -> failed
|
|||||||
- `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝;前端不要保存商户私钥、API v3 key 或支付宝应用私钥。
|
- `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝;前端不要保存商户私钥、API v3 key 或支付宝应用私钥。
|
||||||
- 微信退款通常先进入 `processing`,租户后台可以调用 `query_provider_refund` 主动向微信查询,确认成功后后端才会更新订单退款金额和权益。
|
- 微信退款通常先进入 `processing`,租户后台可以调用 `query_provider_refund` 主动向微信查询,确认成功后后端才会更新订单退款金额和权益。
|
||||||
- 支付宝普通退款如果响应 `fund_change=Y` 会同步进入 `succeeded`;处于 `processing` 的退款也可以用 `query_provider_refund` 调用 `alipay.trade.fastpay.refund.query` 确认。
|
- 支付宝普通退款如果响应 `fund_change=Y` 会同步进入 `succeeded`;处于 `processing` 的退款也可以用 `query_provider_refund` 调用 `alipay.trade.fastpay.refund.query` 确认。
|
||||||
|
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>`、`POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址,Taro 前端不要主动调用。
|
||||||
|
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
|
||||||
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
|
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
|
||||||
- 退款通知 webhook、自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。
|
- 自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。
|
||||||
|
|
||||||
### 激活码预检查与兑换
|
### 激活码预检查与兑换
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,21 @@ function encryptWechatResource(plain) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encryptWechatRefundResource(plain) {
|
||||||
|
const nonce = crypto.randomBytes(12).toString('base64url');
|
||||||
|
const aad = 'refund';
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
|
||||||
|
cipher.setAAD(Buffer.from(aad, 'utf8'));
|
||||||
|
const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return {
|
||||||
|
algorithm: 'AEAD_AES_256_GCM',
|
||||||
|
nonce,
|
||||||
|
associated_data: aad,
|
||||||
|
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function signWechatNotification(rawBody, timestamp, nonce) {
|
function signWechatNotification(rawBody, timestamp, nonce) {
|
||||||
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
|
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
|
||||||
return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64');
|
return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64');
|
||||||
@@ -1755,24 +1770,152 @@ async function testCommerce() {
|
|||||||
assert.equal(wechatRefundRequest?.body?.out_refund_no, 'RF-WECHAT-PROVIDER-001', 'WeChat refund should use out_refund_no');
|
assert.equal(wechatRefundRequest?.body?.out_refund_no, 'RF-WECHAT-PROVIDER-001', 'WeChat refund should use out_refund_no');
|
||||||
assert.equal(wechatRefundRequest?.body?.amount?.refund, 100, 'WeChat refund should send refund cents');
|
assert.equal(wechatRefundRequest?.body?.amount?.refund, 100, 'WeChat refund should send refund cents');
|
||||||
assert.equal(wechatRefundRequest?.body?.amount?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents');
|
assert.equal(wechatRefundRequest?.body?.amount?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents');
|
||||||
const wechatQueriedRefund = await request('/api/commerce/refunds/status', {
|
const wechatRefundNotifyBody = {
|
||||||
|
id: `refund-evt-${wechatOrder.item.orderNo}`,
|
||||||
|
create_time: '2026-06-28T00:00:00+08:00',
|
||||||
|
event_type: 'REFUND.SUCCESS',
|
||||||
|
resource_type: 'encrypt-resource',
|
||||||
|
resource: encryptWechatRefundResource({
|
||||||
|
mchid: '1900000001',
|
||||||
|
out_trade_no: wechatOrder.item.orderNo,
|
||||||
|
transaction_id: `wx-trade-${wechatOrder.item.orderNo}`,
|
||||||
|
out_refund_no: 'RF-WECHAT-PROVIDER-001',
|
||||||
|
refund_id: 'refund-RF-WECHAT-PROVIDER-001',
|
||||||
|
refund_status: 'SUCCESS',
|
||||||
|
success_time: '2026-06-28T00:00:00+08:00',
|
||||||
|
amount: { refund: 100, total: wechatOrder.item.amountCents, payer_total: wechatOrder.item.amountCents, payer_refund: 100 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const wechatRefundRaw = JSON.stringify(wechatRefundNotifyBody);
|
||||||
|
const wechatRefundTimestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const wechatRefundNonce = 'nonce-refund-smoke';
|
||||||
|
const wechatRefundNotify = await request('/api/commerce/refunds/notify/wechat_pay', {
|
||||||
|
userId: false,
|
||||||
|
method: 'POST',
|
||||||
|
query: { tenantId: MAIN_TENANT_ID },
|
||||||
|
headers: {
|
||||||
|
'wechatpay-timestamp': wechatRefundTimestamp,
|
||||||
|
'wechatpay-nonce': wechatRefundNonce,
|
||||||
|
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
|
||||||
|
'wechatpay-serial': 'platform-serial-smoke',
|
||||||
|
},
|
||||||
|
body: wechatRefundNotifyBody,
|
||||||
|
});
|
||||||
|
assert.equal(wechatRefundNotify.item?.status, 'succeeded', 'WeChat refund notify should confirm provider success');
|
||||||
|
assert.equal(wechatRefundNotify.item?.refundNo, 'RF-WECHAT-PROVIDER-001', 'WeChat refund notify should return refund number');
|
||||||
|
const wechatRefundNotifyAgain = await request('/api/commerce/refunds/notify/wechat_pay', {
|
||||||
|
userId: false,
|
||||||
|
method: 'POST',
|
||||||
|
query: { tenantId: MAIN_TENANT_ID },
|
||||||
|
headers: {
|
||||||
|
'wechatpay-timestamp': wechatRefundTimestamp,
|
||||||
|
'wechatpay-nonce': wechatRefundNonce,
|
||||||
|
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
|
||||||
|
'wechatpay-serial': 'platform-serial-smoke',
|
||||||
|
},
|
||||||
|
body: wechatRefundNotifyBody,
|
||||||
|
});
|
||||||
|
assert.equal(wechatRefundNotifyAgain.item?.idempotent, true, 'duplicate WeChat refund notify should be idempotent');
|
||||||
|
const wechatRefundQueryAfterNotify = await request('/api/commerce/refunds/status', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
refundId: wechatRefund.item.id,
|
refundId: wechatRefund.item.id,
|
||||||
action: 'query_provider_refund',
|
action: 'query_provider_refund',
|
||||||
},
|
},
|
||||||
|
expectStatus: 409,
|
||||||
});
|
});
|
||||||
assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success');
|
assert.equal(wechatRefundQueryAfterNotify.code, 'REFUND_STATUS_INVALID', 'succeeded WeChat refund must not be queried and applied twice');
|
||||||
assert.equal(wechatQueriedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund query should keep provider refund id');
|
|
||||||
const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-PROVIDER-001'));
|
|
||||||
assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint');
|
|
||||||
const wechatPartiallyRefundedStatus = await request('/api/commerce/orders/status', {
|
const wechatPartiallyRefundedStatus = await request('/api/commerce/orders/status', {
|
||||||
query: { orderNo: wechatOrder.item.orderNo },
|
query: { orderNo: wechatOrder.item.orderNo },
|
||||||
});
|
});
|
||||||
assert.equal(wechatPartiallyRefundedStatus.item?.status, 'partially_refunded', 'confirmed WeChat partial refund should update order status');
|
assert.equal(wechatPartiallyRefundedStatus.item?.status, 'partially_refunded', 'confirmed WeChat partial refund should update order status');
|
||||||
assert.equal(wechatPartiallyRefundedStatus.item?.refundedAmountCents, 100, 'confirmed WeChat partial refund should update refunded amount');
|
assert.equal(wechatPartiallyRefundedStatus.item?.refundedAmountCents, 100, 'confirmed WeChat partial refund should update refunded amount');
|
||||||
|
|
||||||
|
const wechatQueryOrder = await request('/api/commerce/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
planId: ids.plan,
|
||||||
|
payProvider: 'wechat_pay',
|
||||||
|
payMethod: 'jsapi',
|
||||||
|
regionId: ids.region,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await request('/api/commerce/payments/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
orderNo: wechatQueryOrder.item.orderNo,
|
||||||
|
provider: 'wechat_pay',
|
||||||
|
openId: 'openid-pay-query-smoke',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wechatQueryNotificationBody = {
|
||||||
|
id: `evt-${wechatQueryOrder.item.orderNo}`,
|
||||||
|
create_time: '2026-06-28T00:00:00+08:00',
|
||||||
|
event_type: 'TRANSACTION.SUCCESS',
|
||||||
|
resource_type: 'encrypt-resource',
|
||||||
|
resource: encryptWechatResource({
|
||||||
|
appid: 'wx-pay-smoke-appid',
|
||||||
|
mchid: '1900000001',
|
||||||
|
out_trade_no: wechatQueryOrder.item.orderNo,
|
||||||
|
transaction_id: `wx-trade-${wechatQueryOrder.item.orderNo}`,
|
||||||
|
trade_state: 'SUCCESS',
|
||||||
|
success_time: '2026-06-28T00:00:00+08:00',
|
||||||
|
amount: { total: wechatQueryOrder.item.amountCents, currency: 'CNY' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const wechatQueryRaw = JSON.stringify(wechatQueryNotificationBody);
|
||||||
|
const wechatQueryTimestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
await request('/api/commerce/payments/notify/wechat_pay', {
|
||||||
|
userId: false,
|
||||||
|
method: 'POST',
|
||||||
|
query: { tenantId: MAIN_TENANT_ID },
|
||||||
|
headers: {
|
||||||
|
'wechatpay-timestamp': wechatQueryTimestamp,
|
||||||
|
'wechatpay-nonce': 'nonce-pay-query-smoke',
|
||||||
|
'wechatpay-signature': signWechatNotification(wechatQueryRaw, wechatQueryTimestamp, 'nonce-pay-query-smoke'),
|
||||||
|
'wechatpay-serial': 'platform-serial-smoke',
|
||||||
|
},
|
||||||
|
body: wechatQueryNotificationBody,
|
||||||
|
});
|
||||||
|
const wechatQueryRefund = await request('/api/commerce/refunds', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
orderNo: wechatQueryOrder.item.orderNo,
|
||||||
|
refundNo: 'RF-WECHAT-QUERY-001',
|
||||||
|
amountCents: 100,
|
||||||
|
reason: 'provider refund query smoke',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: wechatQueryRefund.item.id,
|
||||||
|
action: 'approve',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: wechatQueryRefund.item.id,
|
||||||
|
action: 'submit_provider_refund',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wechatQueriedRefund = await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: wechatQueryRefund.item.id,
|
||||||
|
action: 'query_provider_refund',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success');
|
||||||
|
const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-QUERY-001'));
|
||||||
|
assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint');
|
||||||
|
|
||||||
const fakeAlipay = await startFakeAlipayServer();
|
const fakeAlipay = await startFakeAlipayServer();
|
||||||
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
|
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
@@ -1877,6 +2020,32 @@ async function testCommerce() {
|
|||||||
query: { orderNo: alipayOrder.item.orderNo },
|
query: { orderNo: alipayOrder.item.orderNo },
|
||||||
});
|
});
|
||||||
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
|
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
|
||||||
|
const alipayRefundNotifyBody = {
|
||||||
|
notify_id: `alipay-refund-notify-${alipayOrder.item.orderNo}`,
|
||||||
|
notify_time: '2026-06-28 00:00:00',
|
||||||
|
notify_type: 'trade_status_sync',
|
||||||
|
app_id: 'alipay-smoke-appid',
|
||||||
|
trade_no: `ali-trade-${alipayOrder.item.orderNo}`,
|
||||||
|
out_trade_no: alipayOrder.item.orderNo,
|
||||||
|
out_biz_no: 'RF-ALIPAY-PROVIDER-001',
|
||||||
|
refund_fee: (alipayOrder.item.amountCents / 100).toFixed(2),
|
||||||
|
fund_change: 'Y',
|
||||||
|
charset: 'utf-8',
|
||||||
|
version: '1.0',
|
||||||
|
};
|
||||||
|
alipayRefundNotifyBody.sign_type = 'RSA2';
|
||||||
|
alipayRefundNotifyBody.sign = signAlipayParams(alipayRefundNotifyBody);
|
||||||
|
const alipayRefundNotify = await request('/api/commerce/refunds/notify/alipay', {
|
||||||
|
userId: false,
|
||||||
|
method: 'POST',
|
||||||
|
query: { tenantId: MAIN_TENANT_ID },
|
||||||
|
body: alipayRefundNotifyBody,
|
||||||
|
});
|
||||||
|
assert.equal(alipayRefundNotify.item?.idempotent, true, 'Alipay refund notify for already succeeded refund should be idempotent');
|
||||||
|
const alipayRefundedAfterNotify = await request('/api/commerce/orders/status', {
|
||||||
|
query: { orderNo: alipayOrder.item.orderNo },
|
||||||
|
});
|
||||||
|
assert.equal(alipayRefundedAfterNotify.item?.refundedAmountCents, alipayOrder.item.amountCents, 'duplicate Alipay refund notify must not add refunded amount twice');
|
||||||
const alipayRefundQueryRejected = await request('/api/commerce/refunds/status', {
|
const alipayRefundQueryRejected = await request('/api/commerce/refunds/status', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user