seaislee1209 3213d6d98a feat: complete AirGate core features + full audit fixes
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>
2026-03-19 15:08:33 +08:00

179 lines
7.9 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)
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}"