Compare commits
No commits in common. "236e08234986c9be3de77d7c24a5326ce4a7b283" and "c4c6a03f6167c26631d6ffeb72c26c90a4c09010" have entirely different histories.
236e082349
...
c4c6a03f61
@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-28 16:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0009_iamuser_volc_login_allowed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='deny_policy_exempt',
|
||||
field=models.BooleanField(default=False, help_text='开启后不生成项目隔离 Deny 策略(适用于管理员自用账号)', verbose_name='免除 Deny 策略'),
|
||||
),
|
||||
]
|
||||
@ -1,38 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-29 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0010_iamuser_deny_policy_exempt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='globalconfig',
|
||||
name='feishu_app_id',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='飞书 App ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='globalconfig',
|
||||
name='feishu_app_secret',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='飞书 App Secret'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalconfig',
|
||||
name='feishu_alert_mobiles',
|
||||
field=models.CharField(blank=True, help_text='接收告警的飞书用户手机号,多个用逗号分隔', max_length=500, verbose_name='飞书通知手机号(逗号分隔)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalconfig',
|
||||
name='feishu_webhook_url',
|
||||
field=models.URLField(blank=True, max_length=500, verbose_name='飞书 Webhook URL(已弃用)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalconfig',
|
||||
name='monitor_interval_seconds',
|
||||
field=models.IntegerField(default=60, verbose_name='监控间隔(秒)'),
|
||||
),
|
||||
]
|
||||
@ -62,9 +62,6 @@ class IAMUser(models.Model):
|
||||
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
|
||||
help_text='记录已通知过的百分比,划拨新额度时自动重置')
|
||||
|
||||
deny_policy_exempt = models.BooleanField('免除 Deny 策略', default=False,
|
||||
help_text='开启后不生成项目隔离 Deny 策略(适用于管理员自用账号)')
|
||||
|
||||
# --- 停用时保存的策略快照(恢复时自动加回) ---
|
||||
saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True,
|
||||
help_text='停用时自动移除的策略列表,恢复时加回')
|
||||
@ -184,13 +181,9 @@ class GlobalConfig(models.Model):
|
||||
help_text='如 [50, 80, 90]')
|
||||
default_project_policies = models.JSONField('添加项目时自动授权的策略', default=list, blank=True,
|
||||
help_text='如 ["ArkFullAccess", "TOSFullAccess"]')
|
||||
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=60)
|
||||
feishu_app_id = models.CharField('飞书 App ID', max_length=200, blank=True)
|
||||
feishu_app_secret = models.CharField('飞书 App Secret', max_length=200, blank=True)
|
||||
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True,
|
||||
help_text='接收告警的飞书用户手机号,多个用逗号分隔')
|
||||
# 保留 webhook 字段兼容性,但不再使用
|
||||
feishu_webhook_url = models.URLField('飞书 Webhook URL(已弃用)', max_length=500, blank=True)
|
||||
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:
|
||||
|
||||
@ -42,7 +42,6 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
||||
'effective_alert_thresholds',
|
||||
'projects', 'monitored_project_count',
|
||||
'login_enabled', 'volc_login_allowed',
|
||||
'deny_policy_exempt',
|
||||
'remark', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||
@ -78,7 +77,6 @@ class IAMUserConfigSerializer(serializers.Serializer):
|
||||
)
|
||||
monitor_enabled = serializers.BooleanField(required=False)
|
||||
auto_disable_enabled = serializers.BooleanField(required=False)
|
||||
deny_policy_exempt = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class IAMUserProjectAddSerializer(serializers.Serializer):
|
||||
@ -121,8 +119,7 @@ class GlobalConfigSerializer(serializers.ModelSerializer):
|
||||
'default_alert_thresholds',
|
||||
'default_project_policies',
|
||||
'monitor_interval_seconds',
|
||||
'feishu_app_id', 'feishu_app_secret',
|
||||
'feishu_alert_mobiles',
|
||||
'feishu_webhook_url', 'feishu_alert_mobiles',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['updated_at']
|
||||
|
||||
@ -45,7 +45,6 @@ urlpatterns = [
|
||||
|
||||
# Global config
|
||||
path('config/', views.global_config_view),
|
||||
path('config/test-feishu/', views.test_feishu_view),
|
||||
|
||||
# Alerts
|
||||
path('alerts/', views.alert_list_view),
|
||||
|
||||
@ -50,12 +50,6 @@ def _update_deny_policy(user):
|
||||
if not ak:
|
||||
return
|
||||
svc = IAMService(ak, sk)
|
||||
|
||||
if user.deny_policy_exempt:
|
||||
# 免除 Deny 策略的账号,移除已有的 Deny 策略
|
||||
svc.remove_deny_policy(user.username)
|
||||
return
|
||||
|
||||
allowed_projects = list(
|
||||
user.projects.values_list('project_name', flat=True)
|
||||
)
|
||||
@ -184,7 +178,6 @@ def iam_user_sync_view(request):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
imported = []
|
||||
volc_usernames = set()
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
@ -200,7 +193,6 @@ def iam_user_sync_view(request):
|
||||
|
||||
for u in users:
|
||||
username = u.get("UserName", "")
|
||||
volc_usernames.add(username)
|
||||
obj, created = IAMUser.objects.update_or_create(
|
||||
volc_account=account,
|
||||
username=username,
|
||||
@ -255,22 +247,10 @@ def iam_user_sync_view(request):
|
||||
if offset >= total:
|
||||
break
|
||||
|
||||
# 删除火山已不存在的用户(本地有但火山没有)
|
||||
removed = []
|
||||
local_users = IAMUser.objects.filter(volc_account=account)
|
||||
for local_user in local_users:
|
||||
if local_user.username not in volc_usernames:
|
||||
removed.append(local_user.username)
|
||||
local_user.delete()
|
||||
|
||||
total_count = IAMUser.objects.filter(volc_account=account).count()
|
||||
msg = f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个'
|
||||
if removed:
|
||||
msg += f',清理 {len(removed)} 个已删除用户({", ".join(removed)})'
|
||||
return Response({
|
||||
'message': msg,
|
||||
'message': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个',
|
||||
'imported': imported,
|
||||
'removed': removed,
|
||||
'total': total_count,
|
||||
})
|
||||
|
||||
@ -367,8 +347,8 @@ def iam_user_create_view(request):
|
||||
monitor_enabled=True,
|
||||
)
|
||||
|
||||
# 7. Create Deny policy (project isolation) + refresh all users
|
||||
_refresh_all_deny_policies()
|
||||
# 7. Create Deny policy (project isolation)
|
||||
_update_deny_policy(obj)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj,
|
||||
@ -440,16 +420,9 @@ def iam_user_update_view(request, pk):
|
||||
serializer = IAMUserConfigSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
deny_changed = 'deny_policy_exempt' in serializer.validated_data and \
|
||||
serializer.validated_data['deny_policy_exempt'] != user.deny_policy_exempt
|
||||
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(user, field, value)
|
||||
user.save()
|
||||
|
||||
if deny_changed:
|
||||
_update_deny_policy(user)
|
||||
|
||||
return Response(IAMUserSerializer(user).data)
|
||||
|
||||
|
||||
@ -975,8 +948,8 @@ def iam_user_project_add_view(request, pk):
|
||||
obj.attached_policies = attached
|
||||
obj.save(update_fields=['attached_policies'])
|
||||
|
||||
# 更新所有子账号的 Deny 策略(新项目需要加入其他人的拒绝列表)
|
||||
_refresh_all_deny_policies()
|
||||
# 更新 Deny 策略(将新项目加入白名单)
|
||||
_update_deny_policy(user)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
@ -1021,35 +994,19 @@ def iam_user_project_policies_view(request, pk, pid):
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
new_policies = request.data.get('policies', [])
|
||||
old_policies = project.attached_policies or []
|
||||
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
if not ak:
|
||||
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
|
||||
# Get actual current policies from Volcengine (not local DB)
|
||||
actual_old = []
|
||||
try:
|
||||
resp = svc.client.call('ListAttachedUserPolicies', {
|
||||
'UserName': user.username,
|
||||
'ProjectName': project.project_name,
|
||||
})
|
||||
for p in resp.get('Result', {}).get('AttachedPolicyMetadata', []):
|
||||
scopes = p.get('PolicyScope', [])
|
||||
for s in scopes:
|
||||
if s.get('PolicyScopeType') == 'Project' and s.get('ProjectName') == project.project_name:
|
||||
actual_old.append(p.get('PolicyName', ''))
|
||||
break
|
||||
except Exception:
|
||||
actual_old = project.attached_policies or []
|
||||
|
||||
attached = []
|
||||
detached = []
|
||||
errors = []
|
||||
|
||||
# Remove policies that were removed
|
||||
to_remove = [p for p in actual_old if p not in new_policies]
|
||||
to_remove = [p for p in old_policies if p not in new_policies]
|
||||
for policy_name in to_remove:
|
||||
try:
|
||||
svc.detach_policy_in_project(user.username, policy_name, project.project_name)
|
||||
@ -1058,7 +1015,7 @@ def iam_user_project_policies_view(request, pk, pid):
|
||||
errors.append(f"移除 {policy_name}: {e}")
|
||||
|
||||
# Add policies that are new
|
||||
to_add = [p for p in new_policies if p not in actual_old]
|
||||
to_add = [p for p in new_policies if p not in old_policies]
|
||||
for policy_name in to_add:
|
||||
try:
|
||||
svc.attach_policy_in_project(user.username, policy_name, project.project_name)
|
||||
@ -1121,8 +1078,8 @@ def iam_user_project_delete_view(request, pk, pid):
|
||||
|
||||
project.delete()
|
||||
|
||||
# 更新所有子账号的 Deny 策略
|
||||
_refresh_all_deny_policies()
|
||||
# 更新 Deny 策略(将移除的项目从白名单中删除)
|
||||
_update_deny_policy(user)
|
||||
|
||||
result = {'message': f'已移除项目 {name},已回收权限: {detached}'}
|
||||
if detach_errors:
|
||||
@ -1267,28 +1224,6 @@ def global_config_view(request):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def test_feishu_view(request):
|
||||
"""测试飞书通知"""
|
||||
config = GlobalConfig.get_solo()
|
||||
app_id = config.feishu_app_id
|
||||
app_secret = config.feishu_app_secret
|
||||
mobile = request.data.get('mobile', '') or (config.feishu_alert_mobiles or '').split(',')[0].strip()
|
||||
|
||||
if not app_id or not app_secret:
|
||||
return Response({'message': '请先配置飞书 App ID 和 App Secret'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if not mobile:
|
||||
return Response({'message': '请填写接收人手机号'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from utils.feishu import send_feishu_test
|
||||
success, msg = send_feishu_test(app_id, app_secret, mobile)
|
||||
if success:
|
||||
return Response({'message': msg})
|
||||
return Response({'message': msg}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
# ==================== Alerts ====================
|
||||
|
||||
@api_view(['GET'])
|
||||
|
||||
@ -8,21 +8,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingService:
|
||||
"""封装火山引擎 Billing API
|
||||
|
||||
使用 ListSplitBillDetail(分账账单)而非 ListBillDetail(账单明细),
|
||||
因为后者的 Project 字段对 Seedance 等按量付费产品显示为 '-',不准确。
|
||||
分账账单能正确按项目归属消费,与火山控制台分账账单页面一致。
|
||||
"""
|
||||
"""封装火山引擎 Billing API"""
|
||||
|
||||
def __init__(self, ak: str, sk: str):
|
||||
self.client = get_billing_client(ak, sk)
|
||||
|
||||
def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal:
|
||||
"""查询指定项目的消费总额(使用分账账单,带分页)"""
|
||||
"""查询指定项目的消费总额(带分页)"""
|
||||
total = Decimal("0")
|
||||
offset = 0
|
||||
page_size = 100
|
||||
page_size = 300
|
||||
|
||||
while True:
|
||||
params = {
|
||||
@ -30,52 +25,25 @@ class BillingService:
|
||||
"Limit": str(page_size),
|
||||
"Offset": str(offset),
|
||||
"GroupTerm": "0",
|
||||
"GroupPeriod": "2",
|
||||
"GroupPeriod": "0",
|
||||
"NeedRecordNum": "1",
|
||||
}
|
||||
result = self.client.call("ListSplitBillDetail", params)
|
||||
result = self.client.call("ListBillDetail", params)
|
||||
items = result.get("Result", {}).get("List", [])
|
||||
record_num = int(result.get("Result", {}).get("Total", 0))
|
||||
|
||||
for item in items:
|
||||
item_project = item.get("Project", "-")
|
||||
if project_name and item_project != project_name:
|
||||
if project_name and item.get("Project") != project_name:
|
||||
continue
|
||||
amount = item.get("PayableAmount", "0")
|
||||
total += Decimal(str(amount))
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
if offset >= record_num or not items:
|
||||
break
|
||||
|
||||
return total
|
||||
|
||||
def get_spending_all_projects(self, bill_period: str) -> dict:
|
||||
"""查询所有项目的消费汇总(返回 {project_name: Decimal})"""
|
||||
by_project = {}
|
||||
offset = 0
|
||||
page_size = 100
|
||||
|
||||
while True:
|
||||
params = {
|
||||
"BillPeriod": bill_period,
|
||||
"Limit": str(page_size),
|
||||
"Offset": str(offset),
|
||||
"GroupTerm": "0",
|
||||
"GroupPeriod": "2",
|
||||
}
|
||||
result = self.client.call("ListSplitBillDetail", params)
|
||||
items = result.get("Result", {}).get("List", [])
|
||||
|
||||
for item in items:
|
||||
project = item.get("Project", "-")
|
||||
amount = Decimal(str(item.get("PayableAmount", "0")))
|
||||
by_project[project] = by_project.get(project, Decimal("0")) + amount
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
|
||||
return by_project
|
||||
|
||||
def get_bill_overview(self, bill_period: str) -> dict:
|
||||
"""获取账单总览(按产品维度)"""
|
||||
result = self.client.call("ListBillOverviewByProd", {
|
||||
|
||||
@ -1,171 +1,42 @@
|
||||
"""飞书自建应用通知(复用 AirDrama 的飞书应用,发私信卡片)"""
|
||||
"""飞书机器人通知"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token 缓存
|
||||
_token_cache = {'token': '', 'expires': 0}
|
||||
|
||||
|
||||
def _get_tenant_access_token(app_id: str, app_secret: str) -> str:
|
||||
"""获取飞书 tenant_access_token(带简单缓存)"""
|
||||
import time
|
||||
if _token_cache['token'] and _token_cache['expires'] > time.time():
|
||||
return _token_cache['token']
|
||||
|
||||
resp = requests.post(
|
||||
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
|
||||
json={'app_id': app_id, 'app_secret': app_secret},
|
||||
timeout=5,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('code') != 0:
|
||||
raise RuntimeError(f'Feishu token error: {data}')
|
||||
|
||||
token = data['tenant_access_token']
|
||||
expire = data.get('expire', 7200)
|
||||
_token_cache['token'] = token
|
||||
_token_cache['expires'] = time.time() + expire - 60 # 提前60秒过期
|
||||
return token
|
||||
|
||||
|
||||
def _get_open_id_by_mobile(token: str, mobile: str) -> str:
|
||||
"""通过手机号查询飞书 open_id"""
|
||||
resp = requests.post(
|
||||
'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
json={'mobiles': [mobile]},
|
||||
timeout=5,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('code') != 0:
|
||||
raise RuntimeError(f'Feishu user lookup error: {data}')
|
||||
user_list = data.get('data', {}).get('user_list', [])
|
||||
if user_list and user_list[0].get('user_id'):
|
||||
return user_list[0]['user_id']
|
||||
return None
|
||||
|
||||
|
||||
def send_feishu_alert(webhook_url: str, title: str, content: str,
|
||||
template: str = "red"):
|
||||
"""发送飞书卡片消息到配置的接收人(非阻塞)
|
||||
"""发送飞书卡片消息(非阻塞)"""
|
||||
if not webhook_url:
|
||||
logger.warning(f"飞书 Webhook 未配置,跳过通知: {title}")
|
||||
return
|
||||
|
||||
webhook_url 参数保留兼容性但不再使用,改为从 GlobalConfig 读取飞书应用配置。
|
||||
"""
|
||||
def _send():
|
||||
try:
|
||||
from apps.monitor.models import GlobalConfig
|
||||
config = GlobalConfig.get_solo()
|
||||
|
||||
app_id = config.feishu_app_id
|
||||
app_secret = config.feishu_app_secret
|
||||
mobiles_str = config.feishu_alert_mobiles
|
||||
|
||||
if not app_id or not app_secret:
|
||||
logger.warning(f"飞书应用未配置,跳过通知: {title}")
|
||||
return
|
||||
|
||||
if not mobiles_str:
|
||||
logger.warning(f"飞书告警手机号未配置,跳过通知: {title}")
|
||||
return
|
||||
|
||||
mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()]
|
||||
if not mobiles:
|
||||
return
|
||||
|
||||
token = _get_tenant_access_token(app_id, app_secret)
|
||||
|
||||
card = {
|
||||
'config': {'wide_screen_mode': True},
|
||||
'header': {
|
||||
'title': {'tag': 'plain_text', 'content': title},
|
||||
'template': template,
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
"template": template,
|
||||
},
|
||||
'elements': [
|
||||
"elements": [
|
||||
{
|
||||
'tag': 'div',
|
||||
'text': {'tag': 'lark_md', 'content': content},
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": content},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
for mobile in mobiles:
|
||||
try:
|
||||
open_id = _get_open_id_by_mobile(token, mobile)
|
||||
if not open_id:
|
||||
logger.warning(f'未找到手机号 {mobile} 对应的飞书用户')
|
||||
continue
|
||||
|
||||
resp = requests.post(
|
||||
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
params={'receive_id_type': 'open_id'},
|
||||
json={
|
||||
'receive_id': open_id,
|
||||
'msg_type': 'interactive',
|
||||
'content': json.dumps(card, ensure_ascii=False),
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('code') != 0:
|
||||
logger.error(f'飞书发送失败 {mobile}: {data}')
|
||||
else:
|
||||
logger.info(f'飞书通知已发送 {mobile}: {title}')
|
||||
except Exception as e:
|
||||
logger.error(f'飞书通知错误 {mobile}: {e}')
|
||||
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(webhook_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"飞书通知已发送: {title}")
|
||||
except Exception as e:
|
||||
logger.error(f"飞书通知发送失败: {e}")
|
||||
|
||||
thread = threading.Thread(target=_send, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def send_feishu_test(app_id: str, app_secret: str, mobile: str):
|
||||
"""发送测试消息。Returns (success, message)。"""
|
||||
try:
|
||||
token = _get_tenant_access_token(app_id, app_secret)
|
||||
open_id = _get_open_id_by_mobile(token, mobile)
|
||||
if not open_id:
|
||||
return False, f'未找到手机号 {mobile} 对应的飞书用户'
|
||||
|
||||
card = {
|
||||
'config': {'wide_screen_mode': True},
|
||||
'header': {
|
||||
'title': {'tag': 'plain_text', 'content': 'AirGate 告警测试'},
|
||||
'template': 'blue',
|
||||
},
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'div',
|
||||
'text': {
|
||||
'tag': 'lark_md',
|
||||
'content': '这是一条测试消息,说明飞书告警通道配置正常。',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
params={'receive_id_type': 'open_id'},
|
||||
json={
|
||||
'receive_id': open_id,
|
||||
'msg_type': 'interactive',
|
||||
'content': json.dumps(card, ensure_ascii=False),
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('code') != 0:
|
||||
return False, f'发送失败: {data.get("msg", "")}'
|
||||
return True, '测试消息已发送'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@ -92,22 +92,24 @@ class IAMService:
|
||||
|
||||
def attach_policy_in_project(self, username: str, policy_name: str,
|
||||
project_name: str, policy_type: str = "System") -> dict:
|
||||
"""授权策略(全局),项目隔离靠 Deny 策略实现。
|
||||
注意:火山 Open API 不支持项目级授权(Scope=Project 无效),
|
||||
所以统一走全局授权 + AirGate_Deny_{username} 策略隔离。"""
|
||||
"""在项目范围内授权(限定到指定项目)"""
|
||||
return self.client.call("AttachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": policy_name,
|
||||
"PolicyType": policy_type,
|
||||
"ProjectName": project_name,
|
||||
"Scope": "Project",
|
||||
})
|
||||
|
||||
def detach_policy_in_project(self, username: str, policy_name: str,
|
||||
project_name: str, policy_type: str = "System") -> dict:
|
||||
"""回收策略(全局)"""
|
||||
"""在项目范围内回收权限"""
|
||||
return self.client.call("DetachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": policy_name,
|
||||
"PolicyType": policy_type,
|
||||
"ProjectName": project_name,
|
||||
"Scope": "Project",
|
||||
})
|
||||
|
||||
# === Deny Policy (project isolation) ===
|
||||
|
||||
@ -31,13 +31,6 @@ def check_spending():
|
||||
billing = BillingService(ak, sk)
|
||||
iam_svc = IAMService(ak, sk)
|
||||
|
||||
# 一次性查询所有项目的消费(避免 N+1 API 调用)
|
||||
try:
|
||||
all_project_spending = billing.get_spending_all_projects(bill_period)
|
||||
except Exception as e:
|
||||
logger.error(f"批量查询消费失败: {e}")
|
||||
all_project_spending = {}
|
||||
|
||||
users = IAMUser.objects.filter(
|
||||
volc_account=volc_account,
|
||||
monitor_enabled=True,
|
||||
@ -45,7 +38,7 @@ def check_spending():
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# --- 遍历所有开启监测的项目,从批量结果中获取消费 ---
|
||||
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
|
||||
enabled_projects = IAMUserProject.objects.filter(
|
||||
iam_user=user, monitor_enabled=True
|
||||
)
|
||||
@ -57,9 +50,13 @@ def check_spending():
|
||||
total_spending = Decimal('0')
|
||||
|
||||
for project in enabled_projects:
|
||||
proj_spending = all_project_spending.get(
|
||||
project.project_name, project.current_spending
|
||||
)
|
||||
try:
|
||||
proj_spending = billing.get_spending_by_project(
|
||||
bill_period, project.project_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询项目 {project.project_name} 消费失败: {e}")
|
||||
proj_spending = project.current_spending # 保留上次值
|
||||
|
||||
# 更新项目级消费
|
||||
project.current_spending = proj_spending
|
||||
|
||||
@ -51,17 +51,11 @@
|
||||
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联项目" min-width="180">
|
||||
<el-table-column label="项目" min-width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px; align-items:center;">
|
||||
<el-tag v-for="p in (row.projects || [])" :key="p.project_name"
|
||||
:type="p.monitor_enabled ? 'success' : 'info'" size="small">
|
||||
{{ p.project_name }}
|
||||
</el-tag>
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/iam-users/${row.id}/policies`)"
|
||||
style="font-size:12px;">管理</el-button>
|
||||
</div>
|
||||
<span v-if="!(row.projects || []).length" style="color:#999;font-size:12px;">未关联</span>
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/iam-users/${row.id}/policies`)">
|
||||
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" min-width="110" align="center">
|
||||
@ -161,10 +155,6 @@
|
||||
<el-switch v-model="configForm.auto_disable_enabled" />
|
||||
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="免除 Deny 策略">
|
||||
<el-switch v-model="configForm.deny_policy_exempt" />
|
||||
<span class="switch-hint">{{ configForm.deny_policy_exempt ? '不限制项目访问(管理员账号)' : '按关联项目限制访问' }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">取消</el-button>
|
||||
@ -433,21 +423,7 @@ async function handleEditProfile() {
|
||||
|
||||
// (Policies dialog removed - now in UserPoliciesView)
|
||||
|
||||
// Volcengine projects (for create dialog)
|
||||
const volcProjects = ref([])
|
||||
const volcProjectsLoading = ref(false)
|
||||
|
||||
async function loadVolcProjects() {
|
||||
volcProjectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error('获取火山项目列表失败')
|
||||
} finally {
|
||||
volcProjectsLoading.value = false
|
||||
}
|
||||
}
|
||||
// (Projects dialog removed - now in UserPoliciesView)
|
||||
|
||||
// --- Allocate ---
|
||||
const maxDeduct = computed(() => {
|
||||
@ -498,7 +474,6 @@ function openConfig(row) {
|
||||
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
|
||||
monitor_enabled: row.monitor_enabled,
|
||||
auto_disable_enabled: row.auto_disable_enabled,
|
||||
deny_policy_exempt: row.deny_policy_exempt || false,
|
||||
}
|
||||
newStep.value = null
|
||||
configVisible.value = true
|
||||
|
||||
@ -23,25 +23,14 @@
|
||||
<el-form-item label="监控间隔(秒)">
|
||||
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">飞书通知</el-divider>
|
||||
<el-form-item label="飞书 App ID">
|
||||
<el-input v-model="config.feishu_app_id" placeholder="cli_xxxxxxxx" />
|
||||
<el-form-item label="飞书 Webhook URL">
|
||||
<el-input v-model="config.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="飞书 App Secret">
|
||||
<el-input v-model="config.feishu_app_secret" type="password" show-password
|
||||
placeholder="飞书自建应用的密钥" />
|
||||
</el-form-item>
|
||||
<el-form-item label="告警接收手机号">
|
||||
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号,多个用逗号分隔" />
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||
填写飞书用户的手机号,告警会以私信卡片发送
|
||||
</div>
|
||||
<el-form-item label="飞书通知手机号">
|
||||
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号1,手机号2" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button>
|
||||
<el-button @click="testFeishu" :loading="testingFeishu" style="margin-left:12px;">
|
||||
测试飞书通知
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@ -157,20 +146,6 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const testingFeishu = ref(false)
|
||||
|
||||
async function testFeishu() {
|
||||
testingFeishu.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/config/test-feishu/')
|
||||
ElMessage.success(data.message)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '测试失败')
|
||||
} finally {
|
||||
testingFeishu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
loadingAccounts.value = true
|
||||
try {
|
||||
|
||||
220
操作说明.md
220
操作说明.md
@ -42,120 +42,146 @@ npm run dev
|
||||
|
||||
## 二、管理员操作
|
||||
|
||||
### 1. 创建子账号
|
||||
### 给新部门开通子账号
|
||||
|
||||
**步骤 1:创建子账号**
|
||||
|
||||
1. 左侧菜单 → **子账号管理** → 点 **创建子账号**
|
||||
2. 填写:
|
||||
- **用户名**:英文,如 `dept_video`
|
||||
- **显示名**:如 `视频部门`
|
||||
- **手机号**:可选
|
||||
- **火山控制台密码**:需包含大小写字母、数字和特殊字符,8位以上(火山密码策略要求)
|
||||
- **火山控制台密码**:不填(子账号不需要登录火山控制台)
|
||||
- 其他选填
|
||||
3. 点 **创建**
|
||||
4. 系统自动在火山创建 IAM 用户,并自动生成 `AirGate_Deny_{username}` 策略用于项目隔离
|
||||
4. 弹窗显示 IAM API 密钥 → **立即复制保存**(SecretKey 仅显示一次)
|
||||
|
||||
### 2. 权限管理
|
||||
**步骤 2:在火山控制台创建项目并开通模型**
|
||||
|
||||
统一权限管理页面,一页展示所有权限信息:
|
||||
1. 登录 `console.volcengine.com`(你的主账号)
|
||||
2. 项目管理 → 新建项目(如 `team-video-1`)
|
||||
3. 进入方舟平台 → 切到该项目 → 开通 Seedance 2.0 端点
|
||||
4. 在该项目下创建方舟 API Key → **复制完整 Key**
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **权限管理**
|
||||
2. 页面分区:
|
||||
- **全局策略**:当前用户挂载的全局策略列表(实时从火山查询)
|
||||
- **项目级策略**:当前用户在各项目下挂载的策略(实时从火山查询)
|
||||
- **关联项目**:管理用户关联的火山项目(添加/移除)
|
||||
- **添加/移除策略**:为用户附加或移除 IAM 策略
|
||||
3. 添加关联项目时自动更新 Deny 策略(将新项目加入白名单)
|
||||
4. 移除关联项目时自动更新 Deny 策略(将项目从白名单移除)
|
||||
**步骤 3:在 AirGate 关联项目并授权**
|
||||
|
||||
### 3. 额度划拨
|
||||
1. 回到 AirGate → 子账号管理 → 找到刚创建的子账号
|
||||
2. 点 **更多 → 项目管理**
|
||||
3. 从下拉框选择刚创建的项目 → 点 **添加**
|
||||
4. 点项目行上的 **授权** 按钮 → 勾选需要的策略(如 `ArkFullAccess` + `TOSFullAccess`)→ 保存
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **划拨**
|
||||
2. 输入正数追加、负数扣减
|
||||
3. **必须填写备注** → 确认
|
||||
|
||||
> 扣减有保护:总额度不能低于已消费金额
|
||||
|
||||
### 4. 监控配置
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **监控配置**
|
||||
2. 可配置项:
|
||||
- **阶梯告警**:自定义告警百分比阶梯(如 50,80,90),未设置则使用全局默认值
|
||||
- **消费监控开关**:开启/关闭该用户的消费监控
|
||||
- **自动停用开关**:额度用尽时是否自动停用(关闭则只告警不停用)
|
||||
- **Deny策略免除开关**:管理员自用账号可开启,免除 Deny 策略限制
|
||||
|
||||
### 5. 停用/恢复账号
|
||||
|
||||
**停用:**
|
||||
1. 子账号管理 → 更多 → **停用账号**
|
||||
2. 系统自动执行:
|
||||
- 关闭火山控制台登录
|
||||
- 停用所有 API Key
|
||||
- 移除所有权限策略
|
||||
- 保存策略快照(区分全局策略/项目级策略 + 登录状态),用于恢复
|
||||
|
||||
**恢复:**
|
||||
1. 子账号管理 → 更多 → **恢复账号**
|
||||
2. 系统自动执行:
|
||||
- 从快照还原所有权限策略(全局+项目级)
|
||||
- 重建 `AirGate_Deny_{username}` 策略
|
||||
- 按停用前状态恢复火山控制台登录(停用前已关闭的不会自动打开)
|
||||
|
||||
### 6. 火山登录开关
|
||||
|
||||
1. 子账号管理 → 更多 → **火山登录开关**
|
||||
2. 独立于停用/恢复操作,可随时开启或关闭子账号的火山控制台登录权限
|
||||
3. 同步调用火山 `UpdateLoginProfile` API
|
||||
|
||||
### 7. 编辑子账号信息
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **编辑**
|
||||
2. 可修改:显示名、手机号、邮箱
|
||||
3. 修改后自动同步到火山引擎(调用 `UpdateUser` API)
|
||||
|
||||
### 8. API Key 管理
|
||||
**步骤 4:录入 API Key**
|
||||
|
||||
1. 左侧菜单 → **API Key 管理** → 点 **录入 API Key**
|
||||
2. 选择子账号、所属项目
|
||||
3. 填写名称/用途、粘贴完整的 API Key
|
||||
4. 点 **录入** → Key 加密存储
|
||||
|
||||
> API Key 采用手动录入方式。管理员在火山控制台创建 Key 后,将明文录入 AirGate。
|
||||
> 原因:火山 `CreateApiKey` API 不返回 Key 明文,`ListApiKeys` 只返回脱敏值。
|
||||
**步骤 5:划拨额度**
|
||||
|
||||
操作:查看明文 / 启用 / 停用 / 删除,可按子账号、项目筛选。
|
||||
1. 子账号管理 → 点子账号的 **划拨** 按钮
|
||||
2. 输入金额(如 100000)和备注 → 确认
|
||||
|
||||
### 9. 系统管理
|
||||
**步骤 6:设置子账号的 AirGate 登录密码**
|
||||
|
||||
1. **修改密码**:左侧菜单 → 系统管理 → 修改密码
|
||||
2. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码
|
||||
3. **操作日志**:查看所有系统操作记录(含类型筛选)
|
||||
1. 子账号管理 → 更多 → **登录密码**
|
||||
2. 输入密码 → 开启「允许登录」→ 保存
|
||||
|
||||
**步骤 7:告知对方**
|
||||
|
||||
发给对方以下信息:
|
||||
- AirGate 登录地址:`http://你的部署地址`
|
||||
- 登录方式:选择「子账号登录」
|
||||
- 用户名:`dept_video`
|
||||
- 密码:你设置的密码
|
||||
- 登录后在「我的 API Key」中查看和复制 Key
|
||||
|
||||
---
|
||||
|
||||
## 三、Deny策略说明
|
||||
### 给子账号追加/扣减额度
|
||||
|
||||
### 原理
|
||||
1. 子账号管理 → 找到目标用户 → 点 **划拨**
|
||||
2. 输入正数追加、负数扣减
|
||||
3. 填备注 → 确认
|
||||
|
||||
AirGate 通过 Deny 策略实现项目隔离。原理:列出火山账号下所有项目,排除用户的白名单项目(已关联项目),对其余项目全部 Deny。
|
||||
|
||||
### 自动管理
|
||||
|
||||
- **创建子账号时**:自动生成 `AirGate_Deny_{username}` 策略
|
||||
- **添加关联项目时**:自动更新所有子账号的 Deny 策略,将新项目加入白名单
|
||||
- **移除关联项目时**:自动更新 Deny 策略,将项目从白名单移除
|
||||
- **项目变动时**:刷新所有用户的 Deny 策略(确保新增的火山项目也被 Deny)
|
||||
|
||||
### Deny策略免除
|
||||
|
||||
管理员自用账号可在监控配置中开启「Deny策略免除」开关,免除 Deny 策略限制,允许访问所有项目。
|
||||
|
||||
### 为什么用 Deny 策略
|
||||
|
||||
火山 Open API 的 `AttachUserPolicy` 不支持 `Scope=Project` 参数(2026-03-28 实测)。即使传了 `ProjectName` + `Scope=Project`,策略仍以 Global 方式挂载。项目级策略只能在火山控制台网页上手动操作。因此 AirGate 的项目隔离完全依赖 Deny 策略实现。
|
||||
> 扣减有保护:总额度不能低于已消费金额
|
||||
|
||||
---
|
||||
|
||||
## 四、子账号操作
|
||||
### 给子账号增加新项目
|
||||
|
||||
1. 先在火山控制台创建新项目 + 开通模型端点 + 创建 API Key
|
||||
2. 回到 AirGate → 子账号管理 → 更多 → **项目管理** → 添加项目
|
||||
3. 点 **授权** 选择策略
|
||||
4. 去 **API Key 管理** 录入新的 Key
|
||||
|
||||
---
|
||||
|
||||
### 管理子账号的项目级权限
|
||||
|
||||
1. 子账号管理 → 更多 → **项目管理**
|
||||
2. 找到目标项目 → 点 **授权**
|
||||
3. 勾选/取消策略 → 保存(增量更新:只添加新的、移除取消的)
|
||||
|
||||
---
|
||||
|
||||
### 关闭某个项目的监测
|
||||
|
||||
1. 子账号管理 → 更多 → **项目管理**
|
||||
2. 找到目标项目 → 关闭「监测」开关
|
||||
3. 该项目的消费不再计入子账号的累计消费
|
||||
|
||||
---
|
||||
|
||||
### 手动停用/恢复子账号
|
||||
|
||||
**停用:**
|
||||
1. 子账号管理 → 更多 → **停用账号**
|
||||
2. 系统自动执行三步:停用控制台登录 + 停用所有 API Key + 移除所有权限策略
|
||||
3. 子账号刷新页面后立即无法操作
|
||||
|
||||
**恢复:**
|
||||
1. 子账号管理 → 更多 → **恢复账号**
|
||||
2. 自动恢复控制台登录 + API Key + 权限策略(从停用时的快照恢复)
|
||||
|
||||
---
|
||||
|
||||
### 查看/管理 API Key
|
||||
|
||||
1. 左侧菜单 → **API Key 管理**
|
||||
2. 可按子账号、项目筛选
|
||||
3. 操作:查看明文 / 启用 / 停用 / 删除
|
||||
|
||||
---
|
||||
|
||||
### 查看消费明细
|
||||
|
||||
1. 左侧菜单 → **消费监控**
|
||||
2. 表格展示每个子账号的累计消费、额度、使用率
|
||||
3. 点行首展开查看各项目的独立消费
|
||||
4. 点 **刷新消费数据** 手动触发查询
|
||||
5. 点 **查看主账号余额** 查看可用余额
|
||||
|
||||
> 消费数据来自火山 Billing API,有 1-2 天延迟
|
||||
|
||||
---
|
||||
|
||||
### 查看告警/操作日志
|
||||
|
||||
1. 左侧菜单 → **告警记录** — 查看告警和自动停用记录
|
||||
2. 左侧菜单 → **系统管理** → **操作日志** — 查看所有操作记录(含类型筛选)
|
||||
|
||||
---
|
||||
|
||||
### 管理员账号管理
|
||||
|
||||
1. 左侧菜单 → **系统管理**
|
||||
2. **修改密码**:修改当前管理员的密码
|
||||
3. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码
|
||||
4. **操作日志**:查看所有系统操作记录
|
||||
|
||||
---
|
||||
|
||||
## 三、子账号操作
|
||||
|
||||
> 子账号使用独立的登录入口,不需要登录火山控制台。
|
||||
|
||||
@ -201,7 +227,7 @@ response = requests.post(url, headers=headers, json={...})
|
||||
|
||||
---
|
||||
|
||||
## 五、告警与自动停用机制
|
||||
## 四、告警与自动停用机制
|
||||
|
||||
```
|
||||
定时任务每小时运行一次
|
||||
@ -228,7 +254,7 @@ response = requests.post(url, headers=headers, json={...})
|
||||
|
||||
---
|
||||
|
||||
## 六、外部系统对接(AirDrama)
|
||||
## 五、外部系统对接(AirDrama)
|
||||
|
||||
AirGate 支持通过 API Key 认证供外部系统调用:
|
||||
|
||||
@ -245,14 +271,12 @@ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/
|
||||
|
||||
---
|
||||
|
||||
## 七、火山API限制
|
||||
## 六、注意事项
|
||||
|
||||
1. **项目级策略(Scope=Project)通过API无法设置**:需在火山控制台手动操作,AirGate 的项目隔离完全依赖 Deny 策略
|
||||
2. **火山控制台密码要求**:需包含大小写字母、数字和特殊字符,8位以上
|
||||
3. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量
|
||||
4. **IAM SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了
|
||||
5. **方舟 API Key 由管理员录入**:火山 API 不返回 Key 明文,需要在火山控制台创建后手动录入 AirGate
|
||||
6. **子账号不登录火山控制台**:所有操作通过 AirGate 完成,避免权限泄露
|
||||
7. **项目由管理员创建**:子账号没有创建项目的权限,需要新项目时联系管理员
|
||||
8. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭
|
||||
9. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的密钥无法解密
|
||||
1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量
|
||||
2. **IAM SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了
|
||||
3. **方舟 API Key 由管理员录入**:火山 API 不返回 Key 明文,需要在火山控制台创建后手动录入 AirGate
|
||||
4. **子账号不登录火山控制台**:所有操作通过 AirGate 完成,避免权限泄露
|
||||
5. **项目由管理员创建**:子账号没有创建项目的权限,需要新项目时联系管理员
|
||||
6. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭
|
||||
7. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的密钥无法解密
|
||||
|
||||
@ -546,22 +546,7 @@ iam_client.call("UpdateAccessKey", {
|
||||
- 管理员可按需开关某些项目的监测(如测试项目不计费)
|
||||
- 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度
|
||||
|
||||
**消费查询方式:** 使用 `ListSplitBillDetail`(分账账单)接口,按 Project 字段汇总。
|
||||
|
||||
> ⚠️ **重要发现(2026-03-29 实测)**:
|
||||
> - `ListBillDetail`(账单明细)的 Project 字段对 Seedance 等按量付费产品显示为 `-`,**按项目筛选不准确**
|
||||
> - `ListSplitBillDetail`(分账账单)的 Project 字段能正确归属到项目,**与火山控制台分账账单页面一致**
|
||||
> - AirGate 已切换为使用 `ListSplitBillDetail` 查询消费
|
||||
|
||||
**ListSplitBillDetail 验证结果(2026-03):**
|
||||
|
||||
| 项目 | API 查询 | 火山控制台 |
|
||||
|------|---------|----------|
|
||||
| int_dev_Airlabs | ¥24,058.78 | ¥24,014.14 |
|
||||
| HAGOOT_DEV | ¥40.01 | ¥40.01 |
|
||||
| zyc_test | ¥124.08 | - |
|
||||
|
||||
差异为查询时间点不同导致,数据一致。
|
||||
**消费查询方式:** 对每个开启监测的项目分别调用 `ListBillDetail`(按 Project 字段筛选),累加得出总消费。同时记录每个项目的独立消费,前端可展开查看明细。
|
||||
|
||||
### 6.4 账户余额查询
|
||||
|
||||
@ -1247,15 +1232,6 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权
|
||||
|
||||
**结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目",必须在应用层(AirGate)控制。
|
||||
|
||||
### 13.1.2 火山Open API不支持Scope=Project(2026-03-28 实测)
|
||||
|
||||
| 测试场景 | 结果 |
|
||||
|----------|------|
|
||||
| `AttachUserPolicy` 传 `Scope=Project` + `ProjectName=xxx` | 策略仍以 **Global** 方式挂载,`ListAttachedUserPolicies` 查询显示 Scope=Global |
|
||||
| 火山控制台网页上手动「限制到项目资源」 | 策略正确以 Project 方式挂载 |
|
||||
|
||||
**结论**:火山 Open API 的 `AttachUserPolicy` 即使传了 `Scope=Project` + `ProjectName` 参数,策略仍然以 Global 方式挂载。项目级策略(Scope=Project)只能在火山控制台网页上手动操作(点击「限制到项目资源」按钮)。因此 AirGate 无法通过 API 实现项目级授权,项目隔离完全依赖 Deny 策略实现。
|
||||
|
||||
### 13.1.1 跨项目 API 访问问题(2026-03-28 实测)
|
||||
|
||||
| 测试场景 | 结果 |
|
||||
@ -1292,40 +1268,26 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权
|
||||
| `ListApiKeys` 指定 `int_dev_Airlabs` | 返回 1 个 Key | **被拒绝** ✅ |
|
||||
| `ListAssetGroups` 指定 `HAGOOT_DEV` | 正常返回 | 正常返回 ✅ |
|
||||
|
||||
### 13.2 最终权限方案:全局授权 + Deny策略隔离
|
||||
### 13.2 最终权限方案
|
||||
|
||||
```
|
||||
子账号在火山引擎上的权限(由 AirGate 自动管理):
|
||||
|
||||
核心思路:全局授权 + Deny策略隔离
|
||||
由于火山 Open API 不支持 Scope=Project(见 13.1.2),
|
||||
所有策略以全局方式挂载,再通过 Deny 策略限定可访问的项目范围。
|
||||
|
||||
全局权限:
|
||||
├── AccessKeySelfManageAccess ← 管理自己的 AK/SK(可选)
|
||||
└── AirGate_Deny_{username} ← 自定义 Deny 策略,禁止访问非授权项目
|
||||
使用 NotResource 限定只能访问已关联的项目
|
||||
|
||||
全局业务权限(通过 AttachUserPolicy,全局生效):
|
||||
├── ArkFullAccess ← 方舟操作权限(全局,但被 Deny 策略限定到白名单项目)
|
||||
└── TOSFullAccess ← TOS 操作权限(按需)
|
||||
|
||||
⚠️ 重要发现(2026-03-28 实测):
|
||||
火山 Open API 的 AttachUserPolicy 不支持 Scope=Project 参数。
|
||||
即使传了 ProjectName + Scope=Project,策略仍然以 Global 方式挂载。
|
||||
项目级限制只能在火山控制台网页上手动操作(「限制到项目资源」按钮)。
|
||||
因此 AirGate 的项目隔离完全依赖 Deny 策略实现。
|
||||
项目级权限(通过 AttachUserPolicy + ProjectName):
|
||||
├── ArkFullAccess ← API 层面有完整方舟操作权限
|
||||
└── TOSFullAccess ← API 层面有 TOS 操作权限(按需)
|
||||
|
||||
火山控制台登录:默认关闭(AirGate 提供开关可随时切换)
|
||||
|
||||
Deny 策略自动管理(项目隔离的唯一可靠手段):
|
||||
- 创建子账号时 → 自动创建 AirGate_Deny_{username} 策略
|
||||
- 添加关联项目时 → 自动更新 Deny 策略,将新项目加入白名单
|
||||
- 移除关联项目时 → 自动更新 Deny 策略,将项目从白名单移除
|
||||
- 火山项目变动时 → 刷新所有用户的 Deny 策略
|
||||
- Deny 策略列出所有非白名单项目并明确拒绝
|
||||
Deny 策略自动管理:
|
||||
- 添加关联项目时 → 自动将项目加入 NotResource 白名单
|
||||
- 移除关联项目时 → 自动将项目从 NotResource 白名单移除
|
||||
- 策略命名:AirGate_Deny_{username}
|
||||
- 管理员自用账号可免除 Deny 策略(监控配置中开启)
|
||||
```
|
||||
|
||||
子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成:
|
||||
|
||||
20
版本管理.md
20
版本管理.md
@ -2,26 +2,6 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0 (2026-03-28 ~ 2026-03-29)
|
||||
|
||||
### 权限管理重构
|
||||
- feat: 统一权限管理页面(全局策略 + 项目级策略 + 关联项目 + 添加/移除策略,一页展示)
|
||||
- feat: Deny策略自动化(项目隔离)—— 创建子账号时自动生成 `AirGate_Deny_{username}`,添加/移除项目时自动更新
|
||||
- feat: Deny策略免除开关(管理员自用账号可在监控配置中开启)
|
||||
- fix: 火山API不支持Scope=Project(2026-03-28实测),改用全局授权 + Deny策略实现项目隔离
|
||||
- fix: 项目变动刷新所有用户Deny策略
|
||||
- fix: 前后端权限显示一致(实时从火山查询,不再依赖本地缓存)
|
||||
|
||||
### 账号管理增强
|
||||
- feat: 火山控制台登录开关(独立于停用/恢复,随时切换)
|
||||
- feat: 编辑子账号信息(显示名、手机号、邮箱,修改后同步火山 `UpdateUser` API)
|
||||
- feat: 创建子账号密码校验(火山密码策略:大小写字母 + 数字 + 特殊字符,8位以上)
|
||||
- fix: 停用/恢复保存策略快照(区分全局策略/项目级策略 + 登录状态,恢复时精确还原)
|
||||
- fix: 同步不再把火山登录关闭当成账号停用
|
||||
- fix: 检测幽灵LoginProfile(CreateDate=1970,火山API已知问题)
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0 (2026-03-20)
|
||||
|
||||
### UI 优化
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user