diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts new file mode 100644 index 000000000..e1a49e44c --- /dev/null +++ b/apps/web/src/api/points/index.ts @@ -0,0 +1,471 @@ +/** + * 积分 & 会员 API 封装(v2 两档会员制) + * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 + */ +import apiClient from "../client" +import type { + PointsBalance, + PointsTransactionsParams, + PointsTransactionsResponse, + PointsPackagesResponse, + PointsRechargeRequest, + PointsOrder, + PointsRulesResponse, + PointsCheckRequest, + PointsCheckResponse, + SubscriptionCurrent, + SubscribeRequest, + SubscriptionPlan, +} from "./types" + +/** + * 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false) + */ +export const POINTS_API_MOCK = true + +// ==================== Mock 数据 ==================== +const MOCK_BALANCE: PointsBalance = { + balance: 580, + total_earned: 1200, + total_spent: 620, + is_member: false, + member_type: null, + member_expires_at: null, + free_clips_used: 1, + free_clips_limit: 2, + free_clips_remaining: 1, +} + +const MOCK_PACKAGES: PointsPackagesResponse = { + packages: [ + { + id: "starter_pack", + name: "体验包", + points: 100, + price: 990, + discounted_price_for_free: 990, + discounted_price_for_monthly: 891, + discounted_price_for_quarterly: 861, + discounted_price_for_yearly: 792, + }, + { + id: "basic_pack", + name: "基础包", + points: 500, + price: 3900, + discounted_price_for_free: 3900, + discounted_price_for_monthly: 3510, + discounted_price_for_quarterly: 3393, + discounted_price_for_yearly: 3120, + }, + { + id: "pro_pack", + name: "专业包", + points: 2000, + price: 12900, + discounted_price_for_free: 12900, + discounted_price_for_monthly: 11610, + discounted_price_for_quarterly: 11223, + discounted_price_for_yearly: 10320, + }, + ], + user_member_type: "free", + unit_price_yuan: 0.1, +} + +const MOCK_RULES: PointsRulesResponse = { + rules: [ + { + scene_key: "ai_voice", + scene_name: "AI 配音", + points_per_use: 1, + unit: "分钟", + description: "每生成 1 分钟配音", + }, + { + scene_key: "ai_video", + scene_name: "智能混剪", + points_per_use: 3, + unit: "条", + extra_per_30s: 1, + description: "每条 ≤30s 3 积分,每加 30s +1", + }, + { + scene_key: "ai_digital_human", + scene_name: "AI 数字人", + points_per_use: 15, + unit: "分钟", + description: "每生成 1 分钟口播", + }, + { + scene_key: "voice_clone_train", + scene_name: "声音克隆训练", + points_per_use: 0, + unit: "次", + description: "训练免费", + }, + { scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" }, + { scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" }, + { scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" }, + { scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" }, + { scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" }, + ], + free_user_multiplier: 1.15, + note: "免费用户消耗 = 会员消耗 × 1.15,向上取整", +} + +function genMockTransactions(): PointsTransactionsResponse { + const now = new Date() + const list = [ + { + src: "ai_voice", + name: "AI 配音", + type: "spend" as const, + amt: 1, + desc: "生成配音 1 分钟", + days: 0, + hours: 0, + mins: 30, + }, + { + src: "ai_video", + name: "智能混剪", + type: "spend" as const, + amt: 5, + desc: "生成 1 分钟视频(基础3+30s*2)", + days: 0, + hours: 1, + mins: 15, + }, + { + src: "task_reward", + name: "任务奖励", + type: "earn" as const, + amt: 20, + desc: "首次生成视频奖励", + days: 1, + hours: 0, + mins: 0, + }, + { + src: "recharge", + name: "充值", + type: "earn" as const, + amt: 500, + desc: "基础包充值", + days: 15, + hours: 0, + mins: 0, + }, + { + src: "ai_rewrite", + name: "AI 改写文案", + type: "spend" as const, + amt: 2, + desc: "免费用户价(1×1.15 向上取整)", + days: 16, + hours: 2, + mins: 10, + }, + { + src: "ai_title", + name: "AI 标题生成", + type: "spend" as const, + amt: 2, + desc: "免费用户价", + days: 16, + hours: 3, + mins: 0, + }, + { + src: "douyin_extract", + name: "抖音链接提取", + type: "spend" as const, + amt: 2, + desc: "提取 3 分钟文案", + days: 18, + hours: 0, + mins: 0, + }, + { + src: "ai_digital_human", + name: "AI 数字人", + type: "spend" as const, + amt: 18, + desc: "数字人口播 1 分钟(免费用户价)", + days: 20, + hours: 0, + mins: 0, + }, + { + src: "task_reward", + name: "任务奖励", + type: "earn" as const, + amt: 50, + desc: "注册赠送", + days: 30, + hours: 0, + mins: 0, + }, + ] + let bal = MOCK_BALANCE.balance + const items = list + .map((t, i) => { + const signed = t.type === "earn" ? t.amt : -t.amt + const balance_after = bal // 按时间倒序:earliest 先算 + // adjust running bal + bal = t.type === "earn" ? bal - t.amt : bal + t.amt + const d = new Date(now) + d.setDate(d.getDate() - t.days) + d.setHours(d.getHours() - t.hours) + d.setMinutes(d.getMinutes() - t.mins) + return { + id: `tx_${i + 1}`, + type: t.type, + source: t.src as PointsBalance extends never ? never : string, + source_name: t.name, + amount: t.amt, + signed_amount: signed, + balance_after, + description: t.desc, + ref_id: null, + created_at: d.toISOString(), + } + }) + .reverse() + // Rebuild balance_after going forward + let running = 50 + 0 // after registration gift + for (let i = items.length - 1; i >= 0; i--) { + const it: any = items[i] + if (it.source === "task_reward" && it.description.includes("注册")) running = 50 + } + running = 50 + const fwd = [...items].reverse() + for (const it of fwd as any[]) { + running += it.signed_amount + it.balance_after = running + } + return { items: fwd as any, total: fwd.length, page: 1, page_size: 20 } +} + +// ==================== 真实 API ==================== + +/** 查询积分余额 + 会员状态 */ +export async function getPointsBalance(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180)) + } + const res = await apiClient.get("/points/balance") + return res.data +} + +/** 查询积分流水(分页) */ +export async function getPointsTransactions( + params: PointsTransactionsParams = {}, +): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200)) + } + const res = await apiClient.get("/points/transactions", { params }) + return res.data +} + +/** 查询积分包列表 */ +export async function getPointsPackages(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150)) + } + const res = await apiClient.get("/points/packages") + return res.data +} + +/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */ +export async function createPointsOrder(req: PointsRechargeRequest): Promise { + if (POINTS_API_MOCK) { + const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id) + const mt = MOCK_PACKAGES.user_member_type + const discountKey = `discounted_price_for_${mt}` as const + const price = (pkg as any)?.[discountKey] ?? pkg?.price ?? 0 + return new Promise((r) => + setTimeout( + () => + r({ + id: `mock_order_${Date.now()}`, + package_id: req.package_id, + package_name: pkg?.name ?? "", + points_amount: pkg?.points ?? 0, + price_cents: price, + original_price_cents: pkg?.price ?? 0, + discount: price / (pkg?.price || 1), + currency: "CNY", + status: "pending", + payment_method: null, + payment_id: null, + paid_at: null, + expire_at: null, + created_at: new Date().toISOString(), + }), + 300, + ), + ) + } + const res = await apiClient.post("/points/recharge", req) + return res.data +} + +/** 查询积分消耗规则 */ +export async function getPointsRules(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120)) + } + const res = await apiClient.get("/points/rules") + return res.data +} + +/** 消费前余额预检查 */ +export async function checkPoints(req: PointsCheckRequest): Promise { + if (POINTS_API_MOCK) { + const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key) + if (!rule) { + return { + allowed: false, + required_points: 0, + current_balance: MOCK_BALANCE.balance, + remaining_after: MOCK_BALANCE.balance, + is_free_quota: false, + code: "SCENE_NOT_FOUND", + message: "未知场景", + recharge_url: "/app/points", + } + } + const units = req.units ?? 1 + let base = rule.points_per_use * units + if (rule.extra_per_30s && units > 1) { + // ai_video extra_per_30s: base already covers first 30s, subtract + base = rule.points_per_use + rule.extra_per_30s * (units - 1) + } + const isFree = + MOCK_BALANCE.is_member === false && + req.scene_key === "ai_video" && + (MOCK_BALANCE.free_clips_remaining ?? 0) > 0 + const needed = isFree + ? 0 + : MOCK_BALANCE.is_member + ? base + : Math.ceil(base * MOCK_RULES.free_user_multiplier) + const allowed = isFree || MOCK_BALANCE.balance >= needed + return { + allowed, + required_points: needed, + current_balance: MOCK_BALANCE.balance, + remaining_after: MOCK_BALANCE.balance - needed, + is_free_quota: isFree, + code: allowed ? undefined : "INSUFFICIENT_POINTS", + message: allowed + ? undefined + : `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`, + recharge_url: "/app/points", + } + } + const res = await apiClient.post("/points/check", req) + return res.data +} + +// ==================== 订阅相关 ==================== + +/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */ +export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [ + { + id: "monthly", + name: "月卡", + price_cents: 1990, + price_yuan: 19.9, + per_month_yuan: 19.9, + billing_label: "/月", + }, + { + id: "quarterly", + name: "季卡", + price_cents: 3990, + price_yuan: 39.9, + per_month_yuan: 13.3, + savings_percent: 33, + recommended: true, + billing_label: "/季", + }, + { + id: "yearly", + name: "年卡", + price_cents: 15900, + price_yuan: 159, + per_month_yuan: 13.25, + savings_percent: 33, + billing_label: "/年", + }, +] + +/** 查询当前订阅 */ +export async function getCurrentSubscription(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => + setTimeout( + () => + r({ + is_member: MOCK_BALANCE.is_member, + member_type: MOCK_BALANCE.member_type, + member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员", + status: MOCK_BALANCE.is_member ? "active" : "none", + current_period_start: null, + current_period_end: MOCK_BALANCE.member_expires_at, + auto_renew: false, + points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0, + }), + 150, + ), + ) + } + const res = await apiClient.get("/subscription/current") + return res.data +} + +/** 开通/续费会员 */ +export async function subscribe(req: SubscribeRequest): Promise { + if (POINTS_API_MOCK) { + const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)! + return new Promise((r) => + setTimeout( + () => + r({ + id: `mock_sub_${Date.now()}`, + package_id: plan.id, + package_name: plan.name, + points_amount: 0, + price_cents: plan.price_cents, + original_price_cents: plan.price_cents, + discount: 1, + currency: "CNY", + status: "pending", + payment_method: null, + payment_id: null, + paid_at: null, + expire_at: null, + created_at: new Date().toISOString(), + }), + 300, + ), + ) + } + const res = await apiClient.post("/subscription/subscribe", req) + return res.data +} + +/** 取消自动续费 */ +export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> { + if (POINTS_API_MOCK) { + return new Promise((r) => + setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200), + ) + } + const res = await apiClient.post("/subscription/cancel") + return res.data +}