diff --git a/README.md b/README.md index 9b95676c..724a5da0 100644 --- a/README.md +++ b/README.md @@ -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、公共题库授权、租户采纳、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts index 85484a23..a544c301 100644 --- a/apps/api/src/features/commerce/index.ts +++ b/apps/api/src/features/commerce/index.ts @@ -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], ]; diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index 2e5b6bbe..140ed60c 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -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; + 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(sql, [tenantId, planId]); + return result.rows[0] || null; + } + return queryOne(sql, [tenantId, planId]); +} + +async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: string, lock = false) { + const result = await client.query( + ` + 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( + ` + 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( + ` + 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( + ` + 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).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( - ` - 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( + ` + 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( + ` + 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( + ` + 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; +} diff --git a/apps/api/src/features/commerce/service.ts b/apps/api/src/features/commerce/service.ts index 6e0470e8..b5a1ba77 100644 --- a/apps/api/src/features/commerce/service.ts +++ b/apps/api/src/features/commerce/service.ts @@ -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", diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index bd8b5d7a..2b9f1778 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -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、密钥掩码、成员权限、审计、活动、兑换码、优惠券 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index dc5391b5..ed582b45 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 | | 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 | ## 租户后台与平台后台 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index cae5e17f..08cd6903 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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、分数线、视频批量导入。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 471d3a14..7a8cd03f 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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` 抽到跨端包或适配层。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index affd352e..1e69eb21 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -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。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index e7f16331..f5855bbd 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 7b2b0f10..9b0f407a 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 6528e832..88812a1d 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -168,9 +168,9 @@ tenant::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": "", + "couponCode": "<可选,优惠券码>", + "couponRedemptionId": "<可选,已领取优惠券 redemptionId>" +} +``` + +前端可以先领取优惠券,再下单: + +```text +POST /api/commerce/coupons/claim +body: { + "code": "", + "planId": "", "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= +GET /api/commerce/orders/detail?orderNo= GET /api/commerce/entitlements ``` +订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。 + +### 激活码预检查与兑换 + +兑换前建议先调用: + +```text +POST /api/commerce/activation-codes/check +body: { + "code": "", + "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 验签或权益开通。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 9e40631f..eaca1d80 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 42744b60..540c3f2b 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -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 ( diff --git a/supabase/migrations/202606290006_commerce_checkout_experience.sql b/supabase/migrations/202606290006_commerce_checkout_experience.sql new file mode 100644 index 00000000..14b27033 --- /dev/null +++ b/supabase/migrations/202606290006_commerce_checkout_experience.sql @@ -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);