Files
xiaoxia-saas/apps/web/src/api/points/index.ts
T
xiaoxia c3d9f35c0d
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m35s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m25s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m54s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 16s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 29h18m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 29h27m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 29h28m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 29h28m10s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 29h27m27s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 29h27m27s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 29h27m27s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 29h27m27s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 29h27m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 29h27m34s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 29h18m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 29h18m0s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 29h18m0s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 29h27m36s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 29h17m57s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 29h27m20s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 29h27m20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 29h27m20s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 29h27m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 30h3m1s
fix(1895): remove unused PointsPackage import
2026-09-15 09:55:31 +08:00

478 lines
13 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 封装(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<PointsTransaction & { balance_after?: number }>
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<PointsBalance> {
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<PointsTransactionsResponse> {
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<PointsPackagesResponse> {
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<PointsOrder> {
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<PointsRulesResponse> {
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<PointsCheckResponse> {
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<SubscriptionCurrent> {
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<PointsOrder> {
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
}