解决冲突

This commit is contained in:
Lrd
2026-07-06 18:08:21 +08:00
22 changed files with 2313 additions and 747 deletions
File diff suppressed because one or more lines are too long
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: '历史未知' },
];
-1
View File
@@ -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
+91 -4
View File
@@ -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}"},
)
-2
View File
@@ -7,8 +7,6 @@ class Settings(BaseSettings):
APP_NAME: str = "VideoGen API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
# 运行环境,用于生成火山私域真人素材 ProjectNamelocal/test/online。
APP_ENV: str = "local"
SECRET_KEY: str = "change-me"
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
+10
View File
@@ -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: "历史未知",
}
+4 -11
View File
@@ -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))),
+3 -1
View File
@@ -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 来自数据库默认值或 onupdateflush 后可能处于 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 来自数据库默认值或 onupdateflush 后可能处于 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),
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-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>
+19 -2
View File
@@ -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()}`;
}
+289 -110
View File
@@ -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="选填,例如:活动奖励" />