forked from wangziqi/gongxue-base
feat: submit provider refunds
This commit is contained in:
@@ -26,7 +26,7 @@
|
|||||||
还没有达到生产交付的部分:
|
还没有达到生产交付的部分:
|
||||||
|
|
||||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配;微信网页登录、QQ 登录、手机号换绑、真实微信/支付宝退款 API、对账、支付补偿和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款已完成本地适配;微信网页登录、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 登录、真实退款 provider、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
5. 微信网页/QQ 登录、退款通知/查询确认、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -169,7 +169,17 @@ export function providerEndpoint(
|
|||||||
allowedHosts: string[],
|
allowedHosts: string[],
|
||||||
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
|
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
|
||||||
) {
|
) {
|
||||||
const raw = optionalPublicString(providerConfig, ['endpoint']) || fallback;
|
return providerEndpointForKeys(providerConfig, ['endpoint'], fallback, allowedHosts, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerEndpointForKeys(
|
||||||
|
providerConfig: TenantProviderConfig,
|
||||||
|
keys: string[],
|
||||||
|
fallback: string,
|
||||||
|
allowedHosts: string[],
|
||||||
|
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
|
||||||
|
) {
|
||||||
|
const raw = optionalPublicString(providerConfig, keys) || fallback;
|
||||||
let endpoint: URL;
|
let endpoint: URL;
|
||||||
try {
|
try {
|
||||||
endpoint = new URL(raw);
|
endpoint = new URL(raw);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
optionalPublicBoolean,
|
optionalPublicBoolean,
|
||||||
optionalPublicString,
|
optionalPublicString,
|
||||||
providerEndpoint,
|
providerEndpoint,
|
||||||
|
providerEndpointForKeys,
|
||||||
requirePublicString,
|
requirePublicString,
|
||||||
requireSecretString,
|
requireSecretString,
|
||||||
type TenantPaymentProviderConfig,
|
type TenantPaymentProviderConfig,
|
||||||
@@ -25,6 +26,18 @@ export interface PaymentOrderInput {
|
|||||||
quitUrl?: string;
|
quitUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentRefundInput {
|
||||||
|
tenantId: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
refundNo: string;
|
||||||
|
amountCents: number;
|
||||||
|
totalAmountCents: number;
|
||||||
|
providerTradeNo?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
notifyUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaymentCreateResult {
|
export interface PaymentCreateResult {
|
||||||
provider: PaymentProviderName;
|
provider: PaymentProviderName;
|
||||||
method: string;
|
method: string;
|
||||||
@@ -33,6 +46,13 @@ export interface PaymentCreateResult {
|
|||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentRefundResult {
|
||||||
|
provider: PaymentProviderName;
|
||||||
|
status: 'processing' | 'succeeded' | 'failed';
|
||||||
|
providerRefundNo?: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaymentNotificationResult {
|
export interface PaymentNotificationResult {
|
||||||
provider: PaymentProviderName;
|
provider: PaymentProviderName;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -47,6 +67,7 @@ export interface PaymentNotificationResult {
|
|||||||
export interface PaymentProvider {
|
export interface PaymentProvider {
|
||||||
name: PaymentProviderName;
|
name: PaymentProviderName;
|
||||||
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
|
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
|
||||||
|
createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult>;
|
||||||
parseNotification(input: {
|
parseNotification(input: {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -196,6 +217,63 @@ class WechatPayProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult> {
|
||||||
|
const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||||
|
const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||||
|
const endpoint = providerEndpointForKeys(
|
||||||
|
this.config,
|
||||||
|
['refundEndpoint', 'endpoint'],
|
||||||
|
'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds',
|
||||||
|
['api.mch.weixin.qq.com'],
|
||||||
|
'PAYMENT_ENDPOINT_NOT_ALLOWED',
|
||||||
|
);
|
||||||
|
const notifyUrl = input.notifyUrl || optionalPublicString(this.config, ['refundNotifyUrl', 'notifyUrl']);
|
||||||
|
const payloadObject: Record<string, unknown> = {
|
||||||
|
out_trade_no: input.orderNo,
|
||||||
|
out_refund_no: input.refundNo,
|
||||||
|
reason: (input.reason || 'refund').slice(0, 80),
|
||||||
|
amount: {
|
||||||
|
refund: input.amountCents,
|
||||||
|
total: input.totalAmountCents,
|
||||||
|
currency: 'CNY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (input.providerTradeNo) {
|
||||||
|
delete payloadObject.out_trade_no;
|
||||||
|
payloadObject.transaction_id = input.providerTradeNo;
|
||||||
|
}
|
||||||
|
if (notifyUrl) payloadObject.notify_url = notifyUrl;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(payloadObject);
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const nonce = randomNonce();
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n';
|
||||||
|
const signature = rsaSignSha256(wechatPrivateKey(this.config), message);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
const raw = safeJson(await response.json().catch(() => ({})));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpError(502, 'WeChat Pay refund request failed', 'PAYMENT_PROVIDER_REFUND_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : '';
|
||||||
|
return {
|
||||||
|
provider: this.name,
|
||||||
|
status: ['SUCCESS', 'CLOSED', 'ABNORMAL'].includes(statusText) ? 'succeeded' : 'processing',
|
||||||
|
providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : undefined,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async parseNotification(input: {
|
async parseNotification(input: {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -319,6 +397,67 @@ class AlipayProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult> {
|
||||||
|
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||||
|
const gateway = providerEndpointForKeys(
|
||||||
|
this.config,
|
||||||
|
['refundEndpoint', 'endpoint'],
|
||||||
|
'https://openapi.alipay.com/gateway.do',
|
||||||
|
['openapi.alipay.com'],
|
||||||
|
'PAYMENT_ENDPOINT_NOT_ALLOWED',
|
||||||
|
);
|
||||||
|
const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
|
||||||
|
const bizContent: Record<string, unknown> = {
|
||||||
|
out_trade_no: input.orderNo,
|
||||||
|
out_request_no: input.refundNo,
|
||||||
|
refund_amount: (input.amountCents / 100).toFixed(2),
|
||||||
|
refund_reason: (input.reason || 'refund').slice(0, 256),
|
||||||
|
};
|
||||||
|
if (input.providerTradeNo) {
|
||||||
|
delete bizContent.out_trade_no;
|
||||||
|
bizContent.trade_no = input.providerTradeNo;
|
||||||
|
}
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
app_id: appId,
|
||||||
|
method: 'alipay.trade.refund',
|
||||||
|
charset: 'utf-8',
|
||||||
|
sign_type: 'RSA2',
|
||||||
|
timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
version: '1.0',
|
||||||
|
biz_content: JSON.stringify(bizContent),
|
||||||
|
};
|
||||||
|
params.sign = rsaSignSha256(privateKey, canonicalForm(params));
|
||||||
|
|
||||||
|
const response = await fetch(gateway, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||||
|
},
|
||||||
|
body: encodedForm(params),
|
||||||
|
});
|
||||||
|
const raw = safeJson(await response.json().catch(() => ({})));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpError(502, 'Alipay refund request failed', 'PAYMENT_PROVIDER_REFUND_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = objectValue(raw.alipay_trade_refund_response);
|
||||||
|
const code = typeof responseBody.code === 'string' ? responseBody.code : '';
|
||||||
|
if (code !== '10000') {
|
||||||
|
throw new HttpError(502, 'Alipay refund was rejected', 'PAYMENT_PROVIDER_REFUND_REJECTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: this.name,
|
||||||
|
status: 'succeeded',
|
||||||
|
providerRefundNo:
|
||||||
|
(typeof responseBody.trade_no === 'string' && responseBody.trade_no)
|
||||||
|
|| (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no)
|
||||||
|
|| undefined,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async parseNotification(input: {
|
async parseNotification(input: {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -380,6 +519,10 @@ class ManualPaymentProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRefund(): Promise<PaymentRefundResult> {
|
||||||
|
throw new HttpError(501, 'Manual provider does not support online refunds', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1357,6 +1357,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
const action = requiredString(body, 'action');
|
const action = requiredString(body, 'action');
|
||||||
const note = optionalString(body, 'note') || null;
|
const note = optionalString(body, 'note') || null;
|
||||||
const providerRefundNo = optionalString(body, 'providerRefundNo') || null;
|
const providerRefundNo = optionalString(body, 'providerRefundNo') || null;
|
||||||
|
const providerNotifyUrl = optionalString(body, 'providerNotifyUrl') || null;
|
||||||
const failureReason = optionalString(body, 'failureReason') || note;
|
const failureReason = optionalString(body, 'failureReason') || note;
|
||||||
const metadataPatch = objectValue(body.metadata);
|
const metadataPatch = objectValue(body.metadata);
|
||||||
|
|
||||||
@@ -1371,7 +1372,8 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
let toStatus = fromStatus;
|
let toStatus = fromStatus;
|
||||||
let eventType = action;
|
let eventType = action;
|
||||||
let processResult: Record<string, unknown> = {};
|
let processResult: Record<string, unknown> = {};
|
||||||
const details: Record<string, unknown> = { note, providerRefundNo, failureReason, metadata: metadataPatch };
|
let providerResult: Record<string, unknown> = {};
|
||||||
|
const details: Record<string, unknown> = { note, providerRefundNo, providerNotifyUrl, failureReason, metadata: metadataPatch };
|
||||||
|
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
if (fromStatus !== 'requested') throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
if (fromStatus !== 'requested') throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||||
@@ -1387,6 +1389,24 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
}
|
}
|
||||||
toStatus = 'processing';
|
toStatus = 'processing';
|
||||||
eventType = 'processing';
|
eventType = 'processing';
|
||||||
|
} else if (action === 'submit_provider_refund') {
|
||||||
|
if (fromStatus !== 'approved') {
|
||||||
|
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||||
|
}
|
||||||
|
const submitted = await submitRefundToProvider(client, {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
refund,
|
||||||
|
actorUserId: auth.userId,
|
||||||
|
notifyUrl: providerNotifyUrl,
|
||||||
|
});
|
||||||
|
toStatus = submitted.nextStatus;
|
||||||
|
eventType = submitted.nextStatus === 'succeeded' ? 'provider_succeeded' : 'provider_submitted';
|
||||||
|
providerResult = submitted.refundResult.raw;
|
||||||
|
processResult = submitted.processResult;
|
||||||
|
details.provider = submitted.refundResult.provider;
|
||||||
|
details.providerStatus = submitted.refundResult.status;
|
||||||
|
details.providerRefundNo = submitted.refundResult.providerRefundNo || providerRefundNo || null;
|
||||||
|
details.processResult = processResult;
|
||||||
} else if (action === 'mark_succeeded') {
|
} else if (action === 'mark_succeeded') {
|
||||||
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
|
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
|
||||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||||
@@ -1419,7 +1439,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
`
|
`
|
||||||
update public.commerce_refund_requests
|
update public.commerce_refund_requests
|
||||||
set status = $3,
|
set status = $3,
|
||||||
provider_refund_no = coalesce($4, provider_refund_no),
|
provider_refund_no = coalesce($4, $8, provider_refund_no),
|
||||||
reviewed_by = case when $3 in ('approved', 'rejected') then $5 else reviewed_by end,
|
reviewed_by = case when $3 in ('approved', 'rejected') then $5 else reviewed_by end,
|
||||||
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then $5 else processed_by end,
|
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then $5 else processed_by end,
|
||||||
reviewed_at = case when $3 in ('approved', 'rejected') then now() else reviewed_at end,
|
reviewed_at = case when $3 in ('approved', 'rejected') then now() else reviewed_at end,
|
||||||
@@ -1440,7 +1460,8 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
providerRefundNo,
|
providerRefundNo,
|
||||||
auth.userId,
|
auth.userId,
|
||||||
failureReason,
|
failureReason,
|
||||||
JSON.stringify({ lastAction: action, note, processResult, ...metadataPatch }),
|
JSON.stringify({ lastAction: action, note, processResult, providerResult, ...metadataPatch }),
|
||||||
|
typeof details.providerRefundNo === 'string' ? details.providerRefundNo : null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const updatedRefund = await fetchRefundById(client, auth.tenantId, updated.rows[0].id, true);
|
const updatedRefund = await fetchRefundById(client, auth.tenantId, updated.rows[0].id, true);
|
||||||
@@ -1467,7 +1488,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
|
|||||||
fromStatus,
|
fromStatus,
|
||||||
toStatus,
|
toStatus,
|
||||||
amountCents: refund.amountCents,
|
amountCents: refund.amountCents,
|
||||||
providerRefundNo,
|
providerRefundNo: details.providerRefundNo || providerRefundNo,
|
||||||
processResult,
|
processResult,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1613,6 +1634,61 @@ async function loadPaymentProvider(tenantId: string, providerName: string) {
|
|||||||
return createPaymentProvider(provider, providerConfig);
|
return createPaymentProvider(provider, providerConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRefundToProvider(
|
||||||
|
client: pg.PoolClient,
|
||||||
|
input: {
|
||||||
|
tenantId: string;
|
||||||
|
refund: RefundRequestRow;
|
||||||
|
actorUserId: string;
|
||||||
|
notifyUrl?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const paymentResult = await client.query<{
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
providerTradeNo: string | null;
|
||||||
|
amountCents: number;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select id, provider, provider_trade_no as "providerTradeNo", amount_cents as "amountCents"
|
||||||
|
from public.payments
|
||||||
|
where tenant_id = $1 and id = $2
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[input.tenantId, input.refund.paymentId],
|
||||||
|
);
|
||||||
|
const payment = paymentResult.rows[0];
|
||||||
|
if (!payment) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND');
|
||||||
|
if (normalizePaymentProvider(payment.provider) === 'manual') {
|
||||||
|
throw new HttpError(409, 'Manual payment refunds must be recorded manually', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await loadPaymentProvider(input.tenantId, payment.provider);
|
||||||
|
const refundResult = await provider.createRefund({
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
orderId: input.refund.orderId,
|
||||||
|
orderNo: input.refund.orderNo,
|
||||||
|
refundNo: input.refund.refundNo,
|
||||||
|
amountCents: input.refund.amountCents,
|
||||||
|
totalAmountCents: payment.amountCents,
|
||||||
|
providerTradeNo: payment.providerTradeNo,
|
||||||
|
reason: input.refund.reason,
|
||||||
|
notifyUrl: input.notifyUrl || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refundResult.status === 'succeeded') {
|
||||||
|
const processResult = await applySuccessfulRefund(client, {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
refund: input.refund,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
providerRefundNo: refundResult.providerRefundNo || null,
|
||||||
|
});
|
||||||
|
return { refundResult, nextStatus: 'succeeded', processResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { refundResult, nextStatus: 'processing', processResult: {} };
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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`;支持退款申请、审核、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
| 退款状态机和发起退款 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`;支持退款申请、审核、调用微信/支付宝发起退款、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
||||||
| 补偿/对账/真实退款 provider | 待补齐 | 真实微信/支付宝退款 API、支付补偿任务、对账、异常订单自动处理和退款 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 预览、防盗链、视频水印 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、权益发放 | 退款、对账、支付补偿、异常订单处理 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、权益发放 | 退款通知/查询确认、对账、支付补偿、异常订单自动处理 |
|
||||||
| 登录认证 | 迁移期可用 | 短信 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:商用收费和运营能力
|
||||||
|
|
||||||
- 退款、对账、支付补偿任务和异常订单处理。
|
- 退款通知/查询确认、对账、支付补偿任务和异常订单自动处理。
|
||||||
- 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`,全额退款成功会撤销订单来源权益,真实微信/支付宝退款 API 和对账 worker 后续接入。
|
- `/api/commerce/refunds` 和 `/api/commerce/refunds/status` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,全额退款成功会撤销订单来源权益;退款通知/查询确认、对账 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. 补真实微信/支付宝退款 provider、支付补偿任务、对账、异常订单自动处理和优惠券核销报表。
|
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 集成测试 | 商城主链路可联调,退款/对账/支付补偿和异常订单处理待补 |
|
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | 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 登录、真实生产账号联调、退款/对账和支付补偿仍需实现。
|
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付和发起退款的租户级配置入口与本地 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 登录、退款对账和补偿任务,并开始 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 主链路、内部退款状态机和全额退款权益撤销已有;缺真实退款 provider、对账/补偿任务和前端收银台/售后体验 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款和全额退款权益撤销已有;缺退款通知/查询确认、对账/补偿任务和前端收银台/售后体验 |
|
||||||
| 个人中心 | `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 幂等开通权益。
|
||||||
- 已完成内部退款状态机、退款申请/审核/处理接口、部分/全额退款状态、全额退款权益撤销和审计事件。
|
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、部分/全额退款状态、全额退款权益撤销和审计事件。
|
||||||
- 继续补真实微信/支付宝退款 provider、支付补偿任务、对账和异常订单自动处理。
|
- 继续补微信退款通知/查询确认、支付宝退款查询、支付补偿任务、对账和异常订单自动处理。
|
||||||
- 租户自有商户收款和平台代收/服务商模式。
|
- 租户自有商户收款和平台代收/服务商模式。
|
||||||
|
|
||||||
2. 国内登录和短信
|
2. 国内登录和短信
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
7. 订单和营销体验
|
7. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||||
- 继续补真实 provider 退款、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
- 继续补退款通知/查询确认、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
||||||
|
|
||||||
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. 并行补对象存储、真实登录、真实退款 provider、支付对账和公共题库版本同步 worker。
|
5. 并行补对象存储、真实登录、退款通知/查询确认、支付对账和公共题库版本同步 worker。
|
||||||
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -903,8 +903,9 @@ body: {
|
|||||||
POST /api/commerce/refunds/status
|
POST /api/commerce/refunds/status
|
||||||
body: {
|
body: {
|
||||||
"refundId": "<refundId>",
|
"refundId": "<refundId>",
|
||||||
"action": "approve | reject | mark_processing | mark_succeeded | mark_failed | cancel",
|
"action": "approve | reject | submit_provider_refund | mark_processing | mark_succeeded | mark_failed | cancel",
|
||||||
"providerRefundNo": "<支付平台退款单号,可选>",
|
"providerRefundNo": "<支付平台退款单号,可选>",
|
||||||
|
"providerNotifyUrl": "<微信退款通知地址,可选>",
|
||||||
"note": "<处理备注>"
|
"note": "<处理备注>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -913,6 +914,7 @@ body: {
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
requested -> approved -> processing -> succeeded
|
requested -> approved -> processing -> succeeded
|
||||||
|
requested -> approved -> submit_provider_refund -> processing/succeeded
|
||||||
requested/approved -> rejected
|
requested/approved -> rejected
|
||||||
requested/approved -> cancelled
|
requested/approved -> cancelled
|
||||||
approved/processing -> failed
|
approved/processing -> failed
|
||||||
@@ -924,7 +926,9 @@ approved/processing -> failed
|
|||||||
- `refundNo` 是幂等键;同一订单同一金额重复提交会返回原退款申请。
|
- `refundNo` 是幂等键;同一订单同一金额重复提交会返回原退款申请。
|
||||||
- 后端会限制累计退款金额不能超过实付金额。
|
- 后端会限制累计退款金额不能超过实付金额。
|
||||||
- 全额退款成功后订单和支付会进入 `refunded`,相关订单权益会被置为 `revoked`;部分退款进入 `partially_refunded`,默认不撤销权益。
|
- 全额退款成功后订单和支付会进入 `refunded`,相关订单权益会被置为 `revoked`;部分退款进入 `partially_refunded`,默认不撤销权益。
|
||||||
- 当前接口完成内部退款状态机和人工成功登记;真实微信/支付宝退款 API、自动对账和补偿 worker 后续接入。前端不要假设点击退款后已经实时调用支付平台。
|
- `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝;前端不要保存商户私钥、API v3 key 或支付宝应用私钥。
|
||||||
|
- 微信退款通常先进入 `processing`,需要后续退款通知/查询确认;支付宝普通退款成功会同步进入 `succeeded`。前端应按接口返回状态展示,不要假设点击后立即到账。
|
||||||
|
- 退款通知/查询确认、自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。
|
||||||
|
|
||||||
### 激活码预检查与兑换
|
### 激活码预检查与兑换
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,21 @@ async function startFakeWechatPayServer() {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (url.pathname === '/v3/refund/domestic/refunds') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
refund_id: `refund-${body.out_refund_no || 'unknown'}`,
|
||||||
|
out_refund_no: body.out_refund_no,
|
||||||
|
out_trade_no: body.out_trade_no,
|
||||||
|
transaction_id: body.transaction_id,
|
||||||
|
status: 'PROCESSING',
|
||||||
|
amount: body.amount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname !== '/v3/pay/transactions/jsapi') {
|
if (url.pathname !== '/v3/pay/transactions/jsapi') {
|
||||||
res.writeHead(404, { 'content-type': 'application/json' });
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
|
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
|
||||||
@@ -341,6 +356,7 @@ async function startFakeWechatPayServer() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: `${baseUrl}/v3/pay/transactions/jsapi`,
|
endpoint: `${baseUrl}/v3/pay/transactions/jsapi`,
|
||||||
|
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
|
||||||
requests,
|
requests,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -374,6 +390,56 @@ function signAlipayParams(params) {
|
|||||||
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
|
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startFakeAlipayServer() {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let raw = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
raw += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
const params = Object.fromEntries(new URLSearchParams(raw).entries());
|
||||||
|
requests.push({
|
||||||
|
method: req.method,
|
||||||
|
pathname: req.url || '/',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
if (params.method !== 'alipay.trade.refund') {
|
||||||
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error_response: { code: '404', msg: 'not found' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const biz = JSON.parse(params.biz_content || '{}');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
alipay_trade_refund_response: {
|
||||||
|
code: '10000',
|
||||||
|
msg: 'Success',
|
||||||
|
trade_no: biz.trade_no || `ali-trade-${biz.out_trade_no || 'unknown'}`,
|
||||||
|
out_trade_no: biz.out_trade_no,
|
||||||
|
out_request_no: biz.out_request_no,
|
||||||
|
refund_fee: biz.refund_amount,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: `${baseUrl}/gateway.do`,
|
||||||
|
requests,
|
||||||
|
close: () => server.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createSupabaseJwt(authUserId, options = {}) {
|
async function createSupabaseJwt(authUserId, options = {}) {
|
||||||
const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET);
|
const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -1539,6 +1605,7 @@ async function testCommerce() {
|
|||||||
merchantSerialNo: 'serial-smoke',
|
merchantSerialNo: 'serial-smoke',
|
||||||
notifyUrl: 'https://pay.example.test/wechat/notify',
|
notifyUrl: 'https://pay.example.test/wechat/notify',
|
||||||
endpoint: fakeWechatPay.endpoint,
|
endpoint: fakeWechatPay.endpoint,
|
||||||
|
refundEndpoint: fakeWechatPay.refundEndpoint,
|
||||||
wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey,
|
wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey,
|
||||||
},
|
},
|
||||||
secret: {
|
secret: {
|
||||||
@@ -1622,6 +1689,41 @@ async function testCommerce() {
|
|||||||
});
|
});
|
||||||
assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent');
|
assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent');
|
||||||
|
|
||||||
|
const wechatRefund = await request('/api/commerce/refunds', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
orderNo: wechatOrder.item.orderNo,
|
||||||
|
refundNo: 'RF-WECHAT-PROVIDER-001',
|
||||||
|
amountCents: 100,
|
||||||
|
reason: 'provider refund smoke',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: wechatRefund.item.id,
|
||||||
|
action: 'approve',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wechatSubmittedRefund = await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: wechatRefund.item.id,
|
||||||
|
action: 'submit_provider_refund',
|
||||||
|
providerNotifyUrl: 'https://pay.example.test/wechat/refund-notify',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(wechatSubmittedRefund.item?.status, 'processing', 'WeChat provider refund should enter processing until refund notify/query confirms');
|
||||||
|
assert.equal(wechatSubmittedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund should record provider refund id');
|
||||||
|
const wechatRefundRequest = fakeWechatPay.requests.find(item => item.pathname === '/v3/refund/domestic/refunds');
|
||||||
|
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?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents');
|
||||||
|
|
||||||
|
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,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1634,6 +1736,7 @@ async function testCommerce() {
|
|||||||
appId: 'alipay-smoke-appid',
|
appId: 'alipay-smoke-appid',
|
||||||
notifyUrl: 'https://pay.example.test/alipay/notify',
|
notifyUrl: 'https://pay.example.test/alipay/notify',
|
||||||
returnUrl: 'https://app.example.test/pay/success',
|
returnUrl: 'https://app.example.test/pay/success',
|
||||||
|
endpoint: fakeAlipay.endpoint,
|
||||||
},
|
},
|
||||||
secret: {
|
secret: {
|
||||||
secretJson: {
|
secretJson: {
|
||||||
@@ -1688,6 +1791,43 @@ async function testCommerce() {
|
|||||||
assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid');
|
assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid');
|
||||||
assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement');
|
assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement');
|
||||||
|
|
||||||
|
const alipayRefund = await request('/api/commerce/refunds', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
orderNo: alipayOrder.item.orderNo,
|
||||||
|
refundNo: 'RF-ALIPAY-PROVIDER-001',
|
||||||
|
amountCents: alipayOrder.item.amountCents,
|
||||||
|
reason: 'alipay provider refund smoke',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: alipayRefund.item.id,
|
||||||
|
action: 'approve',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const alipaySubmittedRefund = await request('/api/commerce/refunds/status', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refundId: alipayRefund.item.id,
|
||||||
|
action: 'submit_provider_refund',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(alipaySubmittedRefund.item?.status, 'succeeded', 'Alipay provider refund should finish synchronously on success');
|
||||||
|
assert.equal(alipaySubmittedRefund.item?.providerRefundNo, `ali-trade-${alipayOrder.item.orderNo}`, 'Alipay refund should record provider trade number');
|
||||||
|
const alipayRefundRequest = fakeAlipay.requests.find(item => item.params?.method === 'alipay.trade.refund');
|
||||||
|
const alipayRefundBiz = JSON.parse(alipayRefundRequest?.params?.biz_content || '{}');
|
||||||
|
assert.equal(alipayRefundBiz.out_request_no, 'RF-ALIPAY-PROVIDER-001', 'Alipay refund should use out_request_no');
|
||||||
|
assert.equal(alipayRefundBiz.refund_amount, (alipayOrder.item.amountCents / 100).toFixed(2), 'Alipay refund should send yuan amount');
|
||||||
|
const alipayRefundedStatus = await request('/api/commerce/orders/status', {
|
||||||
|
query: { orderNo: alipayOrder.item.orderNo },
|
||||||
|
});
|
||||||
|
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
|
||||||
|
|
||||||
const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', {
|
const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', {
|
||||||
userId: false,
|
userId: false,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1699,6 +1839,7 @@ async function testCommerce() {
|
|||||||
expectStatus: 400,
|
expectStatus: 400,
|
||||||
});
|
});
|
||||||
assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification');
|
assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification');
|
||||||
|
fakeAlipay.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testTenantIsolation() {
|
async function testTenantIsolation() {
|
||||||
|
|||||||
Reference in New Issue
Block a user