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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-27 10:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0020_aitask_read_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name="aitask",
|
||||
new_name="ai_aitask_team_id_d668d5_idx",
|
||||
old_name="ai_aitask_team_read_idx",
|
||||
),
|
||||
]
|
||||
@@ -137,6 +137,9 @@ class AITask(TeamOwnedModel):
|
||||
error_message = models.TextField(blank=True)
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
# YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊
|
||||
# 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
@@ -147,6 +150,8 @@ class AITask(TeamOwnedModel):
|
||||
models.Index(fields=["team", "-created_at"]),
|
||||
# 按对话拉历史(图片创作工作室切换会话时回填批次)
|
||||
models.Index(fields=["conversation", "created_at"]),
|
||||
# YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标)
|
||||
models.Index(fields=["team", "read_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -962,9 +962,22 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||
# 平台套图同批多张要「同款商品、不同版式」,否则 N 张文案/构图雷同(PMC#24)。
|
||||
# 锁死商品一致性,只让排版/构图/视角/配色基调按张变化。
|
||||
_COVER_VARIATIONS = [
|
||||
"本张:正面居中主图版式,商品占画面主体,纯净背景,经典电商主图构图",
|
||||
"本张:换一种排版——商品偏置 + 卖点文案分区,场景化背景,杂志感构图",
|
||||
"本张:特写细节版式,放大商品材质/做工,近景视角,突出质感",
|
||||
"本张:生活场景套图版式,商品置于真实使用情境,环境光,氛围感构图",
|
||||
"本张:多角度组合版式,商品换一个朝向/视角,几何分区背景,现代简约风",
|
||||
"本张:促销封面版式,留出标题/价签区,高对比配色,强视觉冲击构图",
|
||||
]
|
||||
|
||||
|
||||
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0) -> str:
|
||||
"""平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo),
|
||||
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。"""
|
||||
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。
|
||||
`index` 让同批每张的版式/构图/视角不同,避免 N 张雷同(PMC#24)。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
lines = [f"参考图1是「{name}」的真实商品主图。"]
|
||||
if has_model:
|
||||
@@ -978,12 +991,13 @@ def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str
|
||||
"商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;"
|
||||
"干净背景、电商主图构图、真实质感。"
|
||||
)
|
||||
lines.append(_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)] + "。")
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str:
|
||||
def build_free_reference_prompt(base_prompt: str, n_refs: int = 1, index: int = 0) -> str:
|
||||
"""图片创作自由模式 · 带用户上传参考图时的提示词。
|
||||
纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物),
|
||||
这正是「没参考我上传的素材」的根因。这里显式把参考图钉成「画面主体的唯一依据」,要求严格保留其
|
||||
@@ -1005,6 +1019,9 @@ def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str:
|
||||
lines.append(f"在此基础上,按用户要求创作:{base}")
|
||||
else:
|
||||
lines.append("在此基础上,生成干净、专业的电商视觉画面。")
|
||||
# 同批多张要不同构图/视角,否则雷同(PMC#24);主体一致性已在上面钉死,这里只变表现。
|
||||
if index > 0:
|
||||
lines.append(_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)] + ",在保持主体一致的前提下换一种表现。")
|
||||
lines.append("画面真实、构图协调、细节清晰。")
|
||||
return " ".join(lines)
|
||||
|
||||
@@ -2177,7 +2194,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
elif mode == "cover" and product_url:
|
||||
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt, index=index)
|
||||
elif bool(payload.get("reference_product")) and product_url:
|
||||
edit_images = [product_url]
|
||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||
@@ -2185,7 +2202,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
# 图片创作自由模式:以用户上传的参考图为基底出图。提示词必须显式要求「保留参考图主体」,
|
||||
# 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。
|
||||
edit_images = ref_urls
|
||||
edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls))
|
||||
edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls), index=index)
|
||||
use_edit = bool(edit_images)
|
||||
try:
|
||||
if use_edit and can_edit:
|
||||
@@ -2201,7 +2218,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(str(payload.get("ratio") or ""))
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize)
|
||||
else:
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
# 纯文生图同批多张要不同构图,否则雷同(PMC#24);index>0 追加换版式指令。
|
||||
gen_prompt = prompt
|
||||
if index > 0:
|
||||
gen_prompt = f"{prompt}。{_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)]},换一种构图与视角。"
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=gen_prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
|
||||
@@ -111,6 +111,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = queryset.annotate(
|
||||
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
|
||||
rp_mode=KeyTextTransform("mode", "request_payload"),
|
||||
rp_product_id=KeyTextTransform("product_id", "request_payload"),
|
||||
)
|
||||
raw = self.request.query_params.get("task_type", "").strip()
|
||||
if raw:
|
||||
@@ -119,6 +120,46 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = queryset.filter(task_type__in=types)
|
||||
return queryset
|
||||
|
||||
# YYX#row22:只有「工作台图片生成」(mode∈model/cover/image)才计入未读;
|
||||
# 脚本/实体抽取/故事板等流水线内部任务不进这块未读统计。
|
||||
_GEN_MODES = ("model", "cover", "image")
|
||||
|
||||
def _unread_base(self):
|
||||
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。"""
|
||||
return self.get_queryset().filter(rp_mode__in=self._GEN_MODES, read_at__isnull=True)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="unread")
|
||||
def unread(self, request):
|
||||
"""未读生成任务汇总:总数(导航胶囊) + 按商品分组(商品预览角标)。"""
|
||||
rows = self._unread_base().values_list("rp_product_id", flat=True)
|
||||
total = 0
|
||||
by_product: dict[str, int] = {}
|
||||
for pid in rows:
|
||||
total += 1
|
||||
if pid:
|
||||
by_product[str(pid)] = by_product.get(str(pid), 0) + 1
|
||||
return Response({"total": total, "by_product": by_product})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="mark-read")
|
||||
def mark_read(self, request):
|
||||
"""标记已读 → 清零未读胶囊。
|
||||
body 可选:product_id(只清该商品) / batch_id(只清该批) / ids(指定任务);
|
||||
都不传 = 把当前团队所有未读生成任务标记已读(进任务中心时调用)。"""
|
||||
qs = self._unread_base()
|
||||
product_id = str(request.data.get("product_id") or "").strip()
|
||||
batch_id = str(request.data.get("batch_id") or "").strip()
|
||||
ids = request.data.get("ids") or []
|
||||
if isinstance(ids, str):
|
||||
ids = [s for s in ids.split(",") if s.strip()]
|
||||
if product_id:
|
||||
qs = qs.filter(rp_product_id=product_id)
|
||||
if batch_id:
|
||||
qs = qs.filter(rp_batch_id=batch_id)
|
||||
if ids:
|
||||
qs = qs.filter(id__in=[str(i).strip() for i in ids if str(i).strip()])
|
||||
updated = qs.update(read_at=timezone.now())
|
||||
return Response({"updated": updated})
|
||||
|
||||
|
||||
class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""图片创作工作室的「对话」CRUD。
|
||||
|
||||
@@ -8,30 +8,46 @@ from apps.billing.models import CreditAccount, CreditLedger, CreditReservation,
|
||||
|
||||
|
||||
def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
|
||||
"""成员月度限额管控。团队页可设 monthly_credit_limit(0=不限),此前只存不管——纯装饰。
|
||||
口径与成员列表的 month_charged 一致:自然月内该成员的 CHARGE 流水合计;
|
||||
再加上其当前 ACTIVE 预留(在途任务),防止并发把限额冲穿。
|
||||
调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,此检查无竞态。"""
|
||||
"""成员三档限额管控(每日 / 每月 / 累计总额,各 0=不限)。团队页可设,此前只有「月」生效,
|
||||
「日」/「总」纯装饰 → 列表显示与配置对不上(PMC#17/#18)。这里逐档真实管控。
|
||||
口径与成员列表一致:某窗口内该成员的 CHARGE 流水合计 + 其当前 ACTIVE 预留(在途任务),
|
||||
防止并发把限额冲穿。调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,无竞态。"""
|
||||
if user is None:
|
||||
return
|
||||
member = team.members.filter(user=user).first()
|
||||
if member is None:
|
||||
return
|
||||
limit = member.monthly_credit_limit or Decimal("0")
|
||||
if limit <= 0:
|
||||
return # 0 = 不限额
|
||||
now = timezone.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = (
|
||||
CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
reserved = (
|
||||
CreditReservation.objects.filter(team=team, user=user, status=CreditReservation.Status.ACTIVE)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if charged + reserved + amount > limit:
|
||||
raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
|
||||
def _charged_since(since):
|
||||
qs = CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE)
|
||||
if since is not None:
|
||||
qs = qs.filter(created_at__gte=since)
|
||||
return qs.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
|
||||
day_limit = member.daily_credit_limit or Decimal("0")
|
||||
if day_limit > 0:
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = _charged_since(day_start)
|
||||
if charged + reserved + amount > day_limit:
|
||||
raise ValueError(f"成员今日额度不足:每日限额 ¥{day_limit},今日已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
|
||||
month_limit = member.monthly_credit_limit or Decimal("0")
|
||||
if month_limit > 0:
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = _charged_since(month_start)
|
||||
if charged + reserved + amount > month_limit:
|
||||
raise ValueError(f"成员本月额度不足:限额 ¥{month_limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
|
||||
total_limit = member.total_credit_limit or Decimal("0")
|
||||
if total_limit > 0:
|
||||
charged = _charged_since(None)
|
||||
if charged + reserved + amount > total_limit:
|
||||
raise ValueError(f"成员累计额度不足:总额度 ¥{total_limit},累计已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
|
||||
|
||||
def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
|
||||
@@ -60,6 +60,46 @@ class CreditLedgerTests(TestCase):
|
||||
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
|
||||
|
||||
|
||||
class MemberLimitTests(TestCase):
|
||||
"""成员三档额度(每日/每月/累计总额,0=不限)在 reserve_credit 时逐档管控(PMC#17/#18)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="lim-owner", password="pass12345")
|
||||
self.team = Team.objects.create(name="Limit Team", owner=self.user)
|
||||
self.member = TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000"))
|
||||
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE,
|
||||
)
|
||||
|
||||
def _task(self, key):
|
||||
return AITask.objects.create(
|
||||
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
|
||||
model_config=self.model, idempotency_key=key, estimated_cost=Decimal("10.0000"),
|
||||
)
|
||||
|
||||
def test_daily_limit_blocks_over_spend(self):
|
||||
self.member.daily_credit_limit = Decimal("15.0000")
|
||||
self.member.save(update_fields=["daily_credit_limit"])
|
||||
# 第一笔 10 预留通过,第二笔 10 超每日 15 → 被拦
|
||||
reserve_credit(team=self.team, user=self.user, task=self._task("d1"), amount=Decimal("10.0000"))
|
||||
with self.assertRaises(ValueError):
|
||||
reserve_credit(team=self.team, user=self.user, task=self._task("d2"), amount=Decimal("10.0000"))
|
||||
|
||||
def test_total_limit_blocks_over_spend(self):
|
||||
self.member.total_credit_limit = Decimal("12.0000")
|
||||
self.member.save(update_fields=["total_credit_limit"])
|
||||
reserve_credit(team=self.team, user=self.user, task=self._task("t1"), amount=Decimal("10.0000"))
|
||||
with self.assertRaises(ValueError):
|
||||
reserve_credit(team=self.team, user=self.user, task=self._task("t2"), amount=Decimal("10.0000"))
|
||||
|
||||
def test_zero_limit_means_unlimited(self):
|
||||
# 三档都 0 = 不限:多笔大额预留都不被额度档拦(只受账户余额约束)
|
||||
for i in range(5):
|
||||
reserve_credit(team=self.team, user=self.user, task=self._task(f"z{i}"), amount=Decimal("10.0000"))
|
||||
|
||||
|
||||
class RechargePermissionTests(TestCase):
|
||||
"""充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user