Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236e082349 | ||
|
|
9e81717e08 | ||
|
|
6b3a0bda34 | ||
|
|
610058ae5f | ||
|
|
294a0885ff | ||
|
|
9f00e6996b | ||
|
|
5b997bc1a7 | ||
|
|
6f4d7e6b5b | ||
|
|
d7b40beff7 | ||
|
|
765c80a47a | ||
|
|
c4c6a03f61 | ||
|
|
dacc521c1c | ||
|
|
fab4765e90 | ||
|
|
4b8181b96a | ||
|
|
9ffa13f44d | ||
|
|
d0d48ceb19 | ||
|
|
23ec78e83d | ||
|
|
a2a822a889 | ||
|
|
4e62f3f32e | ||
|
|
413977361a | ||
|
|
bae68ea6a1 | ||
|
|
9cfd550485 | ||
|
|
40655d63e0 | ||
|
|
92172c6ec8 | ||
|
|
ff0d0de8f8 | ||
|
|
0f034b7b26 | ||
|
|
48c55765c8 | ||
|
|
f79ae0084d | ||
|
|
8b49d49048 | ||
|
|
b25641cfc6 | ||
|
|
fac5e1b541 | ||
|
|
33c8963d46 | ||
|
|
3d2b332657 | ||
|
|
daa82aee76 | ||
|
|
7feb007f57 | ||
|
|
0ac2ef1f27 | ||
|
|
8e564ed640 | ||
|
|
314612f454 | ||
|
|
c58fe56d89 | ||
|
|
6dd3ac5c0d |
33
API方案.md
Normal file
33
API方案.md
Normal file
@ -0,0 +1,33 @@
|
||||
方案一:火山 IAM 子账号(推荐)
|
||||
思路:给队友创建火山子用户,让他直连火山 API。
|
||||
|
||||
给他的东西
|
||||
用途 给什么
|
||||
Assets API 子账号的 AK/SK
|
||||
Seedance 调用 子账号的 Ark API Key + 专用接入点(只绑 Seedance 2.0)
|
||||
对账 控制台登录密码
|
||||
项目标识 ProjectName: "int_dev_Airlabs"
|
||||
权限范围
|
||||
✅ Ark 模型调用、Assets 素材管理、费用中心(只读)
|
||||
❌ IAM 管理、其他云服务、充值/支付
|
||||
优缺点
|
||||
✅ 零开发量,控制台几分钟搞定
|
||||
✅ 他能自己对账
|
||||
✅ 权限可控,随时可禁用/删除
|
||||
✅ 互不影响,你的服务挂了不影响他
|
||||
❌ 他能直接接触火山资源(但权限受限)
|
||||
方案二:后端转发
|
||||
思路:你的后端包一层,队友只调你的接口,AK/SK 不出服务器。
|
||||
|
||||
给他的东西
|
||||
用途 给什么
|
||||
所有调用 后端地址 + 账号密码(JWT 认证)
|
||||
优缺点
|
||||
✅ 队友什么密钥都不需要,最安全
|
||||
✅ 你能完全掌控调用行为
|
||||
❌ 要写新接口把火山 API 都包一遍
|
||||
❌ 对账要你自己做用量统计页面
|
||||
❌ 多一跳,依赖你的服务稳定性
|
||||
❌ 火山 API 变更你得跟着维护
|
||||
一句话结论
|
||||
队友是自己人 → 方案一,省事;对外卖服务/不信任对方 → 方案二,可控。
|
||||
@ -6,16 +6,12 @@ ENV GUNICORN_RUNNING=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System dependencies (Aliyun mirror for China)
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc default-libmysqlclient-dev pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@ -13,6 +13,10 @@ class UserInfoSerializer(serializers.Serializer):
|
||||
is_active = serializers.BooleanField()
|
||||
date_joined = serializers.DateTimeField()
|
||||
last_login = serializers.DateTimeField()
|
||||
role = serializers.SerializerMethodField()
|
||||
|
||||
def get_role(self, obj):
|
||||
return 'admin'
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
|
||||
@ -10,4 +10,11 @@ urlpatterns = [
|
||||
path('admins/create/', views.admin_create_view),
|
||||
path('admins/<int:pk>/toggle/', views.admin_toggle_view),
|
||||
path('admins/<int:pk>/reset-password/', views.admin_reset_password_view),
|
||||
|
||||
# Sub-account (IAM user) login
|
||||
path('iam/login/', views.iam_login_view),
|
||||
path('iam/me/', views.iam_me_view),
|
||||
path('iam/my-keys/', views.iam_my_keys_view),
|
||||
path('iam/my-keys/<int:pk>/reveal/', views.iam_my_key_reveal_view),
|
||||
path('iam/change-password/', views.iam_change_password_view),
|
||||
]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from django.contrib.auth import authenticate
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
@ -195,3 +195,214 @@ def admin_reset_password_view(request, pk):
|
||||
)
|
||||
|
||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
||||
|
||||
|
||||
# ==================== Sub-account (IAM User) Login ====================
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def iam_login_view(request):
|
||||
"""子账号登录 AirGate"""
|
||||
username = request.data.get('username', '')
|
||||
password = request.data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return Response({'error': 'missing', 'message': '请输入用户名和密码'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from apps.monitor.models import IAMUser
|
||||
try:
|
||||
iam_user = IAMUser.objects.get(username=username)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'},
|
||||
status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if not iam_user.login_enabled:
|
||||
return Response({'error': 'login_disabled', 'message': '此账号未开通 AirGate 登录'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if iam_user.status != IAMUser.Status.ACTIVE:
|
||||
return Response({'error': 'user_disabled', 'message': '账号已停用'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not iam_user.check_login_password(password):
|
||||
return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'},
|
||||
status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Generate JWT token with iam_user info (use a dummy admin user for simplejwt)
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from datetime import datetime, timedelta, timezone
|
||||
payload = {
|
||||
'token_type': 'access',
|
||||
'iam_user_id': iam_user.id,
|
||||
'username': iam_user.username,
|
||||
'role': 'iam_user',
|
||||
'exp': datetime.now(timezone.utc) + timedelta(hours=24),
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
|
||||
|
||||
return Response({
|
||||
'access': token,
|
||||
'user': {
|
||||
'id': iam_user.id,
|
||||
'username': iam_user.username,
|
||||
'display_name': iam_user.display_name,
|
||||
'role': 'iam_user',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
def iam_me_view(request):
|
||||
"""子账号获取自身信息(通过 JWT token 中的 iam_user_id)"""
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
token = auth_header.split(' ', 1)[1]
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return Response({'error': 'token_expired'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
except jwt.InvalidTokenError:
|
||||
return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if payload.get('role') != 'iam_user':
|
||||
return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
from apps.monitor.models import IAMUser
|
||||
try:
|
||||
iam_user = IAMUser.objects.get(pk=payload['iam_user_id'])
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
from apps.monitor.serializers import IAMUserSerializer
|
||||
return Response({
|
||||
'role': 'iam_user',
|
||||
'user': IAMUserSerializer(iam_user).data,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
def iam_my_keys_view(request):
|
||||
"""子账号查看自己的 API Key"""
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
token = auth_header.split(' ', 1)[1]
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if payload.get('role') != 'iam_user':
|
||||
return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
from apps.monitor.models import ArkApiKey
|
||||
from apps.monitor.serializers import ArkApiKeySerializer
|
||||
keys = ArkApiKey.objects.filter(iam_user_id=payload['iam_user_id'])
|
||||
return Response(ArkApiKeySerializer(keys, many=True).data)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
def iam_my_key_reveal_view(request, pk):
|
||||
"""子账号查看自己的 API Key 明文"""
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
token = auth_header.split(' ', 1)[1]
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if payload.get('role') != 'iam_user':
|
||||
return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
from apps.monitor.models import ArkApiKey
|
||||
from utils.crypto import decrypt
|
||||
try:
|
||||
key = ArkApiKey.objects.get(pk=pk, iam_user_id=payload['iam_user_id'])
|
||||
except ArkApiKey.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Response({
|
||||
'api_key': decrypt(key.api_key_enc),
|
||||
'key_name': key.key_name,
|
||||
'project_name': key.project_name,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
def iam_change_password_view(request):
|
||||
"""子账号修改自己的 AirGate 登录密码"""
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
token = auth_header.split(' ', 1)[1]
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if payload.get('role') != 'iam_user':
|
||||
return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
from apps.monitor.models import IAMUser
|
||||
try:
|
||||
iam_user = IAMUser.objects.get(pk=payload['iam_user_id'])
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
old_password = request.data.get('old_password', '')
|
||||
new_password = request.data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return Response({'error': 'missing', 'message': '请输入原密码和新密码'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not iam_user.check_login_password(old_password):
|
||||
return Response({'error': 'wrong_password', 'message': '原密码错误'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if len(new_password) < 6:
|
||||
return Response({'error': 'weak_password', 'message': '密码至少6位'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
iam_user.set_login_password(new_password)
|
||||
iam_user.save(update_fields=['login_password_hash'])
|
||||
|
||||
from apps.monitor.models import AlertRecord
|
||||
AlertRecord.objects.create(
|
||||
iam_user=iam_user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"子账号 {iam_user.username} 修改 AirGate 密码",
|
||||
content=f"操作人: {iam_user.username}(自行修改)",
|
||||
)
|
||||
|
||||
return Response({'message': '密码修改成功,请重新登录'})
|
||||
|
||||
36
backend/apps/monitor/migrations/0007_arkapikey.py
Normal file
36
backend/apps/monitor/migrations/0007_arkapikey.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-20 17:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0006_iamuser_saved_policies_on_disable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ArkApiKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('project_name', models.CharField(max_length=200, verbose_name='所属项目')),
|
||||
('key_name', models.CharField(max_length=200, verbose_name='Key 名称/用途')),
|
||||
('api_key_enc', models.TextField(verbose_name='API Key(加密)')),
|
||||
('api_key_hint', models.CharField(blank=True, max_length=30, verbose_name='API Key 提示(脱敏)')),
|
||||
('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用')], default='active', max_length=20, verbose_name='状态')),
|
||||
('remark', models.TextField(blank=True, verbose_name='备注')),
|
||||
('created_by', models.CharField(blank=True, max_length=100, verbose_name='录入人')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ark_keys', to='monitor.iamuser')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '方舟 API Key',
|
||||
'verbose_name_plural': '方舟 API Key',
|
||||
'db_table': 'airgate_ark_api_key',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-20 17:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0007_arkapikey'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='login_enabled',
|
||||
field=models.BooleanField(default=False, verbose_name='允许登录 AirGate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='login_password_hash',
|
||||
field=models.CharField(blank=True, max_length=256, verbose_name='AirGate 登录密码哈希'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-28 11:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0008_iamuser_login_enabled_iamuser_login_password_hash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='volc_login_allowed',
|
||||
field=models.BooleanField(default=False, verbose_name='允许登录火山控制台'),
|
||||
),
|
||||
]
|
||||
@ -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 策略'),
|
||||
),
|
||||
]
|
||||
@ -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='监控间隔(秒)'),
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from django.contrib.auth.hashers import make_password, check_password
|
||||
from django.db import models
|
||||
|
||||
|
||||
@ -37,6 +38,11 @@ class IAMUser(models.Model):
|
||||
phone = models.CharField('手机号', max_length=20, blank=True)
|
||||
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
|
||||
|
||||
# AirGate 本地登录密码(子账号用来登录 AirGate,与火山控制台无关)
|
||||
login_password_hash = models.CharField('AirGate 登录密码哈希', max_length=256, blank=True)
|
||||
login_enabled = models.BooleanField('允许登录 AirGate', default=False)
|
||||
volc_login_allowed = models.BooleanField('允许登录火山控制台', default=False)
|
||||
|
||||
# Access keys (stored as JSON list of AK IDs, not secrets)
|
||||
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
||||
|
||||
@ -56,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='停用时自动移除的策略列表,恢复时加回')
|
||||
@ -73,6 +82,12 @@ class IAMUser(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.display_name or self.username} ({self.status})"
|
||||
|
||||
def set_login_password(self, raw_password):
|
||||
self.login_password_hash = make_password(raw_password)
|
||||
|
||||
def check_login_password(self, raw_password):
|
||||
return check_password(raw_password, self.login_password_hash)
|
||||
|
||||
@property
|
||||
def remaining_quota(self):
|
||||
"""剩余额度"""
|
||||
@ -116,6 +131,34 @@ class IAMUserProject(models.Model):
|
||||
return f"{self.project_name} ({status}) ¥{self.current_spending}"
|
||||
|
||||
|
||||
class ArkApiKey(models.Model):
|
||||
"""方舟 API Key(管理员手动录入,加密存储)"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = 'active', '启用'
|
||||
DISABLED = 'disabled', '停用'
|
||||
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='ark_keys')
|
||||
project_name = models.CharField('所属项目', max_length=200)
|
||||
key_name = models.CharField('Key 名称/用途', max_length=200)
|
||||
api_key_enc = models.TextField('API Key(加密)')
|
||||
api_key_hint = models.CharField('API Key 提示(脱敏)', max_length=30, blank=True)
|
||||
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.ACTIVE)
|
||||
remark = models.TextField('备注', blank=True)
|
||||
created_by = models.CharField('录入人', max_length=100, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '方舟 API Key'
|
||||
verbose_name_plural = '方舟 API Key'
|
||||
db_table = 'airgate_ark_api_key'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.iam_user.username}/{self.project_name}: {self.key_name} ({self.api_key_hint})"
|
||||
|
||||
|
||||
class QuotaAllocation(models.Model):
|
||||
"""额度划拨记录"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
|
||||
@ -141,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:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey
|
||||
|
||||
|
||||
class VolcAccountSerializer(serializers.ModelSerializer):
|
||||
@ -41,6 +41,8 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
||||
'alert_thresholds', 'triggered_alerts',
|
||||
'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',
|
||||
@ -76,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):
|
||||
@ -118,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']
|
||||
@ -135,6 +139,28 @@ class AlertRecordSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ArkApiKeySerializer(serializers.ModelSerializer):
|
||||
iam_username = serializers.CharField(source='iam_user.username', read_only=True)
|
||||
iam_display_name = serializers.CharField(source='iam_user.display_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ArkApiKey
|
||||
fields = [
|
||||
'id', 'iam_user', 'iam_username', 'iam_display_name',
|
||||
'project_name', 'key_name', 'api_key_hint', 'status',
|
||||
'remark', 'created_by', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['api_key_hint', 'created_by', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ArkApiKeyCreateSerializer(serializers.Serializer):
|
||||
iam_user_id = serializers.IntegerField()
|
||||
project_name = serializers.CharField(max_length=200)
|
||||
key_name = serializers.CharField(max_length=200)
|
||||
api_key = serializers.CharField(write_only=True)
|
||||
remark = serializers.CharField(max_length=500, required=False, default='', allow_blank=True)
|
||||
|
||||
|
||||
class DashboardSerializer(serializers.Serializer):
|
||||
total_users = serializers.IntegerField()
|
||||
active_users = serializers.IntegerField()
|
||||
|
||||
@ -17,15 +17,20 @@ urlpatterns = [
|
||||
path('iam-users/import/', views.iam_user_import_view),
|
||||
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
||||
path('iam-users/<int:pk>/update/', views.iam_user_update_view),
|
||||
path('iam-users/<int:pk>/edit-profile/', views.iam_user_edit_profile_view),
|
||||
path('iam-users/<int:pk>/toggle-volc-login/', views.iam_user_toggle_volc_login_view),
|
||||
path('iam-users/<int:pk>/set-login/', views.iam_user_set_login_view),
|
||||
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
||||
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
||||
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
||||
path('iam-users/<int:pk>/policies/overview/', views.iam_user_policies_overview_view),
|
||||
path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view),
|
||||
path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view),
|
||||
# IAM user projects (multi-project)
|
||||
path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view),
|
||||
path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view),
|
||||
path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view),
|
||||
path('iam-users/<int:pk>/projects/<int:pid>/policies/', views.iam_user_project_policies_view),
|
||||
path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view),
|
||||
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),
|
||||
|
||||
@ -40,10 +45,18 @@ urlpatterns = [
|
||||
|
||||
# Global config
|
||||
path('config/', views.global_config_view),
|
||||
path('config/test-feishu/', views.test_feishu_view),
|
||||
|
||||
# Alerts
|
||||
path('alerts/', views.alert_list_view),
|
||||
|
||||
# Projects
|
||||
path('projects/', views.project_list_view),
|
||||
|
||||
# Ark API Key management (manual entry)
|
||||
path('ark-keys/', views.ark_key_list_view),
|
||||
path('ark-keys/create/', views.ark_key_create_view),
|
||||
path('ark-keys/<int:pk>/', views.ark_key_update_view),
|
||||
path('ark-keys/<int:pk>/delete/', views.ark_key_delete_view),
|
||||
path('ark-keys/<int:pk>/reveal/', views.ark_key_reveal_view),
|
||||
]
|
||||
|
||||
@ -12,9 +12,10 @@ from rest_framework.response import Response
|
||||
from utils.crypto import encrypt, decrypt, make_hint
|
||||
from utils.iam_service import IAMService, ProjectService
|
||||
from utils.billing_service import BillingService
|
||||
from utils.ark_service import ArkService
|
||||
from utils.volcengine_client import VolcengineAPIError
|
||||
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey
|
||||
from .serializers import (
|
||||
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||
@ -23,6 +24,7 @@ from .serializers import (
|
||||
QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
GlobalConfigSerializer,
|
||||
AlertRecordSerializer,
|
||||
ArkApiKeySerializer, ArkApiKeyCreateSerializer,
|
||||
DashboardSerializer,
|
||||
)
|
||||
|
||||
@ -42,6 +44,35 @@ def _get_volc_account(volc_id=None):
|
||||
return account, ak, sk
|
||||
|
||||
|
||||
def _update_deny_policy(user):
|
||||
"""更新子账号的 Deny 策略,只允许访问已关联的项目"""
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
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)
|
||||
)
|
||||
try:
|
||||
svc.upsert_deny_policy(user.username, allowed_projects)
|
||||
except Exception as e:
|
||||
logger.error(f"更新 Deny 策略失败 ({user.username}): {e}")
|
||||
|
||||
|
||||
def _refresh_all_deny_policies():
|
||||
"""刷新所有子账号的 Deny 策略(新建火山项目后调用)"""
|
||||
users = IAMUser.objects.filter(status=IAMUser.Status.ACTIVE)
|
||||
for user in users:
|
||||
if user.projects.exists():
|
||||
_update_deny_policy(user)
|
||||
|
||||
|
||||
# ==================== Dashboard ====================
|
||||
|
||||
@api_view(['GET'])
|
||||
@ -153,6 +184,7 @@ def iam_user_sync_view(request):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
imported = []
|
||||
volc_usernames = set()
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
@ -168,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,
|
||||
@ -188,13 +221,32 @@ def iam_user_sync_view(request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sync login status
|
||||
# Sync account status: check both user status and AK status
|
||||
volc_status = u.get("Status", "active")
|
||||
if volc_status != "active":
|
||||
obj.status = IAMUser.Status.DISABLED
|
||||
else:
|
||||
# User is active, but check if all AKs are inactive (stopped by AirGate)
|
||||
all_inactive = False
|
||||
try:
|
||||
keys = svc.list_access_keys(username)
|
||||
if keys and all(k.get("Status") == "inactive" for k in keys):
|
||||
all_inactive = True
|
||||
except Exception:
|
||||
pass
|
||||
obj.status = IAMUser.Status.DISABLED if all_inactive else IAMUser.Status.ACTIVE
|
||||
|
||||
# Sync volc login status separately
|
||||
try:
|
||||
profile = svc.get_login_profile(username)
|
||||
login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True)
|
||||
obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED
|
||||
lp = profile.get("Result", {}).get("LoginProfile", {})
|
||||
create_date = lp.get("CreateDate", "")
|
||||
if create_date.startswith("1970") or create_date.startswith("0001"):
|
||||
obj.volc_login_allowed = False
|
||||
else:
|
||||
obj.volc_login_allowed = lp.get("LoginAllowed", False)
|
||||
except Exception:
|
||||
obj.status = IAMUser.Status.UNKNOWN
|
||||
obj.volc_login_allowed = False
|
||||
|
||||
obj.save()
|
||||
|
||||
@ -203,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,
|
||||
})
|
||||
|
||||
@ -250,7 +314,18 @@ def iam_user_create_view(request):
|
||||
try:
|
||||
svc.create_login_profile(d['username'], password)
|
||||
result_info['login_enabled'] = True
|
||||
result_info['volc_login_allowed'] = True
|
||||
except VolcengineAPIError as e:
|
||||
if 'InvalidPassword' in str(e):
|
||||
# Rollback: delete the user we just created
|
||||
try:
|
||||
svc.client.call("DeleteUser", {"UserName": d['username']})
|
||||
except Exception:
|
||||
pass
|
||||
return Response({
|
||||
'message': f'火山控制台密码不符合要求(需包含大小写字母、数字和特殊字符,至少8位)',
|
||||
'detail': str(e),
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
result_info['login_error'] = str(e)
|
||||
|
||||
# 3. Create access key
|
||||
@ -280,6 +355,7 @@ def iam_user_create_view(request):
|
||||
phone=d.get('phone', ''),
|
||||
status=IAMUser.Status.ACTIVE,
|
||||
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [],
|
||||
volc_login_allowed=result_info.get('volc_login_allowed', False),
|
||||
)
|
||||
|
||||
# 6. Auto-add project if specified
|
||||
@ -291,6 +367,9 @@ def iam_user_create_view(request):
|
||||
monitor_enabled=True,
|
||||
)
|
||||
|
||||
# 7. Create Deny policy (project isolation) + refresh all users
|
||||
_refresh_all_deny_policies()
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
@ -361,12 +440,142 @@ 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)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_toggle_volc_login_view(request, pk):
|
||||
"""切换子账号的火山控制台登录权限"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
account = user.volc_account
|
||||
ak = decrypt(account.access_key_enc)
|
||||
sk = decrypt(account.secret_key_enc)
|
||||
iam = IAMService(ak, sk)
|
||||
|
||||
# Check current status
|
||||
try:
|
||||
resp = iam.get_login_profile(user.username)
|
||||
current = resp.get('Result', {}).get('LoginProfile', {}).get('LoginAllowed', False)
|
||||
except VolcengineAPIError as e:
|
||||
if 'LoginProfileNotExist' in str(e):
|
||||
return Response({'message': '该子账号未设置火山控制台密码,无法切换登录状态'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
raise
|
||||
|
||||
new_status = not current
|
||||
try:
|
||||
iam.update_login_allowed(user.username, new_status)
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'message': f'操作失败: {e}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.volc_login_allowed = new_status
|
||||
user.save(update_fields=['volc_login_allowed'])
|
||||
|
||||
action = '开启' if new_status else '关闭'
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user, alert_type='manual',
|
||||
title=f'{action}火山控制台登录 {user.username}',
|
||||
content=f'操作人: {request.user.username}',
|
||||
)
|
||||
return Response({'message': f'已{action} {user.username} 的火山控制台登录',
|
||||
'volc_login_allowed': new_status})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_edit_profile_view(request, pk):
|
||||
"""编辑子账号信息(显示名、手机号、邮箱),同步到火山"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
display_name = request.data.get('display_name')
|
||||
email = request.data.get('email')
|
||||
phone = request.data.get('phone')
|
||||
|
||||
# Update on Volcengine
|
||||
account = user.volc_account
|
||||
ak = decrypt(account.access_key_enc)
|
||||
sk = decrypt(account.secret_key_enc)
|
||||
iam = IAMService(ak, sk)
|
||||
|
||||
# Only pass non-empty values to Volcengine (empty strings are rejected)
|
||||
try:
|
||||
iam.update_user(user.username,
|
||||
display_name=display_name if display_name else None,
|
||||
email=email if email else None,
|
||||
phone=phone if phone else None)
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'message': f'火山 API 更新失败: {e}'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update locally
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if phone is not None:
|
||||
user.phone = phone
|
||||
user.save()
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user, alert_type='manual',
|
||||
title=f'编辑子账号信息 {user.username}',
|
||||
content=f'操作人: {request.user.username}',
|
||||
)
|
||||
return Response({'message': '已更新', 'user': IAMUserSerializer(user).data})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_set_login_view(request, pk):
|
||||
"""设置子账号的 AirGate 登录密码"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
password = request.data.get('password', '')
|
||||
enabled = request.data.get('login_enabled')
|
||||
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
return Response({'error': 'weak_password', 'message': '密码至少6位'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_login_password(password)
|
||||
user.login_enabled = True
|
||||
|
||||
if enabled is not None:
|
||||
user.login_enabled = enabled
|
||||
|
||||
user.save(update_fields=['login_password_hash', 'login_enabled'])
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"设置子账号 {user.username} 的 AirGate 登录",
|
||||
content=f"操作人: {request.user.username},登录: {'开启' if user.login_enabled else '关闭'}",
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': f'已{"开启" if user.login_enabled else "关闭"}子账号 {user.username} 的 AirGate 登录',
|
||||
'login_enabled': user.login_enabled,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_disable_view(request, pk):
|
||||
"""停用子账号"""
|
||||
@ -387,6 +596,8 @@ def iam_user_disable_view(request, pk):
|
||||
# 2. 移除所有权限策略并保存快照(恢复时加回)
|
||||
saved_policies = []
|
||||
detach_errors = []
|
||||
|
||||
# 2a. 全局策略
|
||||
try:
|
||||
resp = svc.list_attached_user_policies(user.username)
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
@ -395,15 +606,37 @@ def iam_user_disable_view(request, pk):
|
||||
ptype = p.get("PolicyType", "")
|
||||
try:
|
||||
svc.detach_user_policy(user.username, pname, ptype)
|
||||
saved_policies.append({"name": pname, "type": ptype})
|
||||
saved_policies.append({"name": pname, "type": ptype, "scope": "global"})
|
||||
except VolcengineAPIError as detach_err:
|
||||
detach_errors.append(f"{pname}: {detach_err}")
|
||||
detach_errors.append(f"{pname}(global): {detach_err}")
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
|
||||
# 2b. 项目级策略
|
||||
for proj in user.projects.all():
|
||||
try:
|
||||
resp = svc.client.call('ListAttachedUserPolicies', {
|
||||
'UserName': user.username,
|
||||
'ProjectName': proj.project_name,
|
||||
})
|
||||
proj_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
for p in proj_policies:
|
||||
pname = p.get("PolicyName", "")
|
||||
ptype = p.get("PolicyType", "")
|
||||
try:
|
||||
svc.detach_policy_in_project(user.username, pname, proj.project_name, ptype)
|
||||
saved_policies.append({"name": pname, "type": ptype, "scope": "project", "project": proj.project_name})
|
||||
except VolcengineAPIError as detach_err:
|
||||
detach_errors.append(f"{pname}({proj.project_name}): {detach_err}")
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
|
||||
user.status = IAMUser.Status.DISABLED
|
||||
# 在策略快照里记住停用前的火山登录状态
|
||||
saved_policies.append({"_volc_login_was": user.volc_login_allowed})
|
||||
user.saved_policies_on_disable = saved_policies
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
user.volc_login_allowed = False
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed'])
|
||||
|
||||
policy_count = len(saved_policies)
|
||||
error_info = f",移除失败: {detach_errors}" if detach_errors else ""
|
||||
@ -437,23 +670,39 @@ def iam_user_enable_view(request, pk):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
# 1. 恢复控制台 + API 密钥
|
||||
svc.enable_user(user.username)
|
||||
# 从快照中提取停用前的火山登录状态
|
||||
saved_policies = user.saved_policies_on_disable or []
|
||||
restore_login = False
|
||||
actual_policies = []
|
||||
for p in saved_policies:
|
||||
if "_volc_login_was" in p:
|
||||
restore_login = p["_volc_login_was"]
|
||||
else:
|
||||
actual_policies.append(p)
|
||||
|
||||
# 2. 重新附加停用时保存的策略
|
||||
# 1. 恢复 API 密钥 + 控制台(按停用前状态)
|
||||
svc.enable_user(user.username, restore_login=restore_login)
|
||||
|
||||
# 2. 重新附加停用时保存的策略(按原始位置:全局或项目级)
|
||||
restored_count = 0
|
||||
restore_errors = []
|
||||
saved_policies = user.saved_policies_on_disable or []
|
||||
for p in saved_policies:
|
||||
for p in actual_policies:
|
||||
try:
|
||||
svc.attach_user_policy(user.username, p["name"], p["type"])
|
||||
if p.get("scope") == "project" and p.get("project"):
|
||||
svc.attach_policy_in_project(user.username, p["name"], p["project"], p["type"])
|
||||
else:
|
||||
svc.attach_user_policy(user.username, p["name"], p["type"])
|
||||
restored_count += 1
|
||||
except VolcengineAPIError as restore_err:
|
||||
restore_errors.append(f"{p['name']}: {restore_err}")
|
||||
|
||||
# 3. 重建 Deny 策略(项目隔离)
|
||||
_update_deny_policy(user)
|
||||
|
||||
user.status = IAMUser.Status.ACTIVE
|
||||
user.saved_policies_on_disable = []
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
user.volc_login_allowed = restore_login
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed'])
|
||||
|
||||
error_info = f",恢复失败: {restore_errors}" if restore_errors else ""
|
||||
AlertRecord.objects.create(
|
||||
@ -473,6 +722,84 @@ def iam_user_enable_view(request, pk):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def iam_user_policies_overview_view(request, pk):
|
||||
"""查看子账号的完整权限总览(全局 + 各项目)"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
# Get all policies
|
||||
resp = svc.list_attached_user_policies(user.username)
|
||||
all_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
|
||||
# Separate global vs project
|
||||
global_policies = []
|
||||
for p in all_policies:
|
||||
scopes = p.get('PolicyScope', [])
|
||||
is_global = not scopes or any(s.get('PolicyScopeType') == 'Global' for s in scopes)
|
||||
if is_global:
|
||||
global_policies.append({
|
||||
'name': p.get('PolicyName', ''),
|
||||
'type': p.get('PolicyType', ''),
|
||||
'description': p.get('Description', ''),
|
||||
})
|
||||
|
||||
# Get project-level policies for each associated project
|
||||
project_policies = []
|
||||
for proj in user.projects.all():
|
||||
try:
|
||||
resp2 = svc.client.call('ListAttachedUserPolicies', {
|
||||
'UserName': user.username,
|
||||
'ProjectName': proj.project_name,
|
||||
})
|
||||
proj_items = []
|
||||
for p in resp2.get('Result', {}).get('AttachedPolicyMetadata', []):
|
||||
scopes = p.get('PolicyScope', [])
|
||||
for s in scopes:
|
||||
if s.get('PolicyScopeType') == 'Project' and s.get('ProjectName') == proj.project_name:
|
||||
proj_items.append({
|
||||
'name': p.get('PolicyName', ''),
|
||||
'type': p.get('PolicyType', ''),
|
||||
'description': p.get('Description', ''),
|
||||
})
|
||||
break
|
||||
project_policies.append({
|
||||
'project_name': proj.project_name,
|
||||
'display_name': proj.display_name,
|
||||
'project_id': proj.id,
|
||||
'monitor_enabled': proj.monitor_enabled,
|
||||
'current_spending': str(proj.current_spending),
|
||||
'policies': proj_items,
|
||||
})
|
||||
except Exception:
|
||||
project_policies.append({
|
||||
'project_name': proj.project_name,
|
||||
'display_name': proj.display_name,
|
||||
'project_id': proj.id,
|
||||
'monitor_enabled': proj.monitor_enabled,
|
||||
'current_spending': str(proj.current_spending),
|
||||
'policies': [],
|
||||
})
|
||||
|
||||
return Response({
|
||||
'username': user.username,
|
||||
'display_name': user.display_name,
|
||||
'global_policies': global_policies,
|
||||
'project_policies': project_policies,
|
||||
})
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
def iam_user_policies_view(request, pk):
|
||||
"""查看子账号的权限策略"""
|
||||
try:
|
||||
@ -487,8 +814,15 @@ def iam_user_policies_view(request, pk):
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
resp = svc.list_attached_user_policies(user.username)
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
return Response({'policies': policies})
|
||||
all_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
# 只返回全局策略(过滤项目级的)
|
||||
global_policies = []
|
||||
for p in all_policies:
|
||||
scopes = p.get('PolicyScope', [])
|
||||
is_global = not scopes or any(s.get('PolicyScopeType') == 'Global' for s in scopes)
|
||||
if is_global:
|
||||
global_policies.append(p)
|
||||
return Response({'policies': global_policies})
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
@ -564,12 +898,38 @@ def iam_user_detach_policy_view(request, pk):
|
||||
|
||||
@api_view(['GET'])
|
||||
def iam_user_project_list_view(request, pk):
|
||||
"""查看子账号关联的项目列表"""
|
||||
"""查看子账号关联的项目列表(实时从火山同步项目级策略)"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
projects = user.projects.all()
|
||||
|
||||
# 实时从火山查询每个项目的策略,同步到本地(只取项目级的,过滤全局的)
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
if ak:
|
||||
svc = IAMService(ak, sk)
|
||||
for proj in projects:
|
||||
try:
|
||||
resp = svc.client.call('ListAttachedUserPolicies', {
|
||||
'UserName': user.username,
|
||||
'ProjectName': proj.project_name,
|
||||
})
|
||||
# 只保留 PolicyScopeType=Project 的策略,过滤掉全局的
|
||||
volc_policies = []
|
||||
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') == proj.project_name:
|
||||
volc_policies.append(p.get('PolicyName', ''))
|
||||
break
|
||||
if set(volc_policies) != set(proj.attached_policies or []):
|
||||
proj.attached_policies = volc_policies
|
||||
proj.save(update_fields=['attached_policies'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(IAMUserProjectSerializer(projects, many=True).data)
|
||||
|
||||
|
||||
@ -615,6 +975,9 @@ def iam_user_project_add_view(request, pk):
|
||||
obj.attached_policies = attached
|
||||
obj.save(update_fields=['attached_policies'])
|
||||
|
||||
# 更新所有子账号的 Deny 策略(新项目需要加入其他人的拒绝列表)
|
||||
_refresh_all_deny_policies()
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
@ -648,6 +1011,82 @@ def iam_user_project_update_view(request, pk, pid):
|
||||
return Response(IAMUserProjectSerializer(project).data)
|
||||
|
||||
|
||||
@api_view(['PUT'])
|
||||
def iam_user_project_policies_view(request, pk, pid):
|
||||
"""更新项目级授权策略(增量对比:移除旧的、添加新的)"""
|
||||
try:
|
||||
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
|
||||
user = project.iam_user
|
||||
except IAMUserProject.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
new_policies = request.data.get('policies', [])
|
||||
|
||||
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]
|
||||
for policy_name in to_remove:
|
||||
try:
|
||||
svc.detach_policy_in_project(user.username, policy_name, project.project_name)
|
||||
detached.append(policy_name)
|
||||
except VolcengineAPIError as e:
|
||||
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]
|
||||
for policy_name in to_add:
|
||||
try:
|
||||
svc.attach_policy_in_project(user.username, policy_name, project.project_name)
|
||||
attached.append(policy_name)
|
||||
except VolcengineAPIError as e:
|
||||
if 'PolicyAttachConflict' in str(e):
|
||||
attached.append(policy_name)
|
||||
else:
|
||||
errors.append(f"添加 {policy_name}: {e}")
|
||||
|
||||
project.attached_policies = new_policies
|
||||
project.save(update_fields=['attached_policies'])
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"更新项目 {project.project_name} 授权策略",
|
||||
content=f"操作人: {request.user.username},添加: {attached},移除: {detached}"
|
||||
+ (f",失败: {errors}" if errors else ""),
|
||||
)
|
||||
|
||||
result = {'message': f'已更新,添加 {len(attached)} 个、移除 {len(detached)} 个策略',
|
||||
'project': IAMUserProjectSerializer(project).data}
|
||||
if errors:
|
||||
result['warnings'] = errors
|
||||
return Response(result)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def iam_user_project_delete_view(request, pk, pid):
|
||||
"""移除关联项目:回收权限 + 移出监测"""
|
||||
@ -682,6 +1121,9 @@ def iam_user_project_delete_view(request, pk, pid):
|
||||
|
||||
project.delete()
|
||||
|
||||
# 更新所有子账号的 Deny 策略
|
||||
_refresh_all_deny_policies()
|
||||
|
||||
result = {'message': f'已移除项目 {name},已回收权限: {detached}'}
|
||||
if detach_errors:
|
||||
result['detach_errors'] = detach_errors
|
||||
@ -825,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'])
|
||||
@ -861,3 +1325,128 @@ def project_list_view(request):
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
# ==================== Ark API Key Management (手动录入模式) ====================
|
||||
|
||||
@api_view(['GET'])
|
||||
def ark_key_list_view(request):
|
||||
"""列出 API Key(管理员看全部,子账号看自己的)"""
|
||||
keys = ArkApiKey.objects.select_related('iam_user').all()
|
||||
|
||||
# 按子账号筛选
|
||||
iam_user_id = request.query_params.get('iam_user_id')
|
||||
if iam_user_id:
|
||||
keys = keys.filter(iam_user_id=iam_user_id)
|
||||
|
||||
# 按项目筛选
|
||||
project_name = request.query_params.get('project_name')
|
||||
if project_name:
|
||||
keys = keys.filter(project_name=project_name)
|
||||
|
||||
return Response(ArkApiKeySerializer(keys, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def ark_key_create_view(request):
|
||||
"""录入 API Key(管理员操作)"""
|
||||
serializer = ArkApiKeyCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
d = serializer.validated_data
|
||||
|
||||
try:
|
||||
iam_user = IAMUser.objects.get(pk=d['iam_user_id'])
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found', 'message': '子账号不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
api_key_raw = d['api_key']
|
||||
obj = ArkApiKey.objects.create(
|
||||
iam_user=iam_user,
|
||||
project_name=d['project_name'],
|
||||
key_name=d['key_name'],
|
||||
api_key_enc=encrypt(api_key_raw),
|
||||
api_key_hint=make_hint(api_key_raw),
|
||||
remark=d.get('remark', ''),
|
||||
created_by=request.user.username,
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=iam_user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"录入 API Key: {d['key_name']}",
|
||||
content=f"操作人: {request.user.username},项目: {d['project_name']}",
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': f'API Key "{d["key_name"]}" 录入成功',
|
||||
'key': ArkApiKeySerializer(obj).data,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['PUT'])
|
||||
def ark_key_update_view(request, pk):
|
||||
"""更新 API Key(启用/停用/改备注)"""
|
||||
try:
|
||||
obj = ArkApiKey.objects.get(pk=pk)
|
||||
except ArkApiKey.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
new_status = request.data.get('status')
|
||||
if new_status and new_status in ('active', 'disabled'):
|
||||
old_status = obj.status
|
||||
obj.status = new_status
|
||||
action = '启用' if new_status == 'active' else '停用'
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj.iam_user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"{action} API Key: {obj.key_name}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
|
||||
remark = request.data.get('remark')
|
||||
if remark is not None:
|
||||
obj.remark = remark
|
||||
|
||||
obj.save()
|
||||
return Response(ArkApiKeySerializer(obj).data)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def ark_key_delete_view(request, pk):
|
||||
"""删除 API Key"""
|
||||
try:
|
||||
obj = ArkApiKey.objects.get(pk=pk)
|
||||
except ArkApiKey.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj.iam_user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"删除 API Key: {obj.key_name}",
|
||||
content=f"操作人: {request.user.username},项目: {obj.project_name}",
|
||||
)
|
||||
obj.delete()
|
||||
return Response({'message': 'API Key 已删除'})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def ark_key_reveal_view(request, pk):
|
||||
"""查看完整 API Key(解密展示)"""
|
||||
try:
|
||||
obj = ArkApiKey.objects.get(pk=pk)
|
||||
except ArkApiKey.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj.iam_user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"查看 API Key 明文: {obj.key_name}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
|
||||
return Response({
|
||||
'api_key': decrypt(obj.api_key_enc),
|
||||
'key_name': obj.key_name,
|
||||
'project_name': obj.project_name,
|
||||
})
|
||||
|
||||
42
backend/utils/ark_service.py
Normal file
42
backend/utils/ark_service.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""方舟(Ark)API Key 管理服务"""
|
||||
|
||||
import logging
|
||||
from .volcengine_client import VolcengineClient, VolcengineAPIError, get_ark_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArkService:
|
||||
"""方舟 API Key 管理"""
|
||||
|
||||
def __init__(self, ak: str, sk: str):
|
||||
self.client = get_ark_client(ak, sk)
|
||||
|
||||
def list_api_keys(self, project_name: str, page_size: int = 100, page_number: int = 1) -> dict:
|
||||
"""列出项目下的 API Key"""
|
||||
return self.client.call_json("ListApiKeys", {
|
||||
"ProjectName": project_name,
|
||||
"PageSize": page_size,
|
||||
"PageNumber": page_number,
|
||||
})
|
||||
|
||||
def create_api_key(self, project_name: str, name: str, resource_type: str = "all") -> dict:
|
||||
"""在项目下创建 API Key"""
|
||||
return self.client.call_json("CreateApiKey", {
|
||||
"ProjectName": project_name,
|
||||
"Name": name,
|
||||
"ResourceInstances": [{"ResourceId": "*", "ResourceType": resource_type}],
|
||||
})
|
||||
|
||||
def delete_api_key(self, api_key_id: int) -> dict:
|
||||
"""删除 API Key"""
|
||||
return self.client.call_json("DeleteApiKey", {
|
||||
"Id": api_key_id,
|
||||
})
|
||||
|
||||
def update_api_key_status(self, api_key_id: int, status: str) -> dict:
|
||||
"""启用/停用 API Key (status: Active / Inactive)"""
|
||||
return self.client.call_json("UpdateApiKey", {
|
||||
"Id": api_key_id,
|
||||
"Status": status,
|
||||
})
|
||||
@ -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", {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -76,37 +76,151 @@ class IAMService:
|
||||
"PolicyType": policy_type,
|
||||
})
|
||||
|
||||
def update_user(self, username: str, display_name: str = None,
|
||||
email: str = None, phone: str = None) -> dict:
|
||||
params = {"UserName": username}
|
||||
if display_name is not None:
|
||||
params["NewDisplayName"] = display_name
|
||||
if email is not None:
|
||||
params["NewEmail"] = email
|
||||
if phone is not None:
|
||||
params["NewMobilePhone"] = phone
|
||||
return self.client.call("UpdateUser", params)
|
||||
|
||||
def list_attached_user_policies(self, username: str) -> dict:
|
||||
return self.client.call("ListAttachedUserPolicies", {"UserName": username})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
# === Deny Policy (project isolation) ===
|
||||
|
||||
def _deny_policy_name(self, username: str) -> str:
|
||||
return f"AirGate_Deny_{username}"
|
||||
|
||||
def upsert_deny_policy(self, username: str, allowed_projects: list[str]):
|
||||
"""创建或更新子账号的 Deny 策略,只允许访问指定项目"""
|
||||
import json
|
||||
policy_name = self._deny_policy_name(username)
|
||||
|
||||
# Get all projects to build explicit deny list
|
||||
from .volcengine_client import get_resource_client
|
||||
res_client = get_resource_client(
|
||||
self.client.ak, self.client.sk
|
||||
)
|
||||
resp = res_client.call("ListProjects", {"Limit": "100"})
|
||||
all_projects = [
|
||||
p.get("ProjectName", "") for p in
|
||||
resp.get("Result", {}).get("Projects", [])
|
||||
]
|
||||
|
||||
if not all_projects:
|
||||
logger.warning(f"无法获取项目列表,跳过 Deny 策略更新 ({username})")
|
||||
return
|
||||
|
||||
if not allowed_projects:
|
||||
# No projects, deny everything
|
||||
policy_doc = json.dumps({
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["ark:*"],
|
||||
"Resource": ["*"],
|
||||
}]
|
||||
})
|
||||
else:
|
||||
# Build explicit deny list: all projects minus allowed ones
|
||||
deny_projects = [p for p in all_projects if p not in allowed_projects]
|
||||
if deny_projects:
|
||||
policy_doc = json.dumps({
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["ark:*"],
|
||||
"Resource": [f"trn:iam::*:project/{p}" for p in deny_projects],
|
||||
}]
|
||||
})
|
||||
else:
|
||||
# All projects are allowed, no deny needed
|
||||
# Create a no-op policy
|
||||
policy_doc = json.dumps({
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["ark:ThisActionDoesNotExist"],
|
||||
"Resource": ["*"],
|
||||
}]
|
||||
})
|
||||
|
||||
# Delete old policy (must detach first), then recreate
|
||||
try:
|
||||
self.detach_user_policy(username, policy_name, "Custom")
|
||||
except VolcengineAPIError:
|
||||
pass # Not attached or doesn't exist
|
||||
|
||||
try:
|
||||
self.client.call("DeletePolicy", {"PolicyName": policy_name})
|
||||
except VolcengineAPIError:
|
||||
pass # Policy doesn't exist yet
|
||||
|
||||
self.client.call("CreatePolicy", {
|
||||
"PolicyName": policy_name,
|
||||
"PolicyDocument": policy_doc,
|
||||
"Description": f"AirGate 自动生成:限制 {username} 只能访问授权项目",
|
||||
})
|
||||
|
||||
self.attach_user_policy(username, policy_name, "Custom")
|
||||
|
||||
def remove_deny_policy(self, username: str):
|
||||
"""移除子账号的 Deny 策略"""
|
||||
policy_name = self._deny_policy_name(username)
|
||||
try:
|
||||
self.detach_user_policy(username, policy_name, "Custom")
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
try:
|
||||
self.client.call("DeletePolicy", {"PolicyName": policy_name})
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
|
||||
def _has_login_profile(self, username: str) -> bool:
|
||||
"""检查用户是否有真实的 LoginProfile(火山可能返回空壳)"""
|
||||
try:
|
||||
resp = self.get_login_profile(username)
|
||||
profile = resp.get("Result", {}).get("LoginProfile", {})
|
||||
# Empty shell has CreateDate=19700101 and Password=""
|
||||
create_date = profile.get("CreateDate", "")
|
||||
if create_date.startswith("1970") or create_date.startswith("0001"):
|
||||
return False
|
||||
return True
|
||||
except VolcengineAPIError as e:
|
||||
if "LoginProfileNotExist" in str(e) or "RecordNotFound" in str(e):
|
||||
return False
|
||||
raise
|
||||
|
||||
def disable_user(self, username: str):
|
||||
"""完全停用用户:停控制台 + 停所有 AccessKey"""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
self.update_login_allowed(username, False)
|
||||
except VolcengineAPIError as e:
|
||||
errors.append(f"停用控制台失败: {e}")
|
||||
if self._has_login_profile(username):
|
||||
try:
|
||||
self.update_login_allowed(username, False)
|
||||
except VolcengineAPIError as e:
|
||||
errors.append(f"停用控制台失败: {e}")
|
||||
|
||||
try:
|
||||
keys = self.list_access_keys(username)
|
||||
@ -119,14 +233,15 @@ class IAMService:
|
||||
if errors:
|
||||
raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors))
|
||||
|
||||
def enable_user(self, username: str):
|
||||
"""恢复用户:恢复控制台 + 恢复所有 AccessKey"""
|
||||
def enable_user(self, username: str, restore_login: bool = True):
|
||||
"""恢复用户:恢复控制台(可选) + 恢复所有 AccessKey"""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
self.update_login_allowed(username, True)
|
||||
except VolcengineAPIError as e:
|
||||
errors.append(f"恢复控制台失败: {e}")
|
||||
if restore_login and self._has_login_profile(username):
|
||||
try:
|
||||
self.update_login_allowed(username, True)
|
||||
except VolcengineAPIError as e:
|
||||
errors.append(f"恢复控制台失败: {e}")
|
||||
|
||||
try:
|
||||
keys = self.list_access_keys(username)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -46,25 +46,25 @@ class VolcengineClient:
|
||||
def _hash_sha256(self, content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
def call(self, action: str, params: dict = None, body: str = "") -> dict:
|
||||
params = params or {}
|
||||
def _sign_and_call(self, action: str, method: str, content_type: str,
|
||||
query_params: dict, body_bytes: bytes) -> dict:
|
||||
"""统一签名并调用"""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
x_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
short_date = x_date[:8]
|
||||
|
||||
x_content_sha256 = self._hash_sha256(body)
|
||||
all_params = {"Action": action, "Version": self.version, **params}
|
||||
x_content_sha256 = hashlib.sha256(body_bytes).hexdigest()
|
||||
query_string = self._norm_query(query_params)
|
||||
|
||||
signed_headers_str = "content-type;host;x-content-sha256;x-date"
|
||||
canonical_headers = (
|
||||
f"content-type:application/x-www-form-urlencoded\n"
|
||||
f"content-type:{content_type}\n"
|
||||
f"host:{self.host}\n"
|
||||
f"x-content-sha256:{x_content_sha256}\n"
|
||||
f"x-date:{x_date}"
|
||||
)
|
||||
query_string = self._norm_query(all_params)
|
||||
canonical_request = "\n".join([
|
||||
"GET", "/", query_string,
|
||||
method, "/", query_string,
|
||||
canonical_headers, "", signed_headers_str, x_content_sha256
|
||||
])
|
||||
|
||||
@ -84,7 +84,7 @@ class VolcengineClient:
|
||||
"Host": self.host,
|
||||
"X-Date": x_date,
|
||||
"X-Content-Sha256": x_content_sha256,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Type": content_type,
|
||||
"Authorization": (
|
||||
f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers_str}, Signature={signature}"
|
||||
@ -93,7 +93,10 @@ class VolcengineClient:
|
||||
|
||||
url = f"https://{self.host}/?{query_string}"
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=30)
|
||||
if method == "GET":
|
||||
r = requests.get(url, headers=headers, timeout=30)
|
||||
else:
|
||||
r = requests.post(url, headers=headers, data=body_bytes, timeout=30)
|
||||
resp = r.json()
|
||||
except Exception as e:
|
||||
raise VolcengineAPIError(action, "NetworkError", str(e))
|
||||
@ -105,6 +108,21 @@ class VolcengineClient:
|
||||
)
|
||||
return resp
|
||||
|
||||
def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict:
|
||||
"""GET 方式调用(IAM / Billing 等传统接口)"""
|
||||
params = params or {}
|
||||
all_params = {"Action": action, "Version": self.version, **params}
|
||||
return self._sign_and_call(action, "GET", "application/x-www-form-urlencoded",
|
||||
all_params, body.encode("utf-8") if body else b"")
|
||||
|
||||
def call_json(self, action: str, body: dict = None) -> dict:
|
||||
"""POST + JSON body 方式调用(方舟 Ark 等新接口)"""
|
||||
import json
|
||||
query_params = {"Action": action, "Version": self.version}
|
||||
body_bytes = json.dumps(body or {}).encode("utf-8")
|
||||
return self._sign_and_call(action, "POST", "application/json",
|
||||
query_params, body_bytes)
|
||||
|
||||
|
||||
def get_iam_client(ak: str, sk: str) -> VolcengineClient:
|
||||
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com")
|
||||
@ -118,3 +136,9 @@ def get_billing_client(ak: str, sk: str) -> VolcengineClient:
|
||||
def get_resource_client(ak: str, sk: str) -> VolcengineClient:
|
||||
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
|
||||
version="2021-08-01")
|
||||
|
||||
|
||||
def get_ark_client(ak: str, sk: str) -> VolcengineClient:
|
||||
"""方舟 API 客户端(使用 POST + JSON body)"""
|
||||
return VolcengineClient(ak, sk, "ark", "open.volcengineapi.com",
|
||||
region="cn-beijing", version="2024-01-01")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
airgate-backend:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8101:8100"
|
||||
@ -15,12 +15,12 @@ services:
|
||||
- backend-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
airgate-web:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5174:80"
|
||||
depends_on:
|
||||
- airgate-backend
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@ -2,7 +2,7 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://airgate-backend:8100;
|
||||
proxy_pass http://backend:8100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@ -4,37 +4,60 @@
|
||||
<div class="logo">AirGate</div>
|
||||
<el-menu :default-active="route.path" router background-color="#1d1e2c"
|
||||
text-color="#a0a3bd" active-text-color="#fff">
|
||||
<el-menu-item index="/">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/iam-users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>子账号管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/billing">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>消费监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- Admin menus -->
|
||||
<template v-if="auth.isAdmin">
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/iam-users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>子账号管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ark-keys">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>API Key 管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/billing">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>消费监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<!-- IAM User (sub-account) menus -->
|
||||
<template v-else>
|
||||
<el-menu-item index="/my-keys">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>我的 API Key</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/my-password">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>修改密码</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header style="display: flex; align-items: center; justify-content: flex-end;
|
||||
background: #fff; border-bottom: 1px solid #eee; height: 56px;">
|
||||
<span style="margin-right: 16px; color: #666;">{{ auth.user?.username }}</span>
|
||||
<el-tag v-if="auth.isIamUser" type="info" size="small" style="margin-right: 12px;">子账号</el-tag>
|
||||
<span style="margin-right: 16px; color: #666;">
|
||||
{{ auth.user?.display_name || auth.user?.username }}
|
||||
</span>
|
||||
<el-button text @click="handleLogout">退出登录</el-button>
|
||||
</el-header>
|
||||
<el-main style="background: #f5f7fa; padding: 24px;">
|
||||
|
||||
@ -12,12 +12,24 @@ const routes = [
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') },
|
||||
// Dynamic home: admin sees Dashboard, iam_user sees MyKeys
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('../views/HomeRedirect.vue'),
|
||||
},
|
||||
// Admin routes
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') },
|
||||
{ path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') },
|
||||
{ path: 'iam-users/:id/policies', name: 'UserPolicies', component: () => import('../views/iam/UserPoliciesView.vue'), props: true },
|
||||
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
|
||||
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') },
|
||||
{ path: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
|
||||
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') },
|
||||
// IAM user (sub-account) routes
|
||||
{ path: 'my-keys', name: 'MyKeys', component: () => import('../views/portal/MyKeysView.vue') },
|
||||
{ path: 'my-password', name: 'MyPassword', component: () => import('../views/portal/MyPasswordView.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -7,13 +7,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role !== 'iam_user')
|
||||
const isIamUser = computed(() => user.value?.role === 'iam_user')
|
||||
|
||||
function setAuth(data) {
|
||||
token.value = data.access
|
||||
refreshToken.value = data.refresh
|
||||
refreshToken.value = data.refresh || ''
|
||||
user.value = data.user
|
||||
localStorage.setItem('airgate_token', data.access)
|
||||
localStorage.setItem('airgate_refresh', data.refresh)
|
||||
localStorage.setItem('airgate_refresh', data.refresh || '')
|
||||
localStorage.setItem('airgate_user', JSON.stringify(data.user))
|
||||
}
|
||||
|
||||
@ -26,5 +28,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('airgate_user')
|
||||
}
|
||||
|
||||
return { token, refreshToken, user, isLoggedIn, setAuth, logout }
|
||||
return { token, refreshToken, user, isLoggedIn, isAdmin, isIamUser, setAuth, logout }
|
||||
})
|
||||
|
||||
20
frontend/src/views/HomeRedirect.vue
Normal file
20
frontend/src/views/HomeRedirect.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isIamUser) {
|
||||
router.replace('/my-keys')
|
||||
} else {
|
||||
router.replace('/dashboard')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
@ -5,9 +5,14 @@
|
||||
<h1>AirGate</h1>
|
||||
<p>火山引擎 IAM 子账号管控平台</p>
|
||||
</div>
|
||||
|
||||
<el-segmented v-model="loginMode" :options="loginModes" block
|
||||
style="margin-bottom: 24px;" />
|
||||
|
||||
<el-form :model="form" @submit.prevent="handleLogin" label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="admin" size="large" />
|
||||
<el-input v-model="form.username" :placeholder="loginMode === 'admin' ? 'admin' : '子账号用户名'"
|
||||
size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large"
|
||||
@ -32,6 +37,12 @@ import api from '../api'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginMode = ref('admin')
|
||||
const loginModes = [
|
||||
{ label: '管理员登录', value: 'admin' },
|
||||
{ label: '子账号登录', value: 'iam' },
|
||||
]
|
||||
|
||||
const form = ref({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
@ -42,11 +53,14 @@ async function handleLogin() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/auth/login/', form.value)
|
||||
const url = loginMode.value === 'admin'
|
||||
? '/api/v1/auth/login/'
|
||||
: '/api/v1/auth/iam/login/'
|
||||
const { data } = await api.post(url, form.value)
|
||||
auth.setAuth(data)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.message || '登录失败')
|
||||
ElMessage.error(err.response?.data?.message || '登录失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -65,7 +79,7 @@ async function handleLogin() {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 48px 40px;
|
||||
width: 400px;
|
||||
width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.login-header {
|
||||
|
||||
239
frontend/src/views/ark/ArkKeysView.vue
Normal file
239
frontend/src/views/ark/ArkKeysView.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div style="max-width: 1400px; margin: 0 auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">API Key 管理</h2>
|
||||
<el-button type="primary" @click="openCreate">录入 API Key</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<el-select v-model="filterUser" placeholder="按子账号筛选" clearable filterable
|
||||
style="width: 200px;" @change="loadKeys">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.display_name || u.username"
|
||||
:value="u.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterProject" placeholder="按项目筛选" clearable filterable
|
||||
style="width: 200px;" @change="loadKeys">
|
||||
<el-option v-for="p in allProjects" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button @click="loadKeys" text><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<!-- Keys table -->
|
||||
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
|
||||
empty-text="暂无 API Key">
|
||||
<el-table-column label="子账号" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.iam_display_name || row.iam_username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="project_name" label="所属项目" min-width="150" />
|
||||
<el-table-column prop="key_name" label="名称/用途" min-width="160" />
|
||||
<el-table-column label="API Key" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<code style="color: #999;">{{ row.api_key_hint }}</code>
|
||||
<el-button size="small" text type="primary" @click="handleReveal(row)"
|
||||
style="margin-left: 4px;">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="录入时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'active'" size="small" text type="warning"
|
||||
@click="handleToggle(row, 'disabled')">停用</el-button>
|
||||
<el-button v-else size="small" text type="success"
|
||||
@click="handleToggle(row, 'active')">启用</el-button>
|
||||
<el-button size="small" text type="danger"
|
||||
@click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- Create (manual entry) dialog -->
|
||||
<el-dialog v-model="showCreate" title="录入 API Key" width="90%" style="max-width: 600px;">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px;">
|
||||
请先在火山控制台创建 API Key,然后将完整 Key 粘贴到下方录入。
|
||||
</el-alert>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="子账号" required>
|
||||
<el-select v-model="createForm.iam_user_id" placeholder="选择子账号" filterable style="width: 100%;">
|
||||
<el-option v-for="u in users" :key="u.id"
|
||||
:label="`${u.username} (${u.display_name || '-'})`" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属项目" required>
|
||||
<el-select v-model="createForm.project_name" placeholder="选择项目" filterable style="width: 100%;">
|
||||
<el-option v-for="p in volcProjects" :key="p.name"
|
||||
:label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称/用途" required>
|
||||
<el-input v-model="createForm.key_name" placeholder="如:zyc-seedance-production" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input v-model="createForm.api_key" type="textarea" :rows="2"
|
||||
placeholder="粘贴完整的 API Key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createForm.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreate" :loading="creating">录入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Reveal key dialog -->
|
||||
<el-dialog v-model="showReveal" title="查看 API Key" width="90%" style="max-width: 600px;">
|
||||
<div style="margin-bottom: 8px; color: #606266;">
|
||||
<strong>{{ revealData.key_name }}</strong> · {{ revealData.project_name }}
|
||||
</div>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; word-break: break-all;">
|
||||
<code style="font-size: 14px; color: #409eff;">{{ revealData.api_key }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="copyRevealKey">复制</el-button>
|
||||
<el-button @click="showReveal = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
const keys = ref([])
|
||||
const loading = ref(false)
|
||||
const users = ref([])
|
||||
const volcProjects = ref([])
|
||||
|
||||
const filterUser = ref('')
|
||||
const filterProject = ref('')
|
||||
|
||||
const allProjects = computed(() => {
|
||||
const set = new Set(keys.value.map(k => k.project_name))
|
||||
return [...set].sort()
|
||||
})
|
||||
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({ iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
const showReveal = ref(false)
|
||||
const revealData = ref({ api_key: '', key_name: '', project_name: '' })
|
||||
|
||||
async function loadKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {}
|
||||
if (filterUser.value) params.iam_user_id = filterUser.value
|
||||
if (filterProject.value) params.project_name = filterProject.value
|
||||
const { data } = await api.get('/api/v1/ark-keys/', { params })
|
||||
keys.value = data
|
||||
} catch (e) {
|
||||
keys.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/iam-users/')
|
||||
users.value = data
|
||||
} catch { users.value = [] }
|
||||
}
|
||||
|
||||
async function loadVolcProjects() {
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch { volcProjects.value = [] }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' }
|
||||
showCreate.value = true
|
||||
loadVolcProjects()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const f = createForm.value
|
||||
if (!f.iam_user_id || !f.project_name || !f.key_name || !f.api_key) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/ark-keys/create/', f)
|
||||
ElMessage.success(data.message)
|
||||
showCreate.value = false
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '录入失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(row) {
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/ark-keys/${row.id}/reveal/`)
|
||||
revealData.value = data
|
||||
showReveal.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRevealKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealData.value.api_key)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row, newStatus) {
|
||||
const action = newStatus === 'active' ? '启用' : '停用'
|
||||
try {
|
||||
await api.put(`/api/v1/ark-keys/${row.id}/`, { status: newStatus })
|
||||
ElMessage.success(`已${action}`)
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error(`${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除 "${row.key_name}" 吗?`, '确认删除', { type: 'error' })
|
||||
try {
|
||||
await api.delete(`/api/v1/ark-keys/${row.id}/delete/`)
|
||||
ElMessage.success('已删除')
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadKeys()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
@ -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="openProjectsDialog(row)">
|
||||
{{ 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">
|
||||
@ -76,10 +82,14 @@
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openProjectsDialog(row)">项目管理</el-dropdown-item>
|
||||
<el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item>
|
||||
<el-dropdown-item @click="openPolicies(row)">权限策略</el-dropdown-item>
|
||||
<el-dropdown-item @click="openEditProfile(row)">编辑信息</el-dropdown-item>
|
||||
<el-dropdown-item @click="toggleVolcLogin(row)">
|
||||
{{ row.volc_login_allowed ? '🔓 关闭火山登录' : '🔒 开启火山登录' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="$router.push(`/iam-users/${row.id}/policies`)">权限管理</el-dropdown-item>
|
||||
<el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item>
|
||||
<el-dropdown-item @click="openSetLogin(row)">登录密码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status === 'active'" divided
|
||||
@click="handleDisable(row)" style="color:#f56c6c;">停用账号</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status === 'disabled'" divided
|
||||
@ -151,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>
|
||||
@ -158,58 +172,23 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Projects Dialog -->
|
||||
<el-dialog v-model="projectsDialogVisible" :title="`${projectsUser?.username} 关联项目`" width="90%" style="max-width: 900px;">
|
||||
<div style="margin-bottom:12px; display:flex; gap:8px; align-items:center;">
|
||||
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="flex:1;"
|
||||
:loading="volcProjectsLoading">
|
||||
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<el-button @click="loadVolcProjects" :loading="volcProjectsLoading" text>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="projectToAdd" style="margin-bottom:12px;">
|
||||
<div style="margin-bottom:4px; font-size:13px; color:#606266;">授权策略(可多选,不选则仅加入监测不授权):</div>
|
||||
<el-checkbox-group v-model="projectPoliciesToAttach">
|
||||
<el-checkbox label="ArkFullAccess">方舟/Seedance 完整权限</el-checkbox>
|
||||
<el-checkbox label="ArkReadOnlyAccess">方舟只读</el-checkbox>
|
||||
<el-checkbox label="TOSFullAccess">对象存储完整权限</el-checkbox>
|
||||
<el-checkbox label="TOSReadOnlyAccess">对象存储只读</el-checkbox>
|
||||
<el-checkbox label="AccessKeySelfManageAccess">自管理密钥</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-button type="primary" @click="handleAddProject" style="margin-top:8px;">确认添加</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button size="small" @click="handleToggleAll(true)">全部开启监测</el-button>
|
||||
<el-button size="small" @click="handleToggleAll(false)">全部关闭监测</el-button>
|
||||
</div>
|
||||
<el-table :data="userProjects" stripe v-loading="projectsDialogLoading" empty-text="暂无关联项目">
|
||||
<el-table-column prop="project_name" label="项目名" min-width="160" />
|
||||
<el-table-column prop="display_name" label="显示名" min-width="120" />
|
||||
<el-table-column label="消费" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color:#e6a23c;">¥{{ Number(row.current_spending).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已授权策略" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="p in (row.attached_policies || [])" :key="p" size="small"
|
||||
style="margin:1px 2px;">{{ p }}</el-tag>
|
||||
<span v-if="!(row.attached_policies || []).length" style="color:#999;font-size:12px;">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="监测" min-width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.monitor_enabled" @change="val => handleToggleProject(row, val)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="handleRemoveProject(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- Set Login Password Dialog -->
|
||||
<el-dialog v-model="loginPwdVisible" :title="`设置 ${loginPwdUser?.username} 的 AirGate 登录密码`"
|
||||
width="90%" style="max-width: 450px;">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="登录状态">
|
||||
<el-switch v-model="loginPwdEnabled"
|
||||
active-text="允许登录" inactive-text="禁止登录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="loginPwdValue" type="password" show-password
|
||||
placeholder="至少6位(留空则不修改密码)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="loginPwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSetLogin" :loading="loginPwdSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Quota History Dialog -->
|
||||
@ -233,31 +212,6 @@
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Policies Dialog -->
|
||||
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="90%" style="max-width: 850px;">
|
||||
<div style="margin-bottom:12px; display:flex; gap:8px;">
|
||||
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
|
||||
<el-option-group label="常用策略">
|
||||
<el-option value="ArkFullAccess" label="ArkFullAccess(方舟/Seedance 完整权限)" />
|
||||
<el-option value="ArkReadOnlyAccess" label="ArkReadOnlyAccess(方舟只读)" />
|
||||
<el-option value="TOSFullAccess" label="TOSFullAccess(对象存储完整权限)" />
|
||||
<el-option value="TOSReadOnlyAccess" label="TOSReadOnlyAccess(对象存储只读)" />
|
||||
<el-option value="AccessKeySelfManageAccess" label="AccessKeySelfManageAccess(自管理密钥)" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleAttachPolicy" :disabled="!policyToAttach">附加</el-button>
|
||||
</div>
|
||||
<el-table :data="policies" stripe v-loading="policiesLoading" empty-text="暂无策略">
|
||||
<el-table-column prop="PolicyName" label="策略名" />
|
||||
<el-table-column prop="PolicyType" label="类型" width="80" />
|
||||
<el-table-column prop="Description" label="说明" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="handleDetachPolicy(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Create User Dialog -->
|
||||
<el-dialog v-model="showCreate" title="创建子账号" width="90%" style="max-width: 580px;">
|
||||
@ -280,7 +234,8 @@
|
||||
<el-input v-model="createForm.password" type="password" show-password
|
||||
placeholder="选填" />
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||
火山引擎网页后台的登录密码。不填则子账号无法登录火山网页后台,仅能通过 API Key 使用服务
|
||||
火山引擎网页后台的登录密码。不填则子账号无法登录火山网页后台,仅能通过 API Key 使用服务。
|
||||
密码需包含大小写字母、数字和特殊字符,至少8位(如 User@1234)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联项目">
|
||||
@ -297,6 +252,32 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Edit Profile Dialog -->
|
||||
<el-dialog v-model="editProfileVisible" :title="`编辑 ${editProfileUser?.username} 信息`"
|
||||
width="90%" style="max-width: 500px;">
|
||||
<el-form :model="editProfileForm" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input :model-value="editProfileUser?.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名">
|
||||
<el-input v-model="editProfileForm.display_name" placeholder="如:视频部门" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="editProfileForm.phone" placeholder="如 13800138000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editProfileForm.email" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="font-size:12px; color:#999; margin-top:8px;">
|
||||
修改会同步到火山引擎 IAM
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="editProfileVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleEditProfile" :loading="editProfileSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Secret Key Display Dialog -->
|
||||
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="90%" style="max-width: 580px;" :close-on-click-modal="false">
|
||||
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
|
||||
@ -401,23 +382,73 @@ async function handleEnable(row) {
|
||||
}
|
||||
}
|
||||
|
||||
// Policies
|
||||
const policiesVisible = ref(false)
|
||||
const policiesUser = ref(null)
|
||||
const policies = ref([])
|
||||
const policiesLoading = ref(false)
|
||||
const policyToAttach = ref('')
|
||||
// Toggle Volcengine console login
|
||||
async function toggleVolcLogin(row) {
|
||||
const action = row.volc_login_allowed ? '关闭' : '开启'
|
||||
await ElMessageBox.confirm(
|
||||
`确定${action} "${row.username}" 的火山引擎控制台登录?`,
|
||||
`${action}火山登录`, { type: 'warning' }
|
||||
)
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${row.id}/toggle-volc-login/`)
|
||||
ElMessage.success(data.message)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// Projects dialog
|
||||
const projectsDialogVisible = ref(false)
|
||||
const projectsUser = ref(null)
|
||||
const userProjects = ref([])
|
||||
const projectsDialogLoading = ref(false)
|
||||
const projectToAdd = ref('')
|
||||
const projectPoliciesToAttach = ref([])
|
||||
// Edit Profile
|
||||
const editProfileVisible = ref(false)
|
||||
const editProfileUser = ref(null)
|
||||
const editProfileForm = ref({ display_name: '', phone: '', email: '' })
|
||||
const editProfileSaving = ref(false)
|
||||
|
||||
function openEditProfile(row) {
|
||||
editProfileUser.value = row
|
||||
editProfileForm.value = {
|
||||
display_name: row.display_name || '',
|
||||
phone: row.phone || '',
|
||||
email: row.email || '',
|
||||
}
|
||||
editProfileVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEditProfile() {
|
||||
editProfileSaving.value = true
|
||||
try {
|
||||
const { data } = await api.post(
|
||||
`/api/v1/iam-users/${editProfileUser.value.id}/edit-profile/`,
|
||||
editProfileForm.value
|
||||
)
|
||||
ElMessage.success(data.message)
|
||||
editProfileVisible.value = false
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '更新失败')
|
||||
} finally {
|
||||
editProfileSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// (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
|
||||
}
|
||||
}
|
||||
|
||||
// --- Allocate ---
|
||||
const maxDeduct = computed(() => {
|
||||
if (!allocateUser.value) return 0
|
||||
@ -467,102 +498,12 @@ 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
|
||||
}
|
||||
|
||||
// --- Projects Dialog ---
|
||||
async function loadVolcProjects() {
|
||||
volcProjectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取火山项目列表失败')
|
||||
} finally {
|
||||
volcProjectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openProjectsDialog(row) {
|
||||
projectsUser.value = row
|
||||
projectsDialogVisible.value = true
|
||||
projectToAdd.value = ''
|
||||
await loadUserProjects(row.id)
|
||||
if (volcProjects.value.length === 0) loadVolcProjects()
|
||||
}
|
||||
|
||||
async function loadUserProjects(userId) {
|
||||
projectsDialogLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/iam-users/${userId}/projects/`)
|
||||
userProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error('获取项目列表失败')
|
||||
userProjects.value = []
|
||||
} finally {
|
||||
projectsDialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProject() {
|
||||
if (!projectToAdd.value) return
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
|
||||
project_name: projectToAdd.value,
|
||||
policies: projectPoliciesToAttach.value,
|
||||
})
|
||||
const policyMsg = data.attached_policies?.length
|
||||
? `,已授权 ${data.attached_policies.length} 个策略`
|
||||
: ''
|
||||
ElMessage.success(`已添加${policyMsg}`)
|
||||
projectToAdd.value = ''
|
||||
projectPoliciesToAttach.value = []
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleProject(row, val) {
|
||||
try {
|
||||
await api.put(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/`, {
|
||||
monitor_enabled: val,
|
||||
})
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveProject(row) {
|
||||
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/delete/`)
|
||||
ElMessage.success('已移除')
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAll(enable) {
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/toggle-all/`, {
|
||||
monitor_enabled: enable,
|
||||
})
|
||||
ElMessage.success(data.message)
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
if (!newStep.value || newStep.value < 1 || newStep.value > 99) {
|
||||
ElMessage.warning('请输入 1-99 之间的百分比')
|
||||
@ -596,6 +537,42 @@ async function saveConfig() {
|
||||
}
|
||||
|
||||
// --- Quota History ---
|
||||
// === Set Login Password ===
|
||||
const loginPwdVisible = ref(false)
|
||||
const loginPwdUser = ref(null)
|
||||
const loginPwdValue = ref('')
|
||||
const loginPwdEnabled = ref(false)
|
||||
const loginPwdSaving = ref(false)
|
||||
|
||||
function openSetLogin(row) {
|
||||
loginPwdUser.value = row
|
||||
loginPwdValue.value = ''
|
||||
loginPwdEnabled.value = row.login_enabled || false
|
||||
loginPwdVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSetLogin() {
|
||||
const payload = { login_enabled: loginPwdEnabled.value }
|
||||
if (loginPwdValue.value) {
|
||||
if (loginPwdValue.value.length < 6) {
|
||||
ElMessage.warning('密码至少6位')
|
||||
return
|
||||
}
|
||||
payload.password = loginPwdValue.value
|
||||
}
|
||||
loginPwdSaving.value = true
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${loginPwdUser.value.id}/set-login/`, payload)
|
||||
ElMessage.success(data.message)
|
||||
loginPwdVisible.value = false
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
loginPwdSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openQuotaHistory(row) {
|
||||
historyUser.value = row
|
||||
historyVisible.value = true
|
||||
@ -611,52 +588,6 @@ async function openQuotaHistory(row) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Policies ---
|
||||
async function openPolicies(row) {
|
||||
policiesUser.value = row
|
||||
policiesVisible.value = true
|
||||
policiesLoading.value = true
|
||||
policyToAttach.value = ''
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
|
||||
policies.value = data.policies || []
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取权限失败')
|
||||
policies.value = []
|
||||
} finally {
|
||||
policiesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachPolicy() {
|
||||
if (!policyToAttach.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/attach/`, {
|
||||
policy_name: policyToAttach.value,
|
||||
policy_type: 'System',
|
||||
})
|
||||
ElMessage.success(`已附加 ${policyToAttach.value}`)
|
||||
policyToAttach.value = ''
|
||||
await openPolicies(policiesUser.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '附加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDetachPolicy(row) {
|
||||
await ElMessageBox.confirm(`确定移除策略 "${row.PolicyName}" 吗?`, '确认移除', { type: 'warning' })
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/detach/`, {
|
||||
policy_name: row.PolicyName,
|
||||
policy_type: row.PolicyType,
|
||||
})
|
||||
ElMessage.success(`已移除 ${row.PolicyName}`)
|
||||
await openPolicies(policiesUser.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create User ---
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.username) {
|
||||
|
||||
282
frontend/src/views/iam/UserPoliciesView.vue
Normal file
282
frontend/src/views/iam/UserPoliciesView.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px; margin: 0 auto;">
|
||||
<div class="page-header">
|
||||
<h2>{{ overview.display_name || overview.username || '...' }} 权限管理</h2>
|
||||
<el-button @click="$router.push('/iam-users')">返回子账号列表</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<!-- Global Policies -->
|
||||
<el-card style="margin-bottom: 20px;">
|
||||
<template #header>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span style="font-weight:600; font-size:16px;">全局策略</span>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<el-select v-model="globalPolicyToAdd" placeholder="添加全局策略" filterable size="small" style="width:280px;">
|
||||
<el-option v-for="opt in policyOptions" :key="opt.value"
|
||||
:value="opt.value" :label="opt.label"
|
||||
:disabled="overview.global_policies?.some(p => p.name === opt.value)" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="attachGlobal" :disabled="!globalPolicyToAdd">添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px;">
|
||||
全局策略对所有项目生效。一般只放 Deny 策略(项目隔离),业务权限请加到项目级。
|
||||
</el-alert>
|
||||
<el-table :data="overview.global_policies || []" stripe empty-text="无全局策略" table-layout="auto">
|
||||
<el-table-column prop="name" label="策略名" min-width="220" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'Custom' ? 'warning' : 'info'" size="small">
|
||||
{{ row.type === 'Custom' ? '自定义' : '系统' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="detachGlobal(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Add Project Section -->
|
||||
<el-card style="margin-bottom: 20px;">
|
||||
<template #header>
|
||||
<span style="font-weight:600; font-size:16px;">关联项目</span>
|
||||
</template>
|
||||
<div style="display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="width:260px;"
|
||||
:loading="volcProjectsLoading" @focus="loadVolcProjects">
|
||||
<el-option v-for="p in volcProjects" :key="p.name"
|
||||
:label="p.display_name || p.name" :value="p.name"
|
||||
:disabled="(overview.project_policies || []).some(pp => pp.project_name === p.name)" />
|
||||
</el-select>
|
||||
<div v-if="projectToAdd" style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
|
||||
<el-checkbox-group v-model="projectPoliciesToAttach" style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||
<el-checkbox label="ArkFullAccess" size="small">方舟完整</el-checkbox>
|
||||
<el-checkbox label="TOSFullAccess" size="small">TOS完整</el-checkbox>
|
||||
<el-checkbox label="ArkReadOnlyAccess" size="small">方舟只读</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-button type="primary" size="small" @click="handleAddProject">确认添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Project-level Policies -->
|
||||
<el-card v-for="proj in (overview.project_policies || [])" :key="proj.project_name" style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>
|
||||
<el-tag type="success" size="small" style="margin-right:8px;">项目</el-tag>
|
||||
<span style="font-weight:600; font-size:15px;">{{ proj.project_name }}</span>
|
||||
<span v-if="proj.display_name" style="color:#999; margin-left:8px;">{{ proj.display_name }}</span>
|
||||
<span style="color:#e6a23c; margin-left:12px; font-size:13px;">消费: ¥{{ Number(proj.current_spending || 0).toLocaleString() }}</span>
|
||||
<el-switch :model-value="proj.monitor_enabled" @change="val => toggleMonitor(proj, val)"
|
||||
active-text="监测" inactive-text="" size="small" style="margin-left:12px;" />
|
||||
</span>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<el-select v-model="projectPolicyToAdd[proj.project_name]" placeholder="添加策略" filterable size="small" style="width:260px;">
|
||||
<el-option v-for="opt in policyOptions" :key="opt.value"
|
||||
:value="opt.value" :label="opt.label"
|
||||
:disabled="proj.policies?.some(p => p.name === opt.value)" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="attachProject(proj)" :disabled="!projectPolicyToAdd[proj.project_name]">添加</el-button>
|
||||
<el-button type="danger" size="small" text @click="removeProject(proj)">移除项目</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="proj.policies || []" stripe empty-text="无项目级策略" table-layout="auto">
|
||||
<el-table-column prop="name" label="策略名" min-width="220" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'Custom' ? 'warning' : 'info'" size="small">
|
||||
{{ row.type === 'Custom' ? '自定义' : '系统' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="detachProject(proj, row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-empty v-if="!(overview.project_policies || []).length && !loading"
|
||||
description="暂无关联项目,请在上方添加" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const userId = route.params.id
|
||||
|
||||
const loading = ref(false)
|
||||
const overview = ref({})
|
||||
const globalPolicyToAdd = ref('')
|
||||
const projectPolicyToAdd = reactive({})
|
||||
|
||||
// Add project
|
||||
const projectToAdd = ref('')
|
||||
const projectPoliciesToAttach = ref([])
|
||||
const volcProjects = ref([])
|
||||
const volcProjectsLoading = ref(false)
|
||||
|
||||
const policyOptions = [
|
||||
{ value: 'ArkFullAccess', label: 'ArkFullAccess(方舟/Seedance 完整权限)' },
|
||||
{ value: 'ArkExperienceAccess', label: 'ArkExperienceAccess(方舟体验权限)' },
|
||||
{ value: 'ArkReadOnlyAccess', label: 'ArkReadOnlyAccess(方舟只读)' },
|
||||
{ value: 'TOSFullAccess', label: 'TOSFullAccess(对象存储完整权限)' },
|
||||
{ value: 'TOSReadOnlyAccess', label: 'TOSReadOnlyAccess(对象存储只读)' },
|
||||
{ value: 'AccessKeySelfManageAccess', label: 'AccessKeySelfManageAccess(自管理密钥)' },
|
||||
]
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/iam-users/${userId}/policies/overview/`)
|
||||
overview.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '加载权限信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVolcProjects() {
|
||||
if (volcProjects.value.length) return
|
||||
volcProjectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error('获取火山项目列表失败')
|
||||
} finally {
|
||||
volcProjectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === Global policies ===
|
||||
async function attachGlobal() {
|
||||
if (!globalPolicyToAdd.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${userId}/policies/attach/`, {
|
||||
policy_name: globalPolicyToAdd.value,
|
||||
policy_type: 'System',
|
||||
})
|
||||
ElMessage.success(`已添加全局策略 ${globalPolicyToAdd.value}`)
|
||||
globalPolicyToAdd.value = ''
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachGlobal(row) {
|
||||
await ElMessageBox.confirm(`确定移除全局策略 "${row.name}" 吗?`, '确认移除', { type: 'warning' })
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${userId}/policies/detach/`, {
|
||||
policy_name: row.name,
|
||||
policy_type: row.type,
|
||||
})
|
||||
ElMessage.success(`已移除 ${row.name}`)
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// === Project management ===
|
||||
async function handleAddProject() {
|
||||
if (!projectToAdd.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${userId}/projects/add/`, {
|
||||
project_name: projectToAdd.value,
|
||||
policies: projectPoliciesToAttach.value,
|
||||
})
|
||||
ElMessage.success(`已关联项目 ${projectToAdd.value}`)
|
||||
projectToAdd.value = ''
|
||||
projectPoliciesToAttach.value = []
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProject(proj) {
|
||||
await ElMessageBox.confirm(`确定移除项目 "${proj.project_name}" 吗?权限将被回收。`, '确认移除', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/delete/`)
|
||||
ElMessage.success(`已移除项目 ${proj.project_name}`)
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMonitor(proj, val) {
|
||||
try {
|
||||
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/`, {
|
||||
monitor_enabled: val,
|
||||
})
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error('切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
// === Project-level policies ===
|
||||
async function attachProject(proj) {
|
||||
const policyName = projectPolicyToAdd[proj.project_name]
|
||||
if (!policyName) return
|
||||
try {
|
||||
const newPolicies = [...(proj.policies || []).map(p => p.name), policyName]
|
||||
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/policies/`, {
|
||||
policies: newPolicies,
|
||||
})
|
||||
ElMessage.success(`已添加 ${policyName} 到 ${proj.project_name}`)
|
||||
projectPolicyToAdd[proj.project_name] = ''
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachProject(proj, row) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定从 ${proj.project_name} 移除策略 "${row.name}" 吗?`,
|
||||
'确认移除', { type: 'warning' }
|
||||
)
|
||||
try {
|
||||
const newPolicies = (proj.policies || []).filter(p => p.name !== row.name).map(p => p.name)
|
||||
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/policies/`, {
|
||||
policies: newPolicies,
|
||||
})
|
||||
ElMessage.success(`已从 ${proj.project_name} 移除 ${row.name}`)
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOverview)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/views/portal/MyKeysView.vue
Normal file
77
frontend/src/views/portal/MyKeysView.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<h2 style="margin-bottom: 16px;">我的 API Key</h2>
|
||||
|
||||
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
|
||||
empty-text="暂无 API Key,请联系管理员分配">
|
||||
<el-table-column prop="project_name" label="所属项目" min-width="150" />
|
||||
<el-table-column prop="key_name" label="名称/用途" min-width="180" />
|
||||
<el-table-column label="API Key" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<template v-if="revealedKeys[row.id]">
|
||||
<code style="font-size: 13px; color: #409eff; word-break: break-all;">
|
||||
{{ revealedKeys[row.id] }}
|
||||
</code>
|
||||
<el-button size="small" text @click="copyKey(revealedKeys[row.id])"
|
||||
style="margin-left: 4px;">复制</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<code style="color: #999;">{{ row.api_key_hint }}</code>
|
||||
<el-button size="small" text type="primary" @click="handleReveal(row)"
|
||||
style="margin-left: 4px;">查看</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
const keys = ref([])
|
||||
const loading = ref(false)
|
||||
const revealedKeys = reactive({})
|
||||
|
||||
async function loadKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/auth/iam/my-keys/')
|
||||
keys.value = data
|
||||
} catch (e) {
|
||||
keys.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(row) {
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/auth/iam/my-keys/${row.id}/reveal/`)
|
||||
revealedKeys[row.id] = data.api_key
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(key) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadKeys)
|
||||
</script>
|
||||
66
frontend/src/views/portal/MyPasswordView.vue
Normal file
66
frontend/src/views/portal/MyPasswordView.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div style="max-width: 500px; margin: 0 auto;">
|
||||
<h2 style="margin-bottom: 16px;">修改密码</h2>
|
||||
<el-card>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="form.old_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="form.new_password" type="password" show-password
|
||||
placeholder="至少6位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="form.confirm_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleChange" :loading="loading">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import api from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const form = ref({ old_password: '', new_password: '', confirm_password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleChange() {
|
||||
if (!form.value.old_password || !form.value.new_password) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
if (form.value.new_password !== form.value.confirm_password) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
if (form.value.new_password.length < 6) {
|
||||
ElMessage.warning('密码至少6位')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/auth/iam/change-password/', {
|
||||
old_password: form.value.old_password,
|
||||
new_password: form.value.new_password,
|
||||
})
|
||||
ElMessage.success(data.message)
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '修改失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -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 {
|
||||
|
||||
234
操作说明.md
234
操作说明.md
@ -17,7 +17,7 @@ cd C:\Airlabs_Project\AirGate\frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录。
|
||||
打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录(首次登录后请立即修改密码)。
|
||||
|
||||
### 2. 配置火山主账号
|
||||
|
||||
@ -35,132 +35,173 @@ npm run dev
|
||||
| 配置项 | 说明 | 建议值 |
|
||||
|--------|------|--------|
|
||||
| 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 |
|
||||
| 项目默认授权策略 | 添加项目时自动授权的策略 | ArkFullAccess,TOSFullAccess |
|
||||
| 监控间隔(秒) | 定时查询消费的间隔 | 3600(1小时) |
|
||||
| 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 |
|
||||
|
||||
---
|
||||
|
||||
## 二、日常操作
|
||||
## 二、管理员操作
|
||||
|
||||
### 给新部门开通子账号
|
||||
|
||||
**步骤 1:创建子账号**
|
||||
### 1. 创建子账号
|
||||
|
||||
1. 左侧菜单 → **子账号管理** → 点 **创建子账号**
|
||||
2. 填写:
|
||||
- **用户名**:英文,如 `dept_video`
|
||||
- **显示名**:如 `视频部门`
|
||||
- **火山控制台密码**:填上(对方需要登录火山后台创建方舟 API Key)
|
||||
- 其他选填
|
||||
- **手机号**:可选
|
||||
- **火山控制台密码**:需包含大小写字母、数字和特殊字符,8位以上(火山密码策略要求)
|
||||
3. 点 **创建**
|
||||
4. 弹窗显示 API 密钥 → **立即复制保存**(SecretKey 仅显示一次)
|
||||
4. 系统自动在火山创建 IAM 用户,并自动生成 `AirGate_Deny_{username}` 策略用于项目隔离
|
||||
|
||||
**步骤 2:在火山控制台创建项目**
|
||||
### 2. 权限管理
|
||||
|
||||
1. 登录 `console.volcengine.com`(你的主账号)
|
||||
2. 左上角项目管理 → 新建项目(如 `team-video-1`)
|
||||
统一权限管理页面,一页展示所有权限信息:
|
||||
|
||||
**步骤 3:在 AirGate 关联项目并授权**
|
||||
1. 子账号管理 → 找到目标用户 → 点 **权限管理**
|
||||
2. 页面分区:
|
||||
- **全局策略**:当前用户挂载的全局策略列表(实时从火山查询)
|
||||
- **项目级策略**:当前用户在各项目下挂载的策略(实时从火山查询)
|
||||
- **关联项目**:管理用户关联的火山项目(添加/移除)
|
||||
- **添加/移除策略**:为用户附加或移除 IAM 策略
|
||||
3. 添加关联项目时自动更新 Deny 策略(将新项目加入白名单)
|
||||
4. 移除关联项目时自动更新 Deny 策略(将项目从白名单移除)
|
||||
|
||||
1. 回到 AirGate → 子账号管理 → 找到刚创建的子账号
|
||||
2. 点 **更多 → 项目管理**
|
||||
3. 从下拉框选择刚创建的项目 → 点 **添加**
|
||||
4. 系统自动在项目范围内授权 ArkFullAccess + TOSFullAccess
|
||||
|
||||
**步骤 4:划拨额度**
|
||||
|
||||
1. 点子账号的 **划拨** 按钮
|
||||
2. 选择「追加额度」,输入金额(如 100000)
|
||||
3. 填备注(如 `首次划拨`)→ 确认
|
||||
|
||||
**步骤 5:告知对方**
|
||||
|
||||
发给对方以下信息:
|
||||
- 火山控制台登录地址:`https://console.volcengine.com`
|
||||
- 用户名:`dept_video`
|
||||
- 密码:你设置的密码
|
||||
- 登录后选择项目 `team-video-1`,进入方舟平台创建 Seedance 2.0 的 API Key
|
||||
|
||||
---
|
||||
|
||||
### 给子账号追加/扣减额度
|
||||
### 3. 额度划拨
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **划拨**
|
||||
2. 选择「追加额度」或「扣减额度」
|
||||
3. 输入金额和备注 → 确认
|
||||
2. 输入正数追加、负数扣减
|
||||
3. **必须填写备注** → 确认
|
||||
|
||||
> 扣减有保护:总额度不能低于已消费金额
|
||||
|
||||
---
|
||||
### 4. 监控配置
|
||||
|
||||
### 给子账号增加新项目
|
||||
1. 子账号管理 → 找到目标用户 → 点 **监控配置**
|
||||
2. 可配置项:
|
||||
- **阶梯告警**:自定义告警百分比阶梯(如 50,80,90),未设置则使用全局默认值
|
||||
- **消费监控开关**:开启/关闭该用户的消费监控
|
||||
- **自动停用开关**:额度用尽时是否自动停用(关闭则只告警不停用)
|
||||
- **Deny策略免除开关**:管理员自用账号可开启,免除 Deny 策略限制
|
||||
|
||||
1. 先在火山控制台创建新项目
|
||||
2. 回到 AirGate → 子账号管理 → 更多 → **项目管理**
|
||||
3. 从下拉框选择新项目 → 添加(自动授权)
|
||||
|
||||
---
|
||||
|
||||
### 关闭某个项目的监测
|
||||
|
||||
1. 子账号管理 → 更多 → **项目管理**
|
||||
2. 找到目标项目 → 关闭「监测」开关
|
||||
3. 该项目的消费不再计入子账号的累计消费(不影响告警和停用判断)
|
||||
|
||||
---
|
||||
|
||||
### 手动停用/恢复子账号
|
||||
### 5. 停用/恢复账号
|
||||
|
||||
**停用:**
|
||||
1. 子账号管理 → 更多 → **停用账号**
|
||||
2. 确认后,子账号的控制台登录和所有 API Key 立即失效
|
||||
2. 系统自动执行:
|
||||
- 关闭火山控制台登录
|
||||
- 停用所有 API Key
|
||||
- 移除所有权限策略
|
||||
- 保存策略快照(区分全局策略/项目级策略 + 登录状态),用于恢复
|
||||
|
||||
**恢复:**
|
||||
1. 子账号管理 → 更多 → **恢复账号**
|
||||
2. 确认后,控制台登录和 API Key 立即恢复
|
||||
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 加密存储
|
||||
|
||||
> API Key 采用手动录入方式。管理员在火山控制台创建 Key 后,将明文录入 AirGate。
|
||||
> 原因:火山 `CreateApiKey` API 不返回 Key 明文,`ListApiKeys` 只返回脱敏值。
|
||||
|
||||
操作:查看明文 / 启用 / 停用 / 删除,可按子账号、项目筛选。
|
||||
|
||||
### 9. 系统管理
|
||||
|
||||
1. **修改密码**:左侧菜单 → 系统管理 → 修改密码
|
||||
2. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码
|
||||
3. **操作日志**:查看所有系统操作记录(含类型筛选)
|
||||
|
||||
---
|
||||
|
||||
### 查看消费明细
|
||||
## 三、Deny策略说明
|
||||
|
||||
1. 左侧菜单 → **消费监控**
|
||||
2. 表格展示每个子账号的累计消费、额度、使用率
|
||||
3. 点行首的 **展开箭头**,查看该子账号各项目的独立消费
|
||||
4. 点 **刷新消费数据** 手动触发一次消费查询
|
||||
5. 点 **查看主账号余额** 查看主账号的可用余额
|
||||
### 原理
|
||||
|
||||
> 消费数据来自火山 Billing API,有 1-2 天延迟
|
||||
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. 左侧菜单 → **告警记录**
|
||||
2. 可按类型筛选:告警 / 自动停用 / 手动操作 / 错误
|
||||
> 子账号使用独立的登录入口,不需要登录火山控制台。
|
||||
|
||||
### 登录
|
||||
|
||||
1. 打开 AirGate 登录页面
|
||||
2. 切换到 **「子账号登录」**
|
||||
3. 输入用户名和密码(由管理员提供)
|
||||
|
||||
### 查看我的 API Key
|
||||
|
||||
1. 登录后默认进入 **「我的 API Key」** 页面
|
||||
2. 显示管理员分配给你的所有 API Key
|
||||
3. 点 **查看** 显示完整 Key 明文 → 点 **复制** 复制到剪贴板
|
||||
4. Key 状态:启用(可用)/ 停用(不可用,联系管理员)
|
||||
|
||||
### 修改密码
|
||||
|
||||
1. 左侧菜单 → **修改密码**
|
||||
2. 输入原密码 + 新密码 → 确认
|
||||
3. 修改成功后自动跳转到登录页,需要用新密码重新登录
|
||||
|
||||
### 使用 API Key 调用服务
|
||||
|
||||
拿到 API Key 后,直接调用火山方舟的 API:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "你在 AirGate 看到的 Key"
|
||||
url = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# 调用示例(以 Seedance 2.0 为例)
|
||||
response = requests.post(url, headers=headers, json={...})
|
||||
```
|
||||
|
||||
> 不需要登录火山控制台,API Key 可以直接使用。
|
||||
|
||||
---
|
||||
|
||||
### 修改子账号的告警阶梯
|
||||
|
||||
1. 子账号管理 → 更多 → **监控配置**
|
||||
2. 修改告警阶梯百分比(如添加 95%)
|
||||
3. 开关消费监控 / 额度用尽自动停用
|
||||
4. 保存
|
||||
|
||||
---
|
||||
|
||||
### 查看/管理子账号的权限策略
|
||||
|
||||
1. 子账号管理 → 更多 → **权限策略**
|
||||
2. 上方可从下拉框选择策略手动附加
|
||||
3. 已有策略列表中可点 **移除**
|
||||
|
||||
> 常规情况不需要手动管理权限,添加项目时会自动授权
|
||||
|
||||
---
|
||||
|
||||
## 三、告警与自动停用机制
|
||||
## 五、告警与自动停用机制
|
||||
|
||||
```
|
||||
定时任务每小时运行一次
|
||||
@ -183,10 +224,11 @@ npm run dev
|
||||
- 每个阶梯只通知一次,不会重复
|
||||
- 追加或扣减额度后,告警状态自动重置
|
||||
- 「额度用尽自动停用」可在监控配置中关闭(只告警不停用)
|
||||
- 停用会同时移除所有权限策略,确保即使有活跃会话也立即失效
|
||||
|
||||
---
|
||||
|
||||
## 四、外部系统对接(AirDrama)
|
||||
## 六、外部系统对接(AirDrama)
|
||||
|
||||
AirGate 支持通过 API Key 认证供外部系统调用:
|
||||
|
||||
@ -199,14 +241,18 @@ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/iam-users/
|
||||
curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/
|
||||
```
|
||||
|
||||
完整 API 列表见 [README.md](README.md) 或研究报告第 11 章。
|
||||
完整 API 列表见研究报告第 11 章。
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
## 七、火山API限制
|
||||
|
||||
1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量
|
||||
2. **SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了,务必保存
|
||||
3. **项目由你创建**:子账号没有创建项目的权限,需要新项目时在火山控制台创建后在 AirGate 关联
|
||||
4. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭
|
||||
5. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的火山主账号 AK/SK 无法解密,需要重新配置
|
||||
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,7 +1,7 @@
|
||||
# 火山引擎 IAM 子账号管控工具 -- 深度研究报告
|
||||
|
||||
> 研究日期:2026-03-19
|
||||
> 目标:通过火山引擎 Open API,实现对 IAM 子账号的全面管控,包括权限隔离、消费监控、告警、自动停用等功能。
|
||||
> 研究日期:2026-03-19(最后更新:2026-03-20)
|
||||
> 目标:通过火山引擎 Open API,构建 IAM 子账号的完整管控平台。子账号**不登录火山控制台**,所有操作(API Key 管理、消费查询等)均通过 AirGate 完成,实现权限隔离、消费监控、告警、自动停用等功能。
|
||||
|
||||
---
|
||||
|
||||
@ -18,8 +18,10 @@
|
||||
9. [项目管理与资源隔离](#9-项目管理与资源隔离)
|
||||
10. [SDK 与工具链](#10-sdk-与工具链)
|
||||
11. [可执行实施方案](#11-可执行实施方案)
|
||||
12. [限制与注意事项](#12-限制与注意事项)
|
||||
13. [参考文档](#13-参考文档)
|
||||
12. [方舟 API Key 管理](#12-方舟-api-key-管理)
|
||||
13. [实测发现与架构决策](#13-实测发现与架构决策)
|
||||
14. [限制与注意事项](#14-限制与注意事项)
|
||||
15. [参考文档](#15-参考文档)
|
||||
|
||||
---
|
||||
|
||||
@ -29,40 +31,54 @@
|
||||
|
||||
| 需求 | 实现方式 | 可行性 |
|
||||
|------|----------|--------|
|
||||
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
|
||||
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
|
||||
| 子账号不能看到主账号信息 | **子账号不登录火山控制台**,只登录 AirGate | **完全可行** |
|
||||
| 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess,全局无权限 | **完全可行** |
|
||||
| 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** |
|
||||
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
|
||||
| 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** |
|
||||
| 子账号能查看自己的 API Key | 管理员在火山控制台创建 Key 后录入 AirGate,子账号登录 AirGate 查看 | **完全可行** |
|
||||
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** |
|
||||
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
|
||||
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** |
|
||||
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** |
|
||||
| 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** |
|
||||
|
||||
### 1.2 架构图
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 管控工具 (后端服务) │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ IAM管理 │ │ 消费监控 │ │ 告警引擎 │ │
|
||||
│ │ 模块 │ │ 模块 │ │ 模块 │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼─────────────▼─────────────▼───────┐ │
|
||||
│ │ 火山引擎 Open API 调用层 │ │
|
||||
│ │ (HMAC-SHA256 签名认证) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ AirGate(子账号的唯一操作入口) │
|
||||
│ │
|
||||
│ 管理员界面 子账号界面 │
|
||||
│ ┌───────────────┐ ┌──────────────┐ │
|
||||
│ │ 子账号管理 │ │ 我的 API Key │ │
|
||||
│ │ 消费监控/告警 │ │ 我的消费 │ │
|
||||
│ │ 额度划拨 │ │ 我的项目 │ │
|
||||
│ │ 权限配置 │ └──────────────┘ │
|
||||
│ │ 系统管理 │ │
|
||||
│ └───────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ 火山引擎 Open API 调用层 │ │
|
||||
│ │ IAM API | Billing API | 方舟 API (Ark) │ │
|
||||
│ │ (HMAC-SHA256 签名认证) │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
|
||||
│ IAM API │ │Billing API│ │CloudMonitor│
|
||||
┌────▼────┐ ┌─────▼─────┐ ┌────▼──────┐
|
||||
│ IAM API │ │Billing API│ │ 方舟 API │
|
||||
│iam.vol..│ │billing.vol│ │open.vol.. │
|
||||
└─────────┘ └───────────┘ └────────────┘
|
||||
└─────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### 1.3 关键发现
|
||||
### 1.3 关键发现与架构决策
|
||||
|
||||
> **重要**:火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**或**标签(Tag)**维度来实现,由主账号通过 Billing API 查询后聚合展示。
|
||||
> **重要发现(2026-03-20 实测验证)**:
|
||||
>
|
||||
> 1. **火山控制台的权限隔离不彻底**:给子账号全局 `ArkReadOnlyAccess` 后,子账号在方舟控制台能看到**所有项目**的 API Key,包括其他子账号和主账号的资源。项目级授权能控制"能不能操作",但控制台页面渲染依赖全局只读权限。
|
||||
>
|
||||
> 2. **方舟 API Key 管理页面需要 `ArkExperienceAccess` 全局权限**:即使给了项目级 `ArkFullAccess`,不加全局体验权限就无法进入 API Key 管理页面。而加了全局权限又会泄露其他项目信息。
|
||||
>
|
||||
> 3. **架构决策**:基于以上发现,AirGate 的定位从"管控工具"升级为"子账号的唯一操作入口"。子账号**不登录火山控制台**,所有操作(创建/查看/删除 API Key、查看消费等)均通过 AirGate 完成。AirGate 使用主账号的 AK/SK 调用火山 API,在应用层做项目级隔离。
|
||||
>
|
||||
> 4. 火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**维度来实现。
|
||||
|
||||
---
|
||||
|
||||
@ -530,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 账户余额查询
|
||||
|
||||
@ -868,13 +899,14 @@ iam.call("CreateUser", {
|
||||
"MobilePhone": "+8618000000000"
|
||||
})
|
||||
|
||||
# 开通控制台登录
|
||||
iam.call("CreateLoginProfile", {
|
||||
"UserName": "dept_a_user",
|
||||
"Password": "Initial@Pass123",
|
||||
"LoginAllowed": "true",
|
||||
"PasswordResetRequired": "true"
|
||||
})
|
||||
# 注意:不开通控制台登录(子账号通过 AirGate 操作,不登录火山控制台)
|
||||
# 如果确实需要开通控制台登录(不推荐),取消以下注释:
|
||||
# iam.call("CreateLoginProfile", {
|
||||
# "UserName": "dept_a_user",
|
||||
# "Password": "Initial@Pass123",
|
||||
# "LoginAllowed": "true",
|
||||
# "PasswordResetRequired": "true"
|
||||
# })
|
||||
|
||||
# 创建 API 密钥(记录返回的 SecretAccessKey!)
|
||||
result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"})
|
||||
@ -1119,25 +1151,223 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛
|
||||
|
||||
# 项目列表
|
||||
GET /api/v1/projects/ # 从火山拉取项目列表
|
||||
|
||||
# 方舟 API Key 管理(管理员手动录入,子账号只能查看自己的 Key)
|
||||
GET /api/v1/ark-keys/ # 列出 API Key(管理员看全部,子账号看自己的)
|
||||
POST /api/v1/ark-keys/ # 录入 API Key(管理员操作)
|
||||
PUT /api/v1/ark-keys/{id}/ # 更新 API Key(启用/停用/改备注)
|
||||
DELETE /api/v1/ark-keys/{id}/ # 删除 API Key(管理员操作)
|
||||
GET /api/v1/ark-keys/{id}/reveal/ # 查看完整 Key(解密展示)
|
||||
|
||||
# 管理员管理
|
||||
GET /api/v1/auth/admins/ # 列出所有管理员
|
||||
POST /api/v1/auth/admins/create/ # 创建管理员
|
||||
POST /api/v1/auth/admins/{id}/toggle/ # 启用/停用管理员
|
||||
POST /api/v1/auth/admins/{id}/reset-password/ # 重置管理员密码
|
||||
POST /api/v1/auth/change-password/ # 修改当前用户密码
|
||||
|
||||
# 子账号项目策略管理
|
||||
PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权策略
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 限制与注意事项
|
||||
## 12. 方舟 API Key 管理
|
||||
|
||||
### 12.1 关键限制
|
||||
### 12.1 方舟 Open API 调研结果(2026-03-20 实测)
|
||||
|
||||
方舟 API Key 管理接口使用 **POST + JSON body** 方式调用(与 IAM 的 GET + Query 不同)。
|
||||
|
||||
| 接口 | 说明 | 状态 |
|
||||
|------|------|------|
|
||||
| `ListApiKeys` | 列出项目下的 API Key(返回脱敏值 `fedd****a052`) | **已验证** |
|
||||
| `CreateApiKey` | 创建 API Key(**仅返回 ID,不返回明文 Key**) | **已验证** |
|
||||
| `DeleteApiKey` | 删除 API Key | **已验证** |
|
||||
| `GetApiKey` | 需要 `DurationSeconds`,疑似生成临时凭证,非查询明文 | **不适用** |
|
||||
|
||||
### 12.2 关键限制
|
||||
|
||||
> **方舟 API Key 的明文(完整 Key)只有在火山控制台网页上创建时才会显示一次。通过 Open API 创建的 Key 无法获取明文,`ListApiKeys` 返回的永远是脱敏值。**
|
||||
|
||||
这意味着通过 API 自动化创建 Key 后,用户拿不到可用的 Key 值。
|
||||
|
||||
### 12.3 最终方案:管理员手动录入
|
||||
|
||||
鉴于上述限制,AirGate 采用**手动录入模式**:
|
||||
|
||||
```
|
||||
管理员操作流程:
|
||||
1. 登录火山控制台 → 切到子账号的项目 → 创建 API Key → 复制完整 Key
|
||||
2. 登录 AirGate → API Key 管理 → 录入 Key(绑定到子账号 + 项目)
|
||||
3. 子账号登录 AirGate → 只能看到自己的 Key
|
||||
|
||||
数据模型(存入 AirGate 数据库):
|
||||
ArkApiKey:
|
||||
- iam_user: FK → IAMUser(所属子账号)
|
||||
- project_name: 所属项目名
|
||||
- key_name: Key 名称/用途说明
|
||||
- api_key_enc: 加密存储的完整 API Key(AES-256)
|
||||
- api_key_hint: 脱敏显示(前4后4)
|
||||
- status: active / disabled
|
||||
- created_by: 谁录入的
|
||||
- created_at / updated_at
|
||||
```
|
||||
|
||||
### 12.4 安全设计
|
||||
|
||||
- **加密存储**:API Key 使用与主账号 AK/SK 相同的 `AIRGATE_ENCRYPTION_KEY` 加密存储
|
||||
- **按需解密**:子账号查看 Key 时解密展示,页面关闭后不保留
|
||||
- **权限隔离**:子账号只能看到绑定给自己的 Key,管理员能看到所有
|
||||
- **操作审计**:Key 的录入、查看、停用、删除均记录到操作日志
|
||||
|
||||
### 12.5 火山控制台操作保留
|
||||
|
||||
以下操作仍需管理员在火山控制台完成(无法通过 API 替代):
|
||||
|
||||
| 操作 | 在哪里做 | 频率 |
|
||||
|------|----------|------|
|
||||
| 创建火山项目 | 火山控制台 | 低(新团队入驻时) |
|
||||
| 在项目下开通模型端点 | 火山控制台 | 低(新模型接入时) |
|
||||
| 创建方舟 API Key | 火山控制台 | 低(按需创建) |
|
||||
| **其他所有操作** | **AirGate** | 日常 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 实测发现与架构决策
|
||||
|
||||
### 13.1 火山控制台权限隔离问题(2026-03-20)
|
||||
|
||||
| 测试场景 | 结果 |
|
||||
|----------|------|
|
||||
| 项目级 `ArkFullAccess` + 无全局权限 | 无法进入方舟控制台页面,提示需要 `ArkReadOnlyAccess` |
|
||||
| 全局 `ArkReadOnlyAccess` | 能进入控制台,但能看到**所有项目**的 API Key |
|
||||
| 全局 `ArkExperienceAccess` | 能进入体验中心,能看到所有项目的内容 |
|
||||
| 停用账号但子账号未刷新页面 | 子账号仍可在体验中心生成视频 |
|
||||
| 停用账号 + 移除所有策略 | 子账号刷新页面后立即失效 |
|
||||
|
||||
**结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目",必须在应用层(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 实测)
|
||||
|
||||
| 测试场景 | 结果 |
|
||||
|----------|------|
|
||||
| 项目级 `ArkFullAccess` 后,用 AK/SK 调 `ListApiKeys` 指定其他项目 | **能看到**其他项目的 API Key(脱敏) |
|
||||
| 项目级 `ArkFullAccess` 后,用 AK/SK 调 `ListAssetGroups` 指定其他项目 | **能看到**其他项目的全部素材组 |
|
||||
|
||||
**关键发现**:项目级授权(`AttachUserPolicy` + `ProjectName`)只限制了火山控制台的视图,**API 层面的 `ListApiKeys`、`ListAssetGroups` 等查询接口不受项目级权限约束**。子账号用 AK/SK 可以跨项目查询甚至操作其他项目的方舟资源。
|
||||
|
||||
**解决方案**:为每个子账号创建自定义 Deny 策略,使用 `NotResource` 明确限定只能访问授权项目:
|
||||
|
||||
```json
|
||||
{
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["ark:*"],
|
||||
"NotResource": [
|
||||
"trn:iam::*:project/HAGOOT_DEV"
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
- `NotResource` 表示"除了列出的项目外,其他全部 Deny"
|
||||
- Deny 优先级高于 Allow,确保跨项目访问被完全阻断
|
||||
- AirGate 在添加/移除关联项目时自动更新此 Deny 策略的 `NotResource` 列表
|
||||
- 策略命名规则:`AirGate_Deny_{username}`,每个子账号一个
|
||||
|
||||
**实测验证(2026-03-28)**:
|
||||
|
||||
| 测试 | 无 Deny 策略 | 有 Deny 策略 |
|
||||
|------|-------------|-------------|
|
||||
| `ListAssetGroups` 指定 `int_dev_Airlabs` | 返回 79 个素材组 | **被拒绝** ✅ |
|
||||
| `ListApiKeys` 指定 `int_dev_Airlabs` | 返回 1 个 Key | **被拒绝** ✅ |
|
||||
| `ListAssetGroups` 指定 `HAGOOT_DEV` | 正常返回 | 正常返回 ✅ |
|
||||
|
||||
### 13.2 最终权限方案:全局授权 + Deny策略隔离
|
||||
|
||||
```
|
||||
子账号在火山引擎上的权限(由 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 策略实现。
|
||||
|
||||
火山控制台登录:默认关闭(AirGate 提供开关可随时切换)
|
||||
|
||||
Deny 策略自动管理(项目隔离的唯一可靠手段):
|
||||
- 创建子账号时 → 自动创建 AirGate_Deny_{username} 策略
|
||||
- 添加关联项目时 → 自动更新 Deny 策略,将新项目加入白名单
|
||||
- 移除关联项目时 → 自动更新 Deny 策略,将项目从白名单移除
|
||||
- 火山项目变动时 → 刷新所有用户的 Deny 策略
|
||||
- Deny 策略列出所有非白名单项目并明确拒绝
|
||||
- 策略命名:AirGate_Deny_{username}
|
||||
- 管理员自用账号可免除 Deny 策略(监控配置中开启)
|
||||
```
|
||||
|
||||
子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成:
|
||||
|
||||
| 操作 | 在哪里做 |
|
||||
|------|----------|
|
||||
| 查看自己的 API Key | AirGate(管理员录入,子账号查看) |
|
||||
| 查看消费 | AirGate(代调 Billing API) |
|
||||
| 管理项目 | AirGate(管理员操作) |
|
||||
| 使用 Seedance 2.0 | 直接用 API Key 调用(不需要控制台) |
|
||||
|
||||
### 13.3 停用/恢复增强方案(2026-03-20 实测验证)
|
||||
|
||||
停用操作执行三步(确保即使子账号有活跃浏览器会话也立即失效):
|
||||
|
||||
1. **停用控制台登录**:`UpdateLoginProfile(LoginAllowed=false)` — 阻止新登录
|
||||
2. **停用所有 API 密钥**:`UpdateAccessKey(Status=inactive)` — 阻止 API 调用
|
||||
3. **移除所有权限策略**:遍历 `ListAttachedUserPolicies` 结果,逐个 `DetachUserPolicy` — 已登录的会话刷新后也无法操作
|
||||
|
||||
移除的策略列表保存到数据库 `saved_policies_on_disable` 字段(JSONField),恢复时自动附加回来。
|
||||
|
||||
---
|
||||
|
||||
## 14. 限制与注意事项
|
||||
|
||||
### 14.1 关键限制
|
||||
|
||||
| 限制项 | 说明 |
|
||||
|--------|------|
|
||||
| IAM 子账号无独立计费 | 所有费用归主账号,通过多项目聚合追踪(子账号关联 N 个项目,消费=开启监测的项目之和) |
|
||||
| Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 |
|
||||
| 每用户最多 2 个 API 密钥 | 无法创建更多 |
|
||||
| SecretKey 仅返回一次 | 创建后立即保存 |
|
||||
| 每用户最多 2 个 IAM AccessKey | IAM 级别的 AK/SK 最多 2 对(方舟 API Key 无此限制) |
|
||||
| IAM SecretKey 仅返回一次 | 创建后立即保存 |
|
||||
| Billing API QPS 限制 5 | 批量查询需注意限流 |
|
||||
| Ark 推理限额无公开 API | 目前仅支持控制台操作 |
|
||||
| **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 |
|
||||
| **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 |
|
||||
| **方舟 CreateApiKey 不返回 Key 明文** | 只返回 ID,ListApiKeys 返回脱敏值,明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 |
|
||||
| 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 |
|
||||
| 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
|
||||
| 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 |
|
||||
|
||||
### 12.2 安全建议
|
||||
### 14.2 安全建议
|
||||
|
||||
1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务
|
||||
2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥
|
||||
@ -1145,7 +1375,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
|
||||
4. **显式 Deny 策略优先**,防止权限漏洞
|
||||
5. **监控日志**,使用 CloudTrail 审计 API 调用
|
||||
|
||||
### 12.3 消费监控的精确度问题
|
||||
### 14.3 消费监控的精确度问题
|
||||
|
||||
由于账单数据有 1-2 天延迟,消费监控存在滞后。AirGate 的应对策略:
|
||||
- **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90])
|
||||
@ -1155,7 +1385,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
|
||||
|
||||
---
|
||||
|
||||
## 13. 参考文档
|
||||
## 15. 参考文档
|
||||
|
||||
### 官方文档
|
||||
|
||||
|
||||
20
版本管理.md
20
版本管理.md
@ -2,6 +2,26 @@
|
||||
|
||||
---
|
||||
|
||||
## 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