Compare commits
No commits in common. "cc8a91995db9d2aec33bfe4dc541166f8b866158" and "f305ae4262b2e8671c873ae90f2aa990e45de8b1" have entirely different histories.
cc8a91995d
...
f305ae4262
@ -1,140 +0,0 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["https://docker.m.daocloud.io", "https://docker.1panel.live", "https://hub.rat.dev"]
|
||||
|
||||
- name: Login to Huawei Cloud SWR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ secrets.SWR_SERVER }}
|
||||
username: ${{ secrets.SWR_USERNAME }}
|
||||
password: ${{ secrets.SWR_PASSWORD }}
|
||||
|
||||
- name: Build and Push Backend
|
||||
id: build_backend
|
||||
run: |
|
||||
set -o pipefail
|
||||
docker buildx build \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/airgate-backend:latest \
|
||||
./backend 2>&1 | tee /tmp/build.log
|
||||
|
||||
- name: Build and Push Web
|
||||
id: build_web
|
||||
run: |
|
||||
set -o pipefail
|
||||
docker buildx build \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/airgate-web:latest \
|
||||
./frontend 2>&1 | tee -a /tmp/build.log
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.K3S_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.K3S_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy to K3s via SSH
|
||||
id: deploy
|
||||
run: |
|
||||
SWR_IMAGE="${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}"
|
||||
|
||||
# Replace image placeholders in yaml files
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/airgate-backend:latest|${SWR_IMAGE}/airgate-backend:latest|g" k8s/backend-deployment.yaml
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/airgate-web:latest|${SWR_IMAGE}/airgate-web:latest|g" k8s/web-deployment.yaml
|
||||
|
||||
# Copy k8s manifests to server
|
||||
scp -o StrictHostKeyChecking=no k8s/backend-deployment.yaml k8s/web-deployment.yaml k8s/ingress.yaml root@${{ secrets.K3S_HOST }}:/tmp/
|
||||
|
||||
# Create/update secrets and apply manifests on server
|
||||
set -o pipefail
|
||||
ssh -o StrictHostKeyChecking=no root@${{ secrets.K3S_HOST }} << ENDSSH
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
|
||||
kubectl apply -f /tmp/backend-deployment.yaml
|
||||
kubectl apply -f /tmp/web-deployment.yaml
|
||||
kubectl apply -f /tmp/ingress.yaml
|
||||
kubectl rollout restart deployment/airgate-backend
|
||||
kubectl rollout restart deployment/airgate-web
|
||||
|
||||
rm -f /tmp/backend-deployment.yaml /tmp/web-deployment.yaml /tmp/ingress.yaml
|
||||
ENDSSH
|
||||
|
||||
# ===== Log Center: failure reporting =====
|
||||
- name: Report failure to Log Center
|
||||
if: failure()
|
||||
run: |
|
||||
BUILD_LOG=""
|
||||
DEPLOY_LOG=""
|
||||
FAILED_STEP="unknown"
|
||||
|
||||
if [[ "${{ steps.build_backend.outcome }}" == "failure" || "${{ steps.build_web.outcome }}" == "failure" ]]; then
|
||||
FAILED_STEP="build"
|
||||
if [ -f /tmp/build.log ]; then
|
||||
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||
fi
|
||||
elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then
|
||||
FAILED_STEP="deploy"
|
||||
if [ -f /tmp/deploy.log ]; then
|
||||
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||
fi
|
||||
fi
|
||||
|
||||
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
|
||||
if [ -z "$ERROR_LOG" ]; then
|
||||
ERROR_LOG="No captured output. Check Gitea Actions UI for details."
|
||||
fi
|
||||
|
||||
if [[ "$FAILED_STEP" == "deploy" ]]; then
|
||||
SOURCE="deployment"
|
||||
ERROR_TYPE="DeployError"
|
||||
else
|
||||
SOURCE="cicd"
|
||||
ERROR_TYPE="DockerBuildError"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"project_id\": \"airgate\",
|
||||
\"environment\": \"${{ github.ref_name }}\",
|
||||
\"level\": \"ERROR\",
|
||||
\"source\": \"${SOURCE}\",
|
||||
\"commit_hash\": \"${{ github.sha }}\",
|
||||
\"repo_url\": \"${{ github.server_url }}/${{ github.repository }}\",
|
||||
\"error\": {
|
||||
\"type\": \"${ERROR_TYPE}\",
|
||||
\"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\",
|
||||
\"stack_trace\": [\"${ERROR_LOG}\"]
|
||||
},
|
||||
\"context\": {
|
||||
\"job_name\": \"build-and-deploy\",
|
||||
\"step_name\": \"${FAILED_STEP}\",
|
||||
\"workflow\": \"${{ github.workflow }}\",
|
||||
\"run_id\": \"${{ github.run_number }}\",
|
||||
\"branch\": \"${{ github.ref_name }}\",
|
||||
\"actor\": \"${{ github.actor }}\",
|
||||
\"commit\": \"${{ github.sha }}\",
|
||||
\"run_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
||||
}
|
||||
}" || true
|
||||
79
README.md
79
README.md
@ -1,79 +0,0 @@
|
||||
# AirGate
|
||||
|
||||
火山引擎 IAM 子账号管控平台。独立部署,带管理界面,可通过 API 对接其他系统。
|
||||
|
||||
## 解决什么问题
|
||||
|
||||
火山引擎的 IAM 子账号没有消费限额功能,子账号可以不受限地花光主账号余额。AirGate 通过额度划拨 + 阶梯告警 + 自动停用,实现对子账号的消费管控。
|
||||
|
||||
## 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **子账号管理** | 创建 / 同步 / 停用 / 恢复 IAM 子账号 |
|
||||
| **权限策略** | 查看 / 附加 / 移除 IAM 策略(Ark、TOS 等) |
|
||||
| **额度划拨** | 主账号给子账号划拨额度,支持追加和扣减 |
|
||||
| **阶梯告警** | 消费达到额度的 50% / 80% / 90% 时飞书通知 |
|
||||
| **自动停用** | 消费达到额度 100% 时自动停用子账号 |
|
||||
| **消费监控** | 定时查询 Billing API,按项目维度追踪消费 |
|
||||
| **密钥安全** | 主账号 AK/SK 加密存储(AES-256),界面脱敏显示 |
|
||||
|
||||
## 使用前提
|
||||
|
||||
- 子账号的资源需要放在**独立的火山项目**下,消费才能按项目准确追踪
|
||||
- 火山账单数据有 **1-2 天延迟**,划拨额度时建议预留余量
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
# 1. 复制环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 AIRGATE_ENCRYPTION_KEY(生成方式见文件内注释)
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
python manage.py runserver 8101
|
||||
|
||||
# 3. 启动前端(另一个终端)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 `http://localhost:5174`,登录后:
|
||||
|
||||
1. **系统设置** → 添加火山主账号(填 AK/SK,加密存储)
|
||||
2. **子账号管理** → 同步已有用户 或 创建新子账号
|
||||
3. 给子账号点 **划拨** → 设置额度
|
||||
4. 点 **更多 → 监控配置** → 选关联项目 + 设告警阶梯
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
- 前端:`http://localhost:5174`
|
||||
- 后端:`http://localhost:8101`
|
||||
|
||||
## 外部系统对接
|
||||
|
||||
AirDrama 等外部系统可通过 API Key 调用 AirGate 接口:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: 你的key" http://airgate:8101/api/v1/iam-users/
|
||||
```
|
||||
|
||||
在 `.env` 中设置 `AIRGATE_API_KEY` 启用。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Django 4.2 + DRF + APScheduler
|
||||
- 前端:Vue 3 + Element Plus
|
||||
- 加密:cryptography (Fernet)
|
||||
- 部署:Docker + Nginx
|
||||
@ -1,692 +0,0 @@
|
||||
# 【申请权限填客户名称】Seedance 2.0 & 2.0 fast API文档(邀测用户版)
|
||||
|
||||
该文档目前仅限开白客户使用,发送前请和销管确认客户是否在开白名单内
|
||||
|
||||
***【❗️❗️❗️】该文档限制客户申请权限,只有返回了服务协议的客户方可申请***
|
||||
|
||||
本文介绍 Seedance 2.0 & 2.0 fast 模型相较于存量模型 **新增/配置有区别 **的 API 参数介绍,存量 API 参数的完整介绍参见 [视频生成 API](https://www.volcengine.com/docs/82379/1520758?lang=zh)。
|
||||
|
||||
> 本文档仅限预览及邀测用户使用:
|
||||
>
|
||||
> * 不承诺正式API上线100%一致。
|
||||
>
|
||||
> * 仅限邀测用户阅读,请勿截图/分享给其他人员。
|
||||
>
|
||||
> * 您上传的内容请确保由您原创或已取得授权。
|
||||
|
||||
# 模型能力
|
||||
|
||||
> **Seedance 2.0 和 Seedance 2.0 fast 提供的模型能力一致,**追求最高生成品质,推荐使用 **Seedance 2.0**;更注重成本与生成速度,不要求极限品质,推荐使用 **Seedance 2.0 fast**。
|
||||
|
||||
**Seedance 2.0 & 2.0 fast (有声视频/无声视频)**
|
||||
|
||||
* **多模态参考生视频**:输入参考图片(0\~9)+参考视频(0\~3)+ 参考音频(0\~3)+ 文本提示词(可选)生成 1 个目标视频。支持生成全新视频、编辑视频、延长视频。
|
||||
|
||||
> **注意:不可单独输入音频,应至少包含 1 个参考视频或图片。**
|
||||
|
||||
* **图生视频-首尾帧**:输入首帧图片+尾帧图片+文本提示词(可选)生成 1 个目标视频。
|
||||
|
||||
* **图生视频-首帧**:输入首帧图片+文本提示词(可选)生成 1 个目标视频。
|
||||
|
||||
* **文生视频**:输入文本提示词生成 1 个目标视频。
|
||||
|
||||
|
||||
|
||||
**模型能力对比表:**
|
||||
|
||||
| 模型名称 | | [Seedance 2.0](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0) | [Seedance 2.0 fast](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0-fast\&projectName=default) | [Seedance 1.5 pro](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-5-pro\&projectName=default) | [Seedance 1.0 pro ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-pro\&projectName=default) | [Seedance 1.0 pro fast ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-pro-fast\&projectName=default) | [Seedance 1.0 lite i2v](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-lite-i2v\&projectName=default) | [Seedance-1.0 lite t2v ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-lite-t2v) |
|
||||
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Model ID | | doubao-seedance-2-0-260128 | doubao-seedance-2-0-fast-260128 | doubao-seedance-1-5-pro-251215 | doubao-seedance-1-0-pro-250528 | doubao-seedance-1-0-pro-fast-251015 | doubao-seedance-1-0-lite-i2v-250428 | doubao-seedance-1-0-lite-t2v-250428 |
|
||||
| 文生视频 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 图生视频-首帧 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| 图生视频-首尾帧 | | ✅ | | ✅ | ✅ | ❌ | ✅ | ❌ |
|
||||
| 多模态参考【New】 | 图片参考 | ✅ | | ❌ | ❌ | ❌ | ✅ | ❌ |
|
||||
| | 视频参考 | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| | 组合参考 | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| 编辑视频【New】 | | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| 延长视频【New】 | | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| 生成有声视频 | | ✅ | | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| 联网搜索增强【New】 | | ✅ | | ❌ | [❌](https://p9-arcosite.byteimg.com/obj/tos-cn-i-goo7wpa0wc/f359753773c94d97885008ca1223c9bc) | ❌ | ❌ | ❌ |
|
||||
| 样片模式 | | ❌ | | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| 返回视频尾帧 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 输出视频规格 | 输出分辨率 | 480p, 720p | | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p |
|
||||
| | 输出宽高比 | 21:9, 16:9, 4:3, 1:1, 3:4, 9:16 | | | | | | |
|
||||
| | 输出时长 | 4\~15 秒 | | 4\~12 秒 | 2\~12 秒 | 2\~12 秒 | 2\~12 秒 | 2\~12 秒 |
|
||||
| | 输出视频格式 | mp4 | | mp4 | mp4 | mp4 | mp4 | mp4 |
|
||||
| 离线推理 | | [❌](https://p9-arcosite.byteimg.com/obj/tos-cn-i-goo7wpa0wc/f359753773c94d97885008ca1223c9bc) | | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 在线推理限流 | RPM | 600 | | 600 | 600 | 600 | 300 | 300 |
|
||||
| | 并发数 | 10 | | 10 | 10 | 10 | 5 | 5 |
|
||||
| 离线推理限流 | TPD | - | | 5000亿 | 5000亿 | 5000亿 | 2500亿 | 2500亿 |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Creat-创建视频生成任务
|
||||
|
||||
> POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
|
||||
|
||||
## 请求参数
|
||||
|
||||
|
||||
|
||||
#### **content** `object[]` `必选`
|
||||
|
||||
输入给模型,生成视频的信息,支持文本、图片、音频、视频、样片任务 ID。支持以下几种组合:
|
||||
|
||||
* **文本**
|
||||
|
||||
* **文本(可选)+ 图片**
|
||||
|
||||
* **文本(可选)+ 视频**
|
||||
|
||||
* **文本(可选)+ 图片 + 音频**
|
||||
|
||||
* **文本(可选)+ 图片 + 视频**
|
||||
|
||||
* **文本(可选)+ 视频 + 音频**
|
||||
|
||||
* **文本(可选)+ 图片 + 视频 + 音频**
|
||||
|
||||
***
|
||||
|
||||
**信息类型:**
|
||||
|
||||
* **文本信息**`object`
|
||||
|
||||
输入给模型的提示词信息。
|
||||
|
||||
***
|
||||
|
||||
content.**type **`string` `必选`
|
||||
|
||||
输入内容的类型,此处应为 **text**。
|
||||
|
||||
***
|
||||
|
||||
content.**text **`string` `必选`
|
||||
|
||||
输入给模型的文本提示词,描述期望生成的视频。
|
||||
|
||||
支持中英文。建议中文不超过500字,英文不超过1000词。字数过多信息容易分散,模型可能因此忽略细节,只关注重点,造成视频缺失部分元素。提示词的更多使用技巧请参见 [Seedance 提示词指南](https://www.volcengine.com/docs/82379/1587797)。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
* **图片信息** `object`
|
||||
|
||||
输入给模型的图片信息。
|
||||
|
||||
***
|
||||
|
||||
content.**type **`string` `必选`
|
||||
|
||||
输入内容的类型,此处应为 **image\_url**。
|
||||
|
||||
***
|
||||
|
||||
content.**image\_url **`object` `必选`
|
||||
|
||||
输入给模型的图片对象。
|
||||
|
||||
***
|
||||
|
||||
content.image\_url.**url **`string` `必选`
|
||||
|
||||
图片 URL 、图片 Base64 编码、素材 ID。
|
||||
|
||||
* 图片 URL:填入图片的公网 URL。
|
||||
|
||||
* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:data:image/<图片格式>;base64,\<Base64编码>,注意 <图片格式> 需小写,如 data:image/png;base64,{base64\_image}。
|
||||
|
||||
* 素材 ID:用于视频生成的预置素材及虚拟人像的 ID,遵循格式:asset://\<ASSET\_ID>,可从 [素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128) 获取,详细使用请参见[文档](https://www.volcengine.com/docs/82379/2223965?lang=zh)。
|
||||
|
||||
> **传入单张图片要求**
|
||||
>
|
||||
> * 格式:jpeg、png、webp、bmp、tiff、gif
|
||||
>
|
||||
> * 宽高比(宽/高): (0.4, 2.5) 
|
||||
>
|
||||
> * 宽高长度(px):(300, 6000)
|
||||
>
|
||||
> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。
|
||||
>
|
||||
> * 图片数量:
|
||||
>
|
||||
> * 图生视频-首帧:1 张
|
||||
>
|
||||
> * 图生视频-首尾帧:2 张
|
||||
>
|
||||
> * Seedance 2.0 & 2.0 fast 多模态参考生视频:1\~9 张
|
||||
|
||||
***
|
||||
|
||||
content.**role **`string` `条件必填`
|
||||
|
||||
图片的位置或用途。
|
||||
|
||||
> **注意**
|
||||
>
|
||||
> * **图生视频-首帧**、**图生视频-首尾帧**、**多模态参考生视频**(包括参考图、视频、音频)为 3 种互斥场景,**不可混用**。
|
||||
>
|
||||
> * **多模态参考生视频**可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频-首尾帧**(配置 role 为 **first\_frame / last\_frame**)。
|
||||
|
||||
***
|
||||
|
||||
**图生视频-首帧**
|
||||
|
||||
> 需要传入1个 image\_url 对象
|
||||
|
||||
* **字段role取值:**
|
||||
|
||||
* **first\_frame 或不填**
|
||||
|
||||
***
|
||||
|
||||
**图生视频-首尾帧**
|
||||
|
||||
> 需要传入2个 image\_url 对象
|
||||
|
||||
* **字段role取值:**
|
||||
|
||||
* 首帧图片对应的字段 role 为:**first\_frame**,必填
|
||||
|
||||
* 尾帧图片对应的字段 role 为:**last\_frame**,必填
|
||||
|
||||
***
|
||||
|
||||
**图生视频-参考图 **
|
||||
|
||||
> 可传入 1\~9 个 image\_url 对象
|
||||
|
||||
* **字段role取值**:
|
||||
|
||||
* 每张参考图对应的字段 role 均为:**reference\_image**,必填
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
* **视频信息** `object` 
|
||||
|
||||
输入给模型的视频信息。仅 Seedance 2.0 & 2.0 fast 支持输入视频。
|
||||
|
||||
***
|
||||
|
||||
content.**type **`string` `必选`
|
||||
|
||||
输入内容的类型,此处应为 **video\_url**。
|
||||
|
||||
***
|
||||
|
||||
content.**video\_url **`object` `必选`
|
||||
|
||||
输入给模型的视频对象。
|
||||
|
||||
***
|
||||
|
||||
content.video\_url.**url **`string` `必选`
|
||||
|
||||
视频URL、素材 ID。
|
||||
|
||||
* 视频 URL:填入视频的公网 URL。
|
||||
|
||||
* 素材 ID:用于视频生成的预置素材及虚拟人像视频的 ID,遵循格式:asset://\<ASSET\_ID>。可从[素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。
|
||||
|
||||
> **传入单个视频要求**
|
||||
>
|
||||
> * 视频格式:mp4、mov。
|
||||
>
|
||||
> * 分辨率:480p、720p
|
||||
>
|
||||
> * 时长:单个视频时长 \[2, 15] s,最多传入 3 个参考视频,所有视频总时长不超过 15s。
|
||||
>
|
||||
> * 尺寸:
|
||||
>
|
||||
> * 宽高比(宽/高):\[0.4, 2.5]
|
||||
>
|
||||
> * 宽高长度(px):\[300, 6000]
|
||||
>
|
||||
> * 画面像素(宽 × 高):\[409600, 927408] ,示例:
|
||||
>
|
||||
> * 画面尺寸 640×640=409600 满足最小值 ;
|
||||
>
|
||||
> * 画面尺寸 834×1112=927408 满足最大值。
|
||||
>
|
||||
> * 大小:单个视频不超过 50 MB。
|
||||
>
|
||||
> * 帧率 (FPS):\[24, 60] 
|
||||
|
||||
***
|
||||
|
||||
content.**role **`string` `条件必填`
|
||||
|
||||
视频的位置或用途。当前仅支持 **reference\_video**。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
* **音频信息 **`object` 
|
||||
|
||||
输入给模型的音频信息。仅 Seedance 2.0 & 2.0 fast 支持输入音频。注意不可单独输入音频,应至少包含 1 个参考视频或图片。
|
||||
|
||||
***
|
||||
|
||||
content.**type **`string` `必选`
|
||||
|
||||
输入内容的类型,此处应为 **audio\_url**。
|
||||
|
||||
***
|
||||
|
||||
content.**audio\_url **`object` `必选`
|
||||
|
||||
输入给模型的音频对象。
|
||||
|
||||
***
|
||||
|
||||
content.audio\_url.**url **`string` `必选`
|
||||
|
||||
音频 URL 、音频 Base64 编码、素材 ID。
|
||||
|
||||
* 音频 URL:填入音频的公网 URL。
|
||||
|
||||
* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:data:audio/<音频格式>;base64,\<Base64编码>,注意 <音频格式> 需小写,如 data:audio/wav;base64,{base64\_audio}。
|
||||
|
||||
* 素材 ID:用于视频生成的虚拟人的音频素材 ID,遵循格式:asset://\<ASSET\_ID>。可从[素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。
|
||||
|
||||
> **传入单个音频要求**
|
||||
>
|
||||
> * 格式:wav、mp3
|
||||
>
|
||||
> * 时长:单个音频时长 \[2, 15] s,最多传入 3 段参考音频,所有音频总时长不超过 15 s。
|
||||
>
|
||||
> * 大小:单个音频不超过 15 MB,请求体大小不超过 64 MB。大文件请勿使用Base64编码。
|
||||
|
||||
***
|
||||
|
||||
content.**role **`string` `条件必填`
|
||||
|
||||
音频的位置或用途。当前仅支持 **reference\_audio** 。
|
||||
|
||||
|
||||
|
||||
#### **service\_tier** `string`
|
||||
|
||||
 Seedance 2.0 & 2.0 fast 暂不支持
|
||||
|
||||
|
||||
|
||||
#### **generate\_audio **`boolean` 
|
||||
|
||||
> Seedance 2.0 & 2.0 fast 默认值: true
|
||||
|
||||
控制生成的视频是否包含与画面同步的声音。
|
||||
|
||||
* true:模型输出的视频包含同步音频。模型会基于文本提示词与视觉内容,自动生成与之匹配的人声、音效及背景音乐。建议将对话部分置于双引号内,以优化音频生成效果。例如:男人叫住女人说:“你记住,以后不可以用手指指月亮。”
|
||||
|
||||
* false:模型输出的视频为无声视频。
|
||||
|
||||
> **说明**
|
||||
>
|
||||
> 生成的有声视频均为单声道,和传入的音频声道数无关。
|
||||
|
||||
####
|
||||
|
||||
#### **draft **`boolean`
|
||||
|
||||
 Seedance 2.0 & 2.0 fast 暂不支持
|
||||
|
||||
|
||||
|
||||
#### **tools **`object[]`
|
||||
|
||||
> 仅 Seedance 2.0 & 2.0 fast 支持
|
||||
|
||||
配置模型要调用的工具。
|
||||
|
||||
***
|
||||
|
||||
tools.**type **`string`
|
||||
|
||||
指定使用的工具类型。
|
||||
|
||||
* web\_search:联网搜索工具。
|
||||
|
||||
> **说明**
|
||||
>
|
||||
> * 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。
|
||||
>
|
||||
> * 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool\_usage.**web\_search** 字段获取,如果为 0 表示未搜索。
|
||||
|
||||
|
||||
|
||||
#### **resolution ** `string`
|
||||
|
||||
> Seedance 2.0 & 2.0 fast 默认值:720p
|
||||
|
||||
视频分辨率,取值范围:
|
||||
|
||||
* 480p
|
||||
|
||||
* 720p
|
||||
|
||||
|
||||
|
||||
#### **ratio **`string` 
|
||||
|
||||
> Seedance 2.0 & 2.0 fast 默认值: adaptive
|
||||
|
||||
生成视频的宽高比例。不同宽高比对应的宽高像素值见下方表格。
|
||||
|
||||
* 16:9 
|
||||
|
||||
* 4:3
|
||||
|
||||
* 1:1
|
||||
|
||||
* 3:4
|
||||
|
||||
* 9:16
|
||||
|
||||
* 21:9
|
||||
|
||||
* adaptive:根据输入自动选择最合适的宽高比
|
||||
|
||||
> **adaptive 适配规则**
|
||||
>
|
||||
> 当配置 **ratio** 为 adaptive 时,模型会根据生成场景自动适配宽高比;实际生成的视频宽高比可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **ratio** 字段获取。
|
||||
>
|
||||
> * 文生视频:根据输入的提示词,智能选择最合适的宽高比。
|
||||
>
|
||||
> * 首帧 / 首尾帧生视频:根据上传的首帧图片比例,自动选择最接近的宽高比。
|
||||
>
|
||||
> * 多模态参考生视频:根据用户提示词意图判断,如果是首帧生视频/编辑视频/延长视频,以该图片/视频为准选择最接近的宽高比;否则,以传入的第一个媒体文件为准(优先级:视频>图片)选择最接近的宽高比。
|
||||
|
||||
***
|
||||
|
||||
**不同宽高比对应的宽高像素值:**
|
||||
|
||||
| 分辨率 | 宽高比 | 宽高像素值 |
|
||||
| ---- | ---- | -------- |
|
||||
| 480p | 16:9 | 864×496 |
|
||||
| | 4:3 | 752×560 |
|
||||
| | 1:1 | 640×640 |
|
||||
| | 3:4 | 560×752 |
|
||||
| | 9:16 | 496×864 |
|
||||
| | 21:9 | 992×432 |
|
||||
| 720p | 16:9 | 1280×720 |
|
||||
| | 4:3 | 1112×834 |
|
||||
| | 1:1 | 960×960 |
|
||||
| | 3:4 | 834×1112 |
|
||||
| | 9:16 | 720×1280 |
|
||||
| | 21:9 | 1470×630 |
|
||||
|
||||
|
||||
|
||||
#### **duration** `integer` 
|
||||
|
||||
> Seedance 2.0 & 2.0 fast 默认值:5
|
||||
|
||||
生成视频时长,仅支持整数,单位:秒。
|
||||
|
||||
取值范围:
|
||||
|
||||
* \[4,15] 或设置为-1
|
||||
|
||||
> **配置方法**
|
||||
>
|
||||
> * 指定具体时长:支持有效范围内的任一整数。
|
||||
>
|
||||
> * 智能指定:设置为 -1,表示由模型在有效范围内自主选择合适的视频长度(整数秒)。实际生成视频的时长可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **duration** 字段获取。注意视频时长与计费相关,请谨慎设置。
|
||||
|
||||
|
||||
|
||||
#### **frames** `integer` 
|
||||
|
||||
Seedance 2.0 & 2.0 fast 暂不支持
|
||||
|
||||
|
||||
|
||||
#### **camera\_fixed** `boolean`
|
||||
|
||||
 Seedance 2.0 & 2.0 fast 暂不支持
|
||||
|
||||
|
||||
|
||||
# Get/List-查询视频生成任务/列表
|
||||
|
||||
> 查询视频生成任务:GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id}
|
||||
>
|
||||
> 查询视频生成任务列表:GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks?page\_num={page\_num}\&page\_size={page\_size}\&filter.status={filter.status}\&filter.task\_ids={filter.task\_ids}\&filter.model={filter.model}
|
||||
|
||||
## 响应参数
|
||||
|
||||
#### **tools **`object[]` 
|
||||
|
||||
> 仅 Seedance 2.0 & 2.0 fast 支持
|
||||
|
||||
配置模型要调用的工具。
|
||||
|
||||
***
|
||||
|
||||
tools.**type **`string`
|
||||
|
||||
指定使用的工具类型。
|
||||
|
||||
* web\_search:联网搜索工具。
|
||||
|
||||
|
||||
|
||||
#### **usage** `object`
|
||||
|
||||
本次请求的 token 用量。
|
||||
|
||||
***
|
||||
|
||||
usage.**completion\_tokens** `integer`
|
||||
|
||||
模型输出视频花费的 token 数量。
|
||||
|
||||
***
|
||||
|
||||
usage.**total\_tokens** `integer`
|
||||
|
||||
本次请求消耗的总 token 数量。
|
||||
|
||||
***
|
||||
|
||||
usage.**tool\_usage **`object` 
|
||||
|
||||
> 仅 Seedance 2.0 & 2.0 fast 支持
|
||||
|
||||
使用工具的用量信息。
|
||||
|
||||
***
|
||||
|
||||
usage.tool\_usage.**web\_search **`integer` 
|
||||
|
||||
实际调用联网搜索工具的次数,仅开启联网搜索时返回。
|
||||
|
||||
|
||||
|
||||
# 调用简介及示例
|
||||
|
||||
## 流程简介
|
||||
|
||||
任务接口是异步接口,视频生成任务流程
|
||||
|
||||
1. 创建视频生成任务接口创建视频生成任务
|
||||
|
||||
2. 定时使用查询接口查询视频生成任务状态
|
||||
|
||||
1. 任务 running,过段时间再查询任务状态
|
||||
|
||||
2. 任务完成,返回视频链接,在24小时内下载生成的视频文件
|
||||
|
||||
## 1. 创建视频生成任务
|
||||
|
||||
> 以下示例仅展示 Seedance 2.0 & 2.0 fast 新增能力,更多视频生成示例详见 [创建视频生成任务 API](https://www.volcengine.com/docs/82379/1520757)。
|
||||
|
||||
### 多模态参考
|
||||
|
||||
### 编辑视频
|
||||
|
||||
### 延长视频
|
||||
|
||||
### 使用联网搜索
|
||||
|
||||
仅支持文本生视频
|
||||
|
||||
## 2. 查询视频生成任务
|
||||
|
||||
# 最佳实践-使用公共虚拟人像生成视频
|
||||
|
||||
平台提供公共虚拟人像素材库,目前您可以使用其中的图像素材来创建一个统一、完备的视频主角。帮助您更好地控制主角,并确保其形象在多段视频中保持一致,避免因为真人人脸限制导致角色无法统一的问题。
|
||||
|
||||
素材模态目前包含图片,并提供人物背景描述。每个素材对应一个独立素材 ID (asset ID),在体验中心的视频生成任务中,指定角色人脸生成视频。
|
||||
|
||||
1. 在浏览器中打开[体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo),点击输入框下方的 **虚拟人像库** 页签。
|
||||
|
||||
2. 检索需要使用的人像,支持使用自然语言检索及筛选框组合筛选。
|
||||
|
||||
| 输入:文本 | 输入:虚拟人像、图片 | 输出 |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -- |
|
||||
| **图片1**中美妆博主用中文进行介绍,妆容改为明艳大气,去掉脸部反光,笑容甜美,近景镜头,手持**图片2**的面霜面向镜头展示,清新简约背景,元气甜美风格。博主台词:挖到本命面霜了!质地像云朵一样软糯,一抹就吸收,熬夜急救、补水保湿全搞定,素颜都自带柔光感。 |  | |
|
||||
|
||||
|
||||
|
||||
在 [Video Generation API](https://www.volcengine.com/docs/82379/1520758) 的 **content.<模态>\_url.url** 字段中使用 素材 URI 生成视频。
|
||||
|
||||
> 输入的参考内容,包括人像素材,需符合视频生成限制,具体信息请查看使用限制。
|
||||
>
|
||||
> **注意**:
|
||||
>
|
||||
> * 首次在 API 中使用虚拟人像素材 Asset URI 前,需先在[方舟体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo)提交一次视频生成任务,阅读并同意弹出的 **虚拟人像库使用协议**。
|
||||
>
|
||||
> * 体验中心支持体验视频生成能力。默认单次生成 4 段视频,为节约成本,建议设置为每次生成 1 条,具体方式可参考[虚拟人像库](https://www.volcengine.com/docs/82379/2223965?lang=zh)。
|
||||
|
||||
同意协议的操作方式如下:
|
||||
|
||||

|
||||
|
||||
示例代码:
|
||||
|
||||
# 使用自有虚拟人像素材生成视频(线下提交)
|
||||
|
||||
方舟提供私域人像素材库,您可在视频生成中使用自有虚拟人物或真人(仅限素人)素材,生成短剧等更定制化的视频内容。平台将对您提供的素材进行审核,规避可能产生的法律风险。
|
||||
|
||||
* 自有素材需入库后使用,您可将虚拟人像或真人素材发送给销售代表,同时完成合规承诺函及其他证明材料的准备。
|
||||
|
||||
* 入库后,您可使用素材的 Asset ID,在视频生成 API 中使用自有素材。
|
||||
|
||||
> **重要**:
|
||||
>
|
||||
> * 对虚拟人像素材,您需签署虚拟人像素材合规承诺函,并提供签署承诺函所需的材料。
|
||||
>
|
||||
> * 对真实人物素材,除承诺函外,您还需额外提供真人授权材料。
|
||||
>
|
||||
> * 具体流程及所需材料,请和您的销售代表确认。
|
||||
|
||||
提交自有人像素材时,需按人物将素材分组:
|
||||
|
||||
* 每个人物为一个素材组。
|
||||
|
||||
* 每组可包含多个素材文件,素材文件对应唯一 ID (asset ID)。
|
||||
|
||||
## 入库流程
|
||||
|
||||
提交自有虚拟人像素材方式大致如下,请联系您的销售代表了解详情。
|
||||
|
||||
1. 准备素材文件,完成承诺函签署,并准备其他证明材料。
|
||||
|
||||
2. 准备素材文件,完成承诺函签署,并准备其他证明材料。
|
||||
|
||||
* 每个人物素材需至少提供一张正面图片文件。此外,您可按需提供该人物的其他图片、视频素材。
|
||||
|
||||
* 需确保每个人物组中的素材与该正面图片为同一人物。
|
||||
|
||||
* 每个人物创建一个文件夹(命名:“*虚拟人像 1-<人像名>*”)
|
||||
|
||||
提交素材文件夹示例:
|
||||
|
||||

|
||||
|
||||
> **注意**:
|
||||
>
|
||||
> * 以上示例仅供参考,您可根据视频创作需求,提交虚拟人物素材。
|
||||
>
|
||||
> * 您仅需上传视频生成任务中需要使用的素材。
|
||||
|
||||
* 素材文件需满足视频生成 API 对输入文件的要求:
|
||||
|
||||
> **传入单张图片要求**
|
||||
>
|
||||
> * 格式:jpeg、png、webp、bmp、tiff、gif
|
||||
>
|
||||
> * 宽高比(宽/高): (0.4, 2.5) 
|
||||
>
|
||||
> * 宽高长度(px):(300, 6000)
|
||||
>
|
||||
> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。
|
||||
|
||||
|
||||
|
||||
> **传入单个视频要求**
|
||||
>
|
||||
> * 视频格式:mp4、mov。
|
||||
>
|
||||
> * 分辨率:480p、720p
|
||||
>
|
||||
> * 时长:单个视频时长 \[2, 15] s,最多传入 3 个参考视频,所有视频总时长不超过 15s。
|
||||
>
|
||||
> * 尺寸:
|
||||
>
|
||||
> * 宽高比(宽/高):\[0.4, 2.5]
|
||||
>
|
||||
> * 宽高长度(px):\[300, 6000]
|
||||
>
|
||||
> * 画面像素(宽 × 高):\[409600, 927408] ,示例:
|
||||
>
|
||||
> * 画面尺寸 640×640=409600 满足最小值 ;
|
||||
>
|
||||
> * 画面尺寸 834×1112=927408 满足最大值。
|
||||
>
|
||||
> * 大小:单个视频不超过 50 MB。
|
||||
>
|
||||
> * 帧率 (FPS):\[24, 60] 
|
||||
|
||||
|
||||
|
||||
> **注意**:
|
||||
>
|
||||
> 有关提交流程、承诺函签署所需材料的具体信息,请联系您的销售代表了解详情。
|
||||
|
||||
3. 方舟将对您提供的素材进行审核,通过审核的素材将被上传至虚拟人像库。
|
||||
|
||||
4. 入库后,每个人物组素材将通过以下示例中的形式返回,您可解压后查看:
|
||||
|
||||

|
||||
|
||||
示例中:
|
||||
|
||||
* Andy 为您提交的人物名称
|
||||
|
||||
* group-20260310035119-9mzqn 为该人物组的 ID
|
||||
|
||||
* 解压后,可查看每张素材的 Asset ID,如:
|
||||
|
||||

|
||||
|
||||
* 您可按 `asset: //<asset_id>` 规则拼接 URI,在 API 中使用对应素材生成视频:
|
||||
|
||||
具体调用方式请参考 [最佳实践-使用虚拟人像生成视频](https://bytedance.larkoffice.com/wiki/SANpwJ9bgiKgrykLaMTcAB0InWc#share-YurKdrLfAocLErxsTWDcKidPnGd)。
|
||||
|
||||
## **注意事项**
|
||||
|
||||
1. 首次在 API 中使用虚拟人像素材 Asset URI 前,需先在[方舟体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo)提交一次视频生成任务,阅读并同意弹出的 **虚拟人像库使用协议**,操作方式如下:
|
||||
|
||||

|
||||
|
||||
* 仅支持使用已入库素材生成视频。
|
||||
@ -10,17 +10,3 @@ class UserInfoSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
username = serializers.CharField()
|
||||
is_superuser = serializers.BooleanField()
|
||||
is_active = serializers.BooleanField()
|
||||
date_joined = serializers.DateTimeField()
|
||||
last_login = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
old_password = serializers.CharField(write_only=True)
|
||||
new_password = serializers.CharField(write_only=True, min_length=6)
|
||||
|
||||
|
||||
class AdminUserCreateSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=150)
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
is_superuser = serializers.BooleanField(default=False)
|
||||
|
||||
@ -5,9 +5,4 @@ urlpatterns = [
|
||||
path('login/', views.login_view),
|
||||
path('refresh/', views.refresh_view),
|
||||
path('me/', views.me_view),
|
||||
path('change-password/', views.change_password_view),
|
||||
path('admins/', views.admin_list_view),
|
||||
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),
|
||||
]
|
||||
|
||||
@ -5,11 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from .models import AdminUser
|
||||
from .serializers import (
|
||||
LoginSerializer, UserInfoSerializer,
|
||||
ChangePasswordSerializer, AdminUserCreateSerializer,
|
||||
)
|
||||
from .serializers import LoginSerializer, UserInfoSerializer
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@ -62,136 +58,3 @@ def refresh_view(request):
|
||||
@api_view(['GET'])
|
||||
def me_view(request):
|
||||
return Response(UserInfoSerializer(request.user).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def change_password_view(request):
|
||||
"""修改当前用户密码"""
|
||||
serializer = ChangePasswordSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if not request.user.check_password(serializer.validated_data['old_password']):
|
||||
return Response({'error': 'wrong_password', 'message': '原密码错误'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
request.user.set_password(serializer.validated_data['new_password'])
|
||||
request.user.save()
|
||||
|
||||
# Log operation
|
||||
from apps.monitor.models import AlertRecord
|
||||
AlertRecord.objects.create(
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"管理员 {request.user.username} 修改密码",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
|
||||
return Response({'message': '密码修改成功,请重新登录'})
|
||||
|
||||
|
||||
# ==================== Admin User Management ====================
|
||||
|
||||
@api_view(['GET'])
|
||||
def admin_list_view(request):
|
||||
"""列出所有管理员"""
|
||||
if not request.user.is_superuser:
|
||||
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
users = AdminUser.objects.all().order_by('id')
|
||||
return Response(UserInfoSerializer(users, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def admin_create_view(request):
|
||||
"""创建管理员账号"""
|
||||
if not request.user.is_superuser:
|
||||
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
serializer = AdminUserCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
d = serializer.validated_data
|
||||
|
||||
if AdminUser.objects.filter(username=d['username']).exists():
|
||||
return Response({'error': 'user_exists', 'message': f'用户名 {d["username"]} 已存在'},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
|
||||
user = AdminUser.objects.create_user(
|
||||
username=d['username'],
|
||||
password=d['password'],
|
||||
is_superuser=d.get('is_superuser', False),
|
||||
is_staff=True,
|
||||
)
|
||||
|
||||
from apps.monitor.models import AlertRecord
|
||||
AlertRecord.objects.create(
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"创建管理员 {d['username']}",
|
||||
content=f"操作人: {request.user.username},超级管理员: {'是' if d.get('is_superuser') else '否'}",
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': f'管理员 {d["username"]} 创建成功',
|
||||
'user': UserInfoSerializer(user).data,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def admin_toggle_view(request, pk):
|
||||
"""启用/停用管理员"""
|
||||
if not request.user.is_superuser:
|
||||
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
user = AdminUser.objects.get(pk=pk)
|
||||
except AdminUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if user.pk == request.user.pk:
|
||||
return Response({'error': 'self_toggle', 'message': '不能停用自己'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.is_active = not user.is_active
|
||||
user.save(update_fields=['is_active'])
|
||||
|
||||
action = '启用' if user.is_active else '停用'
|
||||
from apps.monitor.models import AlertRecord
|
||||
AlertRecord.objects.create(
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"{action}管理员 {user.username}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
|
||||
return Response({'message': f'已{action}管理员 {user.username}',
|
||||
'user': UserInfoSerializer(user).data})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def admin_reset_password_view(request, pk):
|
||||
"""超管重置其他管理员密码"""
|
||||
if not request.user.is_superuser:
|
||||
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
user = AdminUser.objects.get(pk=pk)
|
||||
except AdminUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
new_password = request.data.get('new_password', '')
|
||||
if len(new_password) < 6:
|
||||
return Response({'error': 'weak_password', 'message': '密码至少6位'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
from apps.monitor.models import AlertRecord
|
||||
AlertRecord.objects.create(
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"重置管理员 {user.username} 密码",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
|
||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
|
||||
|
||||
@admin.register(VolcAccount)
|
||||
@ -7,23 +7,11 @@ class VolcAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'access_key_hint', 'is_active', 'updated_at')
|
||||
|
||||
|
||||
class IAMUserProjectInline(admin.TabularInline):
|
||||
model = IAMUserProject
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(IAMUser)
|
||||
class IAMUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
||||
'allocated_quota', 'consumed_total')
|
||||
list_filter = ('status', 'monitor_enabled')
|
||||
inlines = [IAMUserProjectInline]
|
||||
|
||||
|
||||
@admin.register(IAMUserProject)
|
||||
class IAMUserProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ('iam_user', 'project_name', 'monitor_enabled', 'current_spending')
|
||||
list_filter = ('monitor_enabled',)
|
||||
|
||||
|
||||
@admin.register(QuotaAllocation)
|
||||
@ -44,4 +32,4 @@ class AlertRecordAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SpendingRecord)
|
||||
class SpendingRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('iam_user', 'project_name', 'bill_period', 'amount', 'updated_at')
|
||||
list_display = ('iam_user', 'bill_period', 'amount', 'updated_at')
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-19 12:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0003_remove_globalconfig_default_monthly_budget_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='spendingrecord',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='iamuser',
|
||||
name='project_name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='spendingrecord',
|
||||
name='project_name',
|
||||
field=models.CharField(blank=True, help_text='空=子账号总消费', max_length=200, verbose_name='项目名'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='quotaallocation',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=12, verbose_name='变更金额(元,正=追加,负=扣减)'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='spendingrecord',
|
||||
unique_together={('iam_user', 'project_name', 'bill_period')},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='spendingrecord',
|
||||
name='detail',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IAMUserProject',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('project_name', models.CharField(max_length=200, verbose_name='火山项目名')),
|
||||
('display_name', models.CharField(blank=True, max_length=200, verbose_name='显示名')),
|
||||
('monitor_enabled', models.BooleanField(default=True, verbose_name='启用监测')),
|
||||
('current_spending', models.DecimalField(decimal_places=2, default=0, help_text='此项目的累计消费,由定时任务更新', max_digits=12, verbose_name='当前消费(元)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='monitor.iamuser')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '子账号关联项目',
|
||||
'verbose_name_plural': '子账号关联项目',
|
||||
'db_table': 'airgate_iam_user_project',
|
||||
'ordering': ['project_name'],
|
||||
'unique_together': {('iam_user', 'project_name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-19 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0004_alter_spendingrecord_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='globalconfig',
|
||||
name='default_project_policies',
|
||||
field=models.JSONField(blank=True, default=list, help_text='如 ["ArkFullAccess", "TOSFullAccess"]', verbose_name='添加项目时自动授权的策略'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='iamuserproject',
|
||||
name='attached_policies',
|
||||
field=models.JSONField(blank=True, default=list, help_text='添加项目时自动附加的策略名列表,移除时自动回收', verbose_name='已授权的策略列表'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-20 06:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0005_globalconfig_default_project_policies_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='saved_policies_on_disable',
|
||||
field=models.JSONField(blank=True, default=list, help_text='停用时自动移除的策略列表,恢复时加回', verbose_name='停用时保存的策略'),
|
||||
),
|
||||
]
|
||||
@ -35,6 +35,8 @@ class IAMUser(models.Model):
|
||||
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
|
||||
email = models.EmailField('邮箱', blank=True)
|
||||
phone = models.CharField('手机号', max_length=20, blank=True)
|
||||
project_name = models.CharField('关联项目名', max_length=200, blank=True,
|
||||
help_text='用于按项目维度追踪消费')
|
||||
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
|
||||
|
||||
# Access keys (stored as JSON list of AK IDs, not secrets)
|
||||
@ -56,10 +58,6 @@ class IAMUser(models.Model):
|
||||
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
|
||||
help_text='记录已通知过的百分比,划拨新额度时自动重置')
|
||||
|
||||
# --- 停用时保存的策略快照(恢复时自动加回) ---
|
||||
saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True,
|
||||
help_text='停用时自动移除的策略列表,恢复时加回')
|
||||
|
||||
remark = models.TextField('备注', blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@ -92,30 +90,6 @@ class IAMUser(models.Model):
|
||||
return sorted(config.default_alert_thresholds or [50, 80, 90])
|
||||
|
||||
|
||||
class IAMUserProject(models.Model):
|
||||
"""子账号关联的火山项目(多对多)"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='projects')
|
||||
project_name = models.CharField('火山项目名', max_length=200)
|
||||
display_name = models.CharField('显示名', max_length=200, blank=True)
|
||||
monitor_enabled = models.BooleanField('启用监测', default=True)
|
||||
current_spending = models.DecimalField('当前消费(元)', max_digits=12, decimal_places=2, default=0,
|
||||
help_text='此项目的累计消费,由定时任务更新')
|
||||
attached_policies = models.JSONField('已授权的策略列表', default=list, blank=True,
|
||||
help_text='添加项目时自动附加的策略名列表,移除时自动回收')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '子账号关联项目'
|
||||
verbose_name_plural = '子账号关联项目'
|
||||
db_table = 'airgate_iam_user_project'
|
||||
unique_together = [('iam_user', 'project_name')]
|
||||
ordering = ['project_name']
|
||||
|
||||
def __str__(self):
|
||||
status = '监测中' if self.monitor_enabled else '未监测'
|
||||
return f"{self.project_name} ({status}) ¥{self.current_spending}"
|
||||
|
||||
|
||||
class QuotaAllocation(models.Model):
|
||||
"""额度划拨记录"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
|
||||
@ -139,8 +113,6 @@ class GlobalConfig(models.Model):
|
||||
"""全局配置(单例)"""
|
||||
default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
|
||||
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)
|
||||
@ -189,20 +161,18 @@ class AlertRecord(models.Model):
|
||||
|
||||
|
||||
class SpendingRecord(models.Model):
|
||||
"""月度消费快照(按项目粒度)"""
|
||||
"""月度消费快照"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
|
||||
project_name = models.CharField('项目名', max_length=200, blank=True,
|
||||
help_text='空=子账号总消费')
|
||||
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
|
||||
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
|
||||
detail = models.JSONField('消费明细', default=dict, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '消费记录'
|
||||
verbose_name_plural = '消费记录'
|
||||
db_table = 'airgate_spending_record'
|
||||
unique_together = [('iam_user', 'project_name', 'bill_period')]
|
||||
unique_together = [('iam_user', 'bill_period')]
|
||||
|
||||
def __str__(self):
|
||||
proj = self.project_name or '总计'
|
||||
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"
|
||||
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
|
||||
|
||||
class VolcAccountSerializer(serializers.ModelSerializer):
|
||||
@ -15,32 +15,21 @@ class VolcAccountCreateSerializer(serializers.Serializer):
|
||||
secret_key = serializers.CharField(write_only=True)
|
||||
|
||||
|
||||
class IAMUserProjectSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = IAMUserProject
|
||||
fields = ['id', 'project_name', 'display_name', 'monitor_enabled',
|
||||
'current_spending', 'attached_policies', 'created_at']
|
||||
read_only_fields = ['current_spending', 'attached_policies', 'created_at']
|
||||
|
||||
|
||||
class IAMUserSerializer(serializers.ModelSerializer):
|
||||
remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||
usage_percent = serializers.FloatField(read_only=True)
|
||||
effective_alert_thresholds = serializers.SerializerMethodField()
|
||||
projects = IAMUserProjectSerializer(many=True, read_only=True)
|
||||
monitored_project_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = IAMUser
|
||||
fields = [
|
||||
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
||||
'status', 'access_key_ids',
|
||||
'project_name', 'status', 'access_key_ids',
|
||||
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
|
||||
'spending_updated_at',
|
||||
'monitor_enabled', 'auto_disable_enabled',
|
||||
'alert_thresholds', 'triggered_alerts',
|
||||
'effective_alert_thresholds',
|
||||
'projects', 'monitored_project_count',
|
||||
'remark', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||
@ -51,18 +40,14 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
||||
def get_effective_alert_thresholds(self, obj):
|
||||
return obj.get_alert_thresholds()
|
||||
|
||||
def get_monitored_project_count(self, obj):
|
||||
return obj.projects.filter(monitor_enabled=True).count()
|
||||
|
||||
|
||||
class IAMUserCreateSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=200)
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
email = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
phone = serializers.CharField(max_length=20, required=False, default='', allow_blank=True)
|
||||
password = serializers.CharField(write_only=True, required=False, default='', allow_blank=True)
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True,
|
||||
help_text='可选,创建后自动关联此项目')
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
email = serializers.EmailField(required=False, default='')
|
||||
phone = serializers.CharField(max_length=20, required=False, default='')
|
||||
password = serializers.CharField(write_only=True, required=False, default='')
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
|
||||
|
||||
class IAMUserImportSerializer(serializers.Serializer):
|
||||
@ -70,6 +55,8 @@ class IAMUserImportSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class IAMUserConfigSerializer(serializers.Serializer):
|
||||
"""子账号配置更新"""
|
||||
project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||
alert_thresholds = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1, max_value=99),
|
||||
required=False,
|
||||
@ -78,21 +65,6 @@ class IAMUserConfigSerializer(serializers.Serializer):
|
||||
auto_disable_enabled = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class IAMUserProjectAddSerializer(serializers.Serializer):
|
||||
project_name = serializers.CharField(max_length=200)
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
monitor_enabled = serializers.BooleanField(required=False, default=True)
|
||||
policies = serializers.ListField(
|
||||
child=serializers.CharField(max_length=200),
|
||||
required=False, default=list,
|
||||
help_text='要在项目范围内授权的策略名列表,如 ["ArkFullAccess"]'
|
||||
)
|
||||
|
||||
|
||||
class IAMUserProjectUpdateSerializer(serializers.Serializer):
|
||||
monitor_enabled = serializers.BooleanField()
|
||||
|
||||
|
||||
class QuotaAllocateSerializer(serializers.Serializer):
|
||||
"""额度变更:正数=追加,负数=扣减"""
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
@ -116,7 +88,6 @@ class GlobalConfigSerializer(serializers.ModelSerializer):
|
||||
model = GlobalConfig
|
||||
fields = [
|
||||
'default_alert_thresholds',
|
||||
'default_project_policies',
|
||||
'monitor_interval_seconds',
|
||||
'feishu_webhook_url', 'feishu_alert_mobiles',
|
||||
'updated_at',
|
||||
@ -135,6 +106,14 @@ class AlertRecordSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SpendingRecordSerializer(serializers.ModelSerializer):
|
||||
iam_username = serializers.CharField(source='iam_user.username')
|
||||
|
||||
class Meta:
|
||||
model = SpendingRecord
|
||||
fields = ['id', 'iam_user', 'iam_username', 'bill_period', 'amount', 'updated_at']
|
||||
|
||||
|
||||
class DashboardSerializer(serializers.Serializer):
|
||||
total_users = serializers.IntegerField()
|
||||
active_users = serializers.IntegerField()
|
||||
|
||||
@ -22,14 +22,6 @@ urlpatterns = [
|
||||
path('iam-users/<int:pk>/policies/', views.iam_user_policies_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>/delete/', views.iam_user_project_delete_view),
|
||||
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),
|
||||
|
||||
# Quota
|
||||
path('iam-users/<int:pk>/allocate/', views.quota_allocate_view),
|
||||
path('iam-users/<int:pk>/quota-history/', views.quota_history_view),
|
||||
|
||||
|
||||
@ -14,13 +14,11 @@ from utils.iam_service import IAMService, ProjectService
|
||||
from utils.billing_service import BillingService
|
||||
from utils.volcengine_client import VolcengineAPIError
|
||||
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .serializers import (
|
||||
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||
IAMUserConfigSerializer,
|
||||
IAMUserProjectSerializer, IAMUserProjectAddSerializer, IAMUserProjectUpdateSerializer,
|
||||
QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
GlobalConfigSerializer,
|
||||
AlertRecordSerializer,
|
||||
DashboardSerializer,
|
||||
@ -234,10 +232,6 @@ def iam_user_create_view(request):
|
||||
phone=d.get('phone', ''),
|
||||
)
|
||||
except VolcengineAPIError as e:
|
||||
if 'UserAlreadyExists' in e.code or 'EntityAlreadyExists' in e.code:
|
||||
return Response({'error': 'user_exists',
|
||||
'message': f"火山引擎上已存在用户 {d['username']},请使用「同步已有用户」导入"},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
return Response({'error': 'create_failed', 'message': f'创建用户失败: {e}'},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
@ -278,19 +272,11 @@ def iam_user_create_view(request):
|
||||
user_id=volc_user.get("UserId", ""),
|
||||
email=d.get('email', ''),
|
||||
phone=d.get('phone', ''),
|
||||
project_name=d.get('project_name', ''),
|
||||
status=IAMUser.Status.ACTIVE,
|
||||
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [],
|
||||
)
|
||||
|
||||
# 6. Auto-add project if specified
|
||||
project_name = d.get('project_name', '')
|
||||
if project_name:
|
||||
IAMUserProject.objects.create(
|
||||
iam_user=obj,
|
||||
project_name=project_name,
|
||||
monitor_enabled=True,
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
@ -381,43 +367,16 @@ def iam_user_disable_view(request, pk):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
# 1. 停用控制台 + API 密钥
|
||||
svc.disable_user(user.username)
|
||||
|
||||
# 2. 移除所有权限策略并保存快照(恢复时加回)
|
||||
saved_policies = []
|
||||
detach_errors = []
|
||||
try:
|
||||
resp = svc.list_attached_user_policies(user.username)
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
for p in policies:
|
||||
pname = p.get("PolicyName", "")
|
||||
ptype = p.get("PolicyType", "")
|
||||
try:
|
||||
svc.detach_user_policy(user.username, pname, ptype)
|
||||
saved_policies.append({"name": pname, "type": ptype})
|
||||
except VolcengineAPIError as detach_err:
|
||||
detach_errors.append(f"{pname}: {detach_err}")
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
|
||||
user.status = IAMUser.Status.DISABLED
|
||||
user.saved_policies_on_disable = saved_policies
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
|
||||
policy_count = len(saved_policies)
|
||||
error_info = f",移除失败: {detach_errors}" if detach_errors else ""
|
||||
user.save(update_fields=['status'])
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"手动停用子账号 {user.username}",
|
||||
content=f"操作人: {request.user.username},已移除 {policy_count} 个权限策略{error_info}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
msg = f'用户 {user.username} 已停用,{policy_count} 个权限策略已移除'
|
||||
result = {'message': msg}
|
||||
if detach_errors:
|
||||
result['warnings'] = detach_errors
|
||||
return Response(result)
|
||||
return Response({'message': f'用户 {user.username} 已停用'})
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
@ -437,36 +396,16 @@ def iam_user_enable_view(request, pk):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
# 1. 恢复控制台 + API 密钥
|
||||
svc.enable_user(user.username)
|
||||
|
||||
# 2. 重新附加停用时保存的策略
|
||||
restored_count = 0
|
||||
restore_errors = []
|
||||
saved_policies = user.saved_policies_on_disable or []
|
||||
for p in saved_policies:
|
||||
try:
|
||||
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}")
|
||||
|
||||
user.status = IAMUser.Status.ACTIVE
|
||||
user.saved_policies_on_disable = []
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
|
||||
error_info = f",恢复失败: {restore_errors}" if restore_errors else ""
|
||||
user.save(update_fields=['status'])
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"手动恢复子账号 {user.username}",
|
||||
content=f"操作人: {request.user.username},已恢复 {restored_count} 个权限策略{error_info}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
)
|
||||
msg = f'用户 {user.username} 已恢复,{restored_count} 个权限策略已恢复'
|
||||
result = {'message': msg}
|
||||
if restore_errors:
|
||||
result['warnings'] = restore_errors
|
||||
return Response(result)
|
||||
return Response({'message': f'用户 {user.username} 已恢复'})
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
@ -560,148 +499,6 @@ def iam_user_detach_policy_view(request, pk):
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
# ==================== IAM User Projects ====================
|
||||
|
||||
@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()
|
||||
return Response(IAMUserProjectSerializer(projects, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_project_add_view(request, pk):
|
||||
"""给子账号添加关联项目:加入监测 + 自动在项目范围内授权"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = IAMUserProjectAddSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
d = serializer.validated_data
|
||||
|
||||
obj, created = IAMUserProject.objects.get_or_create(
|
||||
iam_user=user,
|
||||
project_name=d['project_name'],
|
||||
defaults={
|
||||
'display_name': d.get('display_name', ''),
|
||||
'monitor_enabled': d.get('monitor_enabled', True),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
return Response({'error': 'duplicate', 'message': f'项目 {d["project_name"]} 已关联'},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
|
||||
# 在项目范围内授权前端指定的策略(如果传入了)
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
attached = []
|
||||
auth_errors = []
|
||||
policies_to_attach = d.get('policies', [])
|
||||
if ak and policies_to_attach:
|
||||
svc = IAMService(ak, sk)
|
||||
for policy_name in policies_to_attach:
|
||||
try:
|
||||
svc.attach_policy_in_project(user.username, policy_name,
|
||||
d['project_name'])
|
||||
attached.append(policy_name)
|
||||
except VolcengineAPIError as e:
|
||||
auth_errors.append(f"{policy_name}: {e}")
|
||||
|
||||
obj.attached_policies = attached
|
||||
obj.save(update_fields=['attached_policies'])
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"添加项目 {d['project_name']} → {user.username}",
|
||||
content=f"操作人: {request.user.username},已授权策略: {attached}"
|
||||
+ (f",授权失败: {auth_errors}" if auth_errors else ""),
|
||||
)
|
||||
|
||||
result = {
|
||||
'message': f'已关联项目 {d["project_name"]}',
|
||||
'project': IAMUserProjectSerializer(obj).data,
|
||||
'attached_policies': attached,
|
||||
}
|
||||
if auth_errors:
|
||||
result['auth_errors'] = auth_errors
|
||||
return Response(result, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['PUT'])
|
||||
def iam_user_project_update_view(request, pk, pid):
|
||||
"""更新项目监测开关"""
|
||||
try:
|
||||
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
|
||||
except IAMUserProject.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = IAMUserProjectUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
project.monitor_enabled = serializer.validated_data['monitor_enabled']
|
||||
project.save(update_fields=['monitor_enabled'])
|
||||
return Response(IAMUserProjectSerializer(project).data)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def iam_user_project_delete_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)
|
||||
|
||||
name = project.project_name
|
||||
detached = []
|
||||
detach_errors = []
|
||||
|
||||
# 回收之前自动授权的策略
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
if ak and project.attached_policies:
|
||||
svc = IAMService(ak, sk)
|
||||
for policy_name in project.attached_policies:
|
||||
try:
|
||||
svc.detach_policy_in_project(user.username, policy_name, name)
|
||||
detached.append(policy_name)
|
||||
except VolcengineAPIError as e:
|
||||
detach_errors.append(f"{policy_name}: {e}")
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"移除项目 {name} ← {user.username}",
|
||||
content=f"操作人: {request.user.username},已回收策略: {detached}"
|
||||
+ (f",回收失败: {detach_errors}" if detach_errors else ""),
|
||||
)
|
||||
|
||||
project.delete()
|
||||
|
||||
result = {'message': f'已移除项目 {name},已回收权限: {detached}'}
|
||||
if detach_errors:
|
||||
result['detach_errors'] = detach_errors
|
||||
return Response(result)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_project_toggle_all_view(request, pk):
|
||||
"""批量切换所有项目的监测开关"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
enable = request.data.get('monitor_enabled', True)
|
||||
count = user.projects.update(monitor_enabled=enable)
|
||||
label = '开启' if enable else '关闭'
|
||||
return Response({'message': f'已{label}全部 {count} 个项目的监测'})
|
||||
|
||||
|
||||
# ==================== Quota Allocation ====================
|
||||
|
||||
@api_view(['POST'])
|
||||
|
||||
@ -79,26 +79,6 @@ class IAMService:
|
||||
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:
|
||||
"""在项目范围内授权"""
|
||||
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,
|
||||
})
|
||||
|
||||
def disable_user(self, username: str):
|
||||
"""完全停用用户:停控制台 + 停所有 AccessKey"""
|
||||
errors = []
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""定时消费监控任务 -- 多项目聚合 + 额度划拨制 + 阶梯式告警"""
|
||||
"""定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
@ -10,8 +10,8 @@ _scheduler_started = False
|
||||
|
||||
|
||||
def check_spending():
|
||||
"""定时检查所有子账号消费:遍历开启监测的项目,聚合消费,触发阶梯告警"""
|
||||
from apps.monitor.models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord
|
||||
"""定时检查所有子账号消费,对比已划拨额度触发阶梯告警"""
|
||||
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
||||
from utils.crypto import decrypt
|
||||
from utils.billing_service import BillingService
|
||||
from utils.iam_service import IAMService
|
||||
@ -19,7 +19,6 @@ def check_spending():
|
||||
|
||||
config = GlobalConfig.get_solo()
|
||||
webhook = config.feishu_webhook_url
|
||||
bill_period = timezone.now().strftime("%Y-%m")
|
||||
|
||||
for volc_account in VolcAccount.objects.filter(is_active=True):
|
||||
ak = decrypt(volc_account.access_key_enc)
|
||||
@ -38,50 +37,25 @@ def check_spending():
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
|
||||
enabled_projects = IAMUserProject.objects.filter(
|
||||
iam_user=user, monitor_enabled=True
|
||||
# 查询当月消费(按项目筛选)
|
||||
bill_period = timezone.now().strftime("%Y-%m")
|
||||
spending = billing.get_spending_by_project(
|
||||
bill_period, user.project_name or None
|
||||
)
|
||||
|
||||
if not enabled_projects.exists():
|
||||
logger.info(f"用户 {user.username} 无开启监测的项目,跳过")
|
||||
continue
|
||||
|
||||
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 # 保留上次值
|
||||
|
||||
# 更新项目级消费
|
||||
project.current_spending = proj_spending
|
||||
project.save(update_fields=['current_spending'])
|
||||
|
||||
# 记录项目级月度快照
|
||||
# 记录月度快照
|
||||
SpendingRecord.objects.update_or_create(
|
||||
iam_user=user,
|
||||
project_name=project.project_name,
|
||||
bill_period=bill_period,
|
||||
defaults={'amount': proj_spending},
|
||||
iam_user=user, bill_period=bill_period,
|
||||
defaults={'amount': spending},
|
||||
)
|
||||
|
||||
total_spending += proj_spending
|
||||
|
||||
# 更新子账号总消费
|
||||
# 累计消费 = 所有月份的所有开启监测项目的消费之和
|
||||
all_enabled_names = list(enabled_projects.values_list('project_name', flat=True))
|
||||
# 累计消费 = 所有月份的消费之和
|
||||
from django.db.models import Sum
|
||||
cumulative = SpendingRecord.objects.filter(
|
||||
iam_user=user,
|
||||
project_name__in=all_enabled_names,
|
||||
total = SpendingRecord.objects.filter(
|
||||
iam_user=user
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
user.consumed_total = cumulative
|
||||
user.consumed_total = total
|
||||
user.spending_updated_at = timezone.now()
|
||||
|
||||
quota = user.allocated_quota
|
||||
@ -89,7 +63,7 @@ def check_spending():
|
||||
user.save(update_fields=['consumed_total', 'spending_updated_at'])
|
||||
continue
|
||||
|
||||
usage_percent = float(cumulative) / float(quota) * 100
|
||||
usage_percent = float(total) / float(quota) * 100
|
||||
triggered = user.triggered_alerts or []
|
||||
|
||||
# --- 阶梯式告警 ---
|
||||
@ -98,23 +72,16 @@ def check_spending():
|
||||
triggered.append(step)
|
||||
threshold_amount = Decimal(str(quota)) * step / 100
|
||||
|
||||
# 构建项目明细
|
||||
detail_lines = "\n".join(
|
||||
f" {p.project_name}: ¥{p.current_spending}"
|
||||
for p in enabled_projects
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.WARNING,
|
||||
title=f"{user.username} 消费达到额度 {step}%",
|
||||
content=(
|
||||
f"累计消费 ¥{cumulative:.2f},"
|
||||
f"累计消费 ¥{total:.2f},"
|
||||
f"已划拨额度 ¥{quota:.2f} 的 {step}%\n"
|
||||
f"剩余额度: ¥{user.remaining_quota:.2f}\n"
|
||||
f"项目明细:\n{detail_lines}"
|
||||
f"剩余额度: ¥{user.remaining_quota:.2f}"
|
||||
),
|
||||
spending_amount=cumulative,
|
||||
spending_amount=total,
|
||||
threshold_amount=threshold_amount,
|
||||
notified=True,
|
||||
)
|
||||
@ -122,12 +89,10 @@ def check_spending():
|
||||
webhook,
|
||||
f"⚠️ {user.username} 消费达到额度 {step}%",
|
||||
f"**用户**: {user.username}\n"
|
||||
f"**累计消费**: ¥{cumulative:.2f}\n"
|
||||
f"**累计消费**: ¥{total:.2f}\n"
|
||||
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
|
||||
f"**使用率**: {usage_percent:.1f}%\n"
|
||||
f"**监测项目数**: {enabled_projects.count()}\n"
|
||||
f"**项目明细**:\n{detail_lines}",
|
||||
f"**使用率**: {usage_percent:.1f}%",
|
||||
template="orange" if step < 90 else "red",
|
||||
)
|
||||
|
||||
@ -143,21 +108,15 @@ def check_spending():
|
||||
except Exception as e:
|
||||
logger.error(f"停用用户 {user.username} 失败: {e}")
|
||||
|
||||
detail_lines = "\n".join(
|
||||
f" {p.project_name}: ¥{p.current_spending}"
|
||||
for p in enabled_projects
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.DISABLE,
|
||||
title=f"{user.username} 额度用尽,已自动停用",
|
||||
content=(
|
||||
f"累计消费 ¥{cumulative:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
|
||||
f"项目明细:\n{detail_lines}\n"
|
||||
f"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
|
||||
f"如需继续使用,请划拨新额度后恢复账号。"
|
||||
),
|
||||
spending_amount=cumulative,
|
||||
spending_amount=total,
|
||||
threshold_amount=quota,
|
||||
notified=True,
|
||||
)
|
||||
@ -165,9 +124,8 @@ def check_spending():
|
||||
webhook,
|
||||
f"🚨 {user.username} 额度用尽,已自动停用",
|
||||
f"**用户**: {user.username}\n"
|
||||
f"**累计消费**: ¥{cumulative:.2f}\n"
|
||||
f"**累计消费**: ¥{total:.2f}\n"
|
||||
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||
f"**项目明细**:\n{detail_lines}\n"
|
||||
f"额度已用尽,账号已自动停用。\n"
|
||||
f"请在 AirGate 划拨新额度后恢复。",
|
||||
template="red",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -24,10 +24,6 @@
|
||||
<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>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
|
||||
@ -17,7 +17,6 @@ const routes = [
|
||||
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
|
||||
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
|
||||
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
<template>
|
||||
<div style="max-width: 1400px; margin: 0 auto;">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- Tab 1: Change Password -->
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<el-card style="max-width: 500px;">
|
||||
<template #header>修改密码</template>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.new_password" type="password" show-password
|
||||
placeholder="至少6位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="pwdForm.confirm_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleChangePassword" :loading="pwdLoading">
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: Admin Management -->
|
||||
<el-tab-pane label="管理员管理" name="admins" v-if="auth.user?.is_superuser">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<el-button type="primary" @click="showCreateAdmin = true">创建管理员</el-button>
|
||||
</div>
|
||||
<el-table :data="admins" stripe v-loading="adminsLoading" style="width: 100%;">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="150" />
|
||||
<el-table-column label="角色" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_superuser ? 'danger' : 'info'" size="small">
|
||||
{{ row.is_superuser ? '超级管理员' : '普通管理员' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_login ? new Date(row.last_login).toLocaleString('zh-CN') : '从未登录' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.id !== auth.user?.id">
|
||||
<el-button size="small" text @click="handleToggleAdmin(row)">
|
||||
{{ row.is_active ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" text type="warning" @click="openResetPwd(row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #999; font-size: 12px;">当前账号</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: Operation Log -->
|
||||
<el-tab-pane label="操作日志" name="logs">
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<el-select v-model="logFilter" placeholder="全部类型" clearable style="width: 160px;">
|
||||
<el-option label="手动操作" value="manual" />
|
||||
<el-option label="告警" value="warning" />
|
||||
<el-option label="自动停用" value="disable" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
<el-input-number v-model="logLimit" :min="10" :max="500" :step="50"
|
||||
style="width: 140px;" />
|
||||
<el-button @click="loadLogs" :loading="logsLoading">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="logs" stripe v-loading="logsLoading" style="width: 100%;"
|
||||
empty-text="暂无日志记录">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="logTagType(row.alert_type)" size="small">
|
||||
{{ logTypeLabel(row.alert_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联用户" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.iam_user_username || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="250" />
|
||||
<el-table-column prop="content" label="详情" min-width="300" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Create Admin Dialog -->
|
||||
<el-dialog v-model="showCreateAdmin" title="创建管理员" width="90%" style="max-width: 450px;">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="createAdminForm.username" placeholder="英文字母开头" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="createAdminForm.password" type="password" show-password
|
||||
placeholder="至少6位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="超级管理员">
|
||||
<el-switch v-model="createAdminForm.is_superuser" />
|
||||
<span style="margin-left: 8px; font-size: 12px; color: #999;">
|
||||
超级管理员可以管理其他管理员账号
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateAdmin = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateAdmin" :loading="createAdminLoading">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Reset Password Dialog -->
|
||||
<el-dialog v-model="showResetPwd" title="重置密码" width="90%" style="max-width: 400px;">
|
||||
<p style="margin-bottom: 12px; color: #606266;">
|
||||
重置 <strong>{{ resetPwdUser?.username }}</strong> 的密码
|
||||
</p>
|
||||
<el-input v-model="resetPwdValue" type="password" show-password
|
||||
placeholder="输入新密码(至少6位)" />
|
||||
<template #footer>
|
||||
<el-button @click="showResetPwd = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleResetPwd" :loading="resetPwdLoading">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('password')
|
||||
|
||||
// === Change Password ===
|
||||
const pwdForm = ref({ old_password: '', new_password: '', confirm_password: '' })
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
if (pwdForm.value.new_password !== pwdForm.value.confirm_password) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
if (pwdForm.value.new_password.length < 6) {
|
||||
ElMessage.warning('密码至少6位')
|
||||
return
|
||||
}
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/auth/change-password/', {
|
||||
old_password: pwdForm.value.old_password,
|
||||
new_password: pwdForm.value.new_password,
|
||||
})
|
||||
ElMessage.success(data.message)
|
||||
pwdForm.value = { old_password: '', new_password: '', confirm_password: '' }
|
||||
// Force re-login
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '修改失败')
|
||||
} finally {
|
||||
pwdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === Admin Management ===
|
||||
const admins = ref([])
|
||||
const adminsLoading = ref(false)
|
||||
const showCreateAdmin = ref(false)
|
||||
const createAdminForm = ref({ username: '', password: '', is_superuser: false })
|
||||
const createAdminLoading = ref(false)
|
||||
const showResetPwd = ref(false)
|
||||
const resetPwdUser = ref(null)
|
||||
const resetPwdValue = ref('')
|
||||
const resetPwdLoading = ref(false)
|
||||
|
||||
async function loadAdmins() {
|
||||
if (!auth.user?.is_superuser) return
|
||||
adminsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/auth/admins/')
|
||||
admins.value = data
|
||||
} catch (e) {
|
||||
admins.value = []
|
||||
} finally {
|
||||
adminsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAdmin() {
|
||||
if (!createAdminForm.value.username || !createAdminForm.value.password) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
createAdminLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/auth/admins/create/', createAdminForm.value)
|
||||
ElMessage.success(data.message)
|
||||
showCreateAdmin.value = false
|
||||
createAdminForm.value = { username: '', password: '', is_superuser: false }
|
||||
await loadAdmins()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
createAdminLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAdmin(row) {
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/auth/admins/${row.id}/toggle/`)
|
||||
ElMessage.success(data.message)
|
||||
await loadAdmins()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openResetPwd(row) {
|
||||
resetPwdUser.value = row
|
||||
resetPwdValue.value = ''
|
||||
showResetPwd.value = true
|
||||
}
|
||||
|
||||
async function handleResetPwd() {
|
||||
if (resetPwdValue.value.length < 6) {
|
||||
ElMessage.warning('密码至少6位')
|
||||
return
|
||||
}
|
||||
resetPwdLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/auth/admins/${resetPwdUser.value.id}/reset-password/`, {
|
||||
new_password: resetPwdValue.value,
|
||||
})
|
||||
ElMessage.success(data.message)
|
||||
showResetPwd.value = false
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '重置失败')
|
||||
} finally {
|
||||
resetPwdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === Operation Logs ===
|
||||
const logs = ref([])
|
||||
const logsLoading = ref(false)
|
||||
const logFilter = ref('')
|
||||
const logLimit = ref(100)
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const params = { limit: logLimit.value }
|
||||
if (logFilter.value) params.type = logFilter.value
|
||||
const { data } = await api.get('/api/v1/alerts/', { params })
|
||||
logs.value = data
|
||||
} catch (e) {
|
||||
logs.value = []
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logTagType(type) {
|
||||
const map = { warning: 'warning', disable: 'danger', error: 'danger', manual: 'info' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function logTypeLabel(type) {
|
||||
const map = { warning: '告警', disable: '自动停用', error: '错误', manual: '操作' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAdmins()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
@ -24,37 +24,10 @@
|
||||
<span>各子账号消费与额度</span>
|
||||
</template>
|
||||
<el-table :data="overview.users || []" stripe v-loading="loading" table-layout="auto"
|
||||
:default-sort="{ prop: 'consumed_total', order: 'descending' }"
|
||||
row-key="id">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div style="padding: 8px 48px;" v-if="(row.projects || []).length">
|
||||
<el-table :data="row.projects" size="small" :show-header="true">
|
||||
<el-table-column prop="project_name" label="项目名" min-width="160" />
|
||||
<el-table-column label="消费" min-width="100" align="right">
|
||||
<template #default="{ row: p }">
|
||||
<span style="color:#e6a23c;">¥{{ Number(p.current_spending).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="监测" min-width="80" align="center">
|
||||
<template #default="{ row: p }">
|
||||
<el-tag :type="p.monitor_enabled ? 'success' : 'info'" size="small">
|
||||
{{ p.monitor_enabled ? '开' : '关' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else style="padding:8px 48px; color:#999;">暂无关联项目</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
:default-sort="{ prop: 'consumed_total', order: 'descending' }">
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="display_name" label="显示名" min-width="100" />
|
||||
<el-table-column label="监测项目" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="project_name" label="项目" min-width="120" />
|
||||
<el-table-column prop="consumed_total" label="累计消费" min-width="110" sortable align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight: 600; color: #e6a23c;">
|
||||
@ -83,6 +56,15 @@
|
||||
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
|
||||
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
|
||||
size="small" style="margin:1px;">
|
||||
{{ step }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="spending_updated_at" label="更新时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.spending_updated_at ? new Date(row.spending_updated_at).toLocaleString('zh-CN') : '暂无' }}
|
||||
|
||||
@ -51,13 +51,6 @@
|
||||
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目" min-width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openProjectsDialog(row)">
|
||||
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
|
||||
@ -76,7 +69,6 @@
|
||||
</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="openQuotaHistory(row)">划拨记录</el-dropdown-item>
|
||||
@ -93,7 +85,7 @@
|
||||
</el-card>
|
||||
|
||||
<!-- Allocate Dialog -->
|
||||
<el-dialog v-model="allocateVisible" title="额度变更" width="90%" style="max-width: 520px;">
|
||||
<el-dialog v-model="allocateVisible" title="额度变更" width="480px">
|
||||
<div style="margin-bottom:16px; padding:12px; background:#f5f7fa; border-radius:8px;">
|
||||
<div>用户: <b>{{ allocateUser?.username }}</b></div>
|
||||
<div>当前额度: ¥{{ Number(allocateUser?.allocated_quota || 0).toLocaleString() }}</div>
|
||||
@ -133,8 +125,20 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Config Dialog -->
|
||||
<el-dialog v-model="configVisible" title="监控配置" width="90%" style="max-width: 560px;">
|
||||
<el-form :model="configForm" label-width="140px">
|
||||
<el-dialog v-model="configVisible" title="监控配置" width="560px">
|
||||
<el-form :model="configForm" label-width="130px">
|
||||
<el-form-item label="关联项目">
|
||||
<el-select v-model="configForm.project_name" placeholder="选择火山引擎项目"
|
||||
filterable clearable style="width:100%;" :loading="projectsLoading">
|
||||
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<div class="form-hint">
|
||||
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">告警阶梯</el-divider>
|
||||
|
||||
<el-form-item label="告警阶梯(%)">
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||
<el-tag v-for="(step, i) in configForm.alert_thresholds" :key="i"
|
||||
@ -144,6 +148,9 @@
|
||||
</div>
|
||||
<div class="form-hint">达到已划拨额度对应百分比时发送告警</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">开关</el-divider>
|
||||
|
||||
<el-form-item label="消费监控">
|
||||
<el-switch v-model="configForm.monitor_enabled" />
|
||||
</el-form-item>
|
||||
@ -158,62 +165,8 @@
|
||||
</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>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Quota History Dialog -->
|
||||
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="90%" style="max-width: 800px;">
|
||||
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
|
||||
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
|
||||
<el-table-column prop="created_at" label="时间" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
@ -234,7 +187,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Policies Dialog -->
|
||||
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="90%" style="max-width: 850px;">
|
||||
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="650px">
|
||||
<div style="margin-bottom:12px; display:flex; gap:8px;">
|
||||
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
|
||||
<el-option-group label="常用策略">
|
||||
@ -260,7 +213,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Create User Dialog -->
|
||||
<el-dialog v-model="showCreate" title="创建子账号" width="90%" style="max-width: 580px;">
|
||||
<el-dialog v-model="showCreate" title="创建子账号" width="520px">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px;"
|
||||
description="创建后会在火山引擎生成 IAM 用户和 API 密钥。SecretKey 仅显示一次,请务必保存!" />
|
||||
<el-form :model="createForm" label-width="110px">
|
||||
@ -276,18 +229,14 @@
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="createForm.phone" placeholder="选填,如 +8618000000000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="火山控制台密码">
|
||||
<el-form-item label="控制台密码">
|
||||
<el-input v-model="createForm.password" type="password" show-password
|
||||
placeholder="选填" />
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||
火山引擎网页后台的登录密码。不填则子账号无法登录火山网页后台,仅能通过 API Key 使用服务
|
||||
</div>
|
||||
placeholder="选填,填了才开通控制台登录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联项目">
|
||||
<el-select v-model="createForm.project_name" placeholder="选填" filterable clearable
|
||||
style="width:100%;" :loading="volcProjectsLoading"
|
||||
@focus="() => { if (!volcProjects.length) loadVolcProjects() }">
|
||||
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
style="width:100%;" :loading="projectsLoading">
|
||||
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -298,7 +247,7 @@
|
||||
</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-dialog v-model="showSecretKey" title="API 密钥已生成" width="520px" :close-on-click-modal="false">
|
||||
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
|
||||
description="SecretAccessKey 仅此一次显示!关闭后无法再次获取,请立即复制保存。" />
|
||||
<el-descriptions :column="1" border>
|
||||
@ -346,6 +295,10 @@ const configUserId = ref(null)
|
||||
const saving = ref(false)
|
||||
const newStep = ref(null)
|
||||
|
||||
// Projects
|
||||
const projects = ref([])
|
||||
const projectsLoading = ref(false)
|
||||
|
||||
// Quota History
|
||||
const historyVisible = ref(false)
|
||||
const historyUser = ref(null)
|
||||
@ -408,16 +361,6 @@ const policies = ref([])
|
||||
const policiesLoading = ref(false)
|
||||
const policyToAttach = ref('')
|
||||
|
||||
// Projects dialog
|
||||
const projectsDialogVisible = ref(false)
|
||||
const projectsUser = ref(null)
|
||||
const userProjects = ref([])
|
||||
const projectsDialogLoading = ref(false)
|
||||
const projectToAdd = ref('')
|
||||
const projectPoliciesToAttach = ref([])
|
||||
const volcProjects = ref([])
|
||||
const volcProjectsLoading = ref(false)
|
||||
|
||||
// --- Allocate ---
|
||||
const maxDeduct = computed(() => {
|
||||
if (!allocateUser.value) return 0
|
||||
@ -461,106 +404,29 @@ async function submitAllocate() {
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
async function loadProjects() {
|
||||
projectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
projects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取项目列表失败')
|
||||
} finally {
|
||||
projectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfig(row) {
|
||||
configUserId.value = row.id
|
||||
configForm.value = {
|
||||
project_name: row.project_name || '',
|
||||
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,
|
||||
}
|
||||
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('操作失败')
|
||||
}
|
||||
if (projects.value.length === 0) loadProjects()
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
|
||||
@ -14,12 +14,6 @@
|
||||
逗号分隔的百分比,如 50,80,90 表示消费达到已划拨额度的 50%/80%/90% 时告警
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目默认授权策略">
|
||||
<el-input v-model="projectPoliciesStr" placeholder="ArkFullAccess,TOSFullAccess" />
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||
逗号分隔。添加项目时自动在项目范围内授权这些策略,移除项目时自动回收
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="监控间隔(秒)">
|
||||
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
||||
</el-form-item>
|
||||
@ -94,16 +88,6 @@ const config = ref({})
|
||||
const loadingConfig = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
|
||||
const projectPoliciesStr = computed({
|
||||
get: () => (config.value.default_project_policies || []).join(','),
|
||||
set: (val) => {
|
||||
config.value.default_project_policies = val
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
},
|
||||
})
|
||||
|
||||
const alertThresholdsStr = computed({
|
||||
get: () => (config.value.default_alert_thresholds || []).join(','),
|
||||
set: (val) => {
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airgate-backend
|
||||
labels:
|
||||
app: airgate-backend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airgate-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airgate-backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: swr-secret
|
||||
containers:
|
||||
- name: airgate-backend
|
||||
image: ${CI_REGISTRY_IMAGE}/airgate-backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8100
|
||||
env:
|
||||
- name: DJANGO_DEBUG
|
||||
value: "False"
|
||||
- name: DJANGO_ALLOWED_HOSTS
|
||||
value: "*"
|
||||
- name: DJANGO_SECRET_KEY
|
||||
value: "HYsUppcrbCq05fEMzXfokwC8ge9CF3mV7auoSqlCbCakwC8t7lVrYG_pfixA6CHrCJc"
|
||||
- name: DB_DIR
|
||||
value: "/app/data"
|
||||
# CORS
|
||||
- name: CORS_ALLOWED_ORIGINS
|
||||
value: "https://airgate.airlabs.art"
|
||||
- name: AIRGATE_ENCRYPTION_KEY
|
||||
value: "8By6udk4wn4VUQeHl_zvr8l6ZBEz77HKs_JkhwiC7FQ="
|
||||
volumeMounts:
|
||||
- name: sqlite-data
|
||||
mountPath: /app/data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz/
|
||||
port: 8100
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz/
|
||||
port: 8100
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1024Mi"
|
||||
cpu: "1000m"
|
||||
volumes:
|
||||
- name: sqlite-data
|
||||
persistentVolumeClaim:
|
||||
claimName: airgate-sqlite-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: airgate-sqlite-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airgate-backend
|
||||
spec:
|
||||
selector:
|
||||
app: airgate-backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8100
|
||||
targetPort: 8100
|
||||
@ -1,34 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: airgate-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- airgate-api.airlabs.art
|
||||
- airgate.airlabs.art
|
||||
secretName: airgate-tls
|
||||
rules:
|
||||
- host: airgate-api.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: airgate-backend
|
||||
port:
|
||||
number: 8100
|
||||
- host: airgate.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: airgate-web
|
||||
port:
|
||||
number: 80
|
||||
@ -1,59 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airgate-web
|
||||
labels:
|
||||
app: airgate-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airgate-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airgate-web
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: swr-secret
|
||||
containers:
|
||||
- name: airgate-web
|
||||
image: ${CI_REGISTRY_IMAGE}/airgate-web:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airgate-web
|
||||
spec:
|
||||
selector:
|
||||
app: airgate-web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
212
操作说明.md
212
操作说明.md
@ -1,212 +0,0 @@
|
||||
# AirGate 操作说明
|
||||
|
||||
---
|
||||
|
||||
## 一、首次部署
|
||||
|
||||
### 1. 启动服务
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd C:\Airlabs_Project\AirGate\backend
|
||||
venv\Scripts\activate
|
||||
python manage.py runserver 8101
|
||||
|
||||
# 前端(另一个终端)
|
||||
cd C:\Airlabs_Project\AirGate\frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录。
|
||||
|
||||
### 2. 配置火山主账号
|
||||
|
||||
1. 左侧菜单 → **系统设置**
|
||||
2. 页面下方「火山引擎主账号」→ 点 **添加主账号**
|
||||
3. 填入主账号的 AccessKey 和 SecretKey(加密存储,不可回显)
|
||||
4. 点 **测试** 验证密钥有效
|
||||
|
||||
> AK/SK 获取方式:登录 `console.volcengine.com` → 右上角头像 → 密钥管理 → 新建密钥
|
||||
|
||||
### 3. 配置全局默认参数
|
||||
|
||||
在**系统设置**页面上方「全局默认配置」中设置:
|
||||
|
||||
| 配置项 | 说明 | 建议值 |
|
||||
|--------|------|--------|
|
||||
| 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 |
|
||||
| 项目默认授权策略 | 添加项目时自动授权的策略 | ArkFullAccess,TOSFullAccess |
|
||||
| 监控间隔(秒) | 定时查询消费的间隔 | 3600(1小时) |
|
||||
| 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 |
|
||||
|
||||
---
|
||||
|
||||
## 二、日常操作
|
||||
|
||||
### 给新部门开通子账号
|
||||
|
||||
**步骤 1:创建子账号**
|
||||
|
||||
1. 左侧菜单 → **子账号管理** → 点 **创建子账号**
|
||||
2. 填写:
|
||||
- **用户名**:英文,如 `dept_video`
|
||||
- **显示名**:如 `视频部门`
|
||||
- **火山控制台密码**:填上(对方需要登录火山后台创建方舟 API Key)
|
||||
- 其他选填
|
||||
3. 点 **创建**
|
||||
4. 弹窗显示 API 密钥 → **立即复制保存**(SecretKey 仅显示一次)
|
||||
|
||||
**步骤 2:在火山控制台创建项目**
|
||||
|
||||
1. 登录 `console.volcengine.com`(你的主账号)
|
||||
2. 左上角项目管理 → 新建项目(如 `team-video-1`)
|
||||
|
||||
**步骤 3:在 AirGate 关联项目并授权**
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
### 给子账号追加/扣减额度
|
||||
|
||||
1. 子账号管理 → 找到目标用户 → 点 **划拨**
|
||||
2. 选择「追加额度」或「扣减额度」
|
||||
3. 输入金额和备注 → 确认
|
||||
|
||||
> 扣减有保护:总额度不能低于已消费金额
|
||||
|
||||
---
|
||||
|
||||
### 给子账号增加新项目
|
||||
|
||||
1. 先在火山控制台创建新项目
|
||||
2. 回到 AirGate → 子账号管理 → 更多 → **项目管理**
|
||||
3. 从下拉框选择新项目 → 添加(自动授权)
|
||||
|
||||
---
|
||||
|
||||
### 关闭某个项目的监测
|
||||
|
||||
1. 子账号管理 → 更多 → **项目管理**
|
||||
2. 找到目标项目 → 关闭「监测」开关
|
||||
3. 该项目的消费不再计入子账号的累计消费(不影响告警和停用判断)
|
||||
|
||||
---
|
||||
|
||||
### 手动停用/恢复子账号
|
||||
|
||||
**停用:**
|
||||
1. 子账号管理 → 更多 → **停用账号**
|
||||
2. 确认后,子账号的控制台登录和所有 API Key 立即失效
|
||||
|
||||
**恢复:**
|
||||
1. 子账号管理 → 更多 → **恢复账号**
|
||||
2. 确认后,控制台登录和 API Key 立即恢复
|
||||
|
||||
---
|
||||
|
||||
### 查看消费明细
|
||||
|
||||
1. 左侧菜单 → **消费监控**
|
||||
2. 表格展示每个子账号的累计消费、额度、使用率
|
||||
3. 点行首的 **展开箭头**,查看该子账号各项目的独立消费
|
||||
4. 点 **刷新消费数据** 手动触发一次消费查询
|
||||
5. 点 **查看主账号余额** 查看主账号的可用余额
|
||||
|
||||
> 消费数据来自火山 Billing API,有 1-2 天延迟
|
||||
|
||||
---
|
||||
|
||||
### 查看告警记录
|
||||
|
||||
1. 左侧菜单 → **告警记录**
|
||||
2. 可按类型筛选:告警 / 自动停用 / 手动操作 / 错误
|
||||
|
||||
---
|
||||
|
||||
### 修改子账号的告警阶梯
|
||||
|
||||
1. 子账号管理 → 更多 → **监控配置**
|
||||
2. 修改告警阶梯百分比(如添加 95%)
|
||||
3. 开关消费监控 / 额度用尽自动停用
|
||||
4. 保存
|
||||
|
||||
---
|
||||
|
||||
### 查看/管理子账号的权限策略
|
||||
|
||||
1. 子账号管理 → 更多 → **权限策略**
|
||||
2. 上方可从下拉框选择策略手动附加
|
||||
3. 已有策略列表中可点 **移除**
|
||||
|
||||
> 常规情况不需要手动管理权限,添加项目时会自动授权
|
||||
|
||||
---
|
||||
|
||||
## 三、告警与自动停用机制
|
||||
|
||||
```
|
||||
定时任务每小时运行一次
|
||||
│
|
||||
▼
|
||||
遍历每个子账号下所有开启监测的项目
|
||||
│
|
||||
▼
|
||||
分别查询每个项目的消费 → 累加得出总消费
|
||||
│
|
||||
▼
|
||||
总消费 / 已划拨额度 = 使用率
|
||||
│
|
||||
├── 使用率 ≥ 50% → 飞书告警(仅一次)
|
||||
├── 使用率 ≥ 80% → 飞书告警(仅一次)
|
||||
├── 使用率 ≥ 90% → 飞书告警(仅一次)
|
||||
└── 使用率 ≥ 100% → 自动停用子账号 + 飞书告警
|
||||
```
|
||||
|
||||
- 每个阶梯只通知一次,不会重复
|
||||
- 追加或扣减额度后,告警状态自动重置
|
||||
- 「额度用尽自动停用」可在监控配置中关闭(只告警不停用)
|
||||
|
||||
---
|
||||
|
||||
## 四、外部系统对接(AirDrama)
|
||||
|
||||
AirGate 支持通过 API Key 认证供外部系统调用:
|
||||
|
||||
```bash
|
||||
# 在 .env 中设置
|
||||
AIRGATE_API_KEY=你的密钥
|
||||
|
||||
# 调用示例
|
||||
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 章。
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量
|
||||
2. **SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了,务必保存
|
||||
3. **项目由你创建**:子账号没有创建项目的权限,需要新项目时在火山控制台创建后在 AirGate 关联
|
||||
4. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭
|
||||
5. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的火山主账号 AK/SK 无法解密,需要重新配置
|
||||
@ -31,7 +31,7 @@
|
||||
|------|----------|--------|
|
||||
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
|
||||
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
|
||||
| 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** |
|
||||
| 子账号能看到自己的账单 | 通过 AirGate 按项目维度查询,主账号代查展示 | **部分可行**(见下方说明)|
|
||||
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
|
||||
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** |
|
||||
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
|
||||
@ -507,31 +507,24 @@ iam_client.call("UpdateAccessKey", {
|
||||
|
||||
> **核心问题**:IAM 子账号没有独立的计费维度。不能直接按 IAM UserName 查询消费。
|
||||
|
||||
**AirGate 采用的方案:多项目聚合追踪**
|
||||
**可行方案:**
|
||||
|
||||
一个子账号可以关联多个火山项目,每个项目有独立的监测开关。消费追踪按**所有开启监测的项目消费之和**计算。
|
||||
| 方案 | 实现方式 | 精确度 |
|
||||
|------|----------|--------|
|
||||
| **按项目追踪** | 为每个子账号/部门创建独立项目,资源都放在项目中 | 高 |
|
||||
| **按标签追踪** | 资源打上子账号标签(如 `owner=sub_user_1`) | 高 |
|
||||
| **按 Ark 端点追踪** | Seedance/方舟按 Endpoint 分账(ListSplitBillDetail) | 中 |
|
||||
| **按 TOS 存储桶追踪** | TOS 按 Bucket 分账 | 高 |
|
||||
|
||||
**推荐方案:项目 + 标签 双维度**
|
||||
|
||||
```
|
||||
子账号 (seaislee)
|
||||
├── 项目A: Seedance-团队1 ← 开启监测 → 消费 ¥30,000
|
||||
├── 项目B: Seedance-团队2 ← 开启监测 → 消费 ¥20,000
|
||||
├── 项目C: Seedance-团队3 ← 开启监测 → 消费 ¥15,000
|
||||
├── 项目D: 测试项目 ← 关闭监测(不计入)
|
||||
└── 项目E: 内部工具 ← 关闭监测(不计入)
|
||||
|
||||
累计消费 = 项目A + 项目B + 项目C = ¥65,000(仅算开启监测的)
|
||||
已划拨额度: ¥100,000
|
||||
使用率: 65% → 50% 告警已触发
|
||||
1. 创建项目 "DeptA-Project"
|
||||
2. 子账号的权限限定在该项目范围内 (AttachPolicyInProject)
|
||||
3. 资源打标签 tag: {"department": "DeptA", "owner": "sub_user_1"}
|
||||
4. 通过 ListBillDetail + ListSplitBillDetail 按项目/标签筛选消费
|
||||
```
|
||||
|
||||
**典型使用场景:**
|
||||
- 一个部门子账号下,每个团队各创建一个火山项目
|
||||
- 每个项目下各有一个 Seedance 2.0 API 端点
|
||||
- 管理员可按需开关某些项目的监测(如测试项目不计费)
|
||||
- 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度
|
||||
|
||||
**消费查询方式:** 对每个开启监测的项目分别调用 `ListBillDetail`(按 Project 字段筛选),累加得出总消费。同时记录每个项目的独立消费,前端可展开查看明细。
|
||||
|
||||
### 6.4 账户余额查询
|
||||
|
||||
```python
|
||||
@ -609,9 +602,7 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
主账号通过 AirGate 给子账号划拨额度(如 10 万元)
|
||||
│
|
||||
▼ 定时任务每小时查询 Billing API
|
||||
遍历子账号下所有开启监测的项目 → 分别查询消费 → 累加得出总消费
|
||||
│
|
||||
▼ 总消费对比已划拨额度
|
||||
累计消费不断增长,对比已划拨额度
|
||||
│
|
||||
├── 消费达到额度 50% → 飞书告警
|
||||
├── 消费达到额度 80% → 飞书告警
|
||||
@ -623,9 +614,6 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
```
|
||||
|
||||
**关键设计:**
|
||||
- **多项目聚合**:一个子账号可关联多个火山项目,每个项目有独立监测开关。消费 = 所有开启监测的项目消费之和
|
||||
- **项目即权限**:添加项目时自动调用 `AttachPolicyInProject` 在项目范围内授权,移除项目时自动回收权限。子账号只能操作被授权的项目,碰不到其他人的资源。**添加项目时授权哪些策略由管理员在弹窗中手动选择**(从下拉列表选,支持多选,默认不预选任何策略),避免系统自动附加不需要的权限
|
||||
- **项目明细可查**:前端可展开查看每个项目的独立消费,便于分析哪个团队/项目花得多
|
||||
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
|
||||
- **可追加可扣减**:主账号可随时追加额度(+5万)或扣减额度(-3万),支持灵活调整
|
||||
- **扣减保护**:扣减后总额度不能低于已消费金额(否则会立即触发停用)
|
||||
@ -640,21 +628,18 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
|
||||
### 8.1 完全停用子账号(保留账号,可恢复)
|
||||
|
||||
需要**同时**执行三个操作才能完全停用:
|
||||
|
||||
> **重要发现(2026-03-20 实测验证)**:仅停用控制台登录 + 停用 API 密钥不够。如果子账号已经登录了火山控制台(浏览器会话未过期),他仍然可以继续操作(如在体验中心生成视频)。**必须同时移除所有权限策略**,这样即使会话未过期,刷新页面后任何操作都会返回"权限不足"。
|
||||
需要**同时**执行两个操作才能完全停用:
|
||||
|
||||
```python
|
||||
def get_user_access_keys(iam_client, username: str) -> list:
|
||||
"""获取用户的所有 AccessKey ID"""
|
||||
result = iam_client.call("ListAccessKeys", {"UserName": username})
|
||||
keys = result.get("Result", {}).get("AccessKeyMetadata", [])
|
||||
return [k["AccessKeyId"] for k in keys]
|
||||
|
||||
|
||||
def disable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"""完全停用子账号(保留账号,可一键恢复)
|
||||
|
||||
三步停用:
|
||||
1. 停用控制台登录(阻止新登录)
|
||||
2. 停用所有 API 密钥(阻止 API 调用)
|
||||
3. 移除所有权限策略(已登录的会话也无法操作)
|
||||
|
||||
移除的策略列表保存到本地数据库,恢复时自动加回。
|
||||
"""
|
||||
"""完全停用子账号(保留账号,可一键恢复)"""
|
||||
|
||||
# 0. 如果未传入 access_key_ids,自动查询
|
||||
if access_key_ids is None:
|
||||
@ -674,38 +659,14 @@ def disable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"UserName": username
|
||||
})
|
||||
|
||||
# 3. 移除所有权限策略(保存到 DB 以便恢复)
|
||||
saved_policies = []
|
||||
resp = iam_client.call("ListAttachedUserPolicies", {"UserName": username})
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
for p in policies:
|
||||
policy_name = p["PolicyName"]
|
||||
policy_type = p["PolicyType"]
|
||||
iam_client.call("DetachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": policy_name,
|
||||
"PolicyType": policy_type,
|
||||
})
|
||||
saved_policies.append({"name": policy_name, "type": policy_type})
|
||||
|
||||
# saved_policies 存入本地 DB 的 IAMUser.saved_policies_on_disable 字段(JSONField)
|
||||
# 恢复时从此字段读取并重新附加
|
||||
|
||||
print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个密钥 + {len(saved_policies)} 个策略已移除)")
|
||||
print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个 API 密钥)")
|
||||
```
|
||||
|
||||
### 8.2 一键恢复子账号
|
||||
|
||||
```python
|
||||
def enable_sub_user(iam_client, username: str, access_key_ids: list = None,
|
||||
saved_policies: list = None):
|
||||
"""一键恢复子账号
|
||||
|
||||
三步恢复(与停用操作完全对称):
|
||||
1. 恢复控制台登录
|
||||
2. 恢复所有 API 密钥
|
||||
3. 重新附加停用时保存的权限策略
|
||||
"""
|
||||
def enable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"""一键恢复子账号"""
|
||||
|
||||
# 0. 如果未传入 access_key_ids,自动查询
|
||||
if access_key_ids is None:
|
||||
@ -725,18 +686,7 @@ def enable_sub_user(iam_client, username: str, access_key_ids: list = None,
|
||||
"UserName": username
|
||||
})
|
||||
|
||||
# 3. 重新附加停用时保存的策略
|
||||
if saved_policies:
|
||||
for p in saved_policies:
|
||||
iam_client.call("AttachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": p["name"],
|
||||
"PolicyType": p["type"],
|
||||
})
|
||||
|
||||
# 恢复后清空 saved_policies_on_disable 字段
|
||||
|
||||
print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个密钥 + {len(saved_policies or [])} 个策略已恢复)")
|
||||
print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个 API 密钥)")
|
||||
```
|
||||
|
||||
### 8.3 停用 vs 删除的区别
|
||||
@ -1052,17 +1002,10 @@ def get_user_spending(bill_period: str, project_name: str = None) -> float:
|
||||
**AirGate 实现的核心流程:**
|
||||
|
||||
1. 主账号通过界面给子账号**划拨额度**(如 10 万元)
|
||||
2. 子账号下挂多个火山项目,每个项目有独立监测开关
|
||||
3. 定时任务每小时遍历所有开启监测的项目,分别调用 Billing API 查询消费,累加得出总消费
|
||||
4. 总消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
|
||||
5. 总消费达到 100% → 自动停用整个子账号 + 飞书告警
|
||||
6. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
|
||||
|
||||
**多项目管理:**
|
||||
- 每个子账号可关联 N 个火山项目
|
||||
- 每个项目有独立的监测开关(开/关)
|
||||
- 可通过"全选"一键开启/关闭所有项目的监测
|
||||
- 消费明细可按项目展开查看,但告警和停用看的是**所有开启项目的消费总和**
|
||||
2. 定时任务每小时调用 Billing API 查询累计消费
|
||||
3. 消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
|
||||
4. 消费达到 100% → 自动停用子账号 + 飞书告警
|
||||
5. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
|
||||
|
||||
**告警状态管理:**
|
||||
- 每个阶梯只通知一次,通过 `triggered_alerts` 字段(存数据库)去重
|
||||
@ -1091,15 +1034,6 @@ PUT /api/v1/iam-users/{id}/update/ # 更新配置(告警阈值/开
|
||||
POST /api/v1/iam-users/{id}/disable/ # 停用子账号
|
||||
POST /api/v1/iam-users/{id}/enable/ # 恢复子账号
|
||||
GET /api/v1/iam-users/{id}/policies/ # 查看权限策略
|
||||
POST /api/v1/iam-users/{id}/policies/attach/ # 附加权限策略
|
||||
POST /api/v1/iam-users/{id}/policies/detach/ # 移除权限策略
|
||||
|
||||
# 子账号项目管理(多项目关联 + 自动授权/回收)
|
||||
GET /api/v1/iam-users/{id}/projects/ # 查看子账号关联的项目列表
|
||||
POST /api/v1/iam-users/{id}/projects/add/ # 添加关联项目(自动在项目范围内授权默认策略)
|
||||
PUT /api/v1/iam-users/{id}/projects/{pid}/ # 更新项目监测开关
|
||||
DELETE /api/v1/iam-users/{id}/projects/{pid}/delete/ # 移除关联项目(自动回收项目范围内的策略)
|
||||
POST /api/v1/iam-users/{id}/projects/toggle-all/ # 全选/全不选监测开关
|
||||
|
||||
# 额度管理
|
||||
POST /api/v1/iam-users/{id}/allocate/ # 追加额度(正数)或扣减额度(负数)
|
||||
@ -1129,7 +1063,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
|
||||
|
||||
| 限制项 | 说明 |
|
||||
|--------|------|
|
||||
| IAM 子账号无独立计费 | 所有费用归主账号,通过多项目聚合追踪(子账号关联 N 个项目,消费=开启监测的项目之和) |
|
||||
| IAM 子账号无独立计费 | 所有费用归主账号,需通过项目/标签追踪 |
|
||||
| Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 |
|
||||
| 每用户最多 2 个 API 密钥 | 无法创建更多 |
|
||||
| SecretKey 仅返回一次 | 创建后立即保存 |
|
||||
|
||||
104
版本管理.md
104
版本管理.md
@ -1,104 +0,0 @@
|
||||
# AirGate 版本管理
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0 (2026-03-20)
|
||||
|
||||
### UI 优化
|
||||
- 所有弹窗改为自适应宽度(`width="90%"` + `max-width`),解决小屏溢出和内容截断问题
|
||||
- 「火山控制台密码」字段增加说明文字,明确是火山引擎网页后台的登录密码,非 AirGate 密码
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 (2026-03-19)
|
||||
|
||||
### 项目自动授权
|
||||
- 添加关联项目时,自动调用火山 `AttachPolicyInProject` 在项目范围内授权默认策略(ArkFullAccess + TOSFullAccess)
|
||||
- 移除关联项目时,自动调用 `DetachPolicyInProject` 回收权限
|
||||
- `IAMUserProject` 模型新增 `attached_policies` 字段,记录每个项目已授权的策略列表
|
||||
- `GlobalConfig` 新增 `default_project_policies` 配置项,支持在系统设置页面自定义默认授权策略
|
||||
- `IAMService` 新增 `attach_policy_in_project()` / `detach_policy_in_project()` 方法
|
||||
- 项目管理弹窗新增「已授权策略」列,展示每个项目被授权了哪些策略
|
||||
|
||||
### 多项目支持
|
||||
- 新增 `IAMUserProject` 模型,支持一个子账号关联多个火山项目
|
||||
- 每个项目有独立的监测开关,消费 = 所有开启监测的项目消费之和
|
||||
- 新增项目管理弹窗:添加项目(从火山拉取下拉列表)、开关监测、移除项目、全选/全不选
|
||||
- 子账号列表新增「项目」列,显示监测中/总数,可点击进入项目管理
|
||||
- 消费监控页面支持展开行查看每个项目的独立消费
|
||||
- 消费监控定时任务改为遍历所有开启监测的项目分别查询,累加后触发告警
|
||||
- 告警内容包含每个项目的消费明细
|
||||
- 移除旧的 `IAMUser.project_name` 单项目字段
|
||||
- `SpendingRecord` 改为按项目粒度记录月度消费快照
|
||||
|
||||
### 研究报告同步更新
|
||||
- 6.3 节:单项目 → 多项目聚合追踪模型
|
||||
- 7.4 节:新增「项目即权限」设计说明
|
||||
- 11 节:新增项目 CRUD + 自动授权 API 接口文档
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 (2026-03-19)
|
||||
|
||||
### 额度划拨制
|
||||
- 替换月度预算为一次性额度划拨模式(预付制,用完再充)
|
||||
- 支持追加(+)和扣减(-)额度,扣减有保护(不能低于已消费金额)
|
||||
- 额度变更后自动重置告警状态
|
||||
- 新增 `QuotaAllocation` 模型和划拨记录查看功能
|
||||
- 前端「划拨」弹窗支持追加/扣减模式切换,显示最大可扣减金额
|
||||
|
||||
### 阶梯式告警
|
||||
- 替换单一告警阈值为阶梯式百分比告警(如 50%/80%/90%)
|
||||
- 每个子账号可自定义告警阶梯,未设置则使用全局默认值
|
||||
- 每个阶梯只通知一次,额度变更后自动重置
|
||||
|
||||
### 核心功能完善
|
||||
- 新增创建子账号功能(自动在火山创建 IAM 用户 + 生成 API Key + 附加基础策略)
|
||||
- SecretKey 创建后弹窗显示,支持一键复制到剪贴板
|
||||
- 新增权限策略管理(查看/附加/移除 IAM 策略)
|
||||
- 新增 API Key 认证中间件(`X-API-Key` 头,供 AirDrama 等外部系统调用)
|
||||
- 新增 Docker + docker-compose 部署配置
|
||||
- 新增 entrypoint.sh(自动迁移 + 创建默认管理员)
|
||||
|
||||
### 安全修复
|
||||
- `SECRET_KEY` 在生产环境(DEBUG=False)缺失时强制报错,不再使用不安全的默认值
|
||||
- `APIKeyUser` 添加 `pk`/`is_staff` 等属性,修复 DRF 限流中间件兼容性
|
||||
- docker-compose SQLite 卷挂载修复(目录而非文件)
|
||||
- CORS origins 移除冗余端口 80
|
||||
- 清理死配置(`VOLC_ACCESS_KEY`/`VOLC_SECRET_KEY`/`MONITOR_INTERVAL`)
|
||||
- 清理未使用的 import
|
||||
|
||||
### 审计修复
|
||||
- 修复前端引用不存在的字段(`current_month_spending`、`effective_budget`、`budget_usage_percent`)
|
||||
- 修复 scheduler 使用 naive `datetime.now()` → `timezone.now()`
|
||||
- 修复 scheduler 从 settings 读取间隔改为从 GlobalConfig 数据库读取
|
||||
- 修复 DashboardView 标签「本月总消费」→「累计总消费」
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 (2026-03-19)
|
||||
|
||||
### 项目初始化
|
||||
- Django 4.2 + DRF 后端骨架
|
||||
- Vue 3 + Element Plus 前端骨架
|
||||
- 管理员登录(SimpleJWT)
|
||||
- 火山引擎 API 客户端(HMAC-SHA256 签名)
|
||||
- 密钥加密存储(Fernet AES-256)
|
||||
|
||||
### 页面
|
||||
- 登录页
|
||||
- 仪表盘(子账号统计 + 最近告警)
|
||||
- 子账号管理(列表 / 同步 / 停用 / 恢复)
|
||||
- 消费监控(按用户查看消费 + 进度条)
|
||||
- 告警记录(按类型筛选)
|
||||
- 系统设置(火山主账号管理 + 全局配置 + 飞书 Webhook)
|
||||
|
||||
### 后端
|
||||
- IAM 用户管理(创建/同步/导入/停用/恢复)
|
||||
- Billing 消费查询(分页 + 按项目筛选)
|
||||
- 飞书 Webhook 通知(异步非阻塞)
|
||||
- APScheduler 定时消费监控任务
|
||||
|
||||
### 研究报告
|
||||
- 完成火山引擎 IAM/Billing/签名/策略体系深度研究
|
||||
- 经过 5 轮审计修正(签名 bug、策略冲突、双重编码等)
|
||||
Loading…
x
Reference in New Issue
Block a user