Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c0a31a4b |
+813
@@ -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 三层)
|
||||||
|
- 路由结构 SPA(react-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
@@ -5,11 +5,13 @@
|
|||||||
```
|
```
|
||||||
video_item/
|
video_item/
|
||||||
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
||||||
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
|
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
|
├── video-gen-admin/ # 后台管理端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
└── DEPLOYMENT.md # 本文档
|
└── DEPLOYMENT.md # 本文档
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、环境要求
|
## 一、环境要求
|
||||||
@@ -18,14 +20,13 @@ video_item/
|
|||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| Python | >= 3.10 | 推荐 3.12 |
|
| Python | >= 3.10 | 推荐 3.12 |
|
||||||
| Node.js | >= 18 | 推荐 20+ |
|
| Node.js | >= 18 | 推荐 20+ |
|
||||||
| PostgreSQL | >= 14 | 推荐 16 |
|
| PostgreSQL | >= 14 | 推荐 16;**必须** |
|
||||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
|
||||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
|
||||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
|
||||||
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
|
|
||||||
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
|
|
||||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
| 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. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
|
||||||
# CentOS/RHEL
|
# CentOS/RHEL
|
||||||
sudo yum install -y ca-certificates
|
sudo yum install -y ca-certificates
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
@@ -49,7 +50,10 @@ python -m venv .venv
|
|||||||
# Linux/Mac
|
# Linux/Mac
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
# 安装基础依赖 + PostgreSQL 驱动
|
# 安装基础依赖
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# 安装 PostgreSQL 驱动(生产环境必须)
|
||||||
pip install -e ".[pg]"
|
pip install -e ".[pg]"
|
||||||
|
|
||||||
# 如需 Redis 支持(限流、验证码、Celery)
|
# 如需 Redis 支持(限流、验证码、Celery)
|
||||||
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
|
|||||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||||
pip install -e ".[pg,redis,celery]"
|
pip install -e ".[pg,redis,celery]"
|
||||||
|
|
||||||
#安装阿里支付sdk
|
# 如需支付宝
|
||||||
pip install -e ".[pg,redis,celery,alipay]"
|
pip install -e ".[pg,redis,celery,alipay]"
|
||||||
|
|
||||||
# 安装微信支付sdk
|
# 如需微信支付
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
|
pip install -e ".[pg,redis,celery,alipay,wxpay]"
|
||||||
|
|
||||||
#安装火山sdk
|
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
|
|
||||||
|
|
||||||
|
|
||||||
|
# 如需火山引擎 SDK(短信等)
|
||||||
|
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置环境变量
|
### 2. 配置环境变量
|
||||||
|
|
||||||
复制 `.env.example` 为 `.env`,修改以下关键配置:
|
复制 `.env.example` 为 `.env`,修改关键配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -81,13 +83,14 @@ cp .env.example .env
|
|||||||
```ini
|
```ini
|
||||||
# ── 基础配置 ──
|
# ── 基础配置 ──
|
||||||
APP_NAME=VideoGen API
|
APP_NAME=VideoGen API
|
||||||
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
SECRET_KEY=改成一个随机的长字符串
|
SECRET_KEY=改成一个随机的长字符串(JWT 签名密钥)
|
||||||
|
|
||||||
# ── 数据库 ──
|
# ── 数据库(必须) ──
|
||||||
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
||||||
|
|
||||||
# ── Redis(可选,留空则禁用限流和验证码) ──
|
# ── Redis(可选,留空则禁用限流/验证码/Celery) ──
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# ── JWT ──
|
# ── JWT ──
|
||||||
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
|||||||
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
||||||
|
|
||||||
# ── LLM 提示词优化 ──
|
# ── 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,留空则禁用) ──
|
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
||||||
ENCRYPTION_KEY=你的32字节base64密钥
|
ENCRYPTION_KEY=你的32字节base64密钥
|
||||||
|
|
||||||
# ── 存储路径 ──
|
# ── 存储路径(本地存储) ──
|
||||||
STORAGE_TYPE=local
|
STORAGE_TYPE=local
|
||||||
STORAGE_LOCAL_PATH=./storage/generate/videos
|
STORAGE_LOCAL_PATH=./storage/generate/videos
|
||||||
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
||||||
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
||||||
UPLOAD_LOCAL_PATH=./storage/uploads
|
UPLOAD_LOCAL_PATH=./storage/uploads
|
||||||
|
|
||||||
# ── 跨域(生产环境务必限制域名) ──
|
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有) ──
|
||||||
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
||||||
|
|
||||||
# ── 回调基础地址 ──
|
# ── 回调基础地址 ──
|
||||||
BASE_URL=https://你的域名
|
BASE_URL=https://你的域名
|
||||||
|
|
||||||
|
# ── 验证码 ──
|
||||||
|
CAPTCHA_ENABLED=true
|
||||||
|
|
||||||
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
||||||
SMS_MOCK=true
|
SMS_MOCK=true
|
||||||
VOLC_SMS_ACCESS_KEY_ID=
|
VOLC_SMS_ACCESS_KEY_ID=
|
||||||
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
|
|||||||
|
|
||||||
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
||||||
PAYMENT_MOCK=true
|
PAYMENT_MOCK=true
|
||||||
|
WECHAT_MCH_ID=
|
||||||
|
WECHAT_API_KEY=
|
||||||
|
ALIPAY_APP_ID=
|
||||||
|
ALIPAY_PRIVATE_KEY=
|
||||||
|
ALIPAY_PUBLIC_KEY=
|
||||||
|
ALIPAY_NOTIFY_URL=
|
||||||
|
|
||||||
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||||
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
|||||||
FFMPEG_BIN=/usr/bin/ffmpeg
|
FFMPEG_BIN=/usr/bin/ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
|
||||||
|
|
||||||
### 3. 初始化数据库
|
### 3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建 PostgreSQL 数据库
|
# 创建 PostgreSQL 数据库
|
||||||
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
||||||
|
|
||||||
# 启动后端(首次启动自动建表 + 填充种子数据)
|
# 启动后端(首次启动自动建表)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动会自动完成:
|
首次启动时会**自动创建所有数据表**(41 个 model)。
|
||||||
- 创建所有数据表(23 个 model)
|
|
||||||
- 创建管理员账号:`admin` / `123456`
|
|
||||||
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`)
|
|
||||||
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
|
|
||||||
|
|
||||||
**首次部署后务必修改默认密码。**
|
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**(`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
|
||||||
|
>
|
||||||
|
> **如果你需要种子数据,**有以下选择:
|
||||||
|
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
|
||||||
|
> 2. 手动通过 API 或数据库脚本创建管理员账号
|
||||||
|
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
|
||||||
|
|
||||||
### 4. 数据库迁移 (Alembic)
|
### 4. 数据库迁移 (Alembic)
|
||||||
|
|
||||||
项目使用 Alembic 管理数据库结构变更,`env.py` 已导入全部 20 个 model。
|
项目使用 Alembic 管理数据库结构变更。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
@@ -179,17 +197,13 @@ python -m alembic history
|
|||||||
python -m alembic downgrade -1
|
python -m alembic downgrade -1
|
||||||
```
|
```
|
||||||
|
|
||||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||||
|
|
||||||
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
|
|
||||||
|
|
||||||
### 5. 生产运行
|
### 5. 生产运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式一:直接运行(推荐 4 workers)
|
# 直接运行(推荐 4 workers)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
|
||||||
# 方式二:使用 systemd (Linux)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
||||||
@@ -220,11 +234,22 @@ sudo systemctl start videogen-api
|
|||||||
|
|
||||||
### 6. Celery Worker(可选)
|
### 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
|
```bash
|
||||||
# 启动 Worker(处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download)
|
# 启动 Worker(消费所有队列)
|
||||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
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`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||||
@@ -239,7 +264,7 @@ Type=simple
|
|||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/opt/video-gen-api
|
WorkingDirectory=/opt/video-gen-api
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -249,23 +274,25 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### 7. Docker 部署(可选)
|
### 7. Docker 部署(可选)
|
||||||
|
|
||||||
项目提供 `Dockerfile` 和 `docker-compose.yml`,一键启动完整环境:
|
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||||
|
|
||||||
|
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
|
|
||||||
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
|
# 使用前需修改 Dockerfile 第 6 行为:
|
||||||
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
|
|
||||||
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动的服务:
|
启动的服务:
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
| 服务 | 端口 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| api | 8000 | FastAPI 应用(带 `--reload`,开发模式) |
|
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
|
||||||
| worker | - | Celery Worker |
|
| worker | - | Celery Worker(消费所有队列) |
|
||||||
| postgres | 5432 | PostgreSQL 16 |
|
| postgres | 5432 | PostgreSQL 16 |
|
||||||
| redis | 6379 | Redis 7 |
|
| redis | 6379 | Redis 7 |
|
||||||
|
|
||||||
@@ -276,7 +303,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name api.yourdomain.com;
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
# 上传文件大小限制
|
# 上传文件大小限制(视频上传需要较大值)
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -300,6 +327,8 @@ server {
|
|||||||
|
|
||||||
## 三、前台部署 (video-gen-app)
|
## 三、前台部署 (video-gen-app)
|
||||||
|
|
||||||
|
用户前台,面向最终用户。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -310,8 +339,8 @@ npm install
|
|||||||
# 配置 API 地址(创建 .env.production)
|
# 配置 API 地址(创建 .env.production)
|
||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||||
|
|
||||||
# 如需前后端加密通信
|
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
|
||||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||||
|
|
||||||
# 构建
|
# 构建
|
||||||
npm run build
|
npm run build
|
||||||
@@ -319,7 +348,15 @@ npm run build
|
|||||||
|
|
||||||
构建产物在 `dist/` 目录。
|
构建产物在 `dist/` 目录。
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 前端环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -328,12 +365,12 @@ server {
|
|||||||
root /opt/video-gen-app/dist;
|
root /opt/video-gen-app/dist;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA 路由
|
# SPA 路由:所有页面请求回退到 index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存(带 hash 的文件名可长期缓存)
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -345,6 +382,8 @@ server {
|
|||||||
|
|
||||||
## 四、后台管理部署 (video-gen-admin)
|
## 四、后台管理部署 (video-gen-admin)
|
||||||
|
|
||||||
|
管理员后台,面向运营/管理人员。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -356,13 +395,21 @@ npm install
|
|||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
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
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 后台环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -456,6 +503,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
|
|
||||||
## 七、默认账号
|
## 七、默认账号
|
||||||
|
|
||||||
|
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
|
||||||
|
|
||||||
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
||||||
|------|--------|--------|------|------|
|
|------|--------|--------|------|------|
|
||||||
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
| 管理员 | 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 进程内,轮询视频/图片生成状态 | 无额外依赖 |
|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
|
||||||
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
|
| `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` — 请求/响应日志记录
|
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
||||||
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
||||||
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
||||||
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
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` | 始终开启 |
|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
|
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
|
|
||||||
|
|
||||||
文件日志使用 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 # 环境变量
|
│ ├── .env # 环境变量
|
||||||
│ ├── .venv/ # Python 虚拟环境
|
│ ├── .venv/ # Python 虚拟环境
|
||||||
│ ├── app/ # 应用代码
|
│ ├── app/ # 应用代码
|
||||||
|
│ │ ├── api/ # API 路由
|
||||||
|
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
|
||||||
|
│ │ │ └── admin/ # 管理端接口
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ ├── models/ # 数据模型 (41 个)
|
||||||
|
│ │ ├── services/ # 业务服务
|
||||||
|
│ │ ├── tasks/ # Celery 任务
|
||||||
|
│ │ ├── enums/ # 枚举定义
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
│ ├── alembic/ # 数据库迁移文件
|
│ ├── alembic/ # 数据库迁移文件
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ │ ├── generate/
|
│ │ ├── 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 "描述"
|
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 定义与迁移不一致 | 重新生成迁移文件后执行 |
|
||||||
Vendored
+110
-110
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-3wUbVp5v.js"></script>
|
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import AdminModels from './pages/AdminModels';
|
|||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||||
import AdminModelPricingRules from './pages/AdminModelPricingRules';
|
|
||||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||||
import AdminPaymentStats from './pages/AdminPaymentStats';
|
import AdminPaymentStats from './pages/AdminPaymentStats';
|
||||||
import AdminIndustries from './pages/AdminIndustries';
|
import AdminIndustries from './pages/AdminIndustries';
|
||||||
@@ -88,7 +87,6 @@ const App = () => {
|
|||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
<Route path="teams" element={<AdminTeams />} />
|
<Route path="teams" element={<AdminTeams />} />
|
||||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||||
<Route path="model-pricing" element={<AdminModelPricingRules />} />
|
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||||
<Route path="video-engines" element={<AdminVideoEngines />} />
|
<Route path="video-engines" element={<AdminVideoEngines />} />
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
||||||
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
||||||
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||||
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
|
|
||||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||||
@@ -297,54 +296,12 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
|
|||||||
setMaybe(params, 'source_module', filters?.sourceModule);
|
setMaybe(params, 'source_module', filters?.sourceModule);
|
||||||
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
||||||
setMaybe(params, 'billing_scene', filters?.billingScene);
|
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, 'start_date', filters?.startDate);
|
||||||
setMaybe(params, 'end_date', filters?.endDate);
|
setMaybe(params, 'end_date', filters?.endDate);
|
||||||
const q = params.toString() ? `?${params}` : '';
|
const q = params.toString() ? `?${params}` : '';
|
||||||
return api.get(`/admin/credit-records${q}`);
|
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[]> {
|
export async function getIndustryConfigs(): Promise<any[]> {
|
||||||
return api.get('/admin/industry-configs');
|
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;
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { getCreditRecords, getTeamOptions } from '../api';
|
import { getCreditRecords, getTeamOptions } from '../api';
|
||||||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
@@ -14,15 +14,21 @@ import { formatDate } from '../utils/formatDate';
|
|||||||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
|
|
||||||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||||
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
|
totalRecharge: 0,
|
||||||
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
|
totalConsume: 0,
|
||||||
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
|
totalRefund: 0,
|
||||||
totalTokens: 0, inputTokens: 0, outputTokens: 0,
|
transactionCount: 0,
|
||||||
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
|
generationCount: 0,
|
||||||
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
|
generationAttemptCount: 0,
|
||||||
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
|
imageGenerationCount: 0,
|
||||||
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
|
videoGenerationCount: 0,
|
||||||
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 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 }> = {
|
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 = [
|
const userScopeOptions = [
|
||||||
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
|
{ value: '', label: '全部用户' },
|
||||||
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
|
{ value: 'admin', label: '后台用户' },
|
||||||
|
{ value: 'frontend_internal', label: '前台内部用户' },
|
||||||
|
{ value: 'frontend_external', label: '前台外部用户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const recordTypeOptions = [
|
const recordTypeOptions = [
|
||||||
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
|
{ value: '', label: '全部流水' },
|
||||||
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
|
{ value: 'recharge', label: '充值' },
|
||||||
|
{ value: 'consume', label: '消费' },
|
||||||
|
{ value: 'refund', label: '回退' },
|
||||||
|
{ value: 'team_internal', label: '团队内部' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const creditSubjectOptions = [
|
const creditSubjectOptions = [
|
||||||
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
|
{ value: '', label: '全部积分类型' },
|
||||||
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
|
{ value: 'media', label: '图片/视频生成积分' },
|
||||||
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
|
{ value: 'text', label: '提词优化积分' },
|
||||||
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
|
{ value: 'module', label: '模块功能积分' },
|
||||||
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', 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 = [
|
const chargeKindOptions = [
|
||||||
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
|
{ value: '', label: '全部扣费子类' },
|
||||||
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
|
{ value: 'media', label: '媒体生成' },
|
||||||
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
|
{ value: 'text_prompt', label: '提词优化' },
|
||||||
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', 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: '团队内部转移' },
|
{ value: 'team_internal', label: '团队内部转移' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sourceModuleOptions = [
|
const sourceModuleOptions = [
|
||||||
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
|
{ value: '', label: '全部模块' },
|
||||||
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
|
{ value: 'ai_creation', label: 'AI创作' },
|
||||||
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
|
{ 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 = [
|
const sourceStepOptions = [
|
||||||
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
|
{ value: '', label: '全部步骤' },
|
||||||
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
|
{ value: 'image_prompt_optimize', label: '图片提词优化' },
|
||||||
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
|
{ value: 'image_generate', label: '图片生成' },
|
||||||
|
{ value: 'video_prompt_optimize', label: '视频提词优化' },
|
||||||
|
{ value: 'video_generate', label: '视频生成' },
|
||||||
|
{ value: 'video_analysis', label: '视频分析' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const billingSceneOptions = [
|
const billingSceneOptions = [
|
||||||
{ value: '', label: '全部计费场景' },
|
{ value: '', label: '全部计费场景' },
|
||||||
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
||||||
@@ -94,45 +135,24 @@ const billingSceneOptions = [
|
|||||||
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
||||||
{ value: 'unknown', 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 {
|
function n(value: number | undefined | null): string {
|
||||||
const parsed = Number(value || 0);
|
return Number(value || 0).toLocaleString();
|
||||||
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function engineTypeLabel(type?: string): string {
|
function engineTypeLabel(type?: string): string {
|
||||||
if (type === 'model') return '提词/分析模型';
|
if (type === 'model') return '提词/分析模型';
|
||||||
if (type === 'image') return '图片引擎';
|
if (type === 'image') return '图片引擎';
|
||||||
if (type === 'video') return '视频引擎';
|
if (type === 'video') return '视频引擎';
|
||||||
return '执行配置';
|
return '执行配置';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
||||||
if (scope === 'admin') return { userType: 'admin' };
|
if (scope === 'admin') return { userType: 'admin' };
|
||||||
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
||||||
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
||||||
return {};
|
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 AdminCreditRecords: React.FC = () => {
|
||||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||||
@@ -143,7 +163,6 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const [exportProgress, setExportProgress] = useState('');
|
const [exportProgress, setExportProgress] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
|
|
||||||
|
|
||||||
const [userScope, setUserScope] = useState('');
|
const [userScope, setUserScope] = useState('');
|
||||||
const [teamFilter, setTeamFilter] = useState('');
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
@@ -156,24 +175,24 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const [sourceStepCode, setSourceStepCode] = useState('');
|
const [sourceStepCode, setSourceStepCode] = useState('');
|
||||||
const [billingScene, setBillingScene] = useState('');
|
const [billingScene, setBillingScene] = useState('');
|
||||||
const [userNameFilter, setUserNameFilter] = 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 [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||||
|
|
||||||
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
||||||
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
|
page,
|
||||||
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
|
pageSize,
|
||||||
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
|
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,
|
billingScene: billingScene || undefined,
|
||||||
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
|
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||||
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
|
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||||
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
|
|
||||||
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
|
||||||
...buildScope(userScope),
|
...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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -181,20 +200,33 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const res = await getCreditRecords(query);
|
const res = await getCreditRecords(query);
|
||||||
setRecords(res.items || []);
|
setRecords(res.items || []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
|
setSummary(res.summary || DEFAULT_SUMMARY);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载积分记录失败');
|
message.error(e?.message || '加载积分记录失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [query]);
|
useEffect(() => { load(); }, [query]);
|
||||||
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
|
setUserScope('');
|
||||||
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
|
setTeamFilter('');
|
||||||
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
|
setRecordType('');
|
||||||
|
setCreditSubject('');
|
||||||
|
setMediaType('');
|
||||||
|
setChargeKind('');
|
||||||
|
setSourceModule('');
|
||||||
|
setSourceStepCode('');
|
||||||
|
setBillingScene('');
|
||||||
|
setUserNameFilter('');
|
||||||
|
setDateRange([null, null]);
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportExcel = async () => {
|
const exportExcel = async () => {
|
||||||
@@ -205,179 +237,168 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
||||||
const first = await getCreditRecords(baseQuery);
|
const first = await getCreditRecords(baseQuery);
|
||||||
const all: AdminCreditRecord[] = [...(first.items || [])];
|
const all: AdminCreditRecord[] = [...(first.items || [])];
|
||||||
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
|
const exportSummary = first.summary || DEFAULT_SUMMARY;
|
||||||
const totalRows = first.total || 0;
|
const totalRows = first.total || 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
||||||
|
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
|
||||||
for (let p = 2; p <= totalPages; p += 1) {
|
for (let p = 2; p <= totalPages; p += 1) {
|
||||||
const res = await getCreditRecords({ ...baseQuery, page: p });
|
const res = await getCreditRecords({ ...baseQuery, page: p });
|
||||||
all.push(...(res.items || []));
|
all.push(...(res.items || []));
|
||||||
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
||||||
}
|
}
|
||||||
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
|
|
||||||
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
|
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
|
||||||
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
|
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
|
||||||
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
|
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
|
||||||
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
|
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
||||||
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
|
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||||
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
|
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||||
{ title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' },
|
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
||||||
{ title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' },
|
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||||
{ title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
|
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
||||||
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
|
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
||||||
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
|
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
|
||||||
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
|
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
|
||||||
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
|
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
|
||||||
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
|
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
|
||||||
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
|
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
|
||||||
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
|
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||||||
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
|
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
|
||||||
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
|
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
|
||||||
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
|
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
|
||||||
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
|
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
|
||||||
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
|
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
|
||||||
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
|
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
|
||||||
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
|
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
|
||||||
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
|
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
|
||||||
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '否' },
|
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
|
||||||
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
|
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
||||||
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '否' },
|
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
||||||
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
|
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||||
{ 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 || '' },
|
|
||||||
];
|
];
|
||||||
exportStyledExcel({
|
|
||||||
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
|
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
|
||||||
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
|
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: [
|
summaryRows: [
|
||||||
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
|
['总充值', exportSummary.totalRecharge],
|
||||||
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
|
['总消费', exportSummary.totalConsume],
|
||||||
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
|
['总回退', exportSummary.totalRefund],
|
||||||
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
|
['交易笔数', exportSummary.transactionCount],
|
||||||
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
|
['生成条数', exportSummary.generationCount],
|
||||||
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
|
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||||
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
|
['图片生成条数', exportSummary.imageGenerationCount],
|
||||||
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
|
['视频生成条数', exportSummary.videoGenerationCount],
|
||||||
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
|
['图片消费积分', exportSummary.imageConsume],
|
||||||
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
|
['视频消费积分', exportSummary.videoConsume],
|
||||||
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
|
['提词消费积分', exportSummary.textConsume],
|
||||||
['待核算流水数', exportSummary.providerCostPendingCount],
|
['视频分析积分', exportSummary.analysisConsume],
|
||||||
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
|
['总 Token', exportSummary.totalTokens],
|
||||||
], columns, rows: all,
|
['输入 Token', exportSummary.inputTokens],
|
||||||
|
['输出 Token', exportSummary.outputTokens],
|
||||||
|
],
|
||||||
|
columns: detailColumns,
|
||||||
|
rows: all,
|
||||||
});
|
});
|
||||||
message.success('Excel 已导出');
|
message.success('Excel 已导出');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '导出失败');
|
message.error(e?.message || '导出失败');
|
||||||
} finally { setExporting(false); setExportProgress(''); }
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
setExportProgress('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
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: '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: '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: '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, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
|
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
|
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, 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: '步骤/场景', 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: 85, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
{ 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, 2)}</Typography.Text> },
|
{ 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, 2) },
|
{ 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: '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: '执行配置', 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: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
||||||
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
{ 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: '时间', dataIndex: 'createdAt', width: 160, 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> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
<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} 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}><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} 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}><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} 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}><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>
|
<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>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
<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
|
||||||
<Select value={recordType} onChange={v => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
value={teamFilter}
|
||||||
<Select value={creditSubject} onChange={v => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
<Select value={mediaType} onChange={v => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
style={{ width: 170 }}
|
||||||
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
options={[
|
||||||
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
|
{ value: '', label: '全部团队' },
|
||||||
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
<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 />
|
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||||
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
|
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||||
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
|
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||||
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
|
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||||
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
|
<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>
|
</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>
|
</div>
|
||||||
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</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} 条` }} />
|
<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>
|
</Card>
|
||||||
|
</div>
|
||||||
<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>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminCreditRecords;
|
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;
|
|
||||||
@@ -191,7 +191,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
user_type: userType,
|
user_type: userType,
|
||||||
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
||||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
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('用户创建成功');
|
message.success('用户创建成功');
|
||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
@@ -882,7 +882,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="private_portrait_asset_limit"
|
name="private_portrait_asset_limit"
|
||||||
label="私域人像素材总量上限"
|
label="私域人像素材总量上限"
|
||||||
initialValue={5}
|
initialValue={50}
|
||||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -796,20 +796,6 @@ export interface AdminCreditRecordSummary {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: 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 {
|
export interface AdminCreditRecord {
|
||||||
@@ -862,38 +848,6 @@ export interface AdminCreditRecord {
|
|||||||
engineName?: string;
|
engineName?: string;
|
||||||
engineProvider?: string;
|
engineProvider?: string;
|
||||||
engineModelName?: 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;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,83 +873,10 @@ export interface AdminCreditRecordQueryParams {
|
|||||||
sourceModule?: string;
|
sourceModule?: string;
|
||||||
sourceStepCode?: string;
|
sourceStepCode?: string;
|
||||||
billingScene?: string;
|
billingScene?: string;
|
||||||
engineProvider?: string;
|
|
||||||
engineModelName?: string;
|
|
||||||
pricingVersionCode?: string;
|
|
||||||
providerCostStatus?: string;
|
|
||||||
providerCostIsEstimated?: boolean;
|
|
||||||
hasAttachment?: boolean;
|
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: 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 @@
|
|||||||
{"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")
|
|
||||||
@@ -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.recharge_package import router as recharge_package_router
|
||||||
from app.api.admin.menu_config import router as menu_config_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.upload import router as admin_upload_router
|
||||||
from app.api.admin.model_pricing import router as model_pricing_router
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
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(recharge_package_router)
|
||||||
router.include_router(menu_config_router)
|
router.include_router(menu_config_router)
|
||||||
router.include_router(admin_upload_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
|
|
||||||
@@ -37,7 +37,6 @@ from app.schemas.admin import (
|
|||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
UpdateFrontendUserKindRequest,
|
UpdateFrontendUserKindRequest,
|
||||||
OperationLogOut,
|
OperationLogOut,
|
||||||
AdminCreditRecordListOut,
|
|
||||||
)
|
)
|
||||||
from app.schemas.team import UpdateUserTeamRequest
|
from app.schemas.team import UpdateUserTeamRequest
|
||||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||||
@@ -62,7 +61,6 @@ from app.services.generation_billing_service import (
|
|||||||
get_next_credit_attempt_no,
|
get_next_credit_attempt_no,
|
||||||
)
|
)
|
||||||
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
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.utils.id_gen import generate_id
|
||||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
||||||
|
|
||||||
@@ -483,7 +481,7 @@ async def admin_change_password(
|
|||||||
|
|
||||||
# ── Credit Records ───────────────────────────────────────
|
# ── Credit Records ───────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/credit-records", response_model=AdminCreditRecordListOut)
|
@router.get("/credit-records")
|
||||||
async def list_credit_records(
|
async def list_credit_records(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=1000),
|
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_module: str | None = Query(None),
|
||||||
source_step_code: str | None = Query(None),
|
source_step_code: str | None = Query(None),
|
||||||
billing_scene: 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),
|
start_date: str = Query(None),
|
||||||
end_date: str = Query(None),
|
end_date: str = Query(None),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
@@ -528,12 +520,6 @@ async def list_credit_records(
|
|||||||
source_module=source_module,
|
source_module=source_module,
|
||||||
source_step_code=source_step_code,
|
source_step_code=source_step_code,
|
||||||
billing_scene=billing_scene,
|
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,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
@@ -2060,9 +2046,6 @@ async def admin_generate_video(
|
|||||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||||
|
|
||||||
duration = record.duration or 5
|
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(
|
media_billing = await charge_generation_media_by_params(
|
||||||
db,
|
db,
|
||||||
user_id=record.user_id,
|
user_id=record.user_id,
|
||||||
@@ -2070,21 +2053,14 @@ async def admin_generate_video(
|
|||||||
gen_type="video",
|
gen_type="video",
|
||||||
duration=duration,
|
duration=duration,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
aspect_ratio=aspect_ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
description_prefix="视频生成(管理后台)",
|
description_prefix="视频生成(管理后台)",
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.aspect_ratio = aspect_ratio
|
record.aspect_ratio = aspect_ratio
|
||||||
record.resolution = resolution
|
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.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
||||||
record.status = "generating"
|
record.status = "generating"
|
||||||
record.error_message = None
|
record.error_message = None
|
||||||
@@ -2095,6 +2071,8 @@ async def admin_generate_video(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
try:
|
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(
|
task_id = await submit_video_task(
|
||||||
db,
|
db,
|
||||||
engine,
|
engine,
|
||||||
@@ -2117,31 +2095,19 @@ async def admin_generate_video(
|
|||||||
|
|
||||||
post_image_size = body.get("image_size", "")
|
post_image_size = body.get("image_size", "")
|
||||||
image_size = post_image_size or record.image_size or "2K"
|
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(
|
media_billing = await charge_generation_media_by_params(
|
||||||
db,
|
db,
|
||||||
user_id=record.user_id,
|
user_id=record.user_id,
|
||||||
record_id=record.id,
|
record_id=record.id,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
image_size=image_size,
|
image_size=image_size,
|
||||||
image_px=image_px,
|
|
||||||
aspect_ratio=image_proportion,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
description_prefix="图片生成(管理后台)",
|
description_prefix="图片生成(管理后台)",
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.image_size = image_size
|
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.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
||||||
record.status = "generating"
|
record.status = "generating"
|
||||||
record.error_message = None
|
record.error_message = None
|
||||||
|
|||||||
@@ -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.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.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.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.services.video_cover_service import async_create_video_cover_for_local_video
|
||||||
from app.enums.audio_reference import (
|
from app.enums.audio_reference import (
|
||||||
@@ -339,7 +338,6 @@ async def optimize(
|
|||||||
attempt_no=prompt_attempt_no,
|
attempt_no=prompt_attempt_no,
|
||||||
charge_kind=CHARGE_TEXT_PROMPT,
|
charge_kind=CHARGE_TEXT_PROMPT,
|
||||||
usage=token_usage,
|
usage=token_usage,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
await deduct_credits(
|
await deduct_credits(
|
||||||
db, current_user.id, text_credits,
|
db, current_user.id, text_credits,
|
||||||
@@ -433,9 +431,6 @@ async def generate(
|
|||||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||||
|
|
||||||
duration = record.duration or 5
|
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(
|
media_billing = await charge_generation_media_by_params(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -443,21 +438,14 @@ async def generate(
|
|||||||
gen_type="video",
|
gen_type="video",
|
||||||
duration=duration,
|
duration=duration,
|
||||||
resolution=req.resolution,
|
resolution=req.resolution,
|
||||||
aspect_ratio=req.aspect_ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
description_prefix=project_name+"-",
|
description_prefix=project_name+"-",
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.aspect_ratio = req.aspect_ratio
|
record.aspect_ratio = req.aspect_ratio
|
||||||
record.resolution = req.resolution
|
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.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
||||||
record.status = "generating"
|
record.status = "generating"
|
||||||
record.error_message = None
|
record.error_message = None
|
||||||
@@ -468,9 +456,11 @@ async def generate(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
try:
|
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.error_codes import extract_error_message
|
||||||
from app.services.video_queue import task_queue
|
from app.services.video_queue import task_queue
|
||||||
|
|
||||||
|
engine = await get_active_engine(db)
|
||||||
task_id = await submit_video_task(
|
task_id = await submit_video_task(
|
||||||
db,
|
db,
|
||||||
engine,
|
engine,
|
||||||
@@ -490,31 +480,19 @@ async def generate(
|
|||||||
|
|
||||||
elif record.gen_type == GenerationType.image:
|
elif record.gen_type == GenerationType.image:
|
||||||
image_size = req.image_size or record.image_size or "2K"
|
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(
|
media_billing = await charge_generation_media_by_params(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
record_id=record.id,
|
record_id=record.id,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
image_size=image_size,
|
image_size=image_size,
|
||||||
image_px=image_px,
|
|
||||||
aspect_ratio=image_proportion,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
description_prefix=project_name+"-",
|
description_prefix=project_name+"-",
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.image_size = image_size
|
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.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
||||||
record.status = "generating"
|
record.status = "generating"
|
||||||
record.error_message = None
|
record.error_message = None
|
||||||
@@ -571,46 +549,14 @@ async def retry_generation(
|
|||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
owner_id=record.id,
|
owner_id=record.id,
|
||||||
)
|
)
|
||||||
if record.gen_type == GenerationType.video:
|
media_billing = await charge_generation_media_for_record(
|
||||||
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(
|
|
||||||
db,
|
db,
|
||||||
user_id=record.user_id,
|
record=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,
|
|
||||||
fps=24 if record.gen_type == GenerationType.video else None,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
description_prefix="生成重试-",
|
description_prefix="视频重试",
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
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.status = "generating"
|
||||||
record.error_message = None
|
record.error_message = None
|
||||||
record.video_url = None
|
record.video_url = None
|
||||||
@@ -624,7 +570,8 @@ async def retry_generation(
|
|||||||
try:
|
try:
|
||||||
from app.services.video_queue import task_queue
|
from app.services.video_queue import task_queue
|
||||||
if record.gen_type == GenerationType.video:
|
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(
|
task_id = await submit_video_task(
|
||||||
db,
|
db,
|
||||||
engine,
|
engine,
|
||||||
|
|||||||
@@ -689,20 +689,15 @@ async def retry_task(
|
|||||||
record_id=task.id,
|
record_id=task.id,
|
||||||
gen_type=task.gen_type,
|
gen_type=task.gen_type,
|
||||||
image_size=task.image_size,
|
image_size=task.image_size,
|
||||||
image_px=task.image_px,
|
|
||||||
aspect_ratio=task.aspect_ratio or task.image_proportion,
|
|
||||||
duration=task.duration,
|
duration=task.duration,
|
||||||
resolution=task.resolution,
|
resolution=task.resolution,
|
||||||
fps=24 if task.gen_type == "video" else None,
|
|
||||||
engine_id=task.engine_id,
|
engine_id=task.engine_id,
|
||||||
project_name="AI生成任务",
|
project_name="AI生成任务",
|
||||||
description_prefix="Chat任务重试",
|
description_prefix="Chat任务重试",
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=task.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
task.current_billing_attempt_no = attempt_no
|
|
||||||
task.status = "generating"
|
task.status = "generating"
|
||||||
task.pipeline_stage = "queued"
|
task.pipeline_stage = "queued"
|
||||||
task.error_message = None
|
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()
|
|
||||||
@@ -112,8 +112,6 @@ class Settings(BaseSettings):
|
|||||||
# ChatAPI async generation pipeline settings
|
# ChatAPI async generation pipeline settings
|
||||||
CELERY_BROKER_URL: str = ""
|
CELERY_BROKER_URL: str = ""
|
||||||
CELERY_RESULT_BACKEND: str = ""
|
CELERY_RESULT_BACKEND: str = ""
|
||||||
# Celery result backend 仅保留近期排障状态;业务恢复以数据库状态和业务日志为准。
|
|
||||||
CELERY_RESULT_EXPIRES_SECONDS: int = 7200
|
|
||||||
# Celery async 兼容配置。
|
# Celery async 兼容配置。
|
||||||
# single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。
|
# single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。
|
||||||
# direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。
|
# direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。
|
||||||
|
|||||||
@@ -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: "历史引擎缺失",
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ from enum import Enum
|
|||||||
|
|
||||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
# 用户私域人像素材默认上限。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。
|
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from app.models.project import Project
|
|||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
from app.models.model_config import ModelConfig
|
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.system_config import SystemConfig
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.notification_read import NotificationRead
|
from app.models.notification_read import NotificationRead
|
||||||
@@ -42,7 +41,7 @@ __all__ = [
|
|||||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||||
"init_database", "close_database",
|
"init_database", "close_database",
|
||||||
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
|
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
|
||||||
"ModelConfig", "ModelPricingRule", "SystemConfig", "Notification", "PaymentOrder",
|
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
||||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||||
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
||||||
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
|
"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_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
provider_response_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)
|
credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
|
text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
from __future__ import annotations
|
from sqlalchemy import Float, ForeignKey, Index, Integer, String
|
||||||
|
|
||||||
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.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
JsonType = JSON().with_variant(JSONB, "postgresql")
|
|
||||||
|
|
||||||
|
|
||||||
class CreditRecord(Base, TimestampMixin):
|
class CreditRecord(Base, TimestampMixin):
|
||||||
__tablename__ = "credit_records"
|
__tablename__ = "credit_records"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
# 正式计费幂等键:同一用户同一个业务流水只能写入一次。
|
||||||
|
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
|
||||||
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
|
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_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
|
||||||
Index("ix_credit_records_related_type", "related_id", "type"),
|
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_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_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_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)
|
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)
|
type: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
amount: Mapped[float] = mapped_column(Float)
|
amount: Mapped[float] = mapped_column(Float)
|
||||||
balance_after: 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)
|
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
total_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_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=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_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
engine_provider: Mapped[str | None] = mapped_column(String(64), 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)
|
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)
|
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)
|
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_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)
|
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
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
seedance_task_id: Mapped[str | None] = mapped_column(String(128), 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)
|
credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
text_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)
|
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)
|
|
||||||
@@ -41,7 +41,7 @@ class User(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||||
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
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
|
@property
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
|
|||||||
last_login_at: NaiveDatetimeOptional = None
|
last_login_at: NaiveDatetimeOptional = None
|
||||||
allowed_menus: list | None = None
|
allowed_menus: list | None = None
|
||||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||||
private_portrait_asset_limit: int = 5
|
private_portrait_asset_limit: int = 50
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class CreateUserRequest(BaseModel):
|
|||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||||
allowed_menus: list | None = None
|
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):
|
class UpdateFrontendUserKindRequest(BaseModel):
|
||||||
@@ -135,20 +135,6 @@ class AdminCreditRecordSummaryOut(BaseModel):
|
|||||||
total_tokens: int = 0
|
total_tokens: int = 0
|
||||||
input_tokens: int = 0
|
input_tokens: int = 0
|
||||||
output_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):
|
class AdminCreditRecordOut(BaseModel):
|
||||||
@@ -201,38 +187,6 @@ class AdminCreditRecordOut(BaseModel):
|
|||||||
engine_name: str | None = None
|
engine_name: str | None = None
|
||||||
engine_provider: str | None = None
|
engine_provider: str | None = None
|
||||||
engine_model_name: 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
|
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]
|
|
||||||
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
|
|||||||
allowed_menus: list | None = None
|
allowed_menus: list | None = None
|
||||||
must_set_password: bool = False
|
must_set_password: bool = False
|
||||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||||
private_portrait_asset_limit: int = 5
|
private_portrait_asset_limit: int = 50
|
||||||
team_id: str | None = None
|
team_id: str | None = None
|
||||||
team_name: str | None = None
|
team_name: str | None = None
|
||||||
is_team_manager: bool = False
|
is_team_manager: bool = False
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_, case, distinct, func, or_, select
|
from sqlalchemy import and_, case, distinct, func, or_, select
|
||||||
@@ -17,10 +16,6 @@ from app.enums.credit_record import (
|
|||||||
CREDIT_RECORD_TYPE_LABELS,
|
CREDIT_RECORD_TYPE_LABELS,
|
||||||
CreditRecordSubject,
|
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.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
|
||||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
@@ -39,16 +34,19 @@ CST = timezone(timedelta(hours=8))
|
|||||||
def _iso(dt: Any) -> str | None:
|
def _iso(dt: Any) -> str | None:
|
||||||
if dt is None:
|
if dt is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(dt, datetime):
|
if isinstance(dt, datetime):
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
|
# 数据库已经按东八区业务时间返回但丢了 tzinfo 时,不再额外 +8
|
||||||
return dt.replace(tzinfo=CST).isoformat()
|
return dt.replace(tzinfo=CST).isoformat()
|
||||||
|
|
||||||
return dt.astimezone(CST).isoformat()
|
return dt.astimezone(CST).isoformat()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return dt.isoformat()
|
return dt.isoformat()
|
||||||
except Exception:
|
except Exception:
|
||||||
return str(dt)
|
return str(dt)
|
||||||
|
|
||||||
|
|
||||||
def _round2(value: Any) -> float:
|
def _round2(value: Any) -> float:
|
||||||
try:
|
try:
|
||||||
return round(float(value or 0), 2)
|
return round(float(value or 0), 2)
|
||||||
@@ -56,19 +54,11 @@ def _round2(value: Any) -> float:
|
|||||||
return 0.0
|
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:
|
def _as_date_start(value: str | None) -> datetime | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=CST).astimezone(timezone.utc)
|
return datetime.strptime(value, "%Y-%m-%d")
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -77,11 +67,7 @@ def _as_date_end(value: str | None) -> datetime | None:
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return (
|
return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
datetime.strptime(value, "%Y-%m-%d")
|
|
||||||
.replace(tzinfo=CST, hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
.astimezone(timezone.utc)
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -106,12 +92,6 @@ def _build_filters(
|
|||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
source_step_code: str | None = None,
|
source_step_code: str | None = None,
|
||||||
billing_scene: 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,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
@@ -127,7 +107,10 @@ def _build_filters(
|
|||||||
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
|
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
|
||||||
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
|
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
|
||||||
if team_id:
|
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:
|
if record_type:
|
||||||
filters.append(CreditRecord.type == record_type)
|
filters.append(CreditRecord.type == record_type)
|
||||||
if credit_subject:
|
if credit_subject:
|
||||||
@@ -142,18 +125,6 @@ def _build_filters(
|
|||||||
filters.append(CreditRecord.source_step_code == source_step_code)
|
filters.append(CreditRecord.source_step_code == source_step_code)
|
||||||
if billing_scene:
|
if billing_scene:
|
||||||
filters.append(CreditRecord.billing_scene == 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)
|
start = _as_date_start(start_date)
|
||||||
end = _as_date_end(end_date)
|
end = _as_date_end(end_date)
|
||||||
if start:
|
if start:
|
||||||
@@ -168,6 +139,7 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
|
|||||||
for record in records:
|
for record in records:
|
||||||
if record.owner_type and record.owner_id:
|
if record.owner_type and record.owner_id:
|
||||||
grouped.setdefault(record.owner_type, set()).add(record.owner_id)
|
grouped.setdefault(record.owner_type, set()).add(record.owner_id)
|
||||||
|
|
||||||
model_map: dict[str, Any] = {
|
model_map: dict[str, Any] = {
|
||||||
"chat_generation_task": ChatGenerationTask,
|
"chat_generation_task": ChatGenerationTask,
|
||||||
"generation_record": GenerationRecord,
|
"generation_record": GenerationRecord,
|
||||||
@@ -194,6 +166,7 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
|
|||||||
owner_deleted_at = None
|
owner_deleted_at = None
|
||||||
if record.owner_type and record.owner_id:
|
if record.owner_type and record.owner_id:
|
||||||
owner_deleted, owner_deleted_at = deleted_map.get((record.owner_type, record.owner_id), (False, None))
|
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)
|
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)
|
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
|
||||||
return {
|
return {
|
||||||
@@ -246,38 +219,6 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
|
|||||||
"engine_name": record.engine_name,
|
"engine_name": record.engine_name,
|
||||||
"engine_provider": record.engine_provider,
|
"engine_provider": record.engine_provider,
|
||||||
"engine_model_name": record.engine_model_name,
|
"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),
|
"created_at": _iso(record.created_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +240,6 @@ async def list_admin_credit_records(
|
|||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
source_step_code: str | None = None,
|
source_step_code: str | None = None,
|
||||||
billing_scene: 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,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -323,12 +258,6 @@ async def list_admin_credit_records(
|
|||||||
source_module=source_module,
|
source_module=source_module,
|
||||||
source_step_code=source_step_code,
|
source_step_code=source_step_code,
|
||||||
billing_scene=billing_scene,
|
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,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
@@ -340,81 +269,52 @@ async def list_admin_credit_records(
|
|||||||
base_query = base_query.where(where_clause)
|
base_query = base_query.where(where_clause)
|
||||||
count_query = count_query.where(where_clause)
|
count_query = count_query.where(where_clause)
|
||||||
total = (await db.execute(count_query)).scalar() or 0
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
rows = (
|
|
||||||
await db.execute(
|
result = await db.execute(
|
||||||
base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
|
base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
).all()
|
rows = result.all()
|
||||||
records = [row[0] for row in rows]
|
records = [row[0] for row in rows]
|
||||||
deleted_map = await _load_deleted_map(db, records)
|
deleted_map = await _load_deleted_map(db, records)
|
||||||
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
|
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(
|
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 == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||||
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.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0).label("total_refund"),
|
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
|
||||||
func.count(CreditRecord.id).label("transaction_count"),
|
func.count(CreditRecord.id),
|
||||||
func.count(distinct(case((media_consume, func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("generation_count"),
|
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((media_consume, 1), else_=None)).label("generation_attempt_count"),
|
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), 1), else_=None)),
|
||||||
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_(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_(media_consume, CreditRecord.media_type == "video"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("video_generation_count"),
|
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_(media_consume, CreditRecord.media_type == "image"), func.abs(CreditRecord.amount)), else_=0)), 0).label("image_consume"),
|
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_(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.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).label("text_consume"),
|
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).label("analysis_consume"),
|
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(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.total_tokens), else_=0)), 0).label("total_tokens"),
|
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
|
||||||
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(CreditRecord.input_tokens), 0),
|
||||||
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(CreditRecord.output_tokens), 0),
|
||||||
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"),
|
|
||||||
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
|
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
|
||||||
if where_clause is not None:
|
if where_clause is not None:
|
||||||
summary_query = summary_query.where(where_clause)
|
summary_query = summary_query.where(where_clause)
|
||||||
s = (await db.execute(summary_query)).one()._mapping
|
s = (await db.execute(summary_query)).one()
|
||||||
summary = {
|
summary = {
|
||||||
"total_recharge": _round2(s["total_recharge"]),
|
"total_recharge": _round2(s[0]),
|
||||||
"total_consume": _round2(s["total_consume"]),
|
"total_consume": _round2(s[1]),
|
||||||
"total_refund": _round2(s["total_refund"]),
|
"total_refund": _round2(s[2]),
|
||||||
"transaction_count": int(s["transaction_count"] or 0),
|
"transaction_count": int(s[3] or 0),
|
||||||
"generation_count": int(s["generation_count"] or 0),
|
"generation_count": int(s[4] or 0),
|
||||||
"generation_attempt_count": int(s["generation_attempt_count"] or 0),
|
"generation_attempt_count": int(s[5] or 0),
|
||||||
"image_generation_count": int(s["image_generation_count"] or 0),
|
"image_generation_count": int(s[6] or 0),
|
||||||
"video_generation_count": int(s["video_generation_count"] or 0),
|
"video_generation_count": int(s[7] or 0),
|
||||||
"image_consume": _round2(s["image_consume"]),
|
"image_consume": _round2(s[8]),
|
||||||
"video_consume": _round2(s["video_consume"]),
|
"video_consume": _round2(s[9]),
|
||||||
"text_consume": _round2(s["text_consume"]),
|
"text_consume": _round2(s[10]),
|
||||||
"analysis_consume": _round2(s["analysis_consume"]),
|
"analysis_consume": _round2(s[11]),
|
||||||
"total_tokens": int(s["total_tokens"] or 0),
|
"total_tokens": int(s[12] or 0),
|
||||||
"input_tokens": int(s["input_tokens"] or 0),
|
"input_tokens": int(s[13] or 0),
|
||||||
"output_tokens": int(s["output_tokens"] or 0),
|
"output_tokens": int(s[14] 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),
|
|
||||||
}
|
}
|
||||||
return {"items": items, "total": int(total), "summary": summary}
|
return {"items": items, "total": total, "summary": summary}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from sqlalchemy import select
|
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.team import Team
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.video_engine import VideoEngine
|
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)
|
@dataclass(slots=True)
|
||||||
@@ -60,40 +56,6 @@ class CreditRecordMeta:
|
|||||||
engine_provider: str | None = None
|
engine_provider: str | None = None
|
||||||
engine_model_name: 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
|
user_type_snapshot: str | None = None
|
||||||
frontend_user_kind_snapshot: str | None = None
|
frontend_user_kind_snapshot: str | None = None
|
||||||
team_id_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
|
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:
|
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))
|
result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
||||||
user = result.scalar_one_or_none()
|
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]:
|
async def get_engine_snapshot(db: AsyncSession, *, gen_type: str, engine_id: str | None) -> dict[str, str | None]:
|
||||||
"""锁定本次媒体扣费实际使用的引擎快照。
|
if not engine_id:
|
||||||
|
return {"engine_type": (gen_type or None)}
|
||||||
GenerationRecord 旧链路没有持久化 engine_id;此时必须与图片/视频生成服务保持一致,
|
|
||||||
选择当前启用且 priority 最高的引擎。这样价格版本在请求扣费时就被锁定,不能等任务
|
|
||||||
完成后再按当时的活动引擎或最新价格回算。
|
|
||||||
"""
|
|
||||||
gen_type = (gen_type or "").lower().strip()
|
gen_type = (gen_type or "").lower().strip()
|
||||||
if gen_type == "image":
|
if gen_type == "image":
|
||||||
query = select(ImageEngine)
|
result = await db.execute(select(ImageEngine).where(ImageEngine.id == engine_id).limit(1))
|
||||||
if engine_id:
|
|
||||||
query = query.where(ImageEngine.id == engine_id)
|
|
||||||
else:
|
else:
|
||||||
query = query.where(ImageEngine.is_active == True).order_by(ImageEngine.priority.desc())
|
result = await db.execute(select(VideoEngine).where(VideoEngine.id == engine_id).limit(1))
|
||||||
elif gen_type == "video":
|
engine = result.scalar_one_or_none()
|
||||||
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())
|
|
||||||
else:
|
|
||||||
return {"engine_type": gen_type or None, "engine_id": engine_id}
|
|
||||||
|
|
||||||
engine = (await db.execute(query.limit(1))).scalar_one_or_none()
|
|
||||||
if not engine:
|
if not engine:
|
||||||
return {"engine_type": gen_type or None, "engine_id": engine_id}
|
return {"engine_type": gen_type or None, "engine_id": engine_id}
|
||||||
return {
|
return {
|
||||||
@@ -297,18 +222,6 @@ async def build_generation_media_meta(
|
|||||||
source_step_id: str | None = None,
|
source_step_id: str | None = None,
|
||||||
source_step_code: str | None = None,
|
source_step_code: str | None = None,
|
||||||
billing_scene: 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:
|
) -> CreditRecordMeta:
|
||||||
media_type = (gen_type or "").lower().strip() or None
|
media_type = (gen_type or "").lower().strip() or None
|
||||||
if source_module is 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():
|
for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items():
|
||||||
setattr(meta, key, value)
|
setattr(meta, key, value)
|
||||||
requested_count = max(1, _safe_int(requested_output_count, 1))
|
return meta
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def build_generation_record_prompt_meta(
|
async def build_generation_record_prompt_meta(
|
||||||
@@ -410,7 +254,6 @@ async def build_generation_record_prompt_meta(
|
|||||||
attempt_no: int,
|
attempt_no: int,
|
||||||
charge_kind: str,
|
charge_kind: str,
|
||||||
usage: Mapping[str, Any],
|
usage: Mapping[str, Any],
|
||||||
media_references: Any = None,
|
|
||||||
) -> CreditRecordMeta:
|
) -> CreditRecordMeta:
|
||||||
scene_map = {
|
scene_map = {
|
||||||
CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
|
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")),
|
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"))),
|
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 _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)
|
|
||||||
|
|
||||||
|
|
||||||
async def build_module_step_prompt_meta(
|
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")),
|
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"))),
|
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 _apply_model_snapshot(db, meta, usage)
|
||||||
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def build_shot_video_analysis_meta(
|
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")),
|
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"))),
|
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 _apply_model_snapshot(db, meta, usage)
|
||||||
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
|
|
||||||
|
|
||||||
|
|
||||||
def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None) -> CreditRecordMeta:
|
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(
|
return CreditRecordMeta(
|
||||||
owner_type=getattr(charge, "owner_type", None),
|
owner_type=getattr(charge, "owner_type", None),
|
||||||
owner_id=getattr(charge, "owner_id", 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_name=getattr(charge, "engine_name", None),
|
||||||
engine_provider=getattr(charge, "engine_provider", None),
|
engine_provider=getattr(charge, "engine_provider", None),
|
||||||
engine_model_name=getattr(charge, "engine_model_name", 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),
|
user_type_snapshot=getattr(charge, "user_type_snapshot", None),
|
||||||
frontend_user_kind_snapshot=getattr(charge, "frontend_user_kind_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,
|
record_id=task_id,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
image_size=size,
|
image_size=size,
|
||||||
image_px=px,
|
|
||||||
aspect_ratio=proportion,
|
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
project_name="AI生成任务",
|
project_name="AI生成任务",
|
||||||
description_prefix="AI创作-",
|
description_prefix="AI创作-",
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
attempt_no=1,
|
attempt_no=1,
|
||||||
media_references=refs,
|
|
||||||
)
|
)
|
||||||
snapshot = _build_image_snapshot(engine, size, proportion, px)
|
snapshot = _build_image_snapshot(engine, size, proportion, px)
|
||||||
task = ChatGenerationTask(
|
task = ChatGenerationTask(
|
||||||
@@ -319,7 +316,6 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
|||||||
pipeline_stage="queued",
|
pipeline_stage="queued",
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
engine_snapshot_json=_json(snapshot),
|
engine_snapshot_json=_json(snapshot),
|
||||||
current_billing_attempt_no=1,
|
|
||||||
media_references=_json(refs) if refs else None,
|
media_references=_json(refs) if refs else None,
|
||||||
credits_cost=round(media_billing.total_charged, 2),
|
credits_cost=round(media_billing.total_charged, 2),
|
||||||
idempotency_key=req.idempotency_key,
|
idempotency_key=req.idempotency_key,
|
||||||
@@ -396,15 +392,12 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
|||||||
gen_type="video",
|
gen_type="video",
|
||||||
duration=duration,
|
duration=duration,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
aspect_ratio=ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
input_video_duration=input_video_duration if input_video_duration > 0 else None,
|
input_video_duration=input_video_duration if input_video_duration > 0 else None,
|
||||||
project_name="AI生成任务",
|
project_name="AI生成任务",
|
||||||
description_prefix="AI创作-",
|
description_prefix="AI创作-",
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
attempt_no=1,
|
attempt_no=1,
|
||||||
media_references=refs,
|
|
||||||
)
|
)
|
||||||
snapshot = _build_video_snapshot(engine, ratio, resolution, duration)
|
snapshot = _build_video_snapshot(engine, ratio, resolution, duration)
|
||||||
task = ChatGenerationTask(
|
task = ChatGenerationTask(
|
||||||
@@ -423,7 +416,6 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
|||||||
pipeline_stage="queued",
|
pipeline_stage="queued",
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
engine_snapshot_json=_json(snapshot),
|
engine_snapshot_json=_json(snapshot),
|
||||||
current_billing_attempt_no=1,
|
|
||||||
media_references=_json(refs) if refs else None,
|
media_references=_json(refs) if refs else None,
|
||||||
credits_cost=round(media_billing.total_charged, 2),
|
credits_cost=round(media_billing.total_charged, 2),
|
||||||
idempotency_key=req.idempotency_key,
|
idempotency_key=req.idempotency_key,
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ async def charge_chatapi_prompt_usage(
|
|||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
charge_kind=CHARGE_TEXT_PROMPT,
|
charge_kind=CHARGE_TEXT_PROMPT,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
items.append(
|
items.append(
|
||||||
await deduct_credits_locked_once(
|
await deduct_credits_locked_once(
|
||||||
@@ -271,7 +270,6 @@ async def charge_chatapi_prompt_usage(
|
|||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
charge_kind=CHARGE_FILE_PARSE,
|
charge_kind=CHARGE_FILE_PARSE,
|
||||||
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
|
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(
|
items.append(
|
||||||
await deduct_credits_locked_once(
|
await deduct_credits_locked_once(
|
||||||
@@ -301,7 +299,6 @@ async def charge_chatapi_prompt_usage(
|
|||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
charge_kind=CHARGE_VISION_INPUT,
|
charge_kind=CHARGE_VISION_INPUT,
|
||||||
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
|
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(
|
items.append(
|
||||||
await deduct_credits_locked_once(
|
await deduct_credits_locked_once(
|
||||||
@@ -460,13 +457,8 @@ async def charge_generation_media_by_params(
|
|||||||
record_id: str,
|
record_id: str,
|
||||||
gen_type: str,
|
gen_type: str,
|
||||||
image_size: str | None = None,
|
image_size: str | None = None,
|
||||||
image_px: str | None = None,
|
|
||||||
aspect_ratio: str | None = None,
|
|
||||||
duration: int | None = None,
|
duration: int | None = None,
|
||||||
resolution: str | 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,
|
engine_id: str | None = None,
|
||||||
input_video_duration: float | None = None,
|
input_video_duration: float | None = None,
|
||||||
project_name: str | 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_id: str | None = None,
|
||||||
source_step_code: str | None = None,
|
source_step_code: str | None = None,
|
||||||
billing_scene: 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:
|
) -> BillingSummary:
|
||||||
"""图片/视频媒体生成扣费。
|
"""图片/视频媒体生成扣费。
|
||||||
|
|
||||||
@@ -515,18 +504,6 @@ async def charge_generation_media_by_params(
|
|||||||
source_step_id=source_step_id,
|
source_step_id=source_step_id,
|
||||||
source_step_code=source_step_code,
|
source_step_code=source_step_code,
|
||||||
billing_scene=billing_scene,
|
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":
|
if gen_type == "image":
|
||||||
@@ -584,8 +561,6 @@ async def charge_generation_media_for_record(
|
|||||||
record_id=record.id,
|
record_id=record.id,
|
||||||
gen_type=record.gen_type,
|
gen_type=record.gen_type,
|
||||||
image_size=record.image_size,
|
image_size=record.image_size,
|
||||||
image_px=record.image_px,
|
|
||||||
aspect_ratio=record.aspect_ratio or record.image_proportion,
|
|
||||||
duration=record.duration,
|
duration=record.duration,
|
||||||
resolution=record.resolution,
|
resolution=record.resolution,
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
@@ -593,6 +568,4 @@ async def charge_generation_media_for_record(
|
|||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
|
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.generation_log_service import log_provider_call
|
||||||
from app.services.provider_limit import provider_limit
|
from app.services.provider_limit import provider_limit
|
||||||
from app.utils.id_gen import generate_id
|
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:
|
def _absolute_url(url: str) -> str:
|
||||||
@@ -181,7 +180,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
completion_tokens=output_tokens,
|
completion_tokens=output_tokens,
|
||||||
total_tokens=total_tokens,
|
total_tokens=total_tokens,
|
||||||
)
|
)
|
||||||
return content, normalize_text_pricing_usage(usage, base={
|
return content, {
|
||||||
"token_usage_id": token_usage_id,
|
"token_usage_id": token_usage_id,
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
"model_config_name": config.name,
|
"model_config_name": config.name,
|
||||||
@@ -190,4 +189,4 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
"total_tokens": total_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.image_engine import ImageEngine
|
||||||
from app.models.video_engine import VideoEngine
|
from app.models.video_engine import VideoEngine
|
||||||
from app.services.generation_log_service import log_provider_call
|
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.provider_limit import provider_limit
|
||||||
from app.services.video_gen import poll_task_status, submit_video_task
|
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:
|
async def poll_provider_task(db: AsyncSession, task: ChatGenerationTask) -> dict:
|
||||||
engine = await get_runtime_engine(db, task)
|
engine = await get_runtime_engine(db, task)
|
||||||
task_id = task.seedance_task_id or task.provider_task_id
|
task_id = task.seedance_task_id or task.provider_task_id
|
||||||
if task.gen_type != "video":
|
if task.gen_type == "video":
|
||||||
raise ValueError("当前火山图片引擎为同步生成,不允许进入 Provider 轮询链路")
|
|
||||||
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
|
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
|
||||||
return await poll_task_status(engine, task_id)
|
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,
|
ChatGenerationTaskStatus,
|
||||||
GenerationType,
|
GenerationType,
|
||||||
)
|
)
|
||||||
from app.enums.model_pricing import ProviderCostStatus
|
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.services.celery_download_recovery_service import (
|
from app.services.celery_download_recovery_service import (
|
||||||
ensure_aware_utc,
|
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_log_service import log_task_event
|
||||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
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_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.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||||
from app.services.redis_registry_service import (
|
from app.services.redis_registry_service import (
|
||||||
@@ -376,46 +373,6 @@ async def _mark_failed(
|
|||||||
return "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(
|
async def recover_one_generation_task(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
task: ChatGenerationTask,
|
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())
|
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))
|
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 已经落库,说明生成侧已经成功。
|
# 最高优先级:只要远程结果 URL 已经落库,说明生成侧已经成功。
|
||||||
# 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。
|
# 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。
|
||||||
if has_remote_result:
|
if has_remote_result:
|
||||||
@@ -587,36 +524,21 @@ async def recover_one_generation_task(
|
|||||||
)
|
)
|
||||||
return "recover_poll_has_provider_id"
|
return "recover_poll_has_provider_id"
|
||||||
|
|
||||||
# 未过 deadline,且没有结果 URL / 供应商任务 ID。
|
# 未过 deadline,且没有结果 URL / 供应商任务 ID:
|
||||||
# queued/preparing 代表尚未开始 Provider 调用,可以安全重投;同步图片一旦进入
|
# 图片同步任务会重新进入 submit_image_task;视频/其它任务会重新创建供应商任务。
|
||||||
# creating_provider_task/waiting_remote/polling/result_ready,结果可能已在供应商侧产生,
|
# 这里不能投 poll,因为没有 provider_task_id/seedance_task_id 可查询。
|
||||||
# 不能自动重放,否则可能产生第二次供应商费用。
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
recoverable_create_stages = {
|
recoverable_create_stages = {
|
||||||
ChatGenerationPipelineStage.QUEUED.value,
|
ChatGenerationPipelineStage.QUEUED.value,
|
||||||
ChatGenerationPipelineStage.PREPARING.value,
|
ChatGenerationPipelineStage.PREPARING.value,
|
||||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||||
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
||||||
ChatGenerationPipelineStage.POLLING.value,
|
ChatGenerationPipelineStage.POLLING.value,
|
||||||
ChatGenerationPipelineStage.RESULT_READY.value,
|
|
||||||
}
|
}
|
||||||
if task.pipeline_stage in recoverable_create_stages:
|
if task.pipeline_stage in recoverable_create_stages:
|
||||||
if task.pipeline_stage not in (
|
if task.pipeline_stage not in (
|
||||||
ChatGenerationPipelineStage.QUEUED.value,
|
ChatGenerationPipelineStage.QUEUED.value,
|
||||||
ChatGenerationPipelineStage.PREPARING.value,
|
ChatGenerationPipelineStage.PREPARING.value,
|
||||||
|
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||||
):
|
):
|
||||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -625,7 +547,7 @@ async def recover_one_generation_task(
|
|||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
task,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
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},
|
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||||
)
|
)
|
||||||
chatapi_create_generation_task.apply_async(
|
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"
|
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}"
|
return f"skip_stage_{task.pipeline_stage}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ async def create_chat_generation_task_for_module(
|
|||||||
record_id=task_id,
|
record_id=task_id,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
image_size=size,
|
image_size=size,
|
||||||
image_px=px,
|
|
||||||
aspect_ratio=proportion,
|
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
project_name=billing_project_name,
|
project_name=billing_project_name,
|
||||||
description_prefix=billing_description_prefix,
|
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_id=billing_source_step_id,
|
||||||
source_step_code=billing_source_step_code,
|
source_step_code=billing_source_step_code,
|
||||||
billing_scene=billing_scene,
|
billing_scene=billing_scene,
|
||||||
media_references=refs,
|
|
||||||
)
|
)
|
||||||
snapshot = _build_image_snapshot(engine, size, proportion, px)
|
snapshot = _build_image_snapshot(engine, size, proportion, px)
|
||||||
task = ChatGenerationTask(
|
task = ChatGenerationTask(
|
||||||
@@ -145,7 +142,6 @@ async def create_chat_generation_task_for_module(
|
|||||||
pipeline_stage="queued",
|
pipeline_stage="queued",
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
engine_snapshot_json=_json(snapshot),
|
engine_snapshot_json=_json(snapshot),
|
||||||
current_billing_attempt_no=1,
|
|
||||||
media_references=_json(refs) if refs else None,
|
media_references=_json(refs) if refs else None,
|
||||||
credits_cost=round(media_billing.total_charged, 2),
|
credits_cost=round(media_billing.total_charged, 2),
|
||||||
idempotency_key=backend_idempotency_key,
|
idempotency_key=backend_idempotency_key,
|
||||||
@@ -174,8 +170,6 @@ async def create_chat_generation_task_for_module(
|
|||||||
gen_type="video",
|
gen_type="video",
|
||||||
duration=selected_duration,
|
duration=selected_duration,
|
||||||
resolution=selected_resolution,
|
resolution=selected_resolution,
|
||||||
aspect_ratio=ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
project_name=billing_project_name,
|
project_name=billing_project_name,
|
||||||
description_prefix=billing_description_prefix,
|
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_id=billing_source_step_id,
|
||||||
source_step_code=billing_source_step_code,
|
source_step_code=billing_source_step_code,
|
||||||
billing_scene=billing_scene,
|
billing_scene=billing_scene,
|
||||||
media_references=refs,
|
|
||||||
)
|
)
|
||||||
snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration)
|
snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration)
|
||||||
task = ChatGenerationTask(
|
task = ChatGenerationTask(
|
||||||
@@ -206,7 +199,6 @@ async def create_chat_generation_task_for_module(
|
|||||||
pipeline_stage="queued",
|
pipeline_stage="queued",
|
||||||
engine_id=engine.id,
|
engine_id=engine.id,
|
||||||
engine_snapshot_json=_json(snapshot),
|
engine_snapshot_json=_json(snapshot),
|
||||||
current_billing_attempt_no=1,
|
|
||||||
media_references=_json(refs) if refs else None,
|
media_references=_json(refs) if refs else None,
|
||||||
credits_cost=round(media_billing.total_charged, 2),
|
credits_cost=round(media_billing.total_charged, 2),
|
||||||
idempotency_key=backend_idempotency_key,
|
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.model_config import ModelConfig
|
||||||
from app.models.token_usage import TokenUsage
|
from app.models.token_usage import TokenUsage
|
||||||
from app.utils.id_gen import generate_id
|
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
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
|
|
||||||
DEFAULT_FRAME_RATE = "30fps"
|
DEFAULT_FRAME_RATE = "30fps"
|
||||||
@@ -1676,12 +1675,12 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
usage = data.get("usage", {}) or {}
|
usage = data.get("usage", {}) or {}
|
||||||
token_usage = normalize_text_pricing_usage(usage, base={
|
token_usage = {
|
||||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||||
"log_user_message": log_user_message,
|
"log_user_message": log_user_message,
|
||||||
})
|
}
|
||||||
token_usage_id = generate_id()
|
token_usage_id = generate_id()
|
||||||
db.add(
|
db.add(
|
||||||
TokenUsage(
|
TokenUsage(
|
||||||
|
|||||||
@@ -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:
|
async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
|
||||||
"""Get the active image engine with highest priority."""
|
"""Get the active image engine with highest priority."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -185,17 +167,10 @@ def submit_image_task(
|
|||||||
"created": result.created,
|
"created": result.created,
|
||||||
"data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [],
|
"data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [],
|
||||||
"usage": {
|
"usage": {
|
||||||
"generated_images": result.usage.generated_images if getattr(result, "usage", None) and hasattr(result.usage, "generated_images") else len(result.data or []),
|
"generated_images": result.usage.generated_images if hasattr(result.usage, 'generated_images') else 0,
|
||||||
"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 hasattr(result.usage, 'output_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 hasattr(result.usage, 'total_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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
error_msg = "图片生成超时,请稍后重试"
|
error_msg = "图片生成超时,请稍后重试"
|
||||||
@@ -212,9 +187,9 @@ def submit_image_task(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"image_url": image_url,
|
"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),
|
"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 "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from app.config import settings
|
|||||||
from app.models.model_config import ModelConfig
|
from app.models.model_config import ModelConfig
|
||||||
from app.models.token_usage import TokenUsage
|
from app.models.token_usage import TokenUsage
|
||||||
from app.utils.id_gen import generate_id
|
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
|
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()
|
await db.flush()
|
||||||
|
|
||||||
content = data["choices"][0]["message"]["content"].strip()
|
content = data["choices"][0]["message"]["content"].strip()
|
||||||
token_usage = normalize_text_pricing_usage(usage, base={
|
token_usage = {
|
||||||
"token_usage_id": token_usage_id,
|
"token_usage_id": token_usage_id,
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
"model_config_name": config.name,
|
"model_config_name": config.name,
|
||||||
@@ -395,5 +394,5 @@ async def _call_openai_compatible(
|
|||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
})
|
}
|
||||||
return content, token_usage
|
return content, token_usage
|
||||||
|
|||||||
@@ -1,68 +1,89 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
import json
|
||||||
from datetime import datetime, timezone
|
from typing import Any, Mapping
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.credit_record import CreditRecordAction, CreditRecordChargeKind, CreditRecordOwnerType
|
from app.enums.credit_record import (
|
||||||
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
|
CreditRecordAction,
|
||||||
|
CreditRecordChargeKind,
|
||||||
|
CreditRecordOwnerType,
|
||||||
|
CreditRecordSourceModule,
|
||||||
|
)
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.token_usage import TokenUsage
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
def _provider_model_from_response(provider_response: Any) -> str | None:
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
data = safe_json_dict(provider_response)
|
try:
|
||||||
for candidate in (
|
if value is None or value == "":
|
||||||
data.get("model"),
|
return default
|
||||||
(data.get("data") or {}).get("model") if isinstance(data.get("data"), dict) else None,
|
return int(value)
|
||||||
(data.get("result") or {}).get("model") if isinstance(data.get("result"), dict) else None,
|
except Exception:
|
||||||
):
|
return default
|
||||||
if candidate:
|
|
||||||
return str(candidate)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_task_id_from_response(provider_response: Any) -> str | None:
|
def _safe_json_dict(value: Any) -> dict[str, Any]:
|
||||||
data = safe_json_dict(provider_response)
|
if not value:
|
||||||
for candidate in (data.get("task_id"), data.get("id"), data.get("provider_task_id")):
|
return {}
|
||||||
if candidate:
|
if isinstance(value, dict):
|
||||||
return str(candidate)
|
return value
|
||||||
return None
|
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]:
|
def _extract_usage(provider_response: Any) -> dict[str, Any]:
|
||||||
snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None))
|
data = _safe_json_dict(provider_response)
|
||||||
return {
|
usage = data.get("usage")
|
||||||
"provider": snapshot.get("provider") or snapshot.get("engine_provider"),
|
return usage if isinstance(usage, dict) else {}
|
||||||
"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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
owner_type: str,
|
owner_type: str,
|
||||||
owner_id: str,
|
owner_id: str,
|
||||||
attempt_no: int | None,
|
|
||||||
media_type: str | None,
|
media_type: str | None,
|
||||||
) -> CreditRecord | None:
|
) -> CreditRecord | None:
|
||||||
query = (
|
query = (
|
||||||
@@ -76,17 +97,9 @@ async def _find_media_charge(
|
|||||||
)
|
)
|
||||||
if media_type:
|
if media_type:
|
||||||
query = query.where(CreditRecord.media_type == media_type)
|
query = query.where(CreditRecord.media_type == media_type)
|
||||||
if attempt_no is not None:
|
query = query.order_by(CreditRecord.attempt_no.desc().nullslast(), CreditRecord.created_at.desc()).limit(1)
|
||||||
query = query.where(CreditRecord.attempt_no == attempt_no)
|
result = await db.execute(query)
|
||||||
query = query.order_by(CreditRecord.created_at.desc()).limit(1).with_for_update()
|
return result.scalar_one_or_none()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_token_usage(
|
async def _get_or_create_token_usage(
|
||||||
@@ -96,21 +109,20 @@ async def _get_or_create_token_usage(
|
|||||||
input_tokens: int,
|
input_tokens: int,
|
||||||
output_tokens: int,
|
output_tokens: int,
|
||||||
total_tokens: int,
|
total_tokens: int,
|
||||||
|
model_config_id: str | None = None,
|
||||||
) -> TokenUsage:
|
) -> TokenUsage:
|
||||||
token_usage: TokenUsage | None = None
|
token_usage: TokenUsage | None = None
|
||||||
if charge.token_usage_id:
|
if charge.token_usage_id:
|
||||||
token_usage = (
|
result = await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1))
|
||||||
await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1))
|
token_usage = result.scalar_one_or_none()
|
||||||
).scalar_one_or_none()
|
|
||||||
if token_usage is None and charge.biz_key:
|
if token_usage is None and charge.biz_key:
|
||||||
token_usage = (
|
result = await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1))
|
||||||
await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1))
|
token_usage = result.scalar_one_or_none()
|
||||||
).scalar_one_or_none()
|
|
||||||
if token_usage is None:
|
if token_usage is None:
|
||||||
token_usage = TokenUsage(
|
token_usage = TokenUsage(
|
||||||
id=generate_id(),
|
id=generate_id(),
|
||||||
user_id=charge.user_id,
|
user_id=charge.user_id,
|
||||||
model_config_id=None,
|
model_config_id=model_config_id,
|
||||||
owner_type=charge.owner_type,
|
owner_type=charge.owner_type,
|
||||||
owner_id=charge.owner_id,
|
owner_id=charge.owner_id,
|
||||||
biz_key=charge.biz_key,
|
biz_key=charge.biz_key,
|
||||||
@@ -123,6 +135,13 @@ async def _get_or_create_token_usage(
|
|||||||
db.add(token_usage)
|
db.add(token_usage)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
else:
|
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.input_tokens = input_tokens
|
||||||
token_usage.output_tokens = output_tokens
|
token_usage.output_tokens = output_tokens
|
||||||
token_usage.total_tokens = total_tokens
|
token_usage.total_tokens = total_tokens
|
||||||
@@ -133,190 +152,39 @@ async def _sync_charge_snapshot(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
charge: CreditRecord | None,
|
charge: CreditRecord | None,
|
||||||
owner: Any,
|
gen_type: str | None,
|
||||||
gen_type: str,
|
|
||||||
stage: str,
|
|
||||||
provider_response: Any = None,
|
provider_response: Any = None,
|
||||||
fallback_total: int = 0,
|
fallback_total: int | None = None,
|
||||||
) -> CreditRecord | None:
|
) -> CreditRecord | None:
|
||||||
if not charge:
|
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
|
return None
|
||||||
|
|
||||||
response = provider_response if provider_response is not None else getattr(owner, "provider_response_json", None)
|
input_tokens, output_tokens, total_tokens = _normalize_media_tokens(
|
||||||
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,
|
|
||||||
gen_type=gen_type,
|
gen_type=gen_type,
|
||||||
fallback_total_tokens=fallback_total,
|
provider_response=provider_response,
|
||||||
request_image_px=getattr(owner, "image_px", None),
|
fallback_total=fallback_total,
|
||||||
requested_output_count=max(1, safe_int(generation_counts.get("requested_output_count"), 1)),
|
|
||||||
provider_input_image_count=locked_input_image_count,
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
if total_tokens <= 0:
|
||||||
|
return charge
|
||||||
|
|
||||||
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(
|
token_usage = await _get_or_create_token_usage(
|
||||||
db,
|
db,
|
||||||
charge=charge,
|
charge=charge,
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
output_tokens=output_tokens,
|
output_tokens=output_tokens,
|
||||||
total_tokens=total_tokens,
|
total_tokens=total_tokens,
|
||||||
|
model_config_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
charge.token_usage_id = token_usage.id
|
charge.token_usage_id = token_usage.id
|
||||||
charge.input_tokens = input_tokens
|
charge.input_tokens = input_tokens
|
||||||
charge.output_tokens = output_tokens
|
charge.output_tokens = output_tokens
|
||||||
charge.total_tokens = total_tokens
|
charge.total_tokens = total_tokens
|
||||||
|
|
||||||
provider_model = _provider_model_from_response(response)
|
# 兼容旧流水扣费时未冷备 engine_model_name 的场景,能从 provider response 推出来就补充。
|
||||||
engine_snapshot = _engine_snapshot_from_owner(owner)
|
provider_model = _engine_model_from_provider_response(provider_response)
|
||||||
charge.engine_provider = charge.engine_provider or engine_snapshot.get("provider")
|
if provider_model and not charge.engine_model_name:
|
||||||
charge.engine_model_name = charge.engine_model_name or provider_model or engine_snapshot.get("model_name")
|
charge.engine_model_name = provider_model
|
||||||
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}:
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
return charge
|
return charge
|
||||||
|
|
||||||
|
|
||||||
@@ -325,48 +193,33 @@ async def sync_chat_generation_task_media_token_snapshot(
|
|||||||
task: ChatGenerationTask,
|
task: ChatGenerationTask,
|
||||||
*,
|
*,
|
||||||
provider_response: Any = None,
|
provider_response: Any = None,
|
||||||
stage: str | None = None,
|
|
||||||
) -> CreditRecord | None:
|
) -> CreditRecord | None:
|
||||||
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)) or not task:
|
"""把 ChatGenerationTask 图片/视频媒体生成 token 后置快照回填到积分流水。
|
||||||
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
|
|
||||||
|
|
||||||
callback_task_id = _provider_task_id_from_response(response)
|
媒体扣费发生在创建任务前,供应商 usage 只能在创建/轮询成功后拿到,
|
||||||
current_task_id = task.seedance_task_id or task.provider_task_id
|
所以这里按 owner_type + owner_id + media_type 找到对应 media charge 流水并回填。
|
||||||
if gen_type == "video" and callback_task_id and current_task_id and callback_task_id != current_task_id:
|
"""
|
||||||
log_model_pricing_event(
|
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)):
|
||||||
event_type="pricing_stale_callback_skip",
|
return None
|
||||||
event_status="warning",
|
if not task:
|
||||||
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},
|
|
||||||
)
|
|
||||||
return None
|
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,
|
db,
|
||||||
user_id=task.user_id,
|
user_id=task.user_id,
|
||||||
owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
|
owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
owner_id=task.id,
|
owner_id=task.id,
|
||||||
attempt_no=task.current_billing_attempt_no,
|
|
||||||
media_type=gen_type or None,
|
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(
|
return await _sync_charge_snapshot(
|
||||||
db,
|
db,
|
||||||
charge=charge,
|
charge=charge,
|
||||||
owner=task,
|
|
||||||
gen_type=gen_type,
|
gen_type=gen_type,
|
||||||
stage=stage,
|
|
||||||
provider_response=response,
|
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,
|
record: GenerationRecord,
|
||||||
*,
|
*,
|
||||||
provider_response: Any = None,
|
provider_response: Any = None,
|
||||||
stage: str | None = None,
|
|
||||||
) -> CreditRecord | 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
|
return None
|
||||||
gen_type = (record.gen_type or "").lower().strip()
|
if not record:
|
||||||
stage = stage or (
|
return None
|
||||||
PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value
|
|
||||||
if gen_type == "image"
|
gen_type = (getattr(record, "gen_type", None) or "").lower().strip()
|
||||||
else PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value
|
fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used
|
||||||
)
|
charge = await _find_latest_media_charge(
|
||||||
response = provider_response if provider_response is not None else record.provider_response_json
|
|
||||||
charge = await _find_media_charge(
|
|
||||||
db,
|
db,
|
||||||
user_id=record.user_id,
|
user_id=record.user_id,
|
||||||
owner_type=CreditRecordOwnerType.GENERATION_RECORD.value,
|
owner_type=CreditRecordOwnerType.GENERATION_RECORD.value,
|
||||||
owner_id=record.id,
|
owner_id=record.id,
|
||||||
attempt_no=record.current_billing_attempt_no,
|
|
||||||
media_type=gen_type or None,
|
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(
|
return await _sync_charge_snapshot(
|
||||||
db,
|
db,
|
||||||
charge=charge,
|
charge=charge,
|
||||||
owner=record,
|
|
||||||
gen_type=gen_type,
|
gen_type=gen_type,
|
||||||
stage=stage,
|
provider_response=provider_response,
|
||||||
provider_response=response,
|
fallback_total=fallback_total,
|
||||||
fallback_total=fallback_total or 0,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
error=remote_message if event_status == "failed" else None,
|
||||||
**kwargs,
|
**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.upload_video_asset_service import resolve_upload_video_path
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
from app.utils.id_gen import generate_id
|
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.common import LogEventStatusEnum, LogSourceEnum
|
||||||
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
||||||
from app.services.operation_log_service import log_ai_model_event
|
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)
|
result = filter_and_normalize_breakdown(result, mode=mode)
|
||||||
|
|
||||||
usage = raw.get("usage") or {}
|
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")),
|
"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")),
|
"output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")),
|
||||||
"total_tokens": _int_usage(usage.get("total_tokens")),
|
"total_tokens": _int_usage(usage.get("total_tokens")),
|
||||||
@@ -708,7 +707,7 @@ async def analyze_video_for_shot_split(
|
|||||||
"analysis_mode": mode,
|
"analysis_mode": mode,
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"log_request": log_request_data,
|
"log_request": log_request_data,
|
||||||
})
|
}
|
||||||
if not token_usage["total_tokens"]:
|
if not token_usage["total_tokens"]:
|
||||||
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,14 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.image_engine import ImageEngine
|
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response
|
||||||
from app.models.video_engine import VideoEngine
|
from app.services.image_gen import get_active_image_engine, download_image
|
||||||
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
|
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||||
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.resource_accounting_service import (
|
from app.services.resource_accounting_service import (
|
||||||
record_generation_record_generated_resource,
|
record_generation_record_generated_resource,
|
||||||
safe_file_size,
|
safe_file_size,
|
||||||
@@ -32,38 +25,6 @@ POLL_INTERVAL = 30 # seconds between polls
|
|||||||
MAX_POLLS = 60 # max 30 minutes total
|
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:
|
class TaskQueue:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
||||||
@@ -143,7 +104,7 @@ class TaskQueue:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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)
|
poll_result = await poll_task_status(engine, record.seedance_task_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Poll error for {record_id}: {e}")
|
logger.error(f"Poll error for {record_id}: {e}")
|
||||||
@@ -172,17 +133,6 @@ class TaskQueue:
|
|||||||
|
|
||||||
if status == "succeeded":
|
if status == "succeeded":
|
||||||
file_url = poll_result.get("video_url", "")
|
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
|
storage_path = None
|
||||||
file_size_bytes = 0
|
file_size_bytes = 0
|
||||||
if settings.STORAGE_TYPE == "local" and file_url:
|
if settings.STORAGE_TYPE == "local" and file_url:
|
||||||
@@ -207,6 +157,8 @@ class TaskQueue:
|
|||||||
record.video_url = file_url
|
record.video_url = file_url
|
||||||
else:
|
else:
|
||||||
record.video_url = file_url
|
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.status = "completed"
|
||||||
record.generated_at = datetime.now()
|
record.generated_at = datetime.now()
|
||||||
if record.video_url:
|
if record.video_url:
|
||||||
@@ -219,12 +171,6 @@ class TaskQueue:
|
|||||||
remote_url=file_url,
|
remote_url=file_url,
|
||||||
generated_at=record.generated_at,
|
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)
|
self._active.pop(record_id, None)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(f"Video task completed: {record_id}")
|
logger.info(f"Video task completed: {record_id}")
|
||||||
@@ -261,11 +207,8 @@ class TaskQueue:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
from app.services.image_gen import submit_image_task, _log_image_response
|
from app.services.image_gen import submit_image_task, _log_image_response
|
||||||
|
|
||||||
provider_call_started = False
|
|
||||||
provider_call_completed = False
|
|
||||||
try:
|
try:
|
||||||
engine = await _get_runtime_engine(db, record)
|
engine = await get_active_image_engine(db)
|
||||||
provider_call_started = True
|
|
||||||
poll_result = await asyncio.to_thread(
|
poll_result = await asyncio.to_thread(
|
||||||
submit_image_task,
|
submit_image_task,
|
||||||
db,
|
db,
|
||||||
@@ -273,23 +216,9 @@ class TaskQueue:
|
|||||||
record,
|
record,
|
||||||
include_media_references=False,
|
include_media_references=False,
|
||||||
)
|
)
|
||||||
provider_call_completed = True
|
|
||||||
|
|
||||||
if poll_result["error"] == "":
|
if poll_result["error"] == "":
|
||||||
remote_url = poll_result.get("image_url")
|
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
|
storage_path = None
|
||||||
file_size_bytes = 0
|
file_size_bytes = 0
|
||||||
if settings.STORAGE_TYPE == "local" and remote_url:
|
if settings.STORAGE_TYPE == "local" and remote_url:
|
||||||
@@ -307,6 +236,8 @@ class TaskQueue:
|
|||||||
record.image_url = remote_url
|
record.image_url = remote_url
|
||||||
else:
|
else:
|
||||||
record.image_url = remote_url
|
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.status = "completed"
|
||||||
record.generated_at = datetime.now()
|
record.generated_at = datetime.now()
|
||||||
if record.image_url:
|
if record.image_url:
|
||||||
@@ -319,46 +250,19 @@ class TaskQueue:
|
|||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
generated_at=record.generated_at,
|
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()
|
await db.commit()
|
||||||
logger.info(f"Image task completed: {record_id}")
|
logger.info(f"Image task completed: {record_id}")
|
||||||
else:
|
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(
|
await mark_generation_record_failed_and_refund_once(
|
||||||
db,
|
db,
|
||||||
record=record,
|
record=record,
|
||||||
error_message=error_message,
|
error_message=poll_result.get("error", "图片生成失败"),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(f"Image task failed: {record_id}")
|
logger.info(f"Image task failed: {record_id}")
|
||||||
_log_image_response(record_id, poll_result)
|
_log_image_response(record_id, poll_result)
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
await mark_generation_record_failed_and_refund_once(
|
||||||
db,
|
db,
|
||||||
record=record,
|
record=record,
|
||||||
|
|||||||
@@ -77,8 +77,6 @@ if broker_url:
|
|||||||
task_serializer="json",
|
task_serializer="json",
|
||||||
accept_content=["json"],
|
accept_content=["json"],
|
||||||
result_serializer="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",
|
timezone="Asia/Shanghai",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
task_soft_time_limit=600,
|
task_soft_time_limit=600,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from app.enums.generation_task import (
|
|||||||
GenerationMode,
|
GenerationMode,
|
||||||
GenerationType,
|
GenerationType,
|
||||||
)
|
)
|
||||||
from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus
|
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.services.error_codes import extract_error_message
|
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_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_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||||
from app.services.generation_provider_service import create_provider_task
|
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 sync_chat_generation_task_media_token_snapshot
|
||||||
from app.services.media_token_usage_snapshot_service import (
|
|
||||||
mark_media_provider_cost_status,
|
|
||||||
sync_chat_generation_task_media_token_snapshot,
|
|
||||||
)
|
|
||||||
from app.services.redis_registry_service import ensure_aware_utc
|
from app.services.redis_registry_service import ensure_aware_utc
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
@@ -176,8 +171,6 @@ async def _run(task_id: str):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
provider_call_started = False
|
|
||||||
provider_call_completed = False
|
|
||||||
try:
|
try:
|
||||||
if not task.optimized_prompt:
|
if not task.optimized_prompt:
|
||||||
old_stage = task.pipeline_stage
|
old_stage = task.pipeline_stage
|
||||||
@@ -236,9 +229,7 @@ async def _run(task_id: str):
|
|||||||
to_stage=ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
to_stage=ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_call_started = True
|
|
||||||
created = await create_provider_task(db, task)
|
created = await create_provider_task(db, task)
|
||||||
provider_call_completed = True
|
|
||||||
|
|
||||||
provider_task_id = created.get("task_id")
|
provider_task_id = created.get("task_id")
|
||||||
if provider_task_id:
|
if provider_task_id:
|
||||||
@@ -255,13 +246,7 @@ async def _run(task_id: str):
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
default=str,
|
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)
|
||||||
await sync_chat_generation_task_media_token_snapshot(
|
|
||||||
db,
|
|
||||||
task,
|
|
||||||
provider_response=task.provider_response_json,
|
|
||||||
stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
if task.remote_result_url and not task.seedance_task_id:
|
if task.remote_result_url and not task.seedance_task_id:
|
||||||
# 同步图片路径:原 SDK 已经返回最终 URL。
|
# 同步图片路径:原 SDK 已经返回最终 URL。
|
||||||
@@ -317,19 +302,6 @@ async def _run(task_id: str):
|
|||||||
|
|
||||||
if task:
|
if task:
|
||||||
error_message = extract_error_message(exc, "生成任务") if callable(extract_error_message) else str(exc)
|
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(
|
await mark_chat_generation_task_failed_and_refund_once(
|
||||||
db,
|
db,
|
||||||
task=task,
|
task=task,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from app.enums.generation_task import (
|
|||||||
ChatGenerationTaskStatus,
|
ChatGenerationTaskStatus,
|
||||||
GenerationType,
|
GenerationType,
|
||||||
)
|
)
|
||||||
from app.enums.model_pricing import PricingSnapshotStage
|
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.services.celery_download_recovery_service import (
|
from app.services.celery_download_recovery_service import (
|
||||||
@@ -548,11 +547,7 @@ async def _run(task_id: str):
|
|||||||
remote_url=task.remote_result_url,
|
remote_url=task.remote_result_url,
|
||||||
generated_at=task.generated_at,
|
generated_at=task.generated_at,
|
||||||
)
|
)
|
||||||
await sync_chat_generation_task_media_token_snapshot(
|
await sync_chat_generation_task_media_token_snapshot(db, task)
|
||||||
db,
|
|
||||||
task,
|
|
||||||
stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
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_FAILED_STATUSES,
|
||||||
PROVIDER_SUCCESS_STATUSES,
|
PROVIDER_SUCCESS_STATUSES,
|
||||||
)
|
)
|
||||||
from app.enums.model_pricing import PricingSnapshotStage
|
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.services.error_codes import extract_error_message
|
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.video_tokens_used = poll_result.get("video_tokens", 0) or 0
|
||||||
|
|
||||||
task.provider_response_json = response_data
|
task.provider_response_json = response_data
|
||||||
await sync_chat_generation_task_media_token_snapshot(
|
await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=response_data)
|
||||||
db,
|
|
||||||
task,
|
|
||||||
provider_response=response_data,
|
|
||||||
stage=PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not task.remote_result_url:
|
if not task.remote_result_url:
|
||||||
await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)
|
await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)
|
||||||
|
|||||||
+104
-104
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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;
|
usedAudioCount?: number;
|
||||||
maxAudioDuration?: number;
|
maxAudioDuration?: number;
|
||||||
usedAudioDuration?: number;
|
usedAudioDuration?: number;
|
||||||
|
hideLimitHint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||||
@@ -52,6 +53,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
usedAudioCount,
|
usedAudioCount,
|
||||||
maxAudioDuration,
|
maxAudioDuration,
|
||||||
usedAudioDuration,
|
usedAudioDuration,
|
||||||
|
hideLimitHint,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
||||||
@@ -243,6 +245,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
usedAudioCount={usedAudioCount}
|
usedAudioCount={usedAudioCount}
|
||||||
maxAudioDuration={maxAudioDuration}
|
maxAudioDuration={maxAudioDuration}
|
||||||
usedAudioDuration={usedAudioDuration}
|
usedAudioDuration={usedAudioDuration}
|
||||||
|
hideLimitHint={hideLimitHint}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PrivatePortraitAssetPicker
|
<PrivatePortraitAssetPicker
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
|||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
items={items}
|
items={items}
|
||||||
destroyInactiveTabPane={false}
|
destroyOnHidden={false}
|
||||||
tabBarExtraContent={
|
tabBarExtraContent={
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}。真人/虚拟共用,图片/视频共用;音频暂不开放。</div>
|
<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 }) => {
|
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
||||||
if (!items.length) return <Empty description="暂无项目组" />;
|
if (!items.length) return <Empty description="暂无项目组" />;
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
<Space orientation="vertical" style={{ width: '100%' }} size={10}>
|
||||||
{items.map((project) => {
|
{items.map((project) => {
|
||||||
const active = selectedId === project.id;
|
const active = selectedId === project.id;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -122,7 +122,14 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} lg={5}>
|
<Col xs={24} lg={5}>
|
||||||
@@ -155,7 +162,7 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
onOk={validateSession ? undefined : handleCreate}
|
onOk={validateSession ? undefined : handleCreate}
|
||||||
okText="开始认证并创建"
|
okText="开始认证并创建"
|
||||||
confirmLoading={creating}
|
confirmLoading={creating}
|
||||||
maskClosable={!polling}
|
mask={{ closable: !polling }}
|
||||||
>
|
>
|
||||||
{!validateSession ? (
|
{!validateSession ? (
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
<Card
|
<Card
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
hoverable
|
hoverable
|
||||||
bodyStyle={{ padding: 12 }}
|
styles={{ body: { padding: 12 } }}
|
||||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||||
cover={(
|
cover={(
|
||||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
<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.Title level={4} style={{ margin: 0 }}>虚拟素材库</Typography.Title>
|
||||||
<Typography.Text type="secondary">可提前上传虚拟人像素材,后续生成视频时可直接使用。</Typography.Text>
|
<Typography.Text type="secondary">可提前上传虚拟人像素材,后续生成视频时可直接使用。</Typography.Text>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Row gutter={[10, 10]}>
|
<Row gutter={[10, 10]}>
|
||||||
<Col xs={24} lg={5} >
|
<Col xs={24} lg={5} >
|
||||||
@@ -446,11 +453,24 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||||
extra={(
|
extra={(
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
|
<Button
|
||||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
disabled={!selectedProjectId}
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
上传图片/视频
|
||||||
|
</Button>
|
||||||
{selectedProjectId && (
|
{selectedProjectId && (
|
||||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
删除项目组
|
||||||
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -464,14 +484,15 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
onSearch={() => loadAssets(1, assetPageSize)}
|
onSearch={() => loadAssets(1, assetPageSize)}
|
||||||
style={{ width: 240 }}
|
style={{ width: 240, borderRadius: 8 }}
|
||||||
|
enterButton
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="素材状态"
|
placeholder="素材状态"
|
||||||
value={assetStatus}
|
value={assetStatus}
|
||||||
onChange={(value) => setAssetStatus(value)}
|
onChange={(value) => setAssetStatus(value)}
|
||||||
style={{ width: 150 }}
|
style={{ width: 140, borderRadius: 8 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'Processing', label: '处理中' },
|
{ value: 'Processing', label: '处理中' },
|
||||||
{ value: 'Active', label: '可用' },
|
{ value: 'Active', label: '可用' },
|
||||||
@@ -483,13 +504,19 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
placeholder="素材类型"
|
placeholder="素材类型"
|
||||||
value={assetType}
|
value={assetType}
|
||||||
onChange={(value) => setAssetType(value)}
|
onChange={(value) => setAssetType(value)}
|
||||||
style={{ width: 130 }}
|
style={{ width: 120, borderRadius: 8 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'Image', label: '图片' },
|
{ value: 'Image', label: '图片' },
|
||||||
{ value: 'Video', 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>
|
</Space>
|
||||||
|
|
||||||
<Spin spinning={assetLoading}>
|
<Spin spinning={assetLoading}>
|
||||||
@@ -563,7 +590,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</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' }}>
|
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
{previewType === 'Video' ? (
|
{previewType === 'Video' ? (
|
||||||
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
getPrivatePortraitProjects,
|
getPrivatePortraitProjects,
|
||||||
@@ -251,11 +251,13 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
||||||
</Space>
|
</Space>
|
||||||
<Spin spinning={loadingProjects}>
|
<Spin spinning={loadingProjects}>
|
||||||
<List
|
{projects.length === 0 ? (
|
||||||
dataSource={projects}
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" />
|
||||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
|
) : (
|
||||||
renderItem={(item) => (
|
<div>
|
||||||
<List.Item
|
{projects.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
onClick={() => setProjectId(item.id)}
|
onClick={() => setProjectId(item.id)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -275,9 +277,10 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -173,45 +173,93 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
|
{/* 统一筛选栏:媒体类型 + 搜索 + 批量操作 */}
|
||||||
<Space wrap>
|
<div style={{
|
||||||
<Select
|
display: 'flex',
|
||||||
value={resourceType}
|
flexWrap: 'wrap',
|
||||||
style={{ width: 120 }}
|
justifyContent: 'space-between',
|
||||||
options={[
|
alignItems: 'center',
|
||||||
{ label: '全部', value: '' },
|
gap: 12,
|
||||||
{ label: '图片', value: 'image' },
|
marginBottom: 16,
|
||||||
{ label: '视频', value: 'video' },
|
padding: '12px 16px',
|
||||||
{ label: '音频', value: 'audio' },
|
borderRadius: 12,
|
||||||
]}
|
background: '#fff',
|
||||||
onChange={(value) => {
|
border: '1px solid #f0f0f5',
|
||||||
setResourceType(value);
|
}}>
|
||||||
setPage(1);
|
{/* 左侧:媒体类型按钮 */}
|
||||||
}}
|
<Space size={8} wrap>
|
||||||
/>
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}>媒体类型:</Typography.Text>
|
||||||
<Input
|
<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
|
allowClear
|
||||||
value={keyword}
|
value={keyword}
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
placeholder="搜索文件名或URL"
|
placeholder="搜索文件名或URL"
|
||||||
style={{ width: 240 }}
|
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
onPressEnter={() => {
|
onSearch={() => { setPage(1); load(); }}
|
||||||
setPage(1);
|
style={{ width: 240, borderRadius: 8 }}
|
||||||
load();
|
enterButton
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/* <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> */}
|
|
||||||
</Space>
|
|
||||||
<Space>
|
|
||||||
{batchMode ? (
|
{batchMode ? (
|
||||||
<>
|
<>
|
||||||
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
|
<Button
|
||||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}>批量删除 ({selectedIds.size})</Button>
|
onClick={selectAll}
|
||||||
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}>取消批量操作</Button>
|
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>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +280,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
const checked = selectedIds.has(item.id);
|
const checked = selectedIds.has(item.id);
|
||||||
return (
|
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 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)}
|
{renderMedia(item)}
|
||||||
{batchMode && <Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />}
|
{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>
|
<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;
|
usedAudioCount?: number;
|
||||||
maxAudioDuration?: number;
|
maxAudioDuration?: number;
|
||||||
usedAudioDuration?: number;
|
usedAudioDuration?: number;
|
||||||
|
hideLimitHint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPreviewUrl = (url: string) => {
|
const buildPreviewUrl = (url: string) => {
|
||||||
if (!url) return '';
|
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(/\/$/, '');
|
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
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) => {
|
const typeIcon = (type: string) => {
|
||||||
if (type === 'video') return <VideoCameraOutlined />;
|
if (type === 'video') return <VideoCameraOutlined />;
|
||||||
if (type === 'audio') return <AudioOutlined />;
|
if (type === 'audio') return <AudioOutlined />;
|
||||||
@@ -64,6 +121,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
usedAudioCount,
|
usedAudioCount,
|
||||||
maxAudioDuration,
|
maxAudioDuration,
|
||||||
usedAudioDuration,
|
usedAudioDuration,
|
||||||
|
hideLimitHint,
|
||||||
}) => {
|
}) => {
|
||||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
@@ -175,11 +233,42 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
if (open) setCheckedMap(new Map());
|
if (open) setCheckedMap(new Map());
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const toggle = (item: UploadResourceHistoryItem) => {
|
const toggle = async (item: UploadResourceHistoryItem) => {
|
||||||
if (selectedIdSet.has(item.id)) {
|
if (selectedIdSet.has(item.id)) {
|
||||||
message.warning('该素材已经在参考内容中');
|
message.warning('该素材已经在参考内容中');
|
||||||
return;
|
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) => {
|
setCheckedMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
if (next.has(item.id)) next.delete(item.id);
|
if (next.has(item.id)) next.delete(item.id);
|
||||||
@@ -249,6 +338,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
/>
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadGroups}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={loadGroups}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
{!hideLimitHint && (
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
||||||
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
||||||
@@ -262,6 +352,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
|
<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 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const checked = checkedMap.has(item.id);
|
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);
|
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -326,6 +419,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
|
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' }}>
|
<div style={{ background: '#f1f5f9' }}>
|
||||||
{item.resourceType === 'image' ? (
|
{item.resourceType === 'image' ? (
|
||||||
@@ -340,6 +434,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
<Space size={6} style={{ marginBottom: 6 }}>
|
<Space size={6} style={{ marginBottom: 6 }}>
|
||||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
<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}
|
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||||
|
{validationError && <Tag color="red" style={{ margin: 0 }}>尺寸不符</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
||||||
{item.fileName || item.id}
|
{item.fileName || item.id}
|
||||||
@@ -352,7 +447,12 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
<CheckOutlined />
|
<CheckOutlined />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import dayjs from 'dayjs';
|
|||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// 统一按钮样式
|
||||||
|
const btnBase: React.CSSProperties = { borderRadius: 8, fontWeight: 600 };
|
||||||
|
|
||||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
|
||||||
interface ConsumptionRecord {
|
interface ConsumptionRecord {
|
||||||
@@ -164,13 +167,24 @@ const ConsumePage: React.FC = () => {
|
|||||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
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
|
<Input
|
||||||
placeholder="广告主ID"
|
placeholder="广告主ID"
|
||||||
value={advertiserId}
|
value={advertiserId}
|
||||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||||
style={{ width: 180 }}
|
style={{ width: 180, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
@@ -183,25 +197,32 @@ const ConsumePage: React.FC = () => {
|
|||||||
setConsumeDateRange(undefined);
|
setConsumeDateRange(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="medium"
|
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
|
style={{ ...btnBase, borderColor: 'transparent' }}
|
||||||
>
|
>
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<SyncOutlined />}
|
icon={<SyncOutlined />}
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
loading={syncLoading}
|
loading={syncLoading}
|
||||||
style={{ borderRadius: 8 }}
|
style={btnBase}
|
||||||
>
|
>
|
||||||
拉取消耗
|
拉取消耗
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const CreditRecordsPage: React.FC = () => {
|
|||||||
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
|
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
|
||||||
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
|
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
|
||||||
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,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)' };
|
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -978,7 +978,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
console.log(mediaReferences);
|
// console.log(mediaReferences);
|
||||||
|
|
||||||
// 创建用户消息对象
|
// 创建用户消息对象
|
||||||
const newMessage: Message = {
|
const newMessage: Message = {
|
||||||
@@ -1240,7 +1240,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { width, height } = await getImageDimensions(file);
|
const { width, height } = await getImageDimensions(file);
|
||||||
const error = validateVideoDimensions(width, height);
|
const error = validateImageDimensions(width, height);
|
||||||
if (error) {
|
if (error) {
|
||||||
antdMessage.error(error);
|
antdMessage.error(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -1316,7 +1316,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (isImage) {
|
if (isImage) {
|
||||||
try {
|
try {
|
||||||
const { width, height } = await getImageDimensions(file);
|
const { width, height } = await getImageDimensions(file);
|
||||||
const error = validateVideoDimensions(width, height);
|
const error = validateImageDimensions(width, height);
|
||||||
if (error) {
|
if (error) {
|
||||||
antdMessage.error(error);
|
antdMessage.error(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -1456,7 +1456,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (isImage) {
|
if (isImage) {
|
||||||
try {
|
try {
|
||||||
const { width, height } = await getImageDimensions(file);
|
const { width, height } = await getImageDimensions(file);
|
||||||
const error = validateVideoDimensions(width, height);
|
const error = validateImageDimensions(width, height);
|
||||||
if (error) {
|
if (error) {
|
||||||
antdMessage.error(error);
|
antdMessage.error(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -2183,13 +2183,16 @@ const AIChatPage: React.FC = () => {
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setInputValue(msg.originalPrompt || '');
|
setInputValue(msg.originalPrompt || '');
|
||||||
|
if (msg.genType) {
|
||||||
|
setMediaType(msg.genType);
|
||||||
|
}
|
||||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||||
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
|
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
|
||||||
if (hasFirstLastFrame && msg.genType === 'video') {
|
if (hasFirstLastFrame && msg.genType === 'video') {
|
||||||
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
|
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
|
||||||
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
|
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
|
||||||
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
|
setFirstFrame(first ? { ...first, label: first.label || '', duration: first.duration } : null);
|
||||||
setLastFrame(last ? { ...last, label: last.label || '' } : null);
|
setLastFrame(last ? { ...last, label: last.label || '', duration: last.duration } : null);
|
||||||
setCurrentMedia([]);
|
setCurrentMedia([]);
|
||||||
setReferenceMode('first_last_frame');
|
setReferenceMode('first_last_frame');
|
||||||
} else {
|
} else {
|
||||||
@@ -2199,6 +2202,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
url: ref.url,
|
url: ref.url,
|
||||||
label: ref.label || '',
|
label: ref.label || '',
|
||||||
role: ref.role,
|
role: ref.role,
|
||||||
|
duration: ref.duration,
|
||||||
})));
|
})));
|
||||||
setFirstFrame(null);
|
setFirstFrame(null);
|
||||||
setLastFrame(null);
|
setLastFrame(null);
|
||||||
@@ -2887,6 +2891,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
||||||
maxAudioDuration={15}
|
maxAudioDuration={15}
|
||||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||||
|
hideLimitHint={mediaType === 'image'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -3068,6 +3073,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
||||||
maxAudioDuration={15}
|
maxAudioDuration={15}
|
||||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||||
|
hideLimitHint={mediaType === 'image'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -261,6 +261,25 @@ const GeneratePage: React.FC = () => {
|
|||||||
const [creditRatios, setCreditRatios] = useState<any>([]);
|
const [creditRatios, setCreditRatios] = useState<any>([]);
|
||||||
const [cimage, setCimage] = 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 handlePasteUpload = async (file: File) => {
|
||||||
const isImage = file.type.startsWith("image/");
|
const isImage = file.type.startsWith("image/");
|
||||||
const isVideo = file.type.startsWith("video/");
|
const isVideo = file.type.startsWith("video/");
|
||||||
@@ -299,23 +318,51 @@ const GeneratePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
let fileDuration = 0;
|
let fileDuration = 0;
|
||||||
if (isVideo) {
|
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");
|
const video = document.createElement("video");
|
||||||
video.preload = "metadata";
|
video.preload = "metadata";
|
||||||
video.onloadedmetadata = () => {
|
video.onloadedmetadata = () => {
|
||||||
resolve(video.duration || 0);
|
resolve({ duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 });
|
||||||
video.remove();
|
video.remove();
|
||||||
};
|
};
|
||||||
video.onerror = () => {
|
video.onerror = () => {
|
||||||
resolve(0);
|
resolve({ duration: 0, width: 0, height: 0 });
|
||||||
video.remove();
|
video.remove();
|
||||||
};
|
};
|
||||||
video.src = URL.createObjectURL(file);
|
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) {
|
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
|
||||||
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
|
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
|
||||||
return false;
|
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);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
@@ -1968,7 +2015,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
placeholder="上传参考素材(只用做模型理解,不参与生成)、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
ClearOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||||
@@ -299,7 +301,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
folder?.file(filename, blob);
|
folder?.file(filename, blob);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
// console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
||||||
hasError = true;
|
hasError = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -448,7 +450,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
||||||
setPreTestTemplates(res.data?.data || res.data || []);
|
setPreTestTemplates(res.data?.data || res.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载前测模板失败:', error);
|
// console.error('加载前测模板失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setPreTestTemplatesLoading(false);
|
setPreTestTemplatesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -489,7 +491,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setOauthList(data || []);
|
setOauthList(data || []);
|
||||||
setOauthTotal(res.pagination.total || 0);
|
setOauthTotal(res.pagination.total || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载授权列表失败:', error);
|
// console.error('加载授权列表失败:', error);
|
||||||
setOauthList([]);
|
setOauthList([]);
|
||||||
setOauthTotal(0);
|
setOauthTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -513,7 +515,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setOpenTypeMap(map);
|
setOpenTypeMap(map);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史授权账户列表失败:', error);
|
// console.error('加载历史授权账户列表失败:', error);
|
||||||
setHistoryOAuthList([]);
|
setHistoryOAuthList([]);
|
||||||
setOpenTypeMap({});
|
setOpenTypeMap({});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -532,7 +534,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setUploadHistoryList(data || []);
|
setUploadHistoryList(data || []);
|
||||||
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
|
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载推送历史失败:', error);
|
// console.error('加载推送历史失败:', error);
|
||||||
setUploadHistoryList([]);
|
setUploadHistoryList([]);
|
||||||
setUploadHistoryTotal(0);
|
setUploadHistoryTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -669,7 +671,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setIsPreTest('2');
|
setIsPreTest('2');
|
||||||
setPreTestTemplate('');
|
setPreTestTemplate('');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('批量推送失败:', error);
|
// console.error('批量推送失败:', error);
|
||||||
message.error(error.message || '批量推送失败');
|
message.error(error.message || '批量推送失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@@ -699,7 +701,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
});
|
});
|
||||||
message.success('文件名更新成功');
|
message.success('文件名更新成功');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('文件名更新失败:', error);
|
// console.error('文件名更新失败:', error);
|
||||||
message.error(error.message || '文件名更新失败');
|
message.error(error.message || '文件名更新失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -733,11 +735,11 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
console.log(response);
|
// console.log(response);
|
||||||
const successCount = response?.successCount || 0;
|
const successCount = response?.successCount || 0;
|
||||||
message.success(`已更新 ${successCount} 个文件名`);
|
message.success(`已更新 ${successCount} 个文件名`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('文件名更新失败:', error);
|
// console.error('文件名更新失败:', error);
|
||||||
message.error(error.message || '文件名更新失败');
|
message.error(error.message || '文件名更新失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -909,10 +911,36 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}, [filterType, filterMedia]);
|
}, [filterType, filterMedia]);
|
||||||
return (
|
return (
|
||||||
<div className="content_box" >
|
<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={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -920,278 +948,27 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #f0f0f5',
|
border: '1px solid #f0f0f5',
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<FilterOutlined style={{ color: '#090a0cff', fontSize: 14 }} />
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}>媒体类型:</Typography.Text>
|
||||||
<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>
|
|
||||||
{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
|
<Button
|
||||||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||||||
onClick={() => {
|
onClick={() => { setFilterMedia('video'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
|
||||||
setFilterMedia('video');
|
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'video' ? 'transparent' : '#e2e8f0', color: filterMedia === 'video' ? '#fff' : '#64748b' }}
|
||||||
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 />}
|
icon={<VideoCameraOutlined />}
|
||||||
>
|
>
|
||||||
视频
|
视频
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||||||
onClick={() => {
|
onClick={() => { setFilterMedia('image'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
|
||||||
setFilterMedia('image');
|
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'image' ? 'transparent' : '#e2e8f0', color: filterMedia === 'image' ? '#fff' : '#64748b' }}
|
||||||
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 />}
|
icon={<PictureOutlined />}
|
||||||
>
|
>
|
||||||
图片
|
图片
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Space size={8} wrap>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
picker="date"
|
picker="date"
|
||||||
value={selectedDate ? dayjs(selectedDate) : undefined}
|
value={selectedDate ? dayjs(selectedDate) : undefined}
|
||||||
@@ -1199,16 +976,8 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||||
placeholder="选择日期"
|
placeholder="选择日期"
|
||||||
|
allowClear
|
||||||
/>
|
/>
|
||||||
{selectedDate && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
onClick={() => handleDateChange('')}
|
|
||||||
style={{ color: '#94a3b8', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="搜索提示词"
|
placeholder="搜索提示词"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -1226,32 +995,63 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
loadRecordList();
|
loadRecordList();
|
||||||
}}
|
}}
|
||||||
style={{ width: 240, borderRadius: 8 }}
|
style={{ width: 240, borderRadius: 8 }}
|
||||||
|
enterButton
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
icon={<ClockCircleOutlined />}
|
icon={<ClockCircleOutlined />}
|
||||||
onClick={handleOpenUploadHistory}
|
onClick={handleOpenUploadHistory}
|
||||||
style={{
|
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
|
||||||
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>
|
</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>
|
</div>
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -1263,7 +1063,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
<Spin
|
<Spin
|
||||||
indicator={<LoadingOutlined style={{ fontSize: 24, color: '#64748b' }} spin />}
|
indicator={<LoadingOutlined style={{ fontSize: 24, color: '#64748b' }} spin />}
|
||||||
tip="加载中..."
|
description="加载中..."
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1408,18 +1208,20 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 6,
|
||||||
left: 8,
|
left: 6,
|
||||||
width: 20,
|
width: 22,
|
||||||
height: 20,
|
height: 22,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: isSelectedItem ? '#10b981' : 'rgba(255,255,255,0.9)',
|
backgroundColor: isSelectedItem ? '#6366f1' : 'rgba(255,255,255,0.92)',
|
||||||
border: isSelectedItem ? '2px solid #10b981' : '2px solid #d1d5db',
|
border: isSelectedItem ? '2px solid #6366f1' : '2px solid #cbd5e1',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
zIndex: 10,
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1517,10 +1319,11 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
border: '3px solid #10b981',
|
border: '3px solid #6366f1',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(99,102,241,0.2)',
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const HomePage: React.FC = () => {
|
|||||||
setActiveCaseTab(firstId);
|
setActiveCaseTab(firstId);
|
||||||
// 初始请求第一个 tab 的数据
|
// 初始请求第一个 tab 的数据
|
||||||
getHomeCaseButton(firstId).then((btnRes: any) => {
|
getHomeCaseButton(firstId).then((btnRes: any) => {
|
||||||
console.log('caseButton:', btnRes);
|
// console.log('caseButton:', btnRes);
|
||||||
if (btnRes?.categories?.[0]?.assets) {
|
if (btnRes?.categories?.[0]?.assets) {
|
||||||
setCaseAssets(btnRes.categories[0].assets);
|
setCaseAssets(btnRes.categories[0].assets);
|
||||||
}
|
}
|
||||||
@@ -357,7 +357,7 @@ const HomePage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||||
title: '爆款复刻',
|
title: '爆款复刻',
|
||||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
description: '上传参考视频与产品图片,一键复刻爆款',
|
||||||
action: '立即创作',
|
action: '立即创作',
|
||||||
path: '/initial',
|
path: '/initial',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
|
|||||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
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() {
|
function InitialInfo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
@@ -101,7 +108,7 @@ function InitialInfo() {
|
|||||||
}, [previewVisible, previewType]);
|
}, [previewVisible, previewType]);
|
||||||
|
|
||||||
const openPreview = (url: string, type: 'image' | 'video') => {
|
const openPreview = (url: string, type: 'image' | 'video') => {
|
||||||
console.log(url, type);
|
// console.log(url, type);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
setPreviewType(type);
|
setPreviewType(type);
|
||||||
setPreviewVisible(true);
|
setPreviewVisible(true);
|
||||||
@@ -587,7 +594,7 @@ function InitialInfo() {
|
|||||||
{taskDetail?.material?.materialVideoUrl ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<video
|
||||||
|
|
||||||
src={taskDetail.material.materialVideoUrl}
|
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -602,7 +609,7 @@ function InitialInfo() {
|
|||||||
</span>
|
</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')}>
|
<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 ? (
|
{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>
|
<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)' }}>
|
<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 ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<video
|
||||||
src={taskDetail.material.materialVideoUrl}
|
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||||
controls
|
controls
|
||||||
/>
|
/>
|
||||||
@@ -741,7 +748,7 @@ function InitialInfo() {
|
|||||||
<div style={{ height: 180, borderRadius: 10, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ height: 180, borderRadius: 10, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
{taskDetail?.material?.materialImageUrl ? (
|
{taskDetail?.material?.materialImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={taskDetail.material.materialImageUrl}
|
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
|
||||||
alt="产品图片"
|
alt="产品图片"
|
||||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -383,13 +383,24 @@ const MaterialListPage: React.FC = () => {
|
|||||||
<FolderOpenOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
<FolderOpenOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>素材列表</Typography.Text>
|
<Typography.Text strong style={{ fontSize: 16 }}>素材列表</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'top', marginBottom: 16 }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
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
|
<Input
|
||||||
placeholder="广告主ID"
|
placeholder="广告主ID"
|
||||||
value={searchParams.advertiser_id}
|
value={searchParams.advertiser_id}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||||
style={{ width: 160 }}
|
style={{ width: 150, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
@@ -397,7 +408,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
placeholder="素材ID"
|
placeholder="素材ID"
|
||||||
value={searchParams.material_id}
|
value={searchParams.material_id}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
|
||||||
style={{ width: 160 }}
|
style={{ width: 150, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
@@ -405,7 +416,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
placeholder="上传ID"
|
placeholder="上传ID"
|
||||||
value={searchParams.upload_id}
|
value={searchParams.upload_id}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
|
||||||
style={{ width: 160 }}
|
style={{ width: 150, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
@@ -413,7 +424,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
placeholder="文件名"
|
placeholder="文件名"
|
||||||
value={searchParams.file_name}
|
value={searchParams.file_name}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
|
||||||
style={{ width: 140 }}
|
style={{ width: 140, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
@@ -421,7 +432,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
placeholder="资源类型"
|
placeholder="资源类型"
|
||||||
value={searchParams.resource_type}
|
value={searchParams.resource_type}
|
||||||
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
|
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
|
||||||
style={{ width: 120 }}
|
style={{ width: 110, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
options={[
|
options={[
|
||||||
{ value: 'image', label: '图片' },
|
{ value: 'image', label: '图片' },
|
||||||
@@ -431,21 +442,16 @@ const MaterialListPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||||
|
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
|
||||||
>
|
>
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<RobotOutlined />}
|
icon={<RobotOutlined />}
|
||||||
onClick={() => navigate('/generated?filterType=private_portrait&portraitTab=aigc_virtual')}
|
onClick={() => navigate('/generated?filterType=private_portrait&portraitTab=aigc_virtual')}
|
||||||
style={{
|
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#8b5cf6', color: '#7c3aed', background: '#f5f3ff' }}
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
borderColor: '#8b5cf6',
|
|
||||||
color: '#7c3aed',
|
|
||||||
background: '#f5f3ff',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
私域虚拟人像库
|
私域虚拟人像库
|
||||||
</Button>
|
</Button>
|
||||||
@@ -454,16 +460,9 @@ const MaterialListPage: React.FC = () => {
|
|||||||
loading={pushTemplatesLoading}
|
loading={pushTemplatesLoading}
|
||||||
onClick={handleOpenPushModal}
|
onClick={handleOpenPushModal}
|
||||||
disabled={selectedRows.size === 0}
|
disabled={selectedRows.size === 0}
|
||||||
style={{
|
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
推送前测 ({selectedRows.size})
|
推送前测 {selectedRows.size > 0 && `(${selectedRows.size})`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const ProjectsPage: React.FC = () => {
|
|||||||
backgroundClip: 'text',
|
backgroundClip: 'text',
|
||||||
// textAlign: 'center',
|
// textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
我的项目
|
行业制造
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 13, color: '#64748b', margin: '4px 0 0 0' }}>
|
<p style={{ fontSize: 13, color: '#64748b', margin: '4px 0 0 0' }}>
|
||||||
共 {projects.length} 个项目 · 按行业分类管理
|
共 {projects.length} 个项目 · 按行业分类管理
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ function RemoveInfo() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,6 +486,14 @@ function RemoveInfo() {
|
|||||||
render: (_: any, record: any) => {
|
render: (_: any, record: any) => {
|
||||||
const splitStatus = record.split_status || record.splitStatus;
|
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') {
|
if (splitStatus === 'processing') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
|
|||||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
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() {
|
function InitialInfo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
@@ -590,7 +597,7 @@ function InitialInfo() {
|
|||||||
{taskDetail?.material?.materialVideoUrl ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<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' }}
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -605,7 +612,7 @@ function InitialInfo() {
|
|||||||
</span>
|
</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')}>
|
<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 ? (
|
{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>
|
<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)' }}>
|
<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 ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<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 }}
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||||
controls
|
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)' }}>
|
<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 ? (
|
{taskDetail?.material?.materialImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={taskDetail.material.materialImageUrl}
|
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
|
||||||
alt="产品图片"
|
alt="产品图片"
|
||||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://ceshi.apiforeign.minzhong.cn',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user