/** * 积分系统 API 封装 * 对齐后端 staging 实测最终契约(2026-09-16) * * 当前 POINTS_API_MOCK=true:使用 MOCK_* 常量 + setTimeout 模拟延迟, * 等后端 P0(支付通道接入、change-plan 校验)稳定后切 false 联调。 * * 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。 */ import apiClient from "../client" import type { PointsBalance, PointsRulesResponse, PointsPackagesResponse, PointsTransaction, PointsTransactionsResponse, PointsCheckRequest, PointsCheckResponse, CreateRechargeOrderRequest, CreateRechargeOrderResponse, DailyUsage, MembershipResponse, } from "./types" /** 模拟网络延迟(ms) */ const MOCK_DELAY = 500 /* ================================================================ * Mock 数据 * ================================================================ */ /** mock 余额(无 free_clips_* 字段,已拆分到 dailyUsage) */ const MOCK_BALANCE: PointsBalance = { balance: 258, total_earned: 500, total_spent: 242, is_member: false, member_type: null, member_expires_at: null, } const MOCK_RULES: PointsRulesResponse = { rules: [ { scene_key: "ai_voice", name: "AI 配音", base_points: 2, unit: "次", description: "单次配音消耗 2 积分,超 30 秒每 30 秒 +1 积分", extra_per_30s: 1, }, { scene_key: "ai_video", name: "AI 视频生成", base_points: 8, unit: "条", description: "单条视频 8 积分起,按视频时长加收", extra_per_30s: 3, }, { scene_key: "ai_digital_human", name: "AI 数字人", base_points: 15, unit: "次", description: "数字人生成 15 积分起", extra_per_30s: 5, }, { scene_key: "voice_clone_train", name: "声音克隆训练", base_points: 20, unit: "次", description: "声音模型训练一次性消耗 20 积分", }, { scene_key: "voice_clone_synth", name: "声音克隆合成", base_points: 3, unit: "次", description: "使用克隆声音合成音频每次 3 积分", }, { scene_key: "douyin_extract", name: "抖音文案提取", base_points: 1, unit: "次", description: "提取抖音视频文案每次 1 积分", }, { scene_key: "ai_rewrite", name: "AI 文案改写", base_points: 2, unit: "次", description: "AI 改写文案每次 2 积分", }, { scene_key: "ai_title", name: "AI 标题生成", base_points: 1, unit: "次", description: "AI 生成标题每次 1 积分,一次生成多条", }, { scene_key: "ai_cover", name: "AI 封面生成", base_points: 3, unit: "次", description: "AI 生成封面每次 3 积分", }, ], free_user_multiplier: 1.15, } const MOCK_PACKAGES: PointsPackagesResponse = { packages: [ { code: "points_100", name: "100 积分", points: 100, price_cents: 990, unit_price: 0.099 }, { code: "points_500", name: "500 积分", points: 500, price_cents: 4490, unit_price: 0.0898 }, { code: "points_1000", name: "1000 积分", points: 1000, price_cents: 7990, unit_price: 0.0799 }, { code: "points_3000", name: "3000 积分", points: 3000, price_cents: 19900, unit_price: 0.0663, }, ], user_discount: null, } const MOCK_TRANSACTIONS: PointsTransaction[] = [ { id: 1, type: "deduct", source: "ai_video", amount: 10, balance_after: 248, description: "AI 视频生成 ×1(非会员倍率)", ref_id: "task_abc123", created_at: "2026-09-16T08:30:00Z", }, { id: 2, type: "add", source: "recharge", amount: 100, balance_after: 258, description: "充值 100 积分", ref_id: "order_xyz789", created_at: "2026-09-15T14:20:00Z", }, { id: 3, type: "deduct", source: "ai_voice", amount: 3, balance_after: 158, description: "AI 配音 ×1(45s 加收)", ref_id: "", created_at: "2026-09-15T10:15:00Z", }, { id: 4, type: "add", source: "sign_up", amount: 60, balance_after: 161, description: "新用户注册赠送", ref_id: "", created_at: "2026-09-10T09:00:00Z", }, { id: 5, type: "deduct", source: "ai_title", amount: 1, balance_after: 101, description: "AI 标题生成 ×1", ref_id: "", created_at: "2026-09-14T16:45:00Z", }, ] const MOCK_DAILY_USAGE: DailyUsage = { free_clips_used: 1, free_clips_limit: 3, free_clips_remaining: 2, reset_at: new Date(Date.now() + 8 * 3600_000).toISOString(), } const MOCK_MEMBERSHIP: MembershipResponse = { is_member: false, member_type: null, member_expires_at: null, points_balance: 258, max_resolution: "720p", } /* ================================================================ * 积分 API * ================================================================ */ /** 获取积分余额 */ export async function getPointsBalance(): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) return { ...MOCK_BALANCE } } const { data } = await apiClient.get(`/points/balance`) return data } /** 获取积分消耗规则 */ export async function getPointsRules(): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier } } const { data } = await apiClient.get(`/points/rules`) return data } /** 获取充值包列表 */ export async function getPointsPackages(): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null } } const { data } = await apiClient.get(`/points/packages`) return data } /** * 获取积分流水(分页) */ export async function getPointsTransactions( page = 1, pageSize = 20, ): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) const start = (page - 1) * pageSize const items = MOCK_TRANSACTIONS.slice(start, start + pageSize) return { items: items.map((t) => ({ ...t })), total: MOCK_TRANSACTIONS.length, page, page_size: pageSize, } } const { data } = await apiClient.get(`/points/transactions`, { params: { page, page_size: pageSize }, }) return data } /** * 创建充值订单 * 注意:当前 pay_params 返回空对象 {}(支付通道未接入), * 前端可以完成订单创建 UI,但无法发起真实支付,待后续支付通道接入后联调。 */ export async function createPointsOrder( data: CreateRechargeOrderRequest, ): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2)) const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id) if (!pkg) throw new Error("充值包不存在") return { id: `mock_order_${Date.now()}`, order_type: "points_recharge", product_code: pkg.code, amount_cents: pkg.price_cents, points_amount: pkg.points, status: "pending", pay_params: {}, expire_at: new Date(Date.now() + 30 * 60_000).toISOString(), created_at: new Date().toISOString(), } } const { data: d } = await apiClient.post(`/points/recharge`, data) return d } /** * 积分预检查(消耗前调用) */ export async function checkPoints(data: PointsCheckRequest): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key) if (!rule) { throw { error: { code: 400, message: `未知场景:${data.scene_key}`, valid_scenes: MOCK_RULES.rules.map((r) => r.scene_key), }, } } const durationExtra = data.duration_minutes && data.duration_minutes > 0.5 && rule.extra_per_30s ? Math.ceil((data.duration_minutes * 60 - 30) / 30) * rule.extra_per_30s : 0 const base = (rule.base_points + durationExtra) * data.quantity const balance = MOCK_BALANCE.balance const multiplier = MOCK_BALANCE.is_member ? 1 : MOCK_RULES.free_user_multiplier const required = Math.ceil(base * multiplier) // 免费额度抵扣 const isFreeQuota = !MOCK_BALANCE.is_member && MOCK_DAILY_USAGE.free_clips_remaining > 0 const finalRequired = isFreeQuota ? 0 : required return { allowed: balance >= finalRequired, required_points: finalRequired, current_balance: balance, remaining_after: balance - finalRequired, is_free_quota: isFreeQuota, } } const { data: d2 } = await apiClient.post(`/points/check`, data) return d2 } /* ================================================================ * 每日免费额度 + 会员聚合信息(新接口) * ================================================================ */ /** 获取每日免费额度使用情况 */ export async function getDailyUsage(): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) return { ...MOCK_DAILY_USAGE } } const { data } = await apiClient.get(`/usage/daily`) return data } /** 获取会员聚合信息(创作页可用来判断 max_resolution) */ export async function getMembership(): Promise { if (process.env.POINTS_API_MOCK === "true") { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) return { ...MOCK_MEMBERSHIP } } const { data } = await apiClient.get(`/points/subscription/membership`) return data }