fix: PMC 测试清单未完成项 + YYX#row22 未读角标

PMC#16 账户登录地址不正确:
- 写死的 https://airshelf.com/login(不存在的域名)改成 ${location.origin}/login,
  与邀请链接同源,成员拿到的就是当前真实访问地址。

PMC#17/#18 成员三档额度只有月度生效、日/总纯装饰 → 列表显示与配置对不上:
- TeamMember 新增 daily_credit_limit / total_credit_limit(0=不限,迁移 0006);
- reserve_credit 逐档真实管控(每日/每月/累计总额),口径=窗口内 CHARGE 流水 + 在途预留;
- 成员列表"每日额度"由写死"不限"改成真实日额度+今日已用,并新增"总额度"列;
- 创建/编辑弹窗三档额度真入库真生效;补 3 个计费单测(日/总拦截、0=不限)。

PMC#24 同批生成的图片文字/内容雷同:
- 根因=套图/自由创作同批 N 张用同一 prompt 且无变化(只有模特上身图按 index 变);
- 给平台套图/自由参考/纯文生图都加按 index 的版式变体,避开传 seed(防中转站 400)。

PMC#25 重跑应在原任务上重跑而非新开任务:
- 全批"重跑"改为原地复位同一批次卡;"重跑这张/再生成"改为补图进原卡(不覆盖好图、不新开任务)。

YYX#row22 图片生成未读角标(本仓库既有改动一并提交):
- AITask 新增 read_at(迁移 0020 + 索引一致性迁移 0021);导航栏"图片生成"未读胶囊、
  商品预览右下角未读分数;查看某商品即标记其生成任务已读、清零角标。

注:PMC#14/#15/#20/#22/#23 早前提交已修,需部署重测;PMC#13/#21 为 provider 侧
生成失败,代码侧重试/退费/僵尸回收/错误外露已就位,需部署后看真实 error_message 定位;
PMC#19 收件箱标记与设计稿逐字一致,待截图复现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 18:27:15 +08:00
co-authored by Claude Opus 4.8
parent 2e1c28abea
commit 6e7425048c
19 changed files with 483 additions and 98 deletions
@@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-06-27 10:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_invitation_kind_alter_invitation_team"),
]
operations = [
migrations.AddField(
model_name="teammember",
name="daily_credit_limit",
field=models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
migrations.AddField(
model_name="teammember",
name="total_credit_limit",
field=models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
]
+3
View File
@@ -91,7 +91,10 @@ class TeamMember(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships")
role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
# 三档成员额度(均 0=不限):每日 / 每月 / 累计总额。团队页可设,reserve 时逐档管控(PMC#17/#18)。
daily_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
total_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
unique_together = [("team", "user")]
+14 -1
View File
@@ -46,16 +46,29 @@ class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
month_charged = serializers.SerializerMethodField()
# 今日 / 累计已消费,供成员列表把三档额度用量都显示出来(PMC#17/#18)
day_charged = serializers.SerializerMethodField()
total_charged = serializers.SerializerMethodField()
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
fields = [
"id", "team", "user", "role", "status",
"daily_credit_limit", "monthly_credit_limit", "total_credit_limit",
"month_charged", "day_charged", "total_charged",
]
read_only_fields = ["id", "team", "user", "status"]
def get_month_charged(self, obj):
charged_map = self.context.get("charged_map") or {}
return str(charged_map.get(obj.user_id, 0))
def get_day_charged(self, obj):
return str((self.context.get("day_charged_map") or {}).get(obj.user_id, 0))
def get_total_charged(self, obj):
return str((self.context.get("total_charged_map") or {}).get(obj.user_id, 0))
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
+49 -21
View File
@@ -1,4 +1,5 @@
import uuid
from decimal import Decimal, InvalidOperation
from pathlib import Path
from django.contrib.auth import authenticate
@@ -237,36 +238,53 @@ def can_manage_team(user, team):
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
def _month_charged_by_user(team):
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
def _charged_by_user(team, since=None):
"""本团队每个成员在 since 之后的消费(CHARGE 流水)合计:{user_id: Decimal}。since=None 即累计全量。"""
from django.db.models import Sum
from django.utils import timezone
from apps.billing.models import CreditLedger
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = (
CreditLedger.objects.filter(
team=team,
ledger_type=CreditLedger.Type.CHARGE,
created_at__gte=month_start,
)
.values("user_id")
.annotate(total=Sum("amount"))
)
qs = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE)
if since is not None:
qs = qs.filter(created_at__gte=since)
rows = qs.values("user_id").annotate(total=Sum("amount"))
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
def _month_charged_by_user(team):
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
from django.utils import timezone
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return _charged_by_user(team, since=month_start)
def _normalize_limit(raw, default=None):
"""额度入参归一:前端 -1 / 空 = 不限 → 存 0(0=不限);其余取数值。default 用于 create 缺省。"""
if raw is None or (isinstance(raw, str) and not raw.strip()):
return default
try:
val = Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
return default
return Decimal("0") if val < 0 else val
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
from django.utils import timezone
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
charged_map = _month_charged_by_user(team)
return Response(
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
)
day_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
ctx = {
"charged_map": _month_charged_by_user(team),
"day_charged_map": _charged_by_user(team, since=day_start),
"total_charged_map": _charged_by_user(team, since=None),
}
return Response(TeamMemberSerializer(members, many=True, context=ctx).data)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
@@ -291,8 +309,10 @@ def team_members(request):
team=team,
user=user,
role=role,
# 子账号默认月度限额 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花主账号团队池的钱(PMC#3)。
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or DEFAULT_MEMBER_MONTHLY_LIMIT,
# 三档额度(0=不限);月度默认 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花团队池(PMC#3)。
daily_credit_limit=_normalize_limit(request.data.get("daily_credit_limit") or request.data.get("daily"), default=0),
monthly_credit_limit=_normalize_limit(request.data.get("monthly_credit_limit") or request.data.get("monthly"), default=DEFAULT_MEMBER_MONTHLY_LIMIT),
total_credit_limit=_normalize_limit(request.data.get("total_credit_limit") or request.data.get("total"), default=0),
)
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@@ -362,13 +382,21 @@ def team_member_detail(request, member_id):
member.role = normalize_member_role(role)
if member.role == TeamMember.Role.OWNER:
member.role = TeamMember.Role.ADMIN
update_fields = ["role", "updated_at"]
if "daily_credit_limit" in request.data or "daily" in request.data:
member.daily_credit_limit = _normalize_limit(request.data.get("daily_credit_limit", request.data.get("daily")), default=0)
update_fields.append("daily_credit_limit")
if "monthly_credit_limit" in request.data or "monthly" in request.data:
member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0
member.monthly_credit_limit = _normalize_limit(request.data.get("monthly_credit_limit", request.data.get("monthly")), default=0)
update_fields.append("monthly_credit_limit")
if "total_credit_limit" in request.data or "total" in request.data:
member.total_credit_limit = _normalize_limit(request.data.get("total_credit_limit", request.data.get("total")), default=0)
update_fields.append("total_credit_limit")
name = str(request.data.get("name") or "").strip()
if name:
member.user.first_name = name
member.user.save(update_fields=["first_name"])
member.save(update_fields=["role", "monthly_credit_limit", "updated_at"])
member.save(update_fields=update_fields)
return Response(TeamMemberSerializer(member).data)