This commit is contained in:
2026-07-11 13:00:06 +08:00
parent 6cc1655c69
commit 344fb789a4
82 changed files with 2594 additions and 7930 deletions
+813
View File
@@ -0,0 +1,813 @@
# VideoGen 代码架构指南
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
# AI 工作规则(最高优先级)
当你执行开发任务时:
1. 不要扫描整个 Repository。
2. 不要执行全量目录探索。
3. 仅阅读本任务涉及的目录。
4. 优先按照本文档中的"快速定位表"寻找文件。
5. 如果无法定位文件,再进行有限范围搜索。
6. 单次最多搜索一级目录。
7. 不允许重复搜索已经访问过的目录。
8. 阅读完目标文件后立即开始修改代码。
---
## 一、整体架构
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI)
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
```
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
---
## 二、后端架构 (video-gen-api)
### 2.1 目录结构
```
app/
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
│ │ ├── __init__.py # 聚合所有 v1 router
│ │ ├── auth.py
│ │ ├── projects.py
│ │ ├── generation.py
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1
│ │ └── ...
│ └── admin/ # 额外的管理端细分路由
│ ├── __init__.py
│ ├── team.py
│ ├── home_material.py
│ └── ...
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
│ ├── generation.py
│ ├── auth.py
│ └── ...
├── models/ # SQLAlchemy ORM 模型(41 个)
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
│ ├── user.py
│ └── ...
├── services/ # 业务逻辑层(核心业务写在这里)
│ ├── auth.py # JWT 认证、密码哈希
│ ├── credits.py # 积分扣减
│ ├── operation_log.py # 操作日志
│ └── ...
├── enums/ # 枚举定义(全部用 str, Enum
│ ├── generation_status.py
│ ├── celery_queue.py
│ └── ...
├── tasks/ # Celery 异步任务
│ ├── celery_app.py # Celery 实例 + 配置
│ ├── cleanup.py
│ └── ...
├── middleware/ # FastAPI 中间件
│ ├── logging.py
│ ├── rate_limit.py
│ ├── request_encrypt.py
│ ├── anti_crawler.py
│ └── ...
├── utils/ # 通用工具
│ ├── id_gen.py # generate_id(), generate_order_no()
│ ├── exceptions.py # 自定义 HTTPException
│ └── security.py # AES 加密、HMAC
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
├── config.py # Pydantic Settings,读取 .env
└── main.py # FastAPI 应用入口、lifespan、种子数据
```
### 2.2 分层约定(重要)
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
```
Route (api/) → Service (services/) → Model (models/)
解析参数 业务逻辑 数据库操作
调用 service 编排调用 纯 CRUD
返回序列化结果 不直接操作 ORM 无业务规则
```
**禁止:**
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
- Model 文件中写业务逻辑 → model 只定义字段和关系
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
### 2.3 SQLAlchemy 模型约定
使用 SQLAlchemy 2.0 风格的 `mapped_column`
```python
# app/models/project.py — 标准模板
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class Project(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(128))
industry: Mapped[str] = mapped_column(String(32))
```
**约定:**
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
- 外键统一加 `index=True``ondelete="CASCADE"` 按需
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)`
- 时间统一用 `DateTime(timezone=True)`
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
```python
# app/models/base.py — 公共 Mixin
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
```
### 2.4 Pydantic Schema 约定
```python
# app/schemas/project.py
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
name: str = Field(..., max_length=128)
industry: str = Field(..., max_length=64)
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
id: str
name: str
industry: str
created_at: NaiveDatetime
updated_at: NaiveDatetime
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
```
**命名约定:**
- `XxxCreate` — POST 创建请求
- `XxxUpdate` — PUT 更新请求
- `XxxOut` / `XxxResponse` — 响应
- `XxxListOut` — 列表响应(含 items + total
- `XxxRequest` — 通用请求
**公共工具:**
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
- `PaginatedResponse` — 分页响应基类
### 2.5 API 路由约定
```python
# app/api/v1/projects.py — 标准模板
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectOut
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
async def list_projects(
current_user: User = Depends(get_current_user), # 鉴权依赖
db: AsyncSession = Depends(get_db), # 数据库 session
):
result = await db.execute(
select(Project).where(
Project.user_id == current_user.id,
Project.deleted_at.is_(None), # 软删除过滤
).order_by(Project.created_at.desc())
)
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
@router.post("", response_model=ProjectOut)
async def create_project(
req: ProjectCreate, # 请求体自动校验
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project = Project(
id=generate_id(), # 主键手工生成
user_id=current_user.id,
name=req.name,
industry=req.industry,
)
db.add(project)
await db.flush() # flush 获取自增字段但不出事务
return project
```
**关键约定:**
- `prefix` 不含 `/api``/api``main.py``include_router` 统一加)
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
- 管理员接口用 `Depends(get_admin_user)`
- 可选登录用 `Depends(get_optional_current_user)`
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`
### 2.6 依赖注入约定
```python
# app/dependencies.py
get_db # 生成 AsyncSession,自动 commit/rollback
get_current_user # 验证 JWT → 返回 User,要求密码已设置
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
get_optional_current_user # 验证 JWT → User | None(不强制登录)
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin
```
### 2.7 服务层约定
```python
# app/services/team_service.py — 标准模板
from __future__ import annotations # 前向引用必需的 import
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.schemas.team import TeamCreate, TeamUpdate
from app.utils.id_gen import generate_id
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
team = Team(
id=generate_id(),
name=req.name,
code=req.code,
description=req.description,
)
db.add(team)
await db.flush()
return team
```
**约定:**
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
-`from __future__ import annotations` 支持前向引用
- 纯查询类服务可以接收 `db` 作为第一个参数
### 2.8 枚举约定
```python
# app/enums/generation_status.py
from enum import Enum
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
video = "video"
image = "image"
```
**约定:**
- 所有枚举继承 `str, Enum`(可序列化为 JSON
- 枚举值用 snake_case
- 枚举文件统一放 `app/enums/`
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
### 2.9 异常处理约定
```python
# app/utils/exceptions.py — 预定义异常
InsufficientCreditsError # 402 积分不足
CaptchaFailedError # 400 验证码失败
RecordNotFoundError # 404 记录不存在
ProjectNotFoundError # 404 项目不存在
InvalidStatusError # 400 状态不允许操作
# 在 service 或 route 中抛出
raise InsufficientCreditsError()
```
### 2.10 认证约定
```python
# Token 创建
create_access_token(user_id, remember_me=False) str # JWT 令牌
# Token 解码
decode_access_token(token) str | None # 返回 user_id 或 None
# 密码
hash_password(plain) str # bcrypt 哈希
verify_password(plain, hashed) bool # bcrypt 校验
```
**鉴权流程:**
1. 前端 Authorization header: `Bearer <token>`
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
3. 查 User 表验证 `is_active`
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
### 2.11 数据库 Session 模式
```python
# dependencies.py 中的 get_db — 自动管理事务
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit() # 成功自动提交
except Exception:
await session.rollback() # 异常自动回滚
raise
finally:
await session.close()
```
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
### 2.12 中间件约定
中间件按**注册顺序**从外到内执行(`main.py`):
```python
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
application.add_middleware(CORSMiddleware, ...) # 5. CORS
```
**加密中间件行为:**
- 请求有 `X-Encrypted: true` header → 解密请求体
- GET 请求无 body 但**响应仍加密**
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
### 2.13 Celery 任务约定
```python
# app/tasks/cleanup.py — 标准模板
from app.tasks.celery_app import celery_app
@celery_app.task # 装饰器注册任务
def cleanup_expired_video_urls():
"""Run hourly. Clear expired video URL tokens."""
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
from app.models.base import async_session # 延迟导入避免循环
async with async_session() as db:
...
await db.commit()
```
**约定:**
- 任务装饰器: `@celery_app.task`
- 同步入口 → `asyncio.run()` 包异步逻辑
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`
- 队列路由在 `celery_app.py``task_routes` 配置
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
### 2.14 配置约定
```python
# app/config.py — 基于 pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
SECRET_KEY: str = "change-me"
# ... 所有配置项有默认值,生产通过 .env 覆盖
settings = Settings() # 全局单例
```
**约定:**
- 所有环境变量在 `config.py` 中有类型注解和默认值
- 通过 `settings.XXX` 全局访问
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
- 列表值用 `list[str]`pydantic 自动解析 JSON 数组字符串)
### 2.15 操作日志约定
```python
from app.services.operation_log import log_operation
# 在路由中调用(通常在变更操作后)
await log_operation(
db,
admin.id,
admin.username,
f"创建团队 {team.name}", # action 描述
"POST", # HTTP method
"/admin/teams", # path
detail=None, # 可选详情
ip=None, # 可选 IP
)
```
---
## 三、前端架构 (video-gen-app & video-gen-admin)
### 3.1 相同点
两个前端项目遵循完全相同的架构约定:
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
- Zustand 状态管理
- 统一的 API 调用层(crypto/client/index 三层)
- 路由结构 SPAreact-router-dom v7
### 3.2 目录结构(以 video-gen-app 为例)
```
src/
├── api/ # API 调用层
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
├── store/ # Zustand 状态
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
│ └── useAppStore.ts # 应用状态
├── types/ # TypeScript 类型定义
│ └── index.ts # 所有 interface/type
├── pages/ # 页面组件(路由对应)
│ ├── LoginPage.tsx
│ ├── ProjectsPage.tsx
│ └── ...
├── components/ # 可复用组件
│ ├── Layout/
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content
│ │ └── AppLayout.css
│ ├── privatePortrait/
│ └── ...
├── utils/ # 工具函数
│ ├── formatDate.ts
│ └── ...
├── App.tsx # 路由定义 + 全局配置
└── main.tsx # 入口
```
### 3.3 API 调用约定(核心)
```typescript
// ── client.ts — 核心请求函数 ──
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
// 调用参数:
interface RequestOptions {
method?: string // GET/POST/PUT/DELETE,默认 GET
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
auth?: boolean // 是否带 Authorization header,默认 true
encryptBody?: boolean // 是否加密,默认跟随全局开关
signal?: AbortSignal // 取消请求
skipAuthRedirect?: boolean // 401 时不跳转登录页
}
```
```typescript
// ── index.ts — 业务 API 函数 ──
// 命名约定:动词 + 业务名,camelCase
export async function login(username: string, password: string): Promise<User> {
if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>(
'/auth/login',
{ username, password },
false, // auth: false(登录不需要 token
true // encryptBody: true(登录凭证需要加密)
);
setToken(res.accessToken);
return res.user;
}
// GET 列表
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
return api.get<Project[]>('/projects');
}
// POST 创建
export async function createProject(name: string, industry: Industry): Promise<Project> {
if (USE_MOCK) return mock.mockCreateProject(name, industry);
return api.post<Project>('/projects', { name, industry });
}
// 带查询参数的 GET
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
const query = new URLSearchParams();
if (params.projectId) query.set('project_id', params.projectId);
query.set('page', String(page));
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
}
```
**关键约定:**
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
- 所有 id 字段前端用 `string`(不转 number
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`user_id → userId
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
### 3.4 状态管理约定(Zustand
```typescript
// store/useAuthStore.ts — 标准模板
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api';
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: true,
login: async (username, password) => {
const user = await api.login(username, password);
set({ user });
},
// ...
}));
```
**约定:**
- 全局状态用 Zustand `create`,不用 Redux
- API 调用写在 store actions 或页面中,**不要**写在 components 里
- 状态更新用 `set()`,异步操作加 `async/await`
- 组件中消费: `const { user, login } = useAuthStore()`
### 3.5 页面组件约定
```typescript
// pages/ProjectsPage.tsx — 标准模板
import React, { useEffect, useState } from 'react';
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { getIndustries } from '../api';
import type { IndustryConfig } from '../types';
const ProjectsPage: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const loadProjects = async () => {
setLoading(true);
try {
const data = await getProjects(); // 调 API
setProjects(data);
} catch (e: any) {
message.error(e?.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
return (
<div>
<Typography.Title level={4}></Typography.Title>
{/* ... */}
</div>
);
};
export default ProjectsPage;
```
**约定:**
- 页面组件是 `React.FC`,默认导出
- 业务数据类型从 `../types` 导入(`import type`
- API 函数从 `../api` 导入
- 不用 `axios`,所有请求走 `api/index.ts`
-`message.success/error/warning` 显示操作反馈
-`useNavigate()` 编程式导航
- 列表加载用 `loading` 状态 + `Spin` 组件
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
### 3.6 路由约定
```typescript
// App.tsx — 路由结构
// 公开路由
<Route path="/login" element={<LoginPage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
// 受保护路由(需要登录)
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
</Route>
```
**约定:**
- 路由定义集中在 `App.tsx`
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`
- 未登录用户访问受保护路由 → 跳 `/login`
- 动态路由参数用 `:paramName`
- 嵌套路由用 `<Outlet />` 渲染子页面
### 3.7 TypeScript 类型约定
```typescript
// types/index.ts — 全局类型定义
export interface User {
id: string; // 所有 ID 都是 string
username: string;
credits: number;
// ...
}
```
**约定:**
- 全局共享类型在 `types/index.ts` 定义
- 类型/接口名 PascalCase + 业务含义
- 所有 ID、外键字段类型为 `string`
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
### 3.8 加密约定
```typescript
// crypto.ts
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
// 前端加密流程:
// 1. apiRequest 中如果 encryptBody=true:
// body → JSON.stringify → encrypt() → { data: "<密文>" }
// headers['X-Encrypted'] = 'true'
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
// decrypt(parsed.data) → JSON.parse → 返回
//
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
```
### 3.9 Mock 模式约定
`VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
```typescript
// api/index.ts 中每个 API 函数都检查 USE_MOCK
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
return api.get<Project[]>('/projects'); // ← 后走真实请求
}
```
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
---
## 四、跨项目约定(前后端协作)
### 4.1 字段名转换
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|--------------|-------------------|------|
| `snake_case` | `camelCase` | client.ts 自动转 |
| `created_at` | `createdAt` | 自动 |
| `user_id` | `userId` | 自动 |
**新增 API 时**:
- 后端 request schema 字段用 `snake_case`
- 前端 types/interface 字段用 `camelCase`
- **不需要**手动转,`client.ts``keysToCamel()` 递归转换所有 key
### 4.2 API 路径约定
```
/api/auth/login → 登录(公开)
/api/projects → 项目管理(需登录)
/api/admin/users → 用户管理(需管理员)
/admin/teams → 团队管理(需管理员,二级路由)
```
- `/api/` 前缀统一
- 管理接口路径含 `/admin/`
- 路径用小写 + 短横线(kebab-case
### 4.3 分页约定
后端返回:
```python
@router.get("", response_model=GenerationRecordPageListOut)
# { items: [...], total: 100, page: 1, page_size: 20 }
```
前端类型:
```typescript
export interface GenerationRecordPageListOut {
page: number;
pageSize: number;
total: number;
items: GenerationRecord[];
}
```
### 4.4 错误处理约定
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`
常见错误码:
- `401` — token 无效/过期 → 自动跳 `/login`
- `403` — 权限不足
- `404` — 资源不存在
- `402` — 积分不足(`InsufficientCreditsError`
- `400` — 参数错误
---
## 五、修改检查清单
### 新增后端接口时 ✅
- [ ]`app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`
- [ ]`app/services/` 中实现业务逻辑函数
- [ ]`app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`
- [ ] 确认 `__init__.py` 中注册了新 router
- [ ] 复杂操作记录 `log_operation()`
- [ ] 写入操作后正确 `flush()` + `commit()`
### 新增后端 Model 时 ✅
- [ ]`app/models/` 中新建或编辑 model 文件
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
- [ ] 主键用 `generate_id()`,外键加 `index=True`
- [ ]`app/models/__init__.py` 中 import
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
- [ ] 执行 `alembic upgrade head` 应用迁移
### 新增前端页面/功能时 ✅
- [ ]`types/index.ts` 中定义/补充类型
- [ ]`api/index.ts` 中添加 API 函数(含 mock 分支)
- [ ]`pages/` 中新建页面组件(`React.FC` + 默认导出)
- [ ]`App.tsx` 中注册路由
- [ ] 受保护路由包裹 `<ProtectedRoute>`
- [ ]`mock.ts` 中添加对应 mock 数据函数
---
## 六、常见反模式(应避免)
| ❌ 反模式 | ✅ 正确做法 |
|----------|-----------|
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
| Model 中有业务逻辑 | Model 只定义字段和关系 |
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
| 新增 Model 不加到 `__init__.py` | 立即 import |
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
| 密码明文存储 | bcrypt 哈希(通过 `hash_password` |
---
## 七、快速定位表
| 想修改什么 | 去哪里 |
|-----------|--------|
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
| 改业务规则 | `app/services/xxx.py` |
| 改枚举值 | `app/enums/xxx.py` |
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
| 改配置项 | `app/config.py` + `.env.example` |
| 改中间件行为 | `app/middleware/xxx.py` |
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py``task_routes` |
+245 -70
View File
@@ -5,11 +5,13 @@
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
└── DEPLOYMENT.md # 本文档
```
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
---
## 一、环境要求
@@ -18,14 +20,13 @@ video_item/
|------|---------|------|
| Python | >= 3.10 | 推荐 3.12 |
| Node.js | >= 18 | 推荐 20+ |
| PostgreSQL | >= 14 | 推荐 16 |
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
| PostgreSQL | >= 14 | 推荐 16**必须** |
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
---
@@ -34,7 +35,7 @@ video_item/
### 1. 安装依赖
```bash
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
# CentOS/RHEL
sudo yum install -y ca-certificates
# Ubuntu/Debian
@@ -49,7 +50,10 @@ python -m venv .venv
# Linux/Mac
source .venv/bin/activate
# 安装基础依赖 + PostgreSQL 驱动
# 安装基础依赖
pip install -e .
# 安装 PostgreSQL 驱动(生产环境必须)
pip install -e ".[pg]"
# 如需 Redis 支持(限流、验证码、Celery)
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
# 如需 Celery 异步任务(ChatAPI 生成流水线)
pip install -e ".[pg,redis,celery]"
#安装阿里支付sdk
# 如需支付宝
pip install -e ".[pg,redis,celery,alipay]"
# 安装微信支付sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
#安装火山sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
# 如需微信支付
pip install -e ".[pg,redis,celery,alipay,wxpay]"
# 如需火山引擎 SDK(短信等)
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
```
### 2. 配置环境变量
复制 `.env.example``.env`,修改以下关键配置:
复制 `.env.example``.env`,修改关键配置:
```bash
cp .env.example .env
@@ -81,13 +83,14 @@ cp .env.example .env
```ini
# ── 基础配置 ──
APP_NAME=VideoGen API
APP_VERSION=1.0.0
DEBUG=false
SECRET_KEY=改成一个随机的长字符串
SECRET_KEY=改成一个随机的长字符串JWT 签名密钥)
# ── 数据库 ──
# ── 数据库(必须) ──
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
# ── Redis(可选,留空则禁用限流验证码) ──
# ── Redis(可选,留空则禁用限流/验证码/Celery ──
REDIS_URL=redis://localhost:6379/0
# ── JWT ──
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
# ── LLM 提示词优化 ──
LLM_MOCK=false
LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY=
LLM_MODEL=gpt-4o
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
ENCRYPTION_KEY=你的32字节base64密钥
# ── 存储路径 ──
# ── 存储路径(本地存储) ──
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./storage/generate/videos
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
UPLOAD_LOCAL_PATH=./storage/uploads
# ── 跨域(生产环境务必限制域名) ──
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有 ──
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
# ── 回调基础地址 ──
BASE_URL=https://你的域名
# ── 验证码 ──
CAPTCHA_ENABLED=true
# ── 短信(火山引擎 SDKSMS_MOCK=false 时生效) ──
SMS_MOCK=true
VOLC_SMS_ACCESS_KEY_ID=
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
# ── 支付(PAYMENT_MOCK=false 时生效) ──
PAYMENT_MOCK=true
WECHAT_MCH_ID=
WECHAT_API_KEY=
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
ALIPAY_NOTIFY_URL=
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
CELERY_BROKER_URL=redis://localhost:6379/5
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
FFMPEG_BIN=/usr/bin/ffmpeg
```
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
### 3. 初始化数据库
```bash
# 创建 PostgreSQL 数据库
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
# 启动后端(首次启动自动建表 + 填充种子数据
# 启动后端(首次启动自动建表)
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
首次启动会自动完成:
- 创建所有数据表(23 个 model)
- 创建管理员账号:`admin` / `123456`
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
首次启动时会**自动创建所有数据表**(41 个 model)。
**首次部署后务必修改默认密码。**
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
>
> **如果你需要种子数据,**有以下选择:
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
> 2. 手动通过 API 或数据库脚本创建管理员账号
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
### 4. 数据库迁移 (Alembic)
项目使用 Alembic 管理数据库结构变更`env.py` 已导入全部 20 个 model
项目使用 Alembic 管理数据库结构变更。
```bash
cd video-gen-api
@@ -179,17 +197,13 @@ python -m alembic history
python -m alembic downgrade -1
```
**部署流程** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
### 5. 生产运行
```bash
# 方式一:直接运行(推荐 4 workers
# 直接运行(推荐 4 workers
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# 方式二:使用 systemd (Linux)
```
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`
@@ -220,11 +234,22 @@ sudo systemctl start videogen-api
### 6. Celery Worker(可选)
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
Celery 使用 **6 个队列**,按功能分离:
| 队列 | 用途 |
|------|------|
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
| `gen_provider_poll` | 轮询火山引擎生成状态 |
| `gen_result_download` | 下载生成的视频/图片结果 |
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
| `gen_private_portrait` | 真人素材认证与同步 |
| `default` | 默认队列(用户 OAuth、清理任务等) |
```bash
# 启动 Worker处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
# 启动 Worker消费所有队列
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
```
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`
@@ -239,7 +264,7 @@ Type=simple
User=www-data
WorkingDirectory=/opt/video-gen-api
Environment=PATH=/opt/video-gen-api/.venv/bin
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
Restart=always
RestartSec=5
@@ -249,23 +274,25 @@ WantedBy=multi-user.target
### 7. Docker 部署(可选)
项目提供 `Dockerfile``docker-compose.yml`,一键启动完整环境:
项目提供 `Dockerfile``docker-compose.yml`
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
```bash
cd video-gen-api
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
# 使用前需修改 Dockerfile 第 6 行为:
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
docker compose up -d
```
启动的服务:
| 服务 | 端口 | 说明 |
|------|------|------|
| api | 8000 | FastAPI 应用(`--reload`开发模式) |
| worker | - | Celery Worker |
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
| worker | - | Celery Worker(消费所有队列) |
| postgres | 5432 | PostgreSQL 16 |
| redis | 6379 | Redis 7 |
@@ -276,7 +303,7 @@ server {
listen 80;
server_name api.yourdomain.com;
# 上传文件大小限制
# 上传文件大小限制(视频上传需要较大值)
client_max_body_size 100M;
location / {
@@ -300,6 +327,8 @@ server {
## 三、前台部署 (video-gen-app)
用户前台,面向最终用户。
### 1. 安装依赖 & 构建
```bash
@@ -310,8 +339,8 @@ npm install
# 配置 API 地址(创建 .env.production
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
@@ -319,7 +348,15 @@ npm run build
构建产物在 `dist/` 目录。
### 2. Nginx 配置
### 2. 前端环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -328,12 +365,12 @@ server {
root /opt/video-gen-app/dist;
index index.html;
# SPA 路由
# SPA 路由:所有页面请求回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
# 静态资源缓存(带 hash 的文件名可长期缓存)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
@@ -345,6 +382,8 @@ server {
## 四、后台管理部署 (video-gen-admin)
管理员后台,面向运营/管理人员。
### 1. 安装依赖 & 构建
```bash
@@ -356,13 +395,21 @@ npm install
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
```
### 2. Nginx 配置
### 2. 后台环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -456,6 +503,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 七、默认账号
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|------|--------|--------|------|------|
| 管理员 | admin | 13800000000 | 123456 | 10000 |
@@ -467,32 +516,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 八、架构说明
### 异步任务处理
### 后端启动时的后台任务
系统有两套异步任务机制
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务
| 机制 | 说明 | 依赖 |
|------|------|------|
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
| 任务 | 说明 |
|------|------|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
| `upload_queue` | 上传任务队列 |
| `material_consumption_queue` | 素材消耗队列 |
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
| `poll_pre_test_results` | 每分钟轮询前测结果 |
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
### 中间件栈(从外到内)
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
### 中间件栈(从外到内,即请求到达的顺序)
1. `RequestLoggingMiddleware` — 请求/响应日志记录
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
5. `CORSMiddleware` — 跨域
5. `CORSMiddleware` — 跨域expose `X-Encrypted` 响应头)
### 日志系统
### 前后端通信加密
| 日志类型 | 目录 | 控制方式 |
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
**生效条件:** 同时满足以下两个条件才启用加密:
- 后端 `.env``ENCRYPTION_KEY` 非空
- 前端 `.env``VITE_ENCRYPTION_KEY` 非空
- 运行在安全上下文(HTTPS 或 localhost
**加密范围:**
| 请求类型 | 请求体 | 响应体 |
|---------|--------|--------|
| GET(无 body) | 不加密(无内容) | **加密** |
| POST/PUT/DELETE(有 body | **加密** | **加密** |
**工作流:**
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
### 文件日志加密(独立机制)
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
| 日志类型 | 目录 | 加密方式 |
|---------|------|---------|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面
### 内部管理端点
| 端点 | 用途 |
|------|------|
| `/internal/` | 后端入口导航页 |
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
| `/internal/api-docs` | Swagger API 文档 |
| `/internal/api-redoc` | ReDoc API 文档 |
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
### 前端 Mock 模式
前端支持 Mock 数据模式,无需后端即可开发:
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
- 适用场景:纯前端开发、演示、无后端环境
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
### 存储路径
@@ -522,6 +623,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
│ ├── .env # 环境变量
│ ├── .venv/ # Python 虚拟环境
│ ├── app/ # 应用代码
│ │ ├── api/ # API 路由
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
│ │ │ └── admin/ # 管理端接口
│ │ ├── middleware/ # 中间件
│ │ ├── models/ # 数据模型 (41 个)
│ │ ├── services/ # 业务服务
│ │ ├── tasks/ # Celery 任务
│ │ ├── enums/ # 枚举定义
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移文件
│ ├── storage/
│ │ ├── generate/
@@ -565,4 +675,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
# 生成迁移文件
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
# 查看 Celery 活动任务
celery -A app.tasks.celery_app inspect active
```
---
## 十一、环境变量完整参考
### 后端 (video-gen-api/.env)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
| `STORAGE_TYPE` | 否 | local | 存储类型 |
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery |
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
### 前台 (video-gen-app/.env.production)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
### 后台管理 (video-gen-admin/.env.production)
同前台。
---
## 十二、故障排查
| 现象 | 可能原因 | 解决方案 |
|------|---------|---------|
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-3wUbVp5v.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
-2
View File
@@ -14,7 +14,6 @@ import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminModelPricingRules from './pages/AdminModelPricingRules';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminPaymentStats from './pages/AdminPaymentStats';
import AdminIndustries from './pages/AdminIndustries';
@@ -88,7 +87,6 @@ const App = () => {
<Route path="users" element={<AdminUsers />} />
<Route path="teams" element={<AdminTeams />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="model-pricing" element={<AdminModelPricingRules />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
-43
View File
@@ -13,7 +13,6 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
@@ -297,54 +296,12 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene);
setMaybe(params, 'engine_provider', filters?.engineProvider);
setMaybe(params, 'engine_model_name', filters?.engineModelName);
setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode);
setMaybe(params, 'provider_cost_status', filters?.providerCostStatus);
setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated);
setMaybe(params, 'has_attachment', filters?.hasAttachment);
setMaybe(params, 'start_date', filters?.startDate);
setMaybe(params, 'end_date', filters?.endDate);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
export async function getModelPricingRules(filters?: {
page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string;
}): Promise<ModelPricingRuleListResponse> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'provider', filters?.provider);
setMaybe(params, 'model_name', filters?.modelName);
setMaybe(params, 'model_category', filters?.modelCategory);
setMaybe(params, 'publish_status', filters?.publishStatus);
return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`);
}
export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise<ModelPricingRule> {
return api.post('/admin/model-pricing/rules', payload);
}
export async function updateModelPricingRule(id: string, payload: Partial<ModelPricingRulePayload>): Promise<ModelPricingRule> {
return api.put(`/admin/model-pricing/rules/${id}`, payload);
}
export async function publishModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/publish`);
}
export async function disableModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/disable`);
}
export async function previewModelPricing(payload: {
billing_mode: string; calculator_version: string; rule_json: Record<string, any>; usage: Record<string, any>; currency?: string;
}): Promise<ModelPricingPreviewResponse> {
return api.post('/admin/model-pricing/preview', payload);
}
export async function getIndustryConfigs(): Promise<any[]> {
return api.get('/admin/industry-configs');
}
@@ -1,486 +0,0 @@
import React, { useEffect } from 'react';
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd';
import dayjs from 'dayjs';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingRule,
ModelPricingRulePayload,
} from '../../types';
interface Props {
initial?: ModelPricingRule | null;
loading?: boolean;
onSubmit: (payload: ModelPricingRulePayload) => Promise<void> | void;
onCancel: () => void;
}
const modeOptions = [
{ value: 'text_token_tiered', label: '文本分档 Token 计价' },
{ value: 'image_per_output', label: '按输出图片数量计价' },
{ value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' },
{ value: 'video_token_rate', label: '视频像素 Token 计价' },
];
const calculatorByMode: Record<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
text_token_tiered: 'text_token_tiered_v1',
image_per_output: 'image_per_output_v1',
image_input_output_tiered: 'image_input_output_tiered_v1',
video_token_rate: 'video_pixel_token_v1',
};
const billByOptions = [
{ value: 'successful_output_count', label: '实际成功输出数' },
{ value: 'requested_output_count', label: '请求输出数(估算)' },
{ value: 'provider_billed_count', label: '供应商明确计费数' },
];
function splitList(value?: string): string[] {
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。
* 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。
*/
function normalizeRuleJson(
mode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (mode === 'text_token_tiered') {
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})),
};
}
if (mode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (mode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
function dimensionRows(rule: Record<string, any>): Array<Record<string, any>> {
const rows: Array<Record<string, any>> = [];
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
const item = value as Record<string, any>;
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
});
});
return rows;
}
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
const normalized = normalizeRuleJson(mode, rule || {});
if (mode === 'text_token_tiered') {
return {
tiers: normalized.tiers || [],
cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0,
};
}
if (mode === 'image_per_output') {
return {
outputRate: normalized.output_rate ?? 0,
billBy: normalized.bill_by || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
freeInputImages: normalized.free_input_images ?? 1,
inputImageRate: normalized.input_image_rate ?? 0,
outputTiers: normalized.output_tiers || [],
billBy: normalized.bill_by || 'successful_output_count',
};
}
return {
tokenFormula: normalized.token_formula
|| '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
supportedResolutions: (normalized.supported_resolutions || []).join(','),
defaultFps: normalized.default_fps ?? 30,
dimensionRows: dimensionRows(normalized),
rates: (normalized.rates || []).map((r: any) => ({
...r,
resolutions: (r.resolutions || []).join(','),
inferenceModes: (r.inference_modes || []).join(','),
hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false',
generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false',
})),
};
}
function buildDimensionMap(rows: Array<Record<string, any>>): Record<string, any> {
const result: Record<string, any> = {};
(rows || []).forEach(row => {
const resolution = String(row.resolution || '').trim().toLowerCase();
const aspectRatio = String(row.aspectRatio || '').trim();
const width = Number(row.width || 0);
const height = Number(row.height || 0);
if (!resolution || !aspectRatio || width <= 0 || height <= 0) return;
result[resolution] ||= {};
result[resolution][aspectRatio] = { width, height };
});
return result;
}
function buildRuleJson(
mode: ModelPricingBillingMode,
pricing: Record<string, any>,
baseRule: Record<string, any>,
): Record<string, any> {
const base = normalizeRuleJson(mode, baseRule || {});
if (mode === 'text_token_tiered') {
return {
...base,
unit: 'CNY_per_million_tokens',
cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0),
tiers: (pricing.tiers || []).map((v: any) => ({
max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined
? null
: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'),
input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0),
cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
cached_audio_input_rate: String(
readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate')
?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate')
?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate')
?? readAlias(v || {}, 'input_rate', 'inputRate')
?? 0,
),
})),
};
}
if (mode === 'image_per_output') {
return {
...base,
unit: 'CNY_per_image',
output_rate: String(pricing.outputRate ?? 0),
bill_by: pricing.billBy || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
...base,
unit: 'CNY_per_image',
free_input_images: Number(pricing.freeInputImages || 0),
input_image_rate: String(pricing.inputImageRate ?? 0),
output_tiers: (pricing.outputTiers || []).map((v: any) => ({
max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === null
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === ''
? null
: Number(readAlias(v || {}, 'max_pixels', 'maxPixels')),
rate: String(v.rate ?? 0),
})),
bill_by: pricing.billBy || 'successful_output_count',
};
}
return {
...base,
unit: 'CNY_per_million_tokens',
token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
default_fps: Number(pricing.defaultFps || 30),
supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()),
dimension_map: buildDimensionMap(pricing.dimensionRows || []),
rates: (pricing.rates || []).map((v: any) => ({
...(splitList(v.resolutions).length
? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) }
: {}),
...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined
? { has_input_video: v.hasInputVideo === 'true' }
: {}),
...(v.generateAudio !== 'any' && v.generateAudio !== undefined
? { generate_audio: v.generateAudio === 'true' }
: {}),
...(splitList(v.inferenceModes).length
? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) }
: {}),
rate: String(v.rate ?? 0),
})),
};
}
const PricingRuleForm: React.FC<Props> = ({ initial, loading, onSubmit, onCancel }) => {
const [form] = Form.useForm();
const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined;
useEffect(() => {
const billingMode = initial?.billingMode || 'video_token_rate';
form.setFieldsValue({
provider: initial?.provider || 'volcengine',
modelName: initial?.modelName || '',
modelCategory: initial?.modelCategory || 'video',
billingMode,
calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode],
versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`,
effectiveRange: [
dayjs(initial?.effectiveFrom || undefined),
initial?.effectiveTo ? dayjs(initial.effectiveTo) : null,
],
currency: initial?.currency || 'CNY',
ruleSchemaVersion: initial?.ruleSchemaVersion || 1,
sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106',
sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null,
remark: initial?.remark || '',
pricing: toFormPricing(billingMode, initial?.ruleJson || {}),
});
}, [initial, form]);
useEffect(() => {
if (!mode) return;
form.setFieldValue('calculatorVersion', calculatorByMode[mode]);
}, [mode, form]);
const submit = async (values: any) => {
const billingMode = values.billingMode as ModelPricingBillingMode;
const [from, to] = values.effectiveRange || [];
const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {};
await onSubmit({
provider: values.provider,
model_name: values.modelName,
model_category: values.modelCategory,
billing_mode: billingMode,
calculator_version: calculatorByMode[billingMode],
version_code: values.versionCode,
effective_from: from.toISOString(),
effective_to: to ? to.toISOString() : null,
currency: values.currency || 'CNY',
rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1),
rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}),
source_url: values.sourceUrl || null,
source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null,
remark: values.remark || null,
});
};
return (
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
/>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
</Form.Item>
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
<Select options={modeOptions} />
</Form.Item>
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
</Space>
<Divider titlePlacement="start"></Divider>
{mode === 'text_token_tiered' && <>
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
<InputNumber min={0} stringMode style={{ width: 260 }} />
</Form.Item>
<Form.List name={['pricing', 'tiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => (
<Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
))}
<Button onClick={() => add({})}> Token </Button>
</>}
</Form.List>
</>}
{mode === 'image_per_output' && <Space align="baseline">
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
<InputNumber min={0} stringMode style={{ width: 220 }} />
</Form.Item>
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>}
{mode === 'image_input_output_tiered' && <>
<Space align="baseline">
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>
<Form.List name={['pricing', 'outputTiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline">
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({ max_pixels: null, rate: 0 })}></Button>
</>}
</Form.List>
</>}
{mode === 'video_token_rate' && <>
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
<Space align="baseline" wrap>
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
</Space>
<Divider titlePlacement="start" plain> + </Divider>
<Form.List name={['pricing', 'dimensionRows']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({})}></Button>
</>}
</Form.List>
<Divider titlePlacement="start" plain></Divider>
<Form.List name={['pricing', 'rates']}>
{(fields, { add, remove }) => <>
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
<Space align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
</div>)}
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}></Button>
</>}
</Form.List>
</>}
<Divider titlePlacement="start"></Divider>
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>稿</Button>
</Space>
</Form>
);
};
export default PricingRuleForm;
@@ -1,229 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
import { previewModelPricing } from '../../api';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingPreviewResponse,
} from '../../types';
interface Props {
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
ruleJson: Record<string, any>;
}
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
text_token_tiered: {
input_tokens: 1000,
output_tokens: 300,
cached_input_tokens: 0,
audio_input_tokens: 0,
usage_source: 'provider',
},
image_per_output: {
requested_output_count: 1,
successful_output_count: 1,
provider_billed_count: 1,
usage_source: 'provider_response',
},
image_input_output_tiered: {
provider_input_image_count: 2,
requested_output_count: 1,
successful_output_count: 1,
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
usage_source: 'provider_response',
},
video_token_rate: {
total_tokens: 1000000,
resolution: '720p',
aspect_ratio: '9:16',
has_input_video: false,
generate_audio: false,
inference_mode: 'online',
usage_source: 'provider',
},
};
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
*/
function normalizeRuleJson(
billingMode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (billingMode === 'text_token_tiered') {
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: tiers.map(item => normalizeTextTier(item || {})),
};
}
if (billingMode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (billingMode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
const [usageText, setUsageText] = useState(initial);
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsageText(initial);
setResult(null);
}, [initial, ruleJson, calculatorVersion]);
const run = async () => {
setLoading(true);
try {
const usage = JSON.parse(usageText);
setResult(await previewModelPricing({
billing_mode: billingMode,
calculator_version: calculatorVersion,
rule_json: normalizeRuleJson(billingMode, ruleJson),
usage,
currency: 'CNY',
}));
} catch (e: any) {
message.error(e?.message || '计价试算失败');
} finally {
setLoading(false);
}
};
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
<Typography.Text type="secondary">
{calculatorVersion}使使 Token
</Typography.Text>
<Input.TextArea
value={usageText}
onChange={e => setUsageText(e.target.value)}
rows={10}
style={{ marginTop: 10, fontFamily: 'monospace' }}
/>
<Space style={{ marginTop: 10 }}>
<Button type="primary" loading={loading} onClick={run}></Button>
</Space>
{result && <Alert
style={{ marginTop: 12 }}
type={result.isEstimated ? 'warning' : 'success'}
showIcon
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
description={<>
<div style={{ marginBottom: 8 }}>{result.usageSource || '-'}</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
</>}
/>}
</Card>;
};
export default PricingRulePreview;
+246 -225
View File
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography,
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -14,15 +14,21 @@ import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
totalTokens: 0, inputTokens: 0, outputTokens: 0,
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0,
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
imageGenerationCount: 0,
videoGenerationCount: 0,
imageConsume: 0,
videoConsume: 0,
textConsume: 0,
analysisConsume: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
};
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
@@ -33,38 +39,73 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
};
const userScopeOptions = [
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' },
{ value: 'frontend_external', label: '前台外部用户' },
];
const recordTypeOptions = [
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
{ value: '', label: '全部流水' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
];
const creditSubjectOptions = [
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', label: '历史未知' },
{ value: '', label: '全部积分类型' },
{ value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' },
{ value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' },
];
const mediaTypeOptions = [{ value: '', label: '全部媒体' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }];
const mediaTypeOptions = [
{ value: '', label: '全部媒体' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
];
const chargeKindOptions = [
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: '', label: '全部扣费子类' },
{ value: 'media', label: '媒体生成' },
{ value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' },
{ value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' },
{ value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
{ value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
{ value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' },
];
const sourceStepOptions = [
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
{ value: '', label: '全部步骤' },
{ value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' },
{ value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' },
{ value: 'video_analysis', label: '视频分析' },
];
const billingSceneOptions = [
{ value: '', label: '全部计费场景' },
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
@@ -94,45 +135,24 @@ const billingSceneOptions = [
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' },
];
const costStatusOptions = [
{ value: '', label: '全部成本状态' }, { value: 'pending', label: '待回填' }, { value: 'calculated', label: '已核算' },
{ value: 'estimated', label: '估算' }, { value: 'unmatched_rule', label: '未匹配价格' },
{ value: 'usage_missing', label: '用量缺失' }, { value: 'historical_price_unavailable', label: '历史价格缺失' },
{ value: 'historical_engine_unavailable', label: '历史引擎缺失' },
{ value: 'provider_result_uncertain', label: '供应商结果不确定' },
{ value: 'not_incurred', label: '供应商费用未发生' },
{ value: 'error', label: '核算异常' }, { value: 'not_applicable', label: '不涉及成本' },
];
function n(value: number | string | undefined | null, digits = 0): string {
const parsed = Number(value || 0);
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
function n(value: number | undefined | null): string {
return Number(value || 0).toLocaleString();
}
function engineTypeLabel(type?: string): string {
if (type === 'model') return '提词/分析模型';
if (type === 'image') return '图片引擎';
if (type === 'video') return '视频引擎';
return '执行配置';
}
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
if (scope === 'admin') return { userType: 'admin' };
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
return {};
}
function costStatusColor(status?: string): string {
if (status === 'calculated') return 'green';
if (status === 'estimated') return 'orange';
if (status === 'pending') return 'blue';
if (status === 'not_applicable') return 'default';
return 'red';
}
const JsonBlock: React.FC<{ value?: Record<string, any> | null }> = ({ value }) => (
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{value ? JSON.stringify(value, null, 2) : '-'}
</pre>
);
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
@@ -143,7 +163,6 @@ const AdminCreditRecords: React.FC = () => {
const [exportProgress, setExportProgress] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
@@ -156,24 +175,24 @@ const AdminCreditRecords: React.FC = () => {
const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState('');
const [userNameFilter, setUserNameFilter] = useState('');
const [engineProvider, setEngineProvider] = useState('');
const [engineModelName, setEngineModelName] = useState('');
const [pricingVersionCode, setPricingVersionCode] = useState('');
const [providerCostStatus, setProviderCostStatus] = useState('');
const [hasAttachment, setHasAttachment] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
page,
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined,
sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined,
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
const load = async () => {
setLoading(true);
@@ -181,20 +200,33 @@ const AdminCreditRecords: React.FC = () => {
const res = await getCreditRecords(query);
setRecords(res.items || []);
setTotal(res.total || 0);
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
setSummary(res.summary || DEFAULT_SUMMARY);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
const handleReset = () => {
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
setUserScope('');
setTeamFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
setUserNameFilter('');
setDateRange([null, null]);
setPage(1);
};
const exportExcel = async () => {
@@ -205,179 +237,168 @@ const AdminCreditRecords: React.FC = () => {
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
const first = await getCreditRecords(baseQuery);
const all: AdminCreditRecord[] = [...(first.items || [])];
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
const exportSummary = first.summary || DEFAULT_SUMMARY;
const totalRows = first.total || 0;
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
for (let p = 2; p <= totalPages; p += 1) {
const res = await getCreditRecords({ ...baseQuery, page: p });
all.push(...(res.items || []));
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
}
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '' },
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '否' },
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' },
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
exportStyledExcel({
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
exportStyledExcel<AdminCreditRecord>({
filename,
sheetName: '积分流水',
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
['待核算流水数', exportSummary.providerCostPendingCount],
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
], columns, rows: all,
['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume],
['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume],
['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
],
columns: detailColumns,
rows: all,
});
message.success('Excel 已导出');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally { setExporting(false); setExportProgress(''); }
} finally {
setExporting(false);
setExportProgress('');
}
};
const columns = [
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <div><Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.frontendUserKindLabel || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{ title: '流水类型', dataIndex: 'recordType', width: 105, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v, 2)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) },
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 240, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) => <div> {n(r.attachmentImageCount)} / {n(r.attachmentVideoCount)} / {n(r.attachmentAudioCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.attachmentTotalCount)}</div></div> },
{ title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) => <div> {n(r.generatedImageCount)} / {n(r.generatedVideoCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.requestedOutputCount)} / {n(r.generatedTotalCount)}</div></div> },
{ title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) => <div><Typography.Text strong>{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}</Typography.Text><div><Tag color={costStatusColor(r.providerCostStatus)}>{r.providerCostStatusLabel || '-'}</Tag>{r.providerCostIsEstimated && <Tag color="orange"></Tag>}</div></div> },
{ title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) => <div><div>{r.pricingVersionCode || '未锁价'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}</div></div> },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => <Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}></Button> },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
];
return <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">{n(summary.imageGenerationCount)} / {n(summary.imageConsume, 2)} </Card>
<Card size="small">{n(summary.videoGenerationCount)} / {n(summary.videoConsume, 2)} </Card>
<Card size="small">{n(summary.textConsume, 2)} </Card>
<Card size="small">{n(summary.analysisConsume, 2)} </Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">//{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)}</Card>
<Card size="small">/{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)}</Card>
<Card size="small">¥{n(summary.providerCostCalculatedTotal, 8)}</Card>
<Card size="small">{n(summary.providerCostEstimatedCount)} / ¥{n(summary.providerCostEstimatedTotal, 8)}</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small"> Token{n(summary.totalTokens)}</Card>
<Card size="small">{n(summary.providerCostPendingCount)} </Card>
<Card size="small">{n(summary.providerCostAbnormalCount)} </Card>
<Card size="small">¥{n(summary.providerCostCombinedTotal, 8)}</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select value={teamFilter} onChange={v => { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} />
<Select value={recordType} onChange={v => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={v => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={v => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Select value={providerCostStatus} onChange={v => { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} />
<Select value={hasAttachment} onChange={v => { setPage(1); setHasAttachment(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部附件' }, { value: 'true', label: '有附件' }, { value: 'false', label: '无附件' }]} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={e => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
</Space>
<Space><Button onClick={handleReset}></Button><Button icon={<ReloadOutlined />} onClick={load}></Button><Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button></Space>
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table columns={columns} dataSource={records} rowKey="id" loading={loading} scroll={{ x: 3650 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showTotal: t => `${t}` }} />
</Card>
<Drawer open={!!detail} width={860} title="积分流水财务核查" onClose={() => setDetail(null)}>
{detail && <>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="流水ID" span={2}>{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || '-'} / {detail.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="发生时间">{formatDate(detail.createdAt || '')}</Descriptions.Item>
<Descriptions.Item label="积分变动">{n(detail.amount, 2)}</Descriptions.Item>
<Descriptions.Item label="余额">{n(detail.balanceAfter, 2)}</Descriptions.Item>
<Descriptions.Item label="模型" span={2}>{detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="计价版本">{detail.pricingVersionCode || '-'}</Descriptions.Item>
<Descriptions.Item label="计价模式">{detail.pricingBillingModeLabel || '-'}</Descriptions.Item>
<Descriptions.Item label="计算器版本">{detail.pricingCalculatorVersion || '-'}</Descriptions.Item>
<Descriptions.Item label="用量来源">{detail.pricingUsageSource || '-'}</Descriptions.Item>
<Descriptions.Item label="计价参考时间">{formatDate(detail.pricingReferenceAt || '')}</Descriptions.Item>
<Descriptions.Item label="价格生效区间">{formatDate(detail.pricingEffectiveFrom || '')} {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'}</Descriptions.Item>
<Descriptions.Item label="供应商成本"><b>{detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)}</b></Descriptions.Item>
<Descriptions.Item label="核算状态"><Tag color={costStatusColor(detail.providerCostStatus)}>{detail.providerCostStatusLabel || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label="最终核算时间">{formatDate(detail.providerCostFinalizedAt || '')}</Descriptions.Item>
<Descriptions.Item label="主供应商用量">{detail.providerUsagePrimary ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="附件统计"> {detail.attachmentImageCount} / {detail.attachmentVideoCount} / {detail.attachmentAudioCount}</Descriptions.Item>
<Descriptions.Item label="产出统计"> {detail.requestedOutputCount} / {detail.generatedImageCount} / {detail.generatedVideoCount}</Descriptions.Item>
<Descriptions.Item label="Token"> {detail.inputTokens} / {detail.outputTokens} / {detail.totalTokens}</Descriptions.Item>
<Descriptions.Item label="业务归属">{detail.ownerType || '-'} / {detail.ownerId || '-'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.pricingSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.usageSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.attachmentSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.generationSnapshotJson} />
</>}
</Drawer>
</div>;
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2050 }}
/>
</Card>
</div>
);
};
export default AdminCreditRecords;
@@ -1,146 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Drawer, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { CopyOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createModelPricingRule,
disableModelPricingRule,
getModelPricingRules,
publishModelPricingRule,
updateModelPricingRule,
} from '../api';
import type { ModelPricingRule, ModelPricingRulePayload } from '../types';
import PricingRuleForm from '../components/modelPricing/PricingRuleForm';
import PricingRulePreview from '../components/modelPricing/PricingRulePreview';
import { formatDate } from '../utils/formatDate';
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
published: { color: 'green', text: '已发布' },
disabled: { color: 'red', text: '已停用' },
};
const modeMap: Record<string, string> = {
text_token_tiered: '文本分档 Token',
image_per_output: '按成功输出图片',
image_input_output_tiered: '输入图 + 输出像素',
video_token_rate: '视频 Token',
};
const AdminModelPricingRules: React.FC = () => {
const [rows, setRows] = useState<ModelPricingRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [modelName, setModelName] = useState('');
const [status, setStatus] = useState('');
const [category, setCategory] = useState('');
const [editing, setEditing] = useState<ModelPricingRule | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [detail, setDetail] = useState<ModelPricingRule | null>(null);
const load = async () => {
setLoading(true);
try {
const res = await getModelPricingRules({
page,
pageSize,
modelName: modelName || undefined,
publishStatus: status || undefined,
modelCategory: category || undefined,
});
setRows(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载模型计价规则失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page, pageSize, modelName, status, category]);
const submit = async (payload: ModelPricingRulePayload) => {
setSaving(true);
try {
if (editing?.id && editing.publishStatus === 'draft') {
await updateModelPricingRule(editing.id, payload);
} else {
await createModelPricingRule(payload);
}
message.success('价格草稿已保存');
setFormOpen(false);
setEditing(null);
await load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const cloneRule = (rule: ModelPricingRule) => {
setEditing({
...rule,
id: '',
publishStatus: 'draft',
versionCode: `${rule.versionCode}_copy_${dayjs().format('YYYYMMDDHHmm')}`,
effectiveFrom: dayjs().add(1, 'minute').toISOString(),
effectiveTo: null,
referencedCount: 0,
});
setFormOpen(true);
};
const columns = [
{ title: '模型', dataIndex: 'modelName', width: 280, fixed: 'left' as const, render: (v: string, r: ModelPricingRule) => <div><Typography.Text strong>{v}</Typography.Text><div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider} / {r.modelCategory}</div></div> },
{ title: '价格版本', dataIndex: 'versionCode', width: 180 },
{ title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) => <div>{modeMap[r.billingMode] || r.billingMode}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.calculatorVersion}</div></div> },
{ title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) => <div>{formatDate(r.effectiveFrom)}<div style={{ color: '#94a3b8', fontSize: 12 }}> {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}</div></div> },
{ title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => <Tag color={(statusMap[v] || {}).color}>{(statusMap[v] || {}).text || v}</Tag> },
{ title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) => <div>{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}` : '-'}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.referencedCount || 0} </div></div> },
{ title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>/</Button>
{r.publishStatus === 'draft' && <Button size="small" onClick={() => { setEditing(r); setFormOpen(true); }}></Button>}
<Button size="small" icon={<CopyOutlined />} onClick={() => cloneRule(r)}></Button>
{r.publishStatus === 'draft' && <Popconfirm title="发布后价格正文不可修改,确认发布?" onConfirm={async () => { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}><Button size="small" type="primary"></Button></Popconfirm>}
{r.publishStatus === 'published' && <Popconfirm title="停用后不再匹配新消费,历史快照不受影响。确认?" onConfirm={async () => { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}><Button size="small" danger></Button></Popconfirm>}
</Space> },
];
return <div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Input allowClear placeholder="模型名称" value={modelName} onChange={e => { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} />
<Select value={category} onChange={v => { setPage(1); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
<Select value={status} onChange={v => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); setFormOpen(true); }}></Button>
</Space>
</div>
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1500 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</Card>
<Modal open={formOpen} title={editing?.id ? '编辑价格草稿' : editing ? '克隆价格版本' : '新增价格版本'} width={1100} footer={null} destroyOnClose onCancel={() => { setFormOpen(false); setEditing(null); }}>
<PricingRuleForm initial={editing} loading={saving} onSubmit={submit} onCancel={() => { setFormOpen(false); setEditing(null); }} />
</Modal>
<Drawer open={!!detail} width={760} title={detail ? `${detail.modelName} / ${detail.versionCode}` : '计价详情'} onClose={() => setDetail(null)}>
{detail && <>
<Space wrap style={{ marginBottom: 12 }}><Tag>{detail.provider}</Tag><Tag>{detail.modelCategory}</Tag><Tag color="blue">{modeMap[detail.billingMode] || detail.billingMode}</Tag><Tag color={(statusMap[detail.publishStatus] || {}).color}>{(statusMap[detail.publishStatus] || {}).text}</Tag></Space>
<Typography.Paragraph>{formatDate(detail.effectiveFrom)} {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}<br />{detail.calculatorVersion}<br /> Hash{detail.ruleContentHash || '-'}</Typography.Paragraph>
<Typography.Paragraph>{detail.sourceUrl || '-'}<br />{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}</Typography.Paragraph>
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto' }}>{JSON.stringify(detail.ruleJson, null, 2)}</pre>
<PricingRulePreview billingMode={detail.billingMode} calculatorVersion={detail.calculatorVersion} ruleJson={detail.ruleJson} />
</>}
</Drawer>
</div>;
};
export default AdminModelPricingRules;
+2 -2
View File
@@ -191,7 +191,7 @@ const AdminUsers: React.FC = () => {
user_type: userType,
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
frontend_user_kind: values.frontend_user_kind || 'external',
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
});
message.success('用户创建成功');
setCreateModal(false);
@@ -882,7 +882,7 @@ const AdminUsers: React.FC = () => {
<Form.Item
name="private_portrait_asset_limit"
label="私域人像素材总量上限"
initialValue={5}
initialValue={50}
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
>
-119
View File
@@ -796,20 +796,6 @@ export interface AdminCreditRecordSummary {
totalTokens: number;
inputTokens: number;
outputTokens: number;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
providerCostCalculatedTotal: string;
providerCostEstimatedTotal: string;
providerCostCombinedTotal: string;
providerCostTotal: string;
providerCostPendingCount: number;
providerCostEstimatedCount: number;
providerCostAbnormalCount: number;
}
export interface AdminCreditRecord {
@@ -862,38 +848,6 @@ export interface AdminCreditRecord {
engineName?: string;
engineProvider?: string;
engineModelName?: string;
pricingRuleId?: string;
pricingVersionCode?: string;
pricingBillingMode?: string;
pricingBillingModeLabel?: string;
pricingCalculatorVersion?: ModelPricingCalculatorVersion;
pricingUsageSource?: string;
pricingReferenceAt?: string;
pricingEffectiveFrom?: string;
pricingEffectiveTo?: string;
pricingSnapshotHash?: string;
providerCostCurrency?: string;
providerCostAmount?: string;
providerCostStatus?: string;
providerCostStatusLabel?: string;
providerCostCalculatedAt?: string;
providerCostFinalizedAt?: string;
providerCostIsEstimated?: boolean;
providerUsagePrimary?: boolean;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
attachmentVideoDurationSeconds?: string;
attachmentAudioDurationSeconds?: string;
requestedOutputCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
pricingSnapshotJson?: Record<string, any> | null;
usageSnapshotJson?: Record<string, any> | null;
attachmentSnapshotJson?: Record<string, any> | null;
generationSnapshotJson?: Record<string, any> | null;
createdAt?: string;
}
@@ -919,83 +873,10 @@ export interface AdminCreditRecordQueryParams {
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
engineProvider?: string;
engineModelName?: string;
pricingVersionCode?: string;
providerCostStatus?: string;
providerCostIsEstimated?: boolean;
hasAttachment?: boolean;
startDate?: string;
endDate?: string;
}
export type ModelPricingCategory = 'text' | 'image' | 'video';
export type ModelPricingBillingMode =
| 'text_token_tiered'
| 'image_per_output'
| 'image_input_output_tiered'
| 'video_token_rate';
export type ModelPricingRuleStatus = 'draft' | 'published' | 'disabled';
export type ModelPricingCalculatorVersion =
| 'text_token_tiered_v1'
| 'image_per_output_v1'
| 'image_input_output_tiered_v1'
| 'video_pixel_token_v1';
export interface ModelPricingRule {
id: string;
provider: string;
modelName: string;
modelCategory: ModelPricingCategory;
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
versionCode: string;
effectiveFrom: string;
effectiveTo?: string | null;
publishStatus: ModelPricingRuleStatus;
currency: string;
ruleSchemaVersion: number;
ruleContentHash: string;
ruleJson: Record<string, any>;
sourceUrl?: string | null;
sourceUpdatedAt?: string | null;
remark?: string | null;
referencedCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface ModelPricingRulePayload {
provider: string;
model_name: string;
model_category: ModelPricingCategory;
billing_mode: ModelPricingBillingMode;
calculator_version: ModelPricingCalculatorVersion;
version_code: string;
effective_from: string;
effective_to?: string | null;
currency: string;
rule_schema_version: number;
rule_json: Record<string, any>;
source_url?: string | null;
source_updated_at?: string | null;
remark?: string | null;
}
export interface ModelPricingRuleListResponse {
items: ModelPricingRule[];
total: number;
}
export interface ModelPricingPreviewResponse {
amount: string;
currency: string;
isEstimated: boolean;
selectedRate?: string | null;
usageSource: string;
breakdown: Record<string, any>;
}
// ── 首页素材行业装修 ──────────────────────────────────────
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/modelpricing/pricingruleform.tsx","./src/components/modelpricing/pricingrulepreview.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodelpricingrules.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -1,582 +0,0 @@
"""add model pricing rules and credit pricing snapshots
Revision ID: 1c93b40133f0
Revises: 2026070902
Create Date: 2026-07-10 15:10:44.983521
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "1c93b40133f0"
down_revision: Union[str, None] = "2026070902"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_CREDIT_RECORD_PRICING_RULE = (
"fk_credit_records_pricing_rule_id_model_pricing_rules"
)
def _json_type() -> sa.types.TypeEngine:
"""Use JSONB on PostgreSQL and JSON on other supported development DBs."""
return sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
# 1. Versioned model pricing rules.
op.create_table(
"model_pricing_rules",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("model_name", sa.String(length=128), nullable=False),
sa.Column("model_category", sa.String(length=16), nullable=False),
sa.Column("billing_mode", sa.String(length=48), nullable=False),
sa.Column("calculator_version", sa.String(length=64), nullable=False),
sa.Column("version_code", sa.String(length=64), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("publish_status", sa.String(length=16), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("rule_schema_version", sa.Integer(), nullable=False),
sa.Column("rule_json", _json_type(), nullable=False),
sa.Column("rule_content_hash", sa.String(length=64), nullable=False),
sa.Column("source_url", sa.Text(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=32), nullable=True),
sa.Column("updated_by", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
# A disabled future rule is represented by an empty interval where
# effective_to == effective_from, so equality must be allowed.
sa.CheckConstraint(
"effective_to IS NULL OR effective_to >= effective_from",
name="ck_model_pricing_rules_effective_range",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_model_pricing_rules_provider_model_version",
"model_pricing_rules",
["provider", "model_name", "version_code"],
unique=True,
)
op.create_index(
"ix_model_pricing_rules_resolve",
"model_pricing_rules",
[
"provider",
"model_name",
"publish_status",
"effective_from",
"effective_to",
],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_category_status",
"model_pricing_rules",
["model_category", "publish_status"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_provider",
"model_pricing_rules",
["provider"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_model_name",
"model_pricing_rules",
["model_name"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_model_category",
"model_pricing_rules",
["model_category"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_billing_mode",
"model_pricing_rules",
["billing_mode"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_calculator_version",
"model_pricing_rules",
["calculator_version"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_effective_from",
"model_pricing_rules",
["effective_from"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_effective_to",
"model_pricing_rules",
["effective_to"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_publish_status",
"model_pricing_rules",
["publish_status"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_rule_content_hash",
"model_pricing_rules",
["rule_content_hash"],
unique=False,
)
# 2. Bind provider callbacks to the exact billing attempt.
op.add_column(
"chat_generation_tasks",
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
)
op.create_index(
"ix_chat_generation_tasks_current_billing_attempt_no",
"chat_generation_tasks",
["current_billing_attempt_no"],
unique=False,
)
# 3. Immutable pricing, usage, attachment, and output snapshots.
op.add_column(
"credit_records",
sa.Column("pricing_rule_id", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_version_code", sa.String(length=64), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_billing_mode", sa.String(length=48), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"pricing_calculator_version",
sa.String(length=64),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column("pricing_usage_source", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_reference_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_effective_from", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_effective_to", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_schema_version", sa.Integer(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_hash", sa.String(length=64), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("provider_cost_currency", sa.String(length=8), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_amount",
sa.Numeric(precision=20, scale=8),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column("provider_cost_status", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_calculated_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_finalized_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_usage_primary",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_is_estimated",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_image_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_video_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_audio_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_total_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_video_duration_seconds",
sa.Numeric(precision=20, scale=6),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_audio_duration_seconds",
sa.Numeric(precision=20, scale=6),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"requested_output_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_image_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_video_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_total_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("usage_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("attachment_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("generation_snapshot_json", _json_type(), nullable=True),
)
op.create_index(
"ix_credit_records_pricing_rule_id",
"credit_records",
["pricing_rule_id"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_version_code",
"credit_records",
["pricing_version_code"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_usage_source",
"credit_records",
["pricing_usage_source"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_reference_at",
"credit_records",
["pricing_reference_at"],
unique=False,
)
op.create_index(
"ix_credit_records_provider_cost_status",
"credit_records",
["provider_cost_status"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_status_time",
"credit_records",
["provider_cost_status", "created_at"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_model_time",
"credit_records",
["engine_provider", "engine_model_name", "pricing_reference_at"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_version",
"credit_records",
["pricing_version_code", "pricing_rule_id"],
unique=False,
)
op.create_foreign_key(
FK_CREDIT_RECORD_PRICING_RULE,
"credit_records",
"model_pricing_rules",
["pricing_rule_id"],
["id"],
ondelete="RESTRICT",
)
# 4. Lock the engine and billing attempt for legacy project generations.
op.add_column(
"generation_records",
sa.Column("engine_id", sa.String(length=32), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("engine_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_response_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
)
op.create_index(
"ix_generation_records_engine_id",
"generation_records",
["engine_id"],
unique=False,
)
op.create_index(
"ix_generation_records_current_billing_attempt_no",
"generation_records",
["current_billing_attempt_no"],
unique=False,
)
def downgrade() -> None:
# Reverse legacy generation record extensions.
op.drop_index(
"ix_generation_records_current_billing_attempt_no",
table_name="generation_records",
)
op.drop_index(
"ix_generation_records_engine_id",
table_name="generation_records",
)
op.drop_column("generation_records", "current_billing_attempt_no")
op.drop_column("generation_records", "provider_response_json")
op.drop_column("generation_records", "engine_snapshot_json")
op.drop_column("generation_records", "engine_id")
# Reverse credit pricing snapshots before dropping the referenced rule table.
op.drop_constraint(
FK_CREDIT_RECORD_PRICING_RULE,
"credit_records",
type_="foreignkey",
)
op.drop_index("ix_credit_records_pricing_version", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_model_time", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_status_time", table_name="credit_records")
op.drop_index("ix_credit_records_provider_cost_status", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_reference_at", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_usage_source", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_version_code", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_rule_id", table_name="credit_records")
op.drop_column("credit_records", "generation_snapshot_json")
op.drop_column("credit_records", "attachment_snapshot_json")
op.drop_column("credit_records", "usage_snapshot_json")
op.drop_column("credit_records", "pricing_snapshot_json")
op.drop_column("credit_records", "generated_total_count")
op.drop_column("credit_records", "generated_video_count")
op.drop_column("credit_records", "generated_image_count")
op.drop_column("credit_records", "requested_output_count")
op.drop_column("credit_records", "attachment_audio_duration_seconds")
op.drop_column("credit_records", "attachment_video_duration_seconds")
op.drop_column("credit_records", "attachment_total_count")
op.drop_column("credit_records", "attachment_audio_count")
op.drop_column("credit_records", "attachment_video_count")
op.drop_column("credit_records", "attachment_image_count")
op.drop_column("credit_records", "provider_cost_is_estimated")
op.drop_column("credit_records", "provider_usage_primary")
op.drop_column("credit_records", "provider_cost_finalized_at")
op.drop_column("credit_records", "provider_cost_calculated_at")
op.drop_column("credit_records", "provider_cost_status")
op.drop_column("credit_records", "provider_cost_amount")
op.drop_column("credit_records", "provider_cost_currency")
op.drop_column("credit_records", "pricing_snapshot_hash")
op.drop_column("credit_records", "pricing_snapshot_schema_version")
op.drop_column("credit_records", "pricing_effective_to")
op.drop_column("credit_records", "pricing_effective_from")
op.drop_column("credit_records", "pricing_reference_at")
op.drop_column("credit_records", "pricing_usage_source")
op.drop_column("credit_records", "pricing_calculator_version")
op.drop_column("credit_records", "pricing_billing_mode")
op.drop_column("credit_records", "pricing_version_code")
op.drop_column("credit_records", "pricing_rule_id")
# Reverse exact billing-attempt binding.
op.drop_index(
"ix_chat_generation_tasks_current_billing_attempt_no",
table_name="chat_generation_tasks",
)
op.drop_column("chat_generation_tasks", "current_billing_attempt_no")
# Reverse pricing-rule storage.
op.drop_index(
"ix_model_pricing_rules_rule_content_hash",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_publish_status",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_effective_to",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_effective_from",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_calculator_version",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_billing_mode",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_model_category",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_model_name",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_provider",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_category_status",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_resolve",
table_name="model_pricing_rules",
)
op.drop_index(
"uq_model_pricing_rules_provider_model_version",
table_name="model_pricing_rules",
)
op.drop_table("model_pricing_rules")
-2
View File
@@ -8,7 +8,6 @@ from app.api.admin.private_portrait import router as private_portrait_router
from app.api.admin.recharge_package import router as recharge_package_router
from app.api.admin.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router
from app.api.admin.model_pricing import router as model_pricing_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
@@ -19,4 +18,3 @@ router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(model_pricing_router)
@@ -1,238 +0,0 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.model_pricing import (
ModelPricingPreviewOut,
ModelPricingPreviewRequest,
ModelPricingRuleCreate,
ModelPricingRuleListOut,
ModelPricingRuleOut,
ModelPricingRuleUpdate,
)
from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing
from app.services.model_pricing.rule_service import (
PricingRuleError,
create_rule,
disable_rule,
get_rule_snapshot,
list_rules,
publish_rule,
update_draft_rule,
)
from app.services.operation_log import log_operation
from app.services.operation_log_service import log_model_pricing_event
router = APIRouter(prefix="/admin/model-pricing", tags=["admin-model-pricing"])
def _http_error(exc: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/rules", response_model=ModelPricingRuleListOut)
async def admin_list_model_pricing_rules(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
provider: str | None = Query(None),
model_name: str | None = Query(None),
model_category: str | None = Query(None),
publish_status: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
return await list_rules(
db,
page=page,
page_size=page_size,
provider=provider,
model_name=model_name,
model_category=model_category,
publish_status=publish_status,
)
@router.get("/rules/{rule_id}", response_model=ModelPricingRuleOut)
async def admin_get_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
snapshot = await get_rule_snapshot(db, rule_id)
return {**snapshot, "referenced_count": 0}
except PricingRuleError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/rules", response_model=ModelPricingRuleOut)
async def admin_create_model_pricing_rule(
req: ModelPricingRuleCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await create_rule(db, payload=req.model_dump(), operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"创建模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
"/admin/model-pricing/rules",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
log_model_pricing_event(
event_type="pricing_rule_validate",
user_id=admin_id,
pricing_rule_id=snapshot["id"],
pricing_version=snapshot["version_code"],
provider=snapshot["provider"],
model_name=snapshot["model_name"],
billing_mode=snapshot["billing_mode"],
message="模型计价草稿创建成功",
)
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.put("/rules/{rule_id}", response_model=ModelPricingRuleOut)
async def admin_update_model_pricing_rule(
rule_id: str,
req: ModelPricingRuleUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await update_draft_rule(
db,
rule_id=rule_id,
payload=req.model_dump(exclude_unset=True),
operator_id=admin_id,
)
await log_operation(
db,
admin_id,
admin_username,
f"更新模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
"PUT",
f"/admin/model-pricing/rules/{rule_id}",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/rules/{rule_id}/publish", response_model=ModelPricingRuleOut)
async def admin_publish_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await publish_rule(db, rule_id=rule_id, operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"发布模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
f"/admin/model-pricing/rules/{rule_id}/publish",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
log_model_pricing_event(
event_type="pricing_rule_publish",
user_id=admin_id,
pricing_rule_id=snapshot["id"],
pricing_version=snapshot["version_code"],
provider=snapshot["provider"],
model_name=snapshot["model_name"],
billing_mode=snapshot["billing_mode"],
)
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/rules/{rule_id}/disable", response_model=ModelPricingRuleOut)
async def admin_disable_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await disable_rule(db, rule_id=rule_id, operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"停用模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
f"/admin/model-pricing/rules/{rule_id}/disable",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/preview", response_model=ModelPricingPreviewOut)
async def admin_preview_model_pricing(
req: ModelPricingPreviewRequest,
admin: User = Depends(get_admin_user),
):
try:
result = calculate_pricing(
billing_mode=req.billing_mode,
calculator_version=req.calculator_version,
rule_json=req.rule_json,
usage=req.usage,
currency=req.currency,
)
return {
"amount": str(result.amount),
"currency": result.currency,
"is_estimated": result.is_estimated,
"selected_rate": str(result.selected_rate) if result.selected_rate is not None else None,
"usage_source": result.usage_source,
"breakdown": result.breakdown,
}
except PricingCalculationError as exc:
raise _http_error(exc) from exc
+3 -37
View File
@@ -37,7 +37,6 @@ from app.schemas.admin import (
ResetPasswordRequest,
UpdateFrontendUserKindRequest,
OperationLogOut,
AdminCreditRecordListOut,
)
from app.schemas.team import UpdateUserTeamRequest
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
@@ -62,7 +61,6 @@ from app.services.generation_billing_service import (
get_next_credit_attempt_no,
)
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot
from app.utils.id_gen import generate_id
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
@@ -483,7 +481,7 @@ async def admin_change_password(
# ── Credit Records ───────────────────────────────────────
@router.get("/credit-records", response_model=AdminCreditRecordListOut)
@router.get("/credit-records")
async def list_credit_records(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=1000),
@@ -500,12 +498,6 @@ async def list_credit_records(
source_module: str | None = Query(None),
source_step_code: str | None = Query(None),
billing_scene: str | None = Query(None),
engine_provider: str | None = Query(None),
engine_model_name: str | None = Query(None),
pricing_version_code: str | None = Query(None),
provider_cost_status: str | None = Query(None),
provider_cost_is_estimated: bool | None = Query(None),
has_attachment: bool | None = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin: User = Depends(get_admin_user),
@@ -528,12 +520,6 @@ async def list_credit_records(
source_module=source_module,
source_step_code=source_step_code,
billing_scene=billing_scene,
engine_provider=engine_provider,
engine_model_name=engine_model_name,
pricing_version_code=pricing_version_code,
provider_cost_status=provider_cost_status,
provider_cost_is_estimated=provider_cost_is_estimated,
has_attachment=has_attachment,
start_date=start_date,
end_date=end_date,
)
@@ -2060,9 +2046,6 @@ async def admin_generate_video(
raise HTTPException(status_code=400, detail="不支持的分辨率")
duration = record.duration or 5
from app.services.video_gen import get_active_engine, submit_video_task
engine = await get_active_engine(db)
engine_snapshot = _build_video_snapshot(engine, aspect_ratio, resolution, duration)
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
@@ -2070,21 +2053,14 @@ async def admin_generate_video(
gen_type="video",
duration=duration,
resolution=resolution,
aspect_ratio=aspect_ratio,
fps=24,
engine_id=engine.id,
project_name=project_name,
description_prefix="视频生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
media_references=record.media_references,
)
record.aspect_ratio = aspect_ratio
record.resolution = resolution
record.engine_id = engine.id
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
record.current_billing_attempt_no = attempt_no
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
@@ -2095,6 +2071,8 @@ async def admin_generate_video(
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task
engine = await get_active_engine(db)
task_id = await submit_video_task(
db,
engine,
@@ -2117,31 +2095,19 @@ async def admin_generate_video(
post_image_size = body.get("image_size", "")
image_size = post_image_size or record.image_size or "2K"
from app.services.image_gen import get_active_image_engine
engine = await get_active_image_engine(db)
image_proportion = record.image_proportion or "1:1"
image_px = record.image_px or "2048x2048"
engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px)
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
record_id=record.id,
gen_type="image",
image_size=image_size,
image_px=image_px,
aspect_ratio=image_proportion,
engine_id=engine.id,
project_name=project_name,
description_prefix="图片生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
media_references=record.media_references,
)
record.image_size = image_size
record.engine_id = engine.id
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
record.current_billing_attempt_no = attempt_no
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
+7 -60
View File
@@ -49,7 +49,6 @@ from app.services.generation_billing_service import (
)
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
from app.services.video_cover_service import async_create_video_cover_for_local_video
from app.enums.audio_reference import (
@@ -339,7 +338,6 @@ async def optimize(
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=token_usage,
media_references=record.media_references,
)
await deduct_credits(
db, current_user.id, text_credits,
@@ -433,9 +431,6 @@ async def generate(
raise HTTPException(status_code=400, detail="不支持的分辨率")
duration = record.duration or 5
from app.services.video_gen import get_active_engine, submit_video_task
engine = await get_active_engine(db)
engine_snapshot = _build_video_snapshot(engine, req.aspect_ratio, req.resolution, duration)
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
@@ -443,21 +438,14 @@ async def generate(
gen_type="video",
duration=duration,
resolution=req.resolution,
aspect_ratio=req.aspect_ratio,
fps=24,
engine_id=engine.id,
project_name=project_name,
description_prefix=project_name+"-",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
media_references=record.media_references,
)
record.aspect_ratio = req.aspect_ratio
record.resolution = req.resolution
record.engine_id = engine.id
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
record.current_billing_attempt_no = attempt_no
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
@@ -468,9 +456,11 @@ async def generate(
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task
from app.services.error_codes import extract_error_message
from app.services.video_queue import task_queue
engine = await get_active_engine(db)
task_id = await submit_video_task(
db,
engine,
@@ -490,31 +480,19 @@ async def generate(
elif record.gen_type == GenerationType.image:
image_size = req.image_size or record.image_size or "2K"
from app.services.image_gen import get_active_image_engine
engine = await get_active_image_engine(db)
image_proportion = record.image_proportion or "1:1"
image_px = record.image_px or "2048x2048"
engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px)
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=record.id,
gen_type="image",
image_size=image_size,
image_px=image_px,
aspect_ratio=image_proportion,
engine_id=engine.id,
project_name=project_name,
description_prefix=project_name+"-",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
media_references=record.media_references,
)
record.image_size = image_size
record.engine_id = engine.id
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
record.current_billing_attempt_no = attempt_no
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
@@ -571,46 +549,14 @@ async def retry_generation(
owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id,
)
if record.gen_type == GenerationType.video:
from app.services.video_gen import get_active_engine
engine = await get_active_engine(db)
engine_snapshot = _build_video_snapshot(
engine,
record.aspect_ratio or "16:9",
record.resolution or "720p",
record.duration or 5,
)
else:
from app.services.image_gen import get_active_image_engine
engine = await get_active_image_engine(db)
engine_snapshot = _build_image_snapshot(
engine,
record.image_size or "2K",
record.image_proportion or "1:1",
record.image_px or "2048x2048",
)
media_billing = await charge_generation_media_by_params(
media_billing = await charge_generation_media_for_record(
db,
user_id=record.user_id,
record_id=record.id,
gen_type=record.gen_type,
image_size=record.image_size,
image_px=record.image_px,
aspect_ratio=record.aspect_ratio or record.image_proportion,
duration=record.duration,
resolution=record.resolution,
fps=24 if record.gen_type == GenerationType.video else None,
engine_id=engine.id,
record=record,
project_name=project_name,
description_prefix="生成重试-",
owner_type=OWNER_GENERATION_RECORD,
description_prefix="视频重试",
attempt_no=attempt_no,
media_references=record.media_references,
)
record.engine_id = engine.id
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
record.current_billing_attempt_no = attempt_no
record.status = "generating"
record.error_message = None
record.video_url = None
@@ -624,7 +570,8 @@ async def retry_generation(
try:
from app.services.video_queue import task_queue
if record.gen_type == GenerationType.video:
from app.services.video_gen import submit_video_task, extract_error_message
from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message
engine = await get_active_engine(db)
task_id = await submit_video_task(
db,
engine,
@@ -689,20 +689,15 @@ async def retry_task(
record_id=task.id,
gen_type=task.gen_type,
image_size=task.image_size,
image_px=task.image_px,
aspect_ratio=task.aspect_ratio or task.image_proportion,
duration=task.duration,
resolution=task.resolution,
fps=24 if task.gen_type == "video" else None,
engine_id=task.engine_id,
project_name="AI生成任务",
description_prefix="Chat任务重试",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=attempt_no,
media_references=task.media_references,
)
task.current_billing_attempt_no = attempt_no
task.status = "generating"
task.pipeline_stage = "queued"
task.error_message = None
@@ -1,981 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
from copy import deepcopy
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Mapping
from sqlalchemy import or_, select
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordChargeKind,
CreditRecordOwnerType,
CreditRecordType,
)
from app.enums.model_pricing import (
ModelPricingRuleStatus,
PricingSnapshotStage,
ProviderCostStatus,
)
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit_record import CreditRecord
from app.models.generated_resource import GeneratedResource
from app.models.generation_record import GenerationRecord
from app.models.image_engine import ImageEngine
from app.models.model_config import ModelConfig
from app.models.model_pricing_rule import ModelPricingRule
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.token_usage import TokenUsage
from app.models.video_engine import VideoEngine
from app.services.model_pricing.attachment_snapshot_service import (
build_attachment_snapshot,
build_generation_snapshot,
)
from app.services.model_pricing.rule_service import normalize_provider
from app.services.model_pricing.snapshot_service import finalize_credit_record_pricing
from app.services.model_pricing.usage_normalizer import (
normalize_provider_media_usage,
parse_size,
safe_float,
safe_int,
safe_json_dict,
)
from app.services.operation_log_service import log_model_pricing_event
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
)
PROCESSABLE_CHARGE_KINDS = {
CreditRecordChargeKind.MEDIA.value,
CreditRecordChargeKind.TEXT_PROMPT.value,
CreditRecordChargeKind.VIDEO_ANALYSIS.value,
}
NON_PROVIDER_CHARGE_KINDS = {
CreditRecordChargeKind.FILE_PARSE.value,
CreditRecordChargeKind.VISION_INPUT.value,
CreditRecordChargeKind.MODULE_CREATE.value,
CreditRecordChargeKind.VIDEO_SPLIT.value,
CreditRecordChargeKind.RECHARGE.value,
CreditRecordChargeKind.REFUND.value,
CreditRecordChargeKind.ADMIN_ADJUST.value,
CreditRecordChargeKind.TEAM_INTERNAL.value,
}
INCOMPLETE_COST_STATUSES = {
None,
"",
ProviderCostStatus.PENDING.value,
ProviderCostStatus.UNMATCHED_RULE.value,
ProviderCostStatus.USAGE_MISSING.value,
ProviderCostStatus.ERROR.value,
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value,
ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value,
ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value,
}
class BackfillContext:
def __init__(self) -> None:
self.owner_maps: dict[str, dict[str, Any]] = {}
self.linked_chat_tasks: dict[str, ChatGenerationTask] = {}
self.token_usage_by_id: dict[str, TokenUsage] = {}
self.token_usage_by_owner: dict[tuple[str, str], TokenUsage] = {}
self.model_configs: dict[str, ModelConfig] = {}
self.image_engines: dict[str, ImageEngine] = {}
self.video_engines: dict[str, VideoEngine] = {}
self.resource_counts: dict[tuple[str, str], dict[str, int]] = {}
self.current_rules_by_category: dict[str, list[ModelPricingRule]] = {}
class BackfillStats:
def __init__(self) -> None:
self.scanned = 0
self.changed = 0
self.calculated = 0
self.estimated = 0
self.rule_bound = 0
self.missing_engine = 0
self.missing_usage = 0
self.unmatched_rule = 0
self.not_applicable = 0
self.skipped_completed = 0
self.skipped_non_provider = 0
self.failed = 0
self.force_repriced = 0
def merge(self, other: "BackfillStats") -> None:
for key in vars(self):
setattr(self, key, getattr(self, key) + getattr(other, key))
def as_dict(self) -> dict[str, int]:
return {key: int(value) for key, value in vars(self).items()}
def _parse_date(value: str | None, *, end: bool = False) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
if end and len(value) <= 10:
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
return parsed
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _owner_key(record: CreditRecord) -> tuple[str, str] | None:
owner_type = str(record.owner_type or "").strip()
owner_id = str(record.owner_id or record.related_id or "").strip()
return (owner_type, owner_id) if owner_type and owner_id else None
def _is_provider_cost_candidate(record: CreditRecord) -> bool:
if record.type != CreditRecordType.CONSUME.value:
return False
if record.charge_action not in {None, "", CreditRecordAction.CHARGE.value}:
return False
charge_kind = str(record.charge_kind or "").strip()
if charge_kind in NON_PROVIDER_CHARGE_KINDS:
return False
if charge_kind in PROCESSABLE_CHARGE_KINDS:
return True
if str(record.media_type or "").lower() in {"image", "video"}:
return True
if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)):
return True
return bool(record.token_usage_id or _owner_key(record))
def _raw_references(owner: Any) -> Any:
if owner is None:
return None
if isinstance(owner, ShotReplicateTaskSet):
return [
{
"type": "video",
"url": owner.video_url,
"path": owner.video_path,
"duration_seconds": owner.video_duration_seconds,
"role": "reference_video",
"billable_input": True,
}
]
if isinstance(owner, ShotReplicateSegment):
return [
{
"type": "video",
"url": owner.segment_video_url,
"path": owner.segment_video_path,
"duration_seconds": owner.duration_seconds,
"role": "reference_video",
"billable_input": True,
}
]
if hasattr(owner, "media_references"):
return getattr(owner, "media_references", None)
if hasattr(owner, "input_json"):
return getattr(owner, "input_json", None)
return None
def _provider_response(owner: Any) -> Any:
if owner is None:
return None
for name in (
"provider_response_json",
"output_json",
"analysis_raw_json",
"analysis_result_json",
"analysis_json",
):
value = getattr(owner, name, None)
if value:
return value
return None
def _nested_usage(value: Any) -> dict[str, Any]:
data = safe_json_dict(value)
if not data:
return {}
usage = data.get("usage")
if isinstance(usage, Mapping):
return deepcopy(dict(usage))
for key in ("result", "payload", "data", "response"):
child = data.get(key)
if isinstance(child, Mapping):
found = _nested_usage(child)
if found:
return found
return {}
def _select_token_usage(record: CreditRecord, owner: Any, ctx: BackfillContext) -> TokenUsage | None:
token_usage_id = (
record.token_usage_id
or getattr(owner, "token_usage_id", None)
)
if token_usage_id and token_usage_id in ctx.token_usage_by_id:
return ctx.token_usage_by_id[token_usage_id]
key = _owner_key(record)
if key and key in ctx.token_usage_by_owner:
return ctx.token_usage_by_owner[key]
return None
def _usage_from_record(record: CreditRecord, owner: Any, token_usage: TokenUsage | None) -> dict[str, Any]:
usage = deepcopy(dict(record.usage_snapshot_json or {}))
payload_usage = _nested_usage(_provider_response(owner))
for key, value in payload_usage.items():
usage.setdefault(key, value)
owner_input = safe_int(getattr(owner, "input_tokens", None))
owner_output = safe_int(getattr(owner, "output_tokens", None))
owner_total = safe_int(getattr(owner, "total_tokens", None))
token_input = safe_int(getattr(token_usage, "input_tokens", None))
token_output = safe_int(getattr(token_usage, "output_tokens", None))
token_total = safe_int(getattr(token_usage, "total_tokens", None))
input_tokens = max(0, safe_int(record.input_tokens, token_input or owner_input))
output_tokens = max(0, safe_int(record.output_tokens, token_output or owner_output))
total_tokens = max(
0,
safe_int(record.total_tokens, token_total or owner_total or (input_tokens + output_tokens)),
)
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
if output_tokens <= 0 and total_tokens > input_tokens:
output_tokens = total_tokens - input_tokens
usage.update(
{
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"context_tokens": max(0, safe_int(usage.get("context_tokens"), input_tokens)),
"usage_source": "backfill",
"provider_usage_primary": bool(record.provider_usage_primary),
}
)
return usage
def _get_engine_object(record: CreditRecord, owner: Any, ctx: BackfillContext) -> Any:
engine_id = str(record.engine_id or getattr(owner, "engine_id", None) or "").strip()
if not engine_id:
return None
media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower()
if media_type == "image":
return ctx.image_engines.get(engine_id)
if media_type == "video":
return ctx.video_engines.get(engine_id)
return ctx.image_engines.get(engine_id) or ctx.video_engines.get(engine_id)
def _restore_engine_snapshot(
record: CreditRecord,
*,
owner: Any,
linked_chat: ChatGenerationTask | None,
token_usage: TokenUsage | None,
ctx: BackfillContext,
) -> None:
response = safe_json_dict(_provider_response(linked_chat or owner))
owner_snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None))
chat_snapshot = safe_json_dict(getattr(linked_chat, "engine_snapshot_json", None))
engine = _get_engine_object(record, linked_chat or owner, ctx)
model_config_id = (
getattr(owner, "model_config_id", None)
or getattr(token_usage, "model_config_id", None)
or (record.engine_id if record.engine_type == "model" else None)
or response.get("model_config_id")
)
model_config = ctx.model_configs.get(str(model_config_id)) if model_config_id else None
model_name = (
record.engine_model_name
or response.get("model")
or response.get("model_name")
or chat_snapshot.get("model_name")
or chat_snapshot.get("engine_model_name")
or owner_snapshot.get("model_name")
or owner_snapshot.get("engine_model_name")
or getattr(engine, "model_name", None)
or getattr(model_config, "model_name", None)
)
provider = (
record.engine_provider
or chat_snapshot.get("provider")
or chat_snapshot.get("engine_provider")
or owner_snapshot.get("provider")
or owner_snapshot.get("engine_provider")
or getattr(engine, "provider", None)
or getattr(model_config, "provider", None)
)
if not provider and str(model_name or "").lower().startswith("doubao-"):
provider = "volcengine"
engine_id = (
record.engine_id
or getattr(linked_chat, "engine_id", None)
or getattr(owner, "engine_id", None)
or chat_snapshot.get("engine_id")
or chat_snapshot.get("id")
or owner_snapshot.get("engine_id")
or owner_snapshot.get("id")
or getattr(engine, "id", None)
or getattr(model_config, "id", None)
)
engine_name = (
record.engine_name
or chat_snapshot.get("engine_name")
or chat_snapshot.get("name")
or owner_snapshot.get("engine_name")
or owner_snapshot.get("name")
or getattr(engine, "name", None)
or getattr(model_config, "name", None)
)
record.engine_id = str(engine_id) if engine_id else None
record.engine_name = str(engine_name) if engine_name else None
record.engine_model_name = str(model_name) if model_name else None
record.engine_provider = (
normalize_provider(str(provider), record.engine_model_name)
if provider or record.engine_model_name
else None
)
if not record.engine_type:
if model_config is not None:
record.engine_type = "model"
else:
record.engine_type = str(record.media_type or getattr(linked_chat or owner, "gen_type", None) or "") or None
def _infer_category(record: CreditRecord, owner: Any) -> str | None:
charge_kind = str(record.charge_kind or "").strip()
if charge_kind in {CreditRecordChargeKind.TEXT_PROMPT.value, CreditRecordChargeKind.VIDEO_ANALYSIS.value}:
return "text"
media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower()
if media_type in {"image", "video"}:
return media_type
if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)):
return "text"
return None
def _apply_unique_current_rule_fallback(record: CreditRecord, owner: Any, ctx: BackfillContext) -> None:
if record.engine_model_name:
return
category = _infer_category(record, owner)
rules = ctx.current_rules_by_category.get(category or "", [])
unique_models = {(rule.provider, rule.model_name) for rule in rules}
if len(unique_models) != 1:
return
provider, model_name = next(iter(unique_models))
record.engine_provider = provider
record.engine_model_name = model_name
record.engine_type = record.engine_type or ("model" if category == "text" else category)
def _apply_snapshot_fields(
record: CreditRecord,
*,
attachment_snapshot: dict[str, Any] | None,
attachment_counts: dict[str, Any] | None,
generation_snapshot: dict[str, Any] | None,
generation_counts: dict[str, Any] | None,
) -> None:
if attachment_snapshot is not None:
record.attachment_snapshot_json = deepcopy(attachment_snapshot)
for key, value in (attachment_counts or {}).items():
if hasattr(record, key):
setattr(record, key, value)
if generation_snapshot is not None:
record.generation_snapshot_json = deepcopy(generation_snapshot)
for key, value in (generation_counts or {}).items():
if hasattr(record, key):
setattr(record, key, value)
def _merge_resource_counts(
*,
record: CreditRecord,
generation_snapshot: dict[str, Any] | None,
generation_counts: dict[str, Any] | None,
resource_counts: dict[tuple[str, str], dict[str, int]],
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
source_model_by_owner_type = {
CreditRecordOwnerType.CHAT_GENERATION_TASK.value: SOURCE_MODEL_CHAT_TASK,
CreditRecordOwnerType.GENERATION_RECORD.value: SOURCE_MODEL_GENERATION_RECORD,
}
source_model = source_model_by_owner_type.get(record.owner_type or "")
bucket = (
resource_counts.get((source_model, record.owner_id))
if source_model and record.owner_id
else None
)
if not bucket:
return generation_snapshot, generation_counts
counts = deepcopy(dict(generation_counts or {}))
counts.update(
{
"generated_image_count": int(bucket["image"]),
"generated_video_count": int(bucket["video"]),
"generated_total_count": int(bucket["image"] + bucket["video"]),
}
)
snapshot = deepcopy(dict(generation_snapshot or {}))
snapshot["generated_image_count"] = counts["generated_image_count"]
snapshot["generated_video_count"] = counts["generated_video_count"]
snapshot["generated_total_count"] = counts["generated_total_count"]
snapshot["output_count_source"] = "generated_resource"
return snapshot, counts
def _ensure_image_output_items(
*,
usage: dict[str, Any],
owner: Any,
successful_count: int,
) -> None:
if successful_count <= 0:
return
items = usage.get("output_items")
if isinstance(items, list) and len(items) >= successful_count:
return
width, height = parse_size(getattr(owner, "image_px", None))
if width <= 0 or height <= 0:
return
usage["output_items"] = [
{
"index": index,
"width": width,
"height": height,
"pixels": width * height,
"size_source": "request_explicit_backfill",
}
for index in range(successful_count)
]
usage["output_pixels_are_estimated"] = True
async def _load_context(
db,
records: list[CreditRecord],
*,
backfill_reference_at: datetime,
) -> BackfillContext:
ctx = BackfillContext()
ids_by_type: dict[str, set[str]] = {}
for record in records:
key = _owner_key(record)
if key:
ids_by_type.setdefault(key[0], set()).add(key[1])
if record.source_step_id:
ids_by_type.setdefault(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, set()).add(record.source_step_id)
model_by_owner = {
CreditRecordOwnerType.CHAT_GENERATION_TASK.value: ChatGenerationTask,
CreditRecordOwnerType.GENERATION_RECORD.value: GenerationRecord,
CreditRecordOwnerType.MODULE_GENERATION_PROJECT.value: ModuleGenerationProject,
CreditRecordOwnerType.MODULE_GENERATION_STEP.value: ModuleGenerationStep,
CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value: ShotReplicateTaskSet,
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value: ShotReplicateSegment,
}
for owner_type, model in model_by_owner.items():
ids = ids_by_type.get(owner_type) or set()
if not ids:
ctx.owner_maps[owner_type] = {}
continue
rows = (await db.execute(select(model).where(model.id.in_(ids)))).scalars().all()
ctx.owner_maps[owner_type] = {row.id: row for row in rows}
step_rows = list(ctx.owner_maps.get(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, {}).values())
linked_chat_ids = {str(step.chat_task_id) for step in step_rows if getattr(step, "chat_task_id", None)}
if linked_chat_ids:
rows = (
await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(linked_chat_ids)))
).scalars().all()
ctx.linked_chat_tasks = {row.id: row for row in rows}
token_usage_ids = {str(record.token_usage_id) for record in records if record.token_usage_id}
token_usage_ids.update(
str(step.token_usage_id)
for step in step_rows
if getattr(step, "token_usage_id", None)
)
owner_ids = {key[1] for record in records if (key := _owner_key(record))}
token_filters = []
if token_usage_ids:
token_filters.append(TokenUsage.id.in_(token_usage_ids))
if owner_ids:
token_filters.append(TokenUsage.owner_id.in_(owner_ids))
if token_filters:
token_rows = (
await db.execute(
select(TokenUsage)
.where(or_(*token_filters))
.order_by(TokenUsage.created_at.desc())
)
).scalars().all()
for row in token_rows:
ctx.token_usage_by_id[row.id] = row
if row.owner_type and row.owner_id:
ctx.token_usage_by_owner.setdefault((row.owner_type, row.owner_id), row)
model_config_ids = {
str(row.model_config_id)
for row in ctx.token_usage_by_id.values()
if row.model_config_id
}
model_config_ids.update(
str(step.model_config_id)
for step in step_rows
if getattr(step, "model_config_id", None)
)
model_config_ids.update(
str(record.engine_id)
for record in records
if record.engine_type == "model" and record.engine_id
)
if model_config_ids:
rows = (
await db.execute(select(ModelConfig).where(ModelConfig.id.in_(model_config_ids)))
).scalars().all()
ctx.model_configs = {row.id: row for row in rows}
engine_ids = {str(record.engine_id) for record in records if record.engine_id}
for owner_map in ctx.owner_maps.values():
engine_ids.update(str(row.engine_id) for row in owner_map.values() if getattr(row, "engine_id", None))
engine_ids.update(str(row.engine_id) for row in ctx.linked_chat_tasks.values() if row.engine_id)
if engine_ids:
image_rows = (
await db.execute(select(ImageEngine).where(ImageEngine.id.in_(engine_ids)))
).scalars().all()
video_rows = (
await db.execute(select(VideoEngine).where(VideoEngine.id.in_(engine_ids)))
).scalars().all()
ctx.image_engines = {row.id: row for row in image_rows}
ctx.video_engines = {row.id: row for row in video_rows}
source_ids = {
record.owner_id
for record in records
if record.owner_id
and record.owner_type
in {
CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
CreditRecordOwnerType.GENERATION_RECORD.value,
}
}
if source_ids:
resources = (
await db.execute(
select(GeneratedResource)
.where(GeneratedResource.source_id.in_(source_ids))
.where(GeneratedResource.deleted_at.is_(None))
)
).scalars().all()
for resource in resources:
key = (str(resource.source_model or ""), resource.source_id)
bucket = ctx.resource_counts.setdefault(key, {"image": 0, "video": 0})
resource_type = str(resource.resource_type or "").lower()
if resource_type in bucket:
bucket[resource_type] += 1
current_rules = (
await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(ModelPricingRule.effective_from <= backfill_reference_at)
.where(
or_(
ModelPricingRule.effective_to.is_(None),
ModelPricingRule.effective_to > backfill_reference_at,
)
)
.order_by(ModelPricingRule.model_category, ModelPricingRule.model_name)
)
).scalars().all()
for rule in current_rules:
ctx.current_rules_by_category.setdefault(rule.model_category, []).append(rule)
return ctx
def _record_state(record: CreditRecord) -> tuple[Any, ...]:
return (
record.engine_id,
record.engine_provider,
record.engine_model_name,
record.pricing_rule_id,
record.pricing_version_code,
record.provider_cost_status,
record.provider_cost_amount,
record.pricing_snapshot_hash,
record.attachment_total_count,
record.generated_total_count,
deepcopy(record.usage_snapshot_json),
deepcopy(record.attachment_snapshot_json),
deepcopy(record.generation_snapshot_json),
)
def _classify_result(record: CreditRecord, stats: BackfillStats, *, previous_rule_id: str | None, forced: bool) -> None:
status = record.provider_cost_status
if record.pricing_rule_id and record.pricing_rule_id != previous_rule_id:
stats.rule_bound += 1
if status == ProviderCostStatus.CALCULATED.value:
stats.calculated += 1
if forced:
stats.force_repriced += 1
elif status == ProviderCostStatus.ESTIMATED.value:
stats.estimated += 1
if forced:
stats.force_repriced += 1
elif status == ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value:
stats.missing_engine += 1
elif status == ProviderCostStatus.USAGE_MISSING.value:
stats.missing_usage += 1
elif status == ProviderCostStatus.UNMATCHED_RULE.value:
stats.unmatched_rule += 1
elif status == ProviderCostStatus.NOT_APPLICABLE.value:
stats.not_applicable += 1
async def run(args) -> None:
start_at = _parse_date(args.start_date)
end_at = _parse_date(args.end_date, end=True)
backfill_reference_at = _utcnow()
total = BackfillStats()
last_id: str | None = None
async with async_session() as db:
while True:
filters = [CreditRecord.type == CreditRecordType.CONSUME.value]
filters.append(
or_(
CreditRecord.charge_action == CreditRecordAction.CHARGE.value,
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "",
)
)
filters.append(CreditRecord.created_at <= backfill_reference_at)
if last_id:
filters.append(CreditRecord.id > last_id)
if start_at:
filters.append(CreditRecord.created_at >= start_at)
if end_at:
filters.append(CreditRecord.created_at <= end_at)
if args.only_user_id:
filters.append(CreditRecord.user_id == args.only_user_id)
if args.only_owner_type:
filters.append(CreditRecord.owner_type == args.only_owner_type)
if args.only_missing and not args.force:
filters.append(
or_(
CreditRecord.pricing_rule_id.is_(None),
CreditRecord.provider_cost_status.is_(None),
CreditRecord.provider_cost_status.in_([value for value in INCOMPLETE_COST_STATUSES if value]),
CreditRecord.attachment_snapshot_json.is_(None),
CreditRecord.generation_snapshot_json.is_(None),
)
)
records = (
await db.execute(
select(CreditRecord)
.where(*filters)
.order_by(CreditRecord.id)
.limit(args.batch_size)
.with_for_update(skip_locked=True)
)
).scalars().all()
if not records:
break
ctx = await _load_context(
db,
records,
backfill_reference_at=backfill_reference_at,
)
batch = BackfillStats()
for record in records:
batch.scanned += 1
record_id = str(record.id)
user_id = str(record.user_id) if record.user_id else None
owner_type = str(record.owner_type or "") or None
owner_id = str(record.owner_id or record.related_id or "") or None
if not _is_provider_cost_candidate(record):
batch.skipped_non_provider += 1
continue
if (
not args.force
and record.provider_cost_status == ProviderCostStatus.CALCULATED.value
and record.pricing_rule_id
and record.attachment_snapshot_json is not None
and record.generation_snapshot_json is not None
):
batch.skipped_completed += 1
continue
before = _record_state(record)
previous_rule_id = record.pricing_rule_id
try:
async with db.begin_nested():
key = _owner_key(record)
owner = ctx.owner_maps.get(key[0], {}).get(key[1]) if key else None
if owner is None and record.source_step_id:
owner = ctx.owner_maps.get(
CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
{},
).get(record.source_step_id)
linked_chat = None
if isinstance(owner, ModuleGenerationStep) and owner.chat_task_id:
linked_chat = ctx.linked_chat_tasks.get(owner.chat_task_id)
media_owner = linked_chat or owner
token_usage = _select_token_usage(record, owner, ctx)
usage = _usage_from_record(record, owner, token_usage)
attachment_snapshot, attachment_counts = build_attachment_snapshot(
_raw_references(media_owner or owner)
)
generation_snapshot: dict[str, Any] | None = deepcopy(record.generation_snapshot_json)
generation_counts: dict[str, Any] | None = None
gen_type = str(
record.media_type
or getattr(media_owner, "gen_type", None)
or ""
).lower()
response = _provider_response(media_owner or owner)
if gen_type in {"image", "video"} and media_owner is not None:
generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
media_owner,
provider_response=response,
stage=PricingSnapshotStage.BACKFILL.value,
)
provider_usage = normalize_provider_media_usage(
response,
gen_type=gen_type,
fallback_total_tokens=max(
safe_int(record.total_tokens),
safe_int(getattr(media_owner, "video_tokens_used", None)),
),
request_image_px=getattr(media_owner, "image_px", None),
requested_output_count=max(
1,
safe_int((generation_counts or {}).get("requested_output_count"), 1),
),
provider_input_image_count=safe_int(
attachment_counts.get("provider_input_image_count")
),
)
usage.update(generation_usage)
usage.update(provider_usage)
usage.update(
{
"has_input_video": bool(
attachment_counts.get("provider_input_video_count")
),
"provider_input_image_count": safe_int(
provider_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
),
"input_image_count": safe_int(
provider_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
),
"input_video_duration_seconds": float(
attachment_counts.get("attachment_video_duration_seconds") or 0
),
"input_audio_duration_seconds": float(
attachment_counts.get("attachment_audio_duration_seconds") or 0
),
"usage_stage": PricingSnapshotStage.BACKFILL.value,
}
)
generation_snapshot, generation_counts = _merge_resource_counts(
record=record,
generation_snapshot=generation_snapshot,
generation_counts=generation_counts,
resource_counts=ctx.resource_counts,
)
if generation_counts:
successful_count = (
int(generation_counts.get("generated_image_count") or 0)
if gen_type == "image"
else int(generation_counts.get("generated_video_count") or 0)
)
usage["successful_output_count"] = successful_count
usage["generated_image_count"] = int(
generation_counts.get("generated_image_count") or 0
)
usage["generated_video_count"] = int(
generation_counts.get("generated_video_count") or 0
)
if gen_type == "image":
_ensure_image_output_items(
usage=usage,
owner=media_owner,
successful_count=successful_count,
)
_restore_engine_snapshot(
record,
owner=owner,
linked_chat=linked_chat,
token_usage=token_usage,
ctx=ctx,
)
_apply_unique_current_rule_fallback(record, media_owner or owner, ctx)
if record.charge_action in {None, ""}:
record.charge_action = CreditRecordAction.CHARGE.value
_apply_snapshot_fields(
record,
attachment_snapshot=attachment_snapshot,
attachment_counts=attachment_counts,
generation_snapshot=generation_snapshot,
generation_counts=generation_counts,
)
await finalize_credit_record_pricing(
db,
charge=record,
usage=usage,
stage=PricingSnapshotStage.BACKFILL.value,
attachment_snapshot=attachment_snapshot,
attachment_counts=attachment_counts,
generation_snapshot=generation_snapshot,
generation_counts=generation_counts,
allow_upgrade_estimated=True,
pricing_reference_at=backfill_reference_at,
use_locked_rule=False,
force_reprice=bool(args.force),
backfill_metadata={
"is_backfilled": True,
"pricing_basis": "current_published_rule",
"backfill_reference_at": backfill_reference_at.isoformat(),
"original_credit_created_at": (
record.created_at.isoformat() if record.created_at else None
),
"command": "backfill_credit_record_snapshots",
},
)
after = _record_state(record)
if before != after:
batch.changed += 1
_classify_result(
record,
batch,
previous_rule_id=previous_rule_id,
forced=bool(args.force),
)
except Exception as exc:
batch.failed += 1
log_model_pricing_event(
event_type="pricing_backfill_record_failed",
event_status="failed",
user_id=user_id,
credit_record_id=record_id,
owner_type=owner_type,
owner_id=owner_id,
error=str(exc),
detail={
"backfill_reference_at": backfill_reference_at.isoformat(),
"force": bool(args.force),
},
)
last_id = str(records[-1].id)
total.merge(batch)
log_model_pricing_event(
event_type="pricing_backfill_batch",
event_status="success" if batch.failed == 0 else "warning",
detail={
**batch.as_dict(),
"batch_size": len(records),
"last_id": last_id,
"commit": bool(args.commit),
"force": bool(args.force),
"backfill_reference_at": backfill_reference_at.isoformat(),
},
)
if args.commit:
await db.commit()
else:
await db.rollback()
print(
f"batch={len(records)} scanned={batch.scanned} changed={batch.changed} "
f"bound={batch.rule_bound} calculated={batch.calculated} estimated={batch.estimated} "
f"missing_engine={batch.missing_engine} missing_usage={batch.missing_usage} "
f"unmatched_rule={batch.unmatched_rule} skipped_completed={batch.skipped_completed} "
f"skipped_non_provider={batch.skipped_non_provider} failed={batch.failed} last_id={last_id}"
)
mode = "COMMIT" if args.commit else "DRY-RUN"
summary = " ".join(f"{key}={value}" for key, value in total.as_dict().items())
print(
f"{mode} DONE backfill_reference_at={backfill_reference_at.isoformat()} "
f"force={bool(args.force)} {summary}"
)
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"一次性将历史消费流水按执行时当前已发布的模型计价规则补齐:"
"同时恢复模型、附件/产出快照、绑定规则并计算供应商成本。"
)
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--dry-run", action="store_true", help="执行完整计算但最终回滚")
group.add_argument("--commit", action="store_true", help="分批提交补录结果")
parser.add_argument("--batch-size", type=int, default=500)
parser.add_argument("--start-date")
parser.add_argument("--end-date")
parser.add_argument("--only-user-id")
parser.add_argument("--only-owner-type")
parser.add_argument(
"--only-missing",
action="store_true",
default=False,
help="仅扫描规则/成本或附件/产出快照尚未完整的流水",
)
parser.add_argument(
"--force",
action="store_true",
default=False,
help="按当前发布规则覆盖已经核算过的历史计价结果",
)
args = parser.parse_args()
args.batch_size = max(1, min(args.batch_size, 5000))
asyncio.run(run(args))
if __name__ == "__main__":
main()
@@ -1,115 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
from sqlalchemy import select
from app.models.base import async_session
from app.models.menu_config import MenuConfig
from app.models.model_pricing_rule import ModelPricingRule
from app.services.model_pricing.rule_service import create_rule
from app.services.model_pricing.seed_data import volcengine_pricing_seed_rules
from app.utils.id_gen import generate_id
async def _ensure_admin_menu(db) -> bool:
exists = (
await db.execute(
select(MenuConfig.id)
.where(MenuConfig.menu_target == "admin")
.where(MenuConfig.path == "/model-pricing")
.limit(1)
)
).scalar_one_or_none()
if exists:
return False
group_id = (
await db.execute(
select(MenuConfig.id)
.where(MenuConfig.menu_target == "admin")
.where(MenuConfig.menu_type == "group")
.where(MenuConfig.label == "模型设置")
.limit(1)
)
).scalar_one_or_none()
if not group_id:
group_id = generate_id()
db.add(
MenuConfig(
id=group_id,
path="",
label="模型设置",
icon="RobotOutlined",
sort_order=98,
is_active=True,
menu_type="group",
menu_target="admin",
)
)
await db.flush()
db.add(
MenuConfig(
id=generate_id(),
path="/model-pricing",
label="模型计价",
icon="DollarOutlined",
sort_order=4,
is_active=True,
menu_type="page",
menu_target="admin",
parent_id=group_id,
)
)
await db.flush()
return True
async def run(*, commit: bool) -> None:
async with async_session() as db:
created = skipped = 0
menu_created = await _ensure_admin_menu(db)
for payload in volcengine_pricing_seed_rules():
exists = (
await db.execute(
select(ModelPricingRule.id)
.where(ModelPricingRule.provider == payload["provider"])
.where(ModelPricingRule.model_name == payload["model_name"])
.where(ModelPricingRule.version_code == payload["version_code"])
.limit(1)
)
).scalar_one_or_none()
if exists:
skipped += 1
print(f"SKIP {payload['model_name']} {payload['version_code']} id={exists}")
continue
draft_payload = dict(payload)
draft_payload.pop("publish_status", None)
snapshot = await create_rule(db, payload=draft_payload, operator_id=None)
created += 1
effective_from = snapshot["effective_from"]
print(
f"CREATE_DRAFT {snapshot['model_name']} {snapshot['version_code']} id={snapshot['id']} "
f"effective_from={effective_from.isoformat()}"
)
if commit:
await db.commit()
print(f"COMMIT created={created} skipped={skipped} menu_created={menu_created}")
else:
await db.rollback()
print(f"DRY-RUN created={created} skipped={skipped} menu_created={menu_created}")
def main() -> None:
parser = argparse.ArgumentParser(description="初始化火山模型计价草稿(不会自动发布,需人工核价后在后台发布)")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--dry-run", action="store_true")
group.add_argument("--commit", action="store_true")
args = parser.parse_args()
asyncio.run(run(commit=bool(args.commit)))
if __name__ == "__main__":
main()
-2
View File
@@ -112,8 +112,6 @@ class Settings(BaseSettings):
# ChatAPI async generation pipeline settings
CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = ""
# Celery result backend 仅保留近期排障状态;业务恢复以数据库状态和业务日志为准。
CELERY_RESULT_EXPIRES_SECONDS: int = 7200
# Celery async 兼容配置。
# single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。
# direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。
-114
View File
@@ -1,114 +0,0 @@
from __future__ import annotations
from enum import Enum
class ModelPricingProvider(str, Enum):
VOLCENGINE = "volcengine"
class ModelPricingCategory(str, Enum):
TEXT = "text"
IMAGE = "image"
VIDEO = "video"
class ModelPricingBillingMode(str, Enum):
TEXT_TOKEN_TIERED = "text_token_tiered"
IMAGE_PER_OUTPUT = "image_per_output"
IMAGE_INPUT_OUTPUT_TIERED = "image_input_output_tiered"
VIDEO_TOKEN_RATE = "video_token_rate"
class ModelPricingCalculatorVersion(str, Enum):
TEXT_TOKEN_TIERED_V1 = "text_token_tiered_v1"
IMAGE_PER_OUTPUT_V1 = "image_per_output_v1"
IMAGE_INPUT_OUTPUT_TIERED_V1 = "image_input_output_tiered_v1"
VIDEO_PIXEL_TOKEN_V1 = "video_pixel_token_v1"
class ModelPricingRuleStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
DISABLED = "disabled"
class ProviderCostStatus(str, Enum):
NOT_APPLICABLE = "not_applicable"
NOT_INCURRED = "not_incurred"
PENDING = "pending"
CALCULATED = "calculated"
ESTIMATED = "estimated"
UNMATCHED_RULE = "unmatched_rule"
USAGE_MISSING = "usage_missing"
ERROR = "error"
PROVIDER_RESULT_UNCERTAIN = "provider_result_uncertain"
HISTORICAL_PRICE_UNAVAILABLE = "historical_price_unavailable"
HISTORICAL_ENGINE_UNAVAILABLE = "historical_engine_unavailable"
class PricingUsageSource(str, Enum):
PROVIDER = "provider"
PROVIDER_RESPONSE = "provider_response"
REQUEST_FORMULA = "request_formula"
ENGINE_SNAPSHOT = "engine_snapshot"
PRICING_RULE_MAP = "pricing_rule_map"
BACKFILL = "backfill"
MANUAL = "manual"
UNAVAILABLE = "unavailable"
class PricingSnapshotStage(str, Enum):
REQUEST_LOCKED = "request_locked"
PROVIDER_SYNC_COMPLETED = "provider_sync_completed"
PROVIDER_ASYNC_COMPLETED = "provider_async_completed"
RESOURCE_DOWNLOAD_COMPLETED = "resource_download_completed"
BACKFILL = "backfill"
class PricingDimensionSource(str, Enum):
PROVIDER_RESPONSE = "provider_response"
REQUEST_EXPLICIT = "request_explicit"
ENGINE_SNAPSHOT = "engine_snapshot"
PRICING_RULE_MAP = "pricing_rule_map"
UNAVAILABLE = "unavailable"
class PricingBillBy(str, Enum):
SUCCESSFUL_OUTPUT_COUNT = "successful_output_count"
REQUESTED_OUTPUT_COUNT = "requested_output_count"
PROVIDER_BILLED_COUNT = "provider_billed_count"
class PricingInferenceMode(str, Enum):
ONLINE = "online"
FLEX = "flex"
BATCH = "batch"
MODEL_PRICING_BILLING_MODE_LABELS = {
ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: "文本分档 Token 计价",
ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: "图片按输出数量计价",
ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: "输入图片 + 输出像素分档计价",
ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: "视频像素 Token 计价",
}
MODEL_PRICING_RULE_STATUS_LABELS = {
ModelPricingRuleStatus.DRAFT.value: "草稿",
ModelPricingRuleStatus.PUBLISHED.value: "已发布",
ModelPricingRuleStatus.DISABLED.value: "已停用",
}
PROVIDER_COST_STATUS_LABELS = {
ProviderCostStatus.NOT_APPLICABLE.value: "不涉及供应商成本",
ProviderCostStatus.NOT_INCURRED.value: "供应商费用未发生",
ProviderCostStatus.PENDING.value: "待核算",
ProviderCostStatus.CALCULATED.value: "已核算",
ProviderCostStatus.ESTIMATED.value: "估算",
ProviderCostStatus.UNMATCHED_RULE.value: "未匹配价格",
ProviderCostStatus.USAGE_MISSING.value: "用量缺失",
ProviderCostStatus.ERROR.value: "核算异常",
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value: "供应商结果不确定",
ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value: "历史价格缺失",
ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value: "历史引擎缺失",
}
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 50
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
+1 -2
View File
@@ -7,7 +7,6 @@ from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.credit_record import CreditRecord
from app.models.model_config import ModelConfig
from app.models.model_pricing_rule import ModelPricingRule
from app.models.system_config import SystemConfig
from app.models.notification import Notification
from app.models.notification_read import NotificationRead
@@ -42,7 +41,7 @@ __all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
"init_database", "close_database",
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "ModelPricingRule", "SystemConfig", "Notification", "PaymentOrder",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
@@ -73,8 +73,6 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# 当前媒体扣费尝试号;Provider 回调必须按 owner + attempt_no 精确回填。
current_billing_attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
+8 -55
View File
@@ -1,22 +1,14 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, JSON, Numeric, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import Float, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
JsonType = JSON().with_variant(JSONB, "postgresql")
class CreditRecord(Base, TimestampMixin):
__tablename__ = "credit_records"
__table_args__ = (
# 正式计费幂等键:同一用户同一个业务流水只能写入一次。
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
Index("ix_credit_records_related_type", "related_id", "type"),
@@ -26,13 +18,12 @@ class CreditRecord(Base, TimestampMixin):
Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"),
Index("ix_credit_records_team_time", "team_id_snapshot", "created_at"),
Index("ix_credit_records_user_kind_team_time", "user_type_snapshot", "frontend_user_kind_snapshot", "team_id_snapshot", "created_at"),
Index("ix_credit_records_pricing_status_time", "provider_cost_status", "created_at"),
Index("ix_credit_records_pricing_model_time", "engine_provider", "engine_model_name", "pricing_reference_at"),
Index("ix_credit_records_pricing_version", "pricing_version_code", "pricing_rule_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(16), index=True)
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
@@ -68,53 +59,15 @@ class CreditRecord(Base, TimestampMixin):
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
engine_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
engine_model_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 不可变计价版本快照。后续调价不得基于规则表重新计算历史流水。
pricing_rule_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_pricing_rules.id", ondelete="RESTRICT"), nullable=True, index=True
)
pricing_version_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
pricing_billing_mode: Mapped[str | None] = mapped_column(String(48), nullable=True)
pricing_calculator_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
pricing_usage_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
pricing_reference_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
pricing_effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
pricing_effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
pricing_snapshot_schema_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
pricing_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_cost_currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_cost_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
provider_cost_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
provider_cost_calculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_cost_finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_usage_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
provider_cost_is_estimated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# 财务高频聚合字段平铺,避免列表/导出时逐条解析 JSON 或回查任务链。
attachment_image_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
attachment_video_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
attachment_audio_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
attachment_total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
attachment_video_duration_seconds: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False, default=0, server_default="0")
attachment_audio_duration_seconds: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False, default=0, server_default="0")
requested_output_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
generated_image_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
generated_video_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
generated_total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
pricing_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True)
usage_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True)
attachment_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True)
generation_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True)
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
@@ -35,11 +35,6 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
DateTime(timezone=True), nullable=True
)
seedance_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 生成开始时锁定实际引擎,后续提交、轮询和计价均使用同一快照。
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
current_billing_attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
text_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
@@ -1,63 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, Index, Integer, JSON, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
JsonType = JSON().with_variant(JSONB, "postgresql")
class ModelPricingRule(Base, TimestampMixin):
__tablename__ = "model_pricing_rules"
__table_args__ = (
Index(
"uq_model_pricing_rules_provider_model_version",
"provider",
"model_name",
"version_code",
unique=True,
),
Index(
"ix_model_pricing_rules_resolve",
"provider",
"model_name",
"publish_status",
"effective_from",
"effective_to",
),
Index("ix_model_pricing_rules_category_status", "model_category", "publish_status"),
CheckConstraint(
"effective_to IS NULL OR effective_to >= effective_from",
name="ck_model_pricing_rules_effective_range",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
model_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
model_category: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
billing_mode: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
calculator_version: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
version_code: Mapped[str] = mapped_column(String(64), nullable=False)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
publish_status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY")
rule_schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# 规则 JSON 统一使用“构建新 dict 后整体赋值”,禁止嵌套原地修改。
rule_json: Mapped[dict[str, Any]] = mapped_column(JsonType, nullable=False, default=dict)
rule_content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+1 -1
View File
@@ -41,7 +41,7 @@ class User(Base, TimestampMixin):
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=5, server_default="5", nullable=False
Integer, default=50, server_default="50", nullable=False
)
@property
+2 -48
View File
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_asset_limit: int = 5
private_portrait_asset_limit: int = 50
model_config = {"from_attributes": True}
@@ -73,7 +73,7 @@ class CreateUserRequest(BaseModel):
is_admin: bool = False
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
allowed_menus: list | None = None
private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
private_portrait_asset_limit: int = Field(default=50, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
class UpdateFrontendUserKindRequest(BaseModel):
@@ -135,20 +135,6 @@ class AdminCreditRecordSummaryOut(BaseModel):
total_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
attachment_image_count: int = 0
attachment_video_count: int = 0
attachment_audio_count: int = 0
attachment_total_count: int = 0
generated_image_count: int = 0
generated_video_count: int = 0
generated_total_count: int = 0
provider_cost_calculated_total: str = "0.00000000"
provider_cost_estimated_total: str = "0.00000000"
provider_cost_combined_total: str = "0.00000000"
provider_cost_total: str = "0.00000000"
provider_cost_pending_count: int = 0
provider_cost_estimated_count: int = 0
provider_cost_abnormal_count: int = 0
class AdminCreditRecordOut(BaseModel):
@@ -201,38 +187,6 @@ class AdminCreditRecordOut(BaseModel):
engine_name: str | None = None
engine_provider: str | None = None
engine_model_name: str | None = None
pricing_rule_id: str | None = None
pricing_version_code: str | None = None
pricing_billing_mode: str | None = None
pricing_billing_mode_label: str | None = None
pricing_calculator_version: str | None = None
pricing_usage_source: str | None = None
pricing_reference_at: str | None = None
pricing_effective_from: str | None = None
pricing_effective_to: str | None = None
pricing_snapshot_hash: str | None = None
provider_cost_currency: str = "CNY"
provider_cost_amount: str = "0.00000000"
provider_cost_status: str | None = None
provider_cost_status_label: str | None = None
provider_cost_calculated_at: str | None = None
provider_cost_finalized_at: str | None = None
provider_cost_is_estimated: bool = False
provider_usage_primary: bool = False
attachment_image_count: int = 0
attachment_video_count: int = 0
attachment_audio_count: int = 0
attachment_total_count: int = 0
attachment_video_duration_seconds: str = "0.000000"
attachment_audio_duration_seconds: str = "0.000000"
requested_output_count: int = 0
generated_image_count: int = 0
generated_video_count: int = 0
generated_total_count: int = 0
pricing_snapshot_json: dict | None = None
usage_snapshot_json: dict | None = None
attachment_snapshot_json: dict | None = None
generation_snapshot_json: dict | None = None
created_at: str | None = None
@@ -1,86 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, model_validator
CALCULATOR_PATTERN = "^(text_token_tiered_v1|image_per_output_v1|image_input_output_tiered_v1|video_pixel_token_v1)$"
class ModelPricingRuleBase(BaseModel):
provider: str = Field(default="volcengine", max_length=32)
model_name: str = Field(..., min_length=1, max_length=128)
model_category: str = Field(..., pattern="^(text|image|video)$")
billing_mode: str = Field(..., pattern="^(text_token_tiered|image_per_output|image_input_output_tiered|video_token_rate)$")
calculator_version: str = Field(..., pattern=CALCULATOR_PATTERN)
version_code: str = Field(..., min_length=1, max_length=64)
effective_from: datetime
effective_to: datetime | None = None
currency: str = Field(default="CNY", min_length=3, max_length=8)
rule_schema_version: int = Field(default=1, ge=1, le=100)
rule_json: dict[str, Any]
source_url: str | None = None
source_updated_at: datetime | None = None
remark: str | None = None
@model_validator(mode="after")
def validate_time_range(self):
if self.effective_to and self.effective_to <= self.effective_from:
raise ValueError("effective_to 必须晚于 effective_from")
return self
class ModelPricingRuleCreate(ModelPricingRuleBase):
pass
class ModelPricingRuleUpdate(BaseModel):
provider: str | None = Field(None, max_length=32)
model_name: str | None = Field(None, min_length=1, max_length=128)
model_category: str | None = Field(None, pattern="^(text|image|video)$")
billing_mode: str | None = Field(None, pattern="^(text_token_tiered|image_per_output|image_input_output_tiered|video_token_rate)$")
calculator_version: str | None = Field(None, pattern=CALCULATOR_PATTERN)
version_code: str | None = Field(None, min_length=1, max_length=64)
effective_from: datetime | None = None
effective_to: datetime | None = None
currency: str | None = Field(None, min_length=3, max_length=8)
rule_schema_version: int | None = Field(None, ge=1, le=100)
rule_json: dict[str, Any] | None = None
source_url: str | None = None
source_updated_at: datetime | None = None
remark: str | None = None
class ModelPricingRuleOut(ModelPricingRuleBase):
id: str
publish_status: str
rule_content_hash: str
referenced_count: int = 0
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ModelPricingRuleListOut(BaseModel):
items: list[ModelPricingRuleOut]
total: int
class ModelPricingPreviewRequest(BaseModel):
billing_mode: str
calculator_version: str = Field(..., pattern=CALCULATOR_PATTERN)
rule_json: dict[str, Any]
usage: dict[str, Any]
currency: str = "CNY"
class ModelPricingPreviewOut(BaseModel):
amount: str
currency: str
is_estimated: bool
selected_rate: str | None = None
usage_source: str
breakdown: dict[str, Any]
+1 -1
View File
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
allowed_menus: list | None = None
must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_asset_limit: int = 5
private_portrait_asset_limit: int = 50
team_id: str | None = None
team_name: str | None = None
is_team_manager: bool = False
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, case, distinct, func, or_, select
@@ -17,10 +16,6 @@ from app.enums.credit_record import (
CREDIT_RECORD_TYPE_LABELS,
CreditRecordSubject,
)
from app.enums.model_pricing import (
MODEL_PRICING_BILLING_MODE_LABELS,
PROVIDER_COST_STATUS_LABELS,
)
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.models.chat_generation_task import ChatGenerationTask
@@ -39,16 +34,19 @@ CST = timezone(timedelta(hours=8))
def _iso(dt: Any) -> str | None:
if dt is None:
return None
if isinstance(dt, datetime):
if dt.tzinfo is None:
# 数据库已经按东八区业务时间返回但丢了 tzinfo 时,不再额外 +8
return dt.replace(tzinfo=CST).isoformat()
return dt.astimezone(CST).isoformat()
try:
return dt.isoformat()
except Exception:
return str(dt)
def _round2(value: Any) -> float:
try:
return round(float(value or 0), 2)
@@ -56,19 +54,11 @@ def _round2(value: Any) -> float:
return 0.0
def _decimal_string(value: Any, scale: int = 8) -> str:
try:
quant = Decimal("1").scaleb(-scale)
return format(Decimal(str(value or 0)).quantize(quant), "f")
except Exception:
return format(Decimal("0").quantize(Decimal("1").scaleb(-scale)), "f")
def _as_date_start(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=CST).astimezone(timezone.utc)
return datetime.strptime(value, "%Y-%m-%d")
except Exception:
return None
@@ -77,11 +67,7 @@ def _as_date_end(value: str | None) -> datetime | None:
if not value:
return None
try:
return (
datetime.strptime(value, "%Y-%m-%d")
.replace(tzinfo=CST, hour=23, minute=59, second=59, microsecond=999999)
.astimezone(timezone.utc)
)
return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999)
except Exception:
return None
@@ -106,12 +92,6 @@ def _build_filters(
source_module: str | None = None,
source_step_code: str | None = None,
billing_scene: str | None = None,
engine_provider: str | None = None,
engine_model_name: str | None = None,
pricing_version_code: str | None = None,
provider_cost_status: str | None = None,
provider_cost_is_estimated: bool | None = None,
has_attachment: bool | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> list[Any]:
@@ -127,7 +107,10 @@ def _build_filters(
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
if team_id:
filters.append(CreditRecord.team_id_snapshot.is_(None) if team_id == TEAM_UNASSIGNED_VALUE else CreditRecord.team_id_snapshot == team_id)
if team_id == TEAM_UNASSIGNED_VALUE:
filters.append(CreditRecord.team_id_snapshot.is_(None))
else:
filters.append(CreditRecord.team_id_snapshot == team_id)
if record_type:
filters.append(CreditRecord.type == record_type)
if credit_subject:
@@ -142,18 +125,6 @@ def _build_filters(
filters.append(CreditRecord.source_step_code == source_step_code)
if billing_scene:
filters.append(CreditRecord.billing_scene == billing_scene)
if engine_provider:
filters.append(CreditRecord.engine_provider == engine_provider)
if engine_model_name:
filters.append(CreditRecord.engine_model_name.ilike(f"%{engine_model_name.strip()}%"))
if pricing_version_code:
filters.append(CreditRecord.pricing_version_code == pricing_version_code)
if provider_cost_status:
filters.append(CreditRecord.provider_cost_status == provider_cost_status)
if provider_cost_is_estimated is not None:
filters.append(CreditRecord.provider_cost_is_estimated.is_(provider_cost_is_estimated))
if has_attachment is not None:
filters.append(CreditRecord.attachment_total_count > 0 if has_attachment else CreditRecord.attachment_total_count == 0)
start = _as_date_start(start_date)
end = _as_date_end(end_date)
if start:
@@ -168,6 +139,7 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
for record in records:
if record.owner_type and record.owner_id:
grouped.setdefault(record.owner_type, set()).add(record.owner_id)
model_map: dict[str, Any] = {
"chat_generation_task": ChatGenerationTask,
"generation_record": GenerationRecord,
@@ -194,6 +166,7 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
owner_deleted_at = None
if record.owner_type and record.owner_id:
owner_deleted, owner_deleted_at = deleted_map.get((record.owner_type, record.owner_id), (False, None))
user_type = record.user_type_snapshot or (user.user_type if user else None)
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
return {
@@ -246,38 +219,6 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"engine_name": record.engine_name,
"engine_provider": record.engine_provider,
"engine_model_name": record.engine_model_name,
"pricing_rule_id": record.pricing_rule_id,
"pricing_version_code": record.pricing_version_code,
"pricing_billing_mode": record.pricing_billing_mode,
"pricing_billing_mode_label": _label(MODEL_PRICING_BILLING_MODE_LABELS, record.pricing_billing_mode),
"pricing_calculator_version": record.pricing_calculator_version,
"pricing_usage_source": record.pricing_usage_source,
"pricing_reference_at": _iso(record.pricing_reference_at),
"pricing_effective_from": _iso(record.pricing_effective_from),
"pricing_effective_to": _iso(record.pricing_effective_to),
"pricing_snapshot_hash": record.pricing_snapshot_hash,
"provider_cost_currency": record.provider_cost_currency or "CNY",
"provider_cost_amount": _decimal_string(record.provider_cost_amount, 8),
"provider_cost_status": record.provider_cost_status,
"provider_cost_status_label": _label(PROVIDER_COST_STATUS_LABELS, record.provider_cost_status),
"provider_cost_calculated_at": _iso(record.provider_cost_calculated_at),
"provider_cost_finalized_at": _iso(record.provider_cost_finalized_at),
"provider_cost_is_estimated": bool(record.provider_cost_is_estimated),
"provider_usage_primary": bool(record.provider_usage_primary),
"attachment_image_count": int(record.attachment_image_count or 0),
"attachment_video_count": int(record.attachment_video_count or 0),
"attachment_audio_count": int(record.attachment_audio_count or 0),
"attachment_total_count": int(record.attachment_total_count or 0),
"attachment_video_duration_seconds": _decimal_string(record.attachment_video_duration_seconds, 6),
"attachment_audio_duration_seconds": _decimal_string(record.attachment_audio_duration_seconds, 6),
"requested_output_count": int(record.requested_output_count or 0),
"generated_image_count": int(record.generated_image_count or 0),
"generated_video_count": int(record.generated_video_count or 0),
"generated_total_count": int(record.generated_total_count or 0),
"pricing_snapshot_json": record.pricing_snapshot_json,
"usage_snapshot_json": record.usage_snapshot_json,
"attachment_snapshot_json": record.attachment_snapshot_json,
"generation_snapshot_json": record.generation_snapshot_json,
"created_at": _iso(record.created_at),
}
@@ -299,12 +240,6 @@ async def list_admin_credit_records(
source_module: str | None = None,
source_step_code: str | None = None,
billing_scene: str | None = None,
engine_provider: str | None = None,
engine_model_name: str | None = None,
pricing_version_code: str | None = None,
provider_cost_status: str | None = None,
provider_cost_is_estimated: bool | None = None,
has_attachment: bool | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> dict[str, Any]:
@@ -323,12 +258,6 @@ async def list_admin_credit_records(
source_module=source_module,
source_step_code=source_step_code,
billing_scene=billing_scene,
engine_provider=engine_provider,
engine_model_name=engine_model_name,
pricing_version_code=pricing_version_code,
provider_cost_status=provider_cost_status,
provider_cost_is_estimated=provider_cost_is_estimated,
has_attachment=has_attachment,
start_date=start_date,
end_date=end_date,
)
@@ -340,81 +269,52 @@ async def list_admin_credit_records(
base_query = base_query.where(where_clause)
count_query = count_query.where(where_clause)
total = (await db.execute(count_query)).scalar() or 0
rows = (
await db.execute(
base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).all()
result = await db.execute(
base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
rows = result.all()
records = [row[0] for row in rows]
deleted_map = await _load_deleted_map(db, records)
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
media_consume = and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume")
summary_query = select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0).label("total_recharge"),
func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0).label("total_consume"),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0).label("total_refund"),
func.count(CreditRecord.id).label("transaction_count"),
func.count(distinct(case((media_consume, func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("generation_count"),
func.count(case((media_consume, 1), else_=None)).label("generation_attempt_count"),
func.count(distinct(case((and_(media_consume, CreditRecord.media_type == "image"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("image_generation_count"),
func.count(distinct(case((and_(media_consume, CreditRecord.media_type == "video"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("video_generation_count"),
func.coalesce(func.sum(case((and_(media_consume, CreditRecord.media_type == "image"), func.abs(CreditRecord.amount)), else_=0)), 0).label("image_consume"),
func.coalesce(func.sum(case((and_(media_consume, CreditRecord.media_type == "video"), func.abs(CreditRecord.amount)), else_=0)), 0).label("video_consume"),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0).label("text_consume"),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0).label("analysis_consume"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.total_tokens), else_=0)), 0).label("total_tokens"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.input_tokens), else_=0)), 0).label("input_tokens"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.output_tokens), else_=0)), 0).label("output_tokens"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_image_count), else_=0)), 0).label("attachment_image_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_video_count), else_=0)), 0).label("attachment_video_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_audio_count), else_=0)), 0).label("attachment_audio_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_total_count), else_=0)), 0).label("attachment_total_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_image_count), else_=0)), 0).label("generated_image_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_video_count), else_=0)), 0).label("generated_video_count"),
func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_total_count), else_=0)), 0).label("generated_total_count"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "calculated"), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_calculated_total"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "estimated"), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_estimated_total"),
func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status.in_(["calculated", "estimated"])), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_combined_total"),
func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "pending"), 1), else_=None)).label("provider_cost_pending_count"),
func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "estimated"), 1), else_=None)).label("provider_cost_estimated_count"),
func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status.in_(["unmatched_rule", "usage_missing", "error", "historical_price_unavailable", "historical_engine_unavailable", "provider_result_uncertain"])), 1), else_=None)).label("provider_cost_abnormal_count"),
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
func.count(CreditRecord.id),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), 1), else_=None)),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
if where_clause is not None:
summary_query = summary_query.where(where_clause)
s = (await db.execute(summary_query)).one()._mapping
s = (await db.execute(summary_query)).one()
summary = {
"total_recharge": _round2(s["total_recharge"]),
"total_consume": _round2(s["total_consume"]),
"total_refund": _round2(s["total_refund"]),
"transaction_count": int(s["transaction_count"] or 0),
"generation_count": int(s["generation_count"] or 0),
"generation_attempt_count": int(s["generation_attempt_count"] or 0),
"image_generation_count": int(s["image_generation_count"] or 0),
"video_generation_count": int(s["video_generation_count"] or 0),
"image_consume": _round2(s["image_consume"]),
"video_consume": _round2(s["video_consume"]),
"text_consume": _round2(s["text_consume"]),
"analysis_consume": _round2(s["analysis_consume"]),
"total_tokens": int(s["total_tokens"] or 0),
"input_tokens": int(s["input_tokens"] or 0),
"output_tokens": int(s["output_tokens"] or 0),
"attachment_image_count": int(s["attachment_image_count"] or 0),
"attachment_video_count": int(s["attachment_video_count"] or 0),
"attachment_audio_count": int(s["attachment_audio_count"] or 0),
"attachment_total_count": int(s["attachment_total_count"] or 0),
"generated_image_count": int(s["generated_image_count"] or 0),
"generated_video_count": int(s["generated_video_count"] or 0),
"generated_total_count": int(s["generated_total_count"] or 0),
"provider_cost_calculated_total": _decimal_string(s["provider_cost_calculated_total"], 8),
"provider_cost_estimated_total": _decimal_string(s["provider_cost_estimated_total"], 8),
"provider_cost_combined_total": _decimal_string(s["provider_cost_combined_total"], 8),
# 兼容旧前端字段,值等于实际+估算;新页面不得标记成“已核算成本”。
"provider_cost_total": _decimal_string(s["provider_cost_combined_total"], 8),
"provider_cost_pending_count": int(s["provider_cost_pending_count"] or 0),
"provider_cost_estimated_count": int(s["provider_cost_estimated_count"] or 0),
"provider_cost_abnormal_count": int(s["provider_cost_abnormal_count"] or 0),
"total_recharge": _round2(s[0]),
"total_consume": _round2(s[1]),
"total_refund": _round2(s[2]),
"transaction_count": int(s[3] or 0),
"generation_count": int(s[4] or 0),
"generation_attempt_count": int(s[5] or 0),
"image_generation_count": int(s[6] or 0),
"video_generation_count": int(s[7] or 0),
"image_consume": _round2(s[8]),
"video_consume": _round2(s[9]),
"text_consume": _round2(s[10]),
"analysis_consume": _round2(s[11]),
"total_tokens": int(s[12] or 0),
"input_tokens": int(s[13] or 0),
"output_tokens": int(s[14] or 0),
}
return {"items": items, "total": int(total), "summary": summary}
return {"items": items, "total": total, "summary": summary}
@@ -1,8 +1,6 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any, Mapping
from sqlalchemy import select
@@ -24,8 +22,6 @@ from app.models.module_generation_step import ModuleGenerationStep
from app.models.team import Team
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.services.model_pricing.attachment_snapshot_service import build_attachment_snapshot, parse_dimensions
from app.services.model_pricing.snapshot_service import build_refund_pricing_snapshot, enrich_credit_meta_with_pricing
@dataclass(slots=True)
@@ -60,40 +56,6 @@ class CreditRecordMeta:
engine_provider: str | None = None
engine_model_name: str | None = None
pricing_rule_id: str | None = None
pricing_version_code: str | None = None
pricing_billing_mode: str | None = None
pricing_calculator_version: str | None = None
pricing_usage_source: str | None = None
pricing_reference_at: datetime | None = None
pricing_effective_from: datetime | None = None
pricing_effective_to: datetime | None = None
pricing_snapshot_schema_version: int | None = None
pricing_snapshot_hash: str | None = None
provider_cost_currency: str | None = None
provider_cost_amount: Decimal | None = None
provider_cost_status: str | None = None
provider_cost_calculated_at: datetime | None = None
provider_cost_finalized_at: datetime | None = None
provider_usage_primary: bool | None = None
provider_cost_is_estimated: bool | None = None
attachment_image_count: int | None = None
attachment_video_count: int | None = None
attachment_audio_count: int | None = None
attachment_total_count: int | None = None
attachment_video_duration_seconds: Decimal | None = None
attachment_audio_duration_seconds: Decimal | None = None
requested_output_count: int | None = None
generated_image_count: int | None = None
generated_video_count: int | None = None
generated_total_count: int | None = None
pricing_snapshot_json: dict[str, Any] | None = None
usage_snapshot_json: dict[str, Any] | None = None
attachment_snapshot_json: dict[str, Any] | None = None
generation_snapshot_json: dict[str, Any] | None = None
user_type_snapshot: str | None = None
frontend_user_kind_snapshot: str | None = None
team_id_snapshot: str | None = None
@@ -116,28 +78,6 @@ def _normalize_frontend_kind(value: str | None) -> str:
return value or FrontendUserKind.EXTERNAL.value
_ATTACHMENT_META_FIELDS = (
"attachment_image_count",
"attachment_video_count",
"attachment_audio_count",
"attachment_total_count",
"attachment_video_duration_seconds",
"attachment_audio_duration_seconds",
)
def _apply_attachment_counts(meta: CreditRecordMeta, counts: Mapping[str, Any]) -> None:
"""只把 CreditRecord 实际存在的附件聚合字段平铺到元数据对象。
provider_input_* 属于供应商 usage 快照,不是 CreditRecordMeta/credit_records 顶层字段。
CreditRecordMeta 使用 slots=True,动态 setattr 会直接抛 AttributeError。
"""
for key in _ATTACHMENT_META_FIELDS:
value = counts.get(key)
if value is not None:
setattr(meta, key, value)
async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id: str) -> CreditRecordMeta:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
@@ -190,29 +130,14 @@ async def _apply_model_snapshot(db: AsyncSession, meta: CreditRecordMeta, usage:
async def get_engine_snapshot(db: AsyncSession, *, gen_type: str, engine_id: str | None) -> dict[str, str | None]:
"""锁定本次媒体扣费实际使用的引擎快照。
GenerationRecord 旧链路没有持久化 engine_id;此时必须与图片/视频生成服务保持一致,
选择当前启用且 priority 最高的引擎。这样价格版本在请求扣费时就被锁定,不能等任务
完成后再按当时的活动引擎或最新价格回算。
"""
if not engine_id:
return {"engine_type": (gen_type or None)}
gen_type = (gen_type or "").lower().strip()
if gen_type == "image":
query = select(ImageEngine)
if engine_id:
query = query.where(ImageEngine.id == engine_id)
else:
query = query.where(ImageEngine.is_active == True).order_by(ImageEngine.priority.desc())
elif gen_type == "video":
query = select(VideoEngine)
if engine_id:
query = query.where(VideoEngine.id == engine_id)
else:
query = query.where(VideoEngine.is_active == True).order_by(VideoEngine.priority.desc())
result = await db.execute(select(ImageEngine).where(ImageEngine.id == engine_id).limit(1))
else:
return {"engine_type": gen_type or None, "engine_id": engine_id}
engine = (await db.execute(query.limit(1))).scalar_one_or_none()
result = await db.execute(select(VideoEngine).where(VideoEngine.id == engine_id).limit(1))
engine = result.scalar_one_or_none()
if not engine:
return {"engine_type": gen_type or None, "engine_id": engine_id}
return {
@@ -297,18 +222,6 @@ async def build_generation_media_meta(
source_step_id: str | None = None,
source_step_code: str | None = None,
billing_scene: str | None = None,
media_references: Any = None,
image_size: str | None = None,
image_px: str | None = None,
aspect_ratio: str | None = None,
duration: int | float | None = None,
resolution: str | None = None,
fps: int | float | None = None,
generate_audio: bool | None = None,
inference_mode: str | None = None,
input_video_duration: float | None = None,
requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> CreditRecordMeta:
media_type = (gen_type or "").lower().strip() or None
if source_module is None:
@@ -331,76 +244,7 @@ async def build_generation_media_meta(
)
for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items():
setattr(meta, key, value)
requested_count = max(1, _safe_int(requested_output_count, 1))
if provider_uses_media_references is None:
# GenerationRecord 的附件只参与前置提示词优化,媒体供应商调用明确不再携带;
# ChatGenerationTask/模块任务则会在创建供应商任务时携带附件。
provider_uses_media_references = owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=provider_uses_media_references,
)
_apply_attachment_counts(meta, attachment_counts)
meta.attachment_snapshot_json = attachment_snapshot
width, height = parse_dimensions(image_px, image_size)
dimension_source = "request_explicit" if width > 0 and height > 0 else "unavailable"
meta.requested_output_count = requested_count
meta.generated_image_count = 0
meta.generated_video_count = 0
meta.generated_total_count = 0
meta.generation_snapshot_json = {
"schema_version": 1,
"gen_type": media_type,
"requested_output_count": requested_count,
"generated_image_count": 0,
"generated_video_count": 0,
"generated_total_count": 0,
"image_size": image_size,
"image_px": image_px,
"duration_seconds": float(duration or 0) or None,
"resolution": resolution,
"aspect_ratio": aspect_ratio,
"width": width or None,
"height": height or None,
"dimension_source": dimension_source,
"fps": float(fps or 0) or None,
"generate_audio": bool(generate_audio),
"inference_mode": inference_mode or "online",
"stage": "request_locked",
}
provider_input_image_count = _safe_int(attachment_counts.get("provider_input_image_count"))
provider_input_video_count = _safe_int(attachment_counts.get("provider_input_video_count"))
provider_input_audio_count = _safe_int(attachment_counts.get("provider_input_audio_count"))
provider_input_video_duration = float(
input_video_duration
if input_video_duration is not None
else attachment_counts["attachment_video_duration_seconds"] or 0
)
provider_input_audio_duration = float(attachment_counts["attachment_audio_duration_seconds"] or 0)
request_usage = {
"resolution": str(resolution or "").lower(),
"aspect_ratio": str(aspect_ratio or ""),
"output_width": width,
"output_height": height,
"dimension_source": dimension_source,
"output_video_duration_seconds": float(duration or 0),
"input_video_duration_seconds": provider_input_video_duration,
"input_audio_duration_seconds": provider_input_audio_duration,
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
"input_image_count": provider_input_image_count,
"has_input_video": bool(provider_input_video_count or provider_input_video_duration),
"requested_output_count": requested_count,
"successful_output_count": 0,
"fps": float(fps or 0),
"generate_audio": bool(generate_audio),
"inference_mode": inference_mode or "online",
"usage_stage": "request_locked",
}
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=request_usage, final=False)
return meta
async def build_generation_record_prompt_meta(
@@ -410,7 +254,6 @@ async def build_generation_record_prompt_meta(
attempt_no: int,
charge_kind: str,
usage: Mapping[str, Any],
media_references: Any = None,
) -> CreditRecordMeta:
scene_map = {
CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
@@ -431,14 +274,7 @@ async def build_generation_record_prompt_meta(
output_tokens=_safe_int(usage.get("output_tokens")),
total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))),
)
meta = await _apply_model_snapshot(db, meta, usage)
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=True,
)
_apply_attachment_counts(meta, attachment_counts)
meta.attachment_snapshot_json = attachment_snapshot
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
return await _apply_model_snapshot(db, meta, usage)
async def build_module_step_prompt_meta(
@@ -469,8 +305,7 @@ async def build_module_step_prompt_meta(
output_tokens=_safe_int(usage.get("output_tokens")),
total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))),
)
meta = await _apply_model_snapshot(db, meta, usage)
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
return await _apply_model_snapshot(db, meta, usage)
async def build_shot_video_analysis_meta(
@@ -502,12 +337,10 @@ async def build_shot_video_analysis_meta(
output_tokens=_safe_int(usage.get("output_tokens")),
total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))),
)
meta = await _apply_model_snapshot(db, meta, usage)
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
return await _apply_model_snapshot(db, meta, usage)
def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None) -> CreditRecordMeta:
pricing_snapshot, pricing_hash = build_refund_pricing_snapshot(charge)
return CreditRecordMeta(
owner_type=getattr(charge, "owner_type", None),
owner_id=getattr(charge, "owner_id", None),
@@ -530,35 +363,6 @@ def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None)
engine_name=getattr(charge, "engine_name", None),
engine_provider=getattr(charge, "engine_provider", None),
engine_model_name=getattr(charge, "engine_model_name", None),
pricing_rule_id=getattr(charge, "pricing_rule_id", None),
pricing_version_code=getattr(charge, "pricing_version_code", None),
pricing_billing_mode=getattr(charge, "pricing_billing_mode", None),
pricing_calculator_version=getattr(charge, "pricing_calculator_version", None),
pricing_usage_source=getattr(charge, "pricing_usage_source", None),
pricing_reference_at=getattr(charge, "pricing_reference_at", None),
pricing_effective_from=getattr(charge, "pricing_effective_from", None),
pricing_effective_to=getattr(charge, "pricing_effective_to", None),
pricing_snapshot_schema_version=getattr(charge, "pricing_snapshot_schema_version", None),
pricing_snapshot_hash=pricing_hash,
provider_cost_currency=getattr(charge, "provider_cost_currency", None),
provider_cost_amount=Decimal("0"),
provider_cost_status="not_applicable",
provider_cost_is_estimated=False,
provider_usage_primary=False,
attachment_image_count=getattr(charge, "attachment_image_count", 0),
attachment_video_count=getattr(charge, "attachment_video_count", 0),
attachment_audio_count=getattr(charge, "attachment_audio_count", 0),
attachment_total_count=getattr(charge, "attachment_total_count", 0),
attachment_video_duration_seconds=getattr(charge, "attachment_video_duration_seconds", Decimal("0")),
attachment_audio_duration_seconds=getattr(charge, "attachment_audio_duration_seconds", Decimal("0")),
requested_output_count=getattr(charge, "requested_output_count", 0),
generated_image_count=getattr(charge, "generated_image_count", 0),
generated_video_count=getattr(charge, "generated_video_count", 0),
generated_total_count=getattr(charge, "generated_total_count", 0),
pricing_snapshot_json=pricing_snapshot,
usage_snapshot_json=getattr(charge, "usage_snapshot_json", None),
attachment_snapshot_json=getattr(charge, "attachment_snapshot_json", None),
generation_snapshot_json=getattr(charge, "generation_snapshot_json", None),
user_type_snapshot=getattr(charge, "user_type_snapshot", None),
frontend_user_kind_snapshot=getattr(charge, "frontend_user_kind_snapshot", None),
)
@@ -296,14 +296,11 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
record_id=task_id,
gen_type="image",
image_size=size,
image_px=px,
aspect_ratio=proportion,
engine_id=engine.id,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
media_references=refs,
)
snapshot = _build_image_snapshot(engine, size, proportion, px)
task = ChatGenerationTask(
@@ -319,7 +316,6 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
current_billing_attempt_no=1,
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=req.idempotency_key,
@@ -396,15 +392,12 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
gen_type="video",
duration=duration,
resolution=resolution,
aspect_ratio=ratio,
fps=24,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
media_references=refs,
)
snapshot = _build_video_snapshot(engine, ratio, resolution, duration)
task = ChatGenerationTask(
@@ -423,7 +416,6 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
current_billing_attempt_no=1,
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=req.idempotency_key,
@@ -241,7 +241,6 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=usage,
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -271,7 +270,6 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -301,7 +299,6 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -460,13 +457,8 @@ async def charge_generation_media_by_params(
record_id: str,
gen_type: str,
image_size: str | None = None,
image_px: str | None = None,
aspect_ratio: str | None = None,
duration: int | None = None,
resolution: str | None = None,
fps: int | float | None = None,
generate_audio: bool | None = None,
inference_mode: str | None = None,
engine_id: str | None = None,
input_video_duration: float | None = None,
project_name: str | None = None,
@@ -478,9 +470,6 @@ async def charge_generation_media_by_params(
source_step_id: str | None = None,
source_step_code: str | None = None,
billing_scene: str | None = None,
media_references: Any = None,
requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> BillingSummary:
"""图片/视频媒体生成扣费。
@@ -515,18 +504,6 @@ async def charge_generation_media_by_params(
source_step_id=source_step_id,
source_step_code=source_step_code,
billing_scene=billing_scene,
media_references=media_references,
image_size=image_size,
image_px=image_px,
aspect_ratio=aspect_ratio,
duration=duration,
resolution=resolution,
fps=fps,
generate_audio=generate_audio,
inference_mode=inference_mode,
input_video_duration=input_video_duration,
requested_output_count=requested_output_count,
provider_uses_media_references=provider_uses_media_references,
)
if gen_type == "image":
@@ -584,8 +561,6 @@ async def charge_generation_media_for_record(
record_id=record.id,
gen_type=record.gen_type,
image_size=record.image_size,
image_px=record.image_px,
aspect_ratio=record.aspect_ratio or record.image_proportion,
duration=record.duration,
resolution=record.resolution,
project_name=project_name,
@@ -593,6 +568,4 @@ async def charge_generation_media_for_record(
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
media_references=record.media_references,
provider_uses_media_references=False,
)
@@ -17,7 +17,6 @@ from app.models.token_usage import TokenUsage
from app.services.generation_log_service import log_provider_call
from app.services.provider_limit import provider_limit
from app.utils.id_gen import generate_id
from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage
def _absolute_url(url: str) -> str:
@@ -181,7 +180,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
completion_tokens=output_tokens,
total_tokens=total_tokens,
)
return content, normalize_text_pricing_usage(usage, base={
return content, {
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
@@ -190,4 +189,4 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
})
}
@@ -14,7 +14,7 @@ from app.models.chat_generation_task import ChatGenerationTask
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
from app.services.generation_log_service import log_provider_call
from app.services.image_gen import submit_image_task
from app.services.image_gen import poll_image_task_status, submit_image_task
from app.services.provider_limit import provider_limit
from app.services.video_gen import poll_task_status, submit_video_task
@@ -165,7 +165,8 @@ def _try_json(text: Any) -> Any:
async def poll_provider_task(db: AsyncSession, task: ChatGenerationTask) -> dict:
engine = await get_runtime_engine(db, task)
task_id = task.seedance_task_id or task.provider_task_id
if task.gen_type != "video":
raise ValueError("当前火山图片引擎为同步生成,不允许进入 Provider 轮询链路")
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
return await poll_task_status(engine, task_id)
if task.gen_type == "video":
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
return await poll_task_status(engine, task_id)
async with provider_limit("ark_image_poll", settings.ARK_IMAGE_POLL_MAX_CONCURRENCY):
return await poll_image_task_status(engine, task_id)
@@ -17,7 +17,6 @@ from app.enums.generation_task import (
ChatGenerationTaskStatus,
GenerationType,
)
from app.enums.model_pricing import ProviderCostStatus
from app.models.chat_generation_task import ChatGenerationTask
from app.services.celery_download_recovery_service import (
ensure_aware_utc,
@@ -28,8 +27,6 @@ from app.services.celery_download_recovery_service import (
)
from app.services.generation_log_service import log_task_event
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
from app.services.media_token_usage_snapshot_service import mark_media_provider_cost_status
from app.services.model_pricing.usage_normalizer import extract_image_output_items
from app.services.generation_poll_schedule_service import ensure_video_poll_fields, is_poll_not_due, is_video_generation_task
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.redis_registry_service import (
@@ -376,46 +373,6 @@ async def _mark_failed(
return "mark_failed"
async def _mark_sync_image_provider_result_uncertain(
db: AsyncSession,
task: ChatGenerationTask,
*,
source: str,
payload: dict[str, Any] | None = None,
) -> str:
"""Stop automatic replay after an interrupted synchronous image provider call."""
attempt_no = task.current_billing_attempt_no
message = (
"同步图片任务在供应商调用阶段中断,无法确认火山是否已经生成结果;"
"为避免重复生成和重复计费,已停止自动重放并退款,请结合供应商调用日志人工核查。"
)
await mark_media_provider_cost_status(
db,
owner=task,
status=ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value,
reason=message,
usage_stage="provider_sync_recovery_uncertain",
)
await log_task_event(
task,
event_type="PROVIDER_RESULT_UNCERTAIN",
message=message,
detail={
"source": source,
"payload": payload or {},
"attempt_no": attempt_no,
"pipeline_stage": task.pipeline_stage,
},
)
return await _mark_failed(
db,
task,
error_message=message,
event_type="PROVIDER_RESULT_UNCERTAIN",
detail={"source": source, "attempt_no": attempt_no},
)
async def recover_one_generation_task(
db: AsyncSession,
task: ChatGenerationTask,
@@ -454,26 +411,6 @@ async def recover_one_generation_task(
has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip())
is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time))
# 同步图片可能已经提交并保存了 Provider Response,但 remote_result_url 因旧数据或中断未写入。
# 只从明确的 data 输出条目恢复,绝不递归扫描任意 URL。
if task.gen_type == GenerationType.IMAGE.value and not has_remote_result and task.provider_response_json:
output_items = extract_image_output_items(task.provider_response_json)
recovered_url = next((str(item.get("url")) for item in output_items if item.get("url")), None)
if recovered_url:
task.remote_result_url = recovered_url
task.pipeline_stage = ChatGenerationPipelineStage.RESULT_READY.value
await db.commit()
await log_task_event(
task,
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
message=f"{source} 从同步图片 Provider Response 恢复最终 URL,投递下载队列",
detail={"attempt_no": task.current_billing_attempt_no},
)
from app.tasks.generation_download_tasks import enqueue_download_task
await enqueue_download_task(db, task, recover=True, reason=f"{source}_sync_image_response_recovered")
return "recover_sync_image_from_provider_response"
# 最高优先级:只要远程结果 URL 已经落库,说明生成侧已经成功。
# 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。
if has_remote_result:
@@ -587,36 +524,21 @@ async def recover_one_generation_task(
)
return "recover_poll_has_provider_id"
# 未过 deadline,且没有结果 URL / 供应商任务 ID
# queued/preparing 代表尚未开始 Provider 调用,可以安全重投;同步图片一旦进入
# creating_provider_task/waiting_remote/polling/result_ready,结果可能已在供应商侧产生,
# 不能自动重放,否则可能产生第二次供应商费用。
if task.gen_type == GenerationType.IMAGE.value and task.pipeline_stage in {
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
}:
await _remove_poll_active(task.id)
return await _mark_sync_image_provider_result_uncertain(
db,
task,
source=source,
payload=redis_payload,
)
# 未过 deadline,且没有结果 URL / 供应商任务 ID
# 图片同步任务会重新进入 submit_image_task;视频/其它任务会重新创建供应商任务。
# 这里不能投 poll,因为没有 provider_task_id/seedance_task_id 可查询。
recoverable_create_stages = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
}
if task.pipeline_stage in recoverable_create_stages:
if task.pipeline_stage not in (
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
):
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
await db.commit()
@@ -625,7 +547,7 @@ async def recover_one_generation_task(
await log_task_event(
task,
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
message=f"{source} 发现任务未超时且尚无可恢复的供应商结果,恢复投递创建队列",
message=f"{source} 发现任务未超时且缺少 remote_result_url/供应商任务ID,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
chatapi_create_generation_task.apply_async(
@@ -635,6 +557,24 @@ async def recover_one_generation_task(
)
return "recover_create_no_remote_no_provider_before_deadline"
# result_ready 但没有 URL 是脏状态;未过 deadline 时回创建队列重新处理,过期上面已标记超时。
if task.pipeline_stage == ChatGenerationPipelineStage.RESULT_READY.value:
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
await db.commit()
await _remove_poll_active(task.id)
await log_task_event(
task,
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
message=f"{source} 发现 result_ready 但缺少 remote_result_url,未超时,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
chatapi_create_generation_task.apply_async(
args=[task.id],
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
return "recover_create_result_ready_no_url_before_deadline"
return f"skip_stage_{task.pipeline_stage}"
@@ -116,8 +116,6 @@ async def create_chat_generation_task_for_module(
record_id=task_id,
gen_type="image",
image_size=size,
image_px=px,
aspect_ratio=proportion,
engine_id=engine.id,
project_name=billing_project_name,
description_prefix=billing_description_prefix,
@@ -128,7 +126,6 @@ async def create_chat_generation_task_for_module(
source_step_id=billing_source_step_id,
source_step_code=billing_source_step_code,
billing_scene=billing_scene,
media_references=refs,
)
snapshot = _build_image_snapshot(engine, size, proportion, px)
task = ChatGenerationTask(
@@ -145,7 +142,6 @@ async def create_chat_generation_task_for_module(
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
current_billing_attempt_no=1,
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=backend_idempotency_key,
@@ -174,8 +170,6 @@ async def create_chat_generation_task_for_module(
gen_type="video",
duration=selected_duration,
resolution=selected_resolution,
aspect_ratio=ratio,
fps=24,
engine_id=engine.id,
project_name=billing_project_name,
description_prefix=billing_description_prefix,
@@ -186,7 +180,6 @@ async def create_chat_generation_task_for_module(
source_step_id=billing_source_step_id,
source_step_code=billing_source_step_code,
billing_scene=billing_scene,
media_references=refs,
)
snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration)
task = ChatGenerationTask(
@@ -206,7 +199,6 @@ async def create_chat_generation_task_for_module(
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
current_billing_attempt_no=1,
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=backend_idempotency_key,
@@ -27,7 +27,6 @@ from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSc
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.utils.id_gen import generate_id
from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage
from app.services.resource_signed_url_service import build_resource_signed_url
DEFAULT_FRAME_RATE = "30fps"
@@ -1676,12 +1675,12 @@ async def optimize_hot_opening_video_prompt(
)
raise
usage = data.get("usage", {}) or {}
token_usage = normalize_text_pricing_usage(usage, base={
token_usage = {
"input_tokens": int(usage.get("prompt_tokens") or 0),
"output_tokens": int(usage.get("completion_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
"log_user_message": log_user_message,
})
}
token_usage_id = generate_id()
db.add(
TokenUsage(
+9 -34
View File
@@ -72,24 +72,6 @@ def _log_image_response(record_id: str, response_data: dict, error: str | None =
def is_sync_image_provider_result_uncertain(exc: BaseException) -> bool:
"""Whether replaying the synchronous image request could duplicate provider cost."""
if isinstance(exc, (TimeoutError, httpx.TimeoutException, httpx.TransportError)):
return True
text = str(exc or "").strip().lower()
markers = (
"timeout",
"timed out",
"connection reset",
"connection aborted",
"server disconnected",
"remote protocol",
"read error",
"network is unreachable",
)
return any(marker in text for marker in markers)
async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
"""Get the active image engine with highest priority."""
result = await db.execute(
@@ -130,7 +112,7 @@ def submit_image_task(
) -> dict:
"""Submit an image generation task via Ark SDK. Returns image_url."""
from volcenginesdkarkruntime import Ark
client = Ark(
base_url=engine.api_base,
api_key=engine.api_key,
@@ -179,23 +161,16 @@ def submit_image_task(
image=image_urls if image_urls else None,
)
image_url = result.data[0].url
response_data = {
"model": result.model,
"created": result.created,
"data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [],
"usage": {
"generated_images": result.usage.generated_images if getattr(result, "usage", None) and hasattr(result.usage, "generated_images") else len(result.data or []),
"input_tokens": result.usage.input_tokens if getattr(result, "usage", None) and hasattr(result.usage, "input_tokens") else 0,
"output_tokens": result.usage.output_tokens if getattr(result, "usage", None) and hasattr(result.usage, "output_tokens") else 0,
"total_tokens": result.usage.total_tokens if getattr(result, "usage", None) and hasattr(result.usage, "total_tokens") else 0,
},
"pricing_meta": {
"provider_input_image_count": len(image_urls),
"requested_output_count": 1,
"requested_size": record.image_px or record.image_size or engine.default_size,
"sync_completed": True,
},
"generated_images": result.usage.generated_images if hasattr(result.usage, 'generated_images') else 0,
"output_tokens": result.usage.output_tokens if hasattr(result.usage, 'output_tokens') else 0,
"total_tokens": result.usage.total_tokens if hasattr(result.usage, 'total_tokens') else 0,
}
}
except httpx.TimeoutException:
error_msg = "图片生成超时,请稍后重试"
@@ -209,12 +184,12 @@ def submit_image_task(
raise
finally:
client.close()
return {
"image_url": image_url,
"image_tokens": getattr(getattr(result, "usage", None), "total_tokens", 0),
"image_tokens": getattr(result.usage, "total_tokens", 0),
"response_data": json.dumps(response_data, ensure_ascii=False, default=str),
"error": str(getattr(result, "error", "") or ""),
"error": str(result.error) if result.error else "",
}
+2 -3
View File
@@ -13,7 +13,6 @@ from app.config import settings
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.utils.id_gen import generate_id
from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
@@ -386,7 +385,7 @@ async def _call_openai_compatible(
await db.flush()
content = data["choices"][0]["message"]["content"].strip()
token_usage = normalize_text_pricing_usage(usage, base={
token_usage = {
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
@@ -395,5 +394,5 @@ async def _call_openai_compatible(
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
})
}
return content, token_usage
@@ -1,68 +1,89 @@
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
import json
from typing import Any, Mapping
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.credit_record import CreditRecordAction, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordChargeKind,
CreditRecordOwnerType,
CreditRecordSourceModule,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.token_usage import TokenUsage
from app.services.model_pricing.attachment_snapshot_service import build_attachment_snapshot, build_generation_snapshot
from app.services.model_pricing.usage_normalizer import (
normalize_provider_media_usage,
safe_float,
safe_int,
safe_json_dict,
)
from app.services.model_pricing.snapshot_service import finalize_credit_record_pricing
from app.services.operation_log_service import log_model_pricing_event
from app.utils.id_gen import generate_id
def _provider_model_from_response(provider_response: Any) -> str | None:
data = safe_json_dict(provider_response)
for candidate in (
data.get("model"),
(data.get("data") or {}).get("model") if isinstance(data.get("data"), dict) else None,
(data.get("result") or {}).get("model") if isinstance(data.get("result"), dict) else None,
):
if candidate:
return str(candidate)
return None
def _safe_int(value: Any, default: int = 0) -> int:
try:
if value is None or value == "":
return default
return int(value)
except Exception:
return default
def _provider_task_id_from_response(provider_response: Any) -> str | None:
data = safe_json_dict(provider_response)
for candidate in (data.get("task_id"), data.get("id"), data.get("provider_task_id")):
if candidate:
return str(candidate)
return None
def _safe_json_dict(value: Any) -> dict[str, Any]:
if not value:
return {}
if isinstance(value, dict):
return value
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _engine_snapshot_from_owner(owner: Any) -> dict[str, Any]:
snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None))
return {
"provider": snapshot.get("provider") or snapshot.get("engine_provider"),
"model_name": snapshot.get("model_name") or snapshot.get("engine_model_name"),
"engine_name": snapshot.get("name") or snapshot.get("engine_name"),
"engine_id": snapshot.get("id") or snapshot.get("engine_id") or getattr(owner, "engine_id", None),
}
def _extract_usage(provider_response: Any) -> dict[str, Any]:
data = _safe_json_dict(provider_response)
usage = data.get("usage")
return usage if isinstance(usage, dict) else {}
async def _find_media_charge(
def _normalize_media_tokens(
*,
gen_type: str | None,
provider_response: Any = None,
fallback_total: int | None = None,
) -> tuple[int, int, int]:
usage = _extract_usage(provider_response)
input_tokens = _safe_int(usage.get("input_tokens"), 0)
output_tokens = _safe_int(
usage.get("output_tokens"),
_safe_int(usage.get("generated_tokens"), 0),
)
total_tokens = _safe_int(usage.get("total_tokens"), 0)
if total_tokens <= 0:
total_tokens = _safe_int(fallback_total, 0)
if output_tokens <= 0:
output_tokens = max(0, total_tokens - input_tokens)
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
# 图片生成多数供应商只返回 output/total,没有 input;保持 input=0。视频同理兼容缺字段。
return input_tokens, output_tokens, total_tokens
def _engine_model_from_provider_response(provider_response: Any) -> str | None:
data = _safe_json_dict(provider_response)
model = data.get("model")
return str(model) if model else None
async def _find_latest_media_charge(
db: AsyncSession,
*,
user_id: str,
owner_type: str,
owner_id: str,
attempt_no: int | None,
media_type: str | None,
) -> CreditRecord | None:
query = (
@@ -76,17 +97,9 @@ async def _find_media_charge(
)
if media_type:
query = query.where(CreditRecord.media_type == media_type)
if attempt_no is not None:
query = query.where(CreditRecord.attempt_no == attempt_no)
query = query.order_by(CreditRecord.created_at.desc()).limit(1).with_for_update()
return (await db.execute(query)).scalar_one_or_none()
# 兼容迁移上线时仍在执行、尚未写 current_billing_attempt_no 的旧任务:
# 只有候选消费流水唯一时才允许绑定;多次重试产生多条流水时宁可跳过,也不能猜“最新一条”。
candidates = (
await db.execute(query.order_by(CreditRecord.created_at.desc()).limit(2).with_for_update())
).scalars().all()
return candidates[0] if len(candidates) == 1 else None
query = query.order_by(CreditRecord.attempt_no.desc().nullslast(), CreditRecord.created_at.desc()).limit(1)
result = await db.execute(query)
return result.scalar_one_or_none()
async def _get_or_create_token_usage(
@@ -96,21 +109,20 @@ async def _get_or_create_token_usage(
input_tokens: int,
output_tokens: int,
total_tokens: int,
model_config_id: str | None = None,
) -> TokenUsage:
token_usage: TokenUsage | None = None
if charge.token_usage_id:
token_usage = (
await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1))
).scalar_one_or_none()
result = await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1))
token_usage = result.scalar_one_or_none()
if token_usage is None and charge.biz_key:
token_usage = (
await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1))
).scalar_one_or_none()
result = await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1))
token_usage = result.scalar_one_or_none()
if token_usage is None:
token_usage = TokenUsage(
id=generate_id(),
user_id=charge.user_id,
model_config_id=None,
model_config_id=model_config_id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
biz_key=charge.biz_key,
@@ -123,6 +135,13 @@ async def _get_or_create_token_usage(
db.add(token_usage)
await db.flush()
else:
token_usage.user_id = token_usage.user_id or charge.user_id
token_usage.model_config_id = token_usage.model_config_id or model_config_id
token_usage.owner_type = token_usage.owner_type or charge.owner_type
token_usage.owner_id = token_usage.owner_id or charge.owner_id
token_usage.biz_key = token_usage.biz_key or charge.biz_key
token_usage.source_module = token_usage.source_module or charge.source_module
token_usage.source_step_code = token_usage.source_step_code or charge.source_step_code
token_usage.input_tokens = input_tokens
token_usage.output_tokens = output_tokens
token_usage.total_tokens = total_tokens
@@ -133,190 +152,39 @@ async def _sync_charge_snapshot(
db: AsyncSession,
*,
charge: CreditRecord | None,
owner: Any,
gen_type: str,
stage: str,
gen_type: str | None,
provider_response: Any = None,
fallback_total: int = 0,
fallback_total: int | None = None,
) -> CreditRecord | None:
if not charge:
log_model_pricing_event(
event_type="pricing_snapshot_skip",
event_status="warning",
owner_type=owner.__class__.__name__,
owner_id=getattr(owner, "id", None),
message="未找到与 current_billing_attempt_no 匹配的媒体消费流水",
detail={"attempt_no": getattr(owner, "current_billing_attempt_no", None), "stage": stage},
)
return None
response = provider_response if provider_response is not None else getattr(owner, "provider_response_json", None)
provider_uses_media_references = charge.owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
getattr(owner, "media_references", None),
allow_provider_input=provider_uses_media_references,
)
generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
owner,
provider_response=response,
stage=stage,
)
existing_usage = deepcopy(dict(charge.usage_snapshot_json or {}))
locked_input_image_count = safe_int(
existing_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
)
locked_input_video_count = safe_int(
existing_usage.get("provider_input_video_count"),
safe_int(attachment_counts.get("provider_input_video_count")),
)
locked_input_audio_count = safe_int(
existing_usage.get("provider_input_audio_count"),
safe_int(attachment_counts.get("provider_input_audio_count")),
)
locked_input_video_duration = safe_float(
existing_usage.get("input_video_duration_seconds"),
safe_float(attachment_counts.get("attachment_video_duration_seconds")),
)
locked_input_audio_duration = safe_float(
existing_usage.get("input_audio_duration_seconds"),
safe_float(attachment_counts.get("attachment_audio_duration_seconds")),
)
provider_usage = normalize_provider_media_usage(
response,
input_tokens, output_tokens, total_tokens = _normalize_media_tokens(
gen_type=gen_type,
fallback_total_tokens=fallback_total,
request_image_px=getattr(owner, "image_px", None),
requested_output_count=max(1, safe_int(generation_counts.get("requested_output_count"), 1)),
provider_input_image_count=locked_input_image_count,
provider_response=provider_response,
fallback_total=fallback_total,
)
usage = {**existing_usage, **generation_usage, **provider_usage}
provider_input_image_count = safe_int(
provider_usage.get("provider_input_image_count"),
locked_input_image_count,
)
provider_input_video_count = safe_int(
provider_usage.get("provider_input_video_count"),
locked_input_video_count,
)
provider_input_audio_count = safe_int(
provider_usage.get("provider_input_audio_count"),
locked_input_audio_count,
)
input_video_duration_seconds = safe_float(
provider_usage.get("input_video_duration_seconds"),
locked_input_video_duration,
)
input_audio_duration_seconds = safe_float(
provider_usage.get("input_audio_duration_seconds"),
locked_input_audio_duration,
)
usage.update(
{
"has_input_video": bool(provider_input_video_count or input_video_duration_seconds),
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
"input_image_count": provider_input_image_count,
"input_video_duration_seconds": input_video_duration_seconds,
"input_audio_duration_seconds": input_audio_duration_seconds,
"usage_stage": stage,
}
)
input_tokens = max(0, safe_int(usage.get("input_tokens")))
output_tokens = max(0, safe_int(usage.get("output_tokens")))
total_tokens = max(0, safe_int(usage.get("total_tokens"), input_tokens + output_tokens))
if total_tokens > 0:
token_usage = await _get_or_create_token_usage(
db,
charge=charge,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
charge.token_usage_id = token_usage.id
charge.input_tokens = input_tokens
charge.output_tokens = output_tokens
charge.total_tokens = total_tokens
provider_model = _provider_model_from_response(response)
engine_snapshot = _engine_snapshot_from_owner(owner)
charge.engine_provider = charge.engine_provider or engine_snapshot.get("provider")
charge.engine_model_name = charge.engine_model_name or provider_model or engine_snapshot.get("model_name")
charge.engine_name = charge.engine_name or engine_snapshot.get("engine_name")
charge.engine_id = charge.engine_id or engine_snapshot.get("engine_id")
await finalize_credit_record_pricing(
db,
charge=charge,
usage=usage,
stage=stage,
attachment_snapshot=attachment_snapshot,
attachment_counts=attachment_counts,
generation_snapshot=generation_snapshot,
generation_counts=generation_counts,
allow_upgrade_estimated=True,
)
return charge
async def mark_media_provider_cost_status(
db: AsyncSession,
*,
owner: ChatGenerationTask | GenerationRecord,
status: str,
reason: str,
usage_stage: str,
) -> CreditRecord | None:
"""Finalize a media charge when a synchronous provider call failed or became uncertain.
This helper never commits and always resolves the charge by owner + billing attempt.
"""
if isinstance(owner, ChatGenerationTask):
owner_type = CreditRecordOwnerType.CHAT_GENERATION_TASK.value
else:
owner_type = CreditRecordOwnerType.GENERATION_RECORD.value
gen_type = str(getattr(owner, "gen_type", None) or "").lower().strip()
charge = await _find_media_charge(
db,
user_id=owner.user_id,
owner_type=owner_type,
owner_id=owner.id,
attempt_no=getattr(owner, "current_billing_attempt_no", None),
media_type=gen_type or None,
)
if not charge:
log_model_pricing_event(
event_type="pricing_snapshot_skip",
event_status="warning",
owner_type=owner_type,
owner_id=owner.id,
message="同步图片异常时未找到唯一媒体消费流水",
detail={
"attempt_no": getattr(owner, "current_billing_attempt_no", None),
"target_status": status,
"reason": reason,
},
)
return None
if charge.provider_cost_status in {ProviderCostStatus.CALCULATED.value, ProviderCostStatus.ESTIMATED.value}:
if total_tokens <= 0:
return charge
usage_snapshot = deepcopy(dict(charge.usage_snapshot_json or {}))
usage_snapshot.update(
{
"usage_stage": usage_stage,
"provider_error_reason": reason,
"provider_result_uncertain": status == ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value,
}
token_usage = await _get_or_create_token_usage(
db,
charge=charge,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
model_config_id=None,
)
charge.usage_snapshot_json = usage_snapshot
charge.provider_cost_status = status
charge.provider_cost_amount = None if status == ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value else 0
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = datetime.now(timezone.utc)
charge.provider_cost_finalized_at = datetime.now(timezone.utc)
charge.token_usage_id = token_usage.id
charge.input_tokens = input_tokens
charge.output_tokens = output_tokens
charge.total_tokens = total_tokens
# 兼容旧流水扣费时未冷备 engine_model_name 的场景,能从 provider response 推出来就补充。
provider_model = _engine_model_from_provider_response(provider_response)
if provider_model and not charge.engine_model_name:
charge.engine_model_name = provider_model
return charge
@@ -325,48 +193,33 @@ async def sync_chat_generation_task_media_token_snapshot(
task: ChatGenerationTask,
*,
provider_response: Any = None,
stage: str | None = None,
) -> CreditRecord | None:
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)) or not task:
return None
gen_type = (task.gen_type or "").lower().strip()
stage = stage or (
PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value
if gen_type == "image"
else PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value
)
response = provider_response if provider_response is not None else task.provider_response_json
"""把 ChatGenerationTask 图片/视频媒体生成 token 后置快照回填到积分流水。
callback_task_id = _provider_task_id_from_response(response)
current_task_id = task.seedance_task_id or task.provider_task_id
if gen_type == "video" and callback_task_id and current_task_id and callback_task_id != current_task_id:
log_model_pricing_event(
event_type="pricing_stale_callback_skip",
event_status="warning",
owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
owner_id=task.id,
message="旧 Provider 回调与当前任务 ID 不一致,已跳过",
detail={"callback_task_id": callback_task_id, "current_task_id": current_task_id},
)
媒体扣费发生在创建任务前供应商 usage 只能在创建/轮询成功后拿到
所以这里按 owner_type + owner_id + media_type 找到对应 media charge 流水并回填
"""
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)):
return None
if not task:
return None
charge = await _find_media_charge(
gen_type = (getattr(task, "gen_type", None) or "").lower().strip()
fallback_total = task.image_tokens_used if gen_type == "image" else task.video_tokens_used
response = provider_response if provider_response is not None else getattr(task, "provider_response_json", None)
charge = await _find_latest_media_charge(
db,
user_id=task.user_id,
owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
owner_id=task.id,
attempt_no=task.current_billing_attempt_no,
media_type=gen_type or None,
)
fallback_total = task.image_tokens_used if gen_type == "image" else task.video_tokens_used
return await _sync_charge_snapshot(
db,
charge=charge,
owner=task,
gen_type=gen_type,
stage=stage,
provider_response=response,
fallback_total=fallback_total or 0,
fallback_total=fallback_total,
)
@@ -375,32 +228,26 @@ async def sync_generation_record_media_token_snapshot(
record: GenerationRecord,
*,
provider_response: Any = None,
stage: str | None = None,
) -> CreditRecord | None:
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)) or not record:
"""把旧 GenerationRecord 图片/视频媒体生成 token 后置快照回填到积分流水。"""
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)):
return None
gen_type = (record.gen_type or "").lower().strip()
stage = stage or (
PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value
if gen_type == "image"
else PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value
)
response = provider_response if provider_response is not None else record.provider_response_json
charge = await _find_media_charge(
if not record:
return None
gen_type = (getattr(record, "gen_type", None) or "").lower().strip()
fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used
charge = await _find_latest_media_charge(
db,
user_id=record.user_id,
owner_type=CreditRecordOwnerType.GENERATION_RECORD.value,
owner_id=record.id,
attempt_no=record.current_billing_attempt_no,
media_type=gen_type or None,
)
fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used
return await _sync_charge_snapshot(
db,
charge=charge,
owner=record,
gen_type=gen_type,
stage=stage,
provider_response=response,
fallback_total=fallback_total or 0,
provider_response=provider_response,
fallback_total=fallback_total,
)
@@ -1,7 +0,0 @@
"""模型计价服务软包。
各调用方显式导入 calculator/rule_service/snapshot_service避免导入纯计算器时
提前初始化数据库引擎降低模块耦合并便于离线测试
"""
__all__: list[str] = []
@@ -1,339 +0,0 @@
from __future__ import annotations
import hashlib
import json
import re
from decimal import Decimal
from typing import Any, Iterable, Mapping
from urllib.parse import urlsplit, urlunsplit
from app.services.model_pricing.usage_normalizer import (
extract_image_output_items,
parse_size,
safe_bool,
safe_float,
safe_int,
safe_json_dict,
sanitize_output_items,
)
MEDIA_TYPES = {"image", "video", "audio"}
def _safe_json(value: Any) -> Any:
if value in (None, ""):
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return value
return value
def _normalize_url(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
text = str(value).strip()
try:
parts = urlsplit(text)
if parts.scheme and parts.netloc:
normalized = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
else:
normalized = text.split("?", 1)[0].split("#", 1)[0]
except Exception:
normalized = text.split("?", 1)[0].split("#", 1)[0]
normalized = normalized[:1024]
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
return normalized, digest
def _guess_media_type(item: Mapping[str, Any]) -> str | None:
value = str(item.get("type") or item.get("media_type") or item.get("resource_type") or "").lower().strip()
if value in MEDIA_TYPES:
return value
url = str(item.get("url") or item.get("path") or item.get("display_url") or "").lower()
if re.search(r"\.(png|jpe?g|webp|gif|bmp)(?:\?|$)", url):
return "image"
if re.search(r"\.(mp4|mov|m4v|webm|avi|mkv)(?:\?|$)", url):
return "video"
if re.search(r"\.(mp3|wav|aac|m4a|flac|ogg)(?:\?|$)", url):
return "audio"
return None
def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]:
parsed = _safe_json(value)
if isinstance(parsed, list):
for item in parsed:
yield from _walk_reference_items(item)
return
if isinstance(parsed, dict):
if _guess_media_type(parsed) or any(k in parsed for k in ("url", "path", "resource_id", "private_asset_id")):
yield parsed
return
for child in parsed.values():
if isinstance(child, (dict, list, str)):
yield from _walk_reference_items(child)
def _billable_input(
raw: Mapping[str, Any],
media_type: str,
*,
allow_provider_input: bool,
) -> bool:
# 是否作为供应商直接输入由服务端调用链决定,不能信任客户端附件字段。
if not allow_provider_input:
return False
if "billable_input" in raw:
return safe_bool(raw.get("billable_input"), True)
role = str(raw.get("role") or raw.get("label") or raw.get("reference_role") or "").lower()
if role in {"cover", "preview", "display_only", "generated_output", "output"}:
return False
return media_type in MEDIA_TYPES
def build_attachment_snapshot(
media_references: Any,
*,
allow_provider_input: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
items: list[dict[str, Any]] = []
image_count = video_count = audio_count = 0
provider_input_image_count = 0
provider_input_video_count = 0
provider_input_audio_count = 0
video_duration = Decimal("0")
audio_duration = Decimal("0")
seen: set[str] = set()
for raw in _walk_reference_items(media_references):
media_type = _guess_media_type(raw)
if media_type not in MEDIA_TYPES:
continue
raw_url = raw.get("url") or raw.get("path") or raw.get("display_url") or raw.get("preview_url")
safe_url, url_hash = _normalize_url(str(raw_url) if raw_url else None)
identity = str(
raw.get("resource_id")
or raw.get("upload_resource_id")
or raw.get("private_asset_id")
or url_hash
or f"{media_type}:{len(items)}"
)
dedupe_key = f"{media_type}:{identity}"
if dedupe_key in seen:
continue
seen.add(dedupe_key)
duration = max(0.0, safe_float(raw.get("duration"), safe_float(raw.get("duration_seconds"))))
billable = _billable_input(
raw,
media_type,
allow_provider_input=allow_provider_input,
)
item = {
"type": media_type,
"role": raw.get("role") or raw.get("label") or raw.get("reference_role"),
"source": raw.get("source"),
"billable_input": billable,
"resource_id": raw.get("resource_id") or raw.get("upload_resource_id"),
"private_asset_id": raw.get("private_asset_id"),
"name": raw.get("name") or raw.get("filename"),
"duration_seconds": duration or None,
"file_size": safe_int(raw.get("file_size"), safe_int(raw.get("size"))) or None,
"safe_url": safe_url,
"url_sha256": url_hash,
}
items.append({k: v for k, v in item.items() if v is not None})
if media_type == "image":
image_count += 1
provider_input_image_count += int(billable)
elif media_type == "video":
video_count += 1
provider_input_video_count += int(billable)
video_duration += Decimal(str(duration))
else:
audio_count += 1
provider_input_audio_count += int(billable)
audio_duration += Decimal(str(duration))
counts = {
"attachment_image_count": image_count,
"attachment_video_count": video_count,
"attachment_audio_count": audio_count,
"attachment_total_count": image_count + video_count + audio_count,
"attachment_video_duration_seconds": video_duration,
"attachment_audio_duration_seconds": audio_duration,
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
}
snapshot = {
"schema_version": 1,
"items": items,
"counts": {
"image": image_count,
"video": video_count,
"audio": audio_count,
"total": image_count + video_count + audio_count,
"provider_input_image": provider_input_image_count,
"provider_input_video": provider_input_video_count,
"provider_input_audio": provider_input_audio_count,
},
"durations": {
"video_seconds": str(video_duration),
"audio_seconds": str(audio_duration),
},
}
return snapshot, counts
def parse_dimensions(*values: Any, resolution: str | None = None, aspect_ratio: str | None = None) -> tuple[int, int]:
"""仅解析明确像素;resolution/aspect_ratio 不再映射为猜测尺寸。"""
del resolution, aspect_ratio
for value in values:
width, height = parse_size(value)
if width > 0 and height > 0:
return width, height
return 0, 0
def _engine_snapshot(owner: Any) -> dict[str, Any]:
return safe_json_dict(getattr(owner, "engine_snapshot_json", None))
def build_generation_snapshot(
owner: Any,
*,
provider_response: Any = None,
stage: str | None = None,
) -> tuple[dict[str, Any], dict[str, int], dict[str, Any]]:
"""构建请求/Provider/资源快照。
图片只读取同步接口明确的 data 输出条目不会递归扫描 provider response 中的通用 URL
"""
gen_type = str(getattr(owner, "gen_type", None) or getattr(owner, "media_type", None) or "").lower().strip()
response = safe_json_dict(provider_response if provider_response is not None else getattr(owner, "provider_response_json", None))
engine_snapshot = _engine_snapshot(owner)
aspect_ratio = getattr(owner, "aspect_ratio", None) or getattr(owner, "image_proportion", None)
resolution = getattr(owner, "resolution", None)
width, height = parse_dimensions(
response.get("size"),
getattr(owner, "image_px", None),
engine_snapshot.get("selected_px"),
engine_snapshot.get("image_px"),
)
dimension_source = "unavailable"
if parse_dimensions(response.get("size")) != (0, 0):
dimension_source = "provider_response"
elif parse_dimensions(getattr(owner, "image_px", None)) != (0, 0):
dimension_source = "request_explicit"
elif parse_dimensions(engine_snapshot.get("selected_px"), engine_snapshot.get("image_px")) != (0, 0):
dimension_source = "engine_snapshot"
output_items: list[dict[str, Any]] = []
generated_image_count = 0
generated_video_count = 0
if gen_type == "image":
output_items = extract_image_output_items(response)
if width <= 0 or height <= 0:
first_sized = next(
(item for item in output_items if safe_int(item.get("width")) > 0 and safe_int(item.get("height")) > 0),
None,
)
if first_sized:
width = safe_int(first_sized.get("width"))
height = safe_int(first_sized.get("height"))
dimension_source = "provider_response"
if width > 0 and height > 0:
for item in output_items:
if not item.get("width") or not item.get("height"):
item.update(
{
"width": width,
"height": height,
"pixels": width * height,
"size_source": dimension_source,
}
)
output_items = sanitize_output_items(output_items)
generated_image_count = len(output_items)
if generated_image_count == 0 and stage == "resource_download_completed" and getattr(owner, "image_url", None):
generated_image_count = 1
output_items = sanitize_output_items(
[{"index": 0, "url": getattr(owner, "image_url"), "size_source": "resource_snapshot"}]
)
elif gen_type == "video":
video_url = response.get("video_url") or response.get("url")
if stage == "resource_download_completed":
video_url = getattr(owner, "video_url", None) or video_url
generated_video_count = 1 if video_url else 0
if video_url:
output_items = sanitize_output_items([{"index": 0, "url": video_url, "type": "video"}])
pricing_meta = response.get("pricing_meta") if isinstance(response.get("pricing_meta"), Mapping) else {}
requested_output_count = max(
1,
safe_int(
pricing_meta.get("requested_output_count"),
safe_int(getattr(owner, "output_count", None), safe_int(getattr(owner, "count", None), 1)),
),
)
output_duration = max(0.0, safe_float(getattr(owner, "duration", None)))
fps = max(0.0, safe_float(getattr(owner, "fps", None), safe_float(getattr(owner, "frame_rate", None))))
generate_audio = safe_bool(getattr(owner, "generate_audio", None), safe_bool(response.get("generate_audio")))
inference_mode = str(
getattr(owner, "inference_mode", None)
or getattr(owner, "service_tier", None)
or response.get("service_tier")
or "online"
).lower()
counts = {
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"generated_total_count": generated_image_count + generated_video_count,
}
snapshot = {
"schema_version": 1,
"stage": stage or "unknown",
"gen_type": gen_type,
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"generated_total_count": generated_image_count + generated_video_count,
"output_items": output_items,
"duration_seconds": output_duration or None,
"resolution": resolution,
"aspect_ratio": aspect_ratio,
"width": width or None,
"height": height or None,
"dimension_source": dimension_source,
"fps": fps or None,
"generate_audio": generate_audio,
"inference_mode": inference_mode,
}
usage = {
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"successful_output_count": generated_image_count if gen_type == "image" else generated_video_count,
"output_items": output_items,
"output_width": width,
"output_height": height,
"dimension_source": dimension_source,
"output_video_duration_seconds": output_duration,
"resolution": str(resolution or "").lower(),
"aspect_ratio": str(aspect_ratio or ""),
"fps": fps,
"generate_audio": generate_audio,
"inference_mode": inference_mode,
"usage_stage": stage or "unknown",
}
return snapshot, counts, usage
@@ -1,558 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Mapping
from app.enums.model_pricing import (
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
PricingBillBy,
)
from app.services.model_pricing.usage_normalizer import safe_bool, safe_int
MILLION = Decimal("1000000")
MONEY_QUANT = Decimal("0.00000001")
class PricingCalculationError(ValueError):
pass
@dataclass(slots=True)
class PricingCalculationResult:
amount: Decimal
currency: str
is_estimated: bool
selected_rate: Decimal | None
breakdown: dict[str, Any]
usage_source: str
def to_decimal(value: Any, default: str = "0") -> Decimal:
try:
if value in (None, ""):
return Decimal(default)
return Decimal(str(value))
except Exception:
return Decimal(default)
def money(value: Decimal) -> Decimal:
return value.quantize(MONEY_QUANT, rounding=ROUND_HALF_UP)
def _select_text_tier(rule_json: Mapping[str, Any], context_tokens: int) -> Mapping[str, Any]:
for tier in rule_json.get("tiers") or []:
maximum = tier.get("max_context_tokens")
if maximum is None or context_tokens <= safe_int(maximum):
return tier
raise PricingCalculationError(f"没有匹配到文本 Token 档位: context_tokens={context_tokens}")
def _calculate_text(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult:
input_tokens = max(0, safe_int(usage.get("input_tokens")))
output_tokens = max(0, safe_int(usage.get("output_tokens")))
context_tokens = max(0, safe_int(usage.get("context_tokens"), input_tokens))
cached_input = max(0, min(input_tokens, safe_int(usage.get("cached_input_tokens"))))
audio_input = max(0, min(input_tokens, safe_int(usage.get("audio_input_tokens"))))
cached_audio = max(0, min(audio_input, safe_int(usage.get("cached_audio_input_tokens"))))
tier = _select_text_tier(rule_json, context_tokens)
cached_text_input = max(0, cached_input - cached_audio)
normal_audio_input = max(0, audio_input - cached_audio)
# cached_input_tokens may include cached audio tokens. Add cached_audio back once
# so the four buckets always sum exactly to input_tokens.
normal_text_input = max(0, input_tokens - audio_input - cached_text_input)
input_rate = to_decimal(tier.get("input_rate"))
output_rate = to_decimal(tier.get("output_rate"))
cached_rate = to_decimal(tier.get("cached_input_rate"), str(input_rate))
audio_rate = to_decimal(tier.get("audio_input_rate"), str(input_rate))
cached_audio_rate = to_decimal(tier.get("cached_audio_input_rate"), str(cached_rate))
normal_input_cost = to_decimal(normal_text_input) * input_rate / MILLION
cached_input_cost = to_decimal(cached_text_input) * cached_rate / MILLION
audio_input_cost = to_decimal(normal_audio_input) * audio_rate / MILLION
cached_audio_cost = to_decimal(cached_audio) * cached_audio_rate / MILLION
output_cost = to_decimal(output_tokens) * output_rate / MILLION
cache_storage_tokens = max(0, safe_int(usage.get("cache_storage_tokens")))
cache_storage_hours = max(Decimal("0"), to_decimal(usage.get("cache_storage_duration_hours")))
storage_rate = to_decimal(rule_json.get("cache_storage_rate_per_million_token_hour"))
cache_storage_cost = to_decimal(cache_storage_tokens) * cache_storage_hours * storage_rate / MILLION
total = money(
normal_input_cost
+ cached_input_cost
+ audio_input_cost
+ cached_audio_cost
+ output_cost
+ cache_storage_cost
)
return PricingCalculationResult(
amount=total,
currency=currency,
is_estimated=False,
selected_rate=None,
usage_source=str(usage.get("usage_source") or "provider"),
breakdown={
"formula": "token_items * corresponding_rate / 1e6",
"context_tokens": context_tokens,
"selected_tier": dict(tier),
"normal_text_input_tokens": normal_text_input,
"cached_text_input_tokens": cached_text_input,
"normal_audio_input_tokens": normal_audio_input,
"cached_audio_input_tokens": cached_audio,
"output_tokens": output_tokens,
"cache_storage_tokens": cache_storage_tokens,
"cache_storage_duration_hours": str(cache_storage_hours),
"normal_input_cost": str(money(normal_input_cost)),
"cached_input_cost": str(money(cached_input_cost)),
"audio_input_cost": str(money(audio_input_cost)),
"cached_audio_input_cost": str(money(cached_audio_cost)),
"output_cost": str(money(output_cost)),
"cache_storage_cost": str(money(cache_storage_cost)),
"total_cost": str(total),
},
)
def _resolve_billable_output_count(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, str, bool]:
bill_by = str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value)
if bill_by == PricingBillBy.REQUESTED_OUTPUT_COUNT.value:
return max(0, safe_int(usage.get("requested_output_count"))), bill_by, True
if bill_by == PricingBillBy.PROVIDER_BILLED_COUNT.value:
count = max(0, safe_int(usage.get("provider_billed_count")))
return count, bill_by, count <= 0
if bill_by != PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value:
raise PricingCalculationError(f"不支持的图片计费数量来源: {bill_by}")
return max(0, safe_int(usage.get("successful_output_count"), safe_int(usage.get("generated_image_count")))), bill_by, False
def _calculate_image_per_output(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult:
count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage)
if count <= 0:
raise PricingCalculationError("图片计价缺少有效输出数量")
rate = to_decimal(rule_json.get("output_rate"))
total = money(to_decimal(count) * rate)
return PricingCalculationResult(
amount=total,
currency=currency,
is_estimated=count_estimated or safe_bool(usage.get("output_count_is_estimated")),
selected_rate=rate,
usage_source=str(usage.get("usage_source") or "provider_response"),
breakdown={
"formula": "billable_output_count * output_rate",
"bill_by": bill_by,
"billable_output_count": count,
"output_rate": str(rate),
"total_cost": str(total),
},
)
def _select_image_tier(output_tiers: list[Mapping[str, Any]], pixels: int) -> Mapping[str, Any]:
for tier in output_tiers:
maximum = tier.get("max_pixels")
if maximum is None or pixels <= safe_int(maximum):
return tier
raise PricingCalculationError(f"没有匹配到图片输出像素档位: pixels={pixels}")
def _calculate_image_tiered(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult:
input_count = max(0, safe_int(usage.get("provider_input_image_count"), safe_int(usage.get("input_image_count"))))
free_count = max(0, safe_int(rule_json.get("free_input_images")))
billable_input_count = max(0, input_count - free_count)
input_rate = to_decimal(rule_json.get("input_image_rate"))
input_cost = to_decimal(billable_input_count) * input_rate
output_items = usage.get("output_items") or []
if not isinstance(output_items, list):
output_items = []
count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage)
if count <= 0:
raise PricingCalculationError("图片计价缺少有效输出数量")
output_tiers = list(rule_json.get("output_tiers") or [])
output_cost = Decimal("0")
item_breakdown: list[dict[str, Any]] = []
pixels_estimated = False
if output_items:
priced_count = 0
for index, item in enumerate(output_items[:count]):
if not isinstance(item, Mapping):
continue
pixels = max(0, safe_int(item.get("pixels")))
if pixels <= 0:
width = max(0, safe_int(item.get("width")))
height = max(0, safe_int(item.get("height")))
pixels = width * height
if pixels <= 0:
raise PricingCalculationError(f"{index + 1} 张输出图片缺少明确像素")
tier = _select_image_tier(output_tiers, pixels)
rate = to_decimal(tier.get("rate"))
output_cost += rate
priced_count += 1
item_breakdown.append({"index": index, "pixels": pixels, "tier": dict(tier), "rate": str(rate)})
# provider_billed_count/requested_output_count may be greater than the returned
# output item array. Only use an explicit fallback size; never silently under-bill.
remaining = count - priced_count
if remaining > 0:
fallback_pixels = max(0, safe_int(usage.get("output_pixels")))
if fallback_pixels <= 0:
fallback_width = max(0, safe_int(usage.get("output_width")))
fallback_height = max(0, safe_int(usage.get("output_height")))
fallback_pixels = fallback_width * fallback_height
if fallback_pixels <= 0:
raise PricingCalculationError(f"仍有 {remaining} 张计费输出缺少明确像素")
tier = _select_image_tier(output_tiers, fallback_pixels)
rate = to_decimal(tier.get("rate"))
output_cost += to_decimal(remaining) * rate
pixels_estimated = True
item_breakdown.append(
{
"count": remaining,
"pixels": fallback_pixels,
"tier": dict(tier),
"rate": str(rate),
"size_source": "explicit_fallback",
}
)
else:
pixels = max(0, safe_int(usage.get("output_pixels")))
if pixels <= 0:
width = max(0, safe_int(usage.get("output_width")))
height = max(0, safe_int(usage.get("output_height")))
pixels = width * height
if pixels <= 0:
raise PricingCalculationError("图片输出缺少明确像素")
tier = _select_image_tier(output_tiers, pixels)
rate = to_decimal(tier.get("rate"))
output_cost = to_decimal(count) * rate
pixels_estimated = True
item_breakdown = [{"count": count, "pixels": pixels, "tier": dict(tier), "rate": str(rate)}]
total = money(input_cost + output_cost)
return PricingCalculationResult(
amount=total,
currency=currency,
is_estimated=count_estimated or pixels_estimated or safe_bool(usage.get("output_pixels_is_estimated")),
selected_rate=None,
usage_source=str(usage.get("usage_source") or "provider_response"),
breakdown={
"formula": "billable_input_count*input_image_rate + sum(output_item_rate)",
"bill_by": bill_by,
"provider_input_image_count": input_count,
"free_input_images": free_count,
"billable_input_image_count": billable_input_count,
"input_image_rate": str(input_rate),
"input_cost": str(money(input_cost)),
"billable_output_count": count,
"output_items": item_breakdown,
"output_cost": str(money(output_cost)),
"total_cost": str(total),
},
)
def _normalize_resolution(value: Any) -> str:
resolution = str(value or "").lower().strip().replace(" ", "")
return {"2160p": "4k", "uhd": "4k"}.get(resolution, resolution)
def _normalize_ratio(value: Any) -> str:
return str(value or "").strip().replace("", ":")
def _resolve_video_dimensions(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, int, str]:
width = max(0, safe_int(usage.get("output_width")))
height = max(0, safe_int(usage.get("output_height")))
if width > 0 and height > 0:
return width, height, str(usage.get("dimension_source") or "provider_response")
resolution = _normalize_resolution(usage.get("resolution"))
ratio = _normalize_ratio(usage.get("aspect_ratio"))
dimension_map = rule_json.get("dimension_map") or {}
resolution_map = dimension_map.get(resolution) if isinstance(dimension_map, Mapping) else None
value = resolution_map.get(ratio) if isinstance(resolution_map, Mapping) else None
if isinstance(value, Mapping):
width = max(0, safe_int(value.get("width")))
height = max(0, safe_int(value.get("height")))
elif isinstance(value, (list, tuple)) and len(value) >= 2:
width, height = max(0, safe_int(value[0])), max(0, safe_int(value[1]))
if width <= 0 or height <= 0:
raise PricingCalculationError(f"视频缺少明确尺寸,且价格规则未配置 dimension_map: resolution={resolution}, ratio={ratio}")
return width, height, "pricing_rule_map"
def _rate_specificity(row: Mapping[str, Any]) -> tuple[int, int, int]:
resolutions = {_normalize_resolution(v) for v in (row.get("resolutions") or [])}
modes = {str(v).lower() for v in (row.get("inference_modes") or [])}
constrained = int(bool(resolutions)) + int(row.get("has_input_video") is not None) + int(
row.get("generate_audio") is not None
) + int(bool(modes))
# More constrained dimensions win; within a dimension, a smaller allowed set is
# more specific. Empty sets represent wildcard and therefore score lowest.
return constrained, -(len(resolutions) if resolutions else 10_000), -(len(modes) if modes else 10_000)
def _match_video_rate(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> Mapping[str, Any]:
resolution = _normalize_resolution(usage.get("resolution"))
has_input_video = safe_bool(usage.get("has_input_video"))
generate_audio = safe_bool(usage.get("generate_audio"))
inference_mode = str(usage.get("inference_mode") or "online").lower().strip()
matched: list[Mapping[str, Any]] = []
for row in rule_json.get("rates") or []:
resolutions = [_normalize_resolution(v) for v in (row.get("resolutions") or [])]
if resolutions and resolution not in resolutions:
continue
if row.get("has_input_video") is not None and safe_bool(row.get("has_input_video")) != has_input_video:
continue
if row.get("generate_audio") is not None and safe_bool(row.get("generate_audio")) != generate_audio:
continue
modes = [str(v).lower() for v in (row.get("inference_modes") or [])]
if modes and inference_mode not in modes:
continue
matched.append(row)
if not matched:
raise PricingCalculationError(
"没有匹配到视频价格档位: "
f"resolution={resolution}, has_input_video={has_input_video}, "
f"generate_audio={generate_audio}, inference_mode={inference_mode}"
)
matched.sort(key=_rate_specificity, reverse=True)
if len(matched) > 1 and _rate_specificity(matched[0]) == _rate_specificity(matched[1]):
raise PricingCalculationError("视频价格档位存在同等优先级重叠,请修正规则")
return matched[0]
def calculate_video_formula_tokens(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
input_seconds = max(Decimal("0"), to_decimal(usage.get("input_video_duration_seconds")))
output_seconds = max(Decimal("0"), to_decimal(usage.get("output_video_duration_seconds")))
fps = max(Decimal("0"), to_decimal(usage.get("fps")))
if fps <= 0:
fps = max(Decimal("0"), to_decimal(rule_json.get("default_fps")))
width, height, dimension_source = _resolve_video_dimensions(rule_json, usage)
if output_seconds <= 0 or fps <= 0:
raise PricingCalculationError("视频公式估算缺少输出时长或 FPS")
if safe_bool(usage.get("has_input_video")) and input_seconds <= 0:
raise PricingCalculationError("视频包含输入视频,但缺少输入视频时长,禁止估算")
tokens = (input_seconds + output_seconds) * Decimal(width) * Decimal(height) * fps / Decimal("1024")
rounded = max(0, int(tokens.quantize(Decimal("1"), rounding=ROUND_HALF_UP)))
return rounded, {
"input_video_duration_seconds": str(input_seconds),
"output_video_duration_seconds": str(output_seconds),
"output_width": width,
"output_height": height,
"fps": str(fps),
"dimension_source": dimension_source,
}
def _calculate_video(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult:
actual_tokens = max(0, safe_int(usage.get("total_tokens")))
formula_detail: dict[str, Any] = {}
if actual_tokens > 0:
total_tokens = actual_tokens
use_formula = False
else:
total_tokens, formula_detail = calculate_video_formula_tokens(rule_json, usage)
use_formula = True
rate_row = _match_video_rate(rule_json, usage)
rate = to_decimal(rate_row.get("rate"))
total = money(to_decimal(total_tokens) * rate / MILLION)
return PricingCalculationResult(
amount=total,
currency=currency,
is_estimated=use_formula,
selected_rate=rate,
usage_source="request_formula" if use_formula else str(usage.get("usage_source") or "provider"),
breakdown={
"formula": "billable_total_tokens * rate / 1e6",
"token_source": "request_formula" if use_formula else "provider",
"provider_total_tokens": actual_tokens,
"billable_total_tokens": total_tokens,
"formula_parameters": formula_detail or None,
"selected_rate_rule": dict(rate_row),
"rate": str(rate),
"total_cost": str(total),
},
)
def _expected_calculator(billing_mode: str) -> str:
mapping = {
ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value,
ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value,
ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value,
ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
}
value = mapping.get(billing_mode)
if not value:
raise PricingCalculationError(f"不支持的计价模式: {billing_mode}")
return value
def _constraint_set(values: Any, *, normalize) -> set[str] | None:
normalized = {normalize(value) for value in (values or []) if str(value or "").strip()}
return normalized or None
def _constraints_overlap(left: Mapping[str, Any], right: Mapping[str, Any]) -> bool:
left_res = _constraint_set(left.get("resolutions"), normalize=_normalize_resolution)
right_res = _constraint_set(right.get("resolutions"), normalize=_normalize_resolution)
if left_res is not None and right_res is not None and left_res.isdisjoint(right_res):
return False
for key in ("has_input_video", "generate_audio"):
lv, rv = left.get(key), right.get(key)
if lv is not None and rv is not None and safe_bool(lv) != safe_bool(rv):
return False
left_modes = _constraint_set(left.get("inference_modes"), normalize=lambda value: str(value).lower())
right_modes = _constraint_set(right.get("inference_modes"), normalize=lambda value: str(value).lower())
if left_modes is not None and right_modes is not None and left_modes.isdisjoint(right_modes):
return False
return True
def _constraint_subset(child: Mapping[str, Any], parent: Mapping[str, Any]) -> bool:
child_res = _constraint_set(child.get("resolutions"), normalize=_normalize_resolution)
parent_res = _constraint_set(parent.get("resolutions"), normalize=_normalize_resolution)
if parent_res is not None and (child_res is None or not child_res.issubset(parent_res)):
return False
for key in ("has_input_video", "generate_audio"):
child_value, parent_value = child.get(key), parent.get(key)
if parent_value is not None and (child_value is None or safe_bool(child_value) != safe_bool(parent_value)):
return False
child_modes = _constraint_set(child.get("inference_modes"), normalize=lambda value: str(value).lower())
parent_modes = _constraint_set(parent.get("inference_modes"), normalize=lambda value: str(value).lower())
if parent_modes is not None and (child_modes is None or not child_modes.issubset(parent_modes)):
return False
return True
def _validate_video_rate_overlaps(rates: list[Mapping[str, Any]]) -> None:
for left_index, left in enumerate(rates):
for right_index in range(left_index + 1, len(rates)):
right = rates[right_index]
if not _constraints_overlap(left, right):
continue
left_subset_right = _constraint_subset(left, right)
right_subset_left = _constraint_subset(right, left)
if left_subset_right and right_subset_left:
raise PricingCalculationError("视频价格档位存在重复条件")
if not left_subset_right and not right_subset_left:
raise PricingCalculationError("视频价格档位存在交叉重叠,无法确定唯一价格")
# The more specific row must be before its fallback row, matching the UI
# and keeping exported rule JSON human-readable and deterministic.
if right_subset_left:
raise PricingCalculationError("视频价格档位顺序错误:具体条件必须放在通用兜底条件之前")
def validate_pricing_rule(*, billing_mode: str, calculator_version: str, rule_json: Mapping[str, Any]) -> None:
if calculator_version != _expected_calculator(billing_mode):
raise PricingCalculationError(f"计价模式与计算器版本不匹配: {billing_mode}/{calculator_version}")
if billing_mode == ModelPricingBillingMode.TEXT_TOKEN_TIERED.value:
tiers = list(rule_json.get("tiers") or [])
if not tiers:
raise PricingCalculationError("文本计价至少需要一个 Token 档位")
previous_max = 0
for index, tier in enumerate(tiers, start=1):
maximum = tier.get("max_context_tokens")
if maximum is None and index != len(tiers):
raise PricingCalculationError("无上限 Token 档位只能放在最后")
if maximum is not None:
maximum_int = safe_int(maximum)
if maximum_int <= previous_max:
raise PricingCalculationError("Token 档位上限必须严格递增")
previous_max = maximum_int
for key in ("input_rate", "output_rate"):
if to_decimal(tier.get(key), "-1") < 0:
raise PricingCalculationError(f"{key} 不能为空且不能小于 0")
return
if billing_mode == ModelPricingBillingMode.IMAGE_PER_OUTPUT.value:
if to_decimal(rule_json.get("output_rate"), "-1") < 0:
raise PricingCalculationError("图片输出单价不能为空且不能小于 0")
if str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value) not in {v.value for v in PricingBillBy}:
raise PricingCalculationError("bill_by 不受支持")
return
if billing_mode == ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value:
if safe_int(rule_json.get("free_input_images")) < 0:
raise PricingCalculationError("免费输入图片数不能小于 0")
if to_decimal(rule_json.get("input_image_rate"), "-1") < 0:
raise PricingCalculationError("输入图片单价不能为空且不能小于 0")
tiers = list(rule_json.get("output_tiers") or [])
if not tiers:
raise PricingCalculationError("图片输出至少需要一个像素档位")
previous_max = 0
for index, tier in enumerate(tiers, start=1):
maximum = tier.get("max_pixels")
if maximum is None and index != len(tiers):
raise PricingCalculationError("无上限像素档位只能放在最后")
if maximum is not None:
maximum_int = safe_int(maximum)
if maximum_int <= previous_max:
raise PricingCalculationError("图片像素档位上限必须严格递增")
previous_max = maximum_int
if to_decimal(tier.get("rate"), "-1") < 0:
raise PricingCalculationError("图片输出单价不能为空且不能小于 0")
return
if billing_mode == ModelPricingBillingMode.VIDEO_TOKEN_RATE.value:
rates = list(rule_json.get("rates") or [])
if not rates:
raise PricingCalculationError("视频计价至少需要一个价格档位")
signatures: set[tuple[Any, ...]] = set()
for row in rates:
if to_decimal(row.get("rate"), "-1") < 0:
raise PricingCalculationError("视频 Token 单价不能为空且不能小于 0")
signature = (
tuple(sorted(_normalize_resolution(v) for v in (row.get("resolutions") or []))),
row.get("has_input_video"),
row.get("generate_audio"),
tuple(sorted(str(v).lower() for v in (row.get("inference_modes") or []))),
)
if signature in signatures:
raise PricingCalculationError("视频价格档位存在重复条件")
signatures.add(signature)
_validate_video_rate_overlaps(rates)
dimension_map = rule_json.get("dimension_map") or {}
if dimension_map and not isinstance(dimension_map, Mapping):
raise PricingCalculationError("dimension_map 必须是对象")
return
raise PricingCalculationError(f"不支持的计价模式: {billing_mode}")
def calculate_pricing(
*,
billing_mode: str,
calculator_version: str,
rule_json: Mapping[str, Any],
usage: Mapping[str, Any],
currency: str = "CNY",
) -> PricingCalculationResult:
validate_pricing_rule(
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
if calculator_version == ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value:
return _calculate_text(rule_json, usage, currency)
if calculator_version == ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value:
return _calculate_image_per_output(rule_json, usage, currency)
if calculator_version == ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value:
return _calculate_image_tiered(rule_json, usage, currency)
if calculator_version == ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value:
return _calculate_video(rule_json, usage, currency)
raise PricingCalculationError(f"不支持的计算器版本: {calculator_version}")
@@ -1,517 +0,0 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any, Mapping
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.model_pricing import ModelPricingRuleStatus
from app.models.credit_record import CreditRecord
from app.models.model_pricing_rule import ModelPricingRule
from app.services.model_pricing.calculator import PricingCalculationError, validate_pricing_rule
from app.utils.id_gen import generate_id
PROVIDER_ALIASES = {
"ark": "volcengine",
"volc": "volcengine",
"volc_engine": "volcengine",
"volcano": "volcengine",
"volcengine": "volcengine",
}
class PricingRuleError(ValueError):
pass
def _json_default(value: Any) -> str:
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def canonical_json_hash(value: Mapping[str, Any]) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def build_rule_content_hash(
*,
model_category: str,
billing_mode: str,
calculator_version: str,
currency: str,
rule_schema_version: int,
rule_json: Mapping[str, Any],
) -> str:
"""规则正文哈希包含所有会改变计算结果的字段,不只哈希 rule_json。"""
return canonical_json_hash(
{
"model_category": model_category,
"billing_mode": billing_mode,
"calculator_version": calculator_version,
"currency": str(currency or "CNY").upper(),
"rule_schema_version": int(rule_schema_version or 1),
"rule_json": normalize_rule_json(rule_json),
}
)
def normalize_rule_json(value: Mapping[str, Any] | None) -> dict[str, Any]:
"""返回全新的普通 dict,所有 ORM 更新必须整体赋值,禁止嵌套原地修改。"""
return deepcopy(dict(value or {}))
def normalize_provider(provider: str | None, model_name: str | None = None) -> str:
value = str(provider or "").strip().lower()
normalized = PROVIDER_ALIASES.get(value, value)
if normalized in {"sdk", "openai_compatible"} and str(model_name or "").strip().lower().startswith("doubao-"):
return "volcengine"
return normalized
def ensure_aware(value: datetime | None) -> datetime:
value = value or datetime.now(timezone.utc)
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _validate_category_mode(model_category: str, billing_mode: str) -> None:
expected = {
"text_token_tiered": "text",
"image_per_output": "image",
"image_input_output_tiered": "image",
"video_token_rate": "video",
}.get(billing_mode)
if expected is None or model_category != expected:
raise PricingRuleError("模型类型与计价模式不匹配")
def _validate_rule_payload(
*,
model_category: str,
billing_mode: str,
calculator_version: str,
rule_json: Mapping[str, Any],
) -> None:
_validate_category_mode(model_category, billing_mode)
try:
validate_pricing_rule(
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
except PricingCalculationError as exc:
raise PricingRuleError(str(exc)) from exc
def rule_to_dict(rule: ModelPricingRule) -> dict[str, Any]:
return {
"id": rule.id,
"provider": rule.provider,
"model_name": rule.model_name,
"model_category": rule.model_category,
"billing_mode": rule.billing_mode,
"calculator_version": rule.calculator_version,
"version_code": rule.version_code,
"effective_from": rule.effective_from,
"effective_to": rule.effective_to,
"publish_status": rule.publish_status,
"currency": rule.currency,
"rule_schema_version": rule.rule_schema_version,
"rule_json": deepcopy(rule.rule_json or {}),
"rule_content_hash": rule.rule_content_hash,
"source_url": rule.source_url,
"source_updated_at": rule.source_updated_at,
"remark": rule.remark,
"created_by": rule.created_by,
"updated_by": rule.updated_by,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
}
def _rule_snapshot_query(rule_id: str):
return (
select(
ModelPricingRule.id,
ModelPricingRule.provider,
ModelPricingRule.model_name,
ModelPricingRule.model_category,
ModelPricingRule.billing_mode,
ModelPricingRule.calculator_version,
ModelPricingRule.version_code,
ModelPricingRule.effective_from,
ModelPricingRule.effective_to,
ModelPricingRule.publish_status,
ModelPricingRule.currency,
ModelPricingRule.rule_schema_version,
ModelPricingRule.rule_json,
ModelPricingRule.rule_content_hash,
ModelPricingRule.source_url,
ModelPricingRule.source_updated_at,
ModelPricingRule.remark,
ModelPricingRule.created_by,
ModelPricingRule.updated_by,
ModelPricingRule.created_at,
ModelPricingRule.updated_at,
)
.where(ModelPricingRule.id == rule_id)
.limit(1)
)
def _snapshot_from_mapping(row: Mapping[str, Any]) -> dict[str, Any]:
snapshot = dict(row)
snapshot["rule_json"] = deepcopy(snapshot.get("rule_json") or {})
return snapshot
async def get_rule_snapshot(db: AsyncSession, rule_id: str) -> dict[str, Any]:
"""显式查询并返回普通字典,避免写入后访问过期 ORM 字段触发隐式 IO。"""
row = (await db.execute(_rule_snapshot_query(rule_id))).mappings().one_or_none()
if row is None:
raise PricingRuleError("模型计价规则不存在")
return _snapshot_from_mapping(row)
async def _lock_model_rule_namespace(db: AsyncSession, provider: str, model_name: str) -> None:
bind = db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
lock_key = f"model_pricing:{provider}:{model_name}"
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": lock_key})
async def _assert_unique_version(
db: AsyncSession,
*,
provider: str,
model_name: str,
version_code: str,
exclude_id: str | None = None,
) -> None:
query = select(ModelPricingRule.id).where(
ModelPricingRule.provider == provider,
ModelPricingRule.model_name == model_name,
ModelPricingRule.version_code == version_code,
)
if exclude_id:
query = query.where(ModelPricingRule.id != exclude_id)
if (await db.execute(query.limit(1))).scalar_one_or_none():
raise PricingRuleError("该供应商、模型和价格版本号已存在")
async def _assert_no_overlap(
db: AsyncSession,
*,
provider: str,
model_name: str,
effective_from: datetime,
effective_to: datetime | None,
exclude_id: str | None = None,
) -> None:
query = (
select(ModelPricingRule.id)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > effective_from))
)
if effective_to is not None:
query = query.where(ModelPricingRule.effective_from < effective_to)
if exclude_id:
query = query.where(ModelPricingRule.id != exclude_id)
if (await db.execute(query.limit(1))).scalar_one_or_none():
raise PricingRuleError("该模型已存在生效时间重叠的已发布价格版本")
async def resolve_published_rule(
db: AsyncSession,
*,
provider: str | None,
model_name: str | None,
reference_at: datetime | None = None,
) -> ModelPricingRule | None:
model_name = str(model_name or "").strip()
provider = normalize_provider(provider, model_name)
if not provider or not model_name:
return None
at = ensure_aware(reference_at)
result = await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status.in_([ModelPricingRuleStatus.PUBLISHED.value, ModelPricingRuleStatus.DISABLED.value]))
.where(ModelPricingRule.effective_from <= at)
.where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > at))
.order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_rules(
db: AsyncSession,
*,
page: int = 1,
page_size: int = 50,
provider: str | None = None,
model_name: str | None = None,
model_category: str | None = None,
publish_status: str | None = None,
) -> dict[str, Any]:
filters = []
if provider:
filters.append(ModelPricingRule.provider == normalize_provider(provider))
if model_name:
filters.append(ModelPricingRule.model_name.ilike(f"%{model_name.strip()}%"))
if model_category:
filters.append(ModelPricingRule.model_category == model_category)
if publish_status:
filters.append(ModelPricingRule.publish_status == publish_status)
total = (await db.execute(select(func.count(ModelPricingRule.id)).where(*filters))).scalar_one()
rows = (
await db.execute(
select(ModelPricingRule)
.where(*filters)
.order_by(ModelPricingRule.model_name, ModelPricingRule.effective_from.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
rule_ids = [row.id for row in rows]
referenced: dict[str, int] = {}
if rule_ids:
ref_rows = (
await db.execute(
select(CreditRecord.pricing_rule_id, func.count(CreditRecord.id))
.where(CreditRecord.pricing_rule_id.in_(rule_ids))
.group_by(CreditRecord.pricing_rule_id)
)
).all()
referenced = {str(rule_id): int(count) for rule_id, count in ref_rows if rule_id}
items = []
for row in rows:
item = rule_to_dict(row)
item["referenced_count"] = referenced.get(row.id, 0)
items.append(item)
return {"items": items, "total": int(total or 0)}
async def get_rule(db: AsyncSession, rule_id: str, *, for_update: bool = False) -> ModelPricingRule:
query = select(ModelPricingRule).where(ModelPricingRule.id == rule_id).limit(1)
if for_update:
query = query.with_for_update()
rule = (await db.execute(query)).scalar_one_or_none()
if not rule:
raise PricingRuleError("模型计价规则不存在")
return rule
async def create_rule(db: AsyncSession, *, payload: dict[str, Any], operator_id: str | None) -> dict[str, Any]:
effective_from = ensure_aware(payload["effective_from"])
effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None
if effective_to and effective_to <= effective_from:
raise PricingRuleError("失效时间必须晚于生效时间")
model_name = str(payload.get("model_name") or "").strip()
provider = normalize_provider(payload.get("provider"), model_name)
version_code = str(payload.get("version_code") or "").strip()
calculator_version = str(payload.get("calculator_version") or "").strip()
if not provider or not model_name or not version_code or not calculator_version:
raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空")
rule_json = normalize_rule_json(payload.get("rule_json"))
_validate_rule_payload(
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
rule_json=rule_json,
)
await _lock_model_rule_namespace(db, provider, model_name)
await _assert_unique_version(db, provider=provider, model_name=model_name, version_code=version_code)
rule_id = generate_id()
rule = ModelPricingRule(
id=rule_id,
provider=provider,
model_name=model_name,
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
version_code=version_code,
effective_from=effective_from,
effective_to=effective_to,
publish_status=ModelPricingRuleStatus.DRAFT.value,
currency=str(payload.get("currency") or "CNY").upper(),
rule_schema_version=int(payload.get("rule_schema_version") or 1),
rule_json=rule_json,
rule_content_hash=build_rule_content_hash(
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
currency=str(payload.get("currency") or "CNY").upper(),
rule_schema_version=int(payload.get("rule_schema_version") or 1),
rule_json=rule_json,
),
source_url=payload.get("source_url"),
source_updated_at=payload.get("source_updated_at"),
remark=payload.get("remark"),
created_by=operator_id,
updated_by=operator_id,
)
db.add(rule)
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def update_draft_rule(
db: AsyncSession,
*,
rule_id: str,
payload: dict[str, Any],
operator_id: str | None,
) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
if rule.publish_status != ModelPricingRuleStatus.DRAFT.value:
raise PricingRuleError("已发布或已停用的价格版本不可修改,请克隆为新版本")
provider = normalize_provider(payload.get("provider", rule.provider), payload.get("model_name", rule.model_name))
model_name = str(payload.get("model_name", rule.model_name) or "").strip()
version_code = str(payload.get("version_code", rule.version_code) or "").strip()
model_category = str(payload.get("model_category", rule.model_category))
billing_mode = str(payload.get("billing_mode", rule.billing_mode))
calculator_version = str(payload.get("calculator_version", rule.calculator_version))
rule_json = normalize_rule_json(payload["rule_json"] if "rule_json" in payload else rule.rule_json)
effective_from = ensure_aware(payload.get("effective_from", rule.effective_from))
effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None if "effective_to" in payload else rule.effective_to
if effective_to and effective_to <= effective_from:
raise PricingRuleError("失效时间必须晚于生效时间")
if not provider or not model_name or not version_code or not calculator_version:
raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空")
_validate_rule_payload(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
await _lock_model_rule_namespace(db, provider, model_name)
await _assert_unique_version(
db,
provider=provider,
model_name=model_name,
version_code=version_code,
exclude_id=rule.id,
)
rule.provider = provider
rule.model_name = model_name
rule.model_category = model_category
rule.billing_mode = billing_mode
rule.calculator_version = calculator_version
rule.version_code = version_code
rule.effective_from = effective_from
rule.effective_to = effective_to
rule.currency = str(payload.get("currency", rule.currency) or "CNY").upper()
rule.rule_schema_version = int(payload.get("rule_schema_version", rule.rule_schema_version) or 1)
rule.rule_json = rule_json
rule.rule_content_hash = build_rule_content_hash(
model_category=rule.model_category,
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
currency=rule.currency,
rule_schema_version=rule.rule_schema_version,
rule_json=rule.rule_json,
)
for key in ("source_url", "source_updated_at", "remark"):
if key in payload:
setattr(rule, key, payload[key])
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def publish_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
publish_status = rule.publish_status
if publish_status == ModelPricingRuleStatus.PUBLISHED.value:
return await get_rule_snapshot(db, rule_id)
if publish_status != ModelPricingRuleStatus.DRAFT.value:
raise PricingRuleError("只有草稿价格版本可以发布")
provider = rule.provider
model_name = rule.model_name
model_category = rule.model_category
billing_mode = rule.billing_mode
calculator_version = rule.calculator_version
effective_from = rule.effective_from
effective_to = rule.effective_to
currency = rule.currency
rule_schema_version = rule.rule_schema_version
rule_json = normalize_rule_json(rule.rule_json)
await _lock_model_rule_namespace(db, provider, model_name)
_validate_rule_payload(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
previous = (
await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(ModelPricingRule.effective_from < effective_from)
.where(ModelPricingRule.effective_to.is_(None))
.order_by(ModelPricingRule.effective_from.desc())
.limit(1)
.with_for_update()
)
).scalar_one_or_none()
if previous:
previous.effective_to = effective_from
previous.updated_by = operator_id
await db.flush()
await _assert_no_overlap(
db,
provider=provider,
model_name=model_name,
effective_from=effective_from,
effective_to=effective_to,
exclude_id=rule_id,
)
rule.rule_json = rule_json
rule.rule_content_hash = build_rule_content_hash(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
currency=currency,
rule_schema_version=rule_schema_version,
rule_json=rule_json,
)
rule.publish_status = ModelPricingRuleStatus.PUBLISHED.value
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def disable_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
if rule.publish_status == ModelPricingRuleStatus.DISABLED.value:
return await get_rule_snapshot(db, rule_id)
await _lock_model_rule_namespace(db, rule.provider, rule.model_name)
if rule.publish_status == ModelPricingRuleStatus.PUBLISHED.value:
now = datetime.now(timezone.utc)
close_at = now if rule.effective_from < now else rule.effective_from
if rule.effective_to is None or rule.effective_to > close_at:
rule.effective_to = close_at
rule.publish_status = ModelPricingRuleStatus.DISABLED.value
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
@@ -1,175 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from app.enums.model_pricing import (
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingCategory,
ModelPricingProvider,
PricingBillBy,
)
CST = timezone(timedelta(hours=8))
SOURCE_URL = "https://www.volcengine.com/docs/82379/1544106"
SOURCE_UPDATED_AT = datetime(2026, 7, 9, 12, 2, 6, tzinfo=CST)
# 文档更新时间不等于价格真实生效时间。初始化仅创建草稿;提交前必须逐模型核实并修改。
PROPOSED_EFFECTIVE_FROM = SOURCE_UPDATED_AT
def volcengine_pricing_seed_rules() -> list[dict[str, Any]]:
common = {
"provider": ModelPricingProvider.VOLCENGINE.value,
"publish_status": "draft",
"currency": "CNY",
"rule_schema_version": 1,
"source_url": SOURCE_URL,
"source_updated_at": SOURCE_UPDATED_AT,
"effective_from": PROPOSED_EFFECTIVE_FROM,
"remark_prefix": "初始化草稿:effective_from 仅为建议值,发布前必须核对火山真实生效时间。",
}
rules = [
{
**common,
"model_name": "doubao-seed-2-0-lite-260215",
"model_category": ModelPricingCategory.TEXT.value,
"billing_mode": ModelPricingBillingMode.TEXT_TOKEN_TIERED.value,
"calculator_version": ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"cache_storage_rate_per_million_token_hour": "0.017",
"tiers": [
{"max_context_tokens": 32000, "input_rate": "0.6", "audio_input_rate": "9", "output_rate": "3.6", "cached_input_rate": "0.12", "cached_audio_input_rate": "1.8"},
{"max_context_tokens": 128000, "input_rate": "0.9", "audio_input_rate": "13.5", "output_rate": "5.4", "cached_input_rate": "0.18", "cached_audio_input_rate": "2.7"},
{"max_context_tokens": 256000, "input_rate": "1.8", "audio_input_rate": "27", "output_rate": "10.8", "cached_input_rate": "0.36", "cached_audio_input_rate": "5.4"},
],
},
"remark": "豆包 Seed 2.0 Lite,按上下文长度分档。",
},
{
**common,
"model_name": "doubao-seedream-5-0-pro-260628",
"model_category": ModelPricingCategory.IMAGE.value,
"billing_mode": ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value,
"calculator_version": ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_image",
"free_input_images": 1,
"input_image_rate": "0.02",
"output_tiers": [
{"max_pixels": 2360000, "rate": "0.30"},
{"max_pixels": None, "rate": "0.60"},
],
"bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value,
},
"remark": "Seedream 5.0 Pro:输入图和逐张输出像素分档计价。",
},
{
**common,
"model_name": "doubao-seedream-5-0-260128",
"model_category": ModelPricingCategory.IMAGE.value,
"billing_mode": ModelPricingBillingMode.IMAGE_PER_OUTPUT.value,
"calculator_version": ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {"unit": "CNY_per_image", "output_rate": "0.22", "bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value},
"remark": "Seedream 5.0:按同步接口实际成功输出图片数量计价。",
},
{
**common,
"model_name": "doubao-seedance-2-0-260128",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"token_formula_description": "(input_video_seconds + output_video_seconds) * width * height * fps / 1024",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "46"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "28"},
{"resolutions": ["1080p"], "has_input_video": False, "rate": "51"},
{"resolutions": ["1080p"], "has_input_video": True, "rate": "31"},
{"resolutions": ["4k"], "has_input_video": False, "rate": "26"},
{"resolutions": ["4k"], "has_input_video": True, "rate": "16"},
],
},
"remark": "Seedance 2.0 标准版;dimension_map 空时只接受 Provider 实际 Token,不猜比例像素。",
},
{
**common,
"model_name": "doubao-seedance-2-0-fast-260128",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "37"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "22"},
],
},
"remark": "Seedance 2.0 Fast;仅配置支持的价格档位。",
},
{
**common,
"model_name": "doubao-seedance-2-0-mini-260615",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "23"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "14"},
],
},
"remark": "Seedance 2.0 Mini;仅配置支持的价格档位。",
},
{
**common,
"model_name": "doubao-seedance-1-5-pro-251215",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"inference_modes": ["online"], "generate_audio": False, "rate": "8"},
{"inference_modes": ["online"], "generate_audio": True, "rate": "16"},
{"inference_modes": ["flex", "batch"], "generate_audio": False, "rate": "4"},
{"inference_modes": ["flex", "batch"], "generate_audio": True, "rate": "8"},
],
},
"remark": "Seedance 1.5 Pro,按推理模式与有声/无声选择 Token 单价。",
},
{
**common,
"model_name": "doubao-seedance-1-0-pro-250528",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {"unit": "CNY_per_million_tokens", "default_fps": 30, "dimension_map": {}, "rates": [{"rate": "15"}]},
"remark": "Seedance 1.0 Pro 固定 Token 单价。",
},
]
for item in rules:
prefix = item.pop("remark_prefix")
item["remark"] = f"{prefix} {item.get('remark') or ''}".strip()
return rules
@@ -1,585 +0,0 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Mapping
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.model_pricing import ModelPricingRuleStatus, ProviderCostStatus, PricingSnapshotStage
from app.models.credit_record import CreditRecord
from app.models.model_pricing_rule import ModelPricingRule
from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing
from app.services.model_pricing.rule_service import normalize_provider, resolve_published_rule
from app.services.operation_log_service import log_model_pricing_event
SNAPSHOT_SCHEMA_VERSION = 1
FINAL_STAGES = {
PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value,
PricingSnapshotStage.BACKFILL.value,
}
def _json_default(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
return str(value)
def _canonical_hash(value: Mapping[str, Any]) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _reference_at(value: datetime | None) -> datetime:
value = value or _utcnow()
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def build_refund_pricing_snapshot(charge: Any) -> tuple[dict[str, Any], str]:
snapshot = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"refund": {
"provider_cost": {
"currency": getattr(charge, "provider_cost_currency", None) or "CNY",
"amount": "0",
"status": ProviderCostStatus.NOT_APPLICABLE.value,
"reason": "user_credit_refund_does_not_reverse_provider_cost",
},
"original_charge": {
"credit_record_id": getattr(charge, "id", None),
"pricing_rule_id": getattr(charge, "pricing_rule_id", None),
"pricing_version_code": getattr(charge, "pricing_version_code", None),
"pricing_snapshot_hash": getattr(charge, "pricing_snapshot_hash", None),
"provider_cost_amount": str(getattr(charge, "provider_cost_amount", None) or 0),
"provider_cost_status": getattr(charge, "provider_cost_status", None),
},
},
}
return snapshot, _canonical_hash(snapshot)
def _rule_snapshot(rule: ModelPricingRule) -> dict[str, Any]:
return {
"id": rule.id,
"provider": rule.provider,
"model_name": rule.model_name,
"model_category": rule.model_category,
"billing_mode": rule.billing_mode,
"calculator_version": rule.calculator_version,
"version_code": rule.version_code,
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
"effective_to": rule.effective_to.isoformat() if rule.effective_to else None,
"currency": rule.currency,
"rule_schema_version": rule.rule_schema_version,
"rule_content_hash": rule.rule_content_hash,
"rule_json": deepcopy(rule.rule_json or {}),
"source_url": rule.source_url,
"source_updated_at": rule.source_updated_at.isoformat() if rule.source_updated_at else None,
}
def _apply_rule_fields(target: Any, rule: ModelPricingRule, reference_at: datetime) -> None:
target.pricing_rule_id = rule.id
target.pricing_version_code = rule.version_code
target.pricing_billing_mode = rule.billing_mode
target.pricing_calculator_version = rule.calculator_version
target.pricing_reference_at = reference_at
target.pricing_effective_from = rule.effective_from
target.pricing_effective_to = rule.effective_to
target.pricing_snapshot_schema_version = SNAPSHOT_SCHEMA_VERSION
target.provider_cost_currency = rule.currency
def _build_pricing_snapshot(
*,
rule: ModelPricingRule,
result: Any,
stage: str,
audit_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
# calculated_at 不参与 hash;运行时间单独保存在平铺字段中,保证相同规则/用量快照 hash 稳定。
snapshot = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": stage,
"rule": _rule_snapshot(rule),
"calculation": deepcopy(result.breakdown),
"provider_cost": {
"currency": result.currency,
"amount": str(result.amount),
"status": ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value,
"is_estimated": bool(result.is_estimated),
"usage_source": result.usage_source,
},
}
if audit_metadata:
snapshot["backfill"] = deepcopy(dict(audit_metadata))
return snapshot
def _build_status_snapshot(
*,
status: str,
stage: str,
rule: ModelPricingRule | None = None,
reason: str | None = None,
audit_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
snapshot: dict[str, Any] = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": stage,
"provider_cost": {"status": status},
}
if rule is not None:
snapshot["rule"] = _rule_snapshot(rule)
snapshot["provider_cost"]["currency"] = rule.currency
if reason:
snapshot["provider_cost"]["reason"] = reason
if audit_metadata:
snapshot["backfill"] = deepcopy(dict(audit_metadata))
return snapshot
def _apply_result(
target: Any,
*,
rule: ModelPricingRule,
usage: Mapping[str, Any],
result: Any,
stage: str,
audit_metadata: Mapping[str, Any] | None = None,
) -> None:
now = _utcnow()
status = ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value
pricing_snapshot = _build_pricing_snapshot(
rule=rule,
result=result,
stage=stage,
audit_metadata=audit_metadata,
)
target.provider_cost_amount = result.amount
target.provider_cost_status = status
target.provider_cost_is_estimated = bool(result.is_estimated)
target.provider_cost_calculated_at = now
target.provider_cost_finalized_at = now if stage in FINAL_STAGES else None
target.pricing_usage_source = result.usage_source
target.pricing_snapshot_json = pricing_snapshot
target.usage_snapshot_json = deepcopy(dict(usage))
target.pricing_snapshot_hash = _canonical_hash(pricing_snapshot)
def _can_calculate(billing_mode: str, usage: Mapping[str, Any]) -> bool:
if billing_mode == "text_token_tiered":
return any(int(usage.get(k) or 0) > 0 for k in ("input_tokens", "output_tokens", "cached_input_tokens", "audio_input_tokens"))
if billing_mode in {"image_per_output", "image_input_output_tiered"}:
return int(usage.get("successful_output_count") or usage.get("provider_billed_count") or 0) > 0
if billing_mode == "video_token_rate":
if int(usage.get("total_tokens") or 0) > 0:
return True
return float(usage.get("output_video_duration_seconds") or 0) > 0
return False
def _apply_not_applicable(target: Any, usage: Mapping[str, Any], reason: str) -> None:
target.provider_cost_status = ProviderCostStatus.NOT_APPLICABLE.value
target.provider_cost_amount = Decimal("0")
target.provider_cost_is_estimated = False
target.provider_usage_primary = False
target.usage_snapshot_json = deepcopy(dict(usage)) or None
target.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"provider_cost": {
"status": ProviderCostStatus.NOT_APPLICABLE.value,
"amount": "0",
"reason": reason,
},
}
target.pricing_snapshot_hash = _canonical_hash(target.pricing_snapshot_json)
async def enrich_credit_meta_with_pricing(
db: AsyncSession,
*,
meta: Any,
usage: Mapping[str, Any] | None = None,
reference_at: datetime | None = None,
final: bool = False,
) -> Any:
usage_dict = deepcopy(dict(usage or {}))
reference = _reference_at(reference_at)
if getattr(meta, "charge_kind", None) in {"file_parse", "vision_input"}:
meta.pricing_reference_at = reference
_apply_not_applicable(meta, usage_dict, "cost_included_in_primary_text_prompt_charge")
return meta
provider = getattr(meta, "engine_provider", None)
model_name = getattr(meta, "engine_model_name", None)
if not provider or not model_name:
meta.pricing_reference_at = reference
meta.provider_cost_status = ProviderCostStatus.PENDING.value if not final else ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
meta.usage_snapshot_json = usage_dict or None
return meta
rule = await resolve_published_rule(db, provider=provider, model_name=model_name, reference_at=reference)
if not rule:
meta.pricing_reference_at = reference
meta.provider_cost_status = ProviderCostStatus.UNMATCHED_RULE.value
meta.usage_snapshot_json = usage_dict or None
return meta
_apply_rule_fields(meta, rule, reference)
meta.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True))
meta.usage_snapshot_json = usage_dict or None
# 媒体扣费创建阶段只锁定规则与请求快照。图片等待同步生成响应,视频等待异步 Provider 完成;
# 不能在请求时用预设时长/分辨率提前写入估算成本。
if not final:
meta.provider_cost_status = ProviderCostStatus.PENDING.value
meta.provider_cost_amount = None
meta.provider_cost_is_estimated = False
meta.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": PricingSnapshotStage.REQUEST_LOCKED.value,
"rule": _rule_snapshot(rule),
"provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status},
}
meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json)
return meta
if not _can_calculate(rule.billing_mode, usage_dict):
meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value
meta.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
"rule": _rule_snapshot(rule),
"provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status},
}
meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json)
return meta
try:
result = calculate_pricing(
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
rule_json=rule.rule_json or {},
usage=usage_dict,
currency=rule.currency,
)
_apply_result(
meta,
rule=rule,
usage=usage_dict,
result=result,
stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value if final else PricingSnapshotStage.REQUEST_LOCKED.value,
)
except PricingCalculationError:
meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value if final else ProviderCostStatus.PENDING.value
except Exception as exc:
meta.provider_cost_status = ProviderCostStatus.ERROR.value
log_model_pricing_event(
event_type="pricing_cost_calculate",
event_status="failed",
owner_type=getattr(meta, "owner_type", None),
owner_id=getattr(meta, "owner_id", None),
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=provider,
model_name=model_name,
billing_mode=rule.billing_mode,
cost_status=meta.provider_cost_status,
error=str(exc),
)
return meta
async def _load_locked_rule(
db: AsyncSession,
charge: CreditRecord,
*,
reference_at: datetime,
use_locked_rule: bool,
) -> ModelPricingRule | None:
if use_locked_rule and charge.pricing_rule_id:
return (
await db.execute(select(ModelPricingRule).where(ModelPricingRule.id == charge.pricing_rule_id).limit(1))
).scalar_one_or_none()
if not use_locked_rule:
provider = normalize_provider(charge.engine_provider, charge.engine_model_name)
model_name = str(charge.engine_model_name or "").strip()
if not provider or not model_name:
return None
return (
await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(ModelPricingRule.effective_from <= reference_at)
.where(
(ModelPricingRule.effective_to.is_(None))
| (ModelPricingRule.effective_to > reference_at)
)
.order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return await resolve_published_rule(
db,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
reference_at=reference_at,
)
async def _missing_rule_status(db: AsyncSession, *, charge: CreditRecord, reference_at: datetime) -> str:
model_name = str(charge.engine_model_name or "").strip()
provider = normalize_provider(charge.engine_provider, model_name)
if not provider or not model_name:
return ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
earliest = (
await db.execute(
select(func.min(ModelPricingRule.effective_from)).where(
ModelPricingRule.provider == provider,
ModelPricingRule.model_name == model_name,
)
)
).scalar_one_or_none()
if earliest and _reference_at(reference_at) < _reference_at(earliest):
return ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value
return ProviderCostStatus.UNMATCHED_RULE.value
def _merge_snapshot(existing: Mapping[str, Any] | None, incoming: Mapping[str, Any] | None) -> dict[str, Any] | None:
if incoming is None:
return deepcopy(dict(existing or {})) or None
merged = deepcopy(dict(existing or {}))
merged.update(deepcopy(dict(incoming)))
return merged
async def finalize_credit_record_pricing(
db: AsyncSession,
*,
charge: CreditRecord,
usage: Mapping[str, Any] | None,
stage: str,
attachment_snapshot: Mapping[str, Any] | None = None,
attachment_counts: Mapping[str, Any] | None = None,
generation_snapshot: Mapping[str, Any] | None = None,
generation_counts: Mapping[str, Any] | None = None,
allow_upgrade_estimated: bool = True,
pricing_reference_at: datetime | None = None,
use_locked_rule: bool = True,
force_reprice: bool = False,
backfill_metadata: Mapping[str, Any] | None = None,
) -> CreditRecord:
"""同一事务内回填,不 commit;JSON 一律构建新对象后整体赋值。
正常生成链路保持默认行为使用请求时已锁定的规则已核算成本不可覆盖
历史补录可显式传入统一的当前计价时点忽略旧规则绑定并强制重算
"""
if attachment_snapshot is not None:
charge.attachment_snapshot_json = deepcopy(dict(attachment_snapshot))
for key, value in (attachment_counts or {}).items():
if hasattr(charge, key):
setattr(charge, key, value)
if generation_snapshot is not None:
charge.generation_snapshot_json = _merge_snapshot(charge.generation_snapshot_json, generation_snapshot)
for key, value in (generation_counts or {}).items():
if hasattr(charge, key):
setattr(charge, key, value)
# 资源下载完成只补资源快照;同步图片/异步视频成本均不得在下载阶段重算。
if stage == PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value:
return charge
if charge.type != "consume" or charge.charge_action != "charge":
_apply_not_applicable(charge, dict(usage or {}), "only_consume_charge_can_be_priced")
return charge
current_status = charge.provider_cost_status
if not force_reprice:
if current_status == ProviderCostStatus.CALCULATED.value:
return charge
if current_status == ProviderCostStatus.ESTIMATED.value and not allow_upgrade_estimated:
return charge
usage_dict = deepcopy(dict(usage or {}))
reference = _reference_at(
pricing_reference_at
if pricing_reference_at is not None
else (charge.pricing_reference_at or charge.created_at)
)
rule = await _load_locked_rule(
db,
charge,
reference_at=reference,
use_locked_rule=use_locked_rule,
)
if not rule:
charge.pricing_reference_at = reference
if not charge.engine_provider or not charge.engine_model_name:
status = ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
elif use_locked_rule:
status = await _missing_rule_status(db, charge=charge, reference_at=reference)
else:
status = ProviderCostStatus.UNMATCHED_RULE.value
charge.provider_cost_status = status
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=status,
stage=stage,
reason="current_published_rule_not_found" if not use_locked_rule else "pricing_rule_not_found",
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
return charge
_apply_rule_fields(charge, rule, reference)
charge.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True))
if not _can_calculate(rule.billing_mode, usage_dict):
charge.provider_cost_status = (
ProviderCostStatus.USAGE_MISSING.value
if stage in FINAL_STAGES
else ProviderCostStatus.PENDING.value
)
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason="pricing_usage_missing",
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
return charge
try:
result = calculate_pricing(
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
rule_json=rule.rule_json or {},
usage=usage_dict,
currency=rule.currency,
)
if (
not force_reprice
and current_status == ProviderCostStatus.ESTIMATED.value
and result.is_estimated
):
return charge
_apply_result(
charge,
rule=rule,
usage=usage_dict,
result=result,
stage=stage,
audit_metadata=backfill_metadata,
)
log_model_pricing_event(
event_type="pricing_snapshot_persist",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
provider_cost=charge.provider_cost_amount,
is_estimated=charge.provider_cost_is_estimated,
detail={
"stage": stage,
"usage_source": charge.pricing_usage_source,
"backfill": deepcopy(dict(backfill_metadata or {})) or None,
},
)
except PricingCalculationError as exc:
charge.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason=str(exc),
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
log_model_pricing_event(
event_type="pricing_snapshot_failed",
event_status="warning",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
error=str(exc),
detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None},
)
except Exception as exc:
charge.provider_cost_status = ProviderCostStatus.ERROR.value
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason=str(exc),
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
log_model_pricing_event(
event_type="pricing_snapshot_failed",
event_status="failed",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
error=str(exc),
detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None},
)
return charge
@@ -1,275 +0,0 @@
from __future__ import annotations
import hashlib
import json
import re
from copy import deepcopy
from typing import Any, Mapping
from urllib.parse import urlsplit, urlunsplit
def safe_json_dict(value: Any) -> dict[str, Any]:
"""Best-effort JSON object conversion without leaking parse failures into billing."""
if isinstance(value, Mapping):
return deepcopy(dict(value))
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return deepcopy(dict(parsed)) if isinstance(parsed, Mapping) else {}
except Exception:
return {}
return {}
def safe_int(value: Any, default: int = 0) -> int:
try:
if value in (None, ""):
return default
return int(float(value))
except Exception:
return default
def safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except Exception:
return default
def safe_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, (int, float)):
return value != 0
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on", "enabled"}:
return True
if text in {"0", "false", "no", "off", "disabled"}:
return False
return default
def parse_size(value: Any) -> tuple[int, int]:
text = str(value or "").lower().replace("×", "x")
match = re.search(r"(\d{2,5})\s*x\s*(\d{2,5})", text)
if not match:
return 0, 0
return int(match.group(1)), int(match.group(2))
def _extract_usage(data: Mapping[str, Any]) -> dict[str, Any]:
candidates = [
data.get("usage"),
(data.get("data") or {}).get("usage") if isinstance(data.get("data"), Mapping) else None,
(data.get("result") or {}).get("usage") if isinstance(data.get("result"), Mapping) else None,
]
for value in candidates:
if isinstance(value, Mapping):
return deepcopy(dict(value))
return {}
def normalize_text_pricing_usage(
raw_usage: Mapping[str, Any] | None,
*,
base: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Normalize Ark/OpenAI text usage and retain cache/audio dimensions."""
raw = deepcopy(dict(raw_usage or {}))
result = deepcopy(dict(base or {}))
input_tokens = safe_int(result.get("input_tokens"), safe_int(raw.get("input_tokens"), safe_int(raw.get("prompt_tokens"))))
output_tokens = safe_int(result.get("output_tokens"), safe_int(raw.get("output_tokens"), safe_int(raw.get("completion_tokens"))))
total_tokens = safe_int(result.get("total_tokens"), safe_int(raw.get("total_tokens"), input_tokens + output_tokens))
details: dict[str, Any] = {}
for key in ("prompt_tokens_details", "input_tokens_details"):
value = raw.get(key)
if isinstance(value, Mapping):
details.update(deepcopy(dict(value)))
cached_input_tokens = safe_int(
result.get("cached_input_tokens"),
safe_int(raw.get("cached_input_tokens"), safe_int(details.get("cached_tokens"), safe_int(details.get("cache_read_tokens")))),
)
audio_input_tokens = safe_int(
result.get("audio_input_tokens"),
safe_int(raw.get("audio_input_tokens"), safe_int(details.get("audio_tokens"))),
)
cached_audio_input_tokens = safe_int(
result.get("cached_audio_input_tokens"),
safe_int(raw.get("cached_audio_input_tokens"), safe_int(details.get("cached_audio_tokens"))),
)
result.update(
{
"input_tokens": max(0, input_tokens),
"output_tokens": max(0, output_tokens),
"total_tokens": max(0, total_tokens),
"context_tokens": max(0, safe_int(result.get("context_tokens"), input_tokens)),
"cached_input_tokens": max(0, min(input_tokens, cached_input_tokens)),
"audio_input_tokens": max(0, min(input_tokens, audio_input_tokens)),
"cached_audio_input_tokens": max(0, min(input_tokens, audio_input_tokens, cached_audio_input_tokens)),
"cache_storage_tokens": max(0, safe_int(result.get("cache_storage_tokens"), safe_int(raw.get("cache_storage_tokens")))),
"cache_storage_duration_hours": max(
0.0,
safe_float(result.get("cache_storage_duration_hours"), safe_float(raw.get("cache_storage_duration_hours"))),
),
"provider_usage_primary": safe_bool(result.get("provider_usage_primary"), True),
"usage_source": result.get("usage_source") or "provider",
}
)
if details:
result["provider_input_token_details"] = details
return result
def extract_image_output_items(provider_response: Any) -> list[dict[str, Any]]:
"""Only parse explicit synchronous image output items; never recurse through arbitrary URLs."""
data = safe_json_dict(provider_response)
raw_items = data.get("data")
if isinstance(raw_items, Mapping):
raw_items = raw_items.get("items") or raw_items.get("data")
if not isinstance(raw_items, list):
raw_items = (data.get("result") or {}).get("data") if isinstance(data.get("result"), Mapping) else None
if not isinstance(raw_items, list):
return []
items: list[dict[str, Any]] = []
for index, raw in enumerate(raw_items):
if not isinstance(raw, Mapping):
continue
url = raw.get("url") or raw.get("image_url")
width = safe_int(raw.get("width"))
height = safe_int(raw.get("height"))
if width <= 0 or height <= 0:
width, height = parse_size(raw.get("size"))
item = {
"index": index,
"url": str(url) if url else None,
"width": width or None,
"height": height or None,
"pixels": width * height if width > 0 and height > 0 else None,
"size": raw.get("size"),
"size_source": "provider_response" if width > 0 and height > 0 else "unavailable",
}
# A valid provider output may omit a URL in rare response formats, but it must
# still be represented for generated-count and pixel-tier accounting.
items.append({key: value for key, value in item.items() if value is not None})
return items
def sanitize_output_items(items: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""Remove volatile signed URLs before persisting pricing/generation snapshots."""
sanitized: list[dict[str, Any]] = []
for raw in items:
item = deepcopy(dict(raw))
url = str(item.pop("url", "") or "").strip()
if url:
try:
parts = urlsplit(url)
normalized = (
urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
if parts.scheme and parts.netloc
else url.split("?", 1)[0].split("#", 1)[0]
)
except Exception:
normalized = url.split("?", 1)[0].split("#", 1)[0]
item["url_sha256"] = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
sanitized.append(item)
return sanitized
def normalize_provider_media_usage(
provider_response: Any,
*,
gen_type: str,
fallback_total_tokens: int = 0,
request_image_px: str | None = None,
requested_output_count: int = 1,
provider_input_image_count: int = 0,
) -> dict[str, Any]:
"""Normalize Volcengine synchronous-image or asynchronous-video response usage."""
data = safe_json_dict(provider_response)
raw_usage = _extract_usage(data)
input_tokens = safe_int(raw_usage.get("input_tokens"), safe_int(raw_usage.get("prompt_tokens")))
output_tokens = safe_int(
raw_usage.get("output_tokens"),
safe_int(raw_usage.get("completion_tokens"), safe_int(raw_usage.get("generated_tokens"))),
)
total_tokens = safe_int(raw_usage.get("total_tokens"), fallback_total_tokens)
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
if output_tokens <= 0 and total_tokens > input_tokens:
output_tokens = total_tokens - input_tokens
result: dict[str, Any] = deepcopy(dict(raw_usage))
result.update(
{
"input_tokens": max(0, input_tokens),
"output_tokens": max(0, output_tokens),
"total_tokens": max(0, total_tokens),
"requested_output_count": max(1, requested_output_count),
"provider_usage_primary": True,
}
)
pricing_meta = data.get("pricing_meta") if isinstance(data.get("pricing_meta"), Mapping) else {}
if gen_type == "image":
output_items = extract_image_output_items(data)
fallback_width, fallback_height = parse_size(request_image_px)
for item in output_items:
if not item.get("width") and fallback_width > 0 and fallback_height > 0:
item.update(
{
"width": fallback_width,
"height": fallback_height,
"pixels": fallback_width * fallback_height,
"size_source": "request_explicit",
}
)
output_items = sanitize_output_items(output_items)
provider_count = safe_int(
pricing_meta.get("provider_input_image_count"),
safe_int(data.get("provider_input_image_count"), provider_input_image_count),
)
generated = len(output_items) or safe_int(raw_usage.get("generated_images"))
provider_billed = safe_int(
raw_usage.get("billed_images"),
safe_int(pricing_meta.get("provider_billed_count"), max(0, generated)),
)
result.update(
{
"provider_input_image_count": max(0, provider_count),
"input_image_count": max(0, provider_count),
"output_items": output_items,
"successful_output_count": max(0, generated),
"provider_billed_count": max(0, provider_billed),
"usage_source": "provider_response",
}
)
return result
width = safe_int(raw_usage.get("width"), safe_int(data.get("width")))
height = safe_int(raw_usage.get("height"), safe_int(data.get("height")))
if width <= 0 or height <= 0:
width, height = parse_size(raw_usage.get("size") or data.get("size"))
result.update(
{
"output_width": width,
"output_height": height,
"dimension_source": "provider_response" if width > 0 and height > 0 else "unavailable",
"fps": safe_float(raw_usage.get("fps"), safe_float(data.get("fps"))),
"resolution": str(raw_usage.get("resolution") or data.get("resolution") or "").lower(),
"aspect_ratio": str(raw_usage.get("aspect_ratio") or data.get("aspect_ratio") or data.get("ratio") or ""),
"generate_audio": safe_bool(raw_usage.get("generate_audio"), safe_bool(data.get("generate_audio"))),
"inference_mode": str(raw_usage.get("inference_mode") or data.get("service_tier") or "online").lower(),
"usage_source": "provider" if total_tokens > 0 else "provider_response",
}
)
return result
@@ -364,54 +364,3 @@ def log_remote_api_event(
error=remote_message if event_status == "failed" else None,
**kwargs,
)
def log_model_pricing_event(
*,
event_type: str,
event_status: str = "success",
user_id: str | None = None,
credit_record_id: str | None = None,
owner_type: str | None = None,
owner_id: str | None = None,
pricing_rule_id: str | None = None,
pricing_version: str | None = None,
provider: str | None = None,
model_name: str | None = None,
billing_mode: str | None = None,
cost_status: str | None = None,
provider_cost: Any = None,
is_estimated: bool | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""统一模型计价步骤日志,写入 log/OperationLogs/model_pricing/YYYY-MM-DD.log。"""
payload = dict(detail or {})
payload.update(
{
"credit_record_id": credit_record_id,
"owner_type": owner_type,
"owner_id": owner_id,
"pricing_rule_id": pricing_rule_id,
"pricing_version": pricing_version,
"provider": provider,
"model_name": model_name,
"billing_mode": billing_mode,
"cost_status": cost_status,
"provider_cost": str(provider_cost) if provider_cost is not None else None,
"is_estimated": is_estimated,
}
)
log_operation_event(
domain="model_pricing",
module="model_pricing",
event_type=event_type,
event_status=event_status,
source="model_pricing",
user_id=user_id,
task_id=owner_id,
message=message,
detail=payload,
error=error,
)
@@ -19,7 +19,6 @@ from app.models.token_usage import TokenUsage
from app.services.upload_video_asset_service import resolve_upload_video_path
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
@@ -693,7 +692,7 @@ async def analyze_video_for_shot_split(
result = filter_and_normalize_breakdown(result, mode=mode)
usage = raw.get("usage") or {}
token_usage = normalize_text_pricing_usage(usage, base={
token_usage = {
"input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")),
"output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")),
"total_tokens": _int_usage(usage.get("total_tokens")),
@@ -708,7 +707,7 @@ async def analyze_video_for_shot_split(
"analysis_mode": mode,
"trace_id": trace_id,
"log_request": log_request_data,
})
}
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
+11 -107
View File
@@ -3,21 +3,14 @@ import json
import logging
import os
from datetime import datetime
from types import SimpleNamespace
from sqlalchemy import select
from app.models.base import async_session
from app.models.generation_record import GenerationRecord
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
from app.services.video_gen import poll_task_status, download_video, _log_video_response
from app.services.image_gen import download_image, is_sync_image_provider_result_uncertain
from app.services.media_token_usage_snapshot_service import (
mark_media_provider_cost_status,
sync_generation_record_media_token_snapshot,
)
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response
from app.services.image_gen import get_active_image_engine, download_image
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
from app.services.resource_accounting_service import (
record_generation_record_generated_resource,
safe_file_size,
@@ -32,38 +25,6 @@ POLL_INTERVAL = 30 # seconds between polls
MAX_POLLS = 60 # max 30 minutes total
def _loads(data: str | None) -> dict:
if not data:
return {}
try:
value = json.loads(data)
return value if isinstance(value, dict) else {}
except Exception:
return {}
async def _get_runtime_engine(db, record: GenerationRecord):
"""使用生成开始时冻结的引擎快照,数据库行只读取当前密钥。"""
if not record.engine_id:
raise ValueError("生成记录缺少锁定的 engine_id")
snapshot = _loads(record.engine_snapshot_json)
model = ImageEngine if record.gen_type == "image" else VideoEngine
engine = (await db.execute(select(model).where(model.id == record.engine_id).limit(1))).scalar_one_or_none()
if not engine:
raise ValueError("锁定的生成引擎不存在")
return SimpleNamespace(
id=record.engine_id,
name=snapshot.get("name") or engine.name,
provider=snapshot.get("provider") or engine.provider,
api_base=snapshot.get("api_base") or engine.api_base,
api_key=engine.api_key,
model_name=snapshot.get("model_name") or engine.model_name,
generate_url=snapshot.get("generate_url") or getattr(engine, "generate_url", ""),
query_url=snapshot.get("query_url") or getattr(engine, "query_url", ""),
default_size=snapshot.get("default_size") or getattr(engine, "default_size", "2K"),
)
class TaskQueue:
def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue()
@@ -132,7 +93,7 @@ class TaskQueue:
async def _process_video(self, db, record):
"""Process video generation task."""
record_id = record.id
if not record.seedance_task_id:
await mark_generation_record_failed_and_refund_once(
db,
@@ -143,7 +104,7 @@ class TaskQueue:
return
try:
engine = await _get_runtime_engine(db, record)
engine = await get_active_engine(db)
poll_result = await poll_task_status(engine, record.seedance_task_id)
except Exception as e:
logger.error(f"Poll error for {record_id}: {e}")
@@ -172,17 +133,6 @@ class TaskQueue:
if status == "succeeded":
file_url = poll_result.get("video_url", "")
resp_data["video_url"] = file_url
resp_data["task_id"] = record.seedance_task_id
record.provider_response_json = json.dumps(resp_data, ensure_ascii=False, default=str)
record.video_tokens_used = poll_result.get("video_tokens", 0)
# 视频 Provider 已完成时核算供应商成本;下载失败不影响已发生的供应商费用。
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=resp_data,
stage=PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value,
)
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and file_url:
@@ -207,6 +157,8 @@ class TaskQueue:
record.video_url = file_url
else:
record.video_url = file_url
record.video_tokens_used = poll_result.get("video_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=resp_data)
record.status = "completed"
record.generated_at = datetime.now()
if record.video_url:
@@ -219,12 +171,6 @@ class TaskQueue:
remote_url=file_url,
generated_at=record.generated_at,
)
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=resp_data,
stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value,
)
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task completed: {record_id}")
@@ -261,11 +207,8 @@ class TaskQueue:
record_id = record.id
from app.services.image_gen import submit_image_task, _log_image_response
provider_call_started = False
provider_call_completed = False
try:
engine = await _get_runtime_engine(db, record)
provider_call_started = True
engine = await get_active_image_engine(db)
poll_result = await asyncio.to_thread(
submit_image_task,
db,
@@ -273,23 +216,9 @@ class TaskQueue:
record,
include_media_references=False,
)
provider_call_completed = True
if poll_result["error"] == "":
remote_url = poll_result.get("image_url")
try:
provider_response = json.loads(poll_result.get("response_data") or "{}")
except (json.JSONDecodeError, TypeError):
provider_response = {}
record.provider_response_json = json.dumps(provider_response, ensure_ascii=False, default=str)
record.image_tokens_used = poll_result.get("image_tokens", 0)
# 火山图片接口为同步生成:最终响应返回后立即完成供应商成本核算。
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=provider_response,
stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
)
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and remote_url:
@@ -307,6 +236,8 @@ class TaskQueue:
record.image_url = remote_url
else:
record.image_url = remote_url
record.image_tokens_used = poll_result.get("image_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result)
record.status = "completed"
record.generated_at = datetime.now()
if record.image_url:
@@ -319,46 +250,19 @@ class TaskQueue:
remote_url=remote_url,
generated_at=record.generated_at,
)
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=provider_response,
stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value,
)
await db.commit()
logger.info(f"Image task completed: {record_id}")
else:
error_message = poll_result.get("error", "图片生成失败")
await mark_media_provider_cost_status(
db,
owner=record,
status=ProviderCostStatus.NOT_INCURRED.value,
reason=error_message,
usage_stage="provider_sync_failed",
)
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=error_message,
error_message=poll_result.get("error", "图片生成失败"),
)
await db.commit()
logger.info(f"Image task failed: {record_id}")
_log_image_response(record_id, poll_result)
except Exception as e:
if provider_call_started:
uncertain = provider_call_completed or is_sync_image_provider_result_uncertain(e)
await mark_media_provider_cost_status(
db,
owner=record,
status=(
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value
if uncertain
else ProviderCostStatus.NOT_INCURRED.value
),
reason=str(e),
usage_stage="provider_sync_exception",
)
await mark_generation_record_failed_and_refund_once(
db,
record=record,
-2
View File
@@ -77,8 +77,6 @@ if broker_url:
task_serializer="json",
accept_content=["json"],
result_serializer="json",
result_expires=max(60, int(settings.CELERY_RESULT_EXPIRES_SECONDS or 7200)),
task_store_errors_even_if_ignored=True,
timezone="Asia/Shanghai",
enable_utc=True,
task_soft_time_limit=600,
@@ -15,7 +15,6 @@ from app.enums.generation_task import (
GenerationMode,
GenerationType,
)
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.services.error_codes import extract_error_message
@@ -23,11 +22,7 @@ from app.services.generation_log_service import log_task_event
from app.services.generation_poll_schedule_service import ensure_video_poll_fields
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_provider_service import create_provider_task
from app.services.image_gen import is_sync_image_provider_result_uncertain
from app.services.media_token_usage_snapshot_service import (
mark_media_provider_cost_status,
sync_chat_generation_task_media_token_snapshot,
)
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
from app.services.redis_registry_service import ensure_aware_utc
from app.tasks.celery_app import celery_app
@@ -176,8 +171,6 @@ async def _run(task_id: str):
):
return
provider_call_started = False
provider_call_completed = False
try:
if not task.optimized_prompt:
old_stage = task.pipeline_stage
@@ -236,9 +229,7 @@ async def _run(task_id: str):
to_stage=ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
)
provider_call_started = True
created = await create_provider_task(db, task)
provider_call_completed = True
provider_task_id = created.get("task_id")
if provider_task_id:
@@ -255,13 +246,7 @@ async def _run(task_id: str):
ensure_ascii=False,
default=str,
)
if task.gen_type == GenerationType.IMAGE.value:
await sync_chat_generation_task_media_token_snapshot(
db,
task,
provider_response=task.provider_response_json,
stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
)
await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=task.provider_response_json)
if task.remote_result_url and not task.seedance_task_id:
# 同步图片路径:原 SDK 已经返回最终 URL。
@@ -317,19 +302,6 @@ async def _run(task_id: str):
if task:
error_message = extract_error_message(exc, "生成任务") if callable(extract_error_message) else str(exc)
if task.gen_type == GenerationType.IMAGE.value and provider_call_started:
uncertain = provider_call_completed or is_sync_image_provider_result_uncertain(exc)
await mark_media_provider_cost_status(
db,
owner=task,
status=(
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value
if uncertain
else ProviderCostStatus.NOT_INCURRED.value
),
reason=error_message,
usage_stage="provider_sync_exception",
)
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
@@ -19,7 +19,6 @@ from app.enums.generation_task import (
ChatGenerationTaskStatus,
GenerationType,
)
from app.enums.model_pricing import PricingSnapshotStage
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.services.celery_download_recovery_service import (
@@ -548,11 +547,7 @@ async def _run(task_id: str):
remote_url=task.remote_result_url,
generated_at=task.generated_at,
)
await sync_chat_generation_task_media_token_snapshot(
db,
task,
stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value,
)
await sync_chat_generation_task_media_token_snapshot(db, task)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
@@ -16,7 +16,6 @@ from app.enums.generation_task import (
PROVIDER_FAILED_STATUSES,
PROVIDER_SUCCESS_STATUSES,
)
from app.enums.model_pricing import PricingSnapshotStage
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.services.error_codes import extract_error_message
@@ -369,12 +368,7 @@ async def _run(task_id: str, *, force_due: bool = False):
task.video_tokens_used = poll_result.get("video_tokens", 0) or 0
task.provider_response_json = response_data
await sync_chat_generation_task_media_token_snapshot(
db,
task,
provider_response=response_data,
stage=PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value,
)
await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=response_data)
if not task.remote_result_url:
await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-I3eif2op.js"></script>
<script type="module" crossorigin src="/assets/index-mGpmDG8h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
@@ -0,0 +1,186 @@
import React from 'react';
import { Button, DatePicker, Input, Space } from 'antd';
import {
CheckOutlined,
ClearOutlined,
DeleteOutlined,
DownloadOutlined,
SearchOutlined,
UploadOutlined,
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
interface UnifiedFilterBarProps {
// 搜索
searchValue: string;
searchPlaceholder?: string;
onSearchChange: (value: string) => void;
onSearch: () => void;
// 日期
showDate?: boolean;
dateValue?: Dayjs | null;
onDateChange?: (date: Dayjs | null) => void;
datePlaceholder?: string;
// 批量操作
batchCount: number;
totalCount: number;
onSelectAll: () => void;
onClearSelection: () => void;
onDelete: () => void;
onDownload?: () => void;
onPush?: () => void;
pushLoading?: boolean;
pushText?: string;
// 右侧额外操作
extraActions?: React.ReactNode;
}
const btnBase: React.CSSProperties = {
borderRadius: 8,
fontWeight: 600,
height: 36,
};
const btnSecondary: React.CSSProperties = {
...btnBase,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#334155',
};
const btnDanger: React.CSSProperties = {
...btnBase,
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
border: 'none',
color: '#fff',
};
const btnPrimary: React.CSSProperties = {
...btnBase,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
color: '#fff',
};
const UnifiedFilterBar: React.FC<UnifiedFilterBarProps> = ({
searchValue,
searchPlaceholder = '搜索...',
onSearchChange,
onSearch,
showDate = false,
dateValue,
onDateChange,
datePlaceholder = '选择日期',
batchCount,
totalCount,
onSelectAll,
onClearSelection,
onDelete,
onDownload,
onPush,
pushLoading = false,
pushText = '推送至账户',
extraActions,
}) => {
const allSelected = batchCount === totalCount && totalCount > 0;
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}
>
{/* 左侧:搜索 + 日期 */}
<Space size={8} wrap>
<Input.Search
placeholder={searchPlaceholder}
allowClear
value={searchValue}
onChange={(e) => {
const val = e.target.value;
onSearchChange(val);
if (!val) onSearch();
}}
onSearch={onSearch}
style={{ width: 240, borderRadius: 8 }}
enterButton
/>
{showDate && (
<>
<DatePicker
value={dateValue || undefined}
onChange={(date) => onDateChange?.(date)}
format="YYYY-MM-DD"
placeholder={datePlaceholder}
style={{ width: 160, borderRadius: 8 }}
allowClear
/>
</>
)}
</Space>
{/* 右侧:批量操作 + 额外操作 */}
<Space size={8} wrap>
{/* 全选 / 取消选择 */}
<Button
icon={allSelected ? <ClearOutlined /> : <CheckOutlined />}
onClick={allSelected ? onClearSelection : onSelectAll}
style={btnSecondary}
>
{allSelected ? '取消全选' : '全选'}
</Button>
{/* 删除 */}
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnDanger}
>
{batchCount > 0 && `(${batchCount})`}
</Button>
{/* 下载 */}
{onDownload && (
<Button
icon={<DownloadOutlined />}
onClick={onDownload}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnPrimary}
>
{batchCount > 0 && `(${batchCount})`}
</Button>
)}
{/* 推送 */}
{onPush && (
<Button
type="primary"
icon={<UploadOutlined />}
onClick={onPush}
loading={pushLoading}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnBase, opacity: 0.5 } : btnPrimary}
>
{pushLoading ? '推送中...' : `${pushText} ${batchCount > 0 ? `(${batchCount})` : ''}`}
</Button>
)}
{/* 额外操作按钮 */}
{extraActions}
</Space>
</div>
);
};
export default UnifiedFilterBar;
@@ -30,6 +30,7 @@ interface UploadSelectorProps {
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
}
const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -52,6 +53,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
@@ -243,6 +245,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedAudioCount={usedAudioCount}
maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration}
hideLimitHint={hideLimitHint}
/>
<PrivatePortraitAssetPicker
@@ -120,7 +120,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyInactiveTabPane={false}
destroyOnHidden={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
@@ -13,7 +13,7 @@ interface Props {
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />;
return (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
<Space orientation="vertical" style={{ width: '100%' }} size={10}>
{items.map((project) => {
const active = selectedId === project.id;
return (
@@ -122,7 +122,14 @@ const RealPersonLibraryPanel: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
</div>
<Row gutter={16}>
<Col xs={24} lg={5}>
@@ -155,7 +162,7 @@ const RealPersonLibraryPanel: React.FC = () => {
onOk={validateSession ? undefined : handleCreate}
okText="开始认证并创建"
confirmLoading={creating}
maskClosable={!polling}
mask={{ closable: !polling }}
>
{!validateSession ? (
<Form form={form} layout="vertical">
@@ -338,7 +338,7 @@ const VirtualMaterialPanel: React.FC = () => {
<Card
key={asset.id}
hoverable
bodyStyle={{ padding: 12 }}
styles={{ body: { padding: 12 } }}
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
@@ -392,7 +392,14 @@ const VirtualMaterialPanel: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">使</Typography.Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
</div>
<Row gutter={[10, 10]}>
<Col xs={24} lg={5} >
@@ -446,11 +453,24 @@ const VirtualMaterialPanel: React.FC = () => {
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
<Space wrap>
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button>
<Button
type="primary"
icon={<UploadOutlined />}
disabled={!selectedProjectId}
onClick={() => setUploadOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
>
/
</Button>
{selectedProjectId && (
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button>
<Button
danger
icon={<DeleteOutlined />}
style={{ borderRadius: 8, fontWeight: 600 }}
>
</Button>
</Popconfirm>
)}
</Space>
@@ -464,14 +484,15 @@ const VirtualMaterialPanel: React.FC = () => {
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadAssets(1, assetPageSize)}
style={{ width: 240 }}
style={{ width: 240, borderRadius: 8 }}
enterButton
/>
<Select
allowClear
placeholder="素材状态"
value={assetStatus}
onChange={(value) => setAssetStatus(value)}
style={{ width: 150 }}
style={{ width: 140, borderRadius: 8 }}
options={[
{ value: 'Processing', label: '处理中' },
{ value: 'Active', label: '可用' },
@@ -483,13 +504,19 @@ const VirtualMaterialPanel: React.FC = () => {
placeholder="素材类型"
value={assetType}
onChange={(value) => setAssetType(value)}
style={{ width: 130 }}
style={{ width: 120, borderRadius: 8 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button onClick={() => loadAssets(1, assetPageSize)}></Button>
<Button
type="primary"
onClick={() => loadAssets(1, assetPageSize)}
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
>
</Button>
</Space>
<Spin spinning={assetLoading}>
@@ -563,7 +590,7 @@ const VirtualMaterialPanel: React.FC = () => {
</Space>
</Modal>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnHidden>
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
{previewType === 'Video' ? (
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Input, List, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { Button, Empty, Input, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import {
getPrivatePortraitProjects,
@@ -251,33 +251,36 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
</Space>
<Spin spinning={loadingProjects}>
<List
dataSource={projects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
{projects.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" />
) : (
<div>
{projects.map((item) => (
<div
key={item.id}
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
</div>
</List.Item>
)}
/>
))}
</div>
)}
</Spin>
</div>
@@ -173,45 +173,93 @@ const UploadResourceHistoryPanel: React.FC = () => {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Select
value={resourceType}
style={{ width: 120 }}
options={[
{ label: '全部', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
]}
onChange={(value) => {
setResourceType(value);
setPage(1);
}}
/>
<Input
{/* 统一筛选栏:媒体类型 + 搜索 + 批量操作 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
{/* 左侧:媒体类型按钮 */}
<Space size={8} wrap>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}></Typography.Text>
<Button
type={resourceType === '' ? 'primary' : 'default'}
onClick={() => { setResourceType(''); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === '' ? 'transparent' : '#e2e8f0', color: resourceType === '' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'image' ? 'primary' : 'default'}
onClick={() => { setResourceType('image'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'image' ? 'transparent' : '#e2e8f0', color: resourceType === 'image' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'video' ? 'primary' : 'default'}
onClick={() => { setResourceType('video'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'video' ? 'transparent' : '#e2e8f0', color: resourceType === 'video' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'audio' ? 'primary' : 'default'}
onClick={() => { setResourceType('audio'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'audio' ? 'transparent' : '#e2e8f0', color: resourceType === 'audio' ? '#fff' : '#64748b' }}
>
</Button>
</Space>
{/* 右侧:搜索 + 批量操作 */}
<Space size={8} wrap>
<Input.Search
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名或URL"
style={{ width: 240 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1);
load();
}}
onSearch={() => { setPage(1); load(); }}
style={{ width: 240, borderRadius: 8 }}
enterButton
/>
{/* <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> */}
</Space>
<Space>
{batchMode ? (
<>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}></Button>
<Button
onClick={selectAll}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
>
{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}
</Button>
<Button
danger
onClick={handleBatchDelete}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #ef4444, #dc2626)', border: 'none', color: '#fff' }}
>
{selectedIds.size > 0 && `(${selectedIds.size})`}
</Button>
<Button
onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b' }}
>
</Button>
</>
) : (
<Button onClick={() => setBatchMode(true)}></Button>
<Button
type="primary"
onClick={() => setBatchMode(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
)}
</Space>
</div>
@@ -232,7 +280,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
const checked = selectedIds.has(item.id);
return (
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
<div style={{ position: 'relative', background: '#f1f5f9', cursor: batchMode ? 'pointer' : 'default' }} onClick={() => batchMode && toggle(item.id)}>
{renderMedia(item)}
{batchMode && <Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />}
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
@@ -26,15 +26,72 @@ interface UploadResourceHistoryPickerProps {
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
}
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
if (/^data:|blob:/i.test(url)) return url;
if (/^https?:\/\//i.test(url)) {
const parsed = new URL(url);
if (parsed.pathname.startsWith('/uploads')) {
return parsed.pathname + parsed.search;
}
return url;
}
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const validateImageDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
return null;
};
const validateVideoDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
const totalPixels = width * height;
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
return null;
};
const getItemValidationError = (item: UploadResourceHistoryItem): string | null => {
const itemAny = item as any;
const width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
const height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
if (item.resourceType === 'image') {
if (width > 0 && height > 0) {
return validateImageDimensions(width, height);
}
} else if (item.resourceType === 'video') {
if (width > 0 && height > 0) {
return validateVideoDimensions(width, height);
}
}
return null;
};
const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = url;
});
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
@@ -64,6 +121,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
}) => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
@@ -175,11 +233,42 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
if (open) setCheckedMap(new Map());
}, [open]);
const toggle = (item: UploadResourceHistoryItem) => {
const toggle = async (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中');
return;
}
const itemAny = item as any;
let width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
let height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
if (item.resourceType === 'image') {
if (width <= 0 || height <= 0) {
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
if (preview) {
const dims = await getImageDimensions(preview);
width = dims.width;
height = dims.height;
}
}
if (width > 0 && height > 0) {
const error = validateImageDimensions(width, height);
if (error) {
message.error(`${item.fileName || '图片'}${error}`);
return;
}
}
} else if (item.resourceType === 'video') {
if (width > 0 && height > 0) {
const error = validateVideoDimensions(width, height);
if (error) {
message.error(`${item.fileName || '视频'}${error}`);
return;
}
}
}
setCheckedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id);
@@ -249,19 +338,21 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
/>
<Button icon={<ReloadOutlined />} onClick={loadGroups}></Button>
</Space>
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
{selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'}
</span>
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
{selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} {selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}
</span>
{maxAudioCount !== undefined && (
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
{selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} {selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}
{!hideLimitHint && (
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
{selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'}
</span>
)}
</div>
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
{selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} {selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}
</span>
{maxAudioCount !== undefined && (
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
{selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} {selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}
</span>
)}
</div>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
@@ -310,7 +401,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const selectedDisabled = selectedIdSet.has(item.id);
const validationError = getItemValidationError(item);
const disabled = selectedDisabled || !!validationError;
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
@@ -326,6 +419,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
title={validationError ? validationError : undefined}
>
<div style={{ background: '#f1f5f9' }}>
{item.resourceType === 'image' ? (
@@ -340,6 +434,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
{validationError && <Tag color="red" style={{ margin: 0 }}></Tag>}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
@@ -352,7 +447,12 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
{selectedDisabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
{validationError && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(239, 68, 68, 0.9)', color: '#fff', padding: '4px 8px', fontSize: 11, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{validationError}
</div>
)}
</div>
);
})}
+28 -7
View File
@@ -7,6 +7,9 @@ import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
// 统一按钮样式
const btnBase: React.CSSProperties = { borderRadius: 8, fontWeight: 600 };
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
interface ConsumptionRecord {
@@ -164,13 +167,24 @@ const ConsumePage: React.FC = () => {
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Input
placeholder="广告主ID"
value={advertiserId}
onChange={(e) => setAdvertiserId(e.target.value)}
style={{ width: 180 }}
style={{ width: 180, borderRadius: 8 }}
allowClear
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
/>
@@ -183,25 +197,32 @@ const ConsumePage: React.FC = () => {
setConsumeDateRange(undefined);
}
}}
style={{ borderRadius: 8 }}
/>
<Button
type="primary"
size="medium"
onClick={handleSearch}
style={{ ...btnBase, borderColor: 'transparent' }}
>
</Button>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Button
icon={<SyncOutlined />}
onClick={handleSync}
loading={syncLoading}
style={{ borderRadius: 8 }}
style={btnBase}
>
</Button>
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)} style={{ borderRadius: 8 }}></Button>
<Button
icon={<SettingOutlined />}
onClick={() => setShowModal(true)}
style={{ ...btnBase, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
>
</Button>
</div>
</div>
@@ -44,7 +44,7 @@ const CreditRecordsPage: React.FC = () => {
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,0.1)' },
team_internal: { color: '#0958d9', label: '团队内部', bg: 'rgba(9, 88, 217, 0.1)' },
team_internal: { color: '#0958d9', label: '团队内部', bg: 'rgba(139,92,246,0.1)' },
};
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
return (
+12 -6
View File
@@ -978,7 +978,7 @@ const AIChatPage: React.FC = () => {
}
console.log(mediaReferences);
// console.log(mediaReferences);
// 创建用户消息对象
const newMessage: Message = {
@@ -1240,7 +1240,7 @@ const AIChatPage: React.FC = () => {
try {
const { width, height } = await getImageDimensions(file);
const error = validateVideoDimensions(width, height);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
@@ -1316,7 +1316,7 @@ const AIChatPage: React.FC = () => {
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateVideoDimensions(width, height);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
@@ -1456,7 +1456,7 @@ const AIChatPage: React.FC = () => {
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateVideoDimensions(width, height);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
@@ -2183,13 +2183,16 @@ const AIChatPage: React.FC = () => {
onClick={(e) => {
e.stopPropagation();
setInputValue(msg.originalPrompt || '');
if (msg.genType) {
setMediaType(msg.genType);
}
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
if (hasFirstLastFrame && msg.genType === 'video') {
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
setLastFrame(last ? { ...last, label: last.label || '' } : null);
setFirstFrame(first ? { ...first, label: first.label || '', duration: first.duration } : null);
setLastFrame(last ? { ...last, label: last.label || '', duration: last.duration } : null);
setCurrentMedia([]);
setReferenceMode('first_last_frame');
} else {
@@ -2199,6 +2202,7 @@ const AIChatPage: React.FC = () => {
url: ref.url,
label: ref.label || '',
role: ref.role,
duration: ref.duration,
})));
setFirstFrame(null);
setLastFrame(null);
@@ -2887,6 +2891,7 @@ const AIChatPage: React.FC = () => {
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
>
<div
style={{
@@ -3068,6 +3073,7 @@ const AIChatPage: React.FC = () => {
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
>
<div
style={{
+51 -4
View File
@@ -261,6 +261,25 @@ const GeneratePage: React.FC = () => {
const [creditRatios, setCreditRatios] = useState<any>([]);
const [cimage, setCimage] = useState<any>([]);
const validateImageDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
return null;
};
const validateVideoDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
const totalPixels = width * height;
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
return null;
};
const handlePasteUpload = async (file: File) => {
const isImage = file.type.startsWith("image/");
const isVideo = file.type.startsWith("video/");
@@ -299,23 +318,51 @@ const GeneratePage: React.FC = () => {
}
let fileDuration = 0;
if (isVideo) {
fileDuration = await new Promise<number>((resolve) => {
const videoInfo = await new Promise<{ duration: number; width: number; height: number }>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve(video.duration || 0);
resolve({ duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 });
video.remove();
};
video.onerror = () => {
resolve(0);
resolve({ duration: 0, width: 0, height: 0 });
video.remove();
};
video.src = URL.createObjectURL(file);
});
fileDuration = videoInfo.duration;
if (videoInfo.width > 0 && videoInfo.height > 0) {
const error = validateVideoDimensions(videoInfo.width, videoInfo.height);
if (error) {
message.error(`${error}`);
return false;
}
}
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}`);
return false;
}
} else {
const imageInfo = await new Promise<{ width: number; height: number }>((resolve) => {
const img = document.createElement('img');
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
if (imageInfo.width > 0 && imageInfo.height > 0) {
const error = validateImageDimensions(imageInfo.width, imageInfo.height);
if (error) {
message.error(`${error}`);
return false;
}
}
}
setUploading(true);
try {
@@ -1968,7 +2015,7 @@ const GeneratePage: React.FC = () => {
}
}}
rows={3}
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
placeholder="上传参考素材(只用做模型理解,不参与生成)、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
maxLength={500}
bordered={false}
autoSize={{ minRows: 2, maxRows: 6 }}
+149 -346
View File
@@ -17,6 +17,8 @@ import {
DeleteOutlined,
LoadingOutlined,
UserOutlined,
CheckOutlined,
ClearOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
@@ -299,7 +301,7 @@ const GeneratedRecord: React.FC = () => {
folder?.file(filename, blob);
successCount++;
} catch (error) {
console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
// console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
hasError = true;
break;
}
@@ -448,7 +450,7 @@ const GeneratedRecord: React.FC = () => {
const res = await getPreTestList({ page: 1, pageSize: 100 });
setPreTestTemplates(res.data?.data || res.data || []);
} catch (error) {
console.error('加载前测模板失败:', error);
// console.error('加载前测模板失败:', error);
} finally {
setPreTestTemplatesLoading(false);
}
@@ -489,7 +491,7 @@ const GeneratedRecord: React.FC = () => {
setOauthList(data || []);
setOauthTotal(res.pagination.total || 0);
} catch (error) {
console.error('加载授权列表失败:', error);
// console.error('加载授权列表失败:', error);
setOauthList([]);
setOauthTotal(0);
} finally {
@@ -513,7 +515,7 @@ const GeneratedRecord: React.FC = () => {
});
setOpenTypeMap(map);
} catch (error) {
console.error('加载历史授权账户列表失败:', error);
// console.error('加载历史授权账户列表失败:', error);
setHistoryOAuthList([]);
setOpenTypeMap({});
} finally {
@@ -532,7 +534,7 @@ const GeneratedRecord: React.FC = () => {
setUploadHistoryList(data || []);
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
} catch (error) {
console.error('加载推送历史失败:', error);
// console.error('加载推送历史失败:', error);
setUploadHistoryList([]);
setUploadHistoryTotal(0);
} finally {
@@ -669,7 +671,7 @@ const GeneratedRecord: React.FC = () => {
setIsPreTest('2');
setPreTestTemplate('');
} catch (error: any) {
console.error('批量推送失败:', error);
// console.error('批量推送失败:', error);
message.error(error.message || '批量推送失败');
} finally {
setUploading(false);
@@ -699,7 +701,7 @@ const GeneratedRecord: React.FC = () => {
});
message.success('文件名更新成功');
} catch (error: any) {
console.error('文件名更新失败:', error);
// console.error('文件名更新失败:', error);
message.error(error.message || '文件名更新失败');
}
};
@@ -733,11 +735,11 @@ const GeneratedRecord: React.FC = () => {
}),
}));
});
console.log(response);
// console.log(response);
const successCount = response?.successCount || 0;
message.success(`已更新 ${successCount} 个文件名`);
} catch (error: any) {
console.error('文件名更新失败:', error);
// console.error('文件名更新失败:', error);
message.error(error.message || '文件名更新失败');
}
};
@@ -909,10 +911,36 @@ const GeneratedRecord: React.FC = () => {
}, [filterType, filterMedia]);
return (
<div className="content_box" >
{/* 操作栏:筛选 + 推送按钮 */}
{/* 顶部:标签切换 */}
<Tabs
activeKey={filterType}
onChange={(key) => {
setFilterType(key as typeof filterType);
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
items={[
{ key: 'project', label: <span><FolderOpenOutlined /> </span> },
{ key: 'creation', label: <span><FileTextOutlined /> </span> },
{ key: 'hot_opening_replicate', label: <span><StarOutlined /> </span> },
{ key: 'shot_replicate', label: <span><PlayCircleOutlined /> </span> },
{ key: 'private_portrait', label: <span><UserOutlined /> </span> },
{ key: 'upload_resource', label: <span><UploadOutlined /> </span> },
]}
size="middle"
style={{ marginBottom: 12 }}
/>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : filterType === 'upload_resource' ? (
<UploadResourceHistoryPanel />
) : (
<>
{/* 统一筛选栏:搜索 + 日期 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
@@ -920,338 +948,110 @@ const GeneratedRecord: React.FC = () => {
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<FilterOutlined style={{ color: '#090a0cff', fontSize: 14 }} />
<Space size={8} wrap={true}>
<Button
type={filterType === 'project' ? 'primary' : 'default'}
onClick={() => {
setFilterType('project');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'project'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'project' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'project' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FolderOpenOutlined />}
>
</Button>
<Button
type={filterType === 'creation' ? 'primary' : 'default'}
onClick={() => {
setFilterType('creation');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'creation'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'creation' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'creation' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FileTextOutlined />}
>
</Button>
<Button
type={filterType === 'hot_opening_replicate' ? 'primary' : 'default'}
onClick={() => {
setFilterType('hot_opening_replicate');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'hot_opening_replicate'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'hot_opening_replicate' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'hot_opening_replicate' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<StarOutlined />}
>
</Button>
<Button
type={filterType === 'shot_replicate' ? 'primary' : 'default'}
onClick={() => {
setFilterType('shot_replicate');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'shot_replicate'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'shot_replicate' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'shot_replicate' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<PlayCircleOutlined />}
>
</Button>
<Button
type={filterType === 'private_portrait' ? 'primary' : 'default'}
onClick={() => {
setFilterType('private_portrait');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'private_portrait'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UserOutlined />}
>
</Button>
<Button
type={filterType === 'upload_resource' ? 'primary' : 'default'}
onClick={() => {
setFilterType('upload_resource');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'upload_resource'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UploadOutlined />}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}></Typography.Text>
<Button
type={filterMedia === 'video' ? 'primary' : 'default'}
onClick={() => { setFilterMedia('video'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'video' ? 'transparent' : '#e2e8f0', color: filterMedia === 'video' ? '#fff' : '#64748b' }}
icon={<VideoCameraOutlined />}
>
</Button>
<Button
type={filterMedia === 'image' ? 'primary' : 'default'}
onClick={() => { setFilterMedia('image'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'image' ? 'transparent' : '#e2e8f0', color: filterMedia === 'image' ? '#fff' : '#64748b' }}
icon={<PictureOutlined />}
>
</Button>
</div>
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{/* 多选模式按钮 */}
{isSelectionMode ? (
<Space size={8} wrap={true}>
<Button
onClick={handleSelectAll}
style={{
borderRadius: 8,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#222222ff',
fontWeight: 600,
}}
>
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) ? '取消全选' : '全选'}
</Button>
<Button
onClick={() => {
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#64748b',
fontWeight: 600,
}}
>
</Button>
<Button
onClick={() => handleDeleteSelected()}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #bf0b0a 0%, #ff8165 100%)', fontWeight: 600, padding: '8px 24px', color: '#fff' }}
>
({selectedItems.size})
</Button>
<Button
onClick={() => handleDownloadSelected()}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
fontWeight: 600,
}}
>
({selectedItems.size})
</Button>
<Button
type="primary"
onClick={handleBatchUploadSelected}
loading={uploading}
disabled={uploading || selectedItems.size === 0}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
fontWeight: 600,
}}
>
{uploading ? '推送中...' : `推送至账户 (${selectedItems.size})`}
</Button>
</Space>
) : (
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setIsSelectionMode(true)}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
fontWeight: 600,
}}
>
</Button>
)}
</div>
)}
</div>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : filterType === 'upload_resource' ? (
<UploadResourceHistoryPanel />
) : (
<>
{/* Second row filter: 视频 / 图片 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 24,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}></Typography.Text>
<Space size={8} wrap={true}>
<Button
type={filterMedia === 'video' ? 'primary' : 'default'}
onClick={() => {
setFilterMedia('video');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterMedia === 'video'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0',
color: filterMedia === 'video' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<VideoCameraOutlined />}
>
</Button>
<Button
type={filterMedia === 'image' ? 'primary' : 'default'}
onClick={() => {
setFilterMedia('image');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterMedia === 'image'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterMedia === 'image' ? 'none' : '1px solid #e2e8f0',
color: filterMedia === 'image' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<PictureOutlined />}
>
</Button>
<DatePicker
picker="date"
value={selectedDate ? dayjs(selectedDate) : undefined}
onChange={(date, dateString) => handleDateChange(dateString || '')}
format="YYYY-MM-DD"
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
placeholder="选择日期"
/>
{selectedDate && (
<Button
type="text"
onClick={() => handleDateChange('')}
style={{ color: '#94a3b8', fontSize: 12 }}
>
</Button>
)}
<Input.Search
placeholder="搜索提示词"
allowClear
value={searchKeyword}
onChange={(e) => {
const val = e.target.value;
setSearchKeyword(val);
if (!val) {
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
}
}}
onSearch={() => {
<Space size={8} wrap>
<DatePicker
picker="date"
value={selectedDate ? dayjs(selectedDate) : undefined}
onChange={(date, dateString) => handleDateChange(dateString || '')}
format="YYYY-MM-DD"
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
placeholder="选择日期"
allowClear
/>
<Input.Search
placeholder="搜索提示词"
allowClear
value={searchKeyword}
onChange={(e) => {
const val = e.target.value;
setSearchKeyword(val);
if (!val) {
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
}}
style={{ width: 240, borderRadius: 8 }}
/>
</Space>
</div>
<Button
icon={<ClockCircleOutlined />}
onClick={handleOpenUploadHistory}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
color: '#ffffff',
fontWeight: 600,
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
}}
>
</Button>
}
}}
onSearch={() => {
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
}}
style={{ width: 240, borderRadius: 8 }}
enterButton
/>
<Button
icon={<ClockCircleOutlined />}
onClick={handleOpenUploadHistory}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
>
</Button>
{/* 批量操作按钮 */}
{isSelectionMode ? (
<>
<Button
onClick={selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) && recordlist.length > 0 ? () => { setSelectedItems(new Set()); } : handleSelectAll}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
>
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) && recordlist.length > 0 ? '取消全选' : '全选'}
</Button>
<Button
onClick={() => { setIsSelectionMode(false); setSelectedItems(new Set()); }}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b' }}
>
</Button>
<Button
danger
onClick={handleDeleteSelected}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #ef4444, #dc2626)', border: 'none', color: '#fff' }}
>
{selectedItems.size > 0 && `(${selectedItems.size})`}
</Button>
<Button
onClick={handleDownloadSelected}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', color: '#fff' }}
>
{selectedItems.size > 0 && `(${selectedItems.size})`}
</Button>
<Button
type="primary"
onClick={handleBatchUploadSelected}
loading={uploading}
disabled={uploading || selectedItems.size === 0}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', color: '#fff' }}
>
{uploading ? '推送中...' : `推送至账户 ${selectedItems.size > 0 ? `(${selectedItems.size})` : ''}`}
</Button>
</>
) : (
<Button
type="primary"
onClick={() => setIsSelectionMode(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
)}
</Space>
</div>
{/* Content area */}
{loading ? (
@@ -1263,7 +1063,7 @@ const GeneratedRecord: React.FC = () => {
}}>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 24, color: '#64748b' }} spin />}
tip="加载中..."
description="加载中..."
size="large"
/>
</div>
@@ -1408,18 +1208,20 @@ const GeneratedRecord: React.FC = () => {
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 20,
height: 20,
top: 6,
left: 6,
width: 22,
height: 22,
borderRadius: '50%',
backgroundColor: isSelectedItem ? '#10b981' : 'rgba(255,255,255,0.9)',
border: isSelectedItem ? '2px solid #10b981' : '2px solid #d1d5db',
backgroundColor: isSelectedItem ? '#6366f1' : 'rgba(255,255,255,0.92)',
border: isSelectedItem ? '2px solid #6366f1' : '2px solid #cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 10,
boxShadow: isSelectedItem ? '0 2px 8px rgba(99,102,241,0.4)' : '0 1px 3px rgba(0,0,0,0.1)',
transition: 'all 0.15s ease',
}}
onClick={(e) => {
e.stopPropagation();
@@ -1517,10 +1319,11 @@ const GeneratedRecord: React.FC = () => {
left: 0,
right: 0,
bottom: 0,
border: '3px solid #10b981',
border: '3px solid #6366f1',
borderRadius: 4,
pointerEvents: 'none',
zIndex: 5,
boxShadow: 'inset 0 0 0 1px rgba(99,102,241,0.2)',
}} />
)}
</div>
+2 -2
View File
@@ -206,7 +206,7 @@ const HomePage: React.FC = () => {
setActiveCaseTab(firstId);
// 初始请求第一个 tab 的数据
getHomeCaseButton(firstId).then((btnRes: any) => {
console.log('caseButton:', btnRes);
// console.log('caseButton:', btnRes);
if (btnRes?.categories?.[0]?.assets) {
setCaseAssets(btnRes.categories[0].assets);
}
@@ -357,7 +357,7 @@ const HomePage: React.FC = () => {
{
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
title: '爆款复刻',
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
description: '上传参考视频与产品图片,一键复刻爆款',
action: '立即创作',
path: '/initial',
},
+12 -5
View File
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
const buildMediaUrl = (url: string): string => {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
function InitialInfo() {
const navigate = useNavigate();
const { creatID } = useParams<{ creatID: string }>();
@@ -101,7 +108,7 @@ function InitialInfo() {
}, [previewVisible, previewType]);
const openPreview = (url: string, type: 'image' | 'video') => {
console.log(url, type);
// console.log(url, type);
setPreviewUrl(url);
setPreviewType(type);
setPreviewVisible(true);
@@ -587,7 +594,7 @@ function InitialInfo() {
{taskDetail?.material?.materialVideoUrl ? (
<video
src={taskDetail.material.materialVideoUrl}
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
@@ -602,7 +609,7 @@ function InitialInfo() {
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
{taskDetail?.material?.materialImageUrl ? (
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
<img src={buildMediaUrl(taskDetail.material.materialImageUrl)} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}></div>
)}
@@ -721,7 +728,7 @@ function InitialInfo() {
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)' }}>
{taskDetail?.material?.materialVideoUrl ? (
<video
src={taskDetail.material.materialVideoUrl}
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
controls
/>
@@ -741,7 +748,7 @@ function InitialInfo() {
<div style={{ height: 180, borderRadius: 10, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{taskDetail?.material?.materialImageUrl ? (
<img
src={taskDetail.material.materialImageUrl}
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
alt="产品图片"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
/>
+23 -24
View File
@@ -383,13 +383,24 @@ const MaterialListPage: React.FC = () => {
<FolderOpenOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'top', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Input
placeholder="广告主ID"
value={searchParams.advertiser_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
style={{ width: 160 }}
style={{ width: 150, borderRadius: 8 }}
allowClear
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
/>
@@ -397,7 +408,7 @@ const MaterialListPage: React.FC = () => {
placeholder="素材ID"
value={searchParams.material_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
style={{ width: 160 }}
style={{ width: 150, borderRadius: 8 }}
allowClear
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
/>
@@ -405,7 +416,7 @@ const MaterialListPage: React.FC = () => {
placeholder="上传ID"
value={searchParams.upload_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
style={{ width: 160 }}
style={{ width: 150, borderRadius: 8 }}
allowClear
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
/>
@@ -413,7 +424,7 @@ const MaterialListPage: React.FC = () => {
placeholder="文件名"
value={searchParams.file_name}
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
style={{ width: 140 }}
style={{ width: 140, borderRadius: 8 }}
allowClear
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
/>
@@ -421,7 +432,7 @@ const MaterialListPage: React.FC = () => {
placeholder="资源类型"
value={searchParams.resource_type}
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
style={{ width: 120 }}
style={{ width: 110, borderRadius: 8 }}
allowClear
options={[
{ value: 'image', label: '图片' },
@@ -431,21 +442,16 @@ const MaterialListPage: React.FC = () => {
<Button
type="primary"
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
>
</Button>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Button
icon={<RobotOutlined />}
onClick={() => navigate('/generated?filterType=private_portrait&portraitTab=aigc_virtual')}
style={{
borderRadius: 12,
fontSize: 14,
borderColor: '#8b5cf6',
color: '#7c3aed',
background: '#f5f3ff',
}}
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#8b5cf6', color: '#7c3aed', background: '#f5f3ff' }}
>
</Button>
@@ -454,16 +460,9 @@ const MaterialListPage: React.FC = () => {
loading={pushTemplatesLoading}
onClick={handleOpenPushModal}
disabled={selectedRows.size === 0}
style={{
borderRadius: 12,
fontSize: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
color: '#fff',
boxShadow: '0 4px 14px rgba(99,102,241,0.3)',
}}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
>
({selectedRows.size})
{selectedRows.size > 0 && `(${selectedRows.size})`}
</Button>
</div>
</div>
+1 -1
View File
@@ -128,7 +128,7 @@ const ProjectsPage: React.FC = () => {
backgroundClip: 'text',
// textAlign: 'center',
}}>
</h2>
<p style={{ fontSize: 13, color: '#64748b', margin: '4px 0 0 0' }}>
{projects.length} ·
+9 -1
View File
@@ -473,7 +473,7 @@ function RemoveInfo() {
</>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
{record.splitStatus === 'failed' ? '切割失败' : record.splitStatus === 'pending' ? '等待切割' : '切割中'}
</div>
)}
</div>
@@ -486,6 +486,14 @@ function RemoveInfo() {
render: (_: any, record: any) => {
const splitStatus = record.split_status || record.splitStatus;
if (splitStatus === 'pending') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 14, color: '#64748b' }}></span>
</div>
);
}
if (splitStatus === 'processing') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+11 -4
View File
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
const buildMediaUrl = (url: string): string => {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
function InitialInfo() {
const navigate = useNavigate();
const { creatID } = useParams<{ creatID: string }>();
@@ -590,7 +597,7 @@ function InitialInfo() {
{taskDetail?.material?.materialVideoUrl ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
@@ -605,7 +612,7 @@ function InitialInfo() {
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
{taskDetail?.material?.materialImageUrl ? (
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
<img src={buildMediaUrl(taskDetail.material.materialImageUrl)} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}></div>
)}
@@ -721,7 +728,7 @@ function InitialInfo() {
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
{taskDetail?.material?.materialVideoUrl ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
controls
/>
@@ -741,7 +748,7 @@ function InitialInfo() {
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
{taskDetail?.material?.materialImageUrl ? (
<img
src={taskDetail.material.materialImageUrl}
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
alt="产品图片"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
/>
+8
View File
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/uploads': {
target: 'http://ceshi.apiforeign.minzhong.cn',
changeOrigin: true,
},
},
},
})