解决冲突
This commit is contained in:
+548
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||
};
|
||||
|
||||
const userScopeOptions = [
|
||||
@@ -49,6 +50,7 @@ const recordTypeOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
];
|
||||
|
||||
const creditSubjectOptions = [
|
||||
@@ -59,6 +61,7 @@ const creditSubjectOptions = [
|
||||
{ value: 'analysis', label: '分析积分' },
|
||||
{ value: 'split', label: '切片积分' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
{ value: 'recharge', label: '充值积分' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
@@ -79,6 +82,7 @@ const chargeKindOptions = [
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
{ value: 'video_split', label: '视频切片' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
];
|
||||
|
||||
const sourceModuleOptions = [
|
||||
@@ -89,6 +93,7 @@ const sourceModuleOptions = [
|
||||
{ value: 'shot_replicate', label: '拆镜复刻' },
|
||||
{ value: 'admin', label: '后台管理' },
|
||||
{ value: 'payment', label: '支付充值' },
|
||||
{ value: 'team', label: '团队管理' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
@@ -127,6 +132,7 @@ const billingSceneOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ APP_NAME=VideoGen API
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
SECRET_KEY=local-dev-secret-key-not-for-production
|
||||
APP_ENV=test
|
||||
|
||||
# Database (PostgreSQL)
|
||||
#DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
@@ -99,6 +100,7 @@ async def transfer_credits(
|
||||
current_user.id,
|
||||
req.target_user_id,
|
||||
req.amount,
|
||||
req.direction or "increase",
|
||||
req.description,
|
||||
)
|
||||
return {"message": "ok"}
|
||||
@@ -261,9 +263,10 @@ async def list_team_credit_records(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|admin)$"),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
phone: str | None = Query(None, description="按手机号搜索"),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -273,13 +276,97 @@ async def list_team_credit_records(
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
|
||||
# 如果传了 phone,先找到对应的 user_id
|
||||
resolved_user_id = user_id
|
||||
if phone and not user_id:
|
||||
phone_result = await db.execute(
|
||||
select(User.id).where(
|
||||
User.team_id == team.id,
|
||||
User.phone == phone,
|
||||
User.is_active.is_(True),
|
||||
).limit(1)
|
||||
)
|
||||
resolved_user_id = phone_result.scalar_one_or_none()
|
||||
if not resolved_user_id:
|
||||
return {"items": [], "total": 0, "summary": {}}
|
||||
|
||||
return await list_admin_credit_records(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
team_id=team.id,
|
||||
user_id=user_id,
|
||||
user_id=resolved_user_id,
|
||||
record_type=record_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
|
||||
# ── 团队积分导出 Excel ──────────────────────────────────
|
||||
@router.get("/credit-records/export")
|
||||
async def export_team_credit_records(
|
||||
user_id: str | None = Query(None),
|
||||
phone: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出团队积分变动记录为 Excel(仅管理人)。"""
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
|
||||
resolved_user_id = user_id
|
||||
if phone and not user_id:
|
||||
phone_result = await db.execute(
|
||||
select(User.id).where(
|
||||
User.team_id == team.id,
|
||||
User.phone == phone,
|
||||
User.is_active.is_(True),
|
||||
).limit(1)
|
||||
)
|
||||
resolved_user_id = phone_result.scalar_one_or_none()
|
||||
|
||||
# 拉取全部记录(不分页)
|
||||
result = await list_admin_credit_records(
|
||||
db,
|
||||
page=1,
|
||||
page_size=10000,
|
||||
team_id=team.id,
|
||||
user_id=resolved_user_id,
|
||||
record_type=record_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# 生成 CSV(兼容 Excel 打开)
|
||||
import csv
|
||||
import io
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
|
||||
for item in result.get("items", []):
|
||||
writer.writerow([
|
||||
item.get("username") or "-",
|
||||
item.get("phone") or "-",
|
||||
item.get("record_type_label") or item.get("type") or "-",
|
||||
item.get("amount", 0),
|
||||
item.get("balance_after", 0),
|
||||
item.get("description") or "-",
|
||||
item.get("created_at") or "-",
|
||||
])
|
||||
|
||||
from starlette.responses import StreamingResponse
|
||||
output.seek(0)
|
||||
filename = f"team_credits_{team.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "VideoGen API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
# 运行环境,用于生成火山私域真人素材 ProjectName:local/test/online。
|
||||
APP_ENV: str = "local"
|
||||
SECRET_KEY: str = "change-me"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
|
||||
|
||||
@@ -5,6 +5,7 @@ class CreditRecordType(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
CONSUME = "consume"
|
||||
REFUND = "refund"
|
||||
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
|
||||
|
||||
|
||||
class CreditRecordOwnerType(str, Enum):
|
||||
@@ -30,6 +31,7 @@ class CreditRecordChargeKind(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
TEAM_INTERNAL = "team_internal"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -42,6 +44,7 @@ class CreditRecordSubject(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
TEAM_INTERNAL = "team_internal"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -62,6 +65,7 @@ class CreditRecordSourceModule(str, Enum):
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
PAYMENT = "payment"
|
||||
ADMIN = "admin"
|
||||
TEAM = "team"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -104,6 +108,7 @@ class CreditRecordBillingScene(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
REFUND = "refund"
|
||||
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -111,6 +116,7 @@ CREDIT_RECORD_TYPE_LABELS = {
|
||||
CreditRecordType.RECHARGE.value: "充值",
|
||||
CreditRecordType.CONSUME.value: "消费",
|
||||
CreditRecordType.REFUND.value: "回退",
|
||||
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
|
||||
}
|
||||
|
||||
CREDIT_RECORD_SUBJECT_LABELS = {
|
||||
@@ -122,6 +128,7 @@ CREDIT_RECORD_SUBJECT_LABELS = {
|
||||
CreditRecordSubject.RECHARGE.value: "充值积分",
|
||||
CreditRecordSubject.REFUND.value: "回退积分",
|
||||
CreditRecordSubject.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordSubject.TEAM_INTERNAL.value: "团队内部转移",
|
||||
CreditRecordSubject.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -136,6 +143,7 @@ CREDIT_RECORD_CHARGE_KIND_LABELS = {
|
||||
CreditRecordChargeKind.RECHARGE.value: "充值",
|
||||
CreditRecordChargeKind.REFUND.value: "回退",
|
||||
CreditRecordChargeKind.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordChargeKind.TEAM_INTERNAL.value: "团队内部转移",
|
||||
CreditRecordChargeKind.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ CREDIT_RECORD_SOURCE_MODULE_LABELS = {
|
||||
CreditRecordSourceModule.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
CreditRecordSourceModule.PAYMENT.value: "支付充值",
|
||||
CreditRecordSourceModule.ADMIN.value: "后台管理",
|
||||
CreditRecordSourceModule.TEAM.value: "团队管理",
|
||||
CreditRecordSourceModule.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -189,5 +198,6 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
|
||||
CreditRecordBillingScene.RECHARGE.value: "充值",
|
||||
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordBillingScene.REFUND.value: "回退",
|
||||
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
|
||||
CreditRecordBillingScene.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -5,15 +5,9 @@ from enum import Enum
|
||||
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
|
||||
|
||||
# ProjectName 由服务层按 {env}-{user_id}-{项目名slug} 生成并快照到 private_portrait_projects.remote_project_name。
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_LOCAL = "local"
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_TEST = "test"
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_ONLINE = "online"
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_VALUES = {
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_LOCAL,
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_TEST,
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_ONLINE,
|
||||
}
|
||||
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default"
|
||||
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace"
|
||||
PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time"
|
||||
@@ -132,9 +126,8 @@ class PrivatePortraitEventType(str, Enum):
|
||||
PROJECT_CREATE = "PROJECT_CREATE"
|
||||
PROJECT_UPDATE = "PROJECT_UPDATE"
|
||||
PROJECT_DELETE = "PROJECT_DELETE"
|
||||
PROJECT_REMOTE_NAME_LOCKED = "PROJECT_REMOTE_NAME_LOCKED"
|
||||
|
||||
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
|
||||
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
|
||||
VALIDATE_CALLBACK_SUCCESS = "VALIDATE_CALLBACK_SUCCESS"
|
||||
VALIDATE_CALLBACK_FAILED = "VALIDATE_CALLBACK_FAILED"
|
||||
|
||||
@@ -21,6 +21,7 @@ class TeamMemberOut(BaseModel):
|
||||
class ManagerTransferRequest(BaseModel):
|
||||
target_user_id: str = Field(..., description="接收积分的成员用户ID")
|
||||
amount: float = Field(gt=0, description="转账积分数量(正数)")
|
||||
direction: str = Field(default="increase", pattern="^(increase|decrease)$", description="increase=管理人转给成员;decrease=从成员扣减回管理人")
|
||||
description: str | None = Field(None, max_length=256, description="转账说明")
|
||||
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ async def list_admin_credit_records(
|
||||
|
||||
summary_query = select(
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
|
||||
func.count(CreditRecord.id),
|
||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||
|
||||
@@ -179,12 +179,14 @@ async def deduct_credits(
|
||||
biz_key: str | None = None,
|
||||
refund_for_biz_key: str | None = None,
|
||||
record_meta: CreditRecordMeta | dict | None = None,
|
||||
record_type: str = "consume",
|
||||
) -> User:
|
||||
"""扣减用户积分,并写入消费流水。
|
||||
|
||||
并发安全点:
|
||||
- 先用 SELECT ... FOR UPDATE 锁住 users 行,避免余额覆盖。
|
||||
- biz_key 不为空时,作为正式业务幂等键;重复调用直接返回当前用户,不重复扣。
|
||||
- record_type: 流水类型,默认 "consume";团队内部流转传 "team_internal"。
|
||||
"""
|
||||
amount = round(float(amount or 0), 2)
|
||||
if amount <= 0:
|
||||
@@ -218,7 +220,7 @@ async def deduct_credits(
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
type="consume",
|
||||
type=record_type,
|
||||
amount=-amount,
|
||||
balance_after=user.credits,
|
||||
description=description,
|
||||
|
||||
@@ -4,6 +4,8 @@ import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.private_portrait import (
|
||||
ARK_PRIVATE_PORTRAIT_HOST,
|
||||
@@ -25,6 +27,29 @@ class ArkPrivateAssetClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ArkPrivateAssetRemoteError(ArkPrivateAssetClientError):
|
||||
def __init__(self, *, action: str, code: str, message: str, request_id: str | None = None, raw: dict[str, Any] | None = None):
|
||||
self.action = action
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.request_id = request_id
|
||||
self.raw = raw or {}
|
||||
super().__init__(f"{action} 调用失败:{code} {message}" + (f" RequestId={request_id}" if request_id else ""))
|
||||
|
||||
|
||||
def _remote_error_http_status(code: str) -> int:
|
||||
normalized = (code or "").lower()
|
||||
if "notfound" in normalized or normalized.startswith("not_found"):
|
||||
return 404
|
||||
if "invalid" in normalized or "missing" in normalized or "unsupported" in normalized or "limit" in normalized:
|
||||
return 400
|
||||
if "unauthorized" in normalized or "forbidden" in normalized or "permission" in normalized or "denied" in normalized:
|
||||
return 403
|
||||
if "throttl" in normalized or "rate" in normalized:
|
||||
return 429
|
||||
return 502
|
||||
|
||||
|
||||
class ArkPrivateAssetClient:
|
||||
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
|
||||
@@ -107,6 +132,24 @@ class ArkPrivateAssetClient:
|
||||
remote_request_id=result.get("RequestId") or result.get("request_id"),
|
||||
)
|
||||
return result
|
||||
except ArkPrivateAssetRemoteError as exc:
|
||||
log_remote_api_event(
|
||||
domain=DOMAIN,
|
||||
remote_action=action_value,
|
||||
event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
|
||||
request=payload,
|
||||
response=exc.raw,
|
||||
remote_request_id=exc.request_id,
|
||||
remote_code=exc.code,
|
||||
remote_message=exc.message,
|
||||
)
|
||||
if self.for_celery:
|
||||
raise
|
||||
raise HTTPException(status_code=_remote_error_http_status(exc.code), detail={"message": exc.message, "code": exc.code, "request_id": exc.request_id}) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log_remote_api_event(
|
||||
domain=DOMAIN,
|
||||
@@ -117,7 +160,9 @@ class ArkPrivateAssetClient:
|
||||
request=payload,
|
||||
remote_message=str(exc),
|
||||
)
|
||||
raise
|
||||
if self.for_celery:
|
||||
raise
|
||||
raise HTTPException(status_code=502, detail=f"火山私域素材接口调用失败:{self._exception_message(exc)}") from exc
|
||||
|
||||
def _call_sync(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -135,10 +180,11 @@ class ArkPrivateAssetClient:
|
||||
credentials,
|
||||
10,
|
||||
60,
|
||||
"https",
|
||||
)
|
||||
# volcengine SDK 的 ApiInfo.query 必须是 dict,不能传 "Action=xxx&Version=xxx" 字符串。
|
||||
# 否则 Service.merge() 会按 dict 下标读取字符串,触发:
|
||||
# TypeError: string indices must be integers, not 'str'。
|
||||
# volcengine SDK 签名时要求 body 是 bytes。
|
||||
# ServiceInfo 默认 scheme='http',这里必须显式传 https,避免请求走 http://host:80。
|
||||
api_info = {
|
||||
action.value: ApiInfo(
|
||||
"POST",
|
||||
@@ -149,20 +195,22 @@ class ArkPrivateAssetClient:
|
||||
)
|
||||
}
|
||||
service = Service(service_info, api_info)
|
||||
|
||||
# volcengine SDK 在签名阶段会对 request.body 做 hashlib.sha256(body)。
|
||||
# Python 3 下 hashlib.sha256 只能接收 bytes/bytearray,不能接收 dict 或 str。
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
raw = service.json(action.value, {}, body)
|
||||
try:
|
||||
raw = service.json(action.value, {}, body)
|
||||
except Exception as exc:
|
||||
resp = self._extract_response_from_exception(exc)
|
||||
if resp:
|
||||
self._raise_remote_error_if_any(action.value, resp)
|
||||
message = self._exception_message(exc)
|
||||
if "ConnectTimeout" in message or "timed out" in message or "Connection" in message:
|
||||
raise ArkPrivateAssetClientError(f"{action.value} 连接火山私域素材接口失败:{message}") from exc
|
||||
raise ArkPrivateAssetClientError(f"{action.value} 请求火山私域素材接口异常:{message}") from exc
|
||||
|
||||
resp = self._normalize_response(raw)
|
||||
self._raise_remote_error_if_any(action.value, resp)
|
||||
metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None
|
||||
error = metadata.get("Error") if isinstance(metadata, dict) else None
|
||||
request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None
|
||||
if error:
|
||||
code = error.get("Code") or "ArkPrivateAssetError"
|
||||
message = error.get("Message") or str(error)
|
||||
raise ArkPrivateAssetClientError(f"{action.value} 调用失败:{code} {message}")
|
||||
if isinstance(resp, dict) and isinstance(resp.get("Result"), dict):
|
||||
result = dict(resp["Result"])
|
||||
if request_id:
|
||||
@@ -174,6 +222,45 @@ class ArkPrivateAssetClient:
|
||||
return resp
|
||||
return {"raw": resp, "RequestId": request_id}
|
||||
|
||||
@classmethod
|
||||
def _raise_remote_error_if_any(cls, action: str, resp: dict[str, Any]) -> None:
|
||||
metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None
|
||||
error = metadata.get("Error") if isinstance(metadata, dict) else None
|
||||
if not error:
|
||||
return
|
||||
request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None
|
||||
code = error.get("Code") or "ArkPrivateAssetError"
|
||||
message = error.get("Message") or str(error)
|
||||
raise ArkPrivateAssetRemoteError(action=action, code=code, message=message, request_id=request_id, raw=resp)
|
||||
|
||||
@classmethod
|
||||
def _extract_response_from_exception(cls, exc: Exception) -> dict[str, Any] | None:
|
||||
# volcengine SDK 在 HTTP 非 2xx 时会 raise Exception(resp.text.encode("utf-8")),这里把 bytes JSON 还原,避免业务错误变 500。
|
||||
if not exc.args:
|
||||
return None
|
||||
raw = exc.args[0]
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
raw = raw.decode("utf-8", errors="ignore")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
raw = raw.strip()
|
||||
if not raw or not raw.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if exc.args:
|
||||
raw = exc.args[0]
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
return str(raw)
|
||||
return str(exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_response(raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
|
||||
@@ -52,6 +52,17 @@ def _loads(data: str | None) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
message = detail.get("message") or detail.get("detail") or detail
|
||||
return str(message)
|
||||
return str(detail)
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _public_url(url: str) -> str:
|
||||
if url.startswith(("http://", "https://")):
|
||||
return url
|
||||
@@ -173,13 +184,16 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||||
session.raw_response_json = _json(resp)
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
session.error_message = str(exc)
|
||||
session.error_message = _exception_message(exc)
|
||||
await db.flush()
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
@@ -248,11 +262,13 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, exc=exc)
|
||||
await refresh_project_counters(db, [session.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
session.error_message = str(exc)
|
||||
session.error_message = _exception_message(exc)
|
||||
await db.flush()
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
@@ -310,11 +326,14 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
asset.error_message = str(exc)
|
||||
asset.error_message = _exception_message(exc)
|
||||
await db.flush()
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc)
|
||||
raise
|
||||
@@ -384,6 +403,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
asset.error_message = remote_resp.get("ErrorMessage") or remote_resp.get("error_message") or "素材入库失败"
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
|
||||
@@ -438,6 +458,7 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) ->
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name})
|
||||
return asset
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_PROJECT_ENV_VALUES,
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitEventSource,
|
||||
@@ -28,26 +27,12 @@ DOMAIN = "private_portrait"
|
||||
|
||||
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,避免火山 ProjectName 字符限制不明确导致失败。
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip("_-")
|
||||
return (value[:max_length] or "project")
|
||||
|
||||
|
||||
def get_private_portrait_env() -> str:
|
||||
env = str(getattr(settings, "APP_ENV", "local") or "local").strip().lower()
|
||||
if env not in PRIVATE_PORTRAIT_PROJECT_ENV_VALUES:
|
||||
env = "local"
|
||||
return env
|
||||
|
||||
|
||||
def build_remote_project_name(*, user_id: str, project_name: str) -> tuple[str, str]:
|
||||
slug = _safe_slug(project_name)
|
||||
user_part = _safe_slug(user_id, max_length=40)
|
||||
env = get_private_portrait_env()
|
||||
return f"{env}-{user_part}-{slug}"[:256], slug
|
||||
|
||||
|
||||
def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
|
||||
return PrivatePortraitProjectOut(
|
||||
id=project.id,
|
||||
@@ -81,13 +66,13 @@ async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -
|
||||
|
||||
|
||||
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject:
|
||||
remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=payload.name)
|
||||
slug = _safe_slug(payload.name)
|
||||
project = PrivatePortraitProject(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
name=payload.name.strip(),
|
||||
name_slug=slug,
|
||||
remote_project_name=remote_project_name,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
description=payload.description,
|
||||
status=PrivatePortraitProjectStatus.ACTIVE.value,
|
||||
)
|
||||
@@ -106,13 +91,6 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
|
||||
return project
|
||||
|
||||
|
||||
async def _project_has_remote_resources(db: AsyncSession, *, project_id: str) -> bool:
|
||||
session_count = (await db.execute(select(func.count(PrivatePortraitValidateSession.id)).where(PrivatePortraitValidateSession.project_id == project_id))).scalar_one() or 0
|
||||
group_count = (await db.execute(select(func.count(PrivatePortraitAssetGroup.id)).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.remote_group_id.is_not(None)))).scalar_one() or 0
|
||||
asset_count = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.remote_asset_id.is_not(None)))).scalar_one() or 0
|
||||
return bool(session_count or group_count or asset_count)
|
||||
|
||||
|
||||
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
before = {
|
||||
@@ -122,28 +100,12 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
}
|
||||
remote_name_locked = False
|
||||
if payload.name is not None:
|
||||
new_name = payload.name.strip()
|
||||
if new_name and new_name != project.name:
|
||||
has_remote = await _project_has_remote_resources(db, project_id=project.id)
|
||||
project.name = new_name
|
||||
if not has_remote:
|
||||
remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=new_name)
|
||||
project.name_slug = slug
|
||||
project.remote_project_name = remote_project_name
|
||||
else:
|
||||
remote_name_locked = True
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_REMOTE_NAME_LOCKED.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="项目已有远程资源,仅修改展示名,remote_project_name 保持不变",
|
||||
detail={"remote_project_name": project.remote_project_name},
|
||||
)
|
||||
project.name_slug = _safe_slug(new_name)
|
||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
if payload.status is not None:
|
||||
@@ -166,7 +128,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="更新真人素材项目",
|
||||
detail={"before": before, "after": after, "remote_name_locked": remote_name_locked},
|
||||
detail={"before": before, "after": after},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@@ -7,13 +7,31 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX, PrivatePortraitAssetStatus, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, PrivatePortraitReferenceSource
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitReferenceSource,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
|
||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||
PrivatePortraitAssetType.VIDEO.value: "video",
|
||||
PrivatePortraitAssetType.AUDIO.value: "audio",
|
||||
}
|
||||
|
||||
|
||||
_SUPPORTED_GEN_TYPES = {"image", "video"}
|
||||
|
||||
|
||||
def _ref_get(ref: Any, key: str) -> Any:
|
||||
if isinstance(ref, dict):
|
||||
return ref.get(key)
|
||||
@@ -27,33 +45,109 @@ def _ref_set(ref: Any, key: str, value: Any) -> None:
|
||||
setattr(ref, key, value)
|
||||
|
||||
|
||||
async def resolve_private_portrait_references(db: AsyncSession, *, user_id: str, media_references: list[Any] | None) -> list[Any] | None:
|
||||
def _normalize_gen_type(gen_type: str | None) -> str | None:
|
||||
value = (gen_type or "").strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value not in _SUPPORTED_GEN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_ref_type(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
async def resolve_private_portrait_references(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
media_references: list[Any] | None,
|
||||
gen_type: str | None = None,
|
||||
) -> list[Any] | None:
|
||||
"""Resolve private portrait references before dispatching a generation task.
|
||||
|
||||
generation_ai_service.py 和 generation_task_factory_service.py 都会传 gen_type。
|
||||
这里保留该参数用于兼容调用方,并做基础校验,避免接口因函数签名不一致直接 500。
|
||||
"""
|
||||
normalized_gen_type = _normalize_gen_type(gen_type)
|
||||
if not media_references:
|
||||
return media_references
|
||||
|
||||
refs = deepcopy(media_references)
|
||||
ids = [str(_ref_get(ref, "private_asset_id")) for ref in refs if _ref_get(ref, "source") == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value and _ref_get(ref, "private_asset_id")]
|
||||
ids = [
|
||||
str(_ref_get(ref, "private_asset_id"))
|
||||
for ref in refs
|
||||
if _ref_get(ref, "source") == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value
|
||||
and _ref_get(ref, "private_asset_id")
|
||||
]
|
||||
ids = list(dict.fromkeys(ids))
|
||||
if not ids:
|
||||
return refs
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.SERVICE.value, user_id=user_id, detail={"private_asset_ids": ids})
|
||||
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id.in_(ids)))
|
||||
asset_map = {asset.id: asset for asset in rows.scalars().all()}
|
||||
for ref in refs:
|
||||
if _ref_get(ref, "source") != PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value:
|
||||
continue
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
asset = asset_map.get(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
if asset.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
|
||||
_ref_set(ref, "remote_asset_id", asset.remote_asset_id)
|
||||
_ref_set(ref, "url", f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}")
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.SERVICE.value, user_id=user_id, detail={"count": len(ids)})
|
||||
return refs
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
detail={"private_asset_ids": ids, "gen_type": normalized_gen_type},
|
||||
)
|
||||
|
||||
try:
|
||||
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id.in_(ids)))
|
||||
asset_map = {asset.id: asset for asset in rows.scalars().all()}
|
||||
|
||||
for ref in refs:
|
||||
if _ref_get(ref, "source") != PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value:
|
||||
continue
|
||||
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
if not asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id")
|
||||
|
||||
asset = asset_map.get(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
if asset.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
|
||||
|
||||
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
||||
if expected_ref_type and ref_type and ref_type != expected_ref_type:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
|
||||
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
|
||||
_ref_set(ref, "private_asset_id", asset.id)
|
||||
_ref_set(ref, "remote_asset_id", asset.remote_asset_id)
|
||||
_ref_set(ref, "url", f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}")
|
||||
if expected_ref_type:
|
||||
_ref_set(ref, "type", expected_ref_type)
|
||||
if not _ref_get(ref, "name") and asset.name:
|
||||
_ref_set(ref, "name", asset.name)
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
detail={"count": len(ids), "gen_type": normalized_gen_type},
|
||||
)
|
||||
return refs
|
||||
except Exception as exc:
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
detail={"private_asset_ids": ids, "gen_type": normalized_gen_type},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -7,11 +7,18 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordBillingScene,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordSubject,
|
||||
CreditRecordSourceModule,
|
||||
)
|
||||
from app.enums.team import TeamStatus
|
||||
from app.enums.user import UserType
|
||||
from app.models.team import Team
|
||||
from app.models.user import User
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit_record_meta_service import CreditRecordMeta
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
@@ -129,11 +136,19 @@ async def transfer_credits_to_member(
|
||||
manager_id: str,
|
||||
target_member_id: str,
|
||||
amount: float,
|
||||
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
"""管理人从自己余额转积分给团队成员。"""
|
||||
"""管理人为团队成员增加或扣减积分。
|
||||
|
||||
direction:
|
||||
- "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加)
|
||||
- "decrease": 从成员扣积分回到管理人(成员减少,管理人增加)
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=400, description="转账积分必须大于0")
|
||||
raise HTTPException(status_code=400, description="积分数量必须大于0")
|
||||
if direction not in ("increase", "decrease"):
|
||||
raise HTTPException(status_code=400, description="无效操作方向")
|
||||
|
||||
# 获取管理人
|
||||
manager_result = await db.execute(
|
||||
@@ -155,7 +170,7 @@ async def transfer_credits_to_member(
|
||||
if not manager.team_id:
|
||||
raise HTTPException(status_code=400, detail="您不在任何团队中")
|
||||
if member.team_id != manager.team_id:
|
||||
raise HTTPException(status_code=400, detail="只能转账给同团队成员")
|
||||
raise HTTPException(status_code=400, detail="只能操作同团队成员")
|
||||
|
||||
team_result = await db.execute(
|
||||
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
@@ -164,22 +179,64 @@ async def transfer_credits_to_member(
|
||||
if not team or team.manager_id != manager_id:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
|
||||
|
||||
desc = description or "团队积分发放"
|
||||
# 禁止管理人给自己转积分
|
||||
if target_member_id == manager_id:
|
||||
raise HTTPException(status_code=400, detail="不能给自己调整积分")
|
||||
|
||||
# 从管理人扣减
|
||||
await deduct_credits(
|
||||
db,
|
||||
manager_id,
|
||||
amount,
|
||||
f"分配给成员 {member.username}: {desc}",
|
||||
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{generate_id()}",
|
||||
)
|
||||
# 给成员增加
|
||||
await add_credits(
|
||||
db,
|
||||
target_member_id,
|
||||
amount,
|
||||
f"来自团队管理人: {desc}",
|
||||
record_type="admin",
|
||||
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{generate_id()}",
|
||||
)
|
||||
desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
|
||||
xfer_id = generate_id()
|
||||
|
||||
# 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置
|
||||
def _build_team_transfer_meta(uid: str) -> CreditRecordMeta:
|
||||
meta = CreditRecordMeta(
|
||||
owner_type="team_internal_transfer",
|
||||
owner_id=xfer_id,
|
||||
charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value,
|
||||
credit_subject=CreditRecordSubject.TEAM_INTERNAL.value,
|
||||
source_module=CreditRecordSourceModule.TEAM.value,
|
||||
billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value,
|
||||
)
|
||||
return meta
|
||||
|
||||
if direction == "increase":
|
||||
# 管理人扣减
|
||||
await deduct_credits(
|
||||
db,
|
||||
manager_id,
|
||||
amount,
|
||||
f"分配给成员 {member.username}: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(manager_id),
|
||||
)
|
||||
# 成员增加
|
||||
await add_credits(
|
||||
db,
|
||||
target_member_id,
|
||||
amount,
|
||||
f"来自团队管理人: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(target_member_id),
|
||||
)
|
||||
else:
|
||||
# 成员扣减
|
||||
await deduct_credits(
|
||||
db,
|
||||
target_member_id,
|
||||
amount,
|
||||
f"扣减给团队管理人: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(target_member_id),
|
||||
)
|
||||
# 管理人增加
|
||||
await add_credits(
|
||||
db,
|
||||
manager_id,
|
||||
amount,
|
||||
f"来自成员 {member.username}: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(manager_id),
|
||||
)
|
||||
|
||||
+505
File diff suppressed because one or more lines are too long
+505
File diff suppressed because one or more lines are too long
-505
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-XSsSl940.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DrbYyVjF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -818,8 +818,8 @@ export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
|
||||
return api.get(`/team/members?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function transferCredits(memberId: string, amount: number, description?: string): Promise<void> {
|
||||
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, description: description || null });
|
||||
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
|
||||
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
|
||||
}
|
||||
|
||||
export async function getTeamInvitations(): Promise<any[]> {
|
||||
@@ -854,6 +854,7 @@ export async function getTeamCreditRecords(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: string;
|
||||
phone?: string;
|
||||
recordType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
@@ -862,8 +863,24 @@ export async function getTeamCreditRecords(params: {
|
||||
if (params.page) p.set('page', String(params.page));
|
||||
if (params.pageSize) p.set('page_size', String(params.pageSize));
|
||||
if (params.userId) p.set('user_id', params.userId);
|
||||
if (params.phone) p.set('phone', params.phone);
|
||||
if (params.recordType) p.set('record_type', params.recordType);
|
||||
if (params.startDate) p.set('start_date', params.startDate);
|
||||
if (params.endDate) p.set('end_date', params.endDate);
|
||||
return api.get(`/team/credit-records?${p.toString()}`);
|
||||
}
|
||||
|
||||
export function getTeamCreditExportUrl(params: {
|
||||
phone?: string;
|
||||
recordType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): string {
|
||||
const p = new URLSearchParams();
|
||||
if (params.phone) p.set('phone', params.phone);
|
||||
if (params.recordType) p.set('record_type', params.recordType);
|
||||
if (params.startDate) p.set('start_date', params.startDate);
|
||||
if (params.endDate) p.set('end_date', params.endDate);
|
||||
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
|
||||
return `${base}/api/team/credit-records/export?${p.toString()}`;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
|
||||
getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
|
||||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
|
||||
} from '../api';
|
||||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
/* ── 工具函数 ────────────────────────────────────────── */
|
||||
function formatDateTime(value: any): string {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
recharge: { color: 'green', label: '充值' },
|
||||
consume: { color: 'red', label: '消费' },
|
||||
refund: { color: 'orange', label: '退款' },
|
||||
team_internal: { color: 'blue', label: '团队内部' },
|
||||
};
|
||||
|
||||
/* ── 主组件 ──────────────────────────────────────────── */
|
||||
const TeamManagementPage: React.FC = () => {
|
||||
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
||||
const [teamLoading, setTeamLoading] = useState(false);
|
||||
@@ -52,6 +74,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
|
||||
useEffect(() => { loadMembers(); }, [loadMembers]);
|
||||
|
||||
// ── 调整积分弹窗 ──
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null });
|
||||
const [creditForm] = Form.useForm();
|
||||
const [creditSaving, setCreditSaving] = useState(false);
|
||||
@@ -60,15 +83,27 @@ const TeamManagementPage: React.FC = () => {
|
||||
if (!creditModal.member) return;
|
||||
try {
|
||||
const values = await creditForm.validateFields();
|
||||
// 二次校验:确保是正数
|
||||
const amount = Number(values.amount);
|
||||
if (!amount || amount <= 0 || amount > 9999999) {
|
||||
message.error('请输入有效的正数积分数量');
|
||||
return;
|
||||
}
|
||||
setCreditSaving(true);
|
||||
await transferCredits(creditModal.member.id, values.amount, values.description);
|
||||
message.success('积分转账成功');
|
||||
await transferCredits(
|
||||
creditModal.member.id,
|
||||
amount,
|
||||
values.direction || 'increase',
|
||||
values.description,
|
||||
);
|
||||
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
|
||||
setCreditModal({ open: false, member: null });
|
||||
creditForm.resetFields();
|
||||
loadMembers();
|
||||
loadCreditRecords();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '转账失败');
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setCreditSaving(false);
|
||||
}
|
||||
@@ -163,40 +198,84 @@ const TeamManagementPage: React.FC = () => {
|
||||
// ── Tab 4: 团队积分变动 ──
|
||||
const [creditRecords, setCreditRecords] = useState<any[]>([]);
|
||||
const [creditTotal, setCreditTotal] = useState(0);
|
||||
const [creditSummary, setCreditSummary] = useState<any>(null);
|
||||
const [creditLoading, setCreditLoading] = useState(false);
|
||||
const [creditPage, setCreditPage] = useState(1);
|
||||
const [creditFilterType, setCreditFilterType] = useState<string>('');
|
||||
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
|
||||
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
const loadCreditRecords = useCallback(async () => {
|
||||
setCreditLoading(true);
|
||||
try {
|
||||
const res = await getTeamCreditRecords({
|
||||
page: creditPage,
|
||||
pageSize: 20,
|
||||
pageSize: 10,
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
});
|
||||
setCreditRecords(res.items || []);
|
||||
setCreditTotal(res.total || 0);
|
||||
setCreditSummary(res.summary || null);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载积分记录失败');
|
||||
} finally {
|
||||
setCreditLoading(false);
|
||||
}
|
||||
}, [creditPage, creditFilterType]);
|
||||
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
|
||||
|
||||
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
|
||||
|
||||
const resetCreditFilters = () => {
|
||||
setCreditFilterType('');
|
||||
setCreditFilterPhone('');
|
||||
setCreditDateRange(null);
|
||||
setCreditPage(1);
|
||||
};
|
||||
|
||||
const handleExportCredits = () => {
|
||||
const url = getTeamCreditExportUrl({
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
});
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
fetch(url, { headers })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `team_credits_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
})
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
/* ── 表格列定义 ──────────────────────────────────────── */
|
||||
const memberColumns = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 150, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
|
||||
{ title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 160, render: (v: string) => formatDate(v) },
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_: any, r: TeamMember) => (
|
||||
<Button size="small" type="link" onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}>调整积分</Button>
|
||||
),
|
||||
render: (_: any, r: TeamMember) => {
|
||||
const currentUserId = useAuthStore.getState().user?.id;
|
||||
const isSelf = r.id === currentUserId;
|
||||
return isSelf ? (
|
||||
<Tooltip title="不能给自己调整积分">
|
||||
<Button size="small" type="link" style={{ padding: 0, color: '#999', cursor: 'not-allowed' }} disabled>调整积分</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}>调整积分</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,7 +294,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
|
||||
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
|
||||
{ title: '过期时间', dataIndex: 'expiresAt', width: 160, render: (v: string) => v ? formatDate(v) : '永不过期' },
|
||||
{ title: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 80,
|
||||
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
|
||||
@@ -225,9 +304,9 @@ const TeamManagementPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const reqColumns = [
|
||||
{ title: '申请人', dataIndex: 'username', width: 150, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: (v: string) => formatDate(v) },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 160,
|
||||
render: (_: any, r: TeamJoinRequest) => (
|
||||
@@ -254,34 +333,130 @@ const TeamManagementPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const creditColumns = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (_: any, r: any) => {
|
||||
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
|
||||
render: (_: any, r: any) => (
|
||||
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
|
||||
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
];
|
||||
|
||||
/* ── Tab 配置 ────────────────────────────────────────── */
|
||||
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
|
||||
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Space><UserOutlined />成员列表{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
|
||||
children: (
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={membersLoading}
|
||||
pagination={{
|
||||
current: membersPage,
|
||||
pageSize: 20,
|
||||
total: membersTotal,
|
||||
onChange: (p) => setMembersPage(p),
|
||||
showTotal: (t) => `共 ${t} 人`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
locale={{ emptyText: <Empty description="暂无成员" /> }}
|
||||
/>
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={membersLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 800 }}
|
||||
locale={{ emptyText: <Empty description="暂无成员" /> }}
|
||||
/>
|
||||
{membersTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: <Space><WalletOutlined />团队积分变动</Space>,
|
||||
children: (
|
||||
<div>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Select
|
||||
value={creditFilterType || undefined}
|
||||
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
|
||||
allowClear
|
||||
placeholder="交易类型"
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
{ value: 'refund', label: '退款' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索手机号"
|
||||
value={creditFilterPhone}
|
||||
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<RangePicker
|
||||
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||
} else {
|
||||
setCreditDateRange(null);
|
||||
}
|
||||
setCreditPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={resetCreditFilters}>重置</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}>导出 Excel</Button>
|
||||
</div>
|
||||
|
||||
{/* 汇总统计 */}
|
||||
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
|
||||
<span>总消耗积分:<strong style={{ color: '#ef4444', fontSize: 15 }}>{creditSummary?.total_consume ?? 0}</strong></span>
|
||||
</div>
|
||||
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={creditLoading}
|
||||
dataSource={creditRecords}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 950 }}
|
||||
columns={creditColumns}
|
||||
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
|
||||
/>
|
||||
{creditTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `共 ${t} 条`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'invitations',
|
||||
label: <Space><CopyOutlined />邀请管理</Space>,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={tableWrapper}>
|
||||
<div style={{ padding: 16, paddingBottom: 0 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}>生成邀请码</Button>
|
||||
</div>
|
||||
<Table
|
||||
@@ -290,6 +465,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={invLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 900 }}
|
||||
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
|
||||
/>
|
||||
@@ -300,82 +476,30 @@ const TeamManagementPage: React.FC = () => {
|
||||
key: 'requests',
|
||||
label: <Space><HistoryOutlined />加入申请{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
|
||||
children: (
|
||||
<Table
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 600 }}
|
||||
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: <Space><WalletOutlined />团队积分变动</Space>,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
value={creditFilterType}
|
||||
onChange={(v) => { setCreditFilterType(v); setCreditPage(1); }}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'admin', label: '管理员调整' },
|
||||
{ value: 'refund', label: '退款' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
size="small"
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={creditLoading}
|
||||
dataSource={creditRecords}
|
||||
pagination={{
|
||||
current: creditPage,
|
||||
pageSize: 20,
|
||||
total: creditTotal,
|
||||
onChange: (p) => setCreditPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
columns={[
|
||||
{ title: '用户名', key: 'username', width: 120, render: (_: any, r: any) => <Typography.Text strong>{r.username || r.user_id || '-'}</Typography.Text> },
|
||||
{
|
||||
title: '类型', key: 'type', width: 100,
|
||||
render: (_: any, r: any) => {
|
||||
const colorMap: Record<string, string> = { recharge: 'green', consume: 'red', admin: 'blue', refund: 'orange' };
|
||||
const labelMap: Record<string, string> = { recharge: '充值', consume: '消费', admin: '管理员调整', refund: '退款' };
|
||||
return <Tag color={colorMap[r.type] || 'default'}>{labelMap[r.type] || r.type}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '积分变动', key: 'amount', width: 100,
|
||||
render: (_: any, r: any) => (
|
||||
<Typography.Text style={{ color: r.amount >= 0 ? '#16a34a' : '#dc2626', fontWeight: 600 }}>
|
||||
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', key: 'balance_after', width: 100, render: (_: any, r: any) => (r.balance_after ?? 0).toFixed(2) },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '时间', key: 'created_at', width: 160, render: (_: any, r: any) => formatDate(r.created_at) },
|
||||
]}
|
||||
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 600 }}
|
||||
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 渲染 ────────────────────────────────────────────── */
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, marginBottom: 16 }} loading={teamLoading}>
|
||||
{team && (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 顶部团队信息 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{teamLoading ? (
|
||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||
) : team ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
|
||||
@@ -383,26 +507,81 @@ const TeamManagementPage: React.FC = () => {
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}>刷新</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Tabs items={tabItems} defaultActiveKey="members" />
|
||||
</Card>
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
|
||||
|
||||
{/* 积分转账弹窗 */}
|
||||
{/* 调整积分弹窗 */}
|
||||
<Modal
|
||||
title={<Space><UserOutlined />调整成员积分 - {creditModal.member?.username}</Space>}
|
||||
open={creditModal.open}
|
||||
confirmLoading={creditSaving}
|
||||
onOk={handleTransfer}
|
||||
onCancel={() => setCreditModal({ open: false, member: null })}
|
||||
okText="确认转账"
|
||||
okText="确认"
|
||||
width={480}
|
||||
>
|
||||
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="amount" label="转账积分" rules={[{ required: true, message: '请输入转账数量' }, { type: 'number', min: 0.01, message: '必须大于 0' }]}>
|
||||
<InputNumber style={{ width: '100%' }} step={1} min={0.01} placeholder="正数:从您余额转给该成员" size="large" />
|
||||
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
|
||||
{/* 显示管理人当前积分 */}
|
||||
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">我的当前积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
|
||||
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
|
||||
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
|
||||
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}>增加成员积分</Radio.Button>
|
||||
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}>扣减成员积分</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="积分数量"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入积分数量' },
|
||||
{ type: 'number', min: 0.01, message: '必须大于 0' },
|
||||
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' },
|
||||
]}
|
||||
validateTrigger={['onChange', 'onBlur']}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
step={1}
|
||||
min={0.01}
|
||||
max={9999999}
|
||||
precision={2}
|
||||
placeholder="请输入正数积分数量"
|
||||
size="large"
|
||||
formatter={(value) => {
|
||||
if (!value) return '';
|
||||
let str = `${value}`.replace(/[^0-9.]/g, '');
|
||||
str = str.replace(/^0+(?=\d)/, '');
|
||||
return str;
|
||||
}}
|
||||
parser={(str) => {
|
||||
if (!str || str === '.') return '' as any;
|
||||
let num = parseFloat(str);
|
||||
if (isNaN(num) || num <= 0) return '' as any;
|
||||
return Math.min(num, 9999999) as any;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 禁止输入负号、e、E
|
||||
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onChange={(val) => {
|
||||
if (val === null || val === undefined) {
|
||||
creditForm.validateFields(['amount']);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
|
||||
|
||||
Reference in New Issue
Block a user