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>
194 lines
8.3 KiB
Python
194 lines
8.3 KiB
Python
from rest_framework import serializers
|
|
|
|
from apps.billing.models import CreditAccount, CreditLedger
|
|
|
|
from .models import Invitation, LoginSession, Team, TeamMember, User, UserPreference
|
|
|
|
|
|
class UserSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
"id", "username", "first_name", "last_name", "email", "phone",
|
|
"avatar_url", "status", "is_platform_admin",
|
|
]
|
|
read_only_fields = ["id", "status", "is_platform_admin"]
|
|
|
|
|
|
class UserPreferenceSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = UserPreference
|
|
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
|
|
read_only_fields = ["updated_at"]
|
|
|
|
|
|
class LoginSessionSerializer(serializers.ModelSerializer):
|
|
is_current = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = LoginSession
|
|
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
|
|
read_only_fields = fields
|
|
|
|
def get_is_current(self, obj) -> bool:
|
|
ctx = self.context or {}
|
|
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
|
|
|
|
|
|
class TeamSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Team
|
|
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
|
|
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
|
|
|
|
|
|
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",
|
|
"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)
|
|
password = serializers.CharField(min_length=8, write_only=True)
|
|
email = serializers.EmailField(required=False, allow_blank=True)
|
|
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
|
|
# 邀请码必填(本期定调):create_team 码 → 开新团队当 owner;join_team 码 → 加入该团队当成员。
|
|
# 取消「空码 = 任何人开新团队」。认证定调:不用邮箱注册,username 为主标识。
|
|
invite_code = serializers.CharField(max_length=32, required=True, allow_blank=False)
|
|
|
|
def validate_username(self, value):
|
|
if User.objects.filter(username=value).exists():
|
|
raise serializers.ValidationError("username already exists")
|
|
return value
|
|
|
|
def validate_invite_code(self, value):
|
|
code = (value or "").strip().upper()
|
|
if not code:
|
|
raise serializers.ValidationError("请填写邀请码")
|
|
from django.utils import timezone
|
|
|
|
from .models import Invitation
|
|
|
|
inv = Invitation.objects.filter(code=code).first()
|
|
if inv is None:
|
|
raise serializers.ValidationError("邀请码不存在")
|
|
if inv.status != Invitation.Status.PENDING:
|
|
raise serializers.ValidationError("邀请码已被使用或已撤销")
|
|
if inv.expires_at <= timezone.now():
|
|
raise serializers.ValidationError("邀请码已过期")
|
|
return code
|
|
|
|
def create(self, validated_data):
|
|
from decimal import Decimal
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from .models import Invitation
|
|
|
|
invite_code = (validated_data.get("invite_code") or "").strip().upper()
|
|
with transaction.atomic():
|
|
inv = Invitation.objects.select_for_update().filter(code=invite_code).first()
|
|
if inv is None or inv.status != Invitation.Status.PENDING or inv.expires_at <= timezone.now():
|
|
raise serializers.ValidationError({"invite_code": "邀请码已失效"})
|
|
|
|
user = User.objects.create_user(
|
|
username=validated_data["username"],
|
|
password=validated_data["password"],
|
|
email=validated_data.get("email", ""),
|
|
)
|
|
|
|
if inv.kind == Invitation.Kind.CREATE_TEAM:
|
|
# 开团队码:开新团队 + 当 owner + 发试用额度(原「无码开团队」逻辑,改为凭 create_team 码触发)
|
|
team = Team.objects.create(
|
|
name=(validated_data.get("team_name") or "").strip() or f"{user.username}'s Team",
|
|
owner=user,
|
|
)
|
|
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
|
|
trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS))
|
|
CreditAccount.objects.create(team=team, balance=trial)
|
|
# 开户赠送额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(余额从第一条起就可追溯)
|
|
if trial > 0:
|
|
CreditLedger.objects.create(
|
|
team=team,
|
|
user=user,
|
|
ledger_type=CreditLedger.Type.RECHARGE,
|
|
amount=trial,
|
|
balance_after=trial,
|
|
reason="新用户试用额度赠送",
|
|
metadata={"kind": "trial_grant"},
|
|
)
|
|
# 回填邀请所属团队,便于审计「这个开团队码开出了哪个团队」
|
|
inv.team = team
|
|
else:
|
|
# join_team 码:幂等加入已有团队当成员(避开 unique_together 撞 IntegrityError)、
|
|
# 整段跳过 CreditAccount + ledger(团队级 OneToOne 已存在,二次建必报错、额度也不重发)。
|
|
team = inv.team
|
|
if team is None:
|
|
raise serializers.ValidationError({"invite_code": "邀请码未绑定团队"})
|
|
role = inv.role
|
|
if role == TeamMember.Role.OWNER:
|
|
role = TeamMember.Role.ADMIN # 不允许 OWNER(Team.owner 唯一)
|
|
TeamMember.objects.get_or_create(
|
|
team=team,
|
|
user=user,
|
|
defaults={
|
|
"role": role,
|
|
"status": TeamMember.Status.ACTIVE,
|
|
"monthly_credit_limit": inv.monthly_credit_limit,
|
|
},
|
|
)
|
|
|
|
inv.status = Invitation.Status.USED
|
|
inv.used_by = user
|
|
inv.used_at = timezone.now()
|
|
inv.save(update_fields=["status", "used_by", "used_at", "team", "updated_at"])
|
|
return {"user": user, "team": team}
|
|
|
|
|
|
class InvitationSerializer(serializers.ModelSerializer):
|
|
used_by_username = serializers.CharField(source="used_by.username", read_only=True, default=None)
|
|
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
|
register_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Invitation
|
|
fields = [
|
|
"id", "code", "kind", "role", "email", "monthly_credit_limit", "status",
|
|
"team", "team_name", "expires_at", "used_by", "used_by_username",
|
|
"used_at", "created_at", "register_url",
|
|
]
|
|
read_only_fields = fields
|
|
|
|
def get_register_url(self, obj):
|
|
return f"/register?invite={obj.code}"
|
|
|
|
|
|
class LoginSerializer(serializers.Serializer):
|
|
username = serializers.CharField()
|
|
password = serializers.CharField(write_only=True)
|