/** * 积分 & 会员 API 封装(v2 两档会员制) * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 */ import apiClient from "../client" import type { PointsBalance, PointsTransaction, 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 = items[i] as PointsTransaction & { balance_after?: number } if (it.source === "task_reward" && it.description.includes("注册")) running = 50 } running = 50 const fwd = [...items].reverse() as Array for (const it of fwd) { running += it.signed_amount it.balance_after = running } return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse } // ==================== 真实 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 type DiscountKey = | "discounted_price_for_free" | "discounted_price_for_monthly" | "discounted_price_for_quarterly" | "discounted_price_for_yearly" const discountKey = `discounted_price_for_${mt}` as DiscountKey const price = pkg?.[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 }