forked from wangziqi/gongxue-base
feat: improve commerce checkout flow
This commit is contained in:
@@ -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、公共题库授权、租户采纳、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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、密钥掩码、成员权限、审计、活动、兑换码、优惠券
|
||||
|
||||
@@ -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`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
||||
| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 |
|
||||
|
||||
## 租户后台与平台后台
|
||||
|
||||
@@ -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、分数线、视频批量导入。
|
||||
|
||||
@@ -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/Vault,API 只返回 `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. 增加真实支付 provider:XPay、微信支付、支付宝,并完善 webhook 幂等。
|
||||
4. 补退款、支付补偿任务、对账、异常订单处理和优惠券核销报表。
|
||||
5. 增加 `apps/worker`:支付补偿、CRM webhook、日报统计、导入后检查。
|
||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||
|
||||
|
||||
@@ -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 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
|
||||
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。
|
||||
|
||||
@@ -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:前端联调到云端前
|
||||
|
||||
|
||||
@@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -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 验签或权益开通。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user