seaislee1209 a7e030dc57 feat: auto-authorize policies when adding projects to sub-accounts
- Disable now removes all policies (saved to DB) + Enable restores them
- Project add: policies are now user-selected (checkbox), not auto-attached
- Fix serializer allow_blank for optional fields (email/phone/password)
- Better error reporting for policy detach/attach failures
- Handle duplicate user creation with clear error message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:01:18 +08:00

209 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from decimal import Decimal
from django.db import models
class VolcAccount(models.Model):
"""火山引擎主账号配置(加密存储)"""
name = models.CharField('账号名称', max_length=100, default='默认主账号')
access_key_enc = models.TextField('AccessKey加密', blank=True)
secret_key_enc = models.TextField('SecretKey加密', blank=True)
access_key_hint = models.CharField('AK 提示前4后4', max_length=20, blank=True)
is_active = models.BooleanField('启用', default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '火山主账号'
verbose_name_plural = '火山主账号'
db_table = 'airgate_volc_account'
def __str__(self):
return f"{self.name} ({self.access_key_hint})"
class IAMUser(models.Model):
"""受管理的 IAM 子账号"""
class Status(models.TextChoices):
ACTIVE = 'active', '正常'
DISABLED = 'disabled', '已停用'
UNKNOWN = 'unknown', '未知'
volc_account = models.ForeignKey(VolcAccount, on_delete=models.CASCADE, related_name='iam_users')
username = models.CharField('IAM 用户名', max_length=200, db_index=True)
display_name = models.CharField('显示名', max_length=200, blank=True)
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
email = models.EmailField('邮箱', blank=True)
phone = models.CharField('手机号', max_length=20, blank=True)
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
# Access keys (stored as JSON list of AK IDs, not secrets)
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
# --- 额度管理(划拨制) ---
allocated_quota = models.DecimalField('已划拨额度(元)', max_digits=12, decimal_places=2, default=0,
help_text='主账号累计划拨给此子账号的总额度')
consumed_total = models.DecimalField('累计消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='从 Billing API 获取的累计消费总额')
spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
# --- 监控配置 ---
monitor_enabled = models.BooleanField('启用消费监控', default=True)
alert_thresholds = models.JSONField('告警阈值(百分比列表)', default=list, blank=True,
help_text='如 [50, 80, 90] 表示消费达到额度的 50%/80%/90% 时告警')
auto_disable_enabled = models.BooleanField('额度用尽自动停用', default=True,
help_text='消费达到已划拨额度 100% 时自动停用')
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
help_text='记录已通知过的百分比,划拨新额度时自动重置')
# --- 停用时保存的策略快照(恢复时自动加回) ---
saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True,
help_text='停用时自动移除的策略列表,恢复时加回')
remark = models.TextField('备注', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'IAM 子账号'
verbose_name_plural = 'IAM 子账号'
db_table = 'airgate_iam_user'
unique_together = [('volc_account', 'username')]
def __str__(self):
return f"{self.display_name or self.username} ({self.status})"
@property
def remaining_quota(self):
"""剩余额度"""
return max(Decimal('0'), self.allocated_quota - self.consumed_total)
@property
def usage_percent(self):
"""额度使用率"""
if self.allocated_quota <= 0:
return 0
return round(float(self.consumed_total) / float(self.allocated_quota) * 100, 1)
def get_alert_thresholds(self):
if self.alert_thresholds:
return sorted(self.alert_thresholds)
config = GlobalConfig.get_solo()
return sorted(config.default_alert_thresholds or [50, 80, 90])
class IAMUserProject(models.Model):
"""子账号关联的火山项目(多对多)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='projects')
project_name = models.CharField('火山项目名', max_length=200)
display_name = models.CharField('显示名', max_length=200, blank=True)
monitor_enabled = models.BooleanField('启用监测', default=True)
current_spending = models.DecimalField('当前消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='此项目的累计消费,由定时任务更新')
attached_policies = models.JSONField('已授权的策略列表', default=list, blank=True,
help_text='添加项目时自动附加的策略名列表,移除时自动回收')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '子账号关联项目'
verbose_name_plural = '子账号关联项目'
db_table = 'airgate_iam_user_project'
unique_together = [('iam_user', 'project_name')]
ordering = ['project_name']
def __str__(self):
status = '监测中' if self.monitor_enabled else '未监测'
return f"{self.project_name} ({status}) ¥{self.current_spending}"
class QuotaAllocation(models.Model):
"""额度划拨记录"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
amount = models.DecimalField('变更金额(元,正=追加,负=扣减)', max_digits=12, decimal_places=2)
total_after = models.DecimalField('划拨后总额度', max_digits=12, decimal_places=2)
note = models.CharField('备注', max_length=500, blank=True)
created_by = models.CharField('操作人', max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '额度划拨记录'
verbose_name_plural = '额度划拨记录'
db_table = 'airgate_quota_allocation'
ordering = ['-created_at']
def __str__(self):
return f"{self.iam_user.username}{self.amount} → ¥{self.total_after}"
class GlobalConfig(models.Model):
"""全局配置(单例)"""
default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
help_text='如 [50, 80, 90]')
default_project_policies = models.JSONField('添加项目时自动授权的策略', default=list, blank=True,
help_text='如 ["ArkFullAccess", "TOSFullAccess"]')
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600)
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '全局配置'
verbose_name_plural = '全局配置'
db_table = 'airgate_global_config'
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return '全局配置'
class AlertRecord(models.Model):
"""告警记录"""
class AlertType(models.TextChoices):
WARNING = 'warning', '告警'
DISABLE = 'disable', '自动停用'
ERROR = 'error', '错误'
MANUAL = 'manual', '手动操作'
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='alerts', null=True, blank=True)
alert_type = models.CharField('告警类型', max_length=20, choices=AlertType.choices)
title = models.CharField('标题', max_length=200)
content = models.TextField('详情')
spending_amount = models.DecimalField('触发时消费金额', max_digits=12, decimal_places=2, null=True, blank=True)
threshold_amount = models.DecimalField('触发阈值', max_digits=12, decimal_places=2, null=True, blank=True)
notified = models.BooleanField('已通知', default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '告警记录'
verbose_name_plural = '告警记录'
db_table = 'airgate_alert_record'
ordering = ['-created_at']
def __str__(self):
return f"[{self.alert_type}] {self.title}"
class SpendingRecord(models.Model):
"""月度消费快照(按项目粒度)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
project_name = models.CharField('项目名', max_length=200, blank=True,
help_text='空=子账号总消费')
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '消费记录'
verbose_name_plural = '消费记录'
db_table = 'airgate_spending_record'
unique_together = [('iam_user', 'project_name', 'bill_period')]
def __str__(self):
proj = self.project_name or '总计'
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"