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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user