forked from xiaohei/taiji-AI-PAD
更新heicode
This commit is contained in:
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"1de9258a-6dbf-4827-ab74-de844edd79c7","pid":30640,"acquiredAt":1777959383082}
|
||||||
@@ -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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 反查日志)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
如有问题,请联系开发团队。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 -----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user