feat(points): apps/web/src/api/points/index.ts
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 积分 & 会员 API 封装(v2 两档会员制)
|
||||
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
PointsBalance,
|
||||
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: any = items[i]
|
||||
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
|
||||
}
|
||||
running = 50
|
||||
const fwd = [...items].reverse()
|
||||
for (const it of fwd as any[]) {
|
||||
running += it.signed_amount
|
||||
it.balance_after = running
|
||||
}
|
||||
return { items: fwd as any, total: fwd.length, page: 1, page_size: 20 }
|
||||
}
|
||||
|
||||
// ==================== 真实 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
|
||||
const discountKey = `discounted_price_for_${mt}` as const
|
||||
const price = (pkg as any)?.[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
|
||||
}
|
||||
Reference in New Issue
Block a user