Compare commits

...

10 Commits

Author SHA1 Message Date
seaislee1209
236e082349 ui: show project names in IAMUserList instead of count
Display project name tags (green=monitoring, grey=not) with a
'manage' link to the policy page. Much more informative at a glance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:09:57 +08:00
seaislee1209
9e81717e08 feat: switch feishu alerts from Webhook to App (private message)
- Replace Webhook with App ID + App Secret + mobile number
- Reuse AirDrama's feishu app (send private card messages)
- Add test button in system settings
- Add test-feishu API endpoint
- Default monitor interval changed to 60 seconds
- Token caching for feishu tenant_access_token

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:56:01 +08:00
seaislee1209
6b3a0bda34 fix: sync removes users that no longer exist on Volcengine
- Track all Volcengine usernames during sync
- Delete local users not found on Volcengine (cascade deletes related data)
- Report removed users in sync response
- Deleted test_audit and tudouceshi from local DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:45:14 +08:00
seaislee1209
610058ae5f feat: switch billing to ListSplitBillDetail for accurate project spending
- BillingService now uses ListSplitBillDetail (split bill) instead of
  ListBillDetail (bill detail) - the latter shows Project='-' for
  Seedance pay-as-you-go products
- Added get_spending_all_projects() for batch query (avoids N+1 API calls)
- Scheduler optimized: single API call fetches all project spending
- Verified: amounts match Volcengine console split bill page exactly
- Updated research report with billing API findings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:42:08 +08:00
seaislee1209
294a0885ff docs: update operation guide, changelog, and research report
- Rewrite 操作说明.md with full admin/sub-account operation flow
- Add v0.5.0 to 版本管理.md (permission refactor + account enhancements)
- Add Scope=Project API limitation finding to research report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 02:06:00 +08:00
seaislee1209
9f00e6996b feat: add Deny policy exempt toggle for admin sub-accounts
- New deny_policy_exempt field on IAMUser model
- Toggle in monitoring config dialog
- Exempt accounts skip Deny policy creation
- Changing the toggle immediately updates/removes Deny policy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:01:35 +08:00
seaislee1209
5b997bc1a7 fix: restore volcProjects for create dialog (was removed during cleanup)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:44:34 +08:00
seaislee1209
6f4d7e6b5b fix: refresh ALL users' Deny policies on project changes
When a project is added/removed for any user, all users' Deny
policies must be updated - new projects need to be added to other
users' deny lists to prevent unauthorized cross-project access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:29:54 +08:00
seaislee1209
d7b40beff7 fix: Volcengine API does not support project-level policy scope
AttachUserPolicy ignores Scope=Project parameter - policies always
attach globally. Project isolation now relies entirely on Deny policy
(AirGate_Deny_{username}) which blocks access to non-whitelisted projects.

Updated report with this finding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:21:08 +08:00
seaislee1209
765c80a47a fix: project policy update compares against Volcengine actual state
Was comparing against local DB which could be stale.
Now queries Volcengine for actual project-level policies before diffing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:10:13 +08:00
15 changed files with 576 additions and 198 deletions

View File

@ -0,0 +1,18 @@
# 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 策略'),
),
]

View File

@ -0,0 +1,38 @@
# 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='监控间隔(秒)'),
),
]

View File

@ -62,6 +62,9 @@ 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='停用时自动移除的策略列表,恢复时加回')
@ -181,9 +184,13 @@ 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=3600)
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)
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)
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View File

@ -42,6 +42,7 @@ 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',
@ -77,6 +78,7 @@ 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):
@ -119,7 +121,8 @@ class GlobalConfigSerializer(serializers.ModelSerializer):
'default_alert_thresholds',
'default_project_policies',
'monitor_interval_seconds',
'feishu_webhook_url', 'feishu_alert_mobiles',
'feishu_app_id', 'feishu_app_secret',
'feishu_alert_mobiles',
'updated_at',
]
read_only_fields = ['updated_at']

View File

@ -45,6 +45,7 @@ urlpatterns = [
# Global config
path('config/', views.global_config_view),
path('config/test-feishu/', views.test_feishu_view),
# Alerts
path('alerts/', views.alert_list_view),

View File

@ -50,6 +50,12 @@ 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)
)
@ -178,6 +184,7 @@ def iam_user_sync_view(request):
svc = IAMService(ak, sk)
imported = []
volc_usernames = set()
offset = 0
while True:
@ -193,6 +200,7 @@ 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,
@ -247,10 +255,22 @@ 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': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)}',
'message': msg,
'imported': imported,
'removed': removed,
'total': total_count,
})
@ -347,8 +367,8 @@ def iam_user_create_view(request):
monitor_enabled=True,
)
# 7. Create Deny policy (project isolation)
_update_deny_policy(obj)
# 7. Create Deny policy (project isolation) + refresh all users
_refresh_all_deny_policies()
AlertRecord.objects.create(
iam_user=obj,
@ -420,9 +440,16 @@ 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)
@ -948,8 +975,8 @@ def iam_user_project_add_view(request, pk):
obj.attached_policies = attached
obj.save(update_fields=['attached_policies'])
# 更新 Deny 策略(将新项目加入白名单
_update_deny_policy(user)
# 更新所有子账号的 Deny 策略(新项目需要加入其他人的拒绝列表
_refresh_all_deny_policies()
AlertRecord.objects.create(
iam_user=user,
@ -994,19 +1021,35 @@ 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 old_policies if p not in new_policies]
to_remove = [p for p in actual_old 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)
@ -1015,7 +1058,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 old_policies]
to_add = [p for p in new_policies if p not in actual_old]
for policy_name in to_add:
try:
svc.attach_policy_in_project(user.username, policy_name, project.project_name)
@ -1078,8 +1121,8 @@ def iam_user_project_delete_view(request, pk, pid):
project.delete()
# 更新 Deny 策略(将移除的项目从白名单中删除)
_update_deny_policy(user)
# 更新所有子账号的 Deny 策略
_refresh_all_deny_policies()
result = {'message': f'已移除项目 {name},已回收权限: {detached}'}
if detach_errors:
@ -1224,6 +1267,28 @@ 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'])

View File

@ -8,16 +8,21 @@ logger = logging.getLogger(__name__)
class BillingService:
"""封装火山引擎 Billing API"""
"""封装火山引擎 Billing API
使用 ListSplitBillDetail分账账单而非 ListBillDetail账单明细
因为后者的 Project 字段对 Seedance 等按量付费产品显示为 '-'不准确
分账账单能正确按项目归属消费与火山控制台分账账单页面一致
"""
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 = 300
page_size = 100
while True:
params = {
@ -25,25 +30,52 @@ class BillingService:
"Limit": str(page_size),
"Offset": str(offset),
"GroupTerm": "0",
"GroupPeriod": "0",
"NeedRecordNum": "1",
"GroupPeriod": "2",
}
result = self.client.call("ListBillDetail", params)
result = self.client.call("ListSplitBillDetail", params)
items = result.get("Result", {}).get("List", [])
record_num = int(result.get("Result", {}).get("Total", 0))
for item in items:
if project_name and item.get("Project") != project_name:
item_project = item.get("Project", "-")
if project_name and item_project != project_name:
continue
amount = item.get("PayableAmount", "0")
total += Decimal(str(amount))
offset += page_size
if offset >= record_num or not items:
if len(items) < page_size:
break
offset += page_size
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", {

View File

@ -1,42 +1,171 @@
"""飞书机器人通知"""
"""飞书自建应用通知(复用 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():
payload = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": template,
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,
},
"elements": [
'elements': [
{
"tag": "div",
"text": {"tag": "lark_md", "content": content},
'tag': 'div',
'text': {'tag': 'lark_md', 'content': content},
}
],
},
}
try:
resp = requests.post(webhook_url, json=payload, timeout=10)
resp.raise_for_status()
logger.info(f"飞书通知已发送: {title}")
}
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}')
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)

View File

@ -92,24 +92,22 @@ 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) ===

View File

@ -31,6 +31,13 @@ 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,
@ -38,7 +45,7 @@ def check_spending():
for user in users:
try:
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
# --- 遍历所有开启监测的项目,从批量结果中获取消费 ---
enabled_projects = IAMUserProject.objects.filter(
iam_user=user, monitor_enabled=True
)
@ -50,13 +57,9 @@ def check_spending():
total_spending = Decimal('0')
for project in enabled_projects:
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 # 保留上次值
proj_spending = all_project_spending.get(
project.project_name, project.current_spending
)
# 更新项目级消费
project.current_spending = proj_spending

View File

@ -51,11 +51,17 @@
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="项目" min-width="80" align="center">
<el-table-column label="关联项目" min-width="180">
<template #default="{ row }">
<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>
<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>
</template>
</el-table-column>
<el-table-column label="告警" min-width="110" align="center">
@ -155,6 +161,10 @@
<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>
@ -423,7 +433,21 @@ async function handleEditProfile() {
// (Policies dialog removed - now in UserPoliciesView)
// (Projects 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
}
}
// --- Allocate ---
const maxDeduct = computed(() => {
@ -474,6 +498,7 @@ 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

View File

@ -23,14 +23,25 @@
<el-form-item label="监控间隔(秒)">
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
</el-form-item>
<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-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>
<el-form-item label="飞书通知手机号">
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号1,手机号2" />
<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>
<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>
@ -146,6 +157,20 @@ 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 {

View File

@ -42,146 +42,120 @@ npm run dev
## 二、管理员操作
### 给新部门开通子账号
**步骤 1创建子账号**
### 1. 创建子账号
1. 左侧菜单 → **子账号管理** → 点 **创建子账号**
2. 填写:
- **用户名**:英文,如 `dept_video`
- **显示名**:如 `视频部门`
- **火山控制台密码**:不填(子账号不需要登录火山控制台)
- 其他选填
- **手机号**:可选
- **火山控制台密码**需包含大小写字母、数字和特殊字符8位以上火山密码策略要求
3. 点 **创建**
4. 弹窗显示 IAM API 密钥 → **立即复制保存**SecretKey 仅显示一次)
4. 系统自动在火山创建 IAM 用户,并自动生成 `AirGate_Deny_{username}` 策略用于项目隔离
**步骤 2在火山控制台创建项目并开通模型**
### 2. 权限管理
1. 登录 `console.volcengine.com`(你的主账号)
2. 项目管理 → 新建项目(如 `team-video-1`
3. 进入方舟平台 → 切到该项目 → 开通 Seedance 2.0 端点
4. 在该项目下创建方舟 API Key → **复制完整 Key**
统一权限管理页面,一页展示所有权限信息:
**步骤 3在 AirGate 关联项目并授权**
1. 子账号管理 → 找到目标用户 → 点 **权限管理**
2. 页面分区:
- **全局策略**:当前用户挂载的全局策略列表(实时从火山查询)
- **项目级策略**:当前用户在各项目下挂载的策略(实时从火山查询)
- **关联项目**:管理用户关联的火山项目(添加/移除)
- **添加/移除策略**:为用户附加或移除 IAM 策略
3. 添加关联项目时自动更新 Deny 策略(将新项目加入白名单)
4. 移除关联项目时自动更新 Deny 策略(将项目从白名单移除)
1. 回到 AirGate → 子账号管理 → 找到刚创建的子账号
2. 点 **更多 → 项目管理**
3. 从下拉框选择刚创建的项目 → 点 **添加**
4. 点项目行上的 **授权** 按钮 → 勾选需要的策略(如 `ArkFullAccess` + `TOSFullAccess`)→ 保存
### 3. 额度划拨
**步骤 4录入 API Key**
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 管理
1. 左侧菜单 → **API Key 管理** → 点 **录入 API Key**
2. 选择子账号、所属项目
3. 填写名称/用途、粘贴完整的 API Key
4. 点 **录入** → Key 加密存储
**步骤 5划拨额度**
> API Key 采用手动录入方式。管理员在火山控制台创建 Key 后,将明文录入 AirGate。
> 原因:火山 `CreateApiKey` API 不返回 Key 明文,`ListApiKeys` 只返回脱敏值。
1. 子账号管理 → 点子账号的 **划拨** 按钮
2. 输入金额(如 100000和备注 → 确认
操作:查看明文 / 启用 / 停用 / 删除,可按子账号、项目筛选。
**步骤 6设置子账号的 AirGate 登录密码**
### 9. 系统管理
1. 子账号管理 → 更多 → **登录密码**
2. 输入密码 → 开启「允许登录」→ 保存
**步骤 7告知对方**
发给对方以下信息:
- AirGate 登录地址:`http://你的部署地址`
- 登录方式:选择「子账号登录」
- 用户名:`dept_video`
- 密码:你设置的密码
- 登录后在「我的 API Key」中查看和复制 Key
1. **修改密码**:左侧菜单 → 系统管理 → 修改密码
2. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码
3. **操作日志**:查看所有系统操作记录(含类型筛选)
---
### 给子账号追加/扣减额度
## 三、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. **操作日志**:查看所有系统操作记录
---
## 三、子账号操作
## 四、子账号操作
> 子账号使用独立的登录入口,不需要登录火山控制台。
@ -227,7 +201,7 @@ response = requests.post(url, headers=headers, json={...})
---
## 、告警与自动停用机制
## 、告警与自动停用机制
```
定时任务每小时运行一次
@ -254,7 +228,7 @@ response = requests.post(url, headers=headers, json={...})
---
## 、外部系统对接AirDrama
## 、外部系统对接AirDrama
AirGate 支持通过 API Key 认证供外部系统调用:
@ -271,12 +245,14 @@ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/
---
## 六、注意事项
## 七、火山API限制
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` 丢失后,已存储的密钥无法解密
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` 丢失后,已存储的密钥无法解密

View File

@ -546,7 +546,22 @@ iam_client.call("UpdateAccessKey", {
- 管理员可按需开关某些项目的监测(如测试项目不计费)
- 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度
**消费查询方式:** 对每个开启监测的项目分别调用 `ListBillDetail`(按 Project 字段筛选),累加得出总消费。同时记录每个项目的独立消费,前端可展开查看明细。
**消费查询方式:** 使用 `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 | - |
差异为查询时间点不同导致,数据一致。
### 6.4 账户余额查询
@ -1232,6 +1247,15 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权
**结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目"必须在应用层AirGate控制。
### 13.1.2 火山Open API不支持Scope=Project2026-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 实测)
| 测试场景 | 结果 |
@ -1268,26 +1292,40 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权
| `ListApiKeys` 指定 `int_dev_Airlabs` | 返回 1 个 Key | **被拒绝** ✅ |
| `ListAssetGroups` 指定 `HAGOOT_DEV` | 正常返回 | 正常返回 ✅ |
### 13.2 最终权限方案
### 13.2 最终权限方案:全局授权 + Deny策略隔离
```
子账号在火山引擎上的权限(由 AirGate 自动管理):
核心思路:全局授权 + Deny策略隔离
由于火山 Open API 不支持 Scope=Project见 13.1.2
所有策略以全局方式挂载,再通过 Deny 策略限定可访问的项目范围。
全局权限:
├── AccessKeySelfManageAccess ← 管理自己的 AK/SK可选
└── AirGate_Deny_{username} ← 自定义 Deny 策略,禁止访问非授权项目
使用 NotResource 限定只能访问已关联的项目
项目级权限(通过 AttachUserPolicy + ProjectName
├── ArkFullAccess ← API 层面有完整方舟操作权限
└── TOSFullAccess ← API 层面有 TOS 操作权限(按需)
全局业务权限(通过 AttachUserPolicy全局生效
├── ArkFullAccess ← 方舟操作权限(全局,但被 Deny 策略限定到白名单项目)
└── TOSFullAccess ← TOS 操作权限(按需)
⚠️ 重要发现2026-03-28 实测):
火山 Open API 的 AttachUserPolicy 不支持 Scope=Project 参数。
即使传了 ProjectName + Scope=Project策略仍然以 Global 方式挂载。
项目级限制只能在火山控制台网页上手动操作(「限制到项目资源」按钮)。
因此 AirGate 的项目隔离完全依赖 Deny 策略实现。
火山控制台登录默认关闭AirGate 提供开关可随时切换)
Deny 策略自动管理:
- 添加关联项目时 → 自动将项目加入 NotResource 白名单
- 移除关联项目时 → 自动将项目从 NotResource 白名单移除
Deny 策略自动管理(项目隔离的唯一可靠手段):
- 创建子账号时 → 自动创建 AirGate_Deny_{username} 策略
- 添加关联项目时 → 自动更新 Deny 策略,将新项目加入白名单
- 移除关联项目时 → 自动更新 Deny 策略,将项目从白名单移除
- 火山项目变动时 → 刷新所有用户的 Deny 策略
- Deny 策略列出所有非白名单项目并明确拒绝
- 策略命名AirGate_Deny_{username}
- 管理员自用账号可免除 Deny 策略(监控配置中开启)
```
子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成:

View File

@ -2,6 +2,26 @@
---
## v0.5.0 (2026-03-28 ~ 2026-03-29)
### 权限管理重构
- feat: 统一权限管理页面(全局策略 + 项目级策略 + 关联项目 + 添加/移除策略,一页展示)
- feat: Deny策略自动化项目隔离—— 创建子账号时自动生成 `AirGate_Deny_{username}`,添加/移除项目时自动更新
- feat: Deny策略免除开关管理员自用账号可在监控配置中开启
- fix: 火山API不支持Scope=Project2026-03-28实测改用全局授权 + Deny策略实现项目隔离
- fix: 项目变动刷新所有用户Deny策略
- fix: 前后端权限显示一致(实时从火山查询,不再依赖本地缓存)
### 账号管理增强
- feat: 火山控制台登录开关(独立于停用/恢复,随时切换)
- feat: 编辑子账号信息(显示名、手机号、邮箱,修改后同步火山 `UpdateUser` API
- feat: 创建子账号密码校验(火山密码策略:大小写字母 + 数字 + 特殊字符8位以上
- fix: 停用/恢复保存策略快照(区分全局策略/项目级策略 + 登录状态,恢复时精确还原)
- fix: 同步不再把火山登录关闭当成账号停用
- fix: 检测幽灵LoginProfileCreateDate=1970火山API已知问题
---
## v0.4.0 (2026-03-20)
### UI 优化