From 62c45a8fed83ded660531eb9e9ba58ebba62af84 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 22:58:58 +0800 Subject: [PATCH] feat: add coupon rule reporting --- README.md | 7 +- apps/api/src/features/commerce/routes.ts | 143 +++++++++- apps/api/src/features/tenant-admin/auth.ts | 3 +- apps/api/src/features/tenant-admin/index.ts | 4 + apps/api/src/features/tenant-admin/routes.ts | 254 +++++++++++++++++- docs/refactor/backend-capability-status.md | 5 +- docs/refactor/backend-progress.md | 10 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/implementation-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 2 +- docs/refactor/next-development-todo.md | 5 +- docs/refactor/taro-frontend-integration.md | 54 +++- scripts/api-integration-test.js | 185 ++++++++++++- .../202606290031_coupon_rules_reports.sql | 49 ++++ 14 files changed, 692 insertions(+), 35 deletions(-) create mode 100644 supabase/migrations/202606290031_coupon_rules_reports.sql diff --git a/README.md b/README.md index a4ece9ca..2229b39a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 @@ -33,6 +33,7 @@ - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 +- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;前端营销操作台仍需补更完整活动 UI。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -285,7 +286,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失 apps/api/src/features/ auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录 catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口 - commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账和差错工单 + commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单 health/ 健康检查 learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 @@ -316,7 +317,7 @@ API 身份上下文: - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 -- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID,不向前端暴露下载 URL 或商户密钥。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。 +- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID,不向前端暴露下载 URL 或商户密钥。优惠券状态、最低金额、封顶、单用户限次、首单、适用套餐/地区和订单抵扣都由后端重新校验,前端只能展示后端返回金额。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。 ## 最近一次验证 diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index 36a62279..7890fa6d 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -122,6 +122,15 @@ interface CouponRow { valid_to: string | null; max_uses: number | null; used_count: number; + status: string; + campaign_name: string | null; + min_order_amount_cents: number; + max_discount_cents: number | null; + per_user_limit: number; + first_order_only: boolean; + allowed_plan_ids: string[]; + allowed_region_ids: string[]; + metadata: Record; source: string | null; remark: string | null; } @@ -172,11 +181,13 @@ function safeDiscountValue(value: string | number | null) { 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)); + const discount = coupon.discount_type === 'fixed' + ? Math.max(0, Math.round(safeDiscountValue(coupon.discount_value))) + : Math.floor((amount * Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)))) / 100); + const cappedByCoupon = coupon.max_discount_cents === null || coupon.max_discount_cents === undefined + ? discount + : Math.min(discount, Math.max(0, coupon.max_discount_cents)); + return Math.min(amount, cappedByCoupon); } function calculatePlanDays(planDays: number, quantity: number) { @@ -186,6 +197,9 @@ function calculatePlanDays(planDays: number, quantity: number) { } function couponValidity(coupon: CouponRow, now = new Date()) { + if (coupon.status !== 'active') { + return { valid: false, code: 'COUPON_DISABLED', message: 'Coupon is not active' }; + } if (coupon.valid_from && now < new Date(coupon.valid_from)) { return { valid: false, code: 'COUPON_NOT_STARTED', message: 'Coupon is not active yet' }; } @@ -198,12 +212,43 @@ function couponValidity(coupon: CouponRow, now = new Date()) { return { valid: true, code: '', message: '' }; } +function couponArray(value: unknown) { + return Array.isArray(value) ? value.map(item => String(item)).filter(Boolean) : []; +} + +function couponMetadata(coupon: CouponRow) { + return { + status: coupon.status, + campaignName: coupon.campaign_name, + minOrderAmountCents: coupon.min_order_amount_cents, + maxDiscountCents: coupon.max_discount_cents, + perUserLimit: coupon.per_user_limit, + firstOrderOnly: coupon.first_order_only, + allowedPlanIds: couponArray(coupon.allowed_plan_ids), + allowedRegionIds: couponArray(coupon.allowed_region_ids), + metadata: coupon.metadata || {}, + }; +} + +function couponRuleSnapshot(coupon: CouponRow) { + return { + campaignName: coupon.campaign_name, + minOrderAmountCents: coupon.min_order_amount_cents, + maxDiscountCents: coupon.max_discount_cents, + perUserLimit: coupon.per_user_limit, + firstOrderOnly: coupon.first_order_only, + allowedPlanIds: couponArray(coupon.allowed_plan_ids), + allowedRegionIds: couponArray(coupon.allowed_region_ids), + }; +} + 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, + rules: couponMetadata(coupon), source: coupon.source, remark: coupon.remark, redemptionId: redemption?.id || null, @@ -241,7 +286,10 @@ async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: s 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 + valid_from, valid_to, max_uses, used_count, status, + campaign_name, min_order_amount_cents, max_discount_cents, + per_user_limit, first_order_only, allowed_plan_ids, allowed_region_ids, + metadata, source, remark from public.coupons where tenant_id = $1 and lower(code::text) = lower($2) limit 1 @@ -256,7 +304,10 @@ async function findCouponById(client: pg.PoolClient, tenantId: string, couponId: 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 + valid_from, valid_to, max_uses, used_count, status, + campaign_name, min_order_amount_cents, max_discount_cents, + per_user_limit, first_order_only, allowed_plan_ids, allowed_region_ids, + metadata, source, remark from public.coupons where tenant_id = $1 and id = $2 limit 1 @@ -274,6 +325,7 @@ async function findCouponRedemption(client: pg.PoolClient, tenantId: string, use 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 + and status in ('claimed', 'pending') order by created_at desc limit 1 for update @@ -283,6 +335,67 @@ async function findCouponRedemption(client: pg.PoolClient, tenantId: string, use return result.rows[0] || null; } +async function usedCouponCount(client: pg.PoolClient, tenantId: string, userId: string, couponId: string) { + const result = await client.query<{ count: string }>( + ` + select count(*)::text as count + from public.coupon_redemptions + where tenant_id = $1 + and user_id = $2 + and coupon_id = $3 + and status = 'used' + `, + [tenantId, userId, couponId], + ); + return Number(result.rows[0]?.count || 0); +} + +async function paidOrderCount(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query<{ count: string }>( + ` + select count(*)::text as count + from public.orders + where tenant_id = $1 + and user_id = $2 + and status in ('paid', 'partially_refunded', 'refunded') + `, + [tenantId, userId], + ); + return Number(result.rows[0]?.count || 0); +} + +async function assertCouponBusinessRules( + client: pg.PoolClient, + coupon: CouponRow, + input: { + tenantId: string; + userId: string; + planId: string; + regionId: string | null; + baseAmountCents: number; + }, +) { + const allowedPlans = couponArray(coupon.allowed_plan_ids); + if (allowedPlans.length > 0 && !allowedPlans.includes(input.planId)) { + throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH'); + } + const allowedRegions = couponArray(coupon.allowed_region_ids); + if (allowedRegions.length > 0 && (!input.regionId || !allowedRegions.includes(input.regionId))) { + throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH'); + } + if (input.baseAmountCents < Math.max(0, coupon.min_order_amount_cents || 0)) { + throw new HttpError(409, 'Order amount does not meet coupon minimum', 'COUPON_MIN_ORDER_AMOUNT_NOT_MET'); + } + if (coupon.first_order_only && (await paidOrderCount(client, input.tenantId, input.userId)) > 0) { + throw new HttpError(409, 'Coupon is limited to first paid order', 'COUPON_FIRST_ORDER_ONLY'); + } + const usedCount = await usedCouponCount(client, input.tenantId, input.userId, coupon.id); + if (usedCount >= Math.max(1, coupon.per_user_limit || 1)) { + const code = Math.max(1, coupon.per_user_limit || 1) === 1 ? 'COUPON_ALREADY_USED' : 'COUPON_USER_LIMIT_REACHED'; + throw new HttpError(409, 'Coupon user usage limit reached', code); + } +} + async function claimCoupon( client: pg.PoolClient, input: { @@ -312,9 +425,15 @@ async function claimCoupon( if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) { throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH'); } + await assertCouponBusinessRules(client, coupon, { + tenantId: input.tenantId, + userId: input.userId, + planId: plan.id, + regionId: finalRegionId, + baseAmountCents: input.baseAmountCents ?? plan.price_cents, + }); 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'); } @@ -729,6 +848,13 @@ export async function createOrderRoute(ctx: RequestContext) { 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); + await assertCouponBusinessRules(client, coupon, { + tenantId, + userId, + planId, + regionId: finalRegionId, + baseAmountCents: originalAmountCents, + }); const discountCents = calculateCouponDiscount(coupon, originalAmountCents); couponResult = { coupon, plan, redemption, discountCents, idempotent: true }; } else if (couponCode) { @@ -835,6 +961,7 @@ export async function createOrderRoute(ctx: RequestContext) { redemptionId: couponResult.redemption.id, discountType: couponResult.coupon.discount_type, discountValue: couponResult.coupon.discount_value, + couponRules: couponRuleSnapshot(couponResult.coupon), }), ], ); diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 94adde25..379ac7b9 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -15,7 +15,7 @@ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_admin: ['*'], tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'], teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'], - sales: ['codes:*', 'coupons:*', 'referral:*', 'commission:self'], + sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'], agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'], student: [], }; @@ -110,6 +110,7 @@ export function tenantPermissionCatalog() { { key: 'codes:write', label: '激活码管理' }, { key: 'coupons:read', label: '优惠券查看' }, { key: 'coupons:write', label: '优惠券管理' }, + { key: 'coupons:redemptions:read', label: '优惠券核销明细/报表' }, { key: 'referral:read', label: '客资全局查看' }, { key: 'referral:self', label: '本人客资查看' }, { key: 'referral:write', label: '客资归属管理' }, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 807860f4..16c19b1f 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -34,6 +34,8 @@ import { authProvidersRoute, bannersAdminRoute, codeBatchesRoute, + couponRedemptionsRoute, + couponReportRoute, couponsRoute, createTenantDomainRoute, disableTenantRoleTemplateRoute, @@ -127,6 +129,8 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute], ['GET', '/api/tenant-admin/coupons', couponsRoute], ['PUT', '/api/tenant-admin/coupons', upsertCouponRoute], + ['GET', '/api/tenant-admin/coupons/redemptions', couponRedemptionsRoute], + ['GET', '/api/tenant-admin/coupons/report', couponReportRoute], ['GET', '/api/tenant-admin/members', tenantMembersRoute], ['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute], ['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute], diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index 772b6035..2687dd4f 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -18,6 +18,8 @@ const PAYMENT_MODES = ['platform_collect', 'tenant_collect', 'service_provider'] const PAYMENT_STATUSES = ['active', 'disabled', 'pending']; const AUTH_STATUSES = ['active', 'disabled', 'testing']; const DISCOUNT_TYPES = ['percent', 'fixed']; +const COUPON_STATUSES = ['active', 'disabled', 'archived']; +const COUPON_REDEMPTION_STATUSES = ['claimed', 'pending', 'used', 'cancelled', 'expired']; const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student']; const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled']; const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived']; @@ -75,6 +77,17 @@ function jsonArrayValue(value: unknown) { return JSON.stringify(Array.isArray(value) ? value : []); } +function uuidArrayValue(value: unknown, key: string) { + if (!Array.isArray(value)) return []; + return value.map((item, index) => { + const candidate = optionalUuidString(item, `${key}[${index}]`); + if (!candidate) { + throw new HttpError(400, `${key}[${index}] is required`, 'INVALID_UUID'); + } + return candidate; + }); +} + function nullableString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null; } @@ -2198,18 +2211,36 @@ export async function couponsRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'coupons:read'); const limit = intParam(ctx, 'limit', 100, 500); + const status = stringParam(ctx, 'status'); + const campaignName = stringParam(ctx, 'campaignName'); + const params: unknown[] = [auth.tenantId, limit]; + const filters = ['tenant_id = $1']; + if (status) { + if (!COUPON_STATUSES.includes(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS'); + params.push(status); + filters.push(`status = $${params.length}`); + } + if (campaignName) { + params.push(campaignName); + filters.push(`campaign_name = $${params.length}`); + } const items = await query( ` select id, legacy_id as "legacyId", code, plan_id as "planId", discount_type as "discountType", discount_value as "discountValue", valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses", - used_count as "usedCount", source, remark, created_at as "createdAt", updated_at as "updatedAt" + used_count as "usedCount", status, campaign_name as "campaignName", + min_order_amount_cents as "minOrderAmountCents", + max_discount_cents as "maxDiscountCents", per_user_limit as "perUserLimit", + first_order_only as "firstOrderOnly", allowed_plan_ids as "allowedPlanIds", + allowed_region_ids as "allowedRegionIds", metadata, source, remark, + created_at as "createdAt", updated_at as "updatedAt" from public.coupons - where tenant_id = $1 + where ${filters.join(' and ')} order by created_at desc limit $2 `, - [auth.tenantId, limit], + params, ); return { items }; } @@ -2219,17 +2250,32 @@ export async function upsertCouponRoute(ctx: RequestContext) { requireTenantPermission(auth, 'coupons:write'); const body = await readJsonBody(ctx); const discountType = body.discountType ? optionalChoice(body.discountType, DISCOUNT_TYPES, 'fixed') : null; + const status = optionalStatus(body.status, COUPON_STATUSES, 'active'); + const minOrderAmountCents = Math.max(0, intValue(body.minOrderAmountCents, 0)); + const maxDiscountCents = body.maxDiscountCents === undefined || body.maxDiscountCents === null || body.maxDiscountCents === '' + ? null + : Math.max(0, intValue(body.maxDiscountCents, 0)); + const perUserLimit = Math.max(1, Math.min(100, intValue(body.perUserLimit, 1))); + const allowedPlanIds = uuidArrayValue(body.allowedPlanIds, 'allowedPlanIds'); + const allowedRegionIds = uuidArrayValue(body.allowedRegionIds, 'allowedRegionIds'); + const planId = optionalUuidString(body.planId, 'planId'); + if (planId && allowedPlanIds.length > 0 && !allowedPlanIds.includes(planId)) { + allowedPlanIds.unshift(planId); + } const item = await transaction(async client => { const result = 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, source, remark + valid_from, valid_to, max_uses, status, campaign_name, + min_order_amount_cents, max_discount_cents, per_user_limit, first_order_only, + allowed_plan_ids, allowed_region_ids, metadata, source, remark ) values ( coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5::uuid, $6, $7, - $8::timestamptz, $9::timestamptz, $10, $11, $12 + $8::timestamptz, $9::timestamptz, $10, $11, $12, + $13, $14, $15, $16, $17::uuid[], $18::uuid[], $19::jsonb, $20, $21 ) on conflict (tenant_id, code) do update set plan_id = excluded.plan_id, @@ -2238,31 +2284,56 @@ export async function upsertCouponRoute(ctx: RequestContext) { valid_from = excluded.valid_from, valid_to = excluded.valid_to, max_uses = excluded.max_uses, + status = excluded.status, + campaign_name = excluded.campaign_name, + min_order_amount_cents = excluded.min_order_amount_cents, + max_discount_cents = excluded.max_discount_cents, + per_user_limit = excluded.per_user_limit, + first_order_only = excluded.first_order_only, + allowed_plan_ids = excluded.allowed_plan_ids, + allowed_region_ids = excluded.allowed_region_ids, + metadata = excluded.metadata, source = excluded.source, remark = excluded.remark, updated_at = now() returning id, legacy_id as "legacyId", code, plan_id as "planId", discount_type as "discountType", discount_value as "discountValue", valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses", - used_count as "usedCount", source, remark, updated_at as "updatedAt" + used_count as "usedCount", status, campaign_name as "campaignName", + min_order_amount_cents as "minOrderAmountCents", + max_discount_cents as "maxDiscountCents", per_user_limit as "perUserLimit", + first_order_only as "firstOrderOnly", allowed_plan_ids as "allowedPlanIds", + allowed_region_ids as "allowedRegionIds", metadata, source, remark, + updated_at as "updatedAt" `, [ auth.tenantId, nullableString(body.id), nullableString(body.legacyId), codeValue(body), - nullableString(body.planId), + planId, discountType, numberValue(body.discountValue, null), nullableString(body.validFrom), nullableString(body.validTo), body.maxUses === undefined ? null : intValue(body.maxUses, 0), + status, + nullableString(body.campaignName), + minOrderAmountCents, + maxDiscountCents, + perUserLimit, + boolValue(body.firstOrderOnly, false), + allowedPlanIds, + allowedRegionIds, + JSON.stringify(objectValue(body.metadata)), nullableString(body.source), nullableString(body.remark), ], ); await recordAudit(client, auth, 'tenant.coupon.upserted', 'coupons', result.rows[0].id, { code: result.rows[0].code, + status: result.rows[0].status, + campaignName: result.rows[0].campaignName, }); return result.rows[0]; }); @@ -2270,6 +2341,175 @@ export async function upsertCouponRoute(ctx: RequestContext) { return { item }; } +function dateParam(ctx: RequestContext, key: string, fallback: string) { + const candidate = stringParam(ctx, key) || fallback; + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { + throw new HttpError(400, `${key} must use YYYY-MM-DD format`, 'INVALID_DATE'); + } + return candidate; +} + +function shanghaiDateKey(date = new Date()) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +export async function couponRedemptionsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'coupons:redemptions:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const couponId = stringParam(ctx, 'couponId'); + const status = stringParam(ctx, 'status'); + const params: unknown[] = [auth.tenantId, limit]; + const filters = ['cr.tenant_id = $1']; + if (couponId) { + params.push(optionalUuidString(couponId, 'couponId')); + filters.push(`cr.coupon_id = $${params.length}::uuid`); + } + if (status) { + if (!COUPON_REDEMPTION_STATUSES.includes(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS'); + params.push(status); + filters.push(`cr.status = $${params.length}`); + } + + const items = await query( + ` + select cr.id, cr.coupon_id as "couponId", cr.coupon_code as "couponCode", + c.campaign_name as "campaignName", cr.user_id as "userId", + u.name as "userName", u.phone as "userPhone", cr.plan_id as "planId", + p.name as "planName", cr.order_id as "orderId", o.order_no as "orderNo", + cr.status, cr.discount_applied_cents as "discountAppliedCents", + cr.region_id as "regionId", r.name as "regionName", cr.source, cr.remark, + cr.claimed_at as "claimedAt", cr.used_at as "usedAt", + cr.created_at as "createdAt", cr.updated_at as "updatedAt" + from public.coupon_redemptions cr + left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id + left join public.platform_users u on u.id = cr.user_id + left join public.svip_plans p on p.tenant_id = cr.tenant_id and p.id = cr.plan_id + left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id + left join public.regions r on r.tenant_id = cr.tenant_id and r.id = cr.region_id + where ${filters.join(' and ')} + order by cr.created_at desc + limit $2 + `, + params, + ); + return { items }; +} + +export async function couponReportRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'coupons:redemptions:read'); + const today = shanghaiDateKey(); + const startDate = dateParam(ctx, 'startDate', today.slice(0, 8) + '01'); + const endDate = dateParam(ctx, 'endDate', today); + if (startDate > endDate) throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE'); + const couponId = stringParam(ctx, 'couponId'); + const campaignName = stringParam(ctx, 'campaignName'); + const params: unknown[] = [auth.tenantId, startDate, endDate]; + const couponFilters = ['c.tenant_id = $1']; + const redemptionFilters = ['cr.tenant_id = $1', `cr.created_at >= $2::date`, `cr.created_at < ($3::date + interval '1 day')`]; + if (couponId) { + params.push(optionalUuidString(couponId, 'couponId')); + couponFilters.push(`c.id = $${params.length}::uuid`); + redemptionFilters.push(`cr.coupon_id = $${params.length}::uuid`); + } + if (campaignName) { + params.push(campaignName); + couponFilters.push(`c.campaign_name = $${params.length}`); + redemptionFilters.push(`c.campaign_name = $${params.length}`); + } + + const [summaryRows, couponRows, dailyRows, campaignRows] = await Promise.all([ + query>( + ` + select + count(*)::int as "claimCount", + count(*) filter (where cr.status = 'used')::int as "usedCount", + coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents", + coalesce(sum(o.amount_cents) filter (where cr.status = 'used'), 0)::int as "paidAmountCents" + from public.coupon_redemptions cr + left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id + left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id + where ${redemptionFilters.join(' and ')} + `, + params, + ), + query>( + ` + select c.id, c.code::text as code, c.status, c.campaign_name as "campaignName", + c.used_count as "usedCount", c.max_uses as "maxUses", + count(cr.id)::int as "claimCount", + count(cr.id) filter (where cr.status = 'used')::int as "redeemedCount", + coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents", + coalesce(sum(o.amount_cents) filter (where cr.status = 'used'), 0)::int as "paidAmountCents" + from public.coupons c + left join public.coupon_redemptions cr on cr.tenant_id = c.tenant_id + and cr.coupon_id = c.id + and cr.created_at >= $2::date + and cr.created_at < ($3::date + interval '1 day') + left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id + where ${couponFilters.join(' and ')} + group by c.id, c.code, c.status, c.campaign_name, c.used_count, c.max_uses + order by "claimCount" desc, c.created_at desc + limit 200 + `, + params, + ), + query>( + ` + select cr.created_at::date::text as date, cr.status, + count(*)::int as count, + coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents" + from public.coupon_redemptions cr + left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id + where ${redemptionFilters.join(' and ')} + group by cr.created_at::date, cr.status + order by date asc, cr.status + `, + params, + ), + query>( + ` + select coalesce(c.campaign_name, '未分组') as "campaignName", + count(cr.id)::int as "claimCount", + count(cr.id) filter (where cr.status = 'used')::int as "usedCount", + coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents" + from public.coupon_redemptions cr + left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id + where ${redemptionFilters.join(' and ')} + group by coalesce(c.campaign_name, '未分组') + order by "claimCount" desc + `, + params, + ), + ]); + + const summary = summaryRows[0] || {}; + return { + item: { + startDate, + endDate, + claimCount: intValue(summary.claimCount, 0), + usedCount: intValue(summary.usedCount, 0), + discountCents: intValue(summary.discountCents, 0), + paidAmountCents: intValue(summary.paidAmountCents, 0), + conversionRate: intValue(summary.claimCount, 0) > 0 + ? Number((intValue(summary.usedCount, 0) / intValue(summary.claimCount, 0)).toFixed(4)) + : 0, + byCoupon: couponRows, + byCampaign: campaignRows, + daily: dailyRows, + }, + }; +} + export async function tenantMembersRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'members:read'); diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 900f7eb0..22c85e2e 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -115,8 +115,9 @@ | 支付宝 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`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 | +| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons`;支持状态启停/归档、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata | +| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券未核销幂等领取、多次核销限额、规则快照、下单后端复核、负数订单项、全额优惠自动开通权益 | +| 优惠券核销报表 | 可联调 | `/api/tenant-admin/coupons/redemptions`、`/api/tenant-admin/coupons/report`;支持按券、状态、活动分组、日期查询核销明细、领取数、使用数、抵扣金额、成交金额、转化率和按日趋势 | | 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 | | 支付/退款补偿 worker | 可联调 | `apps/worker --job commerce` 查询微信/支付宝订单和处理中退款,补偿漏通知支付、补发权益、确认退款、全额退款撤销权益;`npm run test:worker:commerce` 覆盖幂等和密钥不泄露 | | 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;对账只生成差异,不自动改订单/权益 | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index c4ec26fb..39e44a97 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -11,10 +11,10 @@ - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。 - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 - - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。 + - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 + - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 @@ -233,6 +233,8 @@ PUT /api/tenant-admin/activation-codes POST /api/tenant-admin/activation-codes/generate GET /api/tenant-admin/coupons PUT /api/tenant-admin/coupons +GET /api/tenant-admin/coupons/redemptions +GET /api/tenant-admin/coupons/report GET /api/tenant-admin/members PUT /api/tenant-admin/members POST /api/tenant-admin/members/disable @@ -249,7 +251,7 @@ GET /api/tenant-admin/audit-logs - B 端合作商年费/服务费使用 `tenant_invoices`、`tenant_invoice_items`、`tenant_invoice_payments`,不与 C 端学生订单混表。 - 订单金额以后端套餐价格为准,不信任前端传价。 - 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。 -- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。 +- 优惠券领取对同用户同券未核销记录幂等;若 `per_user_limit > 1`,前一次 redemption 核销后可继续领取直到达到限额。优惠券状态、活动分组、最低金额、优惠封顶、首单限制、适用套餐/地区和单用户次数都由后端在领取和下单时复核;下单后 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项和规则快照。 - `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。 - `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 @@ -269,7 +271,7 @@ GET /api/tenant-admin/audit-logs 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全服务联调和转码/CDN 级视频水印。 2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。 -4. 补异常订单运营台、真实生产账单格式验收和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、微信/支付宝官方账单下载、资金对账导入比对和差错工单主链路已完成。 +4. 补真实生产账单格式验收、前端财务操作台和营销活动 UI;支付/退款补偿、退款查询确认、退款通知、微信/支付宝官方账单下载、资金对账导入比对、差错工单、异常订单运营台和优惠券核销报表主链路已完成。 5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。 6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index c342469c..c7ae9795 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -25,7 +25,7 @@ | 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | | 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 | -| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、复杂活动规则、核销报表 | +| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、积分活动联动、营销自动化和前端活动操作台 | | 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 | | CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 1253aea3..bf2d03b6 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -34,7 +34,7 @@ | 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试 | 资料资源基础闭环可跑,真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印和资料下载前端渲染待补 | | 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 | -| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 | +| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、积分活动联动、营销自动化和前端活动操作台继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | @@ -257,6 +257,8 @@ tenant-admin: POST /api/tenant-admin/activation-codes/generate GET /api/tenant-admin/coupons PUT /api/tenant-admin/coupons + GET /api/tenant-admin/coupons/redemptions + GET /api/tenant-admin/coupons/report GET /api/tenant-admin/members PUT /api/tenant-admin/members POST /api/tenant-admin/members/disable diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index cb0a2ea4..181edaef 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -55,7 +55,7 @@ | Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 | | 考试日期/倒计时 | 已覆盖 | 租户后台维护、学生端和公开目录查询已有;前端需展示地区/院校匹配结果 | | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | -| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 | +| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | | 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 1fa9937c..ab2e0c1f 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -24,6 +24,7 @@ - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 +- 优惠券商用规则已补齐后端第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 @@ -115,8 +116,8 @@ - Taro 已有后端 session detail 续练、本地断点恢复、倒计时和阅读理解/案例分析多小题第一版;继续补复盘体验、长题干/公式图片混排和更细统计口径。 8. 订单和营销体验 - - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - - 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;继续补优惠券核销报表、复杂活动规则和前端售后操作台。 + - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算、优惠券复杂规则、核销明细/报表和内部退款状态机。 + - 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;继续补前端售后操作台、营销活动 UI、真实生产账单格式抽样验收和活动效果看板。 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 9ecab4b9..a3007da7 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -565,7 +565,7 @@ POST /api/learning/answers 推荐流程: 1. `GET /api/catalog/svip-plans` 加载可购买套餐。 -2. 如有优惠券,先调 `POST /api/commerce/coupons/claim`,仅用于领取和展示预计抵扣。 +2. 如有优惠券,先调 `POST /api/commerce/coupons/claim`,仅用于领取、占用一个未核销 redemption 和展示预计抵扣。 3. 调 `POST /api/commerce/orders` 创建订单,后端会重新计算最终金额和抵扣。 4. 非零元订单调 `POST /api/commerce/payments/create` 获取支付参数。 5. H5 支付可跳转 provider 返回的 URL;微信小程序支付用 provider 返回参数调用 `Taro.requestPayment`。 @@ -1745,7 +1745,55 @@ body: { } ``` -`coupons/claim` 对同一用户同一优惠券是幂等的;已使用的券会返回 `COUPON_ALREADY_USED`。下单时后端会重新计算套餐原价、优惠金额和最终应付,前端展示金额只能使用接口返回的 `originalAmountCents`、`discountCents`、`amountCents`。 +`coupons/claim` 对同一用户同一优惠券的未核销记录是幂等的;如果优惠券允许 `perUserLimit > 1`,前一次 redemption 已经下单核销后,用户可以再次领取直到达到限额。已超过单用户限额会返回 `COUPON_ALREADY_USED` 或 `COUPON_USER_LIMIT_REACHED`。下单时后端会重新计算套餐原价、优惠金额和最终应付,前端展示金额只能使用接口返回的 `originalAmountCents`、`discountCents`、`amountCents`。 + +优惠券规则由后端执行,前端只做展示和提示: + +```text +COUPON_DISABLED 优惠券已停用或归档 +COUPON_NOT_STARTED 未到开始时间 +COUPON_EXPIRED 已过期 +COUPON_QUOTA_EXHAUSTED 总库存已用完 +COUPON_PLAN_MISMATCH 不适用当前套餐 +COUPON_REGION_MISMATCH 不适用当前地区 +COUPON_MIN_ORDER_AMOUNT_NOT_MET 未达到最低订单金额 +COUPON_FIRST_ORDER_ONLY 仅限首单 +COUPON_USER_LIMIT_REACHED 已达到单用户可用次数 +``` + +租户后台优惠券配置字段: + +```json +{ + "code": "SUMMER80", + "planId": "<默认绑定套餐,可选>", + "discountType": "fixed | percent", + "discountValue": 800, + "status": "active | disabled | archived", + "campaignName": "暑期活动", + "minOrderAmountCents": 3000, + "maxDiscountCents": 1000, + "perUserLimit": 2, + "firstOrderOnly": false, + "allowedPlanIds": [""], + "allowedRegionIds": [""], + "maxUses": 500, + "metadata": { + "channel": "poster" + } +} +``` + +租户后台核销和报表: + +```text +GET /api/tenant-admin/coupons?status=active&campaignName=暑期活动 +GET /api/tenant-admin/coupons/redemptions?couponId=&status=used +GET /api/tenant-admin/coupons/report?startDate=2026-06-01&endDate=2026-06-29&campaignName=暑期活动 +权限:`coupons:read` 可查看配置,`coupons:write` 可维护配置,核销明细和报表需要 `coupons:redemptions:read` +``` + +`coupons/report` 返回 `claimCount/usedCount/discountCents/paidAmountCents/conversionRate/byCoupon/byCampaign/daily`。金额均为分,报表只读;前端不要用报表数据反向修改订单、支付、权益或优惠券使用次数。 如果优惠后 `amountCents=0`,后端会立即把订单置为 `paid` 并发放权益,前端不要再调用 `payments/create`。 @@ -2376,7 +2424,7 @@ GET /api/ai/school-recommendations/detail?reportId= - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检 -- Banner/FAQ/公告/激活码/优惠券 +- Banner/FAQ/公告/激活码/优惠券,优惠券后台应接 `GET/PUT /api/tenant-admin/coupons`、`GET /api/tenant-admin/coupons/redemptions`、`GET /api/tenant-admin/coupons/report`,用于配置复杂规则、查看核销明细和活动效果。 - 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 3541b3a4..480c6b3e 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2150,6 +2150,175 @@ async function testCommerce() { }); assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment'); + const minOrderCoupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: `ITMIN${Date.now()}`, + planId: ids.couponOnlyPlan, + discountType: 'fixed', + discountValue: 300, + minOrderAmountCents: 1500, + campaignName: 'integration-coupon-rules', + maxUses: 50, + }, + }); + assert.equal(minOrderCoupon.item?.minOrderAmountCents, 1500, 'tenant admin should persist coupon minimum amount rule'); + const minOrderCouponDenied = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: minOrderCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + expectStatus: 409, + }); + assert.equal(minOrderCouponDenied.code, 'COUPON_MIN_ORDER_AMOUNT_NOT_MET', 'coupon minimum order amount should be enforced by backend'); + + const disabledCoupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: `ITDIS${Date.now()}`, + planId: ids.couponOnlyPlan, + discountType: 'fixed', + discountValue: 100, + status: 'disabled', + campaignName: 'integration-coupon-rules', + }, + }); + const disabledCouponDenied = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: disabledCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + expectStatus: 409, + }); + assert.equal(disabledCouponDenied.code, 'COUPON_DISABLED', 'disabled coupon should not be claimable'); + + const regionRestrictedCoupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: `ITREG${Date.now()}`, + planId: ids.couponOnlyPlan, + discountType: 'fixed', + discountValue: 100, + allowedRegionIds: ['00000000-0000-0000-0000-000000000999'], + campaignName: 'integration-coupon-rules', + }, + }); + const regionCouponDenied = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: regionRestrictedCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + expectStatus: 409, + }); + assert.equal(regionCouponDenied.code, 'COUPON_REGION_MISMATCH', 'coupon allowed region rule should be enforced'); + + const multiUseCoupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: `ITMULTI${Date.now()}`, + planId: ids.couponOnlyPlan, + discountType: 'fixed', + discountValue: 100, + maxDiscountCents: 80, + perUserLimit: 2, + allowedPlanIds: [ids.couponOnlyPlan], + allowedRegionIds: [ids.region], + campaignName: 'integration-coupon-rules', + metadata: { scenario: 'multi-use' }, + }, + }); + assert.equal(multiUseCoupon.item?.maxDiscountCents, 80, 'tenant admin should persist max discount cap'); + assert.equal(multiUseCoupon.item?.perUserLimit, 2, 'tenant admin should persist per-user limit'); + + const multiClaimOne = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + }); + assert.equal(multiClaimOne.coupon?.discountCents, 80, 'coupon max discount cap should apply during claim'); + const multiOrderOne = await request('/api/commerce/orders', { + method: 'POST', + body: { + planId: ids.couponOnlyPlan, + payProvider: 'manual', + payMethod: 'manual', + regionId: ids.region, + couponRedemptionId: multiClaimOne.redemption.id, + }, + }); + assert.equal(multiOrderOne.item?.discountCents, 80, 'order should apply capped coupon discount'); + + const multiClaimTwo = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + }); + assert.notEqual(multiClaimTwo.redemption?.id, multiClaimOne.redemption?.id, 'perUserLimit > 1 should allow another redemption after first use'); + const multiOrderTwo = await request('/api/commerce/orders', { + method: 'POST', + body: { + planId: ids.couponOnlyPlan, + payProvider: 'manual', + payMethod: 'manual', + regionId: ids.region, + couponRedemptionId: multiClaimTwo.redemption.id, + }, + }); + assert.equal(multiOrderTwo.item?.discountCents, 80, 'second allowed coupon use should still apply cap'); + + const multiClaimThreeDenied = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + expectStatus: 409, + }); + assert.equal(multiClaimThreeDenied.code, 'COUPON_USER_LIMIT_REACHED', 'per-user coupon limit should be enforced after allowed uses'); + + const firstOrderCoupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: `ITFIRST${Date.now()}`, + planId: ids.couponOnlyPlan, + discountType: 'fixed', + discountValue: 100, + firstOrderOnly: true, + campaignName: 'integration-coupon-rules', + }, + }); + const firstOrderDenied = await request('/api/commerce/coupons/claim', { + method: 'POST', + body: { code: firstOrderCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region }, + expectStatus: 409, + }); + assert.equal(firstOrderDenied.code, 'COUPON_FIRST_ORDER_ONLY', 'first-order-only coupon should reject users with paid orders'); + + const couponReport = await request('/api/tenant-admin/coupons/report', { + userId: TENANT_ADMIN_USER_ID, + query: { campaignName: 'integration-coupon-rules' }, + }); + assert.ok(couponReport.item?.claimCount >= 2, 'coupon report should aggregate campaign claims'); + assert.ok(couponReport.item?.usedCount >= 2, 'coupon report should aggregate used redemptions'); + assert.ok( + couponReport.item?.byCoupon?.some(item => item.id === multiUseCoupon.item.id && item.redeemedCount >= 2), + 'coupon report should include per-coupon redemption stats', + ); + + const couponRedemptions = await request('/api/tenant-admin/coupons/redemptions', { + userId: TENANT_ADMIN_USER_ID, + query: { couponId: multiUseCoupon.item.id, status: 'used' }, + }); + assert.ok( + couponRedemptions.items?.some(item => item.orderNo === multiOrderOne.item.orderNo), + 'coupon redemption list should include used order identity', + ); + + const couponRedemptionsOperatorDenied = await request('/api/tenant-admin/coupons/redemptions', { + userId: TENANT_OPERATOR_USER_ID, + query: { couponId: multiUseCoupon.item.id, status: 'used' }, + expectStatus: 403, + }); + assert.equal( + couponRedemptionsOperatorDenied.code, + 'TENANT_PERMISSION_REQUIRED', + 'coupon redemption list should require dedicated redemption-report permission beyond coupon config read', + ); + const partialRefundOrder = await request('/api/commerce/orders', { method: 'POST', body: { @@ -6356,10 +6525,20 @@ async function testTenantAdminOps() { discountType: 'fixed', discountValue: 10, maxUses: 100, + minOrderAmountCents: 100, + maxDiscountCents: 10, + perUserLimit: 2, + allowedPlanIds: ['00000000-0000-0000-0000-000000000201'], + allowedRegionIds: [ids.region], + campaignName: 'tenant-admin-coupon-smoke', + metadata: { channel: 'integration' }, source: 'integration-test', }, }); assert.equal(coupon.item?.code, 'IT-COUPON-001', 'tenant admin should upsert coupon'); + assert.equal(coupon.item?.campaignName, 'tenant-admin-coupon-smoke', 'tenant admin should persist coupon campaign'); + assert.equal(coupon.item?.perUserLimit, 2, 'tenant admin should persist coupon per-user limit'); + assert.ok(coupon.item?.allowedRegionIds?.includes(ids.region), 'tenant admin should persist coupon region scope'); const partnerDenied = await request('/api/tenant-admin/auth-providers', { tenantId: PARTNER_TENANT_ID, @@ -6530,7 +6709,8 @@ async function testTenantMemberPermissionsAndAudit() { status: 'active', permissions: { 'codes:*': true, - 'coupons:*': true, + 'coupons:read': true, + 'coupons:write': true, }, }, }); @@ -7031,7 +7211,8 @@ async function testReferralAndCrmGrowth() { status: 'active', permissions: { 'codes:*': true, - 'coupons:*': true, + 'coupons:read': true, + 'coupons:write': true, 'referral:*': true, }, }, diff --git a/supabase/migrations/202606290031_coupon_rules_reports.sql b/supabase/migrations/202606290031_coupon_rules_reports.sql new file mode 100644 index 00000000..539beea5 --- /dev/null +++ b/supabase/migrations/202606290031_coupon_rules_reports.sql @@ -0,0 +1,49 @@ +alter table public.coupons + add column if not exists status text not null default 'active', + add column if not exists campaign_name text, + add column if not exists min_order_amount_cents integer not null default 0, + add column if not exists max_discount_cents integer, + add column if not exists per_user_limit integer not null default 1, + add column if not exists first_order_only boolean not null default false, + add column if not exists allowed_plan_ids uuid[] not null default '{}'::uuid[], + add column if not exists allowed_region_ids uuid[] not null default '{}'::uuid[], + add column if not exists metadata jsonb not null default '{}'::jsonb; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'coupons_status_check') then + alter table public.coupons + add constraint coupons_status_check check (status in ('active', 'disabled', 'archived')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'coupons_min_order_amount_cents_check') then + alter table public.coupons + add constraint coupons_min_order_amount_cents_check check (min_order_amount_cents >= 0); + end if; + + if not exists (select 1 from pg_constraint where conname = 'coupons_max_discount_cents_check') then + alter table public.coupons + add constraint coupons_max_discount_cents_check check (max_discount_cents is null or max_discount_cents >= 0); + end if; + + if not exists (select 1 from pg_constraint where conname = 'coupons_per_user_limit_check') then + alter table public.coupons + add constraint coupons_per_user_limit_check check (per_user_limit > 0 and per_user_limit <= 100); + end if; +end $$; + +drop index if exists public.idx_coupon_redemptions_user_coupon_once; + +create unique index if not exists idx_coupon_redemptions_user_coupon_open + on public.coupon_redemptions(tenant_id, user_id, coupon_id) + where coupon_id is not null and status in ('claimed', 'pending'); + +create index if not exists idx_coupons_tenant_status_campaign + on public.coupons(tenant_id, status, campaign_name, created_at desc); + +create index if not exists idx_coupon_redemptions_coupon_status + on public.coupon_redemptions(tenant_id, coupon_id, status, created_at desc); + +create index if not exists idx_coupon_redemptions_used_at + on public.coupon_redemptions(tenant_id, used_at desc) + where used_at is not null;