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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
/**
|
||
* 积分系统 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<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
|
||
}
|