Quota allocation system: - Replace monthly budget with one-time quota allocation (prepaid model) - Support both adding (+) and deducting (-) quota with underflow protection - Stepped alerts at configurable percentages (e.g., 50%/80%/90%) - Auto-disable when quota exhausted (100%), alert state resets on new allocation - Quota allocation history with operator audit trail IAM management: - Create new IAM sub-accounts directly from AirGate (auto-generates API keys) - SecretKey shown once in dialog with copy-to-clipboard - Attach/detach IAM policies via UI (ArkFullAccess, TOSFullAccess, etc.) - Sync existing users from Volcengine - Project list pulled from Volcengine API for dropdown selection Security & auth: - API Key authentication for external systems (AirDrama integration) - SECRET_KEY enforced in production (raises error if missing with DEBUG=False) - APIKeyUser with proper pk/is_staff attributes for DRF compatibility Infrastructure: - Docker + docker-compose for backend and frontend - Nginx reverse proxy for frontend with /api/ forwarding - Entrypoint with auto-migrate and default admin creation - SQLite data persisted via Docker volume at /app/data/ Bug fixes from audit: - Fix frontend referencing non-existent fields (current_month_spending, effective_budget, budget_usage_percent) - Fix scheduler using naive datetime.now() → timezone.now() - Fix scheduler reading interval from settings instead of GlobalConfig DB - Fix docker-compose SQLite volume mounting as directory - Fix CORS origin with explicit port 80 - Remove dead config (VOLC_ACCESS_KEY/SK, MONITOR_INTERVAL from settings) - Remove unused imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
7.9 KiB
Python
179 lines
7.9 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)
|
||
project_name = models.CharField('关联项目名', max_length=200, blank=True,
|
||
help_text='用于按项目维度追踪消费')
|
||
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 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')
|
||
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
|
||
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
|
||
detail = models.JSONField('消费明细', default=dict, blank=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = '消费记录'
|
||
verbose_name_plural = '消费记录'
|
||
db_table = 'airgate_spending_record'
|
||
unique_together = [('iam_user', 'bill_period')]
|
||
|
||
def __str__(self):
|
||
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
|