Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41cd9cdc56 | |||
| 799a7f7367 | |||
| 13c302c037 | |||
| 1e8ab91984 | |||
| 3f27d199f5 | |||
| 67b270bce2 | |||
| 0432629aef | |||
| 3ad48335f6 | |||
| 1bf0e73fd2 | |||
| 7d2fbfa49f | |||
| de56a67457 | |||
| 371be8034d |
@@ -8,7 +8,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -125,6 +125,7 @@ def get_rules(
|
||||
base_points=scene_data["base_points"],
|
||||
unit=scene_data["unit"],
|
||||
extra_per_30s=scene_data.get("extra_per_30s"),
|
||||
description=scene_data.get("description", ""),
|
||||
)
|
||||
)
|
||||
return PointsRulesResponse(
|
||||
@@ -161,7 +162,16 @@ def check_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。"""
|
||||
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
|
||||
if body.scene_key not in POINTS_SCENES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "UNKNOWN_SCENE",
|
||||
"message": f"未知场景: {body.scene_key}",
|
||||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||||
},
|
||||
)
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
@@ -267,7 +277,7 @@ def create_recharge_order(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""创建积分充值订单。"""
|
||||
"""创建积分充值订单。pay_params 在支付通道接入后填入 prepay_id/payment_url;当前为空 dict。"""
|
||||
svc = _get_service()
|
||||
try:
|
||||
order = svc.create_order(
|
||||
@@ -278,6 +288,14 @@ def create_recharge_order(
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
|
||||
package = POINTS_PACKAGES.get(body.package_id, {})
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at = now + timedelta(hours=48)
|
||||
# TODO: 接入微信/支付宝后填充真实 prepay_id / payment_url
|
||||
order["points_amount"] = package.get("points", 0)
|
||||
order["pay_params"] = {}
|
||||
order["expire_at"] = expire_at.isoformat()
|
||||
return PointsOrderResponse(**order)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
@@ -99,6 +100,39 @@ async def get_current_subscription(
|
||||
return _build_subscription_info(current_user)
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
def list_membership_plans(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""查询所有会员档位(供前端会员购买页展示)。
|
||||
|
||||
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
||||
"""
|
||||
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for plan_id, info in MEMBERSHIP_PRICES.items():
|
||||
days = info["duration_days"]
|
||||
monthly_cents = round(info["price_cents"] * 30 / days)
|
||||
features: dict[str, Any] = {"max_resolution": "1080p"}
|
||||
if plan_id == "monthly":
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == "quarterly":
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == "yearly":
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append({
|
||||
"plan_id": plan_id,
|
||||
"name": info["name"],
|
||||
"price_cents": info["price_cents"],
|
||||
"monthly_price_cents": monthly_cents,
|
||||
"duration_days": days,
|
||||
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
||||
"features": features,
|
||||
})
|
||||
return {"plans": plans}
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -57,6 +57,7 @@ class PointRuleItem(BaseModel):
|
||||
base_points: int
|
||||
unit: str
|
||||
extra_per_30s: Optional[int] = None
|
||||
description: str = Field(default="", description="规则中文说明,例如 AI 配音每分钟消耗 X 积分")
|
||||
|
||||
|
||||
class PointsRulesResponse(BaseModel):
|
||||
@@ -139,7 +140,12 @@ class PointsOrderResponse(BaseModel):
|
||||
order_type: str
|
||||
product_code: str
|
||||
amount_cents: int
|
||||
points_amount: int = Field(0, description="本次充值/购买可获得的积分(仅 points 类型订单有意义)")
|
||||
status: str
|
||||
pay_params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="拉起支付所需参数(payment_url/prepay_id 等),支付通道接入后填充"
|
||||
)
|
||||
expire_at: Optional[str] = Field(None, description="订单过期时间(ISO 8601),默认创建后 48 小时")
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
@@ -171,6 +177,27 @@ class MembershipStatusResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ============ 订阅档位 ============
|
||||
|
||||
|
||||
class MembershipPlanItem(BaseModel):
|
||||
"""单个会员档位"""
|
||||
|
||||
plan_id: str = Field(..., description="档位标识: monthly/quarterly/yearly")
|
||||
name: str = Field(..., description="档位名称,例如 月卡")
|
||||
monthly_price_cents: int = Field(..., description="折算月价(分)")
|
||||
price_cents: int = Field(..., description="该档位总价(分)")
|
||||
duration_days: int = Field(..., description="时长(天)")
|
||||
points_discount: float = Field(..., description="该档位积分折扣,如 0.9 表示 9 折")
|
||||
features: dict[str, Any] = Field(default_factory=dict, description="档位权益(max_resolution 等)")
|
||||
|
||||
|
||||
class MembershipPlansResponse(BaseModel):
|
||||
"""所有会员档位列表"""
|
||||
|
||||
plans: list[MembershipPlanItem]
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ type AssetListResponse = {
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
|
||||
test("walks through 6-step wizard and starts generation", async ({ page, request }) => {
|
||||
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
@@ -169,18 +169,9 @@ test.describe("Core generation flow", () => {
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面)
|
||||
// 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// Step 2: select material (card grid UI)
|
||||
// 5步向导:素材→数量弹窗→配音→标题→确认生成→封面(#1911 删除选模板步骤,后端自动使用默认模板;
|
||||
// #1677 批量生成在选完素材后弹「要生成几个视频?」数量弹窗,默认1,回车确认)
|
||||
// Step 1: select material (card grid UI)
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
@@ -193,11 +184,17 @@ test.describe("Core generation flow", () => {
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
// #1677 数量弹窗:默认值1,点击「生成 1 个视频」确认(新用户单视频冒烟路径)
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// Step 2: voice(新注册用户无配音素材时展示空状态 h3「🎙️ 选择配音」,仍可点「下一步」跳过)
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
// Step 3: title(新顺序:标题在预览之前)
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
@@ -210,7 +207,14 @@ test.describe("Core generation flow", () => {
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await titleInput.fill(titleText)
|
||||
|
||||
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务
|
||||
// 步骤3(标题页)底部操作栏按钮是「下一步 →」,点击后进入步骤4
|
||||
// 步骤4底部才是「✨ 确认生成视频」按钮
|
||||
const nextBtn = page.locator(".xx-step-actions .xx-btn-primary").filter({ hasText: "下一步" })
|
||||
await expect(nextBtn).toBeVisible({ timeout: 15_000 })
|
||||
await nextBtn.click()
|
||||
|
||||
// Step 4:「确认生成」页面——此处底部是「✨ 确认生成视频」按钮
|
||||
// 注意:Step4 主内容区是实时预览画布,没有 h3 「🎬 确认生成」标题,标题由顶部步骤条展示
|
||||
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
|
||||
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
|
||||
await page
|
||||
@@ -218,19 +222,25 @@ test.describe("Core generation flow", () => {
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
// 定位底部操作栏的「✨ 确认生成视频」按钮
|
||||
// 使用底部操作栏 xx-step-actions 作用域,避免命中其他 primary 按钮
|
||||
const confirmBtn = page
|
||||
.locator(".xx-step-actions .xx-btn-primary")
|
||||
.filter({ hasText: "确认生成" })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 30_000 })
|
||||
await expect(confirmBtn).toBeEnabled({ timeout: 30_000 })
|
||||
|
||||
// Wait for generation API to be called — 先挂监听再点击,避免竞态
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
|
||||
// 点击「确认生成视频」
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click()
|
||||
await confirmBtn.click()
|
||||
|
||||
// Verify generation was triggered
|
||||
const genResp = await generatePromise
|
||||
@@ -250,19 +260,13 @@ test.describe("Core generation flow", () => {
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
|
||||
// 单视频(N=1):点击「确认生成视频」后跳 Step 5「确认生成」,展示实时渲染进度
|
||||
await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
// 单视频(N=1):点击「确认生成视频」后跳步骤 5「确认生成」进度页,展示进度卡
|
||||
// 注意:进度页底部按钮变为 disabled 的「⏳ 视频渲染中…」
|
||||
await expect(page.getByText("视频渲染中")).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟)
|
||||
// 冒烟测试通过即代表主链路(素材→数量弹窗→配音→标题→确认生成→渲染完成)可用
|
||||
await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 全部完成后「下一步:选择封面」解锁,点击进入 Step 6
|
||||
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
|
||||
@@ -45,49 +45,60 @@ vi.mock("@/config/navigation", () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// mock antd icons
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
LogoutOutlined: () => <span data-testid="logout-icon" />,
|
||||
SettingOutlined: () => <span data-testid="setting-icon" />,
|
||||
UserOutlined: () => <span data-testid="user-icon" />,
|
||||
MenuOutlined: () => <span data-testid="menu-icon" />,
|
||||
}))
|
||||
// mock antd icons — 透传未显式 mock 的图标,避免 PointsBadge 等子组件引用新图标时报错
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const actual = (await vi.importActual<typeof import("@ant-design/icons")>(
|
||||
"@ant-design/icons",
|
||||
)) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
LogoutOutlined: () => <span data-testid="logout-icon" />,
|
||||
SettingOutlined: () => <span data-testid="setting-icon" />,
|
||||
UserOutlined: () => <span data-testid="user-icon" />,
|
||||
MenuOutlined: () => <span data-testid="menu-icon" />,
|
||||
}
|
||||
})
|
||||
|
||||
// mock antd components
|
||||
vi.mock("antd", () => ({
|
||||
Avatar: ({ children, className }: any) => (
|
||||
<span data-testid="mock-avatar" className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<div data-testid="mock-dropdown">
|
||||
{children}
|
||||
<div data-testid="dropdown-menu" style={{ display: "none" }}>
|
||||
{menu.items?.map((item: any, idx: number) => (
|
||||
<div key={idx} data-testid={`menu-item-${item.key}`} onClick={item.onClick}>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
// mock antd components — 用 importActual 透传未显式覆盖的组件(Popover/Button/Tag/Typography/Badge 等),
|
||||
// 避免 Header 子组件(PointsBadge)使用新 antd 导出时出现 "No xxx export is defined on the antd mock"
|
||||
vi.mock("antd", async () => {
|
||||
const actual = (await vi.importActual<typeof import("antd")>("antd")) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Avatar: ({ children, className }: any) => (
|
||||
<span data-testid="mock-avatar" className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<div data-testid="mock-dropdown">
|
||||
{children}
|
||||
<div data-testid="dropdown-menu" style={{ display: "none" }}>
|
||||
{menu.items?.map((item: any, idx: number) => (
|
||||
<div key={idx} data-testid={`menu-item-${item.key}`} onClick={item.onClick}>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Space: ({ children, className }: any) => (
|
||||
<div data-testid="mock-space" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Drawer: ({ title, open, children, onClose, placement }: any) =>
|
||||
open ? (
|
||||
<div data-testid="mock-drawer" data-placement={placement}>
|
||||
<div data-testid="drawer-title">{title}</div>
|
||||
<button data-testid="drawer-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
),
|
||||
Space: ({ children, className }: any) => (
|
||||
<div data-testid="mock-space" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
),
|
||||
Drawer: ({ title, open, children, onClose, placement }: any) =>
|
||||
open ? (
|
||||
<div data-testid="mock-drawer" data-placement={placement}>
|
||||
<div data-testid="drawer-title">{title}</div>
|
||||
<button data-testid="drawer-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}
|
||||
})
|
||||
|
||||
// mock CSS
|
||||
vi.mock("@/components/layout/Header.css", () => ({}))
|
||||
|
||||
@@ -8,20 +8,51 @@ import math
|
||||
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称)
|
||||
|
||||
POINTS_SCENES: dict[str, dict] = {
|
||||
"ai_voice": {"base_points": 1, "unit": "分钟", "name": "AI 配音"},
|
||||
"ai_voice": {
|
||||
"base_points": 1,
|
||||
"unit": "分钟",
|
||||
"name": "AI 配音",
|
||||
"description": "AI 配音每分钟消耗 1 积分(免费用户上浮 15%,会员 8~9 折)",
|
||||
},
|
||||
"ai_video": {
|
||||
"base_points": 3,
|
||||
"unit": "条",
|
||||
"name": "智能混剪",
|
||||
"extra_per_30s": 1,
|
||||
"description": "智能混剪每条 3 积分起,视频超过 30 秒后每 30 秒加 1 积分;免费用户每日 2 条免费额度",
|
||||
},
|
||||
"ai_digital_human": {"base_points": 15, "unit": "分钟", "name": "AI 数字人"},
|
||||
"voice_clone_train": {"base_points": 0, "unit": "次", "name": "声音克隆训练"},
|
||||
"voice_clone_synth": {"base_points": 1, "unit": "分钟", "name": "声音克隆合成"},
|
||||
"douyin_extract": {"base_points": 1, "unit": "次", "name": "抖音链接提取"},
|
||||
"ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案"},
|
||||
"ai_title": {"base_points": 1, "unit": "次", "name": "AI 标题生成"},
|
||||
"ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成"},
|
||||
"ai_digital_human": {
|
||||
"base_points": 15,
|
||||
"unit": "分钟",
|
||||
"name": "AI 数字人",
|
||||
"description": "AI 数字人每分钟消耗 15 积分",
|
||||
},
|
||||
"voice_clone_train": {
|
||||
"base_points": 0,
|
||||
"unit": "次",
|
||||
"name": "声音克隆训练",
|
||||
"description": "声音克隆训练免费(每用户限 1 个声音)",
|
||||
},
|
||||
"voice_clone_synth": {
|
||||
"base_points": 1,
|
||||
"unit": "分钟",
|
||||
"name": "声音克隆合成",
|
||||
"description": "克隆音色合成每分钟消耗 1 积分",
|
||||
},
|
||||
"douyin_extract": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "抖音链接提取",
|
||||
"description": "抖音文案提取每次 1 积分",
|
||||
},
|
||||
"ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案", "description": "AI 改写文案每次 1 积分"},
|
||||
"ai_title": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "AI 标题生成",
|
||||
"description": "AI 生成标题每次 1 积分(免费用户实际上浮后 2 积分/次)",
|
||||
},
|
||||
"ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成", "description": "AI 封面生成每张 1 积分"},
|
||||
}
|
||||
|
||||
# 免费用户积分消耗上浮系数
|
||||
|
||||
@@ -120,6 +120,29 @@ wait_tcp_ready() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- 幂等容器清理(无论成功/失败/被取消都回收临时 PG、Redis,杜绝泄漏)---
|
||||
# 背景:服务容器用 docker run -d 起在宿主机上,仅在脚本走到结尾时清理;
|
||||
# job 失败(set -e)或被取消(SIGTERM)时会永久残留,堆积压垮构建机。
|
||||
cleanup_containers() {
|
||||
# 清理过程自身不能再次触发退出,避免掩盖原始退出码
|
||||
set +e
|
||||
if [ -n "${PG_CONTAINER:-}" ]; then
|
||||
docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 && echo "✅ 已清理PG容器: $PG_CONTAINER"
|
||||
fi
|
||||
if [ -n "${REDIS_CONTAINER:-}" ]; then
|
||||
docker rm -f "$REDIS_CONTAINER" >/dev/null 2>&1 && echo "✅ 已清理Redis容器: $REDIS_CONTAINER"
|
||||
fi
|
||||
}
|
||||
on_exit() {
|
||||
local code=$?
|
||||
cleanup_containers
|
||||
exit "$code"
|
||||
}
|
||||
# 必须在启动任何服务容器之前注册;INT/TERM 覆盖 Gitea 取消任务场景
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# --- 启动 Redis ---
|
||||
echo ""
|
||||
echo "=== 启动 Redis ==="
|
||||
@@ -302,14 +325,10 @@ conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
else
|
||||
# 清理临时PG容器
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo "✅ PG容器已清理"
|
||||
# 临时PG容器由 EXIT trap 的 cleanup_containers 统一回收(失败/取消也保证清理)
|
||||
:
|
||||
fi
|
||||
|
||||
# 清理Redis容器
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
echo "✅ Redis容器已清理"
|
||||
# 临时Redis容器同样由 EXIT trap 统一回收
|
||||
|
||||
# --- 覆盖率汇总 ---
|
||||
echo ""
|
||||
|
||||
@@ -16,6 +16,17 @@ CONTAINER_NAME="staging-${MODE}-$$"
|
||||
# 强制清理可能残留的同名容器
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# 任何退出路径(成功/失败/被取消 SIGTERM)都回收容器,杜绝 staging 测试容器泄漏
|
||||
cleanup_container() {
|
||||
local code=$?
|
||||
set +e
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 && echo "✅ 已清理容器: $CONTAINER_NAME"
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup_container EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
if [ "$MODE" = "e2e" ]; then
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
@@ -44,7 +55,5 @@ docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
|
||||
# 清理容器
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# 容器由 EXIT trap 的 cleanup_container 统一回收(失败/取消也保证清理)
|
||||
exit "$EXIT_CODE"
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""积分/会员 API 路由对齐测试 — fix/1895-points-api-align
|
||||
|
||||
覆盖:
|
||||
- P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at
|
||||
- P0-2: POST /points/check 未知 scene_key 返回 400(非 500)
|
||||
- P1-3: GET /points/rules 返回 description 字段
|
||||
- P1-6: GET /subscription/plans 返回档位列表
|
||||
- P1-7: multiplier 实际扣费一致(calculate_points_cost 统一应用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _make_cu(user_id="user-1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
cu.user.member_type = member_type
|
||||
cu.user.member_expires_at = None
|
||||
return cu
|
||||
|
||||
|
||||
# ── P0-1: recharge response fields ────────────────────────────────────
|
||||
|
||||
|
||||
class TestRechargeOrderResponse:
|
||||
def test_recharge_returns_pay_params_points_amount_expire_at(self):
|
||||
"""recharge 响应必须包含 pay_params / points_amount / expire_at。"""
|
||||
from app.api.routes.points import create_recharge_order
|
||||
from app.schemas.points import PointsRechargeRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.create_order.return_value = {
|
||||
"id": "order-1",
|
||||
"order_type": "points",
|
||||
"product_code": "starter_pack",
|
||||
"amount_cents": 990,
|
||||
"status": "pending",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsRechargeRequest(package_id="starter_pack")
|
||||
|
||||
before = datetime.now(UTC)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = create_recharge_order(body=body, current_user=cu, db=db)
|
||||
after = datetime.now(UTC) + timedelta(hours=48)
|
||||
|
||||
assert resp.points_amount == 100 # starter_pack 100 分
|
||||
assert isinstance(resp.pay_params, dict)
|
||||
assert resp.expire_at is not None
|
||||
expire_dt = datetime.fromisoformat(resp.expire_at)
|
||||
assert expire_dt >= before + timedelta(hours=47, minutes=55)
|
||||
assert expire_dt <= after
|
||||
|
||||
def test_recharge_invalid_package_returns_400(self):
|
||||
from app.api.routes.points import create_recharge_order
|
||||
from app.schemas.points import PointsRechargeRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.create_order.side_effect = ValueError("invalid package")
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsRechargeRequest(package_id="nonexistent")
|
||||
|
||||
with pytest.raises(HTTPException) as exc, patch(
|
||||
"app.api.routes.points._get_service", return_value=svc
|
||||
):
|
||||
create_recharge_order(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── P0-2: check unknown scene → 400 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckPointsUnknownScene:
|
||||
def test_unknown_scene_returns_400_not_500(self):
|
||||
"""未知 scene_key(如 ai_script)应返回 400 UNKNOWN_SCENE,而不是 500。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_script", quantity=1)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_points(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
detail = exc.value.detail
|
||||
assert detail["code"] == "UNKNOWN_SCENE"
|
||||
assert "ai_script" in detail["message"]
|
||||
assert "ai_voice" in detail["valid_scenes"]
|
||||
assert "ai_title" in detail["valid_scenes"]
|
||||
|
||||
def test_known_scene_still_works(self):
|
||||
"""合法 scene_key 正常返回,免费用户 ai_voice 1 分钟 = 2 积分。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 50}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
|
||||
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
|
||||
assert resp.current_balance == 50
|
||||
assert resp.allowed is True
|
||||
|
||||
|
||||
# ── P1-3: rules include description ───────────────────────────────────
|
||||
|
||||
|
||||
class TestPointsRulesDescription:
|
||||
def test_rules_have_description_field(self):
|
||||
from app.api.routes.points import get_rules
|
||||
|
||||
resp = get_rules(_current_user=_make_cu())
|
||||
assert len(resp.rules) >= 9
|
||||
for rule in resp.rules:
|
||||
assert rule.description, f"{rule.scene_key} missing description"
|
||||
assert isinstance(rule.description, str)
|
||||
assert len(rule.description) > 0
|
||||
|
||||
def test_free_user_multiplier_returned(self):
|
||||
from app.api.routes.points import get_rules
|
||||
|
||||
resp = get_rules(_current_user=_make_cu())
|
||||
assert resp.free_user_multiplier == 1.15
|
||||
|
||||
|
||||
# ── P1-6: GET /subscription/plans ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubscriptionPlans:
|
||||
@staticmethod
|
||||
def _import_plans_fn():
|
||||
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
|
||||
import importlib.util
|
||||
_route_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"_real_subscription_routes", os.path.abspath(_route_path)
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
# inject settings before exec
|
||||
import os as _os
|
||||
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
|
||||
_spec.loader.exec_module(_mod)
|
||||
return _mod.list_membership_plans
|
||||
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
import os # noqa: F401 (used by _import_plans_fn)
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
plan_ids = {p["plan_id"] for p in plans}
|
||||
assert plan_ids == {"monthly", "quarterly", "yearly"}
|
||||
for p in plans:
|
||||
assert p["price_cents"] > 0
|
||||
assert p["duration_days"] in (30, 90, 365)
|
||||
assert 0 < p["points_discount"] <= 1.0
|
||||
assert "max_resolution" in p["features"]
|
||||
|
||||
def test_longer_plans_cheaper_per_month(self):
|
||||
import os # noqa: F401
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
monthly = next(p for p in plans if p["plan_id"] == "monthly")
|
||||
quarterly = next(p for p in plans if p["plan_id"] == "quarterly")
|
||||
yearly = next(p for p in plans if p["plan_id"] == "yearly")
|
||||
assert monthly["monthly_price_cents"] == 1990
|
||||
assert quarterly["monthly_price_cents"] < monthly["monthly_price_cents"]
|
||||
assert yearly["monthly_price_cents"] < quarterly["monthly_price_cents"]
|
||||
|
||||
|
||||
# ── P1-7: multiplier consistency ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiplierConsistency:
|
||||
def test_free_user_ai_title_costs_2(self):
|
||||
"""ai_title base=1,免费用户 ceil(1*1.15)=2。"""
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_title", is_member=False, quantity=1) == 2
|
||||
|
||||
def test_check_matches_direct_calculation(self):
|
||||
"""check 端点 required_points 与 calculate_points_cost 结果一致。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 999}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1)
|
||||
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|
||||
Reference in New Issue
Block a user