Files
xiaoxia-saas/apps/web/src/api/points/index.ts
T
xiaoxia 6155b4d21e
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m37s
CI/CD Pipeline / Validate - Style (push) Successful in 3m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Integration Tests (push) Successful in 5m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m13s
CI/CD Pipeline / Validate - Security (push) Successful in 9m25s
CI/CD Pipeline / Unit Tests (push) Successful in 10m29s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 4h19m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 4h26m39s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 4h28m51s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 4h25m45s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 4h25m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 4h28m48s
CI/CD Pipeline / PR Build Web Image (push) Failing after 4h28m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 4h28m48s
CI/CD Pipeline / Deploy Production (push) Failing after 4h18m14s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 4h18m19s
CI/CD Pipeline / Build Production Web Image (push) Failing after 4h18m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 4h18m14s
CI/CD Pipeline / Frontend Lint (push) Failing after 4h28m48s
CI/CD Pipeline / Build Production API Image (push) Failing after 4h54m12s
feat(points): 对齐后端积分/订阅 API 最终契约(types + API 层改造) (#1956)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-16 10:53:58 +08:00

346 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 积分系统 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 配音 ×145s 加收)",
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<PointsBalance> {
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<PointsRulesResponse> {
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<PointsPackagesResponse> {
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<PointsTransactionsResponse> {
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<CreateRechargeOrderResponse> {
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<PointsCheckResponse> {
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<DailyUsage> {
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<MembershipResponse> {
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
}