feat: improve commerce checkout flow

This commit is contained in:
Codex
2026-06-29 02:44:28 +08:00
parent 9122bb0829
commit c767b87c6f
15 changed files with 1214 additions and 72 deletions

View File

@@ -14,7 +14,7 @@
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单、权益、激活码兑换、资料下载。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
- PocketBase schema/数据导入器雏形和导入后校验脚本。
@@ -23,7 +23,7 @@
还没有达到生产交付的部分:
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配微信网页登录、QQ 登录、手机号换绑、退款/对账和真实生产账号联调还没接完。
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配微信网页登录、QQ 登录、手机号换绑、退款/对账、支付补偿和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF 预览、防盗链和视频水印还没完成。
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
- Taro 跨端前端还没开始 scaffold。
@@ -130,7 +130,7 @@ npm run test:api
apps/api/src/features/
auth/ 短信登录、迁移期 session、OAuth 占位
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
commerce/ 订单、支付确认、激活码、权益
commerce/ 订单、支付确认、激活码、优惠券、权益
health/ 健康检查
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
@@ -178,4 +178,4 @@ npm run check:refactor
2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化,以及排行榜防刷/预聚合。
5. 微信网页/QQ 登录、退款对账、支付补偿、CRM worker、公共题库授权、租户采纳、积分活动深化以及排行榜防刷/预聚合。

View File

@@ -1,10 +1,14 @@
import type { RouteDefinition } from '../../core/router.js';
import {
checkActivationCodeRoute,
claimCouponRoute,
confirmManualPaymentRoute,
createPaymentRoute,
createOrderRoute,
entitlementCheckRoute,
entitlementsRoute,
orderDetailRoute,
orderStatusRoute,
ordersRoute,
paymentNotifyRoute,
redeemActivationCodeRoute,
@@ -13,6 +17,8 @@ import {
export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/orders', createOrderRoute],
['GET', '/api/commerce/orders', ordersRoute],
['GET', '/api/commerce/orders/detail', orderDetailRoute],
['GET', '/api/commerce/orders/status', orderStatusRoute],
['GET', '/api/commerce/entitlements', entitlementsRoute],
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
['POST', '/api/commerce/payments/create', createPaymentRoute],
@@ -20,5 +26,7 @@ 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/alipay', paymentNotifyRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
];

View File

@@ -1,3 +1,4 @@
import type pg from 'pg';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
@@ -5,6 +6,7 @@ import {
optionalString,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
@@ -17,13 +19,16 @@ import {
type PaymentProviderName,
} from './providers.js';
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
import { requireTenantAdmin, requireTenantPermission } from '../tenant-admin/auth.js';
interface PlanRow {
id: string;
name: string;
price_cents: number;
original_price_cents: number | null;
days: number;
region_id: string | null;
coupon_only: boolean;
}
interface OrderRow {
@@ -55,6 +60,56 @@ interface PaymentOrderRow {
region_id: string | null;
}
interface OrderDetailRow {
id: string;
orderNo: string;
status: string;
productType: string | null;
productName: string | null;
amountCents: number;
payMethod: string | null;
payProvider: string | null;
tradeNo: string | null;
planId: string | null;
days: number | null;
userId: string | null;
regionId: string | null;
paidAt: string | null;
rawPayload: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface CouponRow {
id: string;
code: string;
plan_id: string | null;
discount_type: 'percent' | 'fixed' | null;
discount_value: string | number | null;
valid_from: string | null;
valid_to: string | null;
max_uses: number | null;
used_count: number;
source: string | null;
remark: string | null;
}
interface CouponRedemptionRow {
id: string;
coupon_id: string | null;
coupon_code: string | null;
user_id: string | null;
plan_id: string | null;
order_id: string | null;
status: string;
discount_applied_cents: number | null;
region_id: string | null;
source: string | null;
remark: string | null;
claimed_at: string | null;
used_at: string | null;
}
interface EntitlementRow {
id: string;
entitlementType: string;
@@ -69,6 +124,229 @@ interface EntitlementRow {
createdAt: string;
}
function normalizeCode(value: string) {
return value.trim().replace(/\s+/g, '').toUpperCase();
}
function formatPrice(amountCents: number) {
return Number((Math.max(0, amountCents) / 100).toFixed(2));
}
function safeDiscountValue(value: string | number | null) {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function calculateCouponDiscount(coupon: CouponRow, baseAmountCents: number) {
const amount = Math.max(0, Math.trunc(baseAmountCents));
if (!coupon.discount_type || coupon.discount_value === null || coupon.discount_value === undefined) return 0;
if (coupon.discount_type === 'fixed') {
return Math.min(amount, Math.max(0, Math.round(safeDiscountValue(coupon.discount_value))));
}
const percent = Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)));
return Math.min(amount, Math.floor((amount * percent) / 100));
}
function calculatePlanDays(planDays: number, quantity: number) {
const days = Math.trunc(planDays || 0);
if (days < 0) return -1;
return Math.max(0, days * quantity);
}
function couponValidity(coupon: CouponRow, now = new Date()) {
if (coupon.valid_from && now < new Date(coupon.valid_from)) {
return { valid: false, code: 'COUPON_NOT_STARTED', message: 'Coupon is not active yet' };
}
if (coupon.valid_to && now > new Date(coupon.valid_to)) {
return { valid: false, code: 'COUPON_EXPIRED', message: 'Coupon has expired' };
}
if (coupon.max_uses !== null && coupon.max_uses !== undefined && coupon.max_uses > 0 && coupon.used_count >= coupon.max_uses) {
return { valid: false, code: 'COUPON_QUOTA_EXHAUSTED', message: 'Coupon quota has been exhausted' };
}
return { valid: true, code: '', message: '' };
}
function couponPayload(coupon: CouponRow, plan: PlanRow, redemption: CouponRedemptionRow | null, discountCents: number) {
return {
code: coupon.code,
discountType: coupon.discount_type,
discountValue: coupon.discount_value === null || coupon.discount_value === undefined ? null : Number(coupon.discount_value),
discountCents,
source: coupon.source,
remark: coupon.remark,
redemptionId: redemption?.id || null,
redemptionStatus: redemption?.status || null,
plan: {
id: plan.id,
name: plan.name,
priceCents: plan.price_cents,
price: formatPrice(plan.price_cents),
originalPriceCents: plan.original_price_cents,
originalPrice: plan.original_price_cents === null ? null : formatPrice(plan.original_price_cents),
days: plan.days,
regionId: plan.region_id,
couponOnly: plan.coupon_only,
},
};
}
async function loadPlan(client: pg.PoolClient | null, tenantId: string, planId: string, lock = false) {
const sql = `
select id, name, price_cents, original_price_cents, days, region_id, coupon_only
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
${lock ? 'for update' : ''}
`;
if (client) {
const result = await client.query<PlanRow>(sql, [tenantId, planId]);
return result.rows[0] || null;
}
return queryOne<PlanRow>(sql, [tenantId, planId]);
}
async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: string, lock = false) {
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
from public.coupons
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
${lock ? 'for update' : ''}
`,
[tenantId, normalizeCode(code)],
);
return result.rows[0] || null;
}
async function findCouponById(client: pg.PoolClient, tenantId: string, couponId: string, lock = false) {
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
from public.coupons
where tenant_id = $1 and id = $2
limit 1
${lock ? 'for update' : ''}
`,
[tenantId, couponId],
);
return result.rows[0] || null;
}
async function findCouponRedemption(client: pg.PoolClient, tenantId: string, userId: string, couponId: string) {
const result = await client.query<CouponRedemptionRow>(
`
select id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
from public.coupon_redemptions
where tenant_id = $1 and user_id = $2 and coupon_id = $3
order by created_at desc
limit 1
for update
`,
[tenantId, userId, couponId],
);
return result.rows[0] || null;
}
async function claimCoupon(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
code: string;
planId?: string | null;
regionId?: string | null;
baseAmountCents?: number;
},
) {
const coupon = await findCouponByCode(client, input.tenantId, input.code, true);
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
const validity = couponValidity(coupon);
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
const planId = input.planId || coupon.plan_id;
if (!planId) throw new HttpError(409, 'Coupon is not bound to a plan', 'COUPON_PLAN_MISSING');
if (coupon.plan_id && input.planId && coupon.plan_id !== input.planId) {
throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH');
}
const plan = await loadPlan(client, input.tenantId, planId);
if (!plan) throw new HttpError(404, 'Coupon plan not found', 'COUPON_PLAN_NOT_FOUND');
const finalRegionId = input.regionId || plan.region_id;
if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) {
throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH');
}
const existing = await findCouponRedemption(client, input.tenantId, input.userId, coupon.id);
if (existing?.status === 'used') throw new HttpError(409, 'Coupon already used', 'COUPON_ALREADY_USED');
if (existing && !['claimed', 'pending'].includes(existing.status)) {
throw new HttpError(409, `Coupon redemption is ${existing.status}`, 'COUPON_REDEMPTION_UNAVAILABLE');
}
const discountCents = calculateCouponDiscount(coupon, input.baseAmountCents ?? plan.price_cents);
if (existing) {
return { coupon, plan, redemption: existing, discountCents, idempotent: true };
}
const inserted = await client.query<CouponRedemptionRow>(
`
insert into public.coupon_redemptions (
tenant_id, coupon_id, coupon_code, user_id, plan_id, status,
discount_applied_cents, region_id, source, remark, claimed_at
)
values ($1, $2, $3, $4, $5, 'claimed', $6, $7::uuid, $8, $9, now())
returning id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
`,
[
input.tenantId,
coupon.id,
coupon.code,
input.userId,
plan.id,
discountCents,
finalRegionId || null,
coupon.source,
coupon.remark,
],
);
return { coupon, plan, redemption: inserted.rows[0], discountCents, idempotent: false };
}
function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: unknown[], redemptions: unknown[]) {
const snapshot =
order.rawPayload && typeof order.rawPayload === 'object' && 'pricing' in order.rawPayload
? (order.rawPayload as Record<string, unknown>).pricing
: null;
return {
id: order.id,
orderNo: order.orderNo,
status: order.status,
productType: order.productType,
productName: order.productName,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
payMethod: order.payMethod,
payProvider: order.payProvider,
tradeNo: order.tradeNo,
planId: order.planId,
days: order.days,
regionId: order.regionId,
paidAt: order.paidAt,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
pricing: snapshot,
payments,
items,
couponRedemptions: redemptions,
};
}
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
@@ -78,27 +356,98 @@ export async function createOrderRoute(ctx: RequestContext) {
const payMethod = optionalString(body, 'payMethod') || 'manual';
const payProvider = optionalString(body, 'payProvider') || 'manual';
const regionId = optionalString(body, 'regionId') || null;
const couponCode = optionalString(body, 'couponCode');
const couponRedemptionId = optionalString(body, 'couponRedemptionId');
const plan = await queryOne<PlanRow>(
`
select id, name, price_cents, days, region_id
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, planId],
);
const plan = await loadPlan(null, tenantId, planId);
if (!plan) {
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
}
if (plan.coupon_only && !couponCode && !couponRedemptionId) {
throw new HttpError(403, 'This plan requires a coupon', 'PLAN_REQUIRES_COUPON');
}
const finalRegionId = regionId || plan.region_id;
const amountCents = Math.max(0, plan.price_cents * quantity);
const days = Math.max(0, plan.days * quantity);
const originalAmountCents = Math.max(0, plan.price_cents * quantity);
const days = calculatePlanDays(plan.days, quantity);
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
const item = await transaction(async client => {
let couponResult:
| {
coupon: CouponRow;
plan: PlanRow;
redemption: CouponRedemptionRow;
discountCents: number;
idempotent: boolean;
}
| null = null;
if (couponRedemptionId) {
const redemptionResult = await client.query<CouponRedemptionRow>(
`
select id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
from public.coupon_redemptions
where tenant_id = $1 and id = $2 and user_id = $3
limit 1
for update
`,
[tenantId, couponRedemptionId, userId],
);
const redemption = redemptionResult.rows[0];
if (!redemption) throw new HttpError(404, 'Coupon redemption not found', 'COUPON_REDEMPTION_NOT_FOUND');
if (redemption.status === 'used') throw new HttpError(409, 'Coupon already used', 'COUPON_ALREADY_USED');
if (redemption.order_id) throw new HttpError(409, 'Coupon redemption is already bound to an order', 'COUPON_REDEMPTION_BOUND');
if (redemption.plan_id && redemption.plan_id !== planId) {
throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH');
}
const coupon = redemption.coupon_id
? await findCouponById(client, tenantId, redemption.coupon_id, true)
: await findCouponByCode(client, tenantId, redemption.coupon_code || '', true);
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
const validity = couponValidity(coupon);
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
const discountCents = calculateCouponDiscount(coupon, originalAmountCents);
couponResult = { coupon, plan, redemption, discountCents, idempotent: true };
} else if (couponCode) {
const claimed = await claimCoupon(client, {
tenantId,
userId,
code: couponCode,
planId,
regionId: finalRegionId,
baseAmountCents: originalAmountCents,
});
couponResult = {
coupon: claimed.coupon,
plan: claimed.plan,
redemption: claimed.redemption,
discountCents: claimed.discountCents,
idempotent: claimed.idempotent,
};
}
if (couponResult?.plan.id !== undefined && couponResult.plan.id !== plan.id) {
throw new HttpError(409, 'Coupon plan does not match the order plan', 'COUPON_PLAN_MISMATCH');
}
if (couponResult?.redemption.order_id) {
throw new HttpError(409, 'Coupon redemption is already bound to an order', 'COUPON_REDEMPTION_BOUND');
}
const discountCents = couponResult ? Math.min(originalAmountCents, Math.max(0, couponResult.discountCents)) : 0;
const amountCents = Math.max(0, originalAmountCents - discountCents);
const pricingSnapshot = {
quantity,
unitAmountCents: plan.price_cents,
originalAmountCents,
discountCents,
amountCents,
couponCode: couponResult?.coupon.code || null,
couponRedemptionId: couponResult?.redemption.id || null,
};
const orderResult = await client.query(
`
insert into public.orders (
@@ -123,9 +472,10 @@ export async function createOrderRoute(ctx: RequestContext) {
planId,
days,
finalRegionId,
JSON.stringify({ quantity, request: body }),
JSON.stringify({ quantity, pricing: pricingSnapshot, request: body }),
],
);
const order = orderResult.rows[0];
await client.query(
`
@@ -134,25 +484,139 @@ export async function createOrderRoute(ctx: RequestContext) {
`,
[
tenantId,
orderResult.rows[0].id,
order.id,
planId,
plan.name,
quantity,
plan.price_cents,
amountCents,
JSON.stringify({ days: plan.days, totalDays: days }),
originalAmountCents,
JSON.stringify({ days: plan.days, totalDays: days, discountCents, payableAmountCents: amountCents }),
],
);
if (couponResult) {
if (discountCents > 0) {
await client.query(
`
insert into public.order_items (
tenant_id, order_id, item_type, item_id, name, quantity,
unit_amount_cents, total_amount_cents, metadata
)
values ($1, $2, 'coupon_discount', $3, $4, 1, -($5::integer), -($5::integer), $6::jsonb)
`,
[
tenantId,
order.id,
couponResult.coupon.id,
`优惠券 ${couponResult.coupon.code}`,
discountCents,
JSON.stringify({
couponCode: couponResult.coupon.code,
redemptionId: couponResult.redemption.id,
discountType: couponResult.coupon.discount_type,
discountValue: couponResult.coupon.discount_value,
}),
],
);
}
const redemptionUpdate = await client.query(
`
update public.coupon_redemptions
set status = 'used',
order_id = $3,
plan_id = $4,
discount_applied_cents = $5,
region_id = coalesce($6::uuid, region_id),
used_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2 and status in ('claimed', 'pending')
`,
[tenantId, couponResult.redemption.id, order.id, plan.id, discountCents, finalRegionId],
);
if (redemptionUpdate.rowCount !== 1) {
throw new HttpError(409, 'Coupon redemption is no longer available', 'COUPON_REDEMPTION_UNAVAILABLE');
}
await client.query(
`
update public.coupons
set used_count = used_count + 1, updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, couponResult.coupon.id],
);
}
const zeroAmountTradeNo = amountCents === 0 ? `zero-${orderNo}` : null;
if (zeroAmountTradeNo) {
await client.query(
`
update public.orders
set status = 'paid',
trade_no = $3,
paid_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, order.id, zeroAmountTradeNo],
);
order.status = 'paid';
}
await client.query(
`
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
insert into public.payments (
tenant_id, order_id, provider, method, status, amount_cents,
provider_trade_no, paid_at, raw_payload
)
values (
$1, $2, $3, $4, $5, $6,
$7, case when $7::text is null then null else now() end, $8::jsonb
)
`,
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
[
tenantId,
order.id,
payProvider,
payMethod,
zeroAmountTradeNo ? 'paid' : 'pending',
amountCents,
zeroAmountTradeNo,
JSON.stringify({ pricing: pricingSnapshot, request: body }),
],
);
return orderResult.rows[0];
const entitlement =
amountCents === 0
? await grantSvipEntitlement(client, {
tenantId,
userId,
days,
regionId: finalRegionId,
sourceType: 'order',
sourceId: order.id,
metadata: {
orderNo,
paymentProvider: payProvider,
zeroAmountCheckout: true,
couponCode: couponResult?.coupon.code || null,
},
})
: null;
return {
...order,
status: amountCents === 0 ? 'paid' : order.status,
tradeNo: zeroAmountTradeNo,
originalAmountCents,
discountCents,
amount: formatPrice(amountCents),
entitlement,
coupon: couponResult
? couponPayload(couponResult.coupon, couponResult.plan, couponResult.redemption, discountCents)
: null,
};
});
return { item };
@@ -182,6 +646,128 @@ export async function ordersRoute(ctx: RequestContext) {
return { items };
}
async function loadOrderDetail(tenantId: string, userId: string, orderNo: string) {
const order = await queryOne<OrderDetailRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
limit 1
`,
[tenantId, userId, orderNo],
);
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
const [payments, items, redemptions] = await Promise.all([
query(
`
select id, provider, method, status, amount_cents as "amountCents",
provider_trade_no as "providerTradeNo", paid_at as "paidAt",
created_at as "createdAt", updated_at as "updatedAt"
from public.payments
where tenant_id = $1 and order_id = $2
order by created_at desc
`,
[tenantId, order.id],
),
query(
`
select id, item_type as "itemType", item_id as "itemId", name, quantity,
unit_amount_cents as "unitAmountCents", total_amount_cents as "totalAmountCents",
metadata
from public.order_items
where tenant_id = $1 and order_id = $2
order by case item_type when 'svip_plan' then 0 when 'coupon_discount' then 1 else 2 end, id
`,
[tenantId, order.id],
),
query(
`
select id, coupon_code as "couponCode", status, discount_applied_cents as "discountAppliedCents",
region_id as "regionId", claimed_at as "claimedAt", used_at as "usedAt"
from public.coupon_redemptions
where tenant_id = $1 and order_id = $2 and user_id = $3
order by created_at desc
`,
[tenantId, order.id, userId],
),
]);
return orderDetailPayload(order, payments, items, redemptions);
}
export async function orderDetailRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const orderNo = stringParam(ctx, 'orderNo');
if (!orderNo) throw new HttpError(400, 'orderNo is required', 'ORDER_NO_REQUIRED');
return { item: await loadOrderDetail(tenantId, userId, orderNo) };
}
export async function orderStatusRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const orderNo = stringParam(ctx, 'orderNo');
if (!orderNo) throw new HttpError(400, 'orderNo is required', 'ORDER_NO_REQUIRED');
const order = await queryOne<OrderDetailRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
limit 1
`,
[tenantId, userId, orderNo],
);
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
const latestPayment = await queryOne<{
provider: string;
method: string | null;
status: string;
amountCents: number;
providerTradeNo: string | null;
paidAt: string | null;
updatedAt: string;
}>(
`
select provider, method, status, amount_cents as "amountCents",
provider_trade_no as "providerTradeNo", paid_at as "paidAt",
updated_at as "updatedAt"
from public.payments
where tenant_id = $1 and order_id = $2
order by updated_at desc
limit 1
`,
[tenantId, order.id],
);
return {
item: {
orderNo: order.orderNo,
status: order.status,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
payProvider: order.payProvider,
payMethod: order.payMethod,
tradeNo: order.tradeNo,
paidAt: order.paidAt,
updatedAt: order.updatedAt,
payment: latestPayment,
},
};
}
export async function entitlementsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -281,7 +867,9 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:payment:write');
const tenantId = auth.tenantId;
const orderNo = requiredString(body, 'orderNo');
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
const amountCents = optionalInteger(body, 'amountCents', -1);
@@ -342,7 +930,7 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
metadata: { orderNo },
});
return { orderNo, status: 'paid', entitlement };
return { orderNo, status: 'paid', confirmedBy: auth.userId, entitlement };
});
return { item };
@@ -708,11 +1296,95 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
return { item };
}
export async function checkActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = normalizeCode(requiredString(body, 'code'));
const regionId = optionalString(body, 'regionId') || null;
const activationCode = await queryOne<{
id: string;
code: string;
days: number;
isUsed: boolean;
usedRegionId: string | null;
agentUserId: string | null;
saleType: string | null;
unitPriceCents: number | null;
soldTo: string | null;
remark: string | null;
regionName: string | null;
}>(
`
select ac.id, ac.code::text as code, ac.days, ac.is_used as "isUsed",
ac.used_region_id as "usedRegionId", ac.agent_user_id as "agentUserId",
ac.sale_type as "saleType", ac.unit_price_cents as "unitPriceCents",
ac.sold_to as "soldTo", ac.remark, r.name as "regionName"
from public.activation_codes ac
left join public.regions r on r.tenant_id = ac.tenant_id and r.id = ac.used_region_id
where ac.tenant_id = $1 and lower(ac.code::text) = lower($2)
limit 1
`,
[tenantId, code],
);
if (!activationCode) {
return {
valid: false,
code,
reasonCode: 'ACTIVATION_CODE_NOT_FOUND',
message: 'Activation code not found',
};
}
if (activationCode.isUsed) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_USED',
message: 'Activation code already used',
days: activationCode.days,
};
}
if (activationCode.agentUserId && activationCode.agentUserId === userId) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
message: 'Cannot redeem your own activation code',
days: activationCode.days,
};
}
if (regionId && activationCode.usedRegionId && regionId !== activationCode.usedRegionId) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_REGION_MISMATCH',
message: 'Activation code does not apply to this region',
days: activationCode.days,
regionId: activationCode.usedRegionId,
regionName: activationCode.regionName,
};
}
return {
valid: true,
code: activationCode.code,
days: activationCode.days,
regionId: activationCode.usedRegionId,
regionName: activationCode.regionName,
saleType: activationCode.saleType,
unitPriceCents: activationCode.unitPriceCents,
soldTo: activationCode.soldTo,
remark: activationCode.remark,
};
}
export async function redeemActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = requiredString(body, 'code');
const code = normalizeCode(requiredString(body, 'code'));
const regionId = optionalString(body, 'regionId') || null;
const item = await transaction(async client => {
@@ -722,9 +1394,10 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
days: number;
is_used: boolean;
used_region_id: string | null;
agent_user_id: string | null;
}>(
`
select id, code, days, is_used, used_region_id
select id, code::text as code, days, is_used, used_region_id, agent_user_id
from public.activation_codes
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
@@ -740,6 +1413,12 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
if (activationCode.is_used) {
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
}
if (activationCode.agent_user_id && activationCode.agent_user_id === userId) {
throw new HttpError(409, 'Cannot redeem your own activation code', 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN');
}
if (regionId && activationCode.used_region_id && regionId !== activationCode.used_region_id) {
throw new HttpError(409, 'Activation code does not apply to this region', 'ACTIVATION_CODE_REGION_MISMATCH');
}
const finalRegionId = regionId || activationCode.used_region_id;
await client.query(
@@ -769,3 +1448,35 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
return { item };
}
export async function claimCouponRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = normalizeCode(requiredString(body, 'code'));
const planId = optionalString(body, 'planId') || null;
const regionId = optionalString(body, 'regionId') || null;
const result = await transaction(async client => {
const claimed = await claimCoupon(client, {
tenantId,
userId,
code,
planId,
regionId,
});
return {
valid: true,
idempotent: claimed.idempotent,
coupon: couponPayload(claimed.coupon, claimed.plan, claimed.redemption, claimed.discountCents),
redemption: {
id: claimed.redemption.id,
status: claimed.redemption.status,
claimedAt: claimed.redemption.claimed_at,
usedAt: claimed.redemption.used_at,
},
};
});
return result;
}

View File

@@ -12,7 +12,9 @@ interface EntitlementInput {
}
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
const days = Math.max(0, Math.trunc(input.days || 0));
const days = Math.trunc(input.days || 0);
const expiresAtSql =
days < 0 ? `'2099-12-31T23:59:59Z'::timestamptz` : `$8::timestamptz + ($9::text || ' days')::interval`;
const scopeType = input.regionId ? 'region' : 'tenant';
const startsAtResult = await client.query<{ starts_at: string }>(
`
@@ -41,7 +43,7 @@ export async function grantSvipEntitlement(client: pg.PoolClient, input: Entitle
values (
$1, $2, 'svip', $3, $4,
$5, $6, $7, $8::timestamptz,
$8::timestamptz + ($9::text || ' days')::interval,
${expiresAtSql},
'active', $10::jsonb
)
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",

View File

@@ -18,7 +18,7 @@ apps/api/src/
tenant/ 租户解析、品牌配置、域名识别
catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜
commerce/ 订单、支付确认、激活码、权益
commerce/ 订单、支付确认、激活码、优惠券、权益
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
storage/ 对象存储签名 provider
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
@@ -35,7 +35,7 @@ apps/api/src/
features/
auth/ 登录、绑定手机、OAuth 回调、会话换取
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜
commerce/ 商品、订单、支付、退款、权益开通
commerce/ 商品、订单、优惠券、支付、退款、权益开通
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
platform-admin/ 平台租户管理、年费、服务费、账务审计
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券

View File

@@ -99,14 +99,14 @@
| 能力 | 状态 | 说明 |
| --- | --- | --- |
| SVIP 套餐 | 可联调 | `/api/catalog/svip-plans` |
| 创建订单/订单列表 | 可联调 | `/api/commerce/orders` |
| 手工支付确认 | 迁移期 | 可用于测试,不是生产支付 |
| 创建订单/订单列表/订单详情/状态轮询 | 可联调 | `/api/commerce/orders``orders/detail``orders/status`;订单金额、优惠券抵扣、零元订单都以后端计算为准 |
| 手工支付确认 | 迁移期 | `/api/commerce/payments/manual-confirm` 仅允许具备 `tenant:payment:write` 的租户后台成员调用,用于线下收款/本地测试 |
| 微信支付 JSAPI | 可联调 | `/api/commerce/payments/create``notify/wechat_pay`,已覆盖 API v3 签名、通知解密、幂等和权益开通 |
| 支付宝 WAP/H5 | 可联调 | `/api/commerce/payments/create``notify/alipay`,已覆盖 RSA2 通知验签、幂等和权益开通 |
| 权益查询/校验 | 可联调 | `/api/commerce/entitlements` |
| 激活码兑换 | 可联调 | 事务开通权益 |
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check``redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
| 优惠券前台兑换/下单抵扣 | 待补齐 | 后端还需接入下单计算 |
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 |
## 租户后台与平台后台

View File

@@ -10,7 +10,7 @@
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、排行榜、分数线、视频、订单、权益、激活码兑换和资料下载的基础 API。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
- 旧题库 JSON、单词模板、知识手册嵌套模板已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
@@ -29,7 +29,7 @@
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
| 会员与订单 | 基础完成 | 下单、手动确认、激活码兑换、权益发放 | 微信支付、支付宝、退款、webhook 验签和幂等 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、权益发放 | 退款、对账、支付补偿、异常订单处理 |
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入大批量异步 worker |
@@ -65,7 +65,7 @@
5. 个人中心
-`GET /api/profile/me`
- 接会员权益、订单、激活码兑换、错题本、收藏夹、学习统计和排行榜。
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计和排行榜。
6. 资料、视频和支付
- 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。
@@ -83,8 +83,8 @@
### P1商用收费和运营能力
- 微信支付、支付宝、XPay 或实际支付网关 adapter
- 支付 webhook 验签、幂等、退款、补偿任务
- 退款、对账、支付补偿任务和异常订单处理
- XPay 或其它实际支付网关 adapter
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
- 公共题库/地区题库授权,租户按 SaaS 套餐购买地区、科目和题库范围。
- Excel/CSV、分数线、视频批量导入。

View File

@@ -11,7 +11,7 @@
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈。
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
- `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单、支付确认、激活码兑换、权益查询。
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
@@ -127,8 +127,15 @@ PUT /api/tenant-content/handbook-chapters
PUT /api/tenant-content/handbook-entries
POST /api/commerce/orders
GET /api/commerce/orders
GET /api/commerce/orders/detail
GET /api/commerce/orders/status
POST /api/commerce/payments/create
POST /api/commerce/payments/manual-confirm
POST /api/commerce/payments/notify/wechat_pay
POST /api/commerce/payments/notify/alipay
POST /api/commerce/activation-codes/check
POST /api/commerce/activation-codes/redeem
POST /api/commerce/coupons/claim
GET /api/commerce/entitlements
GET /api/commerce/entitlements/check
POST /api/referral/invite-code
@@ -209,7 +216,9 @@ GET /api/tenant-admin/audit-logs
- `platform-admin` 已支持平台管理员 Supabase JWT`x-platform-admin-key` 只作为非生产兼容保护。
- B 端合作商年费/服务费使用 `tenant_invoices``tenant_invoice_items``tenant_invoice_payments`,不与 C 端学生订单混表。
- 订单金额以后端套餐价格为准,不信任前端传价。
- 激活码兑换支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。
- 激活码兑换支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/VaultAPI 只返回 `secretRef` 和掩码状态。
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
@@ -227,7 +236,7 @@ GET /api/tenant-admin/audit-logs
1. 完善内容导入和文件上传Excel/CSV、分数线、视频导入对象存储上传后校验、PDF 预览、防盗链和视频水印。
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
4. 增加真实支付 providerXPay、微信支付、支付宝并完善 webhook 幂等
4. 补退款、支付补偿任务、对账、异常订单处理和优惠券核销报表
5. 增加 `apps/worker`支付补偿、CRM webhook、日报统计、导入后检查。
6. 开始 Taro scaffold`supabaseApi` 抽到跨端包或适配层。

View File

@@ -27,14 +27,14 @@
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账 | 已支持核心映射JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导航、组卷、导入断言 | 新题库导航和组卷基础闭环可跑完整交卷评分报告、Excel 导入、公题库采纳/授权仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,退款/对账/支付补偿和异常订单处理待补 |
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
| 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 |
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、上传/下载签名占位、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载 | 资料资源基础闭环可跑,真实 OSS/COS 签名、PDF 预览渲染、资料下载前端待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用真实微信小程序码、分佣结算单、CRM worker 推送待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
@@ -166,10 +166,17 @@ tenant-content:
commerce:
POST /api/commerce/orders
GET /api/commerce/orders
GET /api/commerce/orders/detail
GET /api/commerce/orders/status
GET /api/commerce/entitlements
GET /api/commerce/entitlements/check
POST /api/commerce/payments/create
POST /api/commerce/payments/manual-confirm
POST /api/commerce/payments/notify/wechat_pay
POST /api/commerce/payments/notify/alipay
POST /api/commerce/activation-codes/check
POST /api/commerce/activation-codes/redeem
POST /api/commerce/coupons/claim
referral/crm:
POST /api/referral/invite-code
@@ -240,10 +247,10 @@ platform-admin:
上线前至少还需要完成:
1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。
2. 国内能力接入:短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付的租户级配置入口和本地 provider 验证已具备微信网页登录、QQ 登录、真实生产账号联调、退款/对账和支付补偿仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API下一步重点是后台维护、权限、统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 APIExcel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
@@ -256,4 +263,4 @@ platform-admin:
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 补视频商用控制SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
6. 接真实支付、短信、微信/QQ 登录 provider adapter,并开始 Taro scaffold。
6. 接真实短信、微信网页/QQ 登录、退款对账和补偿任务,并开始 Taro scaffold。

View File

@@ -30,7 +30,7 @@
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码、微信支付/支付宝 provider 主链路已有;缺优惠券下单抵扣、订单状态轮询、激活码预检查、退款/对账/补偿任务 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐订单、订单详情/状态轮询、权益激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路已有;缺退款/对账/补偿任务和前端收银台体验 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 |
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
@@ -55,8 +55,8 @@
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
| 考试日期/倒计时 | 已覆盖 | 租户后台维护、学生端和公开目录查询已有;前端需展示地区/院校匹配结果 |
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
| 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 |
| 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 |
| 优惠券 | 覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 |
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges缺后台和学生端 API |
| 题库录入 | 已覆盖 | 单题创建/更新、JSON 导入、集合/蓝图已有 |
| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 |
@@ -96,14 +96,13 @@
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
2. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口
3. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力
4. 题库导出PDF/Word/JSON 导出、水印、导出审计和权限控制
5. 导入扩展Excel/CSV、分数线、视频批量导入和大批量异步 worker
6. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步
7. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力
3. 题库导出PDF/Word/JSON 导出、水印、导出审计和权限控制
4. 导入扩展Excel/CSV、分数线、视频批量导入和大批量异步 worker
5. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步
6. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控
### P0前端联调到云端前

View File

@@ -8,7 +8,7 @@
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
- Node.js API 分层:`core/features`
- 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心、考试倒计时、签到积分、题目反馈。
- 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
- 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
@@ -20,6 +20,7 @@
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
- 本地验证:`npm run check:refactor` 已通过。
当前更适合进入前端联调前阅读的总览文档:
@@ -86,10 +87,9 @@
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
- 继续补断点续练和复盘体验。
7. 订单和激活码体验
- 订单详情、订单状态轮询、支付状态刷新
- 补激活码预检查,兑换前展示可用地区、天数、是否绑定代理/销售
- 补优惠券前台兑换和下单抵扣计算。
7. 订单和营销体验
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取和下单抵扣计算
- 继续补退款、支付补偿任务、对账、异常订单处理、优惠券核销报表和复杂活动规则
8. 积分和反馈增强
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
@@ -192,5 +192,5 @@
2. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
5. 并行补对象存储、真实登录、支付 adapter
5. 并行补对象存储、真实登录、退款对账、CRM worker 和公共题库授权
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -168,9 +168,9 @@ tenant:<tenantId>:theme
| 知识手册 | `/api/catalog/handbook-subjects``handbook-chapters``handbook-entries` |
| 分数线 | `/api/scoreline/fields``schools``majors``records``trend``years` |
| 资料下载 | `/api/catalog/assets``/api/catalog/assets/download` |
| 商城 | `/api/catalog/svip-plans``POST /api/commerce/orders``POST /api/commerce/payments/create` |
| 订单/权益 | `/api/commerce/orders``/api/commerce/entitlements` |
| 激活码兑换 | `POST /api/commerce/activation-codes/redeem` |
| 商城 | `/api/catalog/svip-plans``POST /api/commerce/coupons/claim``POST /api/commerce/orders``POST /api/commerce/payments/create` |
| 订单/权益 | `/api/commerce/orders``/api/commerce/orders/detail``/api/commerce/orders/status``/api/commerce/entitlements` |
| 激活码 | `POST /api/commerce/activation-codes/check``POST /api/commerce/activation-codes/redeem` |
| 个人中心 | `GET/PATCH /api/profile/me``POST /api/profile/check-in``GET /api/profile/score-events``GET /api/profile/exam-countdowns` |
| 销售分享 | `/api/referral/resolve``track-event``bind` |
| 租户班级 | `GET/PUT /api/tenant-admin/classes``POST /api/tenant-admin/classes/disable` |
@@ -597,11 +597,28 @@ body: {
"quantity": 1,
"payProvider": "wechat_pay | alipay",
"payMethod": "jsapi | wap",
"regionId": "<regionId>",
"couponCode": "<可选,优惠券码>",
"couponRedemptionId": "<可选,已领取优惠券 redemptionId>"
}
```
前端可以先领取优惠券,再下单:
```text
POST /api/commerce/coupons/claim
body: {
"code": "<couponCode>",
"planId": "<svipPlanId>",
"regionId": "<regionId>"
}
```
返回 `orderNo` 后,再创建支付参数:
`coupons/claim` 对同一用户同一优惠券是幂等的;已使用的券会返回 `COUPON_ALREADY_USED`。下单时后端会重新计算套餐原价、优惠金额和最终应付,前端展示金额只能使用接口返回的 `originalAmountCents``discountCents``amountCents`
如果优惠后 `amountCents=0`,后端会立即把订单置为 `paid` 并发放权益,前端不要再调用 `payments/create`
返回未支付 `orderNo` 后,再创建支付参数:
```text
POST /api/commerce/payments/create
@@ -634,10 +651,36 @@ H5 可以跳转到该 URL。小程序端如果后续要接支付宝小程序
支付完成后前端不要自行开通会员。前端应轮询或重新请求:
```text
GET /api/commerce/orders
GET /api/commerce/orders/status?orderNo=<orderNo>
GET /api/commerce/orders/detail?orderNo=<orderNo>
GET /api/commerce/entitlements
```
订单详情会返回 `pricing``payments``items``couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。
### 激活码预检查与兑换
兑换前建议先调用:
```text
POST /api/commerce/activation-codes/check
body: {
"code": "<activationCode>",
"regionId": "<regionId>"
}
```
可根据返回的 `valid/reasonCode/days/regionName/saleType` 展示确认弹窗。常见 `reasonCode`
```text
ACTIVATION_CODE_NOT_FOUND
ACTIVATION_CODE_USED
ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN
ACTIVATION_CODE_REGION_MISMATCH
```
用户确认后再调用 `POST /api/commerce/activation-codes/redeem`。兑换成功后重新请求 `/api/commerce/entitlements` 和个人中心,不要在前端本地伪造会员状态。
后端支付回调地址由租户支付账户配置:
```text
@@ -649,6 +692,7 @@ GET /api/commerce/entitlements
- 传入自定义金额。
- 伪造支付成功状态。
- 调用 `/api/commerce/payments/manual-confirm`;这个接口只给租户后台线下收款/迁移期使用,后端要求 `tenant:payment:write`
- 保存商户号私钥、API v3 key、支付宝应用私钥。
- 在页面里实现 webhook 验签或权益开通。

View File

@@ -37,6 +37,8 @@ const ids = {
question: '00000000-0000-0000-0000-000000000401',
questionTwo: '00000000-0000-0000-0000-000000000403',
questionThree: '00000000-0000-0000-0000-000000000405',
plan: '00000000-0000-0000-0000-000000000201',
couponOnlyPlan: '00000000-0000-0000-0000-000000000202',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821',
@@ -1142,12 +1144,49 @@ async function testCommerce() {
const orders = await request('/api/commerce/orders');
assert.ok(orders.items?.some(item => item.orderNo === 'SMOKE-ORDER-20260621'), 'orders should include smoke order');
const activationPrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: ' smoke20260621 ', regionId: ids.region },
});
assert.equal(activationPrecheck.valid, true, 'activation code precheck should validate unused code');
assert.equal(activationPrecheck.days, 30, 'activation code precheck should expose granted days');
assert.equal(activationPrecheck.regionId, ids.region, 'activation code precheck should expose bound region');
const selfCodePrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: 'SMOKESELF20260621', regionId: ids.region },
});
assert.equal(selfCodePrecheck.valid, false, 'self-issued activation code should be invalid for the issuing user');
assert.equal(
selfCodePrecheck.reasonCode,
'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
'self-issued activation precheck should expose a stable reason code',
);
const selfCodeRedeem = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'SMOKESELF20260621', regionId: ids.region },
expectStatus: 409,
});
assert.equal(
selfCodeRedeem.code,
'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
'redeeming a self-issued activation code must be blocked',
);
const redeemed = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'SMOKE20260621', regionId: ids.region },
});
assert.ok(redeemed.item?.entitlement?.id, 'activation code should grant an entitlement');
const usedCodePrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: 'SMOKE20260621', regionId: ids.region },
});
assert.equal(usedCodePrecheck.valid, false, 'activation code precheck should reject used code');
assert.equal(usedCodePrecheck.reasonCode, 'ACTIVATION_CODE_USED', 'used activation code should return stable reason code');
const entitlements = await request('/api/commerce/entitlements');
assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list');
assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary');
@@ -1161,6 +1200,151 @@ async function testCommerce() {
assert.equal(svipPlayback.access?.mode, 'svip', 'SVIP video playback should use svip mode');
assert.equal(svipPlayback.access?.consumedQuota, 0, 'SVIP video playback should not consume quota');
const couponOnlyDenied = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
expectStatus: 403,
});
assert.equal(couponOnlyDenied.code, 'PLAN_REQUIRES_COUPON', 'coupon-only plan should require a coupon');
const claimedCoupon = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.equal(claimedCoupon.valid, true, 'coupon claim should be valid');
assert.equal(claimedCoupon.idempotent, false, 'first coupon claim should create a redemption');
assert.equal(claimedCoupon.coupon?.discountCents, 500, 'coupon claim should calculate 50% discount');
const claimedCouponAgain = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.equal(claimedCouponAgain.idempotent, true, 'second coupon claim should be idempotent');
assert.equal(
claimedCouponAgain.redemption?.id,
claimedCoupon.redemption?.id,
'idempotent coupon claim should return the original redemption',
);
const discountedOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponRedemptionId: claimedCoupon.redemption.id,
},
});
assert.equal(discountedOrder.item?.originalAmountCents, 1000, 'discounted order should use server-side plan price');
assert.equal(discountedOrder.item?.discountCents, 500, 'discounted order should apply claimed coupon');
assert.equal(discountedOrder.item?.amountCents, 500, 'discounted order payable amount should be 500 cents');
assert.equal(discountedOrder.item?.status, 'pending', 'partially discounted order should remain pending');
const discountedDetail = await request('/api/commerce/orders/detail', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(discountedDetail.item?.pricing?.discountCents, 500, 'order detail should expose pricing snapshot');
assert.ok(
discountedDetail.item?.items?.some(item => item.itemType === 'coupon_discount' && item.totalAmountCents === -500),
'order detail should include a negative coupon order item',
);
assert.ok(
discountedDetail.item?.couponRedemptions?.some(item => item.status === 'used' && item.discountAppliedCents === 500),
'order detail should include used coupon redemption',
);
const discountedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(discountedStatus.item?.status, 'pending', 'order status endpoint should return pending status');
assert.equal(discountedStatus.item?.payment?.status, 'pending', 'order status endpoint should include latest payment');
const studentManualConfirmDenied = await request('/api/commerce/payments/manual-confirm', {
method: 'POST',
body: { orderNo: discountedOrder.item.orderNo, amountCents: discountedOrder.item.amountCents },
expectStatus: 403,
});
assert.equal(
studentManualConfirmDenied.code,
'TENANT_ADMIN_REQUIRED',
'student must not manually confirm commerce payments',
);
const manualConfirmed = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: discountedOrder.item.amountCents,
providerTradeNo: `manual-${discountedOrder.item.orderNo}`,
},
});
assert.equal(manualConfirmed.item?.status, 'paid', 'tenant admin should manually confirm discounted order');
assert.equal(manualConfirmed.item?.confirmedBy, TENANT_ADMIN_USER_ID, 'manual confirmation should record operator');
assert.ok(manualConfirmed.item?.entitlement?.id, 'manual confirmation should grant entitlement');
const manualConfirmedAgain = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: discountedOrder.item.amountCents,
providerTradeNo: `manual-${discountedOrder.item.orderNo}`,
},
});
assert.equal(manualConfirmedAgain.item?.idempotent, true, 'duplicate manual confirmation should be idempotent');
const usedCouponClaim = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(usedCouponClaim.code, 'COUPON_ALREADY_USED', 'used coupon should not be claimable again by same user');
const freeOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponCode: 'SMOKEFREE',
},
});
assert.equal(freeOrder.item?.originalAmountCents, 1000, 'free order should still keep original server price');
assert.equal(freeOrder.item?.discountCents, 1000, 'free coupon should discount the full amount');
assert.equal(freeOrder.item?.amountCents, 0, 'free order payable amount should be zero');
assert.equal(freeOrder.item?.status, 'paid', 'zero-amount order should be marked paid immediately');
assert.ok(freeOrder.item?.tradeNo?.startsWith('zero-'), 'zero-amount order should get an internal trade number');
assert.ok(freeOrder.item?.entitlement?.id, 'zero-amount order should grant entitlement immediately');
const freeStatus = await request('/api/commerce/orders/status', {
query: { orderNo: freeOrder.item.orderNo },
});
assert.equal(freeStatus.item?.status, 'paid', 'zero-amount order status should be paid');
assert.equal(freeStatus.item?.payment?.status, 'paid', 'zero-amount order payment row should be paid');
assert.equal(freeStatus.item?.payment?.amountCents, 0, 'zero-amount order payment row should have zero amount');
const freePaymentDenied = await request('/api/commerce/payments/create', {
method: 'POST',
body: { orderNo: freeOrder.item.orderNo, provider: 'manual' },
expectStatus: 409,
});
assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment');
const crossTenantOrderDetail = await request('/api/commerce/orders/detail', {
tenantId: PARTNER_TENANT_ID,
query: { orderNo: freeOrder.item.orderNo },
expectStatus: 404,
});
assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated');
const fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
@@ -1192,7 +1376,7 @@ async function testCommerce() {
const wechatOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
planId: ids.plan,
payProvider: 'wechat_pay',
payMethod: 'jsapi',
regionId: ids.region,
@@ -1286,7 +1470,7 @@ async function testCommerce() {
const alipayOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
planId: ids.plan,
payProvider: 'alipay',
payMethod: 'wap',
regionId: ids.region,

View File

@@ -45,9 +45,13 @@ const ids = {
questionThree: '00000000-0000-0000-0000-000000000405',
questionThreeVersion: '00000000-0000-0000-0000-000000000406',
plan: '00000000-0000-0000-0000-000000000201',
couponOnlyPlan: '00000000-0000-0000-0000-000000000202',
checkoutCoupon: '00000000-0000-0000-0000-000000000203',
freeCheckoutCoupon: '00000000-0000-0000-0000-000000000204',
order: '00000000-0000-0000-0000-000000000701',
payment: '00000000-0000-0000-0000-000000000702',
activationCode: '00000000-0000-0000-0000-000000000801',
selfActivationCode: '00000000-0000-0000-0000-000000000802',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
vocabularyWordTwo: '00000000-0000-0000-0000-000000000813',
@@ -163,6 +167,93 @@ async function main() {
[tenantId, ids.leaderboardSessionUser, ids.leaderboardSessionSecond],
);
const checkoutCouponCodes = ['SMOKE50', 'SMOKEFREE'];
await client.query(
`
delete from public.coupon_redemptions
where tenant_id = $1
and (
coupon_id = any($2::uuid[])
or coupon_code = any($3::text[])
or order_id in (
select id from public.orders
where tenant_id = $1
and (raw_payload->'pricing'->>'couponCode') = any($3::text[])
)
)
`,
[tenantId, [ids.checkoutCoupon, ids.freeCheckoutCoupon], checkoutCouponCodes],
);
await client.query(
`
delete from public.order_items
where tenant_id = $1
and order_id in (
select id from public.orders
where tenant_id = $1
and (raw_payload->'pricing'->>'couponCode') = any($2::text[])
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.entitlements
where tenant_id = $1
and user_id = $2::uuid
and source_type in ('order', 'activation_code')
and (
legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621')
or source_id in (
select id
from public.orders
where tenant_id = $1
and (
raw_payload->'pricing'->>'couponCode' = any($3::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
)
or metadata->>'orderNo' like 'SVIP%'
or metadata->>'orderNo' like 'XP%'
)
`,
[tenantId, ids.user, checkoutCouponCodes],
);
await client.query(
`
delete from public.payments
where tenant_id = $1
and order_id in (
select id from public.orders
where tenant_id = $1
and (
(raw_payload->'pricing'->>'couponCode') = any($2::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.orders
where tenant_id = $1
and (
(raw_payload->'pricing'->>'couponCode') = any($2::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.crm_webhook_queue
@@ -1193,6 +1284,62 @@ async function main() {
[ids.plan, tenantId, ids.region],
);
await client.query(
`
insert into public.svip_plans (
id, tenant_id, region_id, legacy_id, name, price_cents, original_price_cents,
days, description, per_day_label, badge, recommended, coupon_only, sort_order, is_active
)
values (
$1, $2, $3, 'smoke-coupon-plan', '烟测优惠券专享月卡', 1000, 2000,
30, '本地 smoke 优惠券专享套餐', '0.33/天', 'COUPON', false, true, 2, true
)
on conflict (id)
do update set name = excluded.name,
price_cents = excluded.price_cents,
original_price_cents = excluded.original_price_cents,
days = excluded.days,
region_id = excluded.region_id,
coupon_only = excluded.coupon_only,
is_active = true,
updated_at = now()
`,
[ids.couponOnlyPlan, tenantId, ids.region],
);
await client.query(
`
insert into public.coupons (
id, tenant_id, legacy_id, code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
)
values
(
$1, $2, 'smoke-coupon', 'SMOKE50', $3, 'percent', 50,
now() - interval '1 day', now() + interval '30 days', 100, 0,
'smoke', '本地 smoke 优惠券'
),
(
$4, $2, 'smoke-free-coupon', 'SMOKEFREE', $3, 'percent', 100,
now() - interval '1 day', now() + interval '30 days', 100, 0,
'smoke', '本地 smoke 全额优惠券'
)
on conflict (id)
do update set code = excluded.code,
plan_id = excluded.plan_id,
discount_type = excluded.discount_type,
discount_value = excluded.discount_value,
valid_from = excluded.valid_from,
valid_to = excluded.valid_to,
max_uses = excluded.max_uses,
used_count = 0,
source = excluded.source,
remark = excluded.remark,
updated_at = now()
`,
[ids.checkoutCoupon, tenantId, ids.couponOnlyPlan, ids.freeCheckoutCoupon],
);
await client.query(
`
insert into public.orders (
@@ -1238,6 +1385,24 @@ async function main() {
[ids.activationCode, tenantId, ids.region],
);
await client.query(
`
insert into public.activation_codes (
id, tenant_id, legacy_id, code, days, is_used, agent_user_id,
sale_type, unit_price_cents, used_region_id, remark
)
values ($1, $2, 'smoke-self-code', 'SMOKESELF20260621', 30, false, $3, 'sales', 0, $4, '不可自用的 smoke 激活码')
on conflict (id)
do update set is_used = false,
used_by = null,
used_at = null,
agent_user_id = excluded.agent_user_id,
used_region_id = excluded.used_region_id,
updated_at = now()
`,
[ids.selfActivationCode, tenantId, ids.user, ids.region],
);
await client.query(
`
insert into public.vocabulary_units (

View File

@@ -0,0 +1,13 @@
create unique index if not exists idx_coupon_redemptions_user_coupon_once
on public.coupon_redemptions(tenant_id, user_id, coupon_id)
where coupon_id is not null;
create index if not exists idx_coupon_redemptions_order_user
on public.coupon_redemptions(tenant_id, order_id, user_id)
where order_id is not null;
create index if not exists idx_payments_order_updated
on public.payments(tenant_id, order_id, updated_at desc);
create index if not exists idx_order_items_order
on public.order_items(tenant_id, order_id);