fix(test-round): 测试清单一批 bug 修复 + 团队月限额持久化

后端:
- accounts: 团队级月限额持久化(Team.monthly_credit_limit 三态 + PATCH/GET
  /api/auth/team/settings/,刷新不丢,PMC#12);头像上传 500→502 可读错误;
  设备下线真失效(删并重建 token,旧 token 401)
- assets: 审核类目加 model_portrait,消除三视图"无需审核"误报
- ai/projects/products: 模特已有三视图复用、产品三视图同步回商品库
  (metadata.view=three_view)、真人模特三视图回写

前端:
- 商品图删除判断改用真实图片数;团队成员弹窗禁点外部关闭
- 图片预览骨架铺满占位;平台套图左侧栏折叠改导航同款;收件箱长文换行对齐
- 团队月限额改真落库(乐观更新+失败回滚)

测试:新增 TeamSettingsTests(5 条),accounts 全套 36 tests 通过;前端 build 0 error

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-29 18:50:22 +08:00
co-authored by Claude Opus 4.8
parent ca0c3e2300
commit b0b3ac2348
20 changed files with 476 additions and 57 deletions
+58 -8
View File
@@ -213,12 +213,24 @@ def update_avatar(request):
user = request.user
suffix = Path(upload.name or "").suffix.lower() or ".png"
object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}"
storage = TosStorage()
storage.upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
# 对象存储上传是唯一会抛异常的环节(凭证/网络/桶不可达)。不捕获则裸抛 500,
# 前端只看到「服务器错误 500」查不到真因(与全站「raise_for_status 吞 body」同类坑)。
# 捕获后回 502 + 人话,既不掩盖问题(日志仍有栈)又让前端 toast 可读。
try:
storage = TosStorage()
storage.upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
except Exception as exc: # noqa: BLE001 — 对象存储故障归一成可读错误,不裸 500
import logging
logging.getLogger(__name__).exception("avatar upload to TOS failed: %s", exc)
return Response(
{"detail": "头像上传失败,请稍后重试(对象存储不可用)"},
status=status.HTTP_502_BAD_GATEWAY,
)
# 桶公读,存稳定的虚拟主机式直链(全站资产同款 public_url)——不存预签名 URL:
# 预签名 URL 带一长串 SigV4 查询参数(常 >300 字符),会超出 avatar_url 这个 URLField
# 的默认 max_length=200,MySQL 严格模式下 save 抛 DataError(Data too long)→ 500;
@@ -320,6 +332,32 @@ def team_members(request):
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def team_settings(request):
"""团队级设置:目前承载「设置月限额」(团队页超管设置,刷新后持久化 · PMC#12)。
GET 任意成员可读;PATCH 仅超管/团管(can_manage_team)。
monthly_credit_limit 三态:null=未设置(按成员累加)· -1=不限 · >=0=固定上限。"""
team = get_current_team(request.user)
if request.method == "GET":
return Response(TeamSerializer(team).data)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
if "monthly_credit_limit" in request.data:
raw = request.data.get("monthly_credit_limit")
if raw is None or (isinstance(raw, str) and not raw.strip()):
team.monthly_credit_limit = None # 清空 = 回到「未设置(按成员累加)」
else:
try:
team.monthly_credit_limit = Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
return Response({"monthly_credit_limit": ["invalid number"]}, status=status.HTTP_400_BAD_REQUEST)
team.save(update_fields=["monthly_credit_limit", "updated_at"])
return Response(TeamSerializer(team).data)
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_invitations(request):
@@ -462,7 +500,14 @@ def login_sessions(request):
@permission_classes([IsAuthenticated])
def revoke_login_session(request, session_id):
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。"""
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。
关键:DRF TokenAuthentication 是「一用户一 token」(authtoken 共享一行),
只标记 LoginSession.revoked_at 根本不动 token —— 被下线的设备仍拿着有效 token,
继续访问畅通无阻(bug:提示「设备已下线」但实际没下线)。单 token 体系下无法
只吊销某一台,唯一能真正下线的做法 = 旋转 token(删旧 + 发新),令所有端旧 token
立即失效,再把新 token 发回当前设备(同 revoke_other_sessions 的思路)。
被下线的目标设备下次请求即 401,真正离线。"""
from django.utils import timezone
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
@@ -474,7 +519,12 @@ def revoke_login_session(request, session_id):
ip_address=target.ip_address,
revoked_at__isnull=True,
).update(revoked_at=timezone.now())
return Response({"revoked": updated})
# 真正吊销:旋转用户 token(令被下线设备旧 token 立即失效),再发新 token 给当前设备。
Token.objects.filter(user=request.user).delete()
token, _ = Token.objects.get_or_create(user=request.user)
# 刷新当前设备这条会话(旋转后当前设备等价于「重新登录」),避免它被自己下线后又冒出来
record_login_session(request, request.user)
return Response({"revoked": updated, "token": token.key})
@api_view(["POST"])