forked from wangziqi/gongxue-base
feat: add coupon rule reporting
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
||||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。
|
- 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 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
|
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;前端营销操作台仍需补更完整活动 UI。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
@@ -285,7 +286,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
|
|||||||
apps/api/src/features/
|
apps/api/src/features/
|
||||||
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
|
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
|
||||||
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
|
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
|
||||||
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账和差错工单
|
commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单
|
||||||
health/ 健康检查
|
health/ 健康检查
|
||||||
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
||||||
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
|
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
|
||||||
@@ -316,7 +317,7 @@ API 身份上下文:
|
|||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;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 或商户密钥。优惠券状态、最低金额、封顶、单用户限次、首单、适用套餐/地区和订单抵扣都由后端重新校验,前端只能展示后端返回金额。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。
|
||||||
|
|
||||||
## 最近一次验证
|
## 最近一次验证
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ interface CouponRow {
|
|||||||
valid_to: string | null;
|
valid_to: string | null;
|
||||||
max_uses: number | null;
|
max_uses: number | null;
|
||||||
used_count: number;
|
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<string, unknown>;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
}
|
}
|
||||||
@@ -172,11 +181,13 @@ function safeDiscountValue(value: string | number | null) {
|
|||||||
function calculateCouponDiscount(coupon: CouponRow, baseAmountCents: number) {
|
function calculateCouponDiscount(coupon: CouponRow, baseAmountCents: number) {
|
||||||
const amount = Math.max(0, Math.trunc(baseAmountCents));
|
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 || coupon.discount_value === null || coupon.discount_value === undefined) return 0;
|
||||||
if (coupon.discount_type === 'fixed') {
|
const discount = coupon.discount_type === 'fixed'
|
||||||
return Math.min(amount, Math.max(0, Math.round(safeDiscountValue(coupon.discount_value))));
|
? Math.max(0, Math.round(safeDiscountValue(coupon.discount_value)))
|
||||||
}
|
: Math.floor((amount * Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)))) / 100);
|
||||||
const percent = Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)));
|
const cappedByCoupon = coupon.max_discount_cents === null || coupon.max_discount_cents === undefined
|
||||||
return Math.min(amount, Math.floor((amount * percent) / 100));
|
? discount
|
||||||
|
: Math.min(discount, Math.max(0, coupon.max_discount_cents));
|
||||||
|
return Math.min(amount, cappedByCoupon);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculatePlanDays(planDays: number, quantity: number) {
|
function calculatePlanDays(planDays: number, quantity: number) {
|
||||||
@@ -186,6 +197,9 @@ function calculatePlanDays(planDays: number, quantity: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function couponValidity(coupon: CouponRow, now = new Date()) {
|
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)) {
|
if (coupon.valid_from && now < new Date(coupon.valid_from)) {
|
||||||
return { valid: false, code: 'COUPON_NOT_STARTED', message: 'Coupon is not active yet' };
|
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: '' };
|
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) {
|
function couponPayload(coupon: CouponRow, plan: PlanRow, redemption: CouponRedemptionRow | null, discountCents: number) {
|
||||||
return {
|
return {
|
||||||
code: coupon.code,
|
code: coupon.code,
|
||||||
discountType: coupon.discount_type,
|
discountType: coupon.discount_type,
|
||||||
discountValue: coupon.discount_value === null || coupon.discount_value === undefined ? null : Number(coupon.discount_value),
|
discountValue: coupon.discount_value === null || coupon.discount_value === undefined ? null : Number(coupon.discount_value),
|
||||||
discountCents,
|
discountCents,
|
||||||
|
rules: couponMetadata(coupon),
|
||||||
source: coupon.source,
|
source: coupon.source,
|
||||||
remark: coupon.remark,
|
remark: coupon.remark,
|
||||||
redemptionId: redemption?.id || null,
|
redemptionId: redemption?.id || null,
|
||||||
@@ -241,7 +286,10 @@ async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: s
|
|||||||
const result = await client.query<CouponRow>(
|
const result = await client.query<CouponRow>(
|
||||||
`
|
`
|
||||||
select id, code::text as code, plan_id, discount_type, discount_value,
|
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
|
from public.coupons
|
||||||
where tenant_id = $1 and lower(code::text) = lower($2)
|
where tenant_id = $1 and lower(code::text) = lower($2)
|
||||||
limit 1
|
limit 1
|
||||||
@@ -256,7 +304,10 @@ async function findCouponById(client: pg.PoolClient, tenantId: string, couponId:
|
|||||||
const result = await client.query<CouponRow>(
|
const result = await client.query<CouponRow>(
|
||||||
`
|
`
|
||||||
select id, code::text as code, plan_id, discount_type, discount_value,
|
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
|
from public.coupons
|
||||||
where tenant_id = $1 and id = $2
|
where tenant_id = $1 and id = $2
|
||||||
limit 1
|
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
|
discount_applied_cents, region_id, source, remark, claimed_at, used_at
|
||||||
from public.coupon_redemptions
|
from public.coupon_redemptions
|
||||||
where tenant_id = $1 and user_id = $2 and coupon_id = $3
|
where tenant_id = $1 and user_id = $2 and coupon_id = $3
|
||||||
|
and status in ('claimed', 'pending')
|
||||||
order by created_at desc
|
order by created_at desc
|
||||||
limit 1
|
limit 1
|
||||||
for update
|
for update
|
||||||
@@ -283,6 +335,67 @@ async function findCouponRedemption(client: pg.PoolClient, tenantId: string, use
|
|||||||
return result.rows[0] || null;
|
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(
|
async function claimCoupon(
|
||||||
client: pg.PoolClient,
|
client: pg.PoolClient,
|
||||||
input: {
|
input: {
|
||||||
@@ -312,9 +425,15 @@ async function claimCoupon(
|
|||||||
if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) {
|
if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) {
|
||||||
throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH');
|
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);
|
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)) {
|
if (existing && !['claimed', 'pending'].includes(existing.status)) {
|
||||||
throw new HttpError(409, `Coupon redemption is ${existing.status}`, 'COUPON_REDEMPTION_UNAVAILABLE');
|
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');
|
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
|
||||||
const validity = couponValidity(coupon);
|
const validity = couponValidity(coupon);
|
||||||
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
|
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);
|
const discountCents = calculateCouponDiscount(coupon, originalAmountCents);
|
||||||
couponResult = { coupon, plan, redemption, discountCents, idempotent: true };
|
couponResult = { coupon, plan, redemption, discountCents, idempotent: true };
|
||||||
} else if (couponCode) {
|
} else if (couponCode) {
|
||||||
@@ -835,6 +961,7 @@ export async function createOrderRoute(ctx: RequestContext) {
|
|||||||
redemptionId: couponResult.redemption.id,
|
redemptionId: couponResult.redemption.id,
|
||||||
discountType: couponResult.coupon.discount_type,
|
discountType: couponResult.coupon.discount_type,
|
||||||
discountValue: couponResult.coupon.discount_value,
|
discountValue: couponResult.coupon.discount_value,
|
||||||
|
couponRules: couponRuleSnapshot(couponResult.coupon),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
|||||||
tenant_admin: ['*'],
|
tenant_admin: ['*'],
|
||||||
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
|
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:*'],
|
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'],
|
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
|
||||||
student: [],
|
student: [],
|
||||||
};
|
};
|
||||||
@@ -110,6 +110,7 @@ export function tenantPermissionCatalog() {
|
|||||||
{ key: 'codes:write', label: '激活码管理' },
|
{ key: 'codes:write', label: '激活码管理' },
|
||||||
{ key: 'coupons:read', label: '优惠券查看' },
|
{ key: 'coupons:read', label: '优惠券查看' },
|
||||||
{ key: 'coupons:write', label: '优惠券管理' },
|
{ key: 'coupons:write', label: '优惠券管理' },
|
||||||
|
{ key: 'coupons:redemptions:read', label: '优惠券核销明细/报表' },
|
||||||
{ key: 'referral:read', label: '客资全局查看' },
|
{ key: 'referral:read', label: '客资全局查看' },
|
||||||
{ key: 'referral:self', label: '本人客资查看' },
|
{ key: 'referral:self', label: '本人客资查看' },
|
||||||
{ key: 'referral:write', label: '客资归属管理' },
|
{ key: 'referral:write', label: '客资归属管理' },
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
authProvidersRoute,
|
authProvidersRoute,
|
||||||
bannersAdminRoute,
|
bannersAdminRoute,
|
||||||
codeBatchesRoute,
|
codeBatchesRoute,
|
||||||
|
couponRedemptionsRoute,
|
||||||
|
couponReportRoute,
|
||||||
couponsRoute,
|
couponsRoute,
|
||||||
createTenantDomainRoute,
|
createTenantDomainRoute,
|
||||||
disableTenantRoleTemplateRoute,
|
disableTenantRoleTemplateRoute,
|
||||||
@@ -127,6 +129,8 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
|
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
|
||||||
['GET', '/api/tenant-admin/coupons', couponsRoute],
|
['GET', '/api/tenant-admin/coupons', couponsRoute],
|
||||||
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
|
['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],
|
['GET', '/api/tenant-admin/members', tenantMembersRoute],
|
||||||
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
|
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
|
||||||
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
|
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const PAYMENT_MODES = ['platform_collect', 'tenant_collect', 'service_provider']
|
|||||||
const PAYMENT_STATUSES = ['active', 'disabled', 'pending'];
|
const PAYMENT_STATUSES = ['active', 'disabled', 'pending'];
|
||||||
const AUTH_STATUSES = ['active', 'disabled', 'testing'];
|
const AUTH_STATUSES = ['active', 'disabled', 'testing'];
|
||||||
const DISCOUNT_TYPES = ['percent', 'fixed'];
|
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_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student'];
|
||||||
const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
|
const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
|
||||||
const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
|
const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
|
||||||
@@ -75,6 +77,17 @@ function jsonArrayValue(value: unknown) {
|
|||||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
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) {
|
function nullableString(value: unknown) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
@@ -2198,18 +2211,36 @@ export async function couponsRoute(ctx: RequestContext) {
|
|||||||
const auth = await requireTenantAdmin(ctx);
|
const auth = await requireTenantAdmin(ctx);
|
||||||
requireTenantPermission(auth, 'coupons:read');
|
requireTenantPermission(auth, 'coupons:read');
|
||||||
const limit = intParam(ctx, 'limit', 100, 500);
|
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(
|
const items = await query(
|
||||||
`
|
`
|
||||||
select id, legacy_id as "legacyId", code, plan_id as "planId",
|
select id, legacy_id as "legacyId", code, plan_id as "planId",
|
||||||
discount_type as "discountType", discount_value as "discountValue",
|
discount_type as "discountType", discount_value as "discountValue",
|
||||||
valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses",
|
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
|
from public.coupons
|
||||||
where tenant_id = $1
|
where ${filters.join(' and ')}
|
||||||
order by created_at desc
|
order by created_at desc
|
||||||
limit $2
|
limit $2
|
||||||
`,
|
`,
|
||||||
[auth.tenantId, limit],
|
params,
|
||||||
);
|
);
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
@@ -2219,17 +2250,32 @@ export async function upsertCouponRoute(ctx: RequestContext) {
|
|||||||
requireTenantPermission(auth, 'coupons:write');
|
requireTenantPermission(auth, 'coupons:write');
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx);
|
||||||
const discountType = body.discountType ? optionalChoice(body.discountType, DISCOUNT_TYPES, 'fixed') : null;
|
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 item = await transaction(async client => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
insert into public.coupons (
|
insert into public.coupons (
|
||||||
id, tenant_id, legacy_id, code, plan_id, discount_type, discount_value,
|
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 (
|
values (
|
||||||
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5::uuid, $6, $7,
|
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)
|
on conflict (tenant_id, code)
|
||||||
do update set plan_id = excluded.plan_id,
|
do update set plan_id = excluded.plan_id,
|
||||||
@@ -2238,31 +2284,56 @@ export async function upsertCouponRoute(ctx: RequestContext) {
|
|||||||
valid_from = excluded.valid_from,
|
valid_from = excluded.valid_from,
|
||||||
valid_to = excluded.valid_to,
|
valid_to = excluded.valid_to,
|
||||||
max_uses = excluded.max_uses,
|
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,
|
source = excluded.source,
|
||||||
remark = excluded.remark,
|
remark = excluded.remark,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
returning id, legacy_id as "legacyId", code, plan_id as "planId",
|
returning id, legacy_id as "legacyId", code, plan_id as "planId",
|
||||||
discount_type as "discountType", discount_value as "discountValue",
|
discount_type as "discountType", discount_value as "discountValue",
|
||||||
valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses",
|
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,
|
auth.tenantId,
|
||||||
nullableString(body.id),
|
nullableString(body.id),
|
||||||
nullableString(body.legacyId),
|
nullableString(body.legacyId),
|
||||||
codeValue(body),
|
codeValue(body),
|
||||||
nullableString(body.planId),
|
planId,
|
||||||
discountType,
|
discountType,
|
||||||
numberValue(body.discountValue, null),
|
numberValue(body.discountValue, null),
|
||||||
nullableString(body.validFrom),
|
nullableString(body.validFrom),
|
||||||
nullableString(body.validTo),
|
nullableString(body.validTo),
|
||||||
body.maxUses === undefined ? null : intValue(body.maxUses, 0),
|
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.source),
|
||||||
nullableString(body.remark),
|
nullableString(body.remark),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await recordAudit(client, auth, 'tenant.coupon.upserted', 'coupons', result.rows[0].id, {
|
await recordAudit(client, auth, 'tenant.coupon.upserted', 'coupons', result.rows[0].id, {
|
||||||
code: result.rows[0].code,
|
code: result.rows[0].code,
|
||||||
|
status: result.rows[0].status,
|
||||||
|
campaignName: result.rows[0].campaignName,
|
||||||
});
|
});
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
});
|
});
|
||||||
@@ -2270,6 +2341,175 @@ export async function upsertCouponRoute(ctx: RequestContext) {
|
|||||||
return { item };
|
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<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
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) {
|
export async function tenantMembersRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantAdmin(ctx);
|
const auth = await requireTenantAdmin(ctx);
|
||||||
requireTenantPermission(auth, 'members:read');
|
requireTenantPermission(auth, 'members:read');
|
||||||
|
|||||||
@@ -115,8 +115,9 @@
|
|||||||
| 支付宝 WAP/H5 | 可联调 | `/api/commerce/payments/create`、`notify/alipay`,已覆盖 RSA2 通知验签、幂等和权益开通 |
|
| 支付宝 WAP/H5 | 可联调 | `/api/commerce/payments/create`、`notify/alipay`,已覆盖 RSA2 通知验签、幂等和权益开通 |
|
||||||
| 权益查询/校验 | 可联调 | `/api/commerce/entitlements` |
|
| 权益查询/校验 | 可联调 | `/api/commerce/entitlements` |
|
||||||
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
|
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
|
||||||
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons`;支持状态启停/归档、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata |
|
||||||
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
| 优惠券前台领取/下单抵扣 | 可联调 | `/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` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
| 退款状态机和供应商确认 | 可联调 | `/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` 覆盖幂等和密钥不泄露 |
|
| 支付/退款补偿 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、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;对账只生成差异,不自动改订单/权益 |
|
| 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;对账只生成差异,不自动改订单/权益 |
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。
|
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。
|
||||||
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
||||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||||
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。
|
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
||||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
||||||
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
||||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
||||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
||||||
- `tenant`:域名/租户解析。
|
- `tenant`:域名/租户解析。
|
||||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
- 鉴权上下文已支持 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
|
POST /api/tenant-admin/activation-codes/generate
|
||||||
GET /api/tenant-admin/coupons
|
GET /api/tenant-admin/coupons
|
||||||
PUT /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
|
GET /api/tenant-admin/members
|
||||||
PUT /api/tenant-admin/members
|
PUT /api/tenant-admin/members
|
||||||
POST /api/tenant-admin/members/disable
|
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 端学生订单混表。
|
- B 端合作商年费/服务费使用 `tenant_invoices`、`tenant_invoice_items`、`tenant_invoice_payments`,不与 C 端学生订单混表。
|
||||||
- 订单金额以后端套餐价格为准,不信任前端传价。
|
- 订单金额以后端套餐价格为准,不信任前端传价。
|
||||||
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||||
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
- 优惠券领取对同用户同券未核销记录幂等;若 `per_user_limit > 1`,前一次 redemption 核销后可继续领取直到达到限额。优惠券状态、活动分组、最低金额、优惠封顶、首单限制、适用套餐/地区和单用户次数都由后端在领取和下单时复核;下单后 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项和规则快照。
|
||||||
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
||||||
- `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。
|
- `/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` 和掩码状态。
|
- 租户支付账户、短信、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 级视频水印。
|
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全服务联调和转码/CDN 级视频水印。
|
||||||
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||||
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
||||||
4. 补异常订单运营台、真实生产账单格式验收和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、微信/支付宝官方账单下载、资金对账导入比对和差错工单主链路已完成。
|
4. 补真实生产账单格式验收、前端财务操作台和营销活动 UI;支付/退款补偿、退款查询确认、退款通知、微信/支付宝官方账单下载、资金对账导入比对、差错工单、异常订单运营台和优惠券核销报表主链路已完成。
|
||||||
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
|
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
|
||||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
||||||
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
||||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
|
| 资料下载/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、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
|
| 题目视频讲解 | 已建 `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 级水印和资料下载前端渲染待补 |
|
| 资料下载/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 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
|
| 个人中心 | 已建 `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 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
|
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
|
||||||
@@ -257,6 +257,8 @@ tenant-admin:
|
|||||||
POST /api/tenant-admin/activation-codes/generate
|
POST /api/tenant-admin/activation-codes/generate
|
||||||
GET /api/tenant-admin/coupons
|
GET /api/tenant-admin/coupons
|
||||||
PUT /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
|
GET /api/tenant-admin/members
|
||||||
PUT /api/tenant-admin/members
|
PUT /api/tenant-admin/members
|
||||||
POST /api/tenant-admin/members/disable
|
POST /api/tenant-admin/members/disable
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
||||||
| 考试日期/倒计时 | 已覆盖 | 租户后台维护、学生端和公开目录查询已有;前端需展示地区/院校匹配结果 |
|
| 考试日期/倒计时 | 已覆盖 | 租户后台维护、学生端和公开目录查询已有;前端需展示地区/院校匹配结果 |
|
||||||
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
||||||
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 |
|
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 |
|
||||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
||||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。
|
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。
|
||||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||||
|
- 优惠券商用规则已补齐后端第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
||||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||||
@@ -115,8 +116,8 @@
|
|||||||
- Taro 已有后端 session detail 续练、本地断点恢复、倒计时和阅读理解/案例分析多小题第一版;继续补复盘体验、长题干/公式图片混排和更细统计口径。
|
- Taro 已有后端 session detail 续练、本地断点恢复、倒计时和阅读理解/案例分析多小题第一版;继续补复盘体验、长题干/公式图片混排和更细统计口径。
|
||||||
|
|
||||||
8. 订单和营销体验
|
8. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算、优惠券复杂规则、核销明细/报表和内部退款状态机。
|
||||||
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;继续补优惠券核销报表、复杂活动规则和前端售后操作台。
|
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;继续补前端售后操作台、营销活动 UI、真实生产账单格式抽样验收和活动效果看板。
|
||||||
|
|
||||||
9. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ POST /api/learning/answers
|
|||||||
推荐流程:
|
推荐流程:
|
||||||
|
|
||||||
1. `GET /api/catalog/svip-plans` 加载可购买套餐。
|
1. `GET /api/catalog/svip-plans` 加载可购买套餐。
|
||||||
2. 如有优惠券,先调 `POST /api/commerce/coupons/claim`,仅用于领取和展示预计抵扣。
|
2. 如有优惠券,先调 `POST /api/commerce/coupons/claim`,仅用于领取、占用一个未核销 redemption 和展示预计抵扣。
|
||||||
3. 调 `POST /api/commerce/orders` 创建订单,后端会重新计算最终金额和抵扣。
|
3. 调 `POST /api/commerce/orders` 创建订单,后端会重新计算最终金额和抵扣。
|
||||||
4. 非零元订单调 `POST /api/commerce/payments/create` 获取支付参数。
|
4. 非零元订单调 `POST /api/commerce/payments/create` 获取支付参数。
|
||||||
5. H5 支付可跳转 provider 返回的 URL;微信小程序支付用 provider 返回参数调用 `Taro.requestPayment`。
|
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": ["<svipPlanId>"],
|
||||||
|
"allowedRegionIds": ["<regionId>"],
|
||||||
|
"maxUses": 500,
|
||||||
|
"metadata": {
|
||||||
|
"channel": "poster"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
租户后台核销和报表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/tenant-admin/coupons?status=active&campaignName=暑期活动
|
||||||
|
GET /api/tenant-admin/coupons/redemptions?couponId=<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`。
|
如果优惠后 `amountCents=0`,后端会立即把订单置为 `paid` 并发放权益,前端不要再调用 `payments/create`。
|
||||||
|
|
||||||
@@ -2376,7 +2424,7 @@ GET /api/ai/school-recommendations/detail?reportId=<reportId>
|
|||||||
- 内容入口/分类树/题目集合/练习蓝图
|
- 内容入口/分类树/题目集合/练习蓝图
|
||||||
- 题目/单词/知识手册/分数线/视频维护
|
- 题目/单词/知识手册/分数线/视频维护
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
|
- 题目/单词/知识手册/分数线/视频 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/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
||||||
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
||||||
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
||||||
|
|||||||
@@ -2150,6 +2150,175 @@ async function testCommerce() {
|
|||||||
});
|
});
|
||||||
assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment');
|
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', {
|
const partialRefundOrder = await request('/api/commerce/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -6356,10 +6525,20 @@ async function testTenantAdminOps() {
|
|||||||
discountType: 'fixed',
|
discountType: 'fixed',
|
||||||
discountValue: 10,
|
discountValue: 10,
|
||||||
maxUses: 100,
|
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',
|
source: 'integration-test',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(coupon.item?.code, 'IT-COUPON-001', 'tenant admin should upsert coupon');
|
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', {
|
const partnerDenied = await request('/api/tenant-admin/auth-providers', {
|
||||||
tenantId: PARTNER_TENANT_ID,
|
tenantId: PARTNER_TENANT_ID,
|
||||||
@@ -6530,7 +6709,8 @@ async function testTenantMemberPermissionsAndAudit() {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
permissions: {
|
permissions: {
|
||||||
'codes:*': true,
|
'codes:*': true,
|
||||||
'coupons:*': true,
|
'coupons:read': true,
|
||||||
|
'coupons:write': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -7031,7 +7211,8 @@ async function testReferralAndCrmGrowth() {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
permissions: {
|
permissions: {
|
||||||
'codes:*': true,
|
'codes:*': true,
|
||||||
'coupons:*': true,
|
'coupons:read': true,
|
||||||
|
'coupons:write': true,
|
||||||
'referral:*': true,
|
'referral:*': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
49
supabase/migrations/202606290031_coupon_rules_reports.sql
Normal file
49
supabase/migrations/202606290031_coupon_rules_reports.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user