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
478 lines
13 KiB
TypeScript
478 lines
13 KiB
TypeScript
/**
|
||
* 积分 & 会员 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
|
||
}
|