Compare commits

..

10 Commits

Author SHA1 Message Date
zyc
cc8a91995d Add K8s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1m11s
2026-03-20 18:35:53 +08:00
seaislee1209
cbc19a6d9e feat: add admin management, change password, and operation log
- Change password: current user can change their own password
- Admin management: superuser can create/toggle/reset-password for admins
- Operation log: view all system operations with type filter
- All operations are recorded to AlertRecord for audit trail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:20:14 +08:00
seaislee1209
a7e030dc57 feat: auto-authorize policies when adding projects to sub-accounts
- Disable now removes all policies (saved to DB) + Enable restores them
- Project add: policies are now user-selected (checkbox), not auto-attached
- Fix serializer allow_blank for optional fields (email/phone/password)
- Better error reporting for policy detach/attach failures
- Handle duplicate user creation with clear error message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:01:18 +08:00
seaislee1209
c4f61a4ada docs: add operations guide (操作说明.md)
Step-by-step guide covering: initial setup, creating sub-accounts,
project authorization flow, quota management, alert mechanism,
external API integration, and important notes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:55:47 +08:00
seaislee1209
47393c4d7d docs: add version changelog (版本管理.md)
Track all iterations from v0.1.0 to v0.4.0 with detailed change logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:54:32 +08:00
seaislee1209
0280cff61f fix: dialog responsive sizing + clarify console password field
- All dialogs use width="90%" + max-width for responsive layout
- Clarify "控制台密码" → "火山控制台密码" with explanation hint
  (not AirGate login, but Volcengine console login for the sub-account)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:53:06 +08:00
seaislee1209
5edf247a7f feat: auto-authorize policies when adding projects to sub-accounts
Project-level authorization:
- Adding a project to a sub-account now auto-calls AttachPolicyInProject
  to grant default policies (ArkFullAccess, TOSFullAccess) in that project scope
- Removing a project auto-calls DetachPolicyInProject to revoke those policies
- Each project records which policies were attached (attached_policies field)
  so removal knows exactly what to revoke

Configuration:
- GlobalConfig.default_project_policies: configurable list of policies to
  auto-attach (editable in Settings page, defaults to ArkFullAccess + TOSFullAccess)

IAM Service:
- Added attach_policy_in_project() and detach_policy_in_project() methods
  using standard AttachUserPolicy/DetachUserPolicy with ProjectName parameter

Frontend:
- Projects dialog now shows "已授权策略" column with policy tags
- Settings page has "项目默认授权策略" config field

Alert logging:
- Project add/remove operations are logged with attached/detached policy details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:24:45 +08:00
seaislee1209
1e94241587 feat: multi-project per sub-account support
Data model:
- Add IAMUserProject model (sub-account → N projects, each with monitoring toggle)
- Remove old single project_name from IAMUser model
- Update SpendingRecord with per-project granularity

Backend:
- Project CRUD views: list/add/update-toggle/delete/toggle-all
- Create user view auto-adds first project if specified
- Scheduler aggregates spending across all enabled projects per user
- Per-project spending recorded in SpendingRecord + IAMUserProject.current_spending
- Alert details include per-project spending breakdown

Frontend:
- New "项目管理" dialog: add projects from Volcengine dropdown, toggle monitoring per project, remove projects, batch toggle all
- "项目" column in user table showing monitored/total count (clickable)
- BillingView: expandable rows showing per-project spending breakdown
- Create dialog: optional initial project selection
- Removed old single-project select from config dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:37:38 +08:00
seaislee1209
9fed282f1e docs: update report for multi-project per sub-account support
- Section 6.3: Replace single-project tracking with multi-project aggregation model
  (sub-account → N projects, each with independent monitoring toggle,
   spending = sum of all enabled projects)
- Section 7.4: Add multi-project to flow diagram and key design points
- Section 11: Update AirGate implementation flow and add project CRUD API endpoints
- Section 1.1: Update requirements table to reflect multi-project
- Section 12: Update limitations to describe multi-project tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:40:53 +08:00
seaislee1209
f76ab6bd97 docs: add README with system overview and usage guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:59:04 +08:00
30 changed files with 2707 additions and 152 deletions

View File

@ -0,0 +1,140 @@
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 Normal file
View File

@ -0,0 +1,79 @@
# 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

View File

@ -0,0 +1,692 @@
# 【申请权限填客户名称】Seedance 2.0 & 2.0 fast API文档邀测用户版
该文档目前仅限开白客户使用,发送前请和销管确认客户是否在开白名单内
***【❗️❗️❗️】该文档限制客户申请权限,只有返回了服务协议的客户方可申请***
本文介绍 Seedance 2.0 & 2.0 fast 模型相较于存量模型 **新增/配置有区别&#x20;**&#x7684; API 参数介绍,存量 API 参数的完整介绍参见 [视频生成 API](https://www.volcengine.com/docs/82379/1520758?lang=zh)。
> 本文档仅限预览及邀测用户使用:
>
> * 不承诺正式API上线100%一致。
>
> * 仅限邀测用户阅读,请勿截图/分享给其他人员。
>
> * 您上传的内容请确保由您原创或已取得授权。
# 模型能力
> **Seedance 2.0 和 Seedance 2.0 fast 提供的模型能力一致,**&#x8FFD;求最高生成品质,推荐使用 **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&#x20;**`string` `必选`
输入内容的类型,此处应为 **text**
***
content.**text&#x20;**`string` `必选`
输入给模型的文本提示词,描述期望生成的视频。
支持中英文。建议中文不超过500字英文不超过1000词。字数过多信息容易分散模型可能因此忽略细节只关注重点造成视频缺失部分元素。提示词的更多使用技巧请参见 [Seedance 提示词指南](https://www.volcengine.com/docs/82379/1587797)。
* **图片信息** `object`
输入给模型的图片信息。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **image\_url**
***
content.**image\_url&#x20;**`object` `必选`
输入给模型的图片对象。
***
content.image\_url.**url&#x20;**`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)&#x20;
>
> * 宽高长度px(300, 6000)
>
> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。
>
> * 图片数量:
>
> * 图生视频-首帧1 张
>
> * 图生视频-首尾帧2 张
>
> * Seedance 2.0 & 2.0 fast 多模态参考生视频1\~9 张
***
content.**role&#x20;**`string` `条件必填`
图片的位置或用途。
> **注意**
>
> * **图生视频-首帧**、**图生视频-首尾帧**、**多模态参考生视频**(包括参考图、视频、音频)为 3 种互斥场景,**不可混用**。
>
> * **多模态参考生视频**可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频-首尾帧**(配置 role 为 **first\_frame / last\_frame**)。
***
**图生视频-首帧**
> 需要传入1个 image\_url 对象
* **字段role取值**
* **first\_frame 或不填**
***
**图生视频-首尾帧**
> 需要传入2个 image\_url 对象
* **字段role取值**
* 首帧图片对应的字段 role 为:**first\_frame**,必填
* 尾帧图片对应的字段 role 为:**last\_frame**,必填
***
**图生视频-参考图&#x20;**
> 可传入 1\~9 个 image\_url 对象
* **字段role取值**
* 每张参考图对应的字段 role 均为:**reference\_image**,必填
* **视频信息** `object`&#x20;
输入给模型的视频信息。仅 Seedance 2.0 & 2.0 fast 支持输入视频。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **video\_url**
***
content.**video\_url&#x20;**`object` `必选`
输入给模型的视频对象。
***
content.video\_url.**url&#x20;**`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]&#x20;
***
content.**role&#x20;**`string` `条件必填`
视频的位置或用途。当前仅支持 **reference\_video**
* **音频信息&#x20;**`object`&#x20;
输入给模型的音频信息。仅 Seedance 2.0 & 2.0 fast 支持输入音频。注意不可单独输入音频,应至少包含 1 个参考视频或图片。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **audio\_url**
***
content.**audio\_url&#x20;**`object` `必选`
输入给模型的音频对象。
***
content.audio\_url.**url&#x20;**`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&#x20;**`string` `条件必填`
音频的位置或用途。当前仅支持 **reference\_audio**
#### **service\_tier** `string`
&#x20;Seedance 2.0 & 2.0 fast 暂不支持
#### **generate\_audio&#x20;**`boolean`&#x20;
> Seedance 2.0 & 2.0 fast 默认值: true
控制生成的视频是否包含与画面同步的声音。
* true模型输出的视频包含同步音频。模型会基于文本提示词与视觉内容自动生成与之匹配的人声、音效及背景音乐。建议将对话部分置于双引号内以优化音频生成效果。例如男人叫住女人说“你记住以后不可以用手指指月亮。”
* false模型输出的视频为无声视频。
> **说明**
>
> 生成的有声视频均为单声道,和传入的音频声道数无关。
####
#### **draft&#x20;**`boolean`
&#x20;Seedance 2.0 & 2.0 fast 暂不支持
#### **tools&#x20;**`object[]`
> 仅 Seedance 2.0 & 2.0 fast 支持
配置模型要调用的工具。
***
tools.**type&#x20;**`string`
指定使用的工具类型。
* web\_search联网搜索工具。
> **说明**
>
> * 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。
>
> * 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool\_usage.**web\_search** 字段获取,如果为 0 表示未搜索。
#### **resolution&#x20;**&#x20;`string`
> Seedance 2.0 & 2.0 fast 默认值720p
视频分辨率,取值范围:
* 480p
* 720p
#### **ratio&#x20;**`string`&#x20;
> Seedance 2.0 & 2.0 fast 默认值: adaptive
生成视频的宽高比例。不同宽高比对应的宽高像素值见下方表格。
* 16:9&#x20;
* 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`&#x20;
> Seedance 2.0 & 2.0 fast 默认值5
生成视频时长,仅支持整数,单位:秒。
取值范围:
* \[4,15] 或设置为-1
> **配置方法**
>
> * 指定具体时长:支持有效范围内的任一整数。
>
> * 智能指定:设置为 -1表示由模型在有效范围内自主选择合适的视频长度整数秒。实际生成视频的时长可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **duration** 字段获取。注意视频时长与计费相关,请谨慎设置。
#### **frames** `integer`&#x20;
Seedance 2.0 & 2.0 fast 暂不支持
#### **camera\_fixed** `boolean`
&#x20;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&#x20;**`object[]`&#x20;
> 仅 Seedance 2.0 & 2.0 fast 支持
配置模型要调用的工具。
***
tools.**type&#x20;**`string`
指定使用的工具类型。
* web\_search联网搜索工具。
#### **usage** `object`
本次请求的 token 用量。
***
usage.**completion\_tokens** `integer`
模型输出视频花费的 token 数量。
***
usage.**total\_tokens** `integer`
本次请求消耗的总 token 数量。
***
usage.**tool\_usage&#x20;**`object`&#x20;
> 仅 Seedance 2.0 & 2.0 fast 支持
使用工具的用量信息。
***
usage.tool\_usage.**web\_search&#x20;**`integer`&#x20;
实际调用联网搜索工具的次数,仅开启联网搜索时返回。
# 调用简介及示例
## 流程简介
任务接口是异步接口,视频生成任务流程
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**的面霜面向镜头展示,清新简约背景,元气甜美风格。博主台词:挖到本命面霜了!质地像云朵一样软糯,一抹就吸收,熬夜急救、补水保湿全搞定,素颜都自带柔光感。 | ![Image Token: HTf6bPRukoWaW4xnCSlcvKtUn7c](images/HTf6bPRukoWaW4xnCSlcvKtUn7c.png)![Image Token: YfCDbzJlqo4yzZxCmdscWdsInCf](images/YfCDbzJlqo4yzZxCmdscWdsInCf.jpeg) | |
在 [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)。
同意协议的操作方式如下:
![Image Token: LK8ybUN9Ko2KkQxq2FdclVQtnkh](images/LK8ybUN9Ko2KkQxq2FdclVQtnkh.gif)
示例代码:
# 使用自有虚拟人像素材生成视频(线下提交)
方舟提供私域人像素材库,您可在视频生成中使用自有虚拟人物或真人(仅限素人)素材,生成短剧等更定制化的视频内容。平台将对您提供的素材进行审核,规避可能产生的法律风险。
* 自有素材需入库后使用,您可将虚拟人像或真人素材发送给销售代表,同时完成合规承诺函及其他证明材料的准备。
* 入库后,您可使用素材的 Asset ID在视频生成 API 中使用自有素材。
> **重要**
>
> * 对虚拟人像素材,您需签署虚拟人像素材合规承诺函,并提供签署承诺函所需的材料。
>
> * 对真实人物素材,除承诺函外,您还需额外提供真人授权材料。
>
> * 具体流程及所需材料,请和您的销售代表确认。
提交自有人像素材时,需按人物将素材分组:
* 每个人物为一个素材组。
* 每组可包含多个素材文件,素材文件对应唯一 ID (asset ID)。
## 入库流程
提交自有虚拟人像素材方式大致如下,请联系您的销售代表了解详情。
1. 准备素材文件,完成承诺函签署,并准备其他证明材料。
2. 准备素材文件,完成承诺函签署,并准备其他证明材料。
* 每个人物素材需至少提供一张正面图片文件。此外,您可按需提供该人物的其他图片、视频素材。
* 需确保每个人物组中的素材与该正面图片为同一人物。
* 每个人物创建一个文件夹(命名:“*虚拟人像 1-<人像名>*”)
提交素材文件夹示例:
![Image Token: XMQ9bz6vhof7vxxsac8cqIZmneB](images/XMQ9bz6vhof7vxxsac8cqIZmneB.png)
> **注意**
>
> * 以上示例仅供参考,您可根据视频创作需求,提交虚拟人物素材。
>
> * 您仅需上传视频生成任务中需要使用的素材。
* 素材文件需满足视频生成 API 对输入文件的要求:
> **传入单张图片要求**
>
> * 格式jpeg、png、webp、bmp、tiff、gif
>
> * 宽高比(宽/高): (0.4, 2.5)&#x20;
>
> * 宽高长度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]&#x20;
> **注意**
>
> 有关提交流程、承诺函签署所需材料的具体信息,请联系您的销售代表了解详情。
3. 方舟将对您提供的素材进行审核,通过审核的素材将被上传至虚拟人像库。
4. 入库后,每个人物组素材将通过以下示例中的形式返回,您可解压后查看:
![Image Token: PKu6b3391oUbVKxxEGjchxBVnbg](images/PKu6b3391oUbVKxxEGjchxBVnbg.png)
示例中:
* Andy 为您提交的人物名称
* group-20260310035119-9mzqn 为该人物组的 ID
* 解压后,可查看每张素材的 Asset ID
![Image Token: VV0ybrxNfouEhZxTjqCcX1epnzb](images/VV0ybrxNfouEhZxTjqCcX1epnzb.png)
* 您可按 `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)提交一次视频生成任务,阅读并同意弹出的 **虚拟人像库使用协议**,操作方式如下:
![Image Token: IFfPbDgceoFXZCxdriIcnwkPnUc](images/IFfPbDgceoFXZCxdriIcnwkPnUc.gif)
* 仅支持使用已入库素材生成视频。

View File

@ -10,3 +10,17 @@ 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)

View File

@ -5,4 +5,9 @@ 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),
]

View File

@ -5,7 +5,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from .serializers import LoginSerializer, UserInfoSerializer
from .models import AdminUser
from .serializers import (
LoginSerializer, UserInfoSerializer,
ChangePasswordSerializer, AdminUserCreateSerializer,
)
@api_view(['POST'])
@ -58,3 +62,136 @@ 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} 的密码'})

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
@admin.register(VolcAccount)
@ -7,11 +7,23 @@ 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)
@ -32,4 +44,4 @@ class AlertRecordAdmin(admin.ModelAdmin):
@admin.register(SpendingRecord)
class SpendingRecordAdmin(admin.ModelAdmin):
list_display = ('iam_user', 'bill_period', 'amount', 'updated_at')
list_display = ('iam_user', 'project_name', 'bill_period', 'amount', 'updated_at')

View File

@ -0,0 +1,59 @@
# 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')},
},
),
]

View File

@ -0,0 +1,23 @@
# 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='已授权的策略列表'),
),
]

View File

@ -0,0 +1,18 @@
# 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='停用时保存的策略'),
),
]

View File

@ -35,8 +35,6 @@ 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)
@ -58,6 +56,10 @@ 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)
@ -90,6 +92,30 @@ 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')
@ -113,6 +139,8 @@ 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)
@ -161,18 +189,20 @@ 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', 'bill_period')]
unique_together = [('iam_user', 'project_name', 'bill_period')]
def __str__(self):
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
proj = self.project_name or '总计'
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"

View File

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
class VolcAccountSerializer(serializers.ModelSerializer):
@ -15,21 +15,32 @@ 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',
'project_name', 'status', 'access_key_ids',
'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',
@ -40,14 +51,18 @@ 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='')
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='')
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='可选,创建后自动关联此项目')
class IAMUserImportSerializer(serializers.Serializer):
@ -55,8 +70,6 @@ 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,
@ -65,6 +78,21 @@ 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)
@ -88,6 +116,7 @@ class GlobalConfigSerializer(serializers.ModelSerializer):
model = GlobalConfig
fields = [
'default_alert_thresholds',
'default_project_policies',
'monitor_interval_seconds',
'feishu_webhook_url', 'feishu_alert_mobiles',
'updated_at',
@ -106,14 +135,6 @@ 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()

View File

@ -22,6 +22,14 @@ 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),

View File

@ -14,11 +14,13 @@ from utils.iam_service import IAMService, ProjectService
from utils.billing_service import BillingService
from utils.volcengine_client import VolcengineAPIError
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .serializers import (
VolcAccountSerializer, VolcAccountCreateSerializer,
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
IAMUserConfigSerializer,
IAMUserProjectSerializer, IAMUserProjectAddSerializer, IAMUserProjectUpdateSerializer,
QuotaAllocateSerializer, QuotaAllocationSerializer,
GlobalConfigSerializer,
AlertRecordSerializer,
DashboardSerializer,
@ -232,6 +234,10 @@ 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)
@ -272,11 +278,19 @@ 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,
@ -367,16 +381,43 @@ 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.save(update_fields=['status'])
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 ""
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"手动停用子账号 {user.username}",
content=f"操作人: {request.user.username}",
content=f"操作人: {request.user.username},已移除 {policy_count} 个权限策略{error_info}",
)
return Response({'message': f'用户 {user.username} 已停用'})
msg = f'用户 {user.username} 已停用,{policy_count} 个权限策略已移除'
result = {'message': msg}
if detach_errors:
result['warnings'] = detach_errors
return Response(result)
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)
@ -396,16 +437,36 @@ 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.save(update_fields=['status'])
user.saved_policies_on_disable = []
user.save(update_fields=['status', 'saved_policies_on_disable'])
error_info = f",恢复失败: {restore_errors}" if restore_errors else ""
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"手动恢复子账号 {user.username}",
content=f"操作人: {request.user.username}",
content=f"操作人: {request.user.username},已恢复 {restored_count} 个权限策略{error_info}",
)
return Response({'message': f'用户 {user.username} 已恢复'})
msg = f'用户 {user.username} 已恢复,{restored_count} 个权限策略已恢复'
result = {'message': msg}
if restore_errors:
result['warnings'] = restore_errors
return Response(result)
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)
@ -499,6 +560,148 @@ 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'])

View File

@ -79,6 +79,26 @@ 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 = []

View File

@ -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, GlobalConfig, AlertRecord, SpendingRecord
"""定时检查所有子账号消费:遍历开启监测的项目,聚合消费,触发阶梯告警"""
from apps.monitor.models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord
from utils.crypto import decrypt
from utils.billing_service import BillingService
from utils.iam_service import IAMService
@ -19,6 +19,7 @@ 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)
@ -37,25 +38,50 @@ def check_spending():
for user in users:
try:
# 查询当月消费(按项目筛选)
bill_period = timezone.now().strftime("%Y-%m")
spending = billing.get_spending_by_project(
bill_period, user.project_name or None
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
enabled_projects = IAMUserProject.objects.filter(
iam_user=user, monitor_enabled=True
)
# 记录月度快照
SpendingRecord.objects.update_or_create(
iam_user=user, bill_period=bill_period,
defaults={'amount': spending},
)
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},
)
total_spending += proj_spending
# 更新子账号总消费
# 累计消费 = 所有月份的所有开启监测项目的消费之和
all_enabled_names = list(enabled_projects.values_list('project_name', flat=True))
from django.db.models import Sum
total = SpendingRecord.objects.filter(
iam_user=user
cumulative = SpendingRecord.objects.filter(
iam_user=user,
project_name__in=all_enabled_names,
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
user.consumed_total = total
user.consumed_total = cumulative
user.spending_updated_at = timezone.now()
quota = user.allocated_quota
@ -63,7 +89,7 @@ def check_spending():
user.save(update_fields=['consumed_total', 'spending_updated_at'])
continue
usage_percent = float(total) / float(quota) * 100
usage_percent = float(cumulative) / float(quota) * 100
triggered = user.triggered_alerts or []
# --- 阶梯式告警 ---
@ -72,16 +98,23 @@ 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"累计消费 ¥{total:.2f}"
f"累计消费 ¥{cumulative:.2f}"
f"已划拨额度 ¥{quota:.2f}{step}%\n"
f"剩余额度: ¥{user.remaining_quota:.2f}"
f"剩余额度: ¥{user.remaining_quota:.2f}\n"
f"项目明细:\n{detail_lines}"
),
spending_amount=total,
spending_amount=cumulative,
threshold_amount=threshold_amount,
notified=True,
)
@ -89,10 +122,12 @@ def check_spending():
webhook,
f"⚠️ {user.username} 消费达到额度 {step}%",
f"**用户**: {user.username}\n"
f"**累计消费**: ¥{total:.2f}\n"
f"**累计消费**: ¥{cumulative:.2f}\n"
f"**已划拨额度**: ¥{quota:.2f}\n"
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
f"**使用率**: {usage_percent:.1f}%",
f"**使用率**: {usage_percent:.1f}%\n"
f"**监测项目数**: {enabled_projects.count()}\n"
f"**项目明细**:\n{detail_lines}",
template="orange" if step < 90 else "red",
)
@ -108,15 +143,21 @@ 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"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
f"累计消费 ¥{cumulative:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
f"项目明细:\n{detail_lines}\n"
f"如需继续使用,请划拨新额度后恢复账号。"
),
spending_amount=total,
spending_amount=cumulative,
threshold_amount=quota,
notified=True,
)
@ -124,8 +165,9 @@ def check_spending():
webhook,
f"🚨 {user.username} 额度用尽,已自动停用",
f"**用户**: {user.username}\n"
f"**累计消费**: ¥{total:.2f}\n"
f"**累计消费**: ¥{cumulative:.2f}\n"
f"**已划拨额度**: ¥{quota:.2f}\n"
f"**项目明细**:\n{detail_lines}\n"
f"额度已用尽,账号已自动停用。\n"
f"请在 AirGate 划拨新额度后恢复。",
template="red",

View File

@ -1,7 +1,7 @@
version: '3.8'
services:
backend:
airgate-backend:
build: ./backend
ports:
- "8101:8100"
@ -15,12 +15,12 @@ services:
- backend-data:/app/data
restart: unless-stopped
frontend:
airgate-web:
build: ./frontend
ports:
- "5174:80"
depends_on:
- backend
- airgate-backend
restart: unless-stopped
volumes:

View File

@ -5,7 +5,7 @@ server {
index index.html;
location /api/ {
proxy_pass http://backend:8100;
proxy_pass http://airgate-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;

View File

@ -24,6 +24,10 @@
<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>

View File

@ -17,6 +17,7 @@ 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') },
],
},
]

View File

@ -0,0 +1,307 @@
<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>

View File

@ -24,10 +24,37 @@
<span>各子账号消费与额度</span>
</template>
<el-table :data="overview.users || []" stripe v-loading="loading" table-layout="auto"
:default-sort="{ prop: 'consumed_total', order: 'descending' }">
: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>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="display_name" label="显示名" min-width="100" />
<el-table-column prop="project_name" label="项目" min-width="120" />
<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="consumed_total" label="累计消费" min-width="110" sortable align="right">
<template #default="{ row }">
<span style="font-weight: 600; color: #e6a23c;">
@ -56,15 +83,6 @@
<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') : '暂无' }}

View File

@ -51,6 +51,13 @@
<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"
@ -69,6 +76,7 @@
</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>
@ -85,7 +93,7 @@
</el-card>
<!-- Allocate Dialog -->
<el-dialog v-model="allocateVisible" title="额度变更" width="480px">
<el-dialog v-model="allocateVisible" title="额度变更" width="90%" style="max-width: 520px;">
<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>
@ -125,20 +133,8 @@
</el-dialog>
<!-- Config Dialog -->
<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-dialog v-model="configVisible" title="监控配置" width="90%" style="max-width: 560px;">
<el-form :model="configForm" label-width="140px">
<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"
@ -148,9 +144,6 @@
</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>
@ -165,8 +158,62 @@
</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="600px">
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="90%" style="max-width: 800px;">
<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>
@ -187,7 +234,7 @@
</el-dialog>
<!-- Policies Dialog -->
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="650px">
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="90%" style="max-width: 850px;">
<div style="margin-bottom:12px; display:flex; gap:8px;">
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
<el-option-group label="常用策略">
@ -213,7 +260,7 @@
</el-dialog>
<!-- Create User Dialog -->
<el-dialog v-model="showCreate" title="创建子账号" width="520px">
<el-dialog v-model="showCreate" title="创建子账号" width="90%" style="max-width: 580px;">
<el-alert type="warning" :closable="false" style="margin-bottom:16px;"
description="创建后会在火山引擎生成 IAM 用户和 API 密钥。SecretKey 仅显示一次,请务必保存!" />
<el-form :model="createForm" label-width="110px">
@ -229,14 +276,18 @@
<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="选填,填了才开通控制台登录" />
placeholder="选填" />
<div style="font-size:12px;color:#999;margin-top:4px;">
火山引擎网页后台的登录密码不填则子账号无法登录火山网页后台仅能通过 API Key 使用服务
</div>
</el-form-item>
<el-form-item label="关联项目">
<el-select v-model="createForm.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" />
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" />
</el-select>
</el-form-item>
</el-form>
@ -247,7 +298,7 @@
</el-dialog>
<!-- Secret Key Display Dialog -->
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="520px" :close-on-click-modal="false">
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="90%" style="max-width: 580px;" :close-on-click-modal="false">
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
description="SecretAccessKey 仅此一次显示!关闭后无法再次获取,请立即复制保存。" />
<el-descriptions :column="1" border>
@ -295,10 +346,6 @@ 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)
@ -361,6 +408,16 @@ 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
@ -404,29 +461,106 @@ 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
if (projects.value.length === 0) loadProjects()
}
// --- Projects Dialog ---
async function loadVolcProjects() {
volcProjectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取火山项目列表失败')
} finally {
volcProjectsLoading.value = false
}
}
async function openProjectsDialog(row) {
projectsUser.value = row
projectsDialogVisible.value = true
projectToAdd.value = ''
await loadUserProjects(row.id)
if (volcProjects.value.length === 0) loadVolcProjects()
}
async function loadUserProjects(userId) {
projectsDialogLoading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${userId}/projects/`)
userProjects.value = data
} catch (e) {
ElMessage.error('获取项目列表失败')
userProjects.value = []
} finally {
projectsDialogLoading.value = false
}
}
async function handleAddProject() {
if (!projectToAdd.value) return
try {
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
project_name: projectToAdd.value,
policies: projectPoliciesToAttach.value,
})
const policyMsg = data.attached_policies?.length
? `,已授权 ${data.attached_policies.length} 个策略`
: ''
ElMessage.success(`已添加${policyMsg}`)
projectToAdd.value = ''
projectPoliciesToAttach.value = []
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function handleToggleProject(row, val) {
try {
await api.put(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/`, {
monitor_enabled: val,
})
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('切换失败')
}
}
async function handleRemoveProject(row) {
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
try {
await api.delete(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/delete/`)
ElMessage.success('已移除')
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('移除失败')
}
}
async function handleToggleAll(enable) {
try {
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/toggle-all/`, {
monitor_enabled: enable,
})
ElMessage.success(data.message)
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('操作失败')
}
}
function addStep() {

View File

@ -14,6 +14,12 @@
逗号分隔的百分比 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>
@ -88,6 +94,16 @@ 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) => {

View File

@ -0,0 +1,97 @@
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

34
k8s/ingress.yaml Normal file
View File

@ -0,0 +1,34 @@
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

59
k8s/web-deployment.yaml Normal file
View File

@ -0,0 +1,59 @@
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 Normal file
View File

@ -0,0 +1,212 @@
# 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 |
| 监控间隔(秒) | 定时查询消费的间隔 | 36001小时 |
| 飞书 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 无法解密,需要重新配置

View File

@ -31,7 +31,7 @@
|------|----------|--------|
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
| 子账号能看到自己的账单 | 通过 AirGate 按项目维度查询,主账号代查展示 | **部分可行**(见下方说明)|
| 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** |
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警50%/80%/90%+ 飞书通知 | **完全可行** |
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
@ -507,24 +507,31 @@ iam_client.call("UpdateAccessKey", {
> **核心问题**IAM 子账号没有独立的计费维度。不能直接按 IAM UserName 查询消费。
**可行方案:**
**AirGate 采用的方案:多项目聚合追踪**
| 方案 | 实现方式 | 精确度 |
|------|----------|--------|
| **按项目追踪** | 为每个子账号/部门创建独立项目,资源都放在项目中 | 高 |
| **按标签追踪** | 资源打上子账号标签(如 `owner=sub_user_1` | 高 |
| **按 Ark 端点追踪** | Seedance/方舟按 Endpoint 分账ListSplitBillDetail | 中 |
| **按 TOS 存储桶追踪** | TOS 按 Bucket 分账 | 高 |
**推荐方案:项目 + 标签 双维度**
一个子账号可以关联多个火山项目,每个项目有独立的监测开关。消费追踪按**所有开启监测的项目消费之和**计算。
```
1. 创建项目 "DeptA-Project"
2. 子账号的权限限定在该项目范围内 (AttachPolicyInProject)
3. 资源打标签 tag: {"department": "DeptA", "owner": "sub_user_1"}
4. 通过 ListBillDetail + ListSplitBillDetail 按项目/标签筛选消费
子账号 (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% 告警已触发
```
**典型使用场景:**
- 一个部门子账号下,每个团队各创建一个火山项目
- 每个项目下各有一个 Seedance 2.0 API 端点
- 管理员可按需开关某些项目的监测(如测试项目不计费)
- 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度
**消费查询方式:** 对每个开启监测的项目分别调用 `ListBillDetail`(按 Project 字段筛选),累加得出总消费。同时记录每个项目的独立消费,前端可展开查看明细。
### 6.4 账户余额查询
```python
@ -602,7 +609,9 @@ balance = billing_client.call("QueryBalanceAcct")
主账号通过 AirGate 给子账号划拨额度(如 10 万元)
▼ 定时任务每小时查询 Billing API
累计消费不断增长,对比已划拨额度
遍历子账号下所有开启监测的项目 → 分别查询消费 → 累加得出总消费
▼ 总消费对比已划拨额度
├── 消费达到额度 50% → 飞书告警
├── 消费达到额度 80% → 飞书告警
@ -614,6 +623,9 @@ balance = billing_client.call("QueryBalanceAcct")
```
**关键设计:**
- **多项目聚合**:一个子账号可关联多个火山项目,每个项目有独立监测开关。消费 = 所有开启监测的项目消费之和
- **项目即权限**:添加项目时自动调用 `AttachPolicyInProject` 在项目范围内授权,移除项目时自动回收权限。子账号只能操作被授权的项目,碰不到其他人的资源。**添加项目时授权哪些策略由管理员在弹窗中手动选择**(从下拉列表选,支持多选,默认不预选任何策略),避免系统自动附加不需要的权限
- **项目明细可查**:前端可展开查看每个项目的独立消费,便于分析哪个团队/项目花得多
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
- **可追加可扣减**:主账号可随时追加额度(+5万或扣减额度-3万支持灵活调整
- **扣减保护**:扣减后总额度不能低于已消费金额(否则会立即触发停用)
@ -628,18 +640,21 @@ 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:
@ -659,14 +674,38 @@ def disable_sub_user(iam_client, username: str, access_key_ids: list = None):
"UserName": username
})
print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个 API 密钥)")
# 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)} 个策略已移除)")
```
### 8.2 一键恢复子账号
```python
def enable_sub_user(iam_client, username: str, access_key_ids: list = None):
"""一键恢复子账号"""
def enable_sub_user(iam_client, username: str, access_key_ids: list = None,
saved_policies: list = None):
"""一键恢复子账号
三步恢复(与停用操作完全对称):
1. 恢复控制台登录
2. 恢复所有 API 密钥
3. 重新附加停用时保存的权限策略
"""
# 0. 如果未传入 access_key_ids自动查询
if access_key_ids is None:
@ -686,7 +725,18 @@ def enable_sub_user(iam_client, username: str, access_key_ids: list = None):
"UserName": username
})
print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个 API 密钥)")
# 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 [])} 个策略已恢复)")
```
### 8.3 停用 vs 删除的区别
@ -1002,10 +1052,17 @@ def get_user_spending(bill_period: str, project_name: str = None) -> float:
**AirGate 实现的核心流程:**
1. 主账号通过界面给子账号**划拨额度**(如 10 万元)
2. 定时任务每小时调用 Billing API 查询累计消费
3. 消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
4. 消费达到 100% → 自动停用子账号 + 飞书告警
5. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
2. 子账号下挂多个火山项目,每个项目有独立监测开关
3. 定时任务每小时遍历所有开启监测的项目,分别调用 Billing API 查询消费,累加得出总消费
4. 总消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
5. 总消费达到 100% → 自动停用整个子账号 + 飞书告警
6. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
**多项目管理:**
- 每个子账号可关联 N 个火山项目
- 每个项目有独立的监测开关(开/关)
- 可通过"全选"一键开启/关闭所有项目的监测
- 消费明细可按项目展开查看,但告警和停用看的是**所有开启项目的消费总和**
**告警状态管理:**
- 每个阶梯只通知一次,通过 `triggered_alerts` 字段(存数据库)去重
@ -1034,6 +1091,15 @@ 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/ # 追加额度(正数)或扣减额度(负数)
@ -1063,7 +1129,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
| 限制项 | 说明 |
|--------|------|
| IAM 子账号无独立计费 | 所有费用归主账号,需通过项目/标签追踪 |
| IAM 子账号无独立计费 | 所有费用归主账号,通过多项目聚合追踪(子账号关联 N 个项目,消费=开启监测的项目之和) |
| Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 |
| 每用户最多 2 个 API 密钥 | 无法创建更多 |
| SecretKey 仅返回一次 | 创建后立即保存 |

104
版本管理.md Normal file
View File

@ -0,0 +1,104 @@
# 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、策略冲突、双重编码等