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>
201 lines
9.0 KiB
Python
201 lines
9.0 KiB
Python
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}"
|