seaislee1209 1e94241587 feat: multi-project per sub-account support
Data model:
- Add IAMUserProject model (sub-account → N projects, each with monitoring toggle)
- Remove old single project_name from IAMUser model
- Update SpendingRecord with per-project granularity

Backend:
- Project CRUD views: list/add/update-toggle/delete/toggle-all
- Create user view auto-adds first project if specified
- Scheduler aggregates spending across all enabled projects per user
- Per-project spending recorded in SpendingRecord + IAMUserProject.current_spending
- Alert details include per-project spending breakdown

Frontend:
- New "项目管理" dialog: add projects from Volcengine dropdown, toggle monitoring per project, remove projects, batch toggle all
- "项目" column in user table showing monitored/total count (clickable)
- BillingView: expandable rows showing per-project spending breakdown
- Create dialog: optional initial project selection
- Removed old single-project select from config dialog

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

201 lines
9.0 KiB
Python
Raw 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='记录已通知过的百分比,划拨新额度时自动重置')
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='此项目的累计消费,由定时任务更新')
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]')
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}"