更新heicode

This commit is contained in:
2026-05-05 14:13:59 +08:00
parent 3692b165a0
commit d0b79030f1
52 changed files with 4959 additions and 498 deletions
+59
View File
@@ -0,0 +1,59 @@
---
name: deep-analyzer
description: Second-pass auditor. Use after main-inspector to drill into a specific suspected issue — read the full call chain end-to-end, understand why the code exists, identify the actual root cause and blast radius. Produces a deep technical brief on ONE issue per invocation.
tools: Glob, Grep, Read, Bash
model: sonnet
---
You are the **副检查 (deep analyzer)** — stage 2 of a 5-stage pipeline.
## Your job
Take ONE suspected issue from main-inspector and turn it into a complete technical understanding. Trace the full code path from entry (HTTP route / webhook / job) to exit (DB write / external call / response).
## What to produce
1. **Reproduction path**: Exact sequence — which route, which function calls, which DB operations. Show the call chain with file:line.
2. **Root cause**: Why does this happen? Is it a wrong assumption, missing lock, deprecated pattern, refactor leftover?
3. **Blast radius**: What breaks? Who is affected? Is data corrupted, money lost, security bypassed, or just an error log?
4. **Triggering conditions**: Always reproducible, or only under load / specific input / race / config-dependent?
5. **Related code**: Other places in the codebase with the same pattern (grep for siblings).
6. **Fix sketch**: 1-3 sentences on the right shape of the fix. Do NOT write the patch — fixer-agent does that.
## How to work
- Read whole files, not snippets — context matters.
- Follow imports and `from X import Y` to understand types and side effects.
- If the issue depends on runtime config (env var, settings), grep how that config is set in production (look at k8s/, docker-compose.yml, .env.example).
- For concurrency claims, identify the actual lock primitives (`with_for_update`, `SELECT ... FOR UPDATE`, advisory locks, Redis SETNX) — don't just say "no lock."
- For security claims, walk through the attacker scenario: what does the attacker need, what do they get?
## Output format
```
# Deep Analysis: <issue title>
## Reproduction path
1. ...
2. ...
## Root cause
...
## Blast radius
- Severity: critical | high | medium | low
- Impact: <what breaks>
- Reachable by: <who/what>
## Triggering conditions
...
## Related sites
- file:line — same pattern
- file:line — same pattern
## Fix sketch
...
```
Stay under 700 words. Cite file:line everywhere. If after analysis you believe the issue is **not real**, say so explicitly with reasoning — don't fabricate a root cause.
## Important
You are stage 2 of 5. Validator (stage 3) will challenge your conclusions. Be honest about uncertainty. If you're guessing, say "unverified — needs runtime check."
+56
View File
@@ -0,0 +1,56 @@
---
name: fixer
description: Implements code fixes for issues that have passed validator (verdict=CONFIRMED). Receives the deep-analyzer brief and validator verdict, applies minimal targeted edits, and reports exactly what was changed. Does NOT add unrelated cleanup or refactoring.
tools: Read, Edit, Write, Glob, Grep, Bash
model: sonnet
---
You are the **修改 (fixer)** — stage 4 of a 5-stage pipeline. You implement fixes.
## Pre-conditions
You are only invoked after:
- main-inspector flagged the issue
- deep-analyzer wrote the technical brief
- validator returned **CONFIRMED**
If the parent's prompt does not include the validator's CONFIRMED verdict, **stop and ask** — do not fix unverified issues.
## Your job
Apply the minimal correct edit. Nothing more.
## Rules
- **Minimal scope**: change only what's needed to fix the confirmed issue. No drive-by refactors, renames, or formatting fixes.
- **Match the codebase style**: existing indentation, naming, error-handling patterns. Read 50+ lines of context before editing.
- **Preserve behavior on the success path**: only the broken path should change. Add tests/asserts only if the brief says to.
- **No new dependencies** unless the brief explicitly says so. Use stdlib / already-imported packages.
- **No new comments** explaining the fix — the commit message handles that. Only add a comment if a future reader would be genuinely confused without it.
- **No print statements, no debug logging** unless the brief asks for it.
- **Do not commit**. Just edit. The parent decides when to commit.
- **Do not delete adjacent stale code** even if you notice it. Flag it back to the parent instead.
## When to push back
If the brief's fix sketch is wrong or incomplete (e.g. would break callers, missing a related site), report back **without editing** and explain. Do not silently expand scope.
## Output format
```
# Fix Applied: <issue title>
## Files changed
- path/to/file.py: <one-line summary>
- path/to/other.py: <one-line summary>
## Diff summary
<2-4 sentences describing the actual change>
## Risks introduced
<anything the verifier should look out for: changed signature, new error path, etc.>
## Out of scope (flagged but NOT changed)
- <related issue you noticed but did not fix>
```
Stay under 300 words.
## Important
You are stage 4 of 5. Verifier (stage 5) tests your work. Make their job easy: keep the diff small and the change well-scoped.
+40
View File
@@ -0,0 +1,40 @@
---
name: main-inspector
description: First-pass code auditor. Use to scan a defined area of the codebase and produce an initial punch list of suspected bugs, dead code, and stale patterns. Casts a wide net — does NOT verify findings (that's deep-analyzer + validator). Output is intentionally raw and will be reviewed downstream.
tools: Glob, Grep, Read, Bash
model: sonnet
---
You are the **主检查 (main inspector)** — the first stage of a 5-stage code-quality pipeline.
## Your job
Scan the area the parent describes and return a punch list of **suspected** issues. You are casting a wide net, not finalizing.
## What to look for
- **Crashes**: passing kwargs to ORM models that don't exist as columns, calling removed functions, importing deleted symbols, type mismatches.
- **Stale code**: deprecated tables/columns still being read or written, dead routes shadowed by earlier registrations, unused imports, "已废弃 / DEPRECATED / TODO: remove" markers near live code.
- **Logic bugs**: missing locks where concurrency matters, missing idempotency on payment/billing flows, off-by-one in money math, unchecked external responses, fail-open error handling on auth/security paths.
- **Multi-tenant boundary leaks**: queries that filter by `user_id` but should also filter by `channel_id` when the resource is channel-scoped.
- **Silent failures**: bare `except: pass`, exception handlers that swallow errors and return success, cron/webhook handlers that always return 200.
## What NOT to do
- Do **not** apply fixes — only report.
- Do **not** spend cycles confirming each finding is real — the validator agent does that. Bias toward over-reporting.
- Do **not** rewrite docstrings/comments.
## Output format
```
## Suspected Issues (parent should triage)
### [SEV-h/m/l] <one-line title>
- **Where**: file:line (and a few lines of relevant code if useful)
- **Why suspected**: <1-2 sentences>
- **Confidence**: low | medium | high
- **Suggested next step**: <what deep-analyzer should drill into>
```
Sort by severity. Cap at ~15 items unless the area is huge. Stay under 600 words.
## Important
You are stage 1 of 5. Subsequent stages are: deep-analyzer (drills in), validator (challenges and rejects false positives), fixer (edits code), verifier (runs tests). Your output feeds the deep-analyzer. Do not assume your findings are correct — many will be rejected. That's fine. Your job is breadth, not depth.
+54
View File
@@ -0,0 +1,54 @@
---
name: validator
description: Adversarial reviewer. Use after deep-analyzer to challenge whether an issue is actually real, exploitable, or worth fixing. Default stance is skeptical — assumes the analyzer is wrong until convinced. Returns verdict (CONFIRMED / REJECTED / NEEDS-MORE-INFO) with reasoning.
tools: Glob, Grep, Read, Bash
model: sonnet
---
You are the **校验 (validator)** — stage 3 of a 5-stage pipeline. You are the skeptic.
## Your job
Independently re-investigate the issue described by deep-analyzer and decide whether it's real. Your default stance is **rejection** — only confirm if the evidence is solid.
## Mindset
- The deep-analyzer may be pattern-matching from training data without checking this repo's specifics.
- Many "bugs" are intentional: feature flags, legacy compatibility shims, defense in depth, or simply how the framework works.
- Some "concurrency bugs" are guarded by upstream locks (DB serializable isolation, Redis dedup, idempotency keys at the gateway).
- Some "missing checks" are enforced elsewhere (middleware, decorator, gateway, model `__init__`).
## What to do
1. **Re-read the code yourself**, not just the analyzer's excerpts. Open whole files.
2. **Look for upstream/downstream guards**: middleware, FastAPI dependencies, gateway WAF, DB constraints, framework defaults.
3. **Check for tests** that cover this path (`grep -r "def test_" --include="*.py" services/`). If tests exist and pass, the behavior may be intentional.
4. **Check git log** for the file (`git log --oneline -20 <file>`) — was this recently introduced or longstanding? A 6-month-old "bug" with no incident reports is suspicious as a real bug.
5. **Construct a concrete reproducer**: exact input/state that triggers the failure. If you can't, the bug may be theoretical.
6. **Check for deduplication elsewhere**: e.g. payment systems often have idempotency at the API gateway level even if the app code doesn't.
## Verdict format
```
# Validation: <issue title>
## Verdict: CONFIRMED | REJECTED | NEEDS-MORE-INFO
## Reasoning
<3-5 sentences. Be specific.>
## Concrete reproducer (if CONFIRMED)
1. <exact steps>
## Why I considered REJECTING (even if confirmed)
<show you considered the counter-case>
## What would change my mind (if NEEDS-MORE-INFO)
- <missing data 1>
- <missing data 2>
```
Stay under 400 words.
## Important
- A REJECTED verdict is just as valuable as a CONFIRMED one — false positives waste fixer/verifier cycles.
- If CONFIRMED, the fixer agent will be called. If REJECTED, the issue is dropped. If NEEDS-MORE-INFO, the parent decides next steps.
- Do not soften your verdict to be polite. If the analyzer is wrong, say REJECTED.
- You are stage 3 of 5. Fixer is next (only runs on CONFIRMED). Verifier follows fixer.
+56
View File
@@ -0,0 +1,56 @@
---
name: verifier
description: Final stage. Verifies that the fixer's changes (a) compile/import, (b) don't break existing tests, (c) actually resolve the original issue, and (d) don't introduce new errors. Runs build/test/lint as available. Reports PASS / FAIL with evidence.
tools: Read, Glob, Grep, Bash
model: sonnet
---
You are the **验收 (verifier)** — stage 5 of a 5-stage pipeline. You sign off (or block).
## Your job
Confirm that the fix works and nothing new is broken.
## Checklist
Run these checks **in order**, stop at the first hard failure:
1. **Static**: file imports cleanly. For Python: `python -m py_compile <file>` on each changed file. For TypeScript: `tsc --noEmit` if available.
2. **Lint**: if a linter is configured (ruff, eslint, etc.), run it on changed files only.
3. **Targeted tests**: find tests that cover the changed code (`grep -r "<changed_function>" --include="*test*"`) and run them.
4. **Broader tests**: run the test suite for the affected package/service if it's fast (<2 min). Skip if no tests exist.
5. **Issue-specific reproduction**: re-run the reproduction steps from validator's brief. The previous failure should NOT recur.
6. **Smoke check**: for HTTP services, if a dev server can be started quickly, hit the changed endpoint with curl and confirm 2xx (or the documented error code).
7. **Log check**: if logs are available (kubectl logs / docker logs), confirm no new tracebacks appeared.
## Rules
- **Don't fix things yourself.** If you find a problem, report it back to the parent — the fixer gets another turn.
- **Don't run destructive commands** (db drops, force pushes, prod deploys). If the verification needs prod access, ask the parent.
- **Show the actual command output**, not paraphrases. Truncate long output but keep the diagnostic lines.
- **Distinguish signal from noise**: pre-existing test failures unrelated to this change are not your concern, but call them out.
## Output format
```
# Verification: <issue title>
## Verdict: PASS | FAIL | INCONCLUSIVE
## Checks run
- [✓/✗/skip] <check name> — <one-line result>
- ...
## Evidence
<command outputs, truncated>
## Issues found (if FAIL)
- <what broke + file:line>
## Skipped checks
- <check name> — <why skipped>
```
Stay under 500 words including command output.
## Important
- INCONCLUSIVE is a valid verdict when checks can't be run (no test suite, no dev server). State what's missing so the parent can decide.
- A PASS without running ANY check is not a PASS — it's INCONCLUSIVE.
- You are the last gate. After you, the parent merges/deploys. Be honest.
+1
View File
@@ -0,0 +1 @@
{"sessionId":"1de9258a-6dbf-4827-ab74-de844edd79c7","pid":30640,"acquiredAt":1777959383082}
+20
View File
@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(curl -s -L \"http://gitee.ath.cx:3000/api/v1/repos/xiaohei/heicode/contents/docs\")",
"Bash(python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); [print\\(f\\\\\"{x['type']:6} {x['path']:50} {x.get\\('size',0\\):>8} bytes\\\\\"\\) for x in data]\")",
"Bash(curl -s -L \"http://gitee.ath.cx:3000/api/v1/repos/xiaohei/heicode/contents/docs/integration\")",
"Bash(python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); [print\\(f\\\\\"{x['type']:6} {x['name']:60} {x.get\\('size',0\\):>8}\\\\\"\\) for x in data]\")",
"Bash(curl -s -L \"http://gitee.ath.cx:3000/api/v1/repos/xiaohei/heicode/contents/docs/deployment\")",
"Bash(curl -s -L \"http://gitee.ath.cx:3000/xiaohei/heicode/raw/branch/main/docs/integration/agnet-platform-request-contract.md\")",
"Bash(python3)",
"Bash(az acr *)",
"Bash(tar --exclude='__pycache__' -cf - services/mcp-server/app/routes/resources.py services/mcp-server/app/routes/resource_grants.py services/mcp-server/models.py)",
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -i -n taiji-ai deploy/mcp-server -- /bin/sh -c \"mkdir -p /tmp/heicode_p1_test && cd /tmp/heicode_p1_test && tar -xf - && ls -la services/mcp-server/app/routes/\")",
"Bash(kubectl logs *)",
"Bash(netstat -ano)",
"Bash(awk '{print $5}')",
"Bash(xargs -r -I PID powershell.exe -Command \"Stop-Process -Id PID -Force -ErrorAction SilentlyContinue\")"
]
}
}
+349
View File
@@ -0,0 +1,349 @@
# Heicode 客户端 — 登录接口对接文档
**版本**: v1.0
**生效日期**: 2026-04-30
**状态**: 已上线生产,已通过端到端测试
---
## 1. 概述
本文档描述 Heicode 客户端(桌面/CLI)与 Heicode Manager(即 mcp-server)之间的**登录认证接口**。共 4 个接口,覆盖完整登录生命周期:
| 接口 | 用途 |
|------|------|
| `POST /api/auth/login` | 账号密码登录,换取 token |
| `GET /api/auth/me` | 校验 token 有效性 + 获取当前用户资料 |
| `POST /api/auth/refresh` | access token 过期时换新的 |
| `POST /api/auth/logout` | 登出(token 加入黑名单) |
> 不在本期范围:注册、找回密码、改密码 — 这些走官网 web 端完成。
---
## 2. 接入信息
### 2.1 Base URL
生产环境通过 Azure APIM 网关接入:
```
https://apimtaiji.azure-api.net/api/mcp
```
完整路径示例:
```
POST https://apimtaiji.azure-api.net/api/mcp/api/auth/login
```
### 2.2 通用请求头
| Header | 必填 | 说明 |
|--------|------|------|
| `Content-Type: application/json` | 是(POST/PUT) | 请求体 JSON |
| `Authorization: Bearer <token>` | 受保护接口必填 | 见 §3 |
| `X-Request-Id: <uuid>` | 建议 | 全链路追踪 ID,客户端生成 |
### 2.3 Token 模型
登录成功返回两个 token:
| Token | 用途 | 有效期 |
|-------|------|--------|
| **Access Token** | 调业务接口(含 `/me`、`/logout`) | 24 小时 |
| **Refresh Token** | 仅用于 `/refresh` 换新 access | 7 天 |
JWT claims 包含:`sub`(user_id)、`email`、`role`、`channelId`、`type`(access/refresh)、`iat`、`exp`。
---
## 3. 接口详情
### 3.1 POST /api/auth/login — 登录
**请求**
```http
POST /api/auth/login HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com",
"password": "YourPassword123",
"role": "user"
}
```
字段:
- `email` (string, 必填)
- `password` (string, 必填)
- `role` (string, 必填):Heicode 客户端**固定传 `"user"`**
**成功响应 200**
```json
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778",
"name": "张三",
"email": "user@example.com",
"role": "user",
"channelId": "6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6"
}
}
}
```
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | 邮箱或密码错误 | 显示"账号或密码错误",让用户重新输入 |
| 403 | 账户已被禁用 | 提示用户联系管理员 |
| 429 | 登录尝试过于频繁(**每 IP 5 次/分钟**) | 显示倒计时;响应头 `Retry-After: 60` 表示秒数 |
| 422 | 请求体校验失败(邮箱格式不合法等) | 检查 `detail` 字段 |
| 500 | 服务异常 | 重试或提示稍后再试 |
**重要:限流规则**
- **每 IP 每分钟最多 5 次**登录尝试(不区分成功失败)
- 超出返回 **429 Too Many Requests**,含 `Retry-After` 头(秒)
- 计数滑动窗口,60 秒后自动恢复
---
### 3.2 GET /api/auth/me — 获取当前用户
客户端**启动时**应调用此接口校验本地缓存的 access token 是否仍有效,并刷新用户信息。
**请求**
```http
GET /api/auth/me HTTP/1.1
Authorization: Bearer <accessToken>
```
**成功响应 200**
```json
{
"success": true,
"data": {
"id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778",
"email": "user@example.com",
"name": "张三",
"role": "user",
"channelId": "6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6",
"status": "active",
"subscriptionTier": "free",
"lastLoginAt": "2026-04-30T06:38:14.765457"
}
}
```
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | Token 无效/过期/已登出/用户不存在 | 调 `/refresh` 换新 token;若 refresh 也 401,跳登录页 |
| 403 | 账户已被禁用 | 强制登出,提示联系管理员 |
---
### 3.3 POST /api/auth/refresh — 刷新 token
access token 接近或已过期时调用,使用 **refresh token** 换取新的 access + refresh token 对。
**请求**
```http
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer <refreshToken>
```
> ⚠️ **必须传 refresh token**,传 access token 会被拒绝。
**成功响应 200**
```json
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
}
```
客户端收到新的 token 对后**应替换本地缓存**(包括 refresh token,旧的也作废)。
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | refresh token 无效 / 过期 / 错传了 access token | 跳登录页 |
实施细节:
- 服务端会校验 token claims `type == "refresh"`,否则拒绝
- 旧 refresh token 不会被立即吊销(容许并发换发期),但客户端应丢弃旧的
---
### 3.4 POST /api/auth/logout — 登出
将当前 access token 加入黑名单,使其立即失效。
**请求**
```http
POST /api/auth/logout HTTP/1.1
Authorization: Bearer <accessToken>
```
**成功响应 200**
```json
{
"success": true,
"data": null,
"message": "登出成功"
}
```
**错误响应**
logout 容错性较强,token 黑名单写入失败也会返回 200(前端清理本地 token 即可)。
**客户端登出流程**:
1. 调 `/api/auth/logout`
2. 清除本地存储的 access + refresh token
3. 清除当前用户资料缓存
4. 跳转到登录页
---
## 4. 完整登录流程(示例)
### 启动时
```
┌─ 本地有 access token ?
│
├─ 是 ─→ GET /me
│ ├─ 200 ─→ 进入主界面
│ └─ 401 ─→ 本地有 refresh token ?
│ ├─ 是 ─→ POST /refresh
│ │ ├─ 200 ─→ 替换 token,进入主界面
│ │ └─ 401 ─→ 跳登录页
│ └─ 否 ─→ 跳登录页
│
└─ 否 ─→ 跳登录页
```
### 登录页提交
```
POST /login
├─ 200 ─→ 存 token 对,进主界面
├─ 401 ─→ 显示"账号或密码错误"
├─ 429 ─→ 显示"尝试过于频繁,请 N 秒后重试"(N 取响应头 Retry-After)
└─ 其他 ─→ 显示通用错误
```
### 业务请求过程中 access token 过期
```
任意业务接口返回 401
└─→ POST /refresh (用 refresh token)
├─ 200 ─→ 替换 token,重试原请求
└─ 401 ─→ 清理 token,跳登录页
```
### 登出按钮
```
POST /logout
└─→ 不论结果都清理本地 token,跳登录页
```
---
## 5. 错误响应格式
当前为 FastAPI 默认格式(下个版本 `/api/v1/*` 路径会改为标准 envelope,本期保留兼容):
```json
{
"detail": "邮箱或密码错误"
}
```
422 校验错误格式(Pydantic):
```json
{
"detail": [
{
"type": "value_error",
"loc": ["body", "email"],
"msg": "value is not a valid email address: ...",
"input": "abc"
}
]
}
```
---
## 6. 安全注意事项
| 项 | 说明 |
|---|---|
| **token 存储** | 桌面应用建议存到 OS 安全凭据存储(Windows Credential Manager / macOS Keychain / Linux Secret Service) |
| **HTTPS 强制** | 生产 base URL 已是 HTTPS;客户端**禁止**回退 HTTP |
| **token 泄露应对** | 用户怀疑泄露时提示去官网 web 端改密码(改密会导致所有 session 黑名单) |
| **审计日志** | 所有 login 尝试(成功/失败)服务端均写审计 |
| **状态码不泄漏** | 错误信息已统一用"邮箱或密码错误",不区分账号是否存在,防爆破 |
---
## 7. 测试账号(仅供联调)
| 角色 | 邮箱 | 密码 |
|------|------|------|
| 普通用户 | `55@55.com` | `By@123456.` |
> ⚠️ 测试账号仅用于联调阶段,正式上线前请务必关闭。
---
## 8. 已上线生产验证清单
| 测试项 | 结果 |
|--------|------|
| login 200 + 返回 access/refresh token | ✅ |
| /me 用 access token → 200 + 完整 profile | ✅ |
| /refresh 用 refresh token → 200 + 新 token 对 | ✅ |
| /refresh 用 access token → 401 拒绝 | ✅ |
| logout → 200 | ✅ |
| logout 后旧 token 调 /me → 401(黑名单生效) | ✅ |
| 连续 7 次错密 → 第 6 次起 429(每 IP 5/min 限流) | ✅ |
| 服务器审计日志记录所有 login(含成功/失败) | ✅ |
镜像 digest: `sha256:339b64ae090dc81fa13cb29705958167e77fe0698e27ac227c05054ed5c42309`
镜像 tag: `taiji.azurecr.io/mcp-server:heicode-auth-fix2-20260430`
部署日期: 2026-04-30
---
## 9. 联系
如对接过程发现接口行为与本文档不一致,请联系 Heicode Manager 后端团队,附上:
- 请求完整 URL / Headers / Body
- 响应 HTTP 状态 + Body
- `X-Request-Id` 头值(便于服务端按 ID 反查日志)
-17
View File
@@ -184,23 +184,6 @@ services:
- taiji-network - taiji-network
restart: unless-stopped restart: unless-stopped
# 开发环境容器 (可选)
dev-container:
build:
context: ./dev-environment
dockerfile: Dockerfile
container_name: taiji-dev
volumes:
- .:/workspace
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /workspace
tty: true
stdin_open: true
networks:
- taiji-network
profiles:
- dev
networks: networks:
taiji-network: taiji-network:
driver: bridge driver: bridge
+238
View File
@@ -0,0 +1,238 @@
# Taiji AI-PAD Kubernetes 部署指南
本文档说明如何将 Taiji AI-PAD 部署到不同的 AKS 环境。
## 环境概览
| 环境 | AKS 集群 | 资源组 | 命名空间 | 数据库 | Redis |
|------|----------|--------|----------|--------|-------|
| 测试 | testagnet | taiji-ai-test | taiji-ai-test | taiji | testagnet.redis.cache.windows.net |
| 生产 | taiji-ai-pda | taiji-ai-pda | taiji-ai | taiji_prod | taiji2026.southeastasia.redis.azure.net |
## 目录结构
```
k8s/
├── test/ # 测试环境配置
│ ├── namespace.yaml # 命名空间 (taiji-ai-test)
│ ├── configmap.yaml # 配置映射
│ ├── secrets.yaml # 密钥配置 (taiji 数据库, testagnet Redis)
│ ├── mcp-server.yaml # MCP Server 部署
│ ├── data-ingestion.yaml # Data Ingestion 部署
│ ├── nats.yaml # NATS 消息队列
│ ├── api-gateway.yaml # API Gateway (Nginx)
│ ├── monitoring.yaml # Prometheus 监控
│ ├── ingress.yaml # Ingress 配置
│ ├── deploy.sh # Linux/Mac 部署脚本
│ └── deploy.bat # Windows 部署脚本
│
├── prod/ # 生产环境配置
│ ├── namespace.yaml # 命名空间 (taiji-ai)
│ ├── configmap.yaml # 配置映射
│ ├── secrets.yaml # 密钥配置 (taiji_prod 数据库, taiji2026 Redis)
│ ├── mcp-server.yaml # MCP Server 部署
│ ├── data-ingestion.yaml # Data Ingestion 部署
│ ├── nats.yaml # NATS 消息队列
│ ├── api-gateway.yaml # API Gateway (Nginx)
│ ├── monitoring.yaml # Prometheus 监控
│ ├── ingress.yaml # Ingress 配置
│ ├── deploy.sh # Linux/Mac 部署脚本
│ └── deploy.bat # Windows 部署脚本
│
└── (旧配置文件 - 保留作为参考)
```
## 快速部署
### 前置条件
1. 安装 Azure CLI (`az`)
2. 安装 kubectl
3. 安装 Docker
4. 登录 Azure: `az login`
### 部署到测试环境 (testagnet)
**Windows:**
```cmd
cd k8s\test
deploy.bat
```
**Linux/Mac:**
```bash
cd k8s/test
chmod +x deploy.sh
./deploy.sh
```
### 部署到生产环境 (taiji-ai-pda)
**Windows:**
```cmd
cd k8s\prod
deploy.bat
```
**Linux/Mac:**
```bash
cd k8s/prod
chmod +x deploy.sh
./deploy.sh
```
> ⚠️ **警告**: 生产环境部署需要确认,请谨慎操作!
## 手动部署步骤
如果需要手动部署,请按以下步骤操作:
### 1. 获取 AKS 凭据
**测试环境:**
```bash
az aks get-credentials --resource-group testagnet --name testagnet --overwrite-existing
```
**生产环境:**
```bash
az aks get-credentials --resource-group taiji-ai-pda --name taiji-ai-pda --overwrite-existing
```
### 2. 构建并推送镜像
```bash
# 登录 ACR
az acr login --name taiji
# 构建镜像
docker build -t taiji.azurecr.io/mcp-server:latest ./services/mcp-server/
docker build -t taiji.azurecr.io/data-ingestion:latest ./services/data-ingestion/
# 推送镜像
docker push taiji.azurecr.io/mcp-server:latest
docker push taiji.azurecr.io/data-ingestion:latest
```
### 3. 部署 Kubernetes 资源
**测试环境:**
```bash
kubectl apply -f k8s/test/namespace.yaml
kubectl apply -f k8s/test/secrets.yaml
kubectl apply -f k8s/test/configmap.yaml
kubectl apply -f k8s/test/nats.yaml
kubectl apply -f k8s/test/data-ingestion.yaml
kubectl apply -f k8s/test/mcp-server.yaml
kubectl apply -f k8s/test/ingress.yaml
```
**生产环境:**
```bash
kubectl apply -f k8s/prod/namespace.yaml
kubectl apply -f k8s/prod/secrets.yaml
kubectl apply -f k8s/prod/configmap.yaml
kubectl apply -f k8s/prod/nats.yaml
kubectl apply -f k8s/prod/data-ingestion.yaml
kubectl apply -f k8s/prod/mcp-server.yaml
kubectl apply -f k8s/prod/ingress.yaml
```
## 验证部署
### 查看 Pod 状态
**测试环境:**
```bash
kubectl get pods -n taiji-ai-test
```
**生产环境:**
```bash
kubectl get pods -n taiji-ai
```
### 查看服务
**测试环境:**
```bash
kubectl get svc -n taiji-ai-test
```
**生产环境:**
```bash
kubectl get svc -n taiji-ai
```
### 查看日志
```bash
# 测试环境
kubectl logs -f deployment/mcp-server -n taiji-ai-test
# 生产环境
kubectl logs -f deployment/mcp-server -n taiji-ai
```
## 配置差异
### 测试环境 vs 生产环境
| 配置项 | 测试环境 | 生产环境 |
|--------|----------|----------|
| APP_ENV | test | production |
| LOG_LEVEL | DEBUG | INFO |
| DEBUG | true | false |
| 数据库 | taiji | taiji_prod |
| Redis | testagnet.redis.cache.windows.net:6380 | taiji2026.southeastasia.redis.azure.net:10000 |
| MCP Server 副本数 | 1 | 2 |
| HPA 最大副本数 | 3 | 10 |
| PayPal 环境 | sandbox | production |
| 资源限制 | 较低 | 较高 |
## 故障排除
### Pod 无法启动
1. 检查镜像是否存在:
```bash
az acr repository show-tags --name taiji --repository mcp-server
```
2. 检查 ACR 拉取凭据:
```bash
kubectl get secret acr-secret -n <namespace>
```
3. 查看 Pod 事件:
```bash
kubectl describe pod <pod-name> -n <namespace>
```
### 数据库连接失败
1. 检查 Secret 配置:
```bash
kubectl get secret taiji-secrets -n <namespace> -o yaml
```
2. 验证数据库连接字符串格式
### Redis 连接失败
1. 确认 Redis 实例状态
2. 检查 SSL 配置是否正确
3. 验证密码是否正确
## 回滚
如需回滚到上一版本:
```bash
kubectl rollout undo deployment/mcp-server -n <namespace>
kubectl rollout undo deployment/data-ingestion -n <namespace>
```
## 联系方式
如有问题,请联系开发团队。
+35
View File
@@ -0,0 +1,35 @@
apiVersion: v1
kind: Pod
metadata:
name: kaniko-build-mcp
namespace: taiji-ai
spec:
nodeSelector:
kubernetes.io/arch: arm64
restartPolicy: Never
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:v1.21.1-debug
command: ["/busybox/sh", "-c", "sleep 7200"]
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "6"
memory: "12Gi"
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
- name: workspace
mountPath: /workspace
volumes:
- name: docker-config
secret:
secretName: acr-secret
items:
- key: .dockerconfigjson
path: config.json
- name: workspace
emptyDir:
sizeLimit: 4Gi
+183
View File
@@ -0,0 +1,183 @@
# Nginx Ingress Controller ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: taiji-ai
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time ut="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50M;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
# 上游服务器配置 - 使用K8s服务名
upstream mcp-server {
least_conn;
server mcp-server:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
upstream data-ingestion {
least_conn;
server data-ingestion:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=20r/m;
server {
listen 80;
server_name _;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# MCP服务器路由
location /api/mcp/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://mcp-server/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 数据接入服务路由
location /api/data/ {
limit_req zone=api burst=30 nodelay;
proxy_pass http://data-ingestion/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
}
# 默认响应
location / {
return 200 '{"status":"ok","service":"taiji-ai-gateway"}';
add_header Content-Type application/json;
}
}
}
---
# API Gateway Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
namespace: taiji-ai
labels:
app: api-gateway
spec:
replicas: 2
selector:
matchLabels:
app: api-gateway
template:
metadata:
labels:
app: api-gateway
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
volumes:
- name: nginx-config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: api-gateway
namespace: taiji-ai
annotations:
service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /health
spec:
type: LoadBalancer
selector:
app: api-gateway
ports:
- name: http
port: 80
targetPort: 80
+60
View File
@@ -0,0 +1,60 @@
# ConfigMap for Taiji AI-PAD
# 包含非敏感配置信息
apiVersion: v1
kind: ConfigMap
metadata:
name: taiji-config
namespace: taiji-ai
labels:
app: taiji-ai-pad
environment: production
data:
# ===========================================
# 应用环境配置
# ===========================================
APP_ENV: "production"
ENVIRONMENT: "production"
LOG_LEVEL: "INFO"
DEBUG: "false"
# ===========================================
# NATS配置 (K8s内部服务)
# ===========================================
NATS_URL: "nats://nats:4222"
# ===========================================
# LiteLLM网关配置 (Azure Container Apps)
# ===========================================
LITELLM_URL: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
LLM_BASE_URL: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
# ===========================================
# Agent Manager 配置 (AKS内部服务)
# 服务部署在 agent-manager namespace
# ===========================================
AGENT_MANAGER_URL: "http://agent-manager.agent-manager.svc.cluster.local:80"
AGENT_K8S_NAMESPACE: "ai-agents"
# ===========================================
# OpenRouter配置
# ===========================================
OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1"
# ===========================================
# RapidAPI配置
# ===========================================
RAPIDAPI_HOST: "rapidapi.com"
# ===========================================
# JWT配置
# ===========================================
JWT_ALGORITHM: "HS256"
JWT_EXPIRE_MINUTES: "1440"
# ===========================================
# SMTP邮箱配置
# ===========================================
SMTP_SERVER: "smtp.189.cn"
SMTP_PORT: "465"
SMTP_EMAIL: "taijiagent@189.cn"
SMTP_USE_SSL: "true"
+119
View File
@@ -0,0 +1,119 @@
# Data Ingestion Service Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: data-ingestion
namespace: taiji-ai
labels:
app: data-ingestion
spec:
replicas: 1
selector:
matchLabels:
app: data-ingestion
template:
metadata:
labels:
app: data-ingestion
spec:
containers:
- name: data-ingestion
image: taiji.azurecr.io/data-ingestion:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
# 环境标识
- name: ENVIRONMENT
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: database-url
- name: ASYNC_DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: async-database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: redis-url
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: NATS_URL
- name: RAPIDAPI_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: rapidapi-key
- name: RAPIDAPI_HOST
valueFrom:
configMapKeyRef:
name: taiji-config
key: RAPIDAPI_HOST
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: openrouter-api-key
- name: OPENROUTER_BASE_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: OPENROUTER_BASE_URL
- name: APP_ENV
valueFrom:
configMapKeyRef:
name: taiji-config
key: APP_ENV
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LOG_LEVEL
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# ACR 镜像拉取凭据
imagePullSecrets:
- name: acr-secret
---
apiVersion: v1
kind: Service
metadata:
name: data-ingestion
namespace: taiji-ai
spec:
selector:
app: data-ingestion
ports:
- name: http
port: 8000
targetPort: 8000
+140
View File
@@ -0,0 +1,140 @@
@echo off
REM Taiji AI-PAD AKS 部署脚本 (生产环境) - Windows 版本
REM 部署到 taiji-ai-pda AKS 集群
setlocal enabledelayedexpansion
REM 配置变量 - 生产环境
set ACR_NAME=taiji
set ACR_LOGIN_SERVER=%ACR_NAME%.azurecr.io
set RESOURCE_GROUP=taiji-ai-pda
set AKS_NAME=taiji-ai-pda
set NAMESPACE=taiji-ai
echo === Taiji AI-PAD AKS 部署脚本 (生产环境) ===
echo 警告: 您正在部署到生产环境!
echo 目标集群: %AKS_NAME%
echo 命名空间: %NAMESPACE%
echo.
REM 确认生产环境部署
set /p confirm=确认部署到生产环境? (输入 'yes' 继续):
if not "%confirm%"=="yes" (
echo 部署已取消
exit /b 0
)
REM 检查 Azure CLI 登录状态
echo 检查 Azure 登录状态...
az account show >nul 2>&1
if errorlevel 1 (
echo 请先运行 'az login' 登录 Azure
exit /b 1
)
echo Azure 已登录
REM 登录 ACR
echo 登录 Azure Container Registry...
az acr login --name %ACR_NAME%
REM 获取 AKS 凭据
echo 获取 AKS 集群凭据 (%AKS_NAME%)...
az aks get-credentials --resource-group %RESOURCE_GROUP% --name %AKS_NAME% --overwrite-existing
REM 构建并推送 Docker 镜像
echo 构建并推送 Docker 镜像到 ACR...
REM 构建 Data Ingestion
REM --platform linux/amd64: 显式锁定架构,与 AKS 标准节点 (amd64) 匹配,避免在 arm64 Mac 上误构建为 arm64
echo [1/2] 构建 Data Ingestion 镜像...
docker build --platform linux/amd64 -t %ACR_LOGIN_SERVER%/data-ingestion:latest ./services/data-ingestion/
docker push %ACR_LOGIN_SERVER%/data-ingestion:latest
REM 构建 MCP Server
echo [2/2] 构建 MCP Server 镜像...
docker build --platform linux/amd64 -t %ACR_LOGIN_SERVER%/mcp-server:latest ./services/mcp-server/
docker push %ACR_LOGIN_SERVER%/mcp-server:latest
echo 所有镜像构建并推送完成!
REM 部署到 AKS
echo 部署到 AKS (生产环境)...
REM 创建命名空间
echo 创建命名空间...
kubectl apply -f k8s/prod/namespace.yaml
REM 部署 Secrets 和 ConfigMap
echo 部署 Secrets 和 ConfigMap...
kubectl apply -f k8s/prod/secrets.yaml
kubectl apply -f k8s/prod/configmap.yaml
REM 部署 NATS
echo 部署 NATS 消息队列...
kubectl apply -f k8s/prod/nats.yaml
REM 等待 NATS 就绪
echo 等待 NATS 就绪...
kubectl wait --for=condition=ready pod -l app=nats -n %NAMESPACE% --timeout=120s
REM 部署 Data Ingestion
echo 部署 Data Ingestion...
kubectl apply -f k8s/prod/data-ingestion.yaml
REM 部署 MCP Server
echo 部署 MCP Server...
kubectl apply -f k8s/prod/mcp-server.yaml
REM 部署 API Gateway
echo 部署 API Gateway...
kubectl apply -f k8s/prod/api-gateway.yaml
REM 部署 Ingress
echo 部署 Ingress...
kubectl apply -f k8s/prod/ingress.yaml
REM 部署 Prometheus 监控
echo 部署 Prometheus 监控...
kubectl apply -f k8s/prod/monitoring.yaml
REM 等待所有服务就绪
echo 等待所有服务就绪...
kubectl wait --for=condition=ready pod -l app=data-ingestion -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=mcp-server -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=api-gateway -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=prometheus -n %NAMESPACE% --timeout=180s
REM 获取 API Gateway 外部 IP
echo 获取 API Gateway 外部 IP...
for /f "tokens=*" %%a in ('kubectl get svc api-gateway -n %NAMESPACE% -o jsonpath^="{.status.loadBalancer.ingress[0].ip}"') do set EXTERNAL_IP=%%a
echo.
echo === 生产环境部署完成! ===
echo.
echo 查看所有 Pod 状态:
kubectl get pods -n %NAMESPACE%
echo.
echo 查看所有 Service:
kubectl get svc -n %NAMESPACE%
echo.
echo 查看 Ingress:
kubectl get ingress -n %NAMESPACE%
echo.
echo 生产环境配置信息:
echo - 数据库: taiji_prod (生产库)
echo - Redis: taiji2026.southeastasia.redis.azure.net
echo - 命名空间: %NAMESPACE%
if defined EXTERNAL_IP (
echo.
echo API Gateway 外部访问地址: http://%EXTERNAL_IP%
echo - 健康检查: http://%EXTERNAL_IP%/health
echo - MCP Server: http://%EXTERNAL_IP%/api/mcp/
echo - Data Ingestion: http://%EXTERNAL_IP%/api/data/
) else (
echo.
echo LoadBalancer IP 尚未分配,请稍后运行以下命令查看:
echo kubectl get svc api-gateway -n %NAMESPACE%
)
endlocal
+153
View File
@@ -0,0 +1,153 @@
#!/bin/bash
# Taiji AI-PAD AKS 部署脚本 (生产环境)
# 部署到 taiji-ai-pda AKS 集群
set -e
# 配置变量 - 生产环境
ACR_NAME="taiji"
ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io"
RESOURCE_GROUP="taiji-ai-pda"
AKS_NAME="taiji-ai-pda"
NAMESPACE="taiji-ai"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${RED}=== Taiji AI-PAD AKS 部署脚本 (生产环境) ===${NC}"
echo -e "${RED}警告: 您正在部署到生产环境!${NC}"
echo -e "${YELLOW}目标集群: ${AKS_NAME}${NC}"
echo -e "${YELLOW}命名空间: ${NAMESPACE}${NC}"
echo ""
# 确认生产环境部署
read -p "确认部署到生产环境? (输入 'yes' 继续): " confirm
if [ "$confirm" != "yes" ]; then
echo -e "${YELLOW}部署已取消${NC}"
exit 0
fi
# 检查 Azure CLI 登录状态
echo -e "${YELLOW}检查 Azure 登录状态...${NC}"
az account show > /dev/null 2>&1 || { echo -e "${RED}请先运行 'az login' 登录 Azure${NC}"; exit 1; }
echo -e "${GREEN}Azure 已登录${NC}"
# 登录 ACR
echo -e "${YELLOW}登录 Azure Container Registry...${NC}"
az acr login --name ${ACR_NAME}
# 获取 AKS 凭据
echo -e "${YELLOW}获取 AKS 集群凭据 (${AKS_NAME})...${NC}"
az aks get-credentials --resource-group ${RESOURCE_GROUP} --name ${AKS_NAME} --overwrite-existing
# 构建并推送 Docker 镜像
echo -e "${YELLOW}构建并推送 Docker 镜像到 ACR...${NC}"
# 构建 Data Ingestion
# --platform linux/amd64: 显式锁定架构,与 AKS 标准节点 (amd64) 匹配,避免在 arm64 Mac 上误构建为 arm64
echo -e "${YELLOW}[1/2] 构建 Data Ingestion 镜像...${NC}"
docker build --platform linux/amd64 -t ${ACR_LOGIN_SERVER}/data-ingestion:latest ./services/data-ingestion/
docker push ${ACR_LOGIN_SERVER}/data-ingestion:latest
# 构建 MCP Server
echo -e "${YELLOW}[2/2] 构建 MCP Server 镜像...${NC}"
docker build --platform linux/amd64 -t ${ACR_LOGIN_SERVER}/mcp-server:latest ./services/mcp-server/
docker push ${ACR_LOGIN_SERVER}/mcp-server:latest
echo -e "${GREEN}所有镜像构建并推送完成!${NC}"
# 部署到 AKS
echo -e "${YELLOW}部署到 AKS (生产环境)...${NC}"
# 创建命名空间
echo -e "${YELLOW}创建命名空间...${NC}"
kubectl apply -f k8s/prod/namespace.yaml
# 创建 ACR 拉取凭据 (如果不存在)
echo -e "${YELLOW}检查 ACR 拉取凭据...${NC}"
if ! kubectl get secret acr-secret -n ${NAMESPACE} > /dev/null 2>&1; then
echo -e "${YELLOW}创建 ACR 拉取凭据...${NC}"
ACR_PASSWORD=$(az acr credential show --name ${ACR_NAME} --query "passwords[0].value" -o tsv)
kubectl create secret docker-registry acr-secret \
--namespace ${NAMESPACE} \
--docker-server=${ACR_LOGIN_SERVER} \
--docker-username=${ACR_NAME} \
--docker-password=${ACR_PASSWORD}
fi
# 部署 Secrets 和 ConfigMap
echo -e "${YELLOW}部署 Secrets 和 ConfigMap...${NC}"
kubectl apply -f k8s/prod/secrets.yaml
kubectl apply -f k8s/prod/configmap.yaml
# 部署 NATS
echo -e "${YELLOW}部署 NATS 消息队列...${NC}"
kubectl apply -f k8s/prod/nats.yaml
# 等待 NATS 就绪
echo -e "${YELLOW}等待 NATS 就绪...${NC}"
kubectl wait --for=condition=ready pod -l app=nats -n ${NAMESPACE} --timeout=120s || true
# 部署 Data Ingestion
echo -e "${YELLOW}部署 Data Ingestion...${NC}"
kubectl apply -f k8s/prod/data-ingestion.yaml
# 部署 MCP Server
echo -e "${YELLOW}部署 MCP Server...${NC}"
kubectl apply -f k8s/prod/mcp-server.yaml
# 部署 API Gateway
echo -e "${YELLOW}部署 API Gateway...${NC}"
kubectl apply -f k8s/prod/api-gateway.yaml
# 部署 Ingress
echo -e "${YELLOW}部署 Ingress...${NC}"
kubectl apply -f k8s/prod/ingress.yaml
# 部署 Prometheus 监控
echo -e "${YELLOW}部署 Prometheus 监控...${NC}"
kubectl apply -f k8s/prod/monitoring.yaml
# 等待所有服务就绪
echo -e "${YELLOW}等待所有服务就绪...${NC}"
kubectl wait --for=condition=ready pod -l app=data-ingestion -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=mcp-server -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=api-gateway -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=prometheus -n ${NAMESPACE} --timeout=180s || true
# 获取 API Gateway 外部 IP
echo -e "${YELLOW}获取 API Gateway 外部 IP...${NC}"
EXTERNAL_IP=$(kubectl get svc api-gateway -n ${NAMESPACE} -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null)
echo ""
echo -e "${GREEN}=== 生产环境部署完成! ===${NC}"
echo ""
echo -e "查看所有 Pod 状态:"
kubectl get pods -n ${NAMESPACE}
echo ""
echo -e "查看所有 Service:"
kubectl get svc -n ${NAMESPACE}
echo ""
echo -e "查看 Ingress:"
kubectl get ingress -n ${NAMESPACE}
echo ""
echo -e "${RED}生产环境配置信息:${NC}"
echo -e " - 数据库: taiji_prod (生产库)"
echo -e " - Redis: taiji2026.southeastasia.redis.azure.net"
echo -e " - 命名空间: ${NAMESPACE}"
if [ -n "$EXTERNAL_IP" ]; then
echo ""
echo -e "${GREEN}API Gateway 外部访问地址: http://${EXTERNAL_IP}${NC}"
echo -e " - 健康检查: http://${EXTERNAL_IP}/health"
echo -e " - MCP Server: http://${EXTERNAL_IP}/api/mcp/"
echo -e " - Data Ingestion: http://${EXTERNAL_IP}/api/data/"
else
echo ""
echo -e "${YELLOW}LoadBalancer IP 尚未分配,请稍后运行以下命令查看:${NC}"
echo -e " kubectl get svc api-gateway -n ${NAMESPACE}"
fi
+78
View File
@@ -0,0 +1,78 @@
# Ingress 配置 - MCP Server
# 支持 Azure Application Gateway Ingress Controller 或 NGINX Ingress Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcp-server-ingress
namespace: taiji-ai
labels:
app: mcp-server
annotations:
# 使用 NGINX Ingress Controller (如果使用 AGIC,请更换注解)
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
# CORS 配置
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization"
# Let's Encrypt 证书 (需要 cert-manager)
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
# TLS 配置 (如果有证书)
# tls:
# - hosts:
# - api.taiji-ai.com
# secretName: mcp-server-tls
rules:
- host: mcp.taiji-ai.com
http:
paths:
# API 路由
- path: /
pathType: Prefix
backend:
service:
name: mcp-server
port:
number: 8000
---
# Azure Application Gateway Ingress Controller 配置 (备选)
# 如果使用 AGIC,请使用以下配置替换上面的 Ingress
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# metadata:
# name: mcp-server-ingress-agic
# namespace: taiji-ai
# annotations:
# kubernetes.io/ingress.class: azure/application-gateway
# appgw.ingress.kubernetes.io/ssl-redirect: "true"
# appgw.ingress.kubernetes.io/connection-draining: "true"
# appgw.ingress.kubernetes.io/connection-draining-timeout: "30"
# appgw.ingress.kubernetes.io/backend-protocol: "http"
# spec:
# tls:
# - hosts:
# - api.taiji-ai.com
# secretName: mcp-server-tls
# rules:
# - host: api.taiji-ai.com
# http:
# paths:
# - path: /
# pathType: Prefix
# backend:
# service:
# name: mcp-server
# port:
# number: 8000
+289
View File
@@ -0,0 +1,289 @@
# MCP Server Deployment for Azure AKS
# 生产环境配置 - PostgreSQL 使用 postgres 数据库,Redis 使用 Azure Cache for Redis
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: taiji-ai
labels:
app: mcp-server
version: v1
environment: production
spec:
replicas: 2
selector:
matchLabels:
app: mcp-server
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: mcp-server
version: v1
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: mcp-server
image: taiji.azurecr.io/mcp-server:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
protocol: TCP
env:
# 应用环境配置
- name: ENVIRONMENT
value: "production"
- name: APP_ENV
valueFrom:
configMapKeyRef:
name: taiji-config
key: APP_ENV
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LOG_LEVEL
# 数据库配置 (Azure Database for PostgreSQL - 生产库 postgres)
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: database-url
- name: ASYNC_DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: async-database-url
# Redis配置 (Azure Cache for Redis with SSL)
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: redis-url
# NATS配置 (K8s内部服务)
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: NATS_URL
# LiteLLM网关配置
- name: LITELLM_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LITELLM_URL
- name: LLM_BASE_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LITELLM_URL
- name: LITELLM_MASTER_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: litellm-master-key
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: litellm-master-key
# Agent Manager 配置 (AKS内部服务)
- name: AGENT_MANAGER_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: AGENT_MANAGER_URL
- name: AGENT_K8S_NAMESPACE
valueFrom:
configMapKeyRef:
name: taiji-config
key: AGENT_K8S_NAMESPACE
# JWT配置
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: jwt-secret
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: jwt-secret
- name: JWT_ALGORITHM
valueFrom:
configMapKeyRef:
name: taiji-config
key: JWT_ALGORITHM
- name: JWT_EXPIRE_MINUTES
valueFrom:
configMapKeyRef:
name: taiji-config
key: JWT_EXPIRE_MINUTES
# SMTP邮箱配置
- name: SMTP_SERVER
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_SERVER
- name: SMTP_PORT
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_PORT
- name: SMTP_EMAIL
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_EMAIL
- name: SMTP_USE_SSL
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_USE_SSL
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: taiji-secrets
key: smtp-password
# PayPal 支付配置 (生产环境)
- name: PAYPAL_CLIENT_ID
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-client-id
- name: PAYPAL_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-client-secret
- name: PAYPAL_ENVIRONMENT
value: "production"
- name: PAYPAL_WEBHOOK_ID
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-webhook-id
# 健康检查
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# 资源限制
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
# 挂载卷
volumeMounts:
- name: logs
mountPath: /app/logs
# 卷定义
volumes:
- name: logs
emptyDir: {}
# 重启策略
restartPolicy: Always
# ACR 镜像拉取凭据
imagePullSecrets:
- name: acr-secret
# 服务账户(如果需要访问K8s API)
# serviceAccountName: mcp-server-sa
---
# MCP Server Service
apiVersion: v1
kind: Service
metadata:
name: mcp-server
namespace: taiji-ai
labels:
app: mcp-server
spec:
type: ClusterIP
selector:
app: mcp-server
ports:
- name: http
port: 8000
targetPort: 8000
protocol: TCP
---
# HorizontalPodAutoscaler - 自动扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
namespace: taiji-ai
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
---
# PodDisruptionBudget - 确保高可用
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: mcp-server-pdb
namespace: taiji-ai
spec:
minAvailable: 1
selector:
matchLabels:
app: mcp-server
+157
View File
@@ -0,0 +1,157 @@
# Prometheus Monitoring Deployment
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: taiji-ai
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'mcp-server'
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- taiji-ai
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: mcp-server
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: ${1}:8000
- job_name: 'data-ingestion'
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- taiji-ai
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: data-ingestion
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: ${1}:8000
- job_name: 'litellm-gateway'
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- taiji-ai
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: litellm-gateway
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: ${1}:4000
- job_name: 'nats'
static_configs:
- targets: ['nats:8222']
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: taiji-ai
labels:
app: prometheus
spec:
replicas: 1
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
serviceAccountName: prometheus
containers:
- name: prometheus
image: prom/prometheus:latest
args:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
ports:
- containerPort: 9090
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "500m"
volumeMounts:
- name: prometheus-config
mountPath: /etc/prometheus
- name: prometheus-data
mountPath: /prometheus
volumes:
- name: prometheus-config
configMap:
name: prometheus-config
- name: prometheus-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: taiji-ai
spec:
selector:
app: prometheus
ports:
- port: 9090
targetPort: 9090
---
# Prometheus Service Account and RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: taiji-ai
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus
rules:
- apiGroups: [""]
resources:
- nodes
- services
- endpoints
- pods
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources:
- configmaps
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: taiji-ai
+8
View File
@@ -0,0 +1,8 @@
# Kubernetes Namespace for Taiji AI-PAD
apiVersion: v1
kind: Namespace
metadata:
name: taiji-ai
labels:
app: taiji-ai-pad
environment: production
+73
View File
@@ -0,0 +1,73 @@
# NATS Message Queue Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: nats
namespace: taiji-ai
labels:
app: nats
spec:
replicas: 1
selector:
matchLabels:
app: nats
template:
metadata:
labels:
app: nats
spec:
containers:
- name: nats
image: nats:2.10-alpine
args: ["-js", "-m", "8222"]
ports:
- containerPort: 4222
name: client
- containerPort: 6222
name: routing
- containerPort: 8222
name: monitoring
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /
port: 8222
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8222
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: nats-data
mountPath: /data
volumes:
- name: nats-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: nats
namespace: taiji-ai
spec:
selector:
app: nats
ports:
- name: client
port: 4222
targetPort: 4222
- name: routing
port: 6222
targetPort: 6222
- name: monitoring
port: 8222
targetPort: 8222
+60
View File
@@ -0,0 +1,60 @@
# Kubernetes Secrets for Taiji AI-PAD
# 注意:生产环境请使用 Azure Key Vault 或 kubectl create secret 命令
# 生成命令: echo -n "your-value" | base64
apiVersion: v1
kind: Secret
metadata:
name: taiji-secrets
namespace: taiji-ai
labels:
app: taiji-ai-pad
environment: production
type: Opaque
stringData:
# ===========================================
# 数据库配置 (Azure Database for PostgreSQL)
# 生产环境使用 taiji_prod 数据库
# ===========================================
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod"
# ===========================================
# Redis配置 (Azure Cache for Redis with SSL)
# 端口 10000 使用 SSL 连接
# 注意:Redis 已迁移到 taiji2026 实例
# ===========================================
redis-url: "rediss://:PzmWkM6CwfRrJTB1d2xLRxE9pzT7JKgvVAzCaEehmFE=@taiji2026.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none"
# ===========================================
# JWT配置
# ===========================================
jwt-secret: "your-super-secret-jwt-key-change-this-in-production"
# ===========================================
# LiteLLM配置
# ===========================================
litellm-master-key: "sk-litellm-taiji-prod-8f3a9b2c4d5e6f7g"
# ===========================================
# OpenRouter配置
# ===========================================
openrouter-api-key: "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"
# ===========================================
# RapidAPI配置
# ===========================================
rapidapi-key: "33902cc39dmsha572ec6ae920fb5p13c196jsn8a11209a7e67"
# ===========================================
# SMTP邮箱配置
# ===========================================
smtp-password: "eR)8hD@1Q)3sU%2q"
# ===========================================
# PayPal 支付配置 (生产环境)
# App Name: taijiagent
# ===========================================
paypal-client-id: "AVlZsAarDjotq5n1Pu2guPDJCy5pvZiVYIOxOuejHTejNyPdGyJ0rXy_5mXiUv4M-NXYsvE1S7TCSQQV"
paypal-client-secret: "EJK7kY_gg7emiiiv3oZPJBTe4FLpqDAnpiuSi5hNl8YtaWRHpZVg9767VqZU3iXp92cQ0GYpmfYvpopG"
paypal-webhook-id: "51263380NC1182518"
+184
View File
@@ -0,0 +1,184 @@
# Nginx Ingress Controller ConfigMap (测试环境)
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: taiji-ai-test
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time ut="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50M;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
# 上游服务器配置 - 使用K8s服务名
upstream mcp-server {
least_conn;
server mcp-server:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
upstream data-ingestion {
least_conn;
server data-ingestion:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=20r/m;
server {
listen 80;
server_name _;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# MCP服务器路由
location /api/mcp/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://mcp-server/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 数据接入服务路由
location /api/data/ {
limit_req zone=api burst=30 nodelay;
proxy_pass http://data-ingestion/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
}
# 默认响应
location / {
return 200 '{"status":"ok","service":"taiji-ai-gateway-test","environment":"test"}';
add_header Content-Type application/json;
}
}
}
---
# API Gateway Deployment (测试环境)
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
namespace: taiji-ai-test
labels:
app: api-gateway
environment: test
spec:
replicas: 1
selector:
matchLabels:
app: api-gateway
template:
metadata:
labels:
app: api-gateway
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
volumes:
- name: nginx-config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: api-gateway
namespace: taiji-ai-test
annotations:
service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /health
spec:
type: LoadBalancer
selector:
app: api-gateway
ports:
- name: http
port: 80
targetPort: 80
+60
View File
@@ -0,0 +1,60 @@
# ConfigMap for Taiji AI-PAD (测试环境)
# 包含非敏感配置信息
apiVersion: v1
kind: ConfigMap
metadata:
name: taiji-config
namespace: taiji-ai-test
labels:
app: taiji-ai-pad
environment: test
data:
# ===========================================
# 应用环境配置
# ===========================================
APP_ENV: "test"
ENVIRONMENT: "test"
LOG_LEVEL: "DEBUG"
DEBUG: "true"
# ===========================================
# NATS配置 (K8s内部服务)
# ===========================================
NATS_URL: "nats://nats:4222"
# ===========================================
# LiteLLM网关配置 (Azure Container Apps - 共用生产环境)
# ===========================================
LITELLM_URL: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
LLM_BASE_URL: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
# ===========================================
# Agent Manager 配置 (AKS内部服务)
# 测试环境服务部署在 agent-manager namespace
# ===========================================
AGENT_MANAGER_URL: "http://agent-manager.agent-manager.svc.cluster.local:80"
AGENT_K8S_NAMESPACE: "ai-agents-test"
# ===========================================
# OpenRouter配置
# ===========================================
OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1"
# ===========================================
# RapidAPI配置
# ===========================================
RAPIDAPI_HOST: "rapidapi.com"
# ===========================================
# JWT配置
# ===========================================
JWT_ALGORITHM: "HS256"
JWT_EXPIRE_MINUTES: "1440"
# ===========================================
# SMTP邮箱配置
# ===========================================
SMTP_SERVER: "smtp.189.cn"
SMTP_PORT: "465"
SMTP_EMAIL: "taijiagent@189.cn"
SMTP_USE_SSL: "true"
+120
View File
@@ -0,0 +1,120 @@
# Data Ingestion Service Deployment (测试环境)
apiVersion: apps/v1
kind: Deployment
metadata:
name: data-ingestion
namespace: taiji-ai-test
labels:
app: data-ingestion
environment: test
spec:
replicas: 1
selector:
matchLabels:
app: data-ingestion
template:
metadata:
labels:
app: data-ingestion
spec:
containers:
- name: data-ingestion
image: taiji.azurecr.io/data-ingestion:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
# 环境标识
- name: ENVIRONMENT
value: "test"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: database-url
- name: ASYNC_DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: async-database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: redis-url
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: NATS_URL
- name: RAPIDAPI_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: rapidapi-key
- name: RAPIDAPI_HOST
valueFrom:
configMapKeyRef:
name: taiji-config
key: RAPIDAPI_HOST
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: openrouter-api-key
- name: OPENROUTER_BASE_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: OPENROUTER_BASE_URL
- name: APP_ENV
valueFrom:
configMapKeyRef:
name: taiji-config
key: APP_ENV
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LOG_LEVEL
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# ACR 镜像拉取凭据
imagePullSecrets:
- name: acr-secret
---
apiVersion: v1
kind: Service
metadata:
name: data-ingestion
namespace: taiji-ai-test
spec:
selector:
app: data-ingestion
ports:
- name: http
port: 8000
targetPort: 8000
+133
View File
@@ -0,0 +1,133 @@
@echo off
REM Taiji AI-PAD AKS 部署脚本 (测试环境) - Windows 版本
REM 部署到 testagnet AKS 集群
setlocal enabledelayedexpansion
REM 配置变量 - 测试环境
set ACR_NAME=taiji
set ACR_LOGIN_SERVER=%ACR_NAME%.azurecr.io
set RESOURCE_GROUP=taiji-ai-test
set AKS_NAME=testagnet
set NAMESPACE=taiji-ai-test
echo === Taiji AI-PAD AKS 部署脚本 (测试环境) ===
echo 目标集群: %AKS_NAME%
echo 命名空间: %NAMESPACE%
echo.
REM 检查 Azure CLI 登录状态
echo 检查 Azure 登录状态...
az account show >nul 2>&1
if errorlevel 1 (
echo 请先运行 'az login' 登录 Azure
exit /b 1
)
echo Azure 已登录
REM 登录 ACR
echo 登录 Azure Container Registry...
az acr login --name %ACR_NAME%
REM 获取 AKS 凭据
echo 获取 AKS 集群凭据 (%AKS_NAME%)...
az aks get-credentials --resource-group %RESOURCE_GROUP% --name %AKS_NAME% --overwrite-existing
REM 构建并推送 Docker 镜像
echo 构建并推送 Docker 镜像到 ACR...
REM 构建 Data Ingestion
REM 构建 Data Ingestion
REM --platform linux/amd64: 显式锁定架构,与 AKS 标准节点 (amd64) 匹配
echo [1/2] 构建 Data Ingestion 镜像...
docker build --platform linux/amd64 -t %ACR_LOGIN_SERVER%/data-ingestion:latest ./services/data-ingestion/
docker push %ACR_LOGIN_SERVER%/data-ingestion:latest
REM 构建 MCP Server
echo [2/2] 构建 MCP Server 镜像...
docker build --platform linux/amd64 -t %ACR_LOGIN_SERVER%/mcp-server:latest ./services/mcp-server/
docker push %ACR_LOGIN_SERVER%/mcp-server:latest
echo 所有镜像构建并推送完成!
REM 部署到 AKS
echo 部署到 AKS (测试环境)...
REM 创建命名空间
echo 创建命名空间...
kubectl apply -f k8s/test/namespace.yaml
REM 部署 Secrets 和 ConfigMap
echo 部署 Secrets 和 ConfigMap...
kubectl apply -f k8s/test/secrets.yaml
kubectl apply -f k8s/test/configmap.yaml
REM 部署 NATS
echo 部署 NATS 消息队列...
kubectl apply -f k8s/test/nats.yaml
REM 等待 NATS 就绪
echo 等待 NATS 就绪...
kubectl wait --for=condition=ready pod -l app=nats -n %NAMESPACE% --timeout=120s
REM 部署 Data Ingestion
echo 部署 Data Ingestion...
kubectl apply -f k8s/test/data-ingestion.yaml
REM 部署 MCP Server
echo 部署 MCP Server...
kubectl apply -f k8s/test/mcp-server.yaml
REM 部署 API Gateway
echo 部署 API Gateway...
kubectl apply -f k8s/test/api-gateway.yaml
REM 部署 Ingress
echo 部署 Ingress...
kubectl apply -f k8s/test/ingress.yaml
REM 部署 Prometheus 监控
echo 部署 Prometheus 监控...
kubectl apply -f k8s/test/monitoring.yaml
REM 等待所有服务就绪
echo 等待所有服务就绪...
kubectl wait --for=condition=ready pod -l app=data-ingestion -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=mcp-server -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=api-gateway -n %NAMESPACE% --timeout=180s
kubectl wait --for=condition=ready pod -l app=prometheus -n %NAMESPACE% --timeout=180s
REM 获取 API Gateway 外部 IP
echo 获取 API Gateway 外部 IP...
for /f "tokens=*" %%a in ('kubectl get svc api-gateway -n %NAMESPACE% -o jsonpath^="{.status.loadBalancer.ingress[0].ip}"') do set EXTERNAL_IP=%%a
echo.
echo === 测试环境部署完成! ===
echo.
echo 查看所有 Pod 状态:
kubectl get pods -n %NAMESPACE%
echo.
echo 查看所有 Service:
kubectl get svc -n %NAMESPACE%
echo.
echo 查看 Ingress:
kubectl get ingress -n %NAMESPACE%
echo.
echo 测试环境配置信息:
echo - 数据库: taiji (测试库)
echo - Redis: testagnet.redis.cache.windows.net
echo - 命名空间: %NAMESPACE%
if defined EXTERNAL_IP (
echo.
echo API Gateway 外部访问地址: http://%EXTERNAL_IP%
echo - 健康检查: http://%EXTERNAL_IP%/health
echo - MCP Server: http://%EXTERNAL_IP%/api/mcp/
echo - Data Ingestion: http://%EXTERNAL_IP%/api/data/
) else (
echo.
echo LoadBalancer IP 尚未分配,请稍后运行以下命令查看:
echo kubectl get svc api-gateway -n %NAMESPACE%
)
endlocal
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# Taiji AI-PAD AKS 部署脚本 (测试环境)
# 部署到 testagnet AKS 集群
set -e
# 配置变量 - 测试环境
ACR_NAME="taiji"
ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io"
RESOURCE_GROUP="taiji-ai-test"
AKS_NAME="testagnet"
NAMESPACE="taiji-ai-test"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}=== Taiji AI-PAD AKS 部署脚本 (测试环境) ===${NC}"
echo -e "${YELLOW}目标集群: ${AKS_NAME}${NC}"
echo -e "${YELLOW}命名空间: ${NAMESPACE}${NC}"
echo ""
# 检查 Azure CLI 登录状态
echo -e "${YELLOW}检查 Azure 登录状态...${NC}"
az account show > /dev/null 2>&1 || { echo -e "${RED}请先运行 'az login' 登录 Azure${NC}"; exit 1; }
echo -e "${GREEN}Azure 已登录${NC}"
# 登录 ACR
echo -e "${YELLOW}登录 Azure Container Registry...${NC}"
az acr login --name ${ACR_NAME}
# 获取 AKS 凭据
echo -e "${YELLOW}获取 AKS 集群凭据 (${AKS_NAME})...${NC}"
az aks get-credentials --resource-group ${RESOURCE_GROUP} --name ${AKS_NAME} --overwrite-existing
# 构建并推送 Docker 镜像
echo -e "${YELLOW}构建并推送 Docker 镜像到 ACR...${NC}"
# 构建 Data Ingestion
# --platform linux/amd64: 显式锁定架构,与 AKS 标准节点 (amd64) 匹配
echo -e "${YELLOW}[1/2] 构建 Data Ingestion 镜像...${NC}"
docker build --platform linux/amd64 -t ${ACR_LOGIN_SERVER}/data-ingestion:latest ./services/data-ingestion/
docker push ${ACR_LOGIN_SERVER}/data-ingestion:latest
# 构建 MCP Server
echo -e "${YELLOW}[2/2] 构建 MCP Server 镜像...${NC}"
docker build --platform linux/amd64 -t ${ACR_LOGIN_SERVER}/mcp-server:latest ./services/mcp-server/
docker push ${ACR_LOGIN_SERVER}/mcp-server:latest
echo -e "${GREEN}所有镜像构建并推送完成!${NC}"
# 部署到 AKS
echo -e "${YELLOW}部署到 AKS (测试环境)...${NC}"
# 创建命名空间
echo -e "${YELLOW}创建命名空间...${NC}"
kubectl apply -f k8s/test/namespace.yaml
# 创建 ACR 拉取凭据 (如果不存在)
echo -e "${YELLOW}检查 ACR 拉取凭据...${NC}"
if ! kubectl get secret acr-secret -n ${NAMESPACE} > /dev/null 2>&1; then
echo -e "${YELLOW}创建 ACR 拉取凭据...${NC}"
ACR_PASSWORD=$(az acr credential show --name ${ACR_NAME} --query "passwords[0].value" -o tsv)
kubectl create secret docker-registry acr-secret \
--namespace ${NAMESPACE} \
--docker-server=${ACR_LOGIN_SERVER} \
--docker-username=${ACR_NAME} \
--docker-password=${ACR_PASSWORD}
fi
# 部署 Secrets 和 ConfigMap
echo -e "${YELLOW}部署 Secrets 和 ConfigMap...${NC}"
kubectl apply -f k8s/test/secrets.yaml
kubectl apply -f k8s/test/configmap.yaml
# 部署 NATS
echo -e "${YELLOW}部署 NATS 消息队列...${NC}"
kubectl apply -f k8s/test/nats.yaml
# 等待 NATS 就绪
echo -e "${YELLOW}等待 NATS 就绪...${NC}"
kubectl wait --for=condition=ready pod -l app=nats -n ${NAMESPACE} --timeout=120s || true
# 部署 Data Ingestion
echo -e "${YELLOW}部署 Data Ingestion...${NC}"
kubectl apply -f k8s/test/data-ingestion.yaml
# 部署 MCP Server
echo -e "${YELLOW}部署 MCP Server...${NC}"
kubectl apply -f k8s/test/mcp-server.yaml
# 部署 API Gateway
echo -e "${YELLOW}部署 API Gateway...${NC}"
kubectl apply -f k8s/test/api-gateway.yaml
# 部署 Ingress
echo -e "${YELLOW}部署 Ingress...${NC}"
kubectl apply -f k8s/test/ingress.yaml
# 部署 Prometheus 监控
echo -e "${YELLOW}部署 Prometheus 监控...${NC}"
kubectl apply -f k8s/test/monitoring.yaml
# 等待所有服务就绪
echo -e "${YELLOW}等待所有服务就绪...${NC}"
kubectl wait --for=condition=ready pod -l app=data-ingestion -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=mcp-server -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=api-gateway -n ${NAMESPACE} --timeout=180s || true
kubectl wait --for=condition=ready pod -l app=prometheus -n ${NAMESPACE} --timeout=180s || true
# 获取 API Gateway 外部 IP
echo -e "${YELLOW}获取 API Gateway 外部 IP...${NC}"
EXTERNAL_IP=$(kubectl get svc api-gateway -n ${NAMESPACE} -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null)
echo ""
echo -e "${GREEN}=== 测试环境部署完成! ===${NC}"
echo ""
echo -e "查看所有 Pod 状态:"
kubectl get pods -n ${NAMESPACE}
echo ""
echo -e "查看所有 Service:"
kubectl get svc -n ${NAMESPACE}
echo ""
echo -e "查看 Ingress:"
kubectl get ingress -n ${NAMESPACE}
echo ""
echo -e "${BLUE}测试环境配置信息:${NC}"
echo -e " - 数据库: taiji (测试库)"
echo -e " - Redis: testagnet.redis.cache.windows.net"
echo -e " - 命名空间: ${NAMESPACE}"
if [ -n "$EXTERNAL_IP" ]; then
echo ""
echo -e "${GREEN}API Gateway 外部访问地址: http://${EXTERNAL_IP}${NC}"
echo -e " - 健康检查: http://${EXTERNAL_IP}/health"
echo -e " - MCP Server: http://${EXTERNAL_IP}/api/mcp/"
echo -e " - Data Ingestion: http://${EXTERNAL_IP}/api/data/"
else
echo ""
echo -e "${YELLOW}LoadBalancer IP 尚未分配,请稍后运行以下命令查看:${NC}"
echo -e " kubectl get svc api-gateway -n ${NAMESPACE}"
fi
+38
View File
@@ -0,0 +1,38 @@
# Ingress 配置 - MCP Server (测试环境)
# 支持 Azure Application Gateway Ingress Controller 或 NGINX Ingress Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcp-server-ingress
namespace: taiji-ai-test
labels:
app: mcp-server
environment: test
annotations:
# 使用 NGINX Ingress Controller (如果使用 AGIC,请更换注解)
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
# CORS 配置
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization"
spec:
ingressClassName: nginx
rules:
- host: mcp-test.taiji-ai.com
http:
paths:
# API 路由
- path: /
pathType: Prefix
backend:
service:
name: mcp-server
port:
number: 8000
+273
View File
@@ -0,0 +1,273 @@
# MCP Server Deployment for Azure AKS (测试环境)
# 测试环境配置 - PostgreSQL 使用 taiji 数据库,Redis 使用测试环境实例
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: taiji-ai-test
labels:
app: mcp-server
version: v1
environment: test
spec:
replicas: 1
selector:
matchLabels:
app: mcp-server
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: mcp-server
version: v1
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: mcp-server
image: taiji.azurecr.io/mcp-server:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
protocol: TCP
env:
# 应用环境配置
- name: ENVIRONMENT
value: "test"
- name: APP_ENV
valueFrom:
configMapKeyRef:
name: taiji-config
key: APP_ENV
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LOG_LEVEL
# 数据库配置 (Azure Database for PostgreSQL - 测试库 taiji)
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: database-url
- name: ASYNC_DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: async-database-url
# Redis配置 (Azure Cache for Redis - 测试环境)
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: redis-url
# NATS配置 (K8s内部服务)
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: NATS_URL
# LiteLLM网关配置
- name: LITELLM_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LITELLM_URL
- name: LLM_BASE_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: LITELLM_URL
- name: LITELLM_MASTER_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: litellm-master-key
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: litellm-master-key
# Agent Manager 配置 (AKS内部服务)
- name: AGENT_MANAGER_URL
valueFrom:
configMapKeyRef:
name: taiji-config
key: AGENT_MANAGER_URL
- name: AGENT_K8S_NAMESPACE
valueFrom:
configMapKeyRef:
name: taiji-config
key: AGENT_K8S_NAMESPACE
# JWT配置
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: jwt-secret
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
name: taiji-secrets
key: jwt-secret
- name: JWT_ALGORITHM
valueFrom:
configMapKeyRef:
name: taiji-config
key: JWT_ALGORITHM
- name: JWT_EXPIRE_MINUTES
valueFrom:
configMapKeyRef:
name: taiji-config
key: JWT_EXPIRE_MINUTES
# SMTP邮箱配置
- name: SMTP_SERVER
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_SERVER
- name: SMTP_PORT
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_PORT
- name: SMTP_EMAIL
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_EMAIL
- name: SMTP_USE_SSL
valueFrom:
configMapKeyRef:
name: taiji-config
key: SMTP_USE_SSL
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: taiji-secrets
key: smtp-password
# PayPal 支付配置 (Sandbox 测试环境)
- name: PAYPAL_CLIENT_ID
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-client-id
- name: PAYPAL_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-client-secret
- name: PAYPAL_ENVIRONMENT
value: "sandbox"
- name: PAYPAL_WEBHOOK_ID
valueFrom:
secretKeyRef:
name: taiji-secrets
key: paypal-webhook-id
# 健康检查
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# 资源限制 (测试环境使用较少资源)
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
# 挂载卷
volumeMounts:
- name: logs
mountPath: /app/logs
# 卷定义
volumes:
- name: logs
emptyDir: {}
# 重启策略
restartPolicy: Always
# ACR 镜像拉取凭据
imagePullSecrets:
- name: acr-secret
---
# MCP Server Service
apiVersion: v1
kind: Service
metadata:
name: mcp-server
namespace: taiji-ai-test
labels:
app: mcp-server
spec:
type: ClusterIP
selector:
app: mcp-server
ports:
- name: http
port: 8000
targetPort: 8000
protocol: TCP
---
# HorizontalPodAutoscaler - 自动扩缩容 (测试环境配置较低)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
namespace: taiji-ai-test
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
+144
View File
@@ -0,0 +1,144 @@
# Prometheus Monitoring Deployment (测试环境)
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: taiji-ai-test
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'mcp-server'
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- taiji-ai-test
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: mcp-server
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: ${1}:8000
- job_name: 'data-ingestion'
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- taiji-ai-test
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: data-ingestion
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: ${1}:8000
- job_name: 'nats'
static_configs:
- targets: ['nats:8222']
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: taiji-ai-test
labels:
app: prometheus
environment: test
spec:
replicas: 1
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
serviceAccountName: prometheus
containers:
- name: prometheus
image: prom/prometheus:latest
args:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
ports:
- containerPort: 9090
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "512Mi"
cpu: "250m"
volumeMounts:
- name: prometheus-config
mountPath: /etc/prometheus
- name: prometheus-data
mountPath: /prometheus
volumes:
- name: prometheus-config
configMap:
name: prometheus-config
- name: prometheus-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: taiji-ai-test
spec:
selector:
app: prometheus
ports:
- port: 9090
targetPort: 9090
---
# Prometheus Service Account and RBAC (测试环境)
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: taiji-ai-test
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus-test
rules:
- apiGroups: [""]
resources:
- nodes
- services
- endpoints
- pods
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources:
- configmaps
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus-test
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus-test
subjects:
- kind: ServiceAccount
name: prometheus
namespace: taiji-ai-test
+8
View File
@@ -0,0 +1,8 @@
# Kubernetes Namespace for Taiji AI-PAD (测试环境)
apiVersion: v1
kind: Namespace
metadata:
name: taiji-ai-test
labels:
app: taiji-ai-pad
environment: test
+74
View File
@@ -0,0 +1,74 @@
# NATS Message Queue Deployment (测试环境)
apiVersion: apps/v1
kind: Deployment
metadata:
name: nats
namespace: taiji-ai-test
labels:
app: nats
environment: test
spec:
replicas: 1
selector:
matchLabels:
app: nats
template:
metadata:
labels:
app: nats
spec:
containers:
- name: nats
image: nats:2.10-alpine
args: ["-js", "-m", "8222"]
ports:
- containerPort: 4222
name: client
- containerPort: 6222
name: routing
- containerPort: 8222
name: monitoring
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "250m"
livenessProbe:
httpGet:
path: /
port: 8222
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8222
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: nats-data
mountPath: /data
volumes:
- name: nats-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: nats
namespace: taiji-ai-test
spec:
selector:
app: nats
ports:
- name: client
port: 4222
targetPort: 4222
- name: routing
port: 6222
targetPort: 6222
- name: monitoring
port: 8222
targetPort: 8222
+58
View File
@@ -0,0 +1,58 @@
# Kubernetes Secrets for Taiji AI-PAD (测试环境)
# 注意:生产环境请使用 Azure Key Vault 或 kubectl create secret 命令
# 生成命令: echo -n "your-value" | base64
apiVersion: v1
kind: Secret
metadata:
name: taiji-secrets
namespace: taiji-ai-test
labels:
app: taiji-ai-pad
environment: test
type: Opaque
stringData:
# ===========================================
# 数据库配置 (Azure Database for PostgreSQL)
# 测试环境使用 taiji 数据库
# ===========================================
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji"
# ===========================================
# Redis配置 (Azure Cache for Redis - 测试环境独立实例)
# testagnet.redis.cache.windows.net
# ===========================================
redis-url: "rediss://:iNi7pNeW5JfgCzKymR2zEY9LRexmA1LGhAzCaB1dXy4=@testagnet.redis.cache.windows.net:6380/0?ssl_cert_reqs=none"
# ===========================================
# JWT配置
# ===========================================
jwt-secret: "your-super-secret-jwt-key-change-this-in-production"
# ===========================================
# LiteLLM配置 (共用生产环境)
# ===========================================
litellm-master-key: "sk-litellm-taiji-prod-8f3a9b2c4d5e6f7g"
# ===========================================
# OpenRouter配置
# ===========================================
openrouter-api-key: "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"
# ===========================================
# RapidAPI配置
# ===========================================
rapidapi-key: "33902cc39dmsha572ec6ae920fb5p13c196jsn8a11209a7e67"
# ===========================================
# SMTP邮箱配置
# ===========================================
smtp-password: "eR)8hD@1Q)3sU%2q"
# ===========================================
# PayPal 支付配置 (Sandbox 测试环境)
# ===========================================
paypal-client-id: "AVlZsAarDjotq5n1Pu2guPDJCy5pvZiVYIOxOuejHTejNyPdGyJ0rXy_5mXiUv4M-NXYsvE1S7TCSQQV"
paypal-client-secret: "EJK7kY_gg7emiiiv3oZPJBTe4FLpqDAnpiuSi5hNl8YtaWRHpZVg9767VqZU3iXp92cQ0GYpmfYvpopG"
paypal-webhook-id: "51263380NC1182518"
+3 -1
View File
@@ -12,7 +12,9 @@ RUN apt-get update && apt-get install -y \
# 安装Python依赖 # 安装Python依赖
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt RUN pip install --no-cache-dir --user -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn
# 最终镜像 # 最终镜像
FROM python:3.11-slim FROM python:3.11-slim
+44
View File
@@ -0,0 +1,44 @@
# Pre-built base image for mcp-server (arm64).
#
# Contains: python:3.11-slim + apt runtime deps + all pip dependencies installed under /root/.local.
# Excludes: application code (intentionally — that goes in the thin app Dockerfile).
#
# Rebuild only when requirements.txt or system deps change.
# Push as: taiji.azurecr.io/mcp-server-base:py3.11-arm64-<DATE>
#
# Build:
# az acr build --registry taiji --image mcp-server-base:py3.11-arm64-<DATE> \
# --platform linux/arm64 --file Dockerfile.base .
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
RUN mkdir -p /app/logs
EXPOSE 8000
+26
View File
@@ -0,0 +1,26 @@
# Thin app image — relies on a pre-built base with pip deps already installed.
#
# Switch to this Dockerfile after the base image
# taiji.azurecr.io/mcp-server-base:py3.11-arm64-20260430
# is published. Subsequent builds become ~1-2 min instead of 40.
#
# When requirements.txt changes, rebuild Dockerfile.base first.
#
# Build:
# az acr build --registry taiji --image mcp-server:<tag> --image latest \
# --platform linux/arm64 --file Dockerfile.thin .
FROM taiji.azurecr.io/mcp-server-base:py3.11-arm64-20260430
WORKDIR /app
# Application code — the only frequently-changed layer
COPY . .
# Drop the prebuilt base Dockerfile to avoid confusion in the running container
RUN rm -f /app/Dockerfile.base /app/Dockerfile.thin
HEALTHCHECK --interval=120s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["python3", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+8 -3
View File
@@ -7,17 +7,21 @@ import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import structlog
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from database import get_db from database import get_db
from models import APIKey, User, TokenBlacklist from models import APIKey, User, TokenBlacklist
logger = structlog.get_logger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
@@ -170,9 +174,10 @@ async def _is_user_logged_out(user_id: str, token_iat: Optional[int], db: AsyncS
return True return True
return False return False
except Exception: except SQLAlchemyError as e:
# 查询失败时不阻止认证 # 数据库错误时保守处理:视为已登出,避免被吊销的 token 在 DB 抖动期间通过认证
return False logger.error("logout_check_db_error", user_id=user_id, error=str(e))
return True
async def require_auth( async def require_auth(
@@ -0,0 +1,172 @@
"""
PayPal 订单卡死恢复(reconciliation)
目的:当 capture-order 处理过程中 mcp-server 进程崩溃,订单会留在 status='processing'
状态,且 webhook 看到 'processing' 也会跳过不补救。这会导致用户付了钱但拿不到余额。
本模块周期性扫描 'processing' 订单:
- 询问 PayPal 该订单的真实状态
- 如果 PayPal 说**未 COMPLETED**:安全地把状态重置为 pending(webhook 或用户可以再次尝试)
- 如果 PayPal 说**已 COMPLETED**:发出 CRITICAL 告警,**不**自动加余额(避免错误金额或重复加款)
→ 由运维人员手动核对 PayPal 后台后再补充值
关键设计:
- 不在自动恢复路径上加余额。任何"PayPal 已收钱但 mcp 这边状态卡了"的情况都人工兜底。
- 状态重置使用 CAS(UPDATE WHERE status='processing'),与 capture/webhook 路径同样的并发安全模型。
"""
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List
import structlog
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from database import AsyncSessionLocal
from models import RechargeRecord
from .paypal_client import get_paypal_client
logger = structlog.get_logger(__name__)
# 订单在 'processing' 状态超过该时长视为卡死(PayPal capture 通常 1-3 秒完成)
STUCK_THRESHOLD_MINUTES = 15
# 重置后的状态映射 — PayPal 真实状态 -> 我们的本地状态
PAYPAL_STATUS_TO_LOCAL = {
"CREATED": "pending", # 未支付,可重试
"SAVED": "pending",
"APPROVED": "pending", # 已批准未捕获,webhook 或用户可触发
"PAYER_ACTION_REQUIRED": "pending",
"VOIDED": "failed", # 已取消
}
async def _list_stuck_orders(db: AsyncSession) -> List[RechargeRecord]:
"""查询所有卡死的 paypal 订单"""
threshold = datetime.utcnow() - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
result = await db.execute(
select(RechargeRecord).where(
RechargeRecord.status == "processing",
RechargeRecord.payment_method == "paypal",
RechargeRecord.updated_at < threshold,
).limit(50)
)
return list(result.scalars().all())
async def _reset_processing_to(db: AsyncSession, record_id, new_status: str) -> bool:
"""
使用 CAS 把 processing 状态重置为目标状态。
返回 True 表示重置成功(仍持有 processing 锁);False 表示已被并发改动,本次跳过。
"""
result = await db.execute(
text(
"UPDATE recharge_records SET status=:new, updated_at=NOW() "
"WHERE id=:id AND status='processing' RETURNING id"
),
{"new": new_status, "id": record_id}
)
row = result.first()
await db.commit()
return row is not None
async def reconcile_stuck_paypal_orders() -> Dict[str, int]:
"""
扫描并恢复卡死的 PayPal processing 订单。
Returns:
统计字典: {scanned, reset_pending, reset_failed, needs_manual, errors}
"""
stats = {
"scanned": 0,
"reset_pending": 0,
"reset_failed": 0,
"needs_manual": 0,
"errors": 0,
}
async with AsyncSessionLocal() as db:
try:
stuck = await _list_stuck_orders(db)
except Exception as e:
logger.error("paypal_reconcile_list_failed", error=str(e))
stats["errors"] += 1
return stats
stats["scanned"] = len(stuck)
if not stuck:
return stats
logger.info("paypal_reconcile_started", stuck_count=len(stuck))
client = get_paypal_client()
for record in stuck:
try:
paypal_info = await client.get_order(record.order_id)
paypal_status = (paypal_info or {}).get("status")
except Exception as e:
# PayPal 查询失败:保持 processing,下次再试
logger.warning(
"paypal_reconcile_query_failed",
order_id=record.order_id,
error=str(e)
)
stats["errors"] += 1
continue
if paypal_status == "COMPLETED":
# PayPal 已收钱但本地未到账 — 不自动处理,告警
logger.critical(
"paypal_order_stuck_needs_manual_reconcile",
order_id=record.order_id,
user_id=str(record.user_id),
amount=float(record.amount),
local_status="processing",
paypal_status="COMPLETED",
stuck_for_minutes=(datetime.utcnow() - record.updated_at).total_seconds() / 60,
)
stats["needs_manual"] += 1
continue
target_status = PAYPAL_STATUS_TO_LOCAL.get(paypal_status)
if target_status is None:
# 未知状态,保守不动,仅日志
logger.warning(
"paypal_reconcile_unknown_status",
order_id=record.order_id,
paypal_status=paypal_status,
)
continue
try:
async with AsyncSessionLocal() as tx_db:
ok = await _reset_processing_to(tx_db, record.id, target_status)
if ok:
if target_status == "pending":
stats["reset_pending"] += 1
elif target_status == "failed":
stats["reset_failed"] += 1
logger.info(
"paypal_reconcile_reset",
order_id=record.order_id,
from_status="processing",
to_status=target_status,
paypal_status=paypal_status,
)
else:
logger.info(
"paypal_reconcile_skipped_concurrent_change",
order_id=record.order_id,
)
except Exception as e:
logger.error(
"paypal_reconcile_reset_failed",
order_id=record.order_id,
error=str(e)
)
stats["errors"] += 1
logger.info("paypal_reconcile_completed", **stats)
return stats
@@ -414,6 +414,21 @@ async def periodic_billing_task():
if quota_stats.get('inconsistent_tenants', 0) > 0: if quota_stats.get('inconsistent_tenants', 0) > 0:
stats['quota_check'] = quota_stats stats['quota_check'] = quota_stats
# 3. PayPal 卡死订单恢复(独立 session,不影响计费循环)
try:
from .paypal_reconciliation import reconcile_stuck_paypal_orders
rec_stats = await reconcile_stuck_paypal_orders()
if rec_stats.get("scanned", 0) > 0:
logger.info(
f"PayPal reconcile: scanned={rec_stats['scanned']} "
f"reset_pending={rec_stats['reset_pending']} "
f"reset_failed={rec_stats['reset_failed']} "
f"needs_manual={rec_stats['needs_manual']} "
f"errors={rec_stats['errors']}"
)
except Exception as e:
logger.error(f"PayPal reconcile 异常: {e}")
except Exception as e: except Exception as e:
logger.error(f"周期性计费任务异常: {e}") logger.error(f"周期性计费任务异常: {e}")
@@ -13,6 +13,7 @@ from . import (
billing_webhook, # LiteLLM Token计费webhook billing_webhook, # LiteLLM Token计费webhook
external_tools, # 外部数据工具管理 external_tools, # 外部数据工具管理
paypal, # PayPal 支付集成 paypal, # PayPal 支付集成
resources, resource_grants, # Heicode P1:资源绑定与授权
) )
@@ -49,5 +50,8 @@ def register_routes(app: FastAPI) -> None:
# PayPal 支付路由 # PayPal 支付路由
paypal.router, # PayPal 用户支付路由(需要认证) paypal.router, # PayPal 用户支付路由(需要认证)
paypal.webhook_router, # PayPal Webhook 回调路由(无需认证,在 whitelist 路径下) paypal.webhook_router, # PayPal Webhook 回调路由(无需认证,在 whitelist 路径下)
# Heicode P1: 资源绑定与授权
resources.router,
resource_grants.router,
): ):
app.include_router(router) app.include_router(router)
-1
View File
@@ -293,7 +293,6 @@ async def create_admin(
role=req.role, role=req.role,
channel_id=channel_id, # 关联到渠道 channel_id=channel_id, # 关联到渠道
status="active", status="active",
balance=0,
credit_limit=0, credit_limit=0,
) )
+211 -109
View File
@@ -3,7 +3,7 @@
""" """
from datetime import timedelta from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -36,6 +36,7 @@ from app.schemas import (
UserCreate, UserCreate,
) )
from app.email_verification import verify_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code from app.email_verification import verify_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code
from app.audit import log_audit_event
from app.agent_manager_client import get_agent_manager_client, AgentManagerError from app.agent_manager_client import get_agent_manager_client, AgentManagerError
from app.litellm_client import get_litellm_client, LiteLLMClientError from app.litellm_client import get_litellm_client, LiteLLMClientError
from config import settings from config import settings
@@ -49,6 +50,40 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/auth", tags=["认证"]) router = APIRouter(prefix="/api/auth", tags=["认证"])
class _LoginRateLimit:
"""每 IP 维度对登录请求限流(默认 5 次/60s,超出 429)。
多副本部署下每副本独立计数,可接受。"""
def __init__(self, max_attempts: int = 5, window_seconds: int = 60):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self._attempts: dict = {}
def _client_ip(self, request) -> str:
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
async def __call__(self, request: Request):
import time
from collections import deque
ip = self._client_ip(request)
now = time.time()
bucket = self._attempts.setdefault(ip, deque())
while bucket and bucket[0] < now - self.window_seconds:
bucket.popleft()
if len(bucket) >= self.max_attempts:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="登录尝试过于频繁,请稍后再试",
headers={"Retry-After": str(self.window_seconds)},
)
bucket.append(now)
_login_rate_limit = _LoginRateLimit()
def _mask_api_key(key: str) -> str: def _mask_api_key(key: str) -> str:
"""隐藏API密钥的中间部分""" """隐藏API密钥的中间部分"""
if len(key) <= 12: if len(key) <= 12:
@@ -57,7 +92,7 @@ def _mask_api_key(key: str) -> str:
@router.post("/login", response_model=SuccessResponse) @router.post("/login", response_model=SuccessResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db), _: None = Depends(_login_rate_limit)):
""" """
用户/渠道/管理员/供应商登录 用户/渠道/管理员/供应商登录
@@ -70,121 +105,185 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
- super_admin: 超级管理员 - super_admin: 超级管理员
- provider: 供应商管理员 - provider: 供应商管理员
""" """
# 根据角色查找用户 success = False
if req.role == "channel": result_user_id: Optional[str] = None
# 渠道管理员登录 error_msg: Optional[str] = None
result = await db.execute(select(Channel).where(Channel.email == req.email)) try:
entity = result.scalar_one_or_none() # 根据角色查找用户
if req.role == "channel":
# 渠道管理员登录
result = await db.execute(select(Channel).where(Channel.email == req.email))
entity = result.scalar_one_or_none()
if not entity or not verify_password(req.password, entity.password_hash): if not entity or not verify_password(req.password, entity.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱或密码错误" detail="邮箱或密码错误"
)
# 更新最后登录时间
from datetime import datetime
entity.last_login_at = datetime.utcnow()
await db.commit()
# 创建JWT token
token_data = {
"sub": str(entity.id),
"email": entity.email,
"role": "channel_admin",
"channelId": str(entity.id),
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
success = True
result_user_id = str(entity.id)
return SuccessResponse(
data={
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(entity.id),
"name": entity.name,
"email": entity.email,
"role": "channel_admin",
"channelId": str(entity.id),
}
}
) )
# 更新最后登录时间 else:
from datetime import datetime # 用户/管理员/供应商登录
entity.last_login_at = datetime.utcnow() result = await db.execute(select(User).where(User.email == req.email))
await db.commit() user = result.scalar_one_or_none()
# 创建JWT token if not user:
token_data = { raise HTTPException(
"sub": str(entity.id), status_code=status.HTTP_401_UNAUTHORIZED,
"email": entity.email, detail="邮箱或密码错误"
"role": "channel_admin", )
"channelId": str(entity.id),
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
return SuccessResponse( # 验证密码(兼容两种密码字段)
data={ password_hash = user.password_hash or user.hashed_password
"token": access_token, if not password_hash or not verify_password(req.password, password_hash):
"refreshToken": refresh_token, raise HTTPException(
"user": { status_code=status.HTTP_401_UNAUTHORIZED,
"id": str(entity.id), detail="邮箱或密码错误"
"name": entity.name, )
"email": entity.email,
"role": "channel_admin", # 验证用户状态
"channelId": str(entity.id), if hasattr(user, 'status') and user.status == "inactive":
} raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账户已被禁用"
)
# 验证角色
user_role = user.role
# 角色验证逻辑
valid_roles = {
"super_admin": ["super_admin"],
"admin": ["admin", "super_admin"],
"billing_admin": ["billing_admin", "admin", "super_admin"],
"operations_admin": ["operations_admin", "admin", "super_admin"],
"user": ["user"],
"provider": ["provider_admin"],
} }
allowed_roles = valid_roles.get(req.role, [])
if user_role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"权限不足,当前角色: {user_role}"
)
# 更新最后登录时间
from datetime import datetime
user.last_login_at = datetime.utcnow()
await db.commit()
# 创建JWT token
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user_role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
success = True
result_user_id = str(user.id)
return SuccessResponse(
data={
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(user.id),
"name": user.name or user.full_name,
"email": user.email,
"role": user_role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
}
)
except HTTPException as e:
error_msg = e.detail if isinstance(e.detail, str) else str(e.detail)
raise
finally:
try:
await log_audit_event(
action="auth.login",
resource_type="user",
resource_id=req.email,
user_id=result_user_id,
success=success,
details={"role": req.role},
error_message=error_msg,
request=request,
db=db,
)
except Exception as audit_exc:
logger.warning("auth_login_audit_failed", error=str(audit_exc))
@router.get("/me", response_model=SuccessResponse)
async def get_current_user(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""
获取当前登录用户信息
"""
user_id = principal.get("user_id")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
) )
else: if getattr(user, "status", None) == "inactive":
# 用户/管理员/供应商登录 raise HTTPException(
result = await db.execute(select(User).where(User.email == req.email)) status_code=status.HTTP_403_FORBIDDEN,
user = result.scalar_one_or_none() detail="账户已被禁用"
)
if not user: return SuccessResponse(
raise HTTPException( data={
status_code=status.HTTP_401_UNAUTHORIZED, "id": str(user.id),
detail="邮箱或密码错误"
)
# 验证密码(兼容两种密码字段)
password_hash = user.password_hash or user.hashed_password
if not password_hash or not verify_password(req.password, password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱或密码错误"
)
# 验证用户状态
if hasattr(user, 'status') and user.status == "inactive":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账户已被禁用"
)
# 验证角色
user_role = user.role
# 角色验证逻辑
valid_roles = {
"super_admin": ["super_admin"],
"admin": ["admin", "super_admin"],
"billing_admin": ["billing_admin", "admin", "super_admin"],
"operations_admin": ["operations_admin", "admin", "super_admin"],
"user": ["user"],
"provider": ["provider_admin"],
}
allowed_roles = valid_roles.get(req.role, [])
if user_role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"权限不足,当前角色: {user_role}"
)
# 更新最后登录时间
from datetime import datetime
user.last_login_at = datetime.utcnow()
await db.commit()
# 创建JWT token
token_data = {
"sub": str(user.id),
"email": user.email, "email": user.email,
"role": user_role, "name": user.name or user.full_name,
"channelId": str(user.channel_id) if user.channel_id else None, "role": user.role,
"channelId": str(user.channel_id) if user.channel_id is not None else None,
"status": user.status,
"subscriptionTier": getattr(user, "subscription_tier", None),
"lastLoginAt": user.last_login_at.isoformat() if user.last_login_at is not None else None,
} }
access_token = create_access_token(data=token_data) )
refresh_token = create_refresh_token(data=token_data)
return SuccessResponse(
data={
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(user.id),
"name": user.name or user.full_name,
"email": user.email,
"role": user_role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
}
)
@router.post("/logout", response_model=SuccessResponse) @router.post("/logout", response_model=SuccessResponse)
@@ -264,9 +363,12 @@ async def refresh_token_endpoint(
user_id = principal.get("user_id") user_id = principal.get("user_id")
claims = principal.get("claims", {}) claims = principal.get("claims", {})
# 验证是否为 refresh token(可选,如果前端确保传入的是 refresh token) # 验证是否为 refresh token
token_type = claims.get("type") if claims.get("type") != "refresh":
# 为了向后兼容,不强制要求 type 为 refresh raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="必须使用 refresh token 调用此接口"
)
# 查询用户信息 # 查询用户信息
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
@@ -237,7 +237,6 @@ async def create_tenant(
channel_id=channel_id, channel_id=channel_id,
subscription_tier=req.subscriptionTier, subscription_tier=req.subscriptionTier,
status="active", status="active",
balance=0,
credit_limit=0, credit_limit=0,
) )
@@ -2077,7 +2076,6 @@ async def create_channel_admin(
role=req.role, role=req.role,
channel_id=channel_id, # 自动设置为当前渠道 channel_id=channel_id, # 自动设置为当前渠道
status="active", status="active",
balance=0,
credit_limit=0, credit_limit=0,
) )
@@ -557,245 +557,6 @@ async def channel_agents_available(db: AsyncSession = Depends(get_db)) -> Dict[s
} }
@router.get("/channel/tenants")
async def channel_tenants(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""获取渠道下的租户列表(需要认证)"""
# 从token中获取渠道ID
role = principal.get("claims", {}).get("role", "")
channel_id_str = principal.get("claims", {}).get("channelId")
if not channel_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
try:
channel_id = uuid.UUID(channel_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID"
)
# 查询该渠道下的所有租户(role="user"的用户)
result = await db.execute(
select(User).where(
User.channel_id == channel_id,
User.role == "user"
)
)
tenants = result.scalars().all()
items = [
{
"id": str(t.id),
"name": t.name or t.full_name,
"email": t.email,
"subscriptionTier": getattr(t, "subscription_tier", "free"),
"balance": float(getattr(t, "balance", 0)),
"creditLimit": float(getattr(t, "credit_limit", 0)),
"status": getattr(t, "status", "active"),
"channelId": str(t.channel_id) if t.channel_id else None,
}
for t in tenants
]
return {"items": items, "count": len(items)}
@router.post("/channel/tenants/create")
async def channel_create_tenant(
payload: Dict[str, Any],
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""创建租户(需要认证,自动关联到当前渠道)"""
# 从token中获取渠道ID
role = principal.get("claims", {}).get("role", "")
channel_id_str = principal.get("claims", {}).get("channelId")
if not channel_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
try:
channel_id = uuid.UUID(channel_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID"
)
# 验证必需字段
if not payload.get("name"):
raise HTTPException(status_code=400, detail="name is required")
if not payload.get("email"):
raise HTTPException(status_code=400, detail="email is required")
if not payload.get("password"):
raise HTTPException(status_code=400, detail="password is required")
# 检查邮箱是否已存在
result = await db.execute(
select(User).where(User.email == payload["email"])
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱已被使用"
)
# 创建租户(User with role="user")
password_hash = get_password_hash(payload["password"])
tenant = User(
name=payload["name"],
email=payload["email"],
password_hash=password_hash,
hashed_password=password_hash,
username=payload["email"].split("@")[0],
full_name=payload["name"],
role="user",
channel_id=channel_id,
subscription_tier=payload.get("subscriptionTier", "free"),
status="active",
balance=0,
credit_limit=0,
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {
"id": str(tenant.id),
"name": tenant.name or tenant.full_name,
"email": tenant.email,
"subscriptionTier": getattr(tenant, "subscription_tier", "free"),
}
@router.put("/channel/tenants/{tenant_id}/resources")
async def channel_update_tenant_resources(tenant_id: str, payload: Dict[str, Any], db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
tenant = await db.get(Tenant, uuid.UUID(tenant_id)) if tenant_id else None
if not tenant:
raise HTTPException(status_code=404, detail="tenant not found")
tenant.subscription_tier = payload.get("subscriptionTier", tenant.subscription_tier)
tenant.discount = payload.get("discount", tenant.discount)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {
"id": str(tenant.id),
"name": tenant.name,
"subscriptionTier": tenant.subscription_tier,
"discount": tenant.discount,
}
@router.put("/channel/tenants/{tenant_id}/billing")
async def channel_update_tenant_billing(tenant_id: str, payload: Dict[str, Any], db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
tenant = await db.get(Tenant, uuid.UUID(tenant_id)) if tenant_id else None
if not tenant:
raise HTTPException(status_code=404, detail="tenant not found")
tenant.subscription_tier = payload.get("subscriptionTier", tenant.subscription_tier)
tenant.discount = payload.get("discount", tenant.discount)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {
"id": str(tenant.id),
"subscriptionTier": tenant.subscription_tier,
"discount": tenant.discount,
}
@router.delete("/channel/tenants/{tenant_id}")
async def channel_delete_tenant(tenant_id: str, db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""删除租户(软删除)"""
tenant = await db.get(Tenant, uuid.UUID(tenant_id)) if tenant_id else None
if not tenant:
raise HTTPException(status_code=404, detail="tenant not found")
# 软删除:标记为不活跃
tenant.status = "inactive"
db.add(tenant)
await db.commit()
return {
"id": str(tenant.id),
"name": tenant.name,
"deleted": True,
"message": "租户已删除"
}
@router.put("/channel/tenants/{tenant_id}/status")
async def channel_update_tenant_status(tenant_id: str, payload: Dict[str, Any], db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""更新租户状态"""
tenant = await db.get(Tenant, uuid.UUID(tenant_id)) if tenant_id else None
if not tenant:
raise HTTPException(status_code=404, detail="tenant not found")
new_status = payload.get("status")
if new_status not in ["active", "inactive", "suspended"]:
raise HTTPException(status_code=400, detail="status must be active, inactive, or suspended")
old_status = tenant.status
tenant.status = new_status
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {
"tenantId": str(tenant.id),
"name": tenant.name,
"oldStatus": old_status,
"newStatus": new_status,
}
@router.put("/channel/tenants/{tenant_id}/permissions")
async def channel_update_tenant_permissions(tenant_id: str, payload: Dict[str, Any], db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""更新租户权限"""
tenant = await db.get(Tenant, uuid.UUID(tenant_id)) if tenant_id else None
if not tenant:
raise HTTPException(status_code=404, detail="tenant not found")
permissions = payload.get("permissions", [])
# 有效权限列表
valid_permissions = {
"use:platform_agents",
"use:custom_agents",
"create:agents",
"read:billing",
"export:data",
}
# 验证权限
invalid_permissions = set(permissions) - valid_permissions
if invalid_permissions:
raise HTTPException(
status_code=400,
detail=f"无效的权限: {', '.join(invalid_permissions)}"
)
# 更新权限(存储在metadata中如果没有专门的permissions字段)
if hasattr(tenant, 'permissions'):
tenant.permissions = permissions
db.add(tenant)
await db.commit()
return {
"tenantId": str(tenant.id),
"name": tenant.name,
"permissions": permissions,
}
@router.get("/channel/resources/agents") @router.get("/channel/resources/agents")
async def channel_resources_agents(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: async def channel_resources_agents(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
quotas = (await db.execute(select(ChannelAgentQuota))).scalars().all() quotas = (await db.execute(select(ChannelAgentQuota))).scalars().all()
@@ -833,43 +594,6 @@ async def channel_resources_models(db: AsyncSession = Depends(get_db)) -> Dict[s
} }
@router.post("/channel/resources/apply")
async def channel_resources_apply(payload: Dict[str, Any]) -> Dict[str, Any]:
request_id = str(uuid.uuid4())
store.resource_applications[request_id] = {"id": request_id, **payload, "status": "pending"}
return store.resource_applications[request_id]
@router.get("/channel/billing/stats")
async def channel_billing_stats() -> Dict[str, Any]:
return {
"totalEU": sum(rec.get("eu", 0) for rec in store.billing_history),
"totalCost": sum(rec.get("cost", 0) for rec in store.billing_history),
"records": store.billing_history,
}
@router.get("/channel/admins")
async def channel_admins() -> Dict[str, Any]:
return {"items": list(store.channel_admins.values())}
@router.post("/channel/admins/create")
async def channel_admins_create(payload: Dict[str, Any]) -> Dict[str, Any]:
admin_id = str(uuid.uuid4())
admin = {"id": admin_id, **payload, "createdAt": _now()}
store.channel_admins[admin_id] = admin
return admin
@router.put("/channel/admins/{admin_id}/permissions")
async def channel_admins_permissions(admin_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if admin_id not in store.channel_admins:
raise HTTPException(status_code=404, detail="admin not found")
store.channel_admins[admin_id]["permissions"] = payload.get("permissions", [])
return store.channel_admins[admin_id]
# ----- Super Admin ----- # ----- Super Admin -----
+176 -69
View File
@@ -17,7 +17,7 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from cryptography import x509 from cryptography import x509
@@ -181,28 +181,51 @@ async def capture_paypal_order(
""" """
user_id = principal.get("user_id") user_id = principal.get("user_id")
# 查找本地订单记录 # 原子化 pending -> processing 转换,防止并发竞态
result = await db.execute( cas_result = await db.execute(
select(RechargeRecord).where( text(
RechargeRecord.order_id == req.orderId, "UPDATE recharge_records SET status='processing', updated_at=NOW() "
RechargeRecord.user_id == user_id, "WHERE order_id=:oid AND user_id=:uid AND payment_method='paypal' "
RechargeRecord.payment_method == "paypal" "AND status='pending' RETURNING id, amount"
) ),
{"oid": req.orderId, "uid": user_id}
) )
recharge_record = result.scalar_one_or_none() cas_row = cas_result.first()
if not recharge_record: if cas_row is None:
raise HTTPException( # CAS 失败,查询现状以返回准确错误
status_code=status.HTTP_404_NOT_FOUND, existing = await db.execute(
detail="订单不存在或不属于当前用户" select(RechargeRecord).where(
RechargeRecord.order_id == req.orderId,
RechargeRecord.user_id == user_id,
RechargeRecord.payment_method == "paypal"
)
) )
existing_record = existing.scalar_one_or_none()
if recharge_record.status == "success": await db.commit()
if not existing_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="订单不存在或不属于当前用户"
)
if existing_record.status == "success":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="订单已完成,请勿重复操作"
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="订单已完成,请勿重复操作" detail=f"订单状态不允许捕获: {existing_record.status}"
) )
await db.commit()
# 重新加载 ORM 实例以便后续更新
result = await db.execute(
select(RechargeRecord).where(RechargeRecord.id == cas_row.id)
)
recharge_record = result.scalar_one()
try: try:
# 捕获 PayPal 订单 # 捕获 PayPal 订单
paypal_client = get_paypal_client() paypal_client = get_paypal_client()
@@ -264,6 +287,15 @@ async def capture_paypal_order(
if not success: if not success:
await db.rollback() await db.rollback()
# 恢复 processing -> pending,允许后续重试
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": req.orderId}
)
await db.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"余额更新失败: {message}" detail=f"余额更新失败: {message}"
@@ -329,6 +361,18 @@ async def capture_paypal_order(
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
# 显式将 processing 恢复为 pending,避免订单卡死
try:
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": req.orderId}
)
await db.commit()
except Exception:
await db.rollback()
logger.error( logger.error(
"捕获 PayPal 订单失败", "捕获 PayPal 订单失败",
order_id=req.orderId, order_id=req.orderId,
@@ -536,23 +580,49 @@ async def _process_capture_completed(
logger.warning("Webhook 中未找到订单 ID", capture_id=capture_id) logger.warning("Webhook 中未找到订单 ID", capture_id=capture_id)
return False return False
# 查找本地订单记录 # 原子化 pending -> processing 转换
result = await db.execute( cas_result = await db.execute(
select(RechargeRecord).where( text(
RechargeRecord.order_id == order_id, "UPDATE recharge_records SET status='processing', updated_at=NOW() "
RechargeRecord.payment_method == "paypal" "WHERE order_id=:oid AND payment_method='paypal' "
) "AND status='pending' RETURNING id"
),
{"oid": order_id}
) )
recharge_record = result.scalar_one_or_none() cas_row = cas_result.first()
if not recharge_record: if cas_row is None:
logger.warning("Webhook: 订单不存在", order_id=order_id, capture_id=capture_id) # CAS 失败,查询现状判断幂等还是冲突
existing = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == order_id,
RechargeRecord.payment_method == "paypal"
)
)
existing_record = existing.scalar_one_or_none()
await db.commit()
if not existing_record:
logger.warning("Webhook: 订单不存在", order_id=order_id, capture_id=capture_id)
return False
if existing_record.status == "success":
logger.info("Webhook: 订单已完成,跳过处理", order_id=order_id)
return True
if existing_record.status == "processing":
logger.info("Webhook: 订单正在处理中,跳过", order_id=order_id)
return False
logger.warning(
"Webhook: 订单状态不允许处理",
order_id=order_id,
status=existing_record.status
)
return False return False
# 如果订单已完成,跳过处理 await db.commit()
if recharge_record.status == "success":
logger.info("Webhook: 订单已完成,跳过处理", order_id=order_id) result = await db.execute(
return True select(RechargeRecord).where(RechargeRecord.id == cas_row.id)
)
recharge_record = result.scalar_one()
# 获取支付金额 # 获取支付金额
amount_info = resource.get("amount", {}) amount_info = resource.get("amount", {})
@@ -600,6 +670,15 @@ async def _process_capture_completed(
if not success: if not success:
logger.error("Webhook: 余额更新失败", order_id=order_id, error=message) logger.error("Webhook: 余额更新失败", order_id=order_id, error=message)
await db.rollback() await db.rollback()
# 恢复 processing -> pending,允许重试
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": order_id}
)
await db.commit()
return False return False
# 更新订单状态 # 更新订单状态
@@ -688,53 +767,78 @@ async def paypal_webhook(
transmission_id=transmission_id transmission_id=transmission_id
) )
# ========== 签名验证 ========== # ========== 签名验证(fail-closed)==========
webhook_id = settings.paypal_webhook_id webhook_id = settings.paypal_webhook_id
signature_valid = False
if webhook_id and transmission_id and transmission_sig and cert_url: if not webhook_id:
signature_valid = await verify_webhook_signature( logger.error("PayPal Webhook ID 未配置,拒绝处理")
transmission_id=transmission_id, raise HTTPException(
transmission_time=transmission_time, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
webhook_id=webhook_id, detail="webhook_id not configured"
event_body=body_str,
cert_url=cert_url,
transmission_sig=transmission_sig,
auth_algo=auth_algo or "SHA256withRSA"
) )
if not signature_valid: if not (transmission_id and transmission_sig and cert_url and transmission_time):
logger.warning(
"PayPal Webhook 签名验证失败",
event_type=event_type,
transmission_id=transmission_id
)
# 记录审计日志但继续处理(避免丢失合法请求)
await log_audit_event(
action="payment.paypal.webhook_signature_invalid",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=False,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
"transmission_id": transmission_id,
},
error_message="Webhook 签名验证失败",
request=request,
db=db
)
# 生产环境应该拒绝无效签名的请求
# 但为了避免配置问题导致丢失合法请求,这里只记录警告
# return {"success": False, "error": "Invalid signature"}
else:
logger.warning( logger.warning(
"PayPal Webhook 缺少签名信息", "PayPal Webhook 缺少签名头",
webhook_id_configured=bool(webhook_id),
has_transmission_id=bool(transmission_id), has_transmission_id=bool(transmission_id),
has_signature=bool(transmission_sig), has_signature=bool(transmission_sig),
has_cert_url=bool(cert_url) has_cert_url=bool(cert_url),
has_transmission_time=bool(transmission_time)
)
await log_audit_event(
action="payment.paypal.webhook_missing_headers",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=False,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
"transmission_id": transmission_id,
},
error_message="Webhook 缺少签名头",
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing signature headers"
)
signature_valid = await verify_webhook_signature(
transmission_id=transmission_id,
transmission_time=transmission_time,
webhook_id=webhook_id,
event_body=body_str,
cert_url=cert_url,
transmission_sig=transmission_sig,
auth_algo=auth_algo or "SHA256withRSA"
)
if not signature_valid:
logger.warning(
"PayPal Webhook 签名验证失败",
event_type=event_type,
transmission_id=transmission_id
)
await log_audit_event(
action="payment.paypal.webhook_signature_invalid",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=False,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
"transmission_id": transmission_id,
},
error_message="Webhook 签名验证失败",
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid signature"
) )
# ========== 审计日志:Webhook 接收 ========== # ========== 审计日志:Webhook 接收 ==========
@@ -795,6 +899,9 @@ async def paypal_webhook(
return {"success": True} return {"success": True}
except HTTPException:
# 签名/配置失败需要返回真实状态码,不能被吞掉
raise
except Exception as e: except Exception as e:
logger.error( logger.error(
"处理 PayPal Webhook 失败", "处理 PayPal Webhook 失败",
@@ -0,0 +1,250 @@
"""
资源授权(Heicode P1)路由
POST /api/resource-grants 创建授权
GET /api/resource-grants 列表
GET /api/resource-grants/{id} 详情
DELETE /api/resource-grants/{id} 撤销(status=revoked)
依据:heicode.md §五 / plan.md §P1。
约束:
- 必须基于已存在且属于同一用户的 ResourceBinding 创建。
- allowed_actions 必须是 binding.permission_scope 的子集。
- constraints 不得放宽 binding.constraints。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import ResourceBinding, ResourceGrant
from app.auth import require_auth
from app.schemas import SuccessResponse
from app.routes.resources import reject_sensitive_keys
router = APIRouter(prefix="/api/resource-grants", tags=["资源授权"])
ALLOWED_GRANT_STATUS = {"active", "suspended", "revoked", "expired"}
# ==================== Pydantic schemas ====================
class ResourceGrantCreate(BaseModel):
resource_id: str
binding_scope: str
role: Optional[str] = None
agent_id: Optional[str] = None
allowed_actions: List[str] = Field(default_factory=list)
constraints: Dict[str, Any] = Field(default_factory=dict)
expires_at: Optional[datetime] = None
status: str = "active"
@field_validator("status")
@classmethod
def _check_status(cls, v: str) -> str:
if v not in ALLOWED_GRANT_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_GRANT_STATUS)}, got '{v}'"
)
return v
def _to_dict(g: ResourceGrant) -> Dict[str, Any]:
return {
"id": str(g.id),
"user_id": str(g.user_id),
"binding_scope": g.binding_scope,
"resource_id": str(g.resource_id),
"role": g.role,
"agent_id": str(g.agent_id) if g.agent_id else None,
"allowed_actions": g.allowed_actions or [],
"constraints": g.constraints or {},
"status": g.status,
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
"created_by": str(g.created_by) if g.created_by else None,
"revoked_by": str(g.revoked_by) if g.revoked_by else None,
"created_at": g.created_at.isoformat() if g.created_at else None,
"revoked_at": g.revoked_at.isoformat() if g.revoked_at else None,
}
def _current_user_id(principal: dict) -> uuid.UUID:
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
if not uid:
raise HTTPException(status_code=401, detail="未登录")
try:
return uuid.UUID(str(uid))
except Exception:
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
def _validate_subset_actions(allowed: List[str], parent_scope: List[str]) -> None:
"""allowed_actions 必须是 parent permission_scope 的子集。"""
extra = set(allowed) - set(parent_scope or [])
if extra:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "RESOURCE_GRANT_INVALID",
"message": f"allowed_actions 超出对应 binding.permission_scope: {sorted(extra)}",
},
)
# ==================== 路由 ====================
@router.post("", response_model=SuccessResponse)
async def create_resource_grant(
payload: ResourceGrantCreate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
# 校验:拒绝明文凭据
body = payload.model_dump()
reject_sensitive_keys(body.get("constraints"), "constraints.")
reject_sensitive_keys(body.get("allowed_actions"), "allowed_actions.")
# 解析 resource_id
try:
rid = uuid.UUID(payload.resource_id)
except Exception:
raise HTTPException(status_code=400, detail="resource_id 格式无效")
binding = await db.get(ResourceBinding, rid)
if binding is None:
raise HTTPException(
status_code=404,
detail={"code": "NOT_FOUND", "message": "对应 ResourceBinding 不存在"},
)
if binding.user_id != user_id:
raise HTTPException(
status_code=403,
detail={"code": "FORBIDDEN_SCOPE", "message": "不能基于他人的资源创建授权"},
)
if binding.status not in ("active", "pending"):
raise HTTPException(
status_code=400,
detail={
"code": "RESOURCE_GRANT_INVALID",
"message": f"binding 状态为 {binding.status},不允许新建授权",
},
)
# allowed_actions 子集校验
_validate_subset_actions(payload.allowed_actions, binding.permission_scope or [])
agent_uid: Optional[uuid.UUID] = None
if payload.agent_id:
try:
agent_uid = uuid.UUID(payload.agent_id)
except Exception:
raise HTTPException(status_code=400, detail="agent_id 格式无效")
grant = ResourceGrant(
user_id=user_id,
binding_scope=payload.binding_scope,
resource_id=rid,
role=payload.role,
agent_id=agent_uid,
allowed_actions=payload.allowed_actions,
constraints=payload.constraints,
status=payload.status,
expires_at=payload.expires_at,
created_by=user_id,
)
db.add(grant)
await db.commit()
await db.refresh(grant)
return SuccessResponse(data=_to_dict(grant))
@router.get("", response_model=SuccessResponse)
async def list_resource_grants(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
resource_id: Optional[str] = Query(None),
role: Optional[str] = Query(None),
binding_scope: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
user_id = _current_user_id(principal)
stmt = select(ResourceGrant).where(ResourceGrant.user_id == user_id)
if resource_id:
try:
rid = uuid.UUID(resource_id)
except Exception:
raise HTTPException(status_code=400, detail="resource_id 格式无效")
stmt = stmt.where(ResourceGrant.resource_id == rid)
if role:
stmt = stmt.where(ResourceGrant.role == role)
if binding_scope:
stmt = stmt.where(ResourceGrant.binding_scope == binding_scope)
if status_filter:
stmt = stmt.where(ResourceGrant.status == status_filter)
stmt = stmt.order_by(ResourceGrant.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
items = [_to_dict(g) for g in result.scalars().all()]
return SuccessResponse(data={"items": items, "total": len(items), "offset": offset, "limit": limit})
async def _load_owned_grant(db: AsyncSession, grant_id: str, user_id: uuid.UUID) -> ResourceGrant:
try:
gid = uuid.UUID(grant_id)
except Exception:
raise HTTPException(status_code=400, detail="grant_id 格式无效")
grant = await db.get(ResourceGrant, gid)
if grant is None:
raise HTTPException(
status_code=404,
detail={"code": "NOT_FOUND", "message": "授权不存在"},
)
if grant.user_id != user_id:
raise HTTPException(
status_code=403,
detail={"code": "FORBIDDEN_SCOPE", "message": "无权访问该授权"},
)
return grant
@router.get("/{grant_id}", response_model=SuccessResponse)
async def get_resource_grant(
grant_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
grant = await _load_owned_grant(db, grant_id, user_id)
return SuccessResponse(data=_to_dict(grant))
@router.delete("/{grant_id}", response_model=SuccessResponse)
async def revoke_resource_grant(
grant_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""撤销授权(软删除:status=revoked + revoked_at)。"""
user_id = _current_user_id(principal)
grant = await _load_owned_grant(db, grant_id, user_id)
if grant.status == "revoked":
# 幂等
return SuccessResponse(data=_to_dict(grant))
grant.status = "revoked"
grant.revoked_by = user_id
grant.revoked_at = datetime.utcnow()
await db.commit()
await db.refresh(grant)
return SuccessResponse(data=_to_dict(grant))
+279
View File
@@ -0,0 +1,279 @@
"""
资源绑定(Heicode P1)路由
POST /api/resources 创建资源绑定
GET /api/resources 列表(按当前登录用户过滤)
GET /api/resources/{id} 详情
PUT /api/resources/{id} 更新
DELETE /api/resources/{id} 软删除(status=revoked)
依据:heicode.md §五 资源绑定与密钥托管 / plan.md §P1。
安全红线:写入前拒绝任何 metadata/constraints/permission_scope 字段名包含
password/token/secret/private_key/access_key/credential 的请求。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import ResourceBinding
from app.auth import require_auth
from app.schemas import SuccessResponse
router = APIRouter(prefix="/api/resources", tags=["资源绑定"])
# ==================== 共享:敏感字段拒绝 ====================
SENSITIVE_KEY_PATTERNS = (
"password", "token", "secret", "private_key", "access_key", "credential"
)
ALLOWED_RESOURCE_TYPES = {"git", "sk", "project_doc", "cloud_account", "cloud_resource"}
ALLOWED_BINDING_STATUS = {"pending", "active", "disabled", "revoked"}
def reject_sensitive_keys(data: Any, path: str = "") -> None:
"""递归检查 dict/list 中的 key,命中敏感词就 422 拒绝。
注意:白名单 key `secret_ref` 是引用而非密钥,单独跳过。
"""
if isinstance(data, dict):
for k, v in data.items():
kl = str(k).lower()
if kl == "secret_ref": # 引用允许通过
continue
for pat in SENSITIVE_KEY_PATTERNS:
if pat in kl:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "RESOURCE_GRANT_SECRET_REJECTED",
"message": (
f"Field '{path}{k}' contains sensitive keyword '{pat}'. "
f"Use secret_ref to reference secrets; never pass plaintext."
),
},
)
reject_sensitive_keys(v, f"{path}{k}.")
elif isinstance(data, list):
for i, item in enumerate(data):
reject_sensitive_keys(item, f"{path}[{i}].")
# ==================== Pydantic schemas ====================
class ResourceBindingCreate(BaseModel):
type: str
name: str
external_ref: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
permission_scope: List[str] = Field(default_factory=list)
constraints: Dict[str, Any] = Field(default_factory=dict)
secret_ref: Optional[str] = None
status: str = "pending"
@field_validator("type")
@classmethod
def _check_type(cls, v: str) -> str:
if v not in ALLOWED_RESOURCE_TYPES:
raise ValueError(
f"type must be one of {sorted(ALLOWED_RESOURCE_TYPES)}, got '{v}'"
)
return v
@field_validator("status")
@classmethod
def _check_status(cls, v: str) -> str:
if v not in ALLOWED_BINDING_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_BINDING_STATUS)}, got '{v}'"
)
return v
class ResourceBindingUpdate(BaseModel):
name: Optional[str] = None
external_ref: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
permission_scope: Optional[List[str]] = None
constraints: Optional[Dict[str, Any]] = None
secret_ref: Optional[str] = None
status: Optional[str] = None
@field_validator("status")
@classmethod
def _check_status(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in ALLOWED_BINDING_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_BINDING_STATUS)}"
)
return v
def _to_dict(b: ResourceBinding) -> Dict[str, Any]:
return {
"id": str(b.id),
"user_id": str(b.user_id),
"type": b.type,
"name": b.name,
"external_ref": b.external_ref,
"metadata": b.binding_metadata or {},
"permission_scope": b.permission_scope or [],
"constraints": b.constraints or {},
"secret_ref": b.secret_ref,
"status": b.status,
"created_by": str(b.created_by) if b.created_by else None,
"updated_by": str(b.updated_by) if b.updated_by else None,
"created_at": b.created_at.isoformat() if b.created_at else None,
"updated_at": b.updated_at.isoformat() if b.updated_at else None,
}
def _current_user_id(principal: dict) -> uuid.UUID:
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
if not uid:
raise HTTPException(status_code=401, detail="未登录")
try:
return uuid.UUID(str(uid))
except Exception:
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
# ==================== 路由 ====================
@router.post("", response_model=SuccessResponse)
async def create_resource_binding(
payload: ResourceBindingCreate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""创建资源绑定。
安全:metadata / permission_scope / constraints 中不得出现敏感关键字(明文凭据)。
"""
user_id = _current_user_id(principal)
body = payload.model_dump()
# 校验:拒绝明文凭据
reject_sensitive_keys(body.get("metadata"), "metadata.")
reject_sensitive_keys(body.get("constraints"), "constraints.")
reject_sensitive_keys(body.get("permission_scope"), "permission_scope.")
binding = ResourceBinding(
user_id=user_id,
type=payload.type,
name=payload.name,
external_ref=payload.external_ref,
binding_metadata=payload.metadata,
permission_scope=payload.permission_scope,
constraints=payload.constraints,
secret_ref=payload.secret_ref,
status=payload.status,
created_by=user_id,
updated_by=user_id,
)
db.add(binding)
await db.commit()
await db.refresh(binding)
return SuccessResponse(data=_to_dict(binding))
@router.get("", response_model=SuccessResponse)
async def list_resource_bindings(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
type: Optional[str] = Query(None, description="按类型过滤"),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""列出当前登录用户的资源绑定。"""
user_id = _current_user_id(principal)
stmt = select(ResourceBinding).where(ResourceBinding.user_id == user_id)
if type is not None:
stmt = stmt.where(ResourceBinding.type == type)
if status_filter is not None:
stmt = stmt.where(ResourceBinding.status == status_filter)
stmt = stmt.order_by(ResourceBinding.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
items = [_to_dict(b) for b in result.scalars().all()]
return SuccessResponse(data={"items": items, "total": len(items), "offset": offset, "limit": limit})
async def _load_owned(db: AsyncSession, binding_id: str, user_id: uuid.UUID) -> ResourceBinding:
try:
bid = uuid.UUID(binding_id)
except Exception:
raise HTTPException(status_code=400, detail="binding_id 格式无效")
binding = await db.get(ResourceBinding, bid)
if binding is None:
raise HTTPException(status_code=404, detail="资源不存在")
if binding.user_id != user_id:
raise HTTPException(status_code=403, detail="无权访问该资源")
return binding
@router.get("/{binding_id}", response_model=SuccessResponse)
async def get_resource_binding(
binding_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
return SuccessResponse(data=_to_dict(binding))
@router.put("/{binding_id}", response_model=SuccessResponse)
async def update_resource_binding(
binding_id: str,
payload: ResourceBindingUpdate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
body = payload.model_dump(exclude_unset=True)
# 校验更新中的敏感字段
if "metadata" in body:
reject_sensitive_keys(body["metadata"], "metadata.")
if "constraints" in body:
reject_sensitive_keys(body["constraints"], "constraints.")
if "permission_scope" in body:
reject_sensitive_keys(body["permission_scope"], "permission_scope.")
for k, v in body.items():
if k == "metadata":
binding.binding_metadata = v
else:
setattr(binding, k, v)
binding.updated_by = user_id
binding.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(binding)
return SuccessResponse(data=_to_dict(binding))
@router.delete("/{binding_id}", response_model=SuccessResponse)
async def delete_resource_binding(
binding_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""软删除:status=revoked。所有关联 grants 也会因 ondelete=CASCADE 被清理(如果使用硬删除);
本接口默认软删除,保留历史审计。"""
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
binding.status = "revoked"
binding.updated_by = user_id
binding.updated_at = datetime.utcnow()
await db.commit()
return SuccessResponse(data={"id": str(binding.id), "status": binding.status})
@@ -72,7 +72,6 @@ async def create_super_admin():
is_active=True, is_active=True,
is_admin=True, is_admin=True,
status="active", status="active",
balance=0,
credit_limit=0, credit_limit=0,
) )
@@ -0,0 +1,71 @@
-- Migration 025: Heicode P1 资源模型
-- 新增 resource_bindings 和 resource_grants 两张表
-- 完全增量,不修改已有表/列。
--
-- 依据:
-- Docs/项目文档/heicode.md §五 资源绑定与密钥托管
-- Docs/项目文档/plan.md §P1 Manager 资源模型
--
-- 安全红线:DB 只保存 secret_ref,不保存明文密钥;任何 metadata/constraints/audit
-- 字段中出现 password/token/secret/private_key/access_key/credential 都应被应用层拒绝。
-- ==================== resource_bindings ====================
CREATE TABLE IF NOT EXISTS resource_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL,
-- 允许值:git | sk | project_doc | cloud_account | cloud_resource
name VARCHAR(255) NOT NULL,
external_ref TEXT,
-- repo URL / subscription ID / resource ID 等非密钥标识
binding_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
-- API 字段名 "metadata";列名加前缀避免与 SQLAlchemy 保留名冲突
permission_scope JSONB NOT NULL DEFAULT '[]'::jsonb,
-- 字符串数组,例如 ["repo:read", "repo:write:current-branch"]
constraints JSONB NOT NULL DEFAULT '{}'::jsonb,
secret_ref VARCHAR(500),
-- vault://... 等引用;为 NULL 表示该资源无凭据(如 project_doc)
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- 允许值:pending | active | disabled | revoked
created_by UUID,
updated_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_resource_bindings_user ON resource_bindings(user_id);
CREATE INDEX IF NOT EXISTS idx_resource_bindings_type ON resource_bindings(type);
CREATE INDEX IF NOT EXISTS idx_resource_bindings_status ON resource_bindings(status);
-- ==================== resource_grants ====================
CREATE TABLE IF NOT EXISTS resource_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
binding_scope VARCHAR(255) NOT NULL,
-- repo/ref/path 或云资源引用,作为 grant 的归属作用域
resource_id UUID NOT NULL REFERENCES resource_bindings(id) ON DELETE CASCADE,
role VARCHAR(100),
-- 子 Agnet 角色:product/frontend/backend/reviewer/ops/...
agent_id UUID,
-- 可空;为空表示授予下一次该角色部署
allowed_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
-- 必须是对应 binding.permission_scope 的子集
constraints JSONB NOT NULL DEFAULT '{}'::jsonb,
-- 不得放宽 binding.constraints 的限制
status VARCHAR(20) NOT NULL DEFAULT 'active',
-- 允许值:active | suspended | revoked | expired
expires_at TIMESTAMP WITH TIME ZONE,
created_by UUID,
revoked_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_resource_grants_user ON resource_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource ON resource_grants(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_grants_status ON resource_grants(status);
CREATE INDEX IF NOT EXISTS idx_resource_grants_binding_scope ON resource_grants(binding_scope);
CREATE INDEX IF NOT EXISTS idx_resource_grants_role ON resource_grants(role);
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Migration 025: Heicode P1 资源模型
新增 resource_bindings 和 resource_grants 两张表。
完全增量:不修改任何已有表/列,对现有业务零影响。
使用:
cd services/mcp-server
python migrations/run_025_add_resource_bindings_and_grants.py
或在 K8s pod 内(推荐生产):
kubectl exec -n taiji-ai deploy/mcp-server -- \
python migrations/run_025_add_resource_bindings_and_grants.py
"""
import asyncio
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
SQL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"025_add_resource_bindings_and_grants.sql")
async def run_migration():
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("❌ 错误:未设置 DATABASE_URL 环境变量")
return False
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
if "sslmode=" in database_url:
database_url = re.sub(r'[?&]sslmode=[^&]*', '', database_url).rstrip('?&')
print("📦 连接数据库...")
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
try:
# 1. 检查表是否已存在(幂等)
print("\n🔍 检查目标表是否已存在...")
result = await session.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('resource_bindings', 'resource_grants')
ORDER BY table_name
"""))
existing = {r[0] for r in result.fetchall()}
if existing:
print(f" 已存在的表: {sorted(existing)}")
else:
print(" 表均不存在,将创建")
# 2. 执行 SQL
print("\n🔄 执行 DDL...")
with open(SQL_FILE, encoding="utf-8") as f:
ddl = f.read()
# asyncpg 不支持多语句单 execute,但 sqlalchemy text() 也不分割;
# 对每一段以分号分隔的语句逐条执行(CREATE TABLE / CREATE INDEX 都是单条)
statements = [s.strip() for s in ddl.split(";") if s.strip() and not s.strip().startswith("--")]
# 过滤纯注释行
cleaned = []
for st in statements:
lines = [ln for ln in st.split("\n") if not ln.strip().startswith("--")]
core = "\n".join(lines).strip()
if core:
cleaned.append(core)
for stmt in cleaned:
short = stmt.split("\n")[0][:80]
print(f" -> {short}")
await session.execute(text(stmt))
await session.commit()
print("✅ DDL 执行完成")
# 3. 验证
print("\n📊 验证...")
for tbl in ("resource_bindings", "resource_grants"):
result = await session.execute(text(f"""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '{tbl}'
ORDER BY ordinal_position
"""))
cols = result.fetchall()
if not cols:
print(f"❌ {tbl} 创建失败")
return False
print(f"\n {tbl} ({len(cols)} 列):")
for c in cols:
nullable = "NULL" if c[2] == "YES" else "NOT NULL"
default = f" default={c[3]}" if c[3] else ""
print(f" - {c[0]}: {c[1]} {nullable}{default}")
# 4. 索引检查
result = await session.execute(text("""
SELECT indexname, tablename
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename IN ('resource_bindings', 'resource_grants')
ORDER BY tablename, indexname
"""))
print("\n 索引:")
for r in result.fetchall():
print(f" - {r[1]}.{r[0]}")
print("\n✅ 迁移 025 完成")
return True
except Exception as e:
await session.rollback()
print(f"\n❌ 迁移失败: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return False
finally:
await engine.dispose()
if __name__ == "__main__":
ok = asyncio.run(run_migration())
sys.exit(0 if ok else 1)
+84
View File
@@ -1475,3 +1475,87 @@ class ExternalToolkit(BaseModel, Base):
Index("idx_external_toolkit_owner", owner_id), Index("idx_external_toolkit_owner", owner_id),
UniqueConstraint("name", owner_id, name="uq_toolkit_name_owner"), # 同一用户下工具集名称唯一 UniqueConstraint("name", owner_id, name="uq_toolkit_name_owner"), # 同一用户下工具集名称唯一
) )
# ============================================================
# Heicode P1:资源绑定与授权模型
# ------------------------------------------------------------
# 依据:heicode.md §五 / plan.md §P1
# 安全红线:表层只保存 secret_ref,不保存明文密钥;
# metadata/constraints/audit 中出现敏感字段名应在应用层拒绝。
# ============================================================
class ResourceBinding(BaseModel, Base):
"""资源绑定(Heicode P1)
用户授权 Heicode 使用的外部资源(Git 仓库 / SK / 项目文档 / 云账号 / 云资源)。
数据库只保存元数据 + secret_ref;真实凭据由后续 Secret Broker 写入 Secret Store。
"""
__tablename__ = "resource_bindings"
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False)
type = Column(String(50), nullable=False)
# 允许值:git | sk | project_doc | cloud_account | cloud_resource
name = Column(String(255), nullable=False)
external_ref = Column(Text, nullable=True)
# DB 列 "binding_metadata" 避开 SQLAlchemy 保留 metadata;API 字段名 "metadata"
binding_metadata = Column(JSON, nullable=False, default=dict)
permission_scope = Column(JSON, nullable=False, default=list)
constraints = Column(JSON, nullable=False, default=dict)
secret_ref = Column(String(500), nullable=True)
status = Column(String(20), nullable=False, default="pending")
# 允许值:pending | active | disabled | revoked
created_by = Column(GUID(), nullable=True)
updated_by = Column(GUID(), nullable=True)
user = relationship("User", foreign_keys=[user_id])
grants = relationship(
"ResourceGrant", back_populates="resource",
cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_resource_bindings_user", user_id),
Index("idx_resource_bindings_type", type),
Index("idx_resource_bindings_status", status),
)
class ResourceGrant(BaseModel, Base):
"""资源授权(Heicode P1)
把某个 ResourceBinding 授予某个用户/角色/子 Agent 使用。
allowed_actions 必须是对应 binding.permission_scope 的子集;
constraints 不得放宽 binding.constraints 的限制(应用层校验)。
"""
__tablename__ = "resource_grants"
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False)
binding_scope = Column(String(255), nullable=False)
resource_id = Column(
GUID(), ForeignKey("resource_bindings.id", ondelete="CASCADE"),
nullable=False
)
role = Column(String(100), nullable=True)
agent_id = Column(GUID(), nullable=True)
allowed_actions = Column(JSON, nullable=False, default=list)
constraints = Column(JSON, nullable=False, default=dict)
status = Column(String(20), nullable=False, default="active")
# 允许值:active | suspended | revoked | expired
expires_at = Column(DateTime, nullable=True)
created_by = Column(GUID(), nullable=True)
revoked_by = Column(GUID(), nullable=True)
revoked_at = Column(DateTime, nullable=True)
user = relationship("User", foreign_keys=[user_id])
resource = relationship("ResourceBinding", back_populates="grants")
__table_args__ = (
Index("idx_resource_grants_user", user_id),
Index("idx_resource_grants_resource", resource_id),
Index("idx_resource_grants_status", status),
Index("idx_resource_grants_binding_scope", binding_scope),
Index("idx_resource_grants_role", role),
)
+1 -1
View File
@@ -9,7 +9,7 @@ pydantic-settings==2.1.0
# 数据库 # 数据库
sqlalchemy==2.0.25 sqlalchemy==2.0.25
asyncpg==0.29.0 asyncpg==0.29.0
aiosqlite==0.19.0 aiosqlite>=0.19.0
alembic==1.13.1 alembic==1.13.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9