# 算力积分管理服务 > **文档版本**:v2.1 > **最后更新**:2026-01-28 > **变更说明**:修复 SQL 注释语法(改为 COMMENT ON COLUMN),符合 PostgreSQL 规范 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [与 AI Service 集成](#与-ai-service-集成) 4. [数据库设计](#数据库设计) 5. [服务实现](#服务实现) 6. [API 接口](#api-接口) 7. [业务流程](#业务流程) --- ## 服务概述 算力积分管理服务负责处理用户的积分充值、消耗、流水记录、定价管理等功能。用户通过充值获得算力积分,使用积分消耗生成 AI 素材(图片、视频、文字处理等)。 ### 核心概念 - **算力积分**:用户使用 AI 功能的虚拟货币单位 - **充值**:用户通过支付人民币兑换算力积分 - **消耗**:使用 AI 功能时扣减积分 - **流水**:记录所有积分变动的历史记录 - **定价**:不同 AI 功能的积分消耗标准 --- ## 核心功能 ### 1. 积分查询 - 查询当前余额 - 查询累计充值/消耗 - 查询积分流水 ### 2. 积分充值 - 创建充值订单 - 支付处理(微信/支付宝) - 充值成功增加积分 - 充值失败处理 ### 3. 积分消耗 - 预扣积分(冻结) - 确认扣减(任务成功) - 退还积分(任务失败) - 消耗记录 ### 4. 积分定价 - 图片生成定价 - 视频生成定价 - 文字处理定价 - 动态计费 ### 5. 积分套餐 - 套餐管理 - 赠送积分 - 优惠活动 --- ## 与 AI Service 集成 ### 集成架构 积分服务与 AI 服务采用**混合集成模式**: - **预扣积分**:同步调用(强一致性,防止超支) - **确认/退还**:同步或异步(根据性能需求) ### 集成流程 #### 1. AI 任务创建流程 ``` 用户请求 AI 生成 ↓ AI Service 计算所需积分(查询 ai_models 表) ↓ AI Service 调用 Credit Service 预扣积分(同步) ↓ Credit Service 检查余额 → 扣除积分 → 创建 consumption_log ↓ AI Service 创建 ai_job,关联 consumption_log_id ↓ 提交异步任务到 Celery ``` #### 2. AI 任务完成流程 ``` Celery Worker 完成 AI 生成 ↓ 更新 ai_job 状态为 completed ↓ 调用 Credit Service 确认消耗 ↓ 更新 consumption_log 状态为 success ``` #### 3. AI 任务失败流程 ``` Celery Worker 任务失败 ↓ 更新 ai_job 状态为 failed ↓ 调用 Credit Service 退还积分 ↓ 更新 consumption_log 状态为 refunded ↓ 用户积分余额恢复 ``` ### 数据关联 ``` ai_jobs (AI Service) ↓ consumption_log_id (UUID) credit_consumption_logs (Credit Service) ↓ ai_job_id (UUID) ai_jobs (双向关联) ``` --- ## 数据库设计 > **说明**:以下表定义遵循 Jointo 技术栈规范,使用 UUID v7 作为主键,SMALLINT 存储枚举值。 ### 1. 关联表说明 - `recharge_orders` 和 `payment_callbacks` 表定义在 [充值管理服务](./recharge-service.md) 中 - 以下为 CreditService 特有的数据表 ### 2. 算力积分流水表 (credit_transactions) ```sql CREATE TABLE credit_transactions ( transaction_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 交易信息 transaction_type SMALLINT NOT NULL, amount INTEGER NOT NULL, balance_before INTEGER NOT NULL, balance_after INTEGER NOT NULL, -- 关联信息(无外键约束,应用层验证) related_order_id UUID, related_consumption_id UUID, -- 描述 description TEXT, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 表级注释 COMMENT ON TABLE credit_transactions IS '算力积分流水表 - 应用层保证引用完整性'; -- 列级注释 COMMENT ON COLUMN credit_transactions.transaction_id IS '交易唯一标识'; COMMENT ON COLUMN credit_transactions.user_id IS '用户 ID - 应用层验证'; COMMENT ON COLUMN credit_transactions.transaction_type IS '交易类型(1=充值, 2=消耗, 3=退款, 4=赠送, 5=过期, 6=管理员调整)'; COMMENT ON COLUMN credit_transactions.amount IS '交易金额(正数=增加,负数=减少)'; COMMENT ON COLUMN credit_transactions.balance_before IS '交易前余额'; COMMENT ON COLUMN credit_transactions.balance_after IS '交易后余额'; COMMENT ON COLUMN credit_transactions.related_order_id IS '关联的充值订单 ID - 应用层验证'; COMMENT ON COLUMN credit_transactions.related_consumption_id IS '关联的消耗记录 ID - 应用层验证'; COMMENT ON COLUMN credit_transactions.description IS '交易描述'; COMMENT ON COLUMN credit_transactions.created_at IS '交易创建时间(UTC)'; COMMENT ON COLUMN credit_transactions.updated_at IS '交易更新时间(UTC)'; -- 索引 CREATE INDEX idx_credit_transactions_user_id ON credit_transactions (user_id); CREATE INDEX idx_credit_transactions_type ON credit_transactions (transaction_type); CREATE INDEX idx_credit_transactions_created_at ON credit_transactions (created_at); CREATE INDEX idx_credit_transactions_order_id ON credit_transactions (related_order_id) WHERE related_order_id IS NOT NULL; CREATE INDEX idx_credit_transactions_consumption_id ON credit_transactions (related_consumption_id) WHERE related_consumption_id IS NOT NULL; -- 组合索引(优化常用查询) CREATE INDEX idx_credit_transactions_user_type ON credit_transactions (user_id, transaction_type); CREATE INDEX idx_credit_transactions_user_created ON credit_transactions (user_id, created_at DESC); -- 触发器 CREATE TRIGGER update_credit_transactions_updated_at BEFORE UPDATE ON credit_transactions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### 3. 积分消耗记录表 (credit_consumption_logs) ```sql CREATE TABLE credit_consumption_logs ( consumption_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 消耗信息 feature_type SMALLINT NOT NULL, credits_consumed INTEGER NOT NULL, -- 任务信息 task_id TEXT, task_status SMALLINT NOT NULL DEFAULT 1, -- 关联 AI Service(无外键约束,应用层验证) ai_job_id UUID, -- 关联资源 resource_id UUID, resource_type TEXT, -- 任务参数(JSON) task_params JSONB, -- 超时管理 frozen_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, -- 备注 remark TEXT ); -- 表级注释 COMMENT ON TABLE credit_consumption_logs IS '积分消耗记录表 - 应用层保证引用完整性'; -- 列级注释 COMMENT ON COLUMN credit_consumption_logs.consumption_id IS '消耗记录唯一标识'; COMMENT ON COLUMN credit_consumption_logs.user_id IS '用户 ID - 应用层验证'; COMMENT ON COLUMN credit_consumption_logs.feature_type IS '功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)'; COMMENT ON COLUMN credit_consumption_logs.credits_consumed IS '消耗的积分数'; COMMENT ON COLUMN credit_consumption_logs.task_id IS '任务 ID(字符串,兼容旧系统)'; COMMENT ON COLUMN credit_consumption_logs.task_status IS '任务状态(1=待处理, 2=处理中, 3=成功, 4=失败, 5=已退款, 6=已过期)'; COMMENT ON COLUMN credit_consumption_logs.ai_job_id IS '关联的 AI 任务 ID - 应用层验证'; COMMENT ON COLUMN credit_consumption_logs.resource_id IS '生成的资源 ID'; COMMENT ON COLUMN credit_consumption_logs.resource_type IS '生成的资源类型'; COMMENT ON COLUMN credit_consumption_logs.task_params IS '任务参数(JSON 格式)'; COMMENT ON COLUMN credit_consumption_logs.frozen_at IS '积分冻结时间(UTC)'; COMMENT ON COLUMN credit_consumption_logs.expires_at IS '积分过期时间(UTC)'; COMMENT ON COLUMN credit_consumption_logs.created_at IS '记录创建时间(UTC)'; COMMENT ON COLUMN credit_consumption_logs.updated_at IS '记录更新时间(UTC)'; COMMENT ON COLUMN credit_consumption_logs.completed_at IS '任务完成时间(UTC)'; COMMENT ON COLUMN credit_consumption_logs.remark IS '备注信息'; -- 单列索引 CREATE INDEX idx_credit_consumption_user_id ON credit_consumption_logs (user_id); CREATE INDEX idx_credit_consumption_feature_type ON credit_consumption_logs (feature_type); CREATE INDEX idx_credit_consumption_task_status ON credit_consumption_logs (task_status); CREATE INDEX idx_credit_consumption_created_at ON credit_consumption_logs (created_at); CREATE INDEX idx_credit_consumption_task_id ON credit_consumption_logs (task_id) WHERE task_id IS NOT NULL; CREATE INDEX idx_credit_consumption_ai_job_id ON credit_consumption_logs (ai_job_id) WHERE ai_job_id IS NOT NULL; CREATE INDEX idx_credit_consumption_expires_at ON credit_consumption_logs (expires_at) WHERE task_status = 1 AND expires_at IS NOT NULL; -- 组合索引(优化常用查询) CREATE INDEX idx_credit_consumption_user_feature ON credit_consumption_logs (user_id, feature_type); CREATE INDEX idx_credit_consumption_user_status ON credit_consumption_logs (user_id, task_status); CREATE INDEX idx_credit_consumption_user_created ON credit_consumption_logs (user_id, created_at DESC); -- 条件索引(仅索引活跃记录) CREATE INDEX idx_credit_consumption_active ON credit_consumption_logs (task_status, created_at DESC) WHERE task_status IN (1, 2); -- 触发器 CREATE TRIGGER update_credit_consumption_logs_updated_at BEFORE UPDATE ON credit_consumption_logs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### 4. 积分套餐表 (credit_packages) ```sql CREATE TABLE credit_packages ( package_id UUID PRIMARY KEY, -- 套餐信息 name TEXT NOT NULL, description TEXT, -- 价格信息 price DECIMAL(10, 2) NOT NULL, credits INTEGER NOT NULL, bonus_credits INTEGER NOT NULL DEFAULT 0, -- 显示信息 display_order INTEGER NOT NULL DEFAULT 0, is_recommended BOOLEAN DEFAULT false, badge_text TEXT, -- 状态 is_active BOOLEAN DEFAULT true, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 表级注释 COMMENT ON TABLE credit_packages IS '积分套餐表'; -- 列级注释 COMMENT ON COLUMN credit_packages.package_id IS '套餐唯一标识'; COMMENT ON COLUMN credit_packages.name IS '套餐名称'; COMMENT ON COLUMN credit_packages.description IS '套餐描述'; COMMENT ON COLUMN credit_packages.price IS '套餐价格(元)'; COMMENT ON COLUMN credit_packages.credits IS '基础积分数'; COMMENT ON COLUMN credit_packages.bonus_credits IS '赠送积分数'; COMMENT ON COLUMN credit_packages.display_order IS '显示顺序(数字越小越靠前)'; COMMENT ON COLUMN credit_packages.is_recommended IS '是否推荐'; COMMENT ON COLUMN credit_packages.badge_text IS '徽章文本(如"热销"、"推荐"等)'; COMMENT ON COLUMN credit_packages.is_active IS '是否启用'; COMMENT ON COLUMN credit_packages.created_at IS '创建时间(UTC)'; COMMENT ON COLUMN credit_packages.updated_at IS '更新时间(UTC)'; -- 索引 CREATE INDEX idx_credit_packages_active ON credit_packages (is_active, display_order); -- 触发器 CREATE TRIGGER update_credit_packages_updated_at BEFORE UPDATE ON credit_packages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### 5. 积分定价配置表 (credit_pricing) ```sql CREATE TABLE credit_pricing ( pricing_id UUID PRIMARY KEY, -- 功能类型 feature_type SMALLINT NOT NULL, -- 定价规则(JSON) pricing_rules JSONB NOT NULL, -- 描述 description TEXT, -- 状态 is_active BOOLEAN DEFAULT true, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 表级注释 COMMENT ON TABLE credit_pricing IS '积分定价配置表'; -- 列级注释 COMMENT ON COLUMN credit_pricing.pricing_id IS '定价配置唯一标识'; COMMENT ON COLUMN credit_pricing.feature_type IS '功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)'; COMMENT ON COLUMN credit_pricing.pricing_rules IS '定价规则(JSON 格式,包含基础价格、倍率等)。示例:图片生成={"base": 10, "hd_multiplier": 2},视频生成={"text2video": {"base": 50, "per_second": 5}}'; COMMENT ON COLUMN credit_pricing.description IS '定价描述'; COMMENT ON COLUMN credit_pricing.is_active IS '是否启用'; COMMENT ON COLUMN credit_pricing.created_at IS '创建时间(UTC)'; COMMENT ON COLUMN credit_pricing.updated_at IS '更新时间(UTC)'; -- 索引 CREATE INDEX idx_credit_pricing_feature_type ON credit_pricing (feature_type, is_active); -- 触发器 CREATE TRIGGER update_credit_pricing_updated_at BEFORE UPDATE ON credit_pricing FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### 6. 积分赠送记录表 (credit_gifts) ```sql CREATE TABLE credit_gifts ( gift_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 赠送信息 credits INTEGER NOT NULL, gift_type SMALLINT NOT NULL, -- 关联信息(无外键约束,应用层验证) related_user_id UUID, activity_id TEXT, -- 描述 description TEXT, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ ); -- 表级注释 COMMENT ON TABLE credit_gifts IS '积分赠送记录表 - 应用层保证引用完整性'; -- 列级注释 COMMENT ON COLUMN credit_gifts.gift_id IS '赠送记录唯一标识'; COMMENT ON COLUMN credit_gifts.user_id IS '接收赠送的用户 ID - 应用层验证'; COMMENT ON COLUMN credit_gifts.credits IS '赠送的积分数'; COMMENT ON COLUMN credit_gifts.gift_type IS '赠送类型(1=注册赠送, 2=邀请赠送, 3=活动赠送, 4=补偿赠送, 5=管理员赠送)'; COMMENT ON COLUMN credit_gifts.related_user_id IS '关联的用户 ID(如邀请人)- 应用层验证'; COMMENT ON COLUMN credit_gifts.activity_id IS '活动 ID'; COMMENT ON COLUMN credit_gifts.description IS '赠送描述'; COMMENT ON COLUMN credit_gifts.created_at IS '赠送时间(UTC)'; COMMENT ON COLUMN credit_gifts.expires_at IS '积分过期时间(UTC)'; -- 单列索引 CREATE INDEX idx_credit_gifts_user_id ON credit_gifts (user_id); CREATE INDEX idx_credit_gifts_type ON credit_gifts (gift_type); CREATE INDEX idx_credit_gifts_created_at ON credit_gifts (created_at); CREATE INDEX idx_credit_gifts_related_user_id ON credit_gifts (related_user_id) WHERE related_user_id IS NOT NULL; -- 组合索引 CREATE INDEX idx_credit_gifts_user_type ON credit_gifts (user_id, gift_type); ``` --- ## Model 层(数据模型) ### 1. 积分流水模型 (CreditTransaction) ```python # app/models/credit/transaction.py from typing import TYPE_CHECKING, Optional from uuid import UUID from datetime import datetime, timezone from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Index from sqlalchemy.dialects.postgresql import UUID as PG_UUID from app.utils.id_generator import generate_uuid if TYPE_CHECKING: from app.models.user import User from app.models.credit.consumption_log import CreditConsumptionLog from app.models.recharge.order import RechargeOrder class CreditTransaction(SQLModel, table=True): """算力积分流水表 - 应用层保证引用完整性""" __tablename__ = "credit_transactions" # 主键 (UUID v7 - 应用层生成) transaction_id: UUID = Field( sa_column=Column( PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid ), description="交易唯一标识" ) # 用户关联(无外键约束,应用层验证) user_id: UUID = Field( sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True), description="用户 ID - 应用层验证" ) # 交易信息 transaction_type: int = Field( sa_column=Column("transaction_type", nullable=False, index=True), description="交易类型(1=充值, 2=消耗, 3=退款, 4=赠送, 5=过期, 6=管理员调整)" ) amount: int = Field(description="交易金额(正数=增加,负数=减少)") balance_before: int = Field(description="交易前余额") balance_after: int = Field(description="交易后余额") # 关联信息(无外键约束,应用层验证) related_order_id: Optional[UUID] = Field( default=None, sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True), description="关联的充值订单 ID - 应用层验证" ) related_consumption_id: Optional[UUID] = Field( default=None, sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True), description="关联的消耗记录 ID - 应用层验证" ) # 描述 description: Optional[str] = Field(default=None, description="交易描述") # 时间戳 created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="交易创建时间" ) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="交易更新时间" ) # Relationship 配置(使用 primaryjoin,因为无物理外键) user: Optional["User"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditTransaction.user_id == User.user_id", "foreign_keys": "[CreditTransaction.user_id]", } ) related_order: Optional["RechargeOrder"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditTransaction.related_order_id == RechargeOrder.order_id", "foreign_keys": "[CreditTransaction.related_order_id]", } ) related_consumption: Optional["CreditConsumptionLog"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditTransaction.related_consumption_id == CreditConsumptionLog.consumption_id", "foreign_keys": "[CreditTransaction.related_consumption_id]", } ) # 表级索引 __table_args__ = ( Index('idx_credit_transactions_user_type', 'user_id', 'transaction_type'), Index('idx_credit_transactions_user_created', 'user_id', 'created_at'), ) ``` ### 2. 积分消耗记录模型 (CreditConsumptionLog) ```python # app/models/credit/consumption_log.py from typing import TYPE_CHECKING, Optional from uuid import UUID from datetime import datetime, timezone from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Index, text from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB from app.utils.id_generator import generate_uuid if TYPE_CHECKING: from app.models.user import User class CreditConsumptionLog(SQLModel, table=True): """积分消耗记录表 - 应用层保证引用完整性""" __tablename__ = "credit_consumption_logs" # 主键 (UUID v7 - 应用层生成) consumption_id: UUID = Field( sa_column=Column( PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid ), description="消耗记录唯一标识" ) # 用户关联(无外键约束,应用层验证) user_id: UUID = Field( sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True), description="用户 ID - 应用层验证" ) # 消耗信息 feature_type: int = Field( sa_column=Column("feature_type", nullable=False, index=True), description="功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)" ) credits_consumed: int = Field(description="消耗的积分数") # 任务信息 task_id: Optional[str] = Field( default=None, sa_column=Column("task_id", nullable=True, index=True), description="任务 ID(字符串,兼容旧系统)" ) task_status: int = Field( default=1, sa_column=Column("task_status", nullable=False, index=True), description="任务状态(1=待处理, 2=处理中, 3=成功, 4=失败, 5=已退款, 6=已过期)" ) # 关联 AI Service(无外键约束,应用层验证) ai_job_id: Optional[UUID] = Field( default=None, sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True), description="关联的 AI 任务 ID - 应用层验证" ) # 关联资源 resource_id: Optional[UUID] = Field( default=None, sa_column=Column(PG_UUID(as_uuid=True), nullable=True), description="生成的资源 ID" ) resource_type: Optional[str] = Field(default=None, description="生成的资源类型") # 任务参数(JSON) task_params: Optional[dict] = Field( default=None, sa_column=Column(JSONB, nullable=True), description="任务参数(JSON 格式)" ) # 超时管理 frozen_at: Optional[datetime] = Field(default=None, description="积分冻结时间") expires_at: Optional[datetime] = Field(default=None, description="积分过期时间") # 时间戳 created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="记录创建时间" ) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="记录更新时间" ) completed_at: Optional[datetime] = Field(default=None, description="任务完成时间") # 备注 remark: Optional[str] = Field(default=None, description="备注信息") # Relationship 配置(使用 primaryjoin,因为无物理外键) user: Optional["User"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditConsumptionLog.user_id == User.user_id", "foreign_keys": "[CreditConsumptionLog.user_id]", } ) # 表级索引 __table_args__ = ( Index('idx_credit_consumption_user_feature', 'user_id', 'feature_type'), Index('idx_credit_consumption_user_status', 'user_id', 'task_status'), Index('idx_credit_consumption_user_created', 'user_id', 'created_at'), Index( 'idx_credit_consumption_active', 'task_status', 'created_at', postgresql_where=text('task_status IN (1, 2)') ), Index( 'idx_credit_consumption_expires_at', 'expires_at', postgresql_where=text('task_status = 1 AND expires_at IS NOT NULL') ), ) ``` ### 3. 积分套餐模型 (CreditPackage) ```python # app/models/credit/package.py from typing import Optional from uuid import UUID from datetime import datetime, timezone from decimal import Decimal from sqlmodel import SQLModel, Field from sqlalchemy import Column, Index from sqlalchemy.dialects.postgresql import UUID as PG_UUID from app.utils.id_generator import generate_uuid class CreditPackage(SQLModel, table=True): """积分套餐表""" __tablename__ = "credit_packages" # 主键 (UUID v7 - 应用层生成) package_id: UUID = Field( sa_column=Column( PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid ), description="套餐唯一标识" ) # 套餐信息 name: str = Field(description="套餐名称") description: Optional[str] = Field(default=None, description="套餐描述") # 价格信息 price: Decimal = Field(description="套餐价格(元)") credits: int = Field(description="基础积分数") bonus_credits: int = Field(default=0, description="赠送积分数") # 显示信息 display_order: int = Field(default=0, description="显示顺序(数字越小越靠前)") is_recommended: bool = Field(default=False, description="是否推荐") badge_text: Optional[str] = Field(default=None, description="徽章文本(如"热销"、"推荐"等)") # 状态 is_active: bool = Field(default=True, description="是否启用") # 时间戳 created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="创建时间" ) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="更新时间" ) # 表级索引 __table_args__ = ( Index('idx_credit_packages_active', 'is_active', 'display_order'), ) ``` ### 4. 积分定价配置模型 (CreditPricing) ```python # app/models/credit/pricing.py from typing import Optional from uuid import UUID from datetime import datetime, timezone from sqlmodel import SQLModel, Field from sqlalchemy import Column, Index from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB from app.utils.id_generator import generate_uuid class CreditPricing(SQLModel, table=True): """积分定价配置表""" __tablename__ = "credit_pricing" # 主键 (UUID v7 - 应用层生成) pricing_id: UUID = Field( sa_column=Column( PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid ), description="定价配置唯一标识" ) # 功能类型 feature_type: int = Field( sa_column=Column("feature_type", nullable=False), description="功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)" ) # 定价规则(JSON) pricing_rules: dict = Field( sa_column=Column(JSONB, nullable=False), description="定价规则(JSON 格式,包含基础价格、倍率等)" ) # 描述 description: Optional[str] = Field(default=None, description="定价描述") # 状态 is_active: bool = Field(default=True, description="是否启用") # 时间戳 created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="创建时间" ) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="更新时间" ) # 表级索引 __table_args__ = ( Index('idx_credit_pricing_feature_type', 'feature_type', 'is_active'), ) ``` ### 5. 积分赠送记录模型 (CreditGift) ```python # app/models/credit/gift.py from typing import TYPE_CHECKING, Optional from uuid import UUID from datetime import datetime, timezone from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Index from sqlalchemy.dialects.postgresql import UUID as PG_UUID from app.utils.id_generator import generate_uuid if TYPE_CHECKING: from app.models.user import User class CreditGift(SQLModel, table=True): """积分赠送记录表 - 应用层保证引用完整性""" __tablename__ = "credit_gifts" # 主键 (UUID v7 - 应用层生成) gift_id: UUID = Field( sa_column=Column( PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid ), description="赠送记录唯一标识" ) # 用户关联(无外键约束,应用层验证) user_id: UUID = Field( sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True), description="接收赠送的用户 ID - 应用层验证" ) # 赠送信息 credits: int = Field(description="赠送的积分数") gift_type: int = Field( sa_column=Column("gift_type", nullable=False, index=True), description="赠送类型(1=注册赠送, 2=邀请赠送, 3=活动赠送, 4=补偿赠送, 5=管理员赠送)" ) # 关联信息(无外键约束,应用层验证) related_user_id: Optional[UUID] = Field( default=None, sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True), description="关联的用户 ID(如邀请人)- 应用层验证" ) activity_id: Optional[str] = Field(default=None, description="活动 ID") # 描述 description: Optional[str] = Field(default=None, description="赠送描述") # 时间戳 created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="赠送时间" ) expires_at: Optional[datetime] = Field(default=None, description="积分过期时间") # Relationship 配置(使用 primaryjoin,因为无物理外键) user: Optional["User"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditGift.user_id == User.user_id", "foreign_keys": "[CreditGift.user_id]", } ) related_user: Optional["User"] = Relationship( sa_relationship_kwargs={ "primaryjoin": "CreditGift.related_user_id == User.user_id", "foreign_keys": "[CreditGift.related_user_id]", } ) # 表级索引 __table_args__ = ( Index('idx_credit_gifts_user_type', 'user_id', 'gift_type'), ) ``` --- ## 服务实现 ### CreditService 类 ```python # app/services/credit_service.py from typing import Optional, Dict, Any, List from uuid import UUID from enum import IntEnum from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy import select, and_ from app.models.credit import ( CreditTransaction, CreditConsumptionLog, CreditPackage, CreditPricing, CreditGift ) from app.models.user import User from app.repositories.credit_repository import CreditRepository from app.core.exceptions import NotFoundError, ValidationError, InsufficientCreditsError from datetime import datetime, timedelta from decimal import Decimal # ==================== 枚举定义 ==================== class PaymentMethod(IntEnum): """支付方式枚举(使用 SMALLINT 存储)""" WECHAT = 1 ALIPAY = 2 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.WECHAT: 'wechat', cls.ALIPAY: 'alipay' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'wechat': cls.WECHAT, 'alipay': cls.ALIPAY } return mapping.get(name, cls.WECHAT) class PaymentStatus(IntEnum): """支付状态枚举(使用 SMALLINT 存储)""" PENDING = 1 PAID = 2 FAILED = 3 REFUNDED = 4 CANCELLED = 5 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.PENDING: 'pending', cls.PAID: 'paid', cls.FAILED: 'failed', cls.REFUNDED: 'refunded', cls.CANCELLED: 'cancelled' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'pending': cls.PENDING, 'paid': cls.PAID, 'failed': cls.FAILED, 'refunded': cls.REFUNDED, 'cancelled': cls.CANCELLED } return mapping.get(name, cls.PENDING) class TransactionType(IntEnum): """交易类型枚举(使用 SMALLINT 存储)""" RECHARGE = 1 CONSUME = 2 REFUND = 3 GIFT = 4 EXPIRE = 5 ADMIN_ADJUST = 6 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.RECHARGE: 'recharge', cls.CONSUME: 'consume', cls.REFUND: 'refund', cls.GIFT: 'gift', cls.EXPIRE: 'expire', cls.ADMIN_ADJUST: 'admin_adjust' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'recharge': cls.RECHARGE, 'consume': cls.CONSUME, 'refund': cls.REFUND, 'gift': cls.GIFT, 'expire': cls.EXPIRE, 'admin_adjust': cls.ADMIN_ADJUST } return mapping.get(name, cls.CONSUME) class FeatureType(IntEnum): """功能类型枚举(使用 SMALLINT 存储)""" IMAGE_GENERATION = 1 VIDEO_GENERATION = 2 TEXT_PROCESSING = 3 AUDIO_GENERATION = 4 SOUND_GENERATION = 5 VOICE_GENERATION = 6 SUBTITLE_GENERATION = 7 RESOURCE_GENERATION = 8 STORYBOARD_GENERATION = 9 SCRIPT_GENERATION = 10 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.IMAGE_GENERATION: 'image_generation', cls.VIDEO_GENERATION: 'video_generation', cls.TEXT_PROCESSING: 'text_processing', cls.AUDIO_GENERATION: 'audio_generation', cls.SOUND_GENERATION: 'sound_generation', cls.VOICE_GENERATION: 'voice_generation', cls.SUBTITLE_GENERATION: 'subtitle_generation', cls.RESOURCE_GENERATION: 'resource_generation', cls.STORYBOARD_GENERATION: 'storyboard_generation', cls.SCRIPT_GENERATION: 'script_generation' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'image_generation': cls.IMAGE_GENERATION, 'video_generation': cls.VIDEO_GENERATION, 'text_processing': cls.TEXT_PROCESSING, 'audio_generation': cls.AUDIO_GENERATION, 'sound_generation': cls.SOUND_GENERATION, 'voice_generation': cls.VOICE_GENERATION, 'subtitle_generation': cls.SUBTITLE_GENERATION, 'resource_generation': cls.RESOURCE_GENERATION, 'storyboard_generation': cls.STORYBOARD_GENERATION, 'script_generation': cls.SCRIPT_GENERATION } return mapping.get(name, cls.IMAGE_GENERATION) @classmethod def from_ai_job_type(cls, job_type: int) -> int: """从 AI Service 的 job_type 映射到 feature_type""" return job_type class TaskStatus(IntEnum): """任务状态枚举(使用 SMALLINT 存储)""" PENDING = 1 PROCESSING = 2 SUCCESS = 3 FAILED = 4 REFUNDED = 5 EXPIRED = 6 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.PENDING: 'pending', cls.PROCESSING: 'processing', cls.SUCCESS: 'success', cls.FAILED: 'failed', cls.REFUNDED: 'refunded', cls.EXPIRED: 'expired' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'pending': cls.PENDING, 'processing': cls.PROCESSING, 'success': cls.SUCCESS, 'failed': cls.FAILED, 'refunded': cls.REFUNDED, 'expired': cls.EXPIRED } return mapping.get(name, cls.PENDING) @classmethod def from_ai_status(cls, status: int) -> int: """从 AI Service 的 status 映射到 task_status AI Service status: 1=等待处理 2=处理中 3=已完成 4=失败 5=已取消 Credit Service task_status: 1=待处理 2=处理中 3=成功 4=失败 5=已退款 6=已过期 """ mapping = { 1: cls.PENDING, 2: cls.PROCESSING, 3: cls.SUCCESS, 4: cls.FAILED, 5: cls.REFUNDED } return mapping.get(status, cls.PENDING) class GiftType(IntEnum): """赠送类型枚举(使用 SMALLINT 存储)""" REGISTER = 1 INVITE = 2 ACTIVITY = 3 COMPENSATION = 4 ADMIN = 5 @classmethod def to_dict(cls) -> dict: """返回所有枚举值的字典""" return {member.value: member.name for member in cls} @classmethod def to_name(cls, value: int) -> str: """整数转字符串""" names = { cls.REGISTER: 'register', cls.INVITE: 'invite', cls.ACTIVITY: 'activity', cls.COMPENSATION: 'compensation', cls.ADMIN: 'admin' } return names.get(value, 'unknown') @classmethod def from_name(cls, name: str) -> int: """字符串转整数""" mapping = { 'register': cls.REGISTER, 'invite': cls.INVITE, 'activity': cls.ACTIVITY, 'compensation': cls.COMPENSATION, 'admin': cls.ADMIN } return mapping.get(name, cls.ACTIVITY) class CreditService: def __init__(self, session: AsyncSession): self.repository = CreditRepository(session) self.session = session # ==================== 枚举验证方法 ==================== def _validate_payment_method(self, payment_method: int) -> None: """验证支付方式枚举值""" if payment_method not in PaymentMethod.to_dict().keys(): raise ValidationError(f"无效的支付方式: {payment_method}") def _validate_payment_status(self, payment_status: int) -> None: """验证支付状态枚举值""" if payment_status not in PaymentStatus.to_dict().keys(): raise ValidationError(f"无效的支付状态: {payment_status}") def _validate_transaction_type(self, transaction_type: int) -> None: """验证交易类型枚举值""" if transaction_type not in TransactionType.to_dict().keys(): raise ValidationError(f"无效的交易类型: {transaction_type}") def _validate_feature_type(self, feature_type: int) -> None: """验证功能类型枚举值""" if feature_type not in FeatureType.to_dict().keys(): raise ValidationError(f"无效的功能类型: {feature_type}") def _validate_task_status(self, task_status: int) -> None: """验证任务状态枚举值""" if task_status not in TaskStatus.to_dict().keys(): raise ValidationError(f"无效的任务状态: {task_status}") def _validate_gift_type(self, gift_type: int) -> None: """验证赠送类型枚举值""" if gift_type not in GiftType.to_dict().keys(): raise ValidationError(f"无效的赠送类型: {gift_type}") # ==================== 积分查询 ==================== async def get_balance(self, user_id: UUID) -> Dict[str, Any]: """查询用户积分余额""" user = await self._get_user(user_id) return { 'balance': user.ai_credits_balance, 'total_earned': user.total_credits_earned, 'total_consumed': user.total_credits_consumed, 'total_recharged_amount': float(user.total_recharged_amount) } async def get_transactions( self, user_id: UUID, transaction_type: Optional[int] = None, limit: int = 50, offset: int = 0 ) -> List[CreditTransaction]: """查询积分流水""" return await self.repository.get_transactions( user_id=user_id, transaction_type=transaction_type, limit=limit, offset=offset ) async def get_consumption_logs( self, user_id: UUID, feature_type: Optional[int] = None, limit: int = 50, offset: int = 0 ) -> List[CreditConsumptionLog]: """查询消耗记录""" return await self.repository.get_consumption_logs( user_id=user_id, feature_type=feature_type, limit=limit, offset=offset ) # ==================== 积分操作 ==================== async def add_credits( self, user_id: UUID, amount: int, transaction_type: int, description: str, related_order_id: Optional[UUID] = None ) -> CreditTransaction: """增加积分""" if amount <= 0: raise ValidationError("积分数量必须大于 0") # 验证用户存在(应用层引用完整性保证) if not await self.repository.user_exists(user_id): raise NotFoundError("用户不存在") # 如果有关联订单,验证订单存在 if related_order_id: # 注意:这里需要调用 Recharge Service 的 Repository 验证 # 实际实现中应该注入 RechargeOrderRepository # order = await self.recharge_order_repository.get_by_id(related_order_id) # if not order: # raise NotFoundError("充值订单不存在") pass user = await self._get_user(user_id) async with self.session.begin(): balance_before = user.ai_credits_balance balance_after = balance_before + amount user.ai_credits_balance = balance_after user.total_credits_earned += amount transaction = CreditTransaction( user_id=user_id, transaction_type=transaction_type, amount=amount, balance_before=balance_before, balance_after=balance_after, related_order_id=related_order_id, description=description ) transaction = await self.repository.create_transaction(transaction) return transaction async def consume_credits( self, user_id: UUID, amount: int, feature_type: int, task_id: Optional[str] = None, ai_job_id: Optional[UUID] = None, task_params: Optional[Dict] = None, timeout_hours: int = 24 ) -> CreditConsumptionLog: """消耗积分(预扣) Args: user_id: 用户 ID amount: 消耗积分数 feature_type: 功能类型(枚举值) task_id: 任务 ID(字符串,兼容旧系统) ai_job_id: AI 任务 ID(UUID,关联 ai_jobs 表) task_params: 任务参数 timeout_hours: 超时时间(小时),默认 24 小时 """ if amount <= 0: raise ValidationError("积分数量必须大于 0") # 验证功能类型枚举值 self._validate_feature_type(feature_type) # 1. 验证用户存在 if not await self.repository.user_exists(user_id): raise NotFoundError("用户不存在") # 2. 如果有 ai_job_id,验证 AI 任务存在(应用层引用完整性保证) if ai_job_id: # 注意:这里需要调用 AI Service 的 Repository 验证 # 实际实现中应该注入 AIJobRepository # ai_job = await self.ai_job_repository.get_by_id(ai_job_id) # if not ai_job: # raise NotFoundError("AI 任务不存在") pass user = await self._get_user(user_id) # 3. 检查余额 if user.ai_credits_balance < amount: raise InsufficientCreditsError( f"积分不足,当前余额:{user.ai_credits_balance},需要:{amount}" ) # 计算过期时间 expires_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours) async with self.session.begin(): balance_before = user.ai_credits_balance balance_after = balance_before - amount user.ai_credits_balance = balance_after user.total_credits_consumed += amount consumption_log = CreditConsumptionLog( user_id=user_id, feature_type=feature_type, credits_consumed=amount, task_id=task_id, ai_job_id=ai_job_id, task_status=TaskStatus.PENDING, task_params=task_params, frozen_at=datetime.now(timezone.utc), expires_at=expires_at ) consumption_log = await self.repository.create_consumption_log(consumption_log) transaction = CreditTransaction( user_id=user_id, transaction_type=TransactionType.CONSUME, amount=-amount, balance_before=balance_before, balance_after=balance_after, related_consumption_id=consumption_log.consumption_id, description=f"{FeatureType.to_name(feature_type)} 任务消耗" ) await self.repository.create_transaction(transaction) return consumption_log async def refund_credits( self, consumption_id: UUID, reason: str ) -> CreditTransaction: """退还积分(任务失败)""" consumption_log = await self.repository.get_consumption_log(consumption_id) if not consumption_log: raise NotFoundError("消耗记录不存在") if consumption_log.task_status == TaskStatus.REFUNDED: raise ValidationError("该任务已退款") user = await self._get_user(consumption_log.user_id) amount = consumption_log.credits_consumed async with self.session.begin(): balance_before = user.ai_credits_balance balance_after = balance_before + amount user.ai_credits_balance = balance_after user.total_credits_consumed -= amount consumption_log.task_status = TaskStatus.REFUNDED consumption_log.remark = reason transaction = CreditTransaction( user_id=consumption_log.user_id, transaction_type=TransactionType.REFUND, amount=amount, balance_before=balance_before, balance_after=balance_after, related_consumption_id=consumption_id, description=f"任务失败退款:{reason}" ) self.session.add(transaction) await self.session.flush() return transaction async def confirm_consumption( self, consumption_id: UUID, resource_id: Optional[UUID] = None, resource_type: Optional[str] = None ) -> None: """确认消耗(任务成功)""" consumption_log = await self.repository.get_consumption_log(consumption_id) if not consumption_log: raise NotFoundError("消耗记录不存在") consumption_log.task_status = TaskStatus.SUCCESS consumption_log.completed_at = datetime.now(timezone.utc) consumption_log.resource_id = resource_id consumption_log.resource_type = resource_type await self.session.commit() # ==================== 积分定价 ==================== async def calculate_credits( self, feature_type: int, params: Dict[str, Any] ) -> int: """计算所需积分(基于 ai_models 表) 说明: - 统一定价逻辑:以 ai_models 表为准 - credit_pricing 表仅用于展示和前端参考 - AI Service 调用此方法时,需传入 model_name 查询 ai_models 表 Args: feature_type: 功能类型(枚举值) params: 任务参数,必须包含 model_name Returns: 所需积分数 """ # 验证功能类型枚举值 self._validate_feature_type(feature_type) # 获取模型配置(从 AI Service 的 ai_models 表) model_name = params.get('model_name') if not model_name: raise ValidationError("缺少 model_name 参数") # 注意:这里需要从 AI Service 查询 ai_models 表 # 实际实现中,CreditService 应该通过 API 调用 AI Service 获取模型配置 # 或者将 ai_models 表移到 Credit Service 管理范围内 # 临时方案:从 credit_pricing 获取定价规则 pricing = await self.repository.get_pricing(feature_type) if not pricing: raise NotFoundError(f"未找到 {FeatureType.to_name(feature_type)} 的定价配置") rules = pricing.pricing_rules credits = 0 if feature_type == FeatureType.IMAGE_GENERATION: # 图片生成定价 base = rules.get('base', 10) credits = base # 高清倍率 if params.get('quality') == 'hd': credits *= rules.get('hd_multiplier', 2) # 模型倍率 model = params.get('model', 'default') model_multipliers = rules.get('model_multipliers', {}) credits *= model_multipliers.get(model, 1.0) elif feature_type == FeatureType.VIDEO_GENERATION: # 视频生成定价(支持多种类型) video_type = params.get('video_type', 'text2video') type_rules = rules.get(video_type, {}) if video_type == 'text2video': # 文本转视频 duration = params.get('duration', 5) base = type_rules.get('base', 50) per_second = type_rules.get('per_second', 5) credits = base + duration * per_second elif video_type == 'img2video': # 图片转视频 duration = params.get('duration', 5) base = type_rules.get('base', 30) per_second = type_rules.get('per_second', 3) credits = base + duration * per_second elif video_type == 'keyframe': # 关键帧动画 keyframe_count = params.get('keyframe_count', 1) base = type_rules.get('base', 100) per_keyframe = type_rules.get('per_keyframe', 10) credits = base + keyframe_count * per_keyframe elif video_type == 'fusion': # 视频融合 video_count = params.get('video_count', 2) base = type_rules.get('base', 80) per_video = type_rules.get('per_video', 20) credits = base + video_count * per_video elif video_type == 'replace': # 视频替换 replace_count = params.get('replace_count', 1) base = type_rules.get('base', 40) per_replace = type_rules.get('per_replace', 5) credits = base + replace_count * per_replace else: # 默认定价 duration = params.get('duration', 5) per_second = rules.get('per_second', 5) credits = duration * per_second # 高清倍率 if params.get('quality') == 'hd': credits *= rules.get('hd_multiplier', 3) elif feature_type == FeatureType.TEXT_PROCESSING: # 文本处理定价 char_count = params.get('char_count', 0) per_1000_chars = rules.get('per_1000_chars', 2) credits = max(1, (char_count // 1000) * per_1000_chars) elif feature_type == FeatureType.SOUND_GENERATION: # 音效生成定价 duration = params.get('duration', 5) base = rules.get('base', 5) per_second = rules.get('per_second', 1) credits = base + duration * per_second elif feature_type == FeatureType.VOICE_GENERATION: # 配音生成定价 char_count = params.get('char_count', 0) base = rules.get('base', 10) per_1000_chars = rules.get('per_1000_chars', 2) credits = base + max(0, (char_count // 1000) * per_1000_chars) elif feature_type == FeatureType.SUBTITLE_GENERATION: # 字幕生成定价 duration = params.get('duration', 60) base = rules.get('base', 5) per_minute = rules.get('per_minute', 2) credits = base + (duration / 60) * per_minute elif feature_type == FeatureType.RESOURCE_GENERATION: # 资源生成定价 resource_count = params.get('resource_count', 1) base = rules.get('base', 20) per_resource = rules.get('per_resource', 10) credits = base + resource_count * per_resource elif feature_type == FeatureType.STORYBOARD_GENERATION: # 分镜脚本生成定价 storyboard_count = params.get('storyboard_count', 1) base = rules.get('base', 50) per_storyboard = rules.get('per_storyboard', 5) credits = base + storyboard_count * per_storyboard elif feature_type == FeatureType.SCRIPT_GENERATION: # 剧本生成定价 char_count = params.get('char_count', 0) base = rules.get('base', 100) per_1000_chars = rules.get('per_1000_chars', 5) credits = base + (char_count // 1000) * per_1000_chars return int(credits) # ==================== 超时管理 ==================== async def expire_pending_consumptions(self, timeout_hours: int = 24) -> int: """超时自动退款 定时任务调用,将超时的 pending 任务自动退款 Args: timeout_hours: 超时时间(小时) Returns: 退款的记录数 """ expired_logs = await self.repository.get_expired_pending_consumptions(timeout_hours) count = 0 for log in expired_logs: try: await self.refund_credits( consumption_id=log.consumption_id, reason=f"任务超时自动退款(超过 {timeout_hours} 小时未完成)" ) log.task_status = TaskStatus.EXPIRED await self.session.commit() count += 1 except Exception as e: print(f"退款失败: {e}") return count async def refund_credits_by_status( self, task_status: int, reason: str ) -> int: """批量退款(按状态) Args: task_status: 任务状态(枚举值) reason: 退款原因 Returns: 退款的记录数 """ logs = await self.repository.get_consumption_logs_by_status(task_status) count = 0 for log in logs: try: await self.refund_credits( consumption_id=log.consumption_id, reason=reason ) count += 1 except Exception as e: print(f"退款失败: {e}") return count # ==================== 积分套餐 ==================== async def get_packages(self) -> List[CreditPackage]: """获取充值套餐列表""" return await self.repository.get_active_packages() async def get_package(self, package_id: UUID) -> CreditPackage: """获取套餐详情""" package = await self.repository.get_package(package_id) if not package: raise NotFoundError("套餐不存在") return package # ==================== 积分赠送 ==================== async def gift_credits( self, user_id: UUID, credits: int, gift_type: int, description: str, related_user_id: Optional[UUID] = None, activity_id: Optional[str] = None, expires_at: Optional[datetime] = None ) -> CreditGift: """赠送积分""" if credits <= 0: raise ValidationError("赠送积分必须大于 0") # 验证赠送类型枚举值 self._validate_gift_type(gift_type) gift = CreditGift( user_id=user_id, credits=credits, gift_type=gift_type, related_user_id=related_user_id, activity_id=activity_id, description=description, expires_at=expires_at ) self.session.add(gift) await self.session.flush() await self.add_credits( user_id=user_id, amount=credits, transaction_type=TransactionType.GIFT, description=description ) return gift # ==================== 私有方法 ==================== async def _get_user(self, user_id: UUID) -> User: """获取用户(私有方法)""" user = await self.repository.get_user(user_id) if not user: raise NotFoundError("用户不存在") return user ``` --- ## 自定义异常 ```python # app/core/exceptions.py from fastapi import HTTPException, status class NotFoundError(HTTPException): """资源不存在异常""" def __init__(self, detail: str = "资源不存在"): super().__init__( status_code=status.HTTP_404_NOT_FOUND, detail=detail ) class ValidationError(HTTPException): """验证失败异常""" def __init__(self, detail: str = "验证失败"): super().__init__( status_code=status.HTTP_400_BAD_REQUEST, detail=detail ) class InsufficientCreditsError(HTTPException): """积分不足异常""" def __init__(self, detail: str = "积分不足"): super().__init__( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail ) class PermissionError(HTTPException): """权限不足异常""" def __init__(self, detail: str = "权限不足"): super().__init__( status_code=status.HTTP_403_FORBIDDEN, detail=detail ) ``` --- ## Schema 层(API 响应) ```python # app/schemas/credit.py from pydantic import BaseModel, Field, field_serializer from uuid import UUID from datetime import datetime from typing import Optional class CreditTransactionResponse(BaseModel): """积分交易响应""" transaction_id: UUID = Field(..., alias="transactionId") user_id: UUID = Field(..., alias="userId") transaction_type: int = Field(..., alias="transactionType") transaction_type_name: str = Field(..., alias="transactionTypeName") amount: int balance_before: int = Field(..., alias="balanceBefore") balance_after: int = Field(..., alias="balanceAfter") description: Optional[str] = None created_at: datetime = Field(..., alias="createdAt") @field_serializer('transaction_type_name') def serialize_transaction_type(self, value: int, _info) -> str: """整数 → 字符串""" from app.services.credit_service import TransactionType if isinstance(self.transaction_type, int): return TransactionType.to_name(self.transaction_type) return value class Config: populate_by_name = True from_attributes = True class CreditConsumptionResponse(BaseModel): """积分消耗响应""" consumption_id: UUID = Field(..., alias="consumptionId") user_id: UUID = Field(..., alias="userId") feature_type: int = Field(..., alias="featureType") feature_type_name: str = Field(..., alias="featureTypeName") credits_consumed: int = Field(..., alias="creditsConsumed") task_id: Optional[str] = Field(None, alias="taskId") task_status: int = Field(..., alias="taskStatus") task_status_name: str = Field(..., alias="taskStatusName") ai_job_id: Optional[UUID] = Field(None, alias="aiJobId") resource_id: Optional[UUID] = Field(None, alias="resourceId") resource_type: Optional[str] = Field(None, alias="resourceType") created_at: datetime = Field(..., alias="createdAt") completed_at: Optional[datetime] = Field(None, alias="completedAt") @field_serializer('feature_type_name') def serialize_feature_type(self, value: int, _info) -> str: """整数 → 字符串""" from app.services.credit_service import FeatureType if isinstance(self.feature_type, int): return FeatureType.to_name(self.feature_type) return value @field_serializer('task_status_name') def serialize_task_status(self, value: int, _info) -> str: """整数 → 字符串""" from app.services.credit_service import TaskStatus if isinstance(self.task_status, int): return TaskStatus.to_name(self.task_status) return value class Config: populate_by_name = True from_attributes = True ``` --- ## 数据库迁移脚本 ```python # app/migrations/008_credit_service_tables.py from sqlalchemy import text from sqlmodel.ext.asyncio.session import AsyncSession async def upgrade(session: AsyncSession): """升级数据库 - 创建积分服务相关表""" # 1. 创建积分流水表(无外键约束) await session.execute(text(""" CREATE TABLE IF NOT EXISTS credit_transactions ( transaction_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 交易信息 transaction_type SMALLINT NOT NULL, amount INTEGER NOT NULL, balance_before INTEGER NOT NULL, balance_after INTEGER NOT NULL, -- 关联信息(无外键约束) related_order_id UUID, related_consumption_id UUID, -- 描述 description TEXT, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """)) # 2. 创建消耗记录表(无外键约束) await session.execute(text(""" CREATE TABLE IF NOT EXISTS credit_consumption_logs ( consumption_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 消耗信息 feature_type SMALLINT NOT NULL, credits_consumed INTEGER NOT NULL, -- 任务信息 task_id TEXT, task_status SMALLINT NOT NULL DEFAULT 1, -- 关联 AI Service(无外键约束) ai_job_id UUID, -- 关联资源 resource_id UUID, resource_type TEXT, -- 任务参数(JSON) task_params JSONB, -- 超时管理 frozen_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, -- 备注 remark TEXT ); """)) # 3. 创建套餐表 await session.execute(text(""" CREATE TABLE IF NOT EXISTS credit_packages ( package_id UUID PRIMARY KEY, -- 套餐信息 name TEXT NOT NULL, description TEXT, -- 价格信息 price DECIMAL(10, 2) NOT NULL, credits INTEGER NOT NULL, bonus_credits INTEGER NOT NULL DEFAULT 0, -- 显示信息 display_order INTEGER NOT NULL DEFAULT 0, is_recommended BOOLEAN DEFAULT false, badge_text TEXT, -- 状态 is_active BOOLEAN DEFAULT true, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """)) # 4. 创建定价配置表 await session.execute(text(""" CREATE TABLE IF NOT EXISTS credit_pricing ( pricing_id UUID PRIMARY KEY, -- 功能类型 feature_type SMALLINT NOT NULL, -- 定价规则(JSON) pricing_rules JSONB NOT NULL, -- 描述 description TEXT, -- 状态 is_active BOOLEAN DEFAULT true, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """)) # 5. 创建赠送记录表(无外键约束) await session.execute(text(""" CREATE TABLE IF NOT EXISTS credit_gifts ( gift_id UUID PRIMARY KEY, user_id UUID NOT NULL, -- 赠送信息 credits INTEGER NOT NULL, gift_type SMALLINT NOT NULL, -- 关联信息(无外键约束) related_user_id UUID, activity_id TEXT, -- 描述 description TEXT, -- 时间戳 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ ); """)) # 6. 创建索引 await session.execute(text(""" -- credit_transactions 索引 CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id); CREATE INDEX IF NOT EXISTS idx_credit_transactions_type ON credit_transactions(transaction_type); CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at); CREATE INDEX IF NOT EXISTS idx_credit_transactions_order_id ON credit_transactions(related_order_id) WHERE related_order_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_transactions_consumption_id ON credit_transactions(related_consumption_id) WHERE related_consumption_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_type ON credit_transactions(user_id, transaction_type); CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created ON credit_transactions(user_id, created_at DESC); -- credit_consumption_logs 索引 CREATE INDEX IF NOT EXISTS idx_credit_consumption_user_id ON credit_consumption_logs(user_id); CREATE INDEX IF NOT EXISTS idx_credit_consumption_feature_type ON credit_consumption_logs(feature_type); CREATE INDEX IF NOT EXISTS idx_credit_consumption_task_status ON credit_consumption_logs(task_status); CREATE INDEX IF NOT EXISTS idx_credit_consumption_created_at ON credit_consumption_logs(created_at); CREATE INDEX IF NOT EXISTS idx_credit_consumption_task_id ON credit_consumption_logs(task_id) WHERE task_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_consumption_ai_job_id ON credit_consumption_logs(ai_job_id) WHERE ai_job_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_consumption_expires_at ON credit_consumption_logs(expires_at) WHERE task_status = 1 AND expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_consumption_user_feature ON credit_consumption_logs(user_id, feature_type); CREATE INDEX IF NOT EXISTS idx_credit_consumption_user_status ON credit_consumption_logs(user_id, task_status); CREATE INDEX IF NOT EXISTS idx_credit_consumption_user_created ON credit_consumption_logs(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_credit_consumption_active ON credit_consumption_logs(task_status, created_at DESC) WHERE task_status IN (1, 2); -- credit_packages 索引 CREATE INDEX IF NOT EXISTS idx_credit_packages_active ON credit_packages(is_active, display_order); -- credit_pricing 索引 CREATE INDEX IF NOT EXISTS idx_credit_pricing_feature_type ON credit_pricing(feature_type, is_active); -- credit_gifts 索引 CREATE INDEX IF NOT EXISTS idx_credit_gifts_user_id ON credit_gifts(user_id); CREATE INDEX IF NOT EXISTS idx_credit_gifts_type ON credit_gifts(gift_type); CREATE INDEX IF NOT EXISTS idx_credit_gifts_created_at ON credit_gifts(created_at); CREATE INDEX IF NOT EXISTS idx_credit_gifts_related_user_id ON credit_gifts(related_user_id) WHERE related_user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_credit_gifts_user_type ON credit_gifts(user_id, gift_type); """)) # 7. 添加表级注释 await session.execute(text(""" COMMENT ON TABLE credit_transactions IS '算力积分流水表 - 应用层保证引用完整性'; COMMENT ON TABLE credit_consumption_logs IS '积分消耗记录表 - 应用层保证引用完整性'; COMMENT ON TABLE credit_packages IS '积分套餐表'; COMMENT ON TABLE credit_pricing IS '积分定价配置表'; COMMENT ON TABLE credit_gifts IS '积分赠送记录表 - 应用层保证引用完整性'; """)) # 8. 添加字段注释(拆分为单独执行,避免 asyncpg 多语句问题) comments = [ # credit_transactions 字段注释 "COMMENT ON COLUMN credit_transactions.transaction_id IS '交易唯一标识'", "COMMENT ON COLUMN credit_transactions.user_id IS '用户 ID - 应用层验证'", "COMMENT ON COLUMN credit_transactions.transaction_type IS '交易类型(1=充值, 2=消耗, 3=退款, 4=赠送, 5=过期, 6=管理员调整)'", "COMMENT ON COLUMN credit_transactions.amount IS '交易金额(正数=增加,负数=减少)'", "COMMENT ON COLUMN credit_transactions.balance_before IS '交易前余额'", "COMMENT ON COLUMN credit_transactions.balance_after IS '交易后余额'", "COMMENT ON COLUMN credit_transactions.related_order_id IS '关联的充值订单 ID - 应用层验证'", "COMMENT ON COLUMN credit_transactions.related_consumption_id IS '关联的消耗记录 ID - 应用层验证'", "COMMENT ON COLUMN credit_transactions.description IS '交易描述'", "COMMENT ON COLUMN credit_transactions.created_at IS '交易创建时间(UTC)'", "COMMENT ON COLUMN credit_transactions.updated_at IS '交易更新时间(UTC)'", # credit_consumption_logs 字段注释 "COMMENT ON COLUMN credit_consumption_logs.consumption_id IS '消耗记录唯一标识'", "COMMENT ON COLUMN credit_consumption_logs.user_id IS '用户 ID - 应用层验证'", "COMMENT ON COLUMN credit_consumption_logs.feature_type IS '功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)'", "COMMENT ON COLUMN credit_consumption_logs.credits_consumed IS '消耗的积分数'", "COMMENT ON COLUMN credit_consumption_logs.task_id IS '任务 ID(字符串,兼容旧系统)'", "COMMENT ON COLUMN credit_consumption_logs.task_status IS '任务状态(1=待处理, 2=处理中, 3=成功, 4=失败, 5=已退款, 6=已过期)'", "COMMENT ON COLUMN credit_consumption_logs.ai_job_id IS '关联的 AI 任务 ID - 应用层验证'", "COMMENT ON COLUMN credit_consumption_logs.resource_id IS '生成的资源 ID'", "COMMENT ON COLUMN credit_consumption_logs.resource_type IS '生成的资源类型'", "COMMENT ON COLUMN credit_consumption_logs.task_params IS '任务参数(JSON 格式)'", "COMMENT ON COLUMN credit_consumption_logs.frozen_at IS '积分冻结时间(UTC)'", "COMMENT ON COLUMN credit_consumption_logs.expires_at IS '积分过期时间(UTC)'", "COMMENT ON COLUMN credit_consumption_logs.created_at IS '记录创建时间(UTC)'", "COMMENT ON COLUMN credit_consumption_logs.updated_at IS '记录更新时间(UTC)'", "COMMENT ON COLUMN credit_consumption_logs.completed_at IS '任务完成时间(UTC)'", "COMMENT ON COLUMN credit_consumption_logs.remark IS '备注信息'", # credit_packages 字段注释 "COMMENT ON COLUMN credit_packages.package_id IS '套餐唯一标识'", "COMMENT ON COLUMN credit_packages.name IS '套餐名称'", "COMMENT ON COLUMN credit_packages.description IS '套餐描述'", "COMMENT ON COLUMN credit_packages.price IS '套餐价格(元)'", "COMMENT ON COLUMN credit_packages.credits IS '基础积分数'", "COMMENT ON COLUMN credit_packages.bonus_credits IS '赠送积分数'", "COMMENT ON COLUMN credit_packages.display_order IS '显示顺序(数字越小越靠前)'", "COMMENT ON COLUMN credit_packages.is_recommended IS '是否推荐'", "COMMENT ON COLUMN credit_packages.badge_text IS '徽章文本(如"热销"、"推荐"等)'", "COMMENT ON COLUMN credit_packages.is_active IS '是否启用'", "COMMENT ON COLUMN credit_packages.created_at IS '创建时间(UTC)'", "COMMENT ON COLUMN credit_packages.updated_at IS '更新时间(UTC)'", # credit_pricing 字段注释 "COMMENT ON COLUMN credit_pricing.pricing_id IS '定价配置唯一标识'", "COMMENT ON COLUMN credit_pricing.feature_type IS '功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成)'", "COMMENT ON COLUMN credit_pricing.pricing_rules IS '定价规则(JSON 格式,包含基础价格、倍率等)'", "COMMENT ON COLUMN credit_pricing.description IS '定价描述'", "COMMENT ON COLUMN credit_pricing.is_active IS '是否启用'", "COMMENT ON COLUMN credit_pricing.created_at IS '创建时间(UTC)'", "COMMENT ON COLUMN credit_pricing.updated_at IS '更新时间(UTC)'", # credit_gifts 字段注释 "COMMENT ON COLUMN credit_gifts.gift_id IS '赠送记录唯一标识'", "COMMENT ON COLUMN credit_gifts.user_id IS '接收赠送的用户 ID - 应用层验证'", "COMMENT ON COLUMN credit_gifts.credits IS '赠送的积分数'", "COMMENT ON COLUMN credit_gifts.gift_type IS '赠送类型(1=注册赠送, 2=邀请赠送, 3=活动赠送, 4=补偿赠送, 5=管理员赠送)'", "COMMENT ON COLUMN credit_gifts.related_user_id IS '关联的用户 ID(如邀请人)- 应用层验证'", "COMMENT ON COLUMN credit_gifts.activity_id IS '活动 ID'", "COMMENT ON COLUMN credit_gifts.description IS '赠送描述'", "COMMENT ON COLUMN credit_gifts.created_at IS '赠送时间(UTC)'", "COMMENT ON COLUMN credit_gifts.expires_at IS '积分过期时间(UTC)'", ] for comment in comments: await session.execute(text(comment)) # 9. 创建触发器 await session.execute(text(""" CREATE TRIGGER update_credit_transactions_updated_at BEFORE UPDATE ON credit_transactions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_credit_consumption_logs_updated_at BEFORE UPDATE ON credit_consumption_logs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_credit_packages_updated_at BEFORE UPDATE ON credit_packages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_credit_pricing_updated_at BEFORE UPDATE ON credit_pricing FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); """)) await session.commit() async def downgrade(session: AsyncSession): """降级数据库 - 删除积分服务相关表""" await session.execute(text(""" DROP TABLE IF EXISTS credit_gifts CASCADE; DROP TABLE IF EXISTS credit_pricing CASCADE; DROP TABLE IF EXISTS credit_packages CASCADE; DROP TABLE IF EXISTS credit_consumption_logs CASCADE; DROP TABLE IF EXISTS credit_transactions CASCADE; """)) await session.commit() __migration__ = { "version": "008", "description": "创建积分服务相关表(无外键约束,应用层保证引用完整性)", "upgrade": upgrade, "downgrade": downgrade } ``` --- ## 定时任务配置 ### 1. 查询积分余额 **端点**: ``` GET /api/v1/credits/balance ``` **请求头**: ``` Authorization: Bearer ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "balance": 150, "totalEarned": 200, "totalConsumed": 50, "totalRechargedAmount": 20.0 } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | balance | integer | 当前余额 | | totalEarned | integer | 累计获得积分 | | totalConsumed | integer | 累计消耗积分 | | totalRechargedAmount | number | 累计充值金额(元) | --- ### 2. 查询积分流水 **端点**: ``` GET /api/v1/credits/transactions ``` **查询参数**: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | type | integer | 否 | 交易类型(1=充值, 2=消耗, 3=退款, 4=赠送, 5=过期, 6=管理员调整) | | page | integer | 否 | 页码(默认 1) | | pageSize | integer | 否 | 每页数量(默认 20,最大 100) | **示例**: ``` GET /api/v1/credits/transactions?type=1&page=1&pageSize=20 ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "items": [ { "transactionId": "550e8400-e29b-41d4-a716-4466554400000", "transactionType": 1, "transactionTypeName": "recharge", "amount": 100, "balanceBefore": 50, "balanceAfter": 150, "description": "充值获得积分", "createdAt": "2025-01-14T10:00:00Z" } ], "total": 100, "page": 1, "pageSize": 20, "totalPages": 5 } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | transactionId | string | 交易 ID | | transactionType | integer | 交易类型(枚举值) | | transactionTypeName | string | 交易类型名称 | | amount | integer | 交易金额(正数=增加,负数=减少) | | balanceBefore | integer | 交易前余额 | | balanceAfter | integer | 交易后余额 | | description | string | 交易描述 | | createdAt | string | 交易创建时间(ISO 8601) | --- ### 3. 查询消耗记录 **端点**: ``` GET /api/v1/credits/consumption ``` **查询参数**: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | featureType | integer | 否 | 功能类型(1=图片生成, 2=视频生成, 3=文本处理, 4=音频生成, 5=音效生成, 6=配音生成, 7=字幕生成, 8=资源生成, 9=分镜脚本生成, 10=剧本生成) | | taskStatus | integer | 否 | 任务状态(1=待处理, 2=处理中, 3=成功, 4=失败, 5=已退款, 6=已过期) | | page | integer | 否 | 页码(默认 1) | | pageSize | integer | 否 | 每页数量(默认 20,最大 100) | **示例**: ``` GET /api/v1/credits/consumption?featureType=1&page=1&pageSize=20 ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "items": [ { "consumptionId": "550e8400-e29b-41d4-a716-4466554400000", "featureType": 1, "featureTypeName": "image_generation", "creditsConsumed": 10, "taskId": "task_123", "taskStatus": 3, "taskStatusName": "success", "resourceId": "550e8400-e29b-41d4-a716-4466554400001", "resourceType": "image", "createdAt": "2025-01-14T10:00:00Z", "completedAt": "2025-01-14T10:01:00Z" } ], "total": 50, "page": 1, "pageSize": 20, "totalPages": 3 } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | consumptionId | string | 消耗记录 ID | | featureType | integer | 功能类型(枚举值) | | featureTypeName | string | 功能类型名称 | | creditsConsumed | integer | 消耗的积分数 | | taskId | string | 任务 ID | | taskStatus | integer | 任务状态(枚举值) | | taskStatusName | string | 任务状态名称 | | resourceId | string | 生成的资源 ID | | resourceType | string | 生成的资源类型 | | createdAt | string | 记录创建时间(ISO 8601) | | completedAt | string | 任务完成时间(ISO 8601) | --- ### 4. 计算所需积分 **端点**: ``` POST /api/v1/credits/calculate ``` **请求头**: ``` Authorization: Bearer Content-Type: application/json ``` **请求体**: ```json { "featureType": 1, "params": { "modelName": "dall-e-3", "quality": "hd", "width": 1024, "height": 1024 } } ``` **请求参数说明**: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | featureType | integer | 是 | 功能类型(枚举值) | | params | object | 是 | 任务参数 | | params.modelName | string | 是 | 模型名称 | | params.quality | string | 否 | 质量类型(sd, hd) | | params.width | integer | 否 | 宽度(图片生成) | | params.height | integer | 否 | 高度(图片生成) | | params.duration | integer | 否 | 时长(视频/音频生成,单位:秒) | | params.videoType | string | 否 | 视频类型(text2video, img2video, keyframe, fusion, replace) | **响应**: ```json { "code": 200, "message": "Success", "data": { "credits": 30, "featureType": 1, "featureTypeName": "image_generation", "params": { "modelName": "dall-e-3", "quality": "hd", "width": 1024, "height": 1024 } } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | credits | integer | 所需积分数 | | featureType | integer | 功能类型(枚举值) | | featureTypeName | string | 功能类型名称 | | params | object | 任务参数 | --- ### 5. 获取充值套餐 **端点**: ``` GET /api/v1/credits/packages ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "items": [ { "packageId": "550e8400-e29b-41d4-a716-4466554400000", "name": "入门套餐", "description": "适合新手用户", "price": 9.9, "credits": 100, "bonusCredits": 10, "displayOrder": 1, "isRecommended": false, "badgeText": null, "isActive": true }, { "packageId": "550e8400-e29b-41d4-a716-4466554400001", "name": "超值套餐", "description": "性价比最高", "price": 49.9, "credits": 600, "bonusCredits": 100, "displayOrder": 2, "isRecommended": true, "badgeText": "最划算", "isActive": true } ], "total": 2, "page": 1, "pageSize": 20, "totalPages": 1 } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | packageId | string | 套餐 ID | | name | string | 套餐名称 | | description | string | 套餐描述 | | price | number | 套餐价格(元) | | credits | integer | 基础积分数 | | bonusCredits | integer | 赠送积分数 | | displayOrder | integer | 显示顺序 | | isRecommended | boolean | 是否推荐 | | badgeText | string | 徽章文本 | | isActive | boolean | 是否启用 | --- ### 6. 创建充值订单 **端点**: ``` POST /api/v1/credits/recharge ``` **请求头**: ``` Authorization: Bearer Content-Type: application/json ``` **请求体**: ```json { "packageId": "550e8400-e29b-41d4-a716-4466554400000", "paymentMethod": "wechat" } ``` **请求参数说明**: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | packageId | string | 是 | 套餐 ID | | paymentMethod | integer | 是 | 支付方式(1=微信, 2=支付宝) | **响应**: ```json { "code": 200, "message": "Success", "data": { "orderId": "550e8400-e29b-41d4-a716-4466554400000", "orderNo": "RE2025011400001", "amount": 9.9, "credits": 100, "bonusCredits": 10, "paymentMethod": 1, "paymentMethodName": "wechat", "paymentStatus": 1, "paymentStatusName": "pending", "expiredAt": "2025-01-14T11:00:00Z", "createdAt": "2025-01-14T10:00:00Z" } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | orderId | string | 订单 ID | | orderNo | string | 订单编号 | | amount | number | 订单金额(元) | | credits | integer | 基础积分数 | | bonusCredits | integer | 赠送积分数 | | paymentMethod | integer | 支付方式(枚举值) | | paymentMethodName | string | 支付方式名称 | | paymentStatus | integer | 支付状态(枚举值) | | paymentStatusName | string | 支付状态名称 | | expiredAt | string | 订单过期时间(ISO 8601) | | createdAt | string | 订单创建时间(ISO 8601) | --- ### 7. 查询订单详情 **端点**: ``` GET /api/v1/credits/orders/{orderId} ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "orderId": "550e8400-e29b-41d4-a716-4466554400000", "orderNo": "RE2025011400001", "packageId": "550e8400-e29b-41d4-a716-4466554400000", "amount": 9.9, "credits": 100, "bonusCredits": 10, "paymentMethod": 1, "paymentMethodName": "wechat", "paymentStatus": 2, "paymentStatusName": "paid", "transactionId": "wx2025011400001", "paidAt": "2025-01-14T10:05:00Z", "createdAt": "2025-01-14T10:00:00Z" } } ``` --- ### 8. 支付回调 **端点**: ``` POST /api/v1/credits/payment/callback ``` **请求头**: ``` Content-Type: application/json ``` **请求体**(微信支付回调示例): ```json { "orderNo": "RE2025011400001", "transactionId": "wx2025011400001", "paymentMethod": 1, "status": "success", "amount": 9.9, "timestamp": "2025-01-14T10:05:00Z", "signature": "xxx" } ``` **响应**: ```json { "code": 200, "message": "Success", "data": null } ``` --- ### 9. 超时自动退款(定时任务) **端点**: ``` POST /api/v1/credits/expire-pending ``` **请求头**: ``` Authorization: Bearer Content-Type: application/json ``` **请求体**: ```json { "timeoutHours": 24 } ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "expiredCount": 5, "timeoutHours": 24 } } ``` **字段说明**: | 字段 | 类型 | 说明 | |------|------|------| | expiredCount | integer | 退款的记录数 | | timeoutHours | integer | 超时时间(小时) | --- ### 10. 批量退款(管理员) **端点**: ``` POST /api/v1/credits/refund-batch ``` **请求头**: ``` Authorization: Bearer Content-Type: application/json ``` **请求体**: ```json { "taskStatus": "failed", "reason": "系统维护导致任务失败" } ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "refundedCount": 10, "taskStatus": "failed", "reason": "系统维护导致任务失败" } } ``` --- ## 错误码 | 错误码 | 说明 | |--------|------| | 40001 | 积分数量必须大于 0 | | 40002 | 积分不足 | | 40401 | 消耗记录不存在 | | 40402 | 套餐不存在 | | 40403 | 用户不存在 | | 40901 | 该任务已退款 | | 40301 | 配额不足 | --- --- ## 数据访问层 ### CreditRepository 类 ```python # app/repositories/credit_repository.py from typing import Optional, List from uuid import UUID from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy import select, and_ from datetime import datetime, timedelta from app.models.credit import ( CreditTransaction, CreditConsumptionLog, CreditPackage, CreditPricing, CreditGift ) from app.models.user import User class CreditRepository: """积分数据访问层""" def __init__(self, session: AsyncSession): self.session = session # ==================== 用户相关 ==================== async def get_user(self, user_id: UUID) -> Optional[User]: """获取用户""" result = await self.session.execute( select(User).where(User.user_id == user_id) ) return result.scalar_one_or_none() async def user_exists(self, user_id: UUID) -> bool: """检查用户是否存在""" result = await self.session.execute( select(User.user_id).where(User.user_id == user_id).limit(1) ) return result.scalar_one_or_none() is not None # ==================== 交易记录 ==================== async def create_transaction(self, transaction: CreditTransaction) -> CreditTransaction: """创建交易记录""" self.session.add(transaction) await self.session.flush() return transaction async def get_transactions( self, user_id: UUID, transaction_type: Optional[int] = None, limit: int = 50, offset: int = 0 ) -> List[CreditTransaction]: """查询积分流水""" query = select(CreditTransaction).where( CreditTransaction.user_id == user_id ) if transaction_type is not None: query = query.where(CreditTransaction.transaction_type == transaction_type) query = query.order_by(CreditTransaction.created_at.desc()) query = query.limit(limit).offset(offset) result = await self.session.execute(query) return result.scalars().all() async def count_transactions( self, user_id: UUID, transaction_type: Optional[int] = None ) -> int: """统计交易记录数""" from sqlalchemy import func query = select(func.count(CreditTransaction.transaction_id)).where( CreditTransaction.user_id == user_id ) if transaction_type is not None: query = query.where(CreditTransaction.transaction_type == transaction_type) result = await self.session.execute(query) return result.scalar_one() # ==================== 消耗记录 ==================== async def create_consumption_log(self, log: CreditConsumptionLog) -> CreditConsumptionLog: """创建消耗记录""" self.session.add(log) await self.session.flush() return log async def get_consumption_log(self, consumption_id: UUID) -> Optional[CreditConsumptionLog]: """获取消耗记录""" return await self.session.get(CreditConsumptionLog, consumption_id) async def get_consumption_logs( self, user_id: UUID, feature_type: Optional[int] = None, task_status: Optional[int] = None, limit: int = 50, offset: int = 0 ) -> List[CreditConsumptionLog]: """查询消耗记录""" query = select(CreditConsumptionLog).where( CreditConsumptionLog.user_id == user_id ) if feature_type is not None: query = query.where(CreditConsumptionLog.feature_type == feature_type) if task_status is not None: query = query.where(CreditConsumptionLog.task_status == task_status) query = query.order_by(CreditConsumptionLog.created_at.desc()) query = query.limit(limit).offset(offset) result = await self.session.execute(query) return result.scalars().all() async def count_consumption_logs( self, user_id: UUID, feature_type: Optional[int] = None, task_status: Optional[int] = None ) -> int: """统计消耗记录数""" from sqlalchemy import func query = select(func.count(CreditConsumptionLog.consumption_id)).where( CreditConsumptionLog.user_id == user_id ) if feature_type is not None: query = query.where(CreditConsumptionLog.feature_type == feature_type) if task_status is not None: query = query.where(CreditConsumptionLog.task_status == task_status) result = await self.session.execute(query) return result.scalar_one() async def get_expired_pending_consumptions(self, timeout_hours: int = 24) -> List[CreditConsumptionLog]: """获取超时的 pending 消耗记录""" timeout_threshold = datetime.now(timezone.utc) - timedelta(hours=timeout_hours) result = await self.session.execute( select(CreditConsumptionLog).where( and_( CreditConsumptionLog.task_status == 1, CreditConsumptionLog.expires_at < timeout_threshold ) ) ) return result.scalars().all() async def get_consumption_logs_by_status(self, task_status: int) -> List[CreditConsumptionLog]: """按状态获取消耗记录""" result = await self.session.execute( select(CreditConsumptionLog).where( CreditConsumptionLog.task_status == task_status ) ) return result.scalars().all() # ==================== 定价配置 ==================== async def get_pricing(self, feature_type: int) -> Optional[CreditPricing]: """获取定价配置""" result = await self.session.execute( select(CreditPricing).where( and_( CreditPricing.feature_type == feature_type, CreditPricing.is_active == True ) ) ) return result.scalar_one_or_none() # ==================== 套餐管理 ==================== async def get_active_packages(self) -> List[CreditPackage]: """获取激活的套餐列表""" result = await self.session.execute( select(CreditPackage).where( CreditPackage.is_active == True ).order_by(CreditPackage.display_order) ) return result.scalars().all() async def get_package(self, package_id: UUID) -> Optional[CreditPackage]: """获取套餐详情""" return await self.session.get(CreditPackage, package_id) # ==================== 赠送记录 ==================== async def create_gift(self, gift: CreditGift) -> CreditGift: """创建赠送记录""" self.session.add(gift) await self.session.flush() return gift ``` --- ## 服务实现 ### Celery Beat 定时任务 ```python # app/tasks/credit_tasks.py from celery import shared_task from sqlalchemy import text from app.services.credit_service import CreditService from app.core.database import async_session @shared_task def expire_pending_consumptions(): """定时任务:超时自动退款(每小时执行一次)""" async with async_session() as session: credit_service = CreditService(session) try: count = await credit_service.expire_pending_consumptions(timeout_hours=24) return f"已退款 {count} 条超时记录" except Exception as e: return f"任务执行失败: {str(e)}" @shared_task def cleanup_old_transactions(): """定时任务:清理旧交易记录(每天执行一次)""" cutoff_date = datetime.now(timezone.utc) - timedelta(days=90) async with async_session() as session: try: await session.execute( text(""" DELETE FROM credit_transactions WHERE created_at < :cutoff_date AND transaction_type IN (2, 3, 5) """), {"cutoff_date": cutoff_date} ) await session.commit() return "清理完成" except Exception as e: return f"清理失败: {str(e)}" ``` ### Celery 配置 ```python # app/core/celery.py from celery import Celery from celery.schedules import crontab celery_app = Celery('jointo') celery_app.conf.beat_schedule = { # 每小时执行一次超时退款 'expire-pending-consumptions': { 'task': 'app.tasks.credit_tasks.expire_pending_consumptions', 'schedule': crontab(minute=0), # 每小时的第 0 分钟 }, # 每天凌晨 2 点清理旧记录 'cleanup-old-transactions': { 'task': 'app.tasks.credit_tasks.cleanup_old_transactions', 'schedule': crontab(hour=2, minute=0), # 每天凌晨 2 点 }, } ``` --- ## 业务流程 ### 充值流程 ``` 用户选择套餐 → 创建充值订单 → 调用支付接口 → 用户支付 → 接收支付回调 → 验证签名 → 增加积分 → 记录流水 → 通知用户 ``` ### 消耗流程 ``` 用户发起生成任务 → 计算所需积分 → 检查余额 → 预扣积分 → 调用 AI 生成 → 生成成功:确认扣减 / 生成失败:退还积分 ``` ### 退款流程 ``` 任务失败 → 查询消耗记录 → 退还积分 → 更新任务状态 → 记录流水 ``` --- **文档版本**:v1.0 **最后更新**:2025-01-14