forked from xiaohei/taiji-AI-PAD
bak
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"1de9258a-6dbf-4827-ab74-de844edd79c7","pid":30640,"acquiredAt":1777959383082}
|
|
||||||
@@ -14,7 +14,56 @@
|
|||||||
"Bash(kubectl logs *)",
|
"Bash(kubectl logs *)",
|
||||||
"Bash(netstat -ano)",
|
"Bash(netstat -ano)",
|
||||||
"Bash(awk '{print $5}')",
|
"Bash(awk '{print $5}')",
|
||||||
"Bash(xargs -r -I PID powershell.exe -Command \"Stop-Process -Id PID -Force -ErrorAction SilentlyContinue\")"
|
"Bash(xargs -r -I PID powershell.exe -Command \"Stop-Process -Id PID -Force -ErrorAction SilentlyContinue\")",
|
||||||
|
"Bash(kubectl get *)",
|
||||||
|
"Bash(python3 -c \"import sys, json; d=json.loads\\(sys.stdin.read\\(\\)\\); print\\('CORS_ORIGINS:', d.get\\('CORS_ORIGINS', '[unset → defaults to *]'\\)\\); print\\('APP_ENV:', d.get\\('APP_ENV', '?'\\)\\)\")",
|
||||||
|
"Bash(kubectl set *)",
|
||||||
|
"Bash(kubectl rollout *)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -- python -c \"from app.routes import heicode_proxy; print\\(sorted\\([r.path for r in heicode_proxy.router.routes if hasattr\\(r,'path'\\)]\\)\\)\")",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -- python -c \"from app.routes import agnet_stub; print\\(sorted\\([r.path for r in agnet_stub.router.routes if hasattr\\(r,'path'\\)]\\)\\)\")",
|
||||||
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('services/mcp-server/app/routes/agnet_stub.py', encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")",
|
||||||
|
"Bash(kubectl port-forward *)",
|
||||||
|
"Bash(kubectl exec -i -n taiji-ai deploy/mcp-server -- python -c ' *)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -- python -c \"from app.routes import heicode_tasks; print\\(sorted\\([r.path for r in heicode_tasks.router.routes if hasattr\\(r,'path'\\)]\\)\\)\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(kubectl exec -n taiji-ai deploy/mcp-server -- python -c ' *)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -- python -c \"from app.routes import heicode_events; print\\(sorted\\(set\\(r.path for r in heicode_events.router.routes if hasattr\\(r,'path'\\)\\)\\)\\)\")",
|
||||||
|
"Bash(curl -sk --max-time 30 -x http://127.0.0.1:6578 https://aks.taiji-ai.com/api/mcp/health -w \"\\\\nHTTP=%{http_code}\\\\n\")",
|
||||||
|
"Bash(curl -sk --max-time 15 -v -x http://127.0.0.1:6578 https://aks.taiji-ai.com/api/mcp/health)",
|
||||||
|
"Bash(curl -s --noproxy '*' --max-time 10 http://127.0.0.1:18080/health -w \"\\\\nHTTP=%{http_code}\\\\n\")",
|
||||||
|
"Bash(kill 1883)",
|
||||||
|
"Bash(pkill -f \"port-forward.*mcp-server\")",
|
||||||
|
"Bash(kill 2231)",
|
||||||
|
"Bash(kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- python -c ' *)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 -o /dev/null -w \"agent-manager.taijiagnet.com HTTP=%{http_code} TIME=%{time_total}\\\\n\" https://agent-manager.taijiagnet.com/)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 -o /dev/null -w \" /health HTTP=%{http_code}\\\\n\" https://agent-manager.taijiagnet.com/health)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 -o /dev/null -w \" /api/agnet/deployments HTTP=%{http_code}\\\\n\" https://agent-manager.taijiagnet.com/api/agnet/deployments)",
|
||||||
|
"Bash(nslookup agent-manager.taijiagnet.com)",
|
||||||
|
"Bash(kubectl exec *)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 --resolve agent-manager.taijiagnet.com:80:20.212.121.126 -i http://agent-manager.taijiagnet.com/)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 --resolve agent-manager.taijiagnet.com:80:20.212.121.126 -w '\\\\n[HTTP=%{http_code}]\\\\n' http://agent-manager.taijiagnet.com/templates)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 5 --resolve agent-manager.taijiagnet.com:80:20.212.121.126 -o /tmp/body -w '%{http_code}|%{size_download}' http://agent-manager.taijiagnet.com__TRACKED_VAR__)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 --resolve agent-manager.taijiagnet.com:80:20.212.121.126 http://agent-manager.taijiagnet.com/openapi.json -o /tmp/oa.json)",
|
||||||
|
"Bash(python -c ' *)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 --resolve agent-manager.taijiagnet.com:80:20.212.121.126 http://agent-manager.taijiagnet.com/openapi.json)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- ls //app/scripts/)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- python //app/scripts/cleanup_orphan_litellm_keys.py --dry-run)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- bash -c \"cd /app && python -m scripts.cleanup_orphan_litellm_keys --dry-run\")",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- bash -c \"cd /app && python -m scripts.cleanup_orphan_litellm_keys\")",
|
||||||
|
"Bash(pkill -f \"port-forward\")",
|
||||||
|
"Bash(curl -s --noproxy '*' --max-time 5 http://127.0.0.1:18083/health)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl logs -n taiji-ai deploy/mcp-server --tail=200)",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl cp \"C:/Users/陈晨/AppData/Local/Temp/check_smoke_user.py\" \"taiji-ai/$\\(kubectl get pod -n taiji-ai -l app=mcp-server -o jsonpath='{.items[0].metadata.name}'\\):/tmp/check_smoke_user.py\")",
|
||||||
|
"Bash(MSYS_NO_PATHCONV=1 kubectl exec -n taiji-ai deploy/mcp-server -c mcp-server -- python /tmp/check_smoke_user.py smoke_1778571031@example.com)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy)",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 8 __TRACKED_VAR__/api/user/heicode/models -o /dev/null -w 'HTTP=%{http_code} \\(no auth\\)\\\\n')",
|
||||||
|
"Bash(curl -sk --noproxy '*' --max-time 15 -X POST __TRACKED_VAR__/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"smoke_1778571031@example.com\",\"password\":\"GoodPwd1234\"}' -w '\\\\nHTTP=%{http_code}\\\\n')",
|
||||||
|
"Bash(python -c \"import sys,json,base64; d=json.load\\(sys.stdin\\); print\\({k: base64.b64decode\\(v\\).decode\\(\\)[:8]+'...' for k,v in d.items\\(\\)}\\)\")",
|
||||||
|
"Bash(python -c \"import ast; ast.parse\\(open\\('app/magic_link.py',encoding='utf-8'\\).read\\(\\)\\); ast.parse\\(open\\('app/routes/auth.py',encoding='utf-8'\\).read\\(\\)\\); ast.parse\\(open\\('config.py',encoding='utf-8'\\).read\\(\\)\\); print\\('syntax OK'\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,3 +668,22 @@ HM 已迁到 AKS,生产域名:
|
|||||||
|
|
||||||
## 24.4 状态
|
## 24.4 状态
|
||||||
落地域名改 `code.heicode.cc` **已上生产**;`request/verify/landing` 契约不变;只待 HM 验 §24.3 的"成功 302 经新反代透传"。
|
落地域名改 `code.heicode.cc` **已上生产**;`request/verify/landing` 契约不变;只待 HM 验 §24.3 的"成功 302 经新反代透传"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 25. HM owner @zsbgnw12 — 验 §24.3:**查出 bug 并已修**(2026-06-12)
|
||||||
|
|
||||||
|
§24.2 的安全判断同意:`/request` 可经 APIM 直达,裸读 `X-Forwarded-Host` 拼落地域名会被伪造钓鱼;你们选 23.4 域名常量更稳,**采纳**。(HM 侧 §23.3 的 X-Forwarded 透传保留无害——你们用常量、不读它;若将来要 23.3 自动双域名,确实需"白名单版",再排期。)
|
||||||
|
|
||||||
|
## 25.1 §24.3 验证结果:HM 反代之前**不能**原样透回 302(已修)
|
||||||
|
按你们提示验了 `code.heicode.cc/api/heicode-auth` 的成功 302 透传,**查出两个 bug**:
|
||||||
|
1. `HeicodeAuthProxy` 的 `http.Client` 用 Go 默认 `CheckRedirect`(**自动 follow**)→ 收到 `302 Location: heicode://...` 会去 follow `heicode://`(非 http scheme)→ 失败 → 反代回 `502 BadGateway`(深链根本到不了浏览器)。
|
||||||
|
2. 反代只回传 `Content-Type`,**未回传 `Location`** → 即便不 follow,浏览器也拿不到深链。
|
||||||
|
|
||||||
|
→ 即:**§24.3 担心的"成功 302 经反代透回"在修之前是断的。** 已修复(HM PR #78):
|
||||||
|
- `CheckRedirect=ErrUseLastResponse`:不 follow,把上游 302 **原样**返回浏览器;
|
||||||
|
- 透回 `Location`(深链)+ `Set-Cookie`(会话)+ `Cache-Control` + `Content-Type`;
|
||||||
|
- 与 §11 4 条一致;非重定向(/me、/refresh、/login、verify 的 JSON)行为不变。单测覆盖:`heicode://` Location 原样透回 + JSON 透传。
|
||||||
|
|
||||||
|
## 25.2 待办(HM 侧)
|
||||||
|
PR #78 待评审合并 + **部署到 AKS 生产 HM** 后,`code.heicode.cc/api/heicode-auth` 上的 landing 成功 302→`heicode://` 才会真正透回浏览器。**部署前**,经 code.heicode.cc 点 magic-link 邮件链接的成功跳转会拿到 502(失败分支 HTML 不受影响)。我会尽快合并 + 部署,完成后在此 §25.3 标注。mcp 侧无需再改。
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: mcp-server
|
- name: mcp-server
|
||||||
image: taiji.azurecr.io/mcp-server:magic-link-20260609-arm64-v2
|
image: taiji.azurecr.io/mcp-server:agnet-auth-20260623-arm64
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
@@ -111,7 +111,13 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: taiji-config
|
name: taiji-config
|
||||||
key: AGENT_K8S_NAMESPACE
|
key: AGENT_K8S_NAMESPACE
|
||||||
|
# 服务令牌:调 agent-manager 鉴权(契约 §2.1,= agent-manager 的 AGNET_RUNTIME_SERVICE_TOKEN)
|
||||||
|
- name: AGENT_MANAGER_SERVICE_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: taiji-secrets
|
||||||
|
key: agent-manager-service-token
|
||||||
|
|
||||||
# JWT配置
|
# JWT配置
|
||||||
- name: SECRET_KEY
|
- name: SECRET_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Adapts to the actual Agent Manager interface (based on agent-manager-interface-d
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
@@ -17,6 +18,10 @@ logger = structlog.get_logger(__name__)
|
|||||||
# Get Agent Manager URL from environment variables
|
# Get Agent Manager URL from environment variables
|
||||||
AGENT_MANAGER_URL = os.getenv("AGENT_MANAGER_URL", "http://localhost:8000")
|
AGENT_MANAGER_URL = os.getenv("AGENT_MANAGER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
# 服务身份令牌(契约 §2.1 Option A):调 agent-manager 必带 Authorization: Bearer。
|
||||||
|
# 值与 agent-manager 的 AGNET_RUNTIME_SERVICE_TOKEN 等值,由 k8s secret 注入。
|
||||||
|
AGENT_MANAGER_SERVICE_TOKEN = os.getenv("AGENT_MANAGER_SERVICE_TOKEN", "")
|
||||||
|
|
||||||
# LLM_BASE_URL - 所有 Agent(平台和自定义)都必须传递的固定参数
|
# LLM_BASE_URL - 所有 Agent(平台和自定义)都必须传递的固定参数
|
||||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io")
|
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io")
|
||||||
|
|
||||||
@@ -587,10 +592,14 @@ class AgentManagerClient:
|
|||||||
async def _get_client(self) -> httpx.AsyncClient:
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client"""
|
"""Get or create HTTP client"""
|
||||||
if self._client is None or self._client.is_closed:
|
if self._client is None or self._client.is_closed:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
# 契约 §2.1:服务令牌鉴权(agent-manager v2 强制校验)
|
||||||
|
if AGENT_MANAGER_SERVICE_TOKEN:
|
||||||
|
headers["Authorization"] = f"Bearer {AGENT_MANAGER_SERVICE_TOKEN}"
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
headers={"Content-Type": "application/json"}
|
headers=headers
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@@ -629,7 +638,8 @@ class AgentManagerClient:
|
|||||||
method=method,
|
method=method,
|
||||||
url=path,
|
url=path,
|
||||||
json=json,
|
json=json,
|
||||||
params=params
|
params=params,
|
||||||
|
headers={"X-Correlation-Id": str(uuid.uuid4())} # 契约 §2.1 必填,全链路追踪
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
|
|||||||
@@ -308,7 +308,12 @@ class ResourceController:
|
|||||||
cost=cost,
|
cost=cost,
|
||||||
eu_consumed=float(cost), # EU = Cost(1 EU = 1 美元)
|
eu_consumed=float(cost), # EU = Cost(1 EU = 1 美元)
|
||||||
start_time=now,
|
start_time=now,
|
||||||
|
# 🔧 修复双扣:本记录是一次性执行(费用已在 execute 路径即时扣除),
|
||||||
|
# 标记 end_time 已结束,避免被周期任务(record_type=vm_runtime AND end_time IS NULL)
|
||||||
|
# 误判为"运行中 VM"而按时长再扣一次(且永不结束、持续幽灵计费)。
|
||||||
|
end_time=now,
|
||||||
period_start=now,
|
period_start=now,
|
||||||
|
period_end=now,
|
||||||
)
|
)
|
||||||
db.add(billing_record)
|
db.add(billing_record)
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ async def agent_manager_callback(
|
|||||||
"""
|
"""
|
||||||
from models import AgentBillingRecord
|
from models import AgentBillingRecord
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from app.billing import calculate_eu, calculate_platform_agent_cost, deduct_balance
|
from app.billing import calculate_eu, calculate_platform_agent_cost, deduct_balance
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -420,7 +421,22 @@ async def agent_manager_callback(
|
|||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"用户不存在: {callback_data.userId}")
|
logger.warning(f"用户不存在: {callback_data.userId}")
|
||||||
raise HTTPException(status_code=404, detail=f"用户不存在: {callback_data.userId}")
|
raise HTTPException(status_code=404, detail=f"用户不存在: {callback_data.userId}")
|
||||||
|
|
||||||
|
# 🔧 幂等:防止 Agent Manager 重发同一 requestId 重复建记录 + 重复扣费
|
||||||
|
# (对齐 LiteLLM 回调按 call_id 去重的做法;request_id 维度的 api_call 记录唯一)
|
||||||
|
if callback_data.requestId:
|
||||||
|
dup = await db.execute(
|
||||||
|
select(AgentBillingRecord).where(
|
||||||
|
and_(
|
||||||
|
AgentBillingRecord.record_type == "api_call",
|
||||||
|
AgentBillingRecord.request_id == callback_data.requestId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dup.scalar_one_or_none():
|
||||||
|
logger.info(f"Agent 回调已处理过(幂等跳过): request_id={callback_data.requestId}")
|
||||||
|
return {"success": True, "message": "Already processed", "request_id": callback_data.requestId}
|
||||||
|
|
||||||
# 解析时间
|
# 解析时间
|
||||||
start_time = None
|
start_time = None
|
||||||
end_time = None
|
end_time = None
|
||||||
@@ -527,7 +543,14 @@ async def agent_manager_callback(
|
|||||||
)
|
)
|
||||||
|
|
||||||
print(f"\n🔥 准备 commit...", flush=True)
|
print(f"\n🔥 准备 commit...", flush=True)
|
||||||
await db.commit()
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# 🔧 并发安全网:唯一索引 uq_agent_billing_api_call_request_id 命中,
|
||||||
|
# 说明同 request_id 已被另一并发回调写入并扣费 → 幂等跳过,绝不重复扣。
|
||||||
|
await db.rollback()
|
||||||
|
logger.info(f"Agent 回调并发重复(唯一索引拦截,幂等跳过): request_id={callback_data.requestId}")
|
||||||
|
return {"success": True, "message": "Already processed", "request_id": callback_data.requestId}
|
||||||
print(f"🔥 commit 完成!\n", flush=True)
|
print(f"🔥 commit 完成!\n", flush=True)
|
||||||
await db.refresh(billing_record)
|
await db.refresh(billing_record)
|
||||||
|
|
||||||
|
|||||||
@@ -1064,18 +1064,35 @@ async def stop_platform_agent(
|
|||||||
billing_record = billing_result.scalar_one_or_none()
|
billing_record = billing_result.scalar_one_or_none()
|
||||||
|
|
||||||
if billing_record:
|
if billing_record:
|
||||||
|
# 计费/扣款(与周期任务同一套:增量扣 + 模板费率)
|
||||||
|
from app.billing import calculate_platform_agent_cost, deduct_balance
|
||||||
|
|
||||||
billing_record.end_time = datetime.utcnow()
|
billing_record.end_time = datetime.utcnow()
|
||||||
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
||||||
billing_record.duration_seconds = int(duration)
|
billing_record.duration_seconds = int(duration)
|
||||||
# 计算成本: 平台Agent固定费率 $0.10/小时
|
# 🔧 修复硬编码 0.10:用模板费率(与周期任务一致),未知模板回退 $0.10/h
|
||||||
cost = 0.10 * (duration / 3600)
|
cost = float(calculate_platform_agent_cost(
|
||||||
|
billing_record.agent_type or agent.template, int(duration)
|
||||||
|
))
|
||||||
|
# 🔧 修复末段漏扣:周期任务已按累计成本"增量"扣过(已扣额=记录里旧 cost)。
|
||||||
|
# 停机补扣"末段增量 = 全程成本 − 已扣",使停机时机器费收齐(之前此处完全不扣款)。
|
||||||
|
already_charged = float(billing_record.cost or 0)
|
||||||
|
increment = cost - already_charged
|
||||||
billing_record.cost = cost
|
billing_record.cost = cost
|
||||||
# EU = Cost(1 EU = 1 美元)
|
billing_record.eu_consumed = cost # EU = Cost(1 EU = 1 美元)
|
||||||
billing_record.eu_consumed = cost
|
|
||||||
|
if increment > 0:
|
||||||
|
success, message = await deduct_balance(
|
||||||
|
user_id, increment, db,
|
||||||
|
f"平台Agent停机结算(末段增量): {agent_name} (运行{int(duration)}秒)",
|
||||||
|
auto_commit=False # 与配额释放/Agent 删除在同一事务提交
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(f"平台Agent停机扣款失败: {message}, 用户: {user_id}, Agent: {agent_name}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"结束平台Agent计费: {agent_name}, "
|
f"结束平台Agent计费: {agent_name}, 运行时长={duration}秒, "
|
||||||
f"运行时长={duration}秒, cost=${cost:.4f}, EU={billing_record.eu_consumed}"
|
f"cost=${cost:.4f}, 末段增量=${increment:.4f}, EU={billing_record.eu_consumed}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"未找到Agent {agent_name} 的计费记录")
|
logger.warning(f"未找到Agent {agent_name} 的计费记录")
|
||||||
|
|||||||
@@ -3486,9 +3486,10 @@ async def delete_custom_agent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
billing_record = billing_result.scalar_one_or_none() # 🔧 修复:之前漏了取值,billing_record 未定义会 NameError
|
||||||
# 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd")
|
# 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd")
|
||||||
agent_full_name = name
|
agent_full_name = name
|
||||||
if billing_record.namespace and billing_record.namespace.startswith("agent-"):
|
if billing_record and billing_record.namespace and billing_record.namespace.startswith("agent-"):
|
||||||
agent_full_name = billing_record.namespace[6:] # 去掉 "agent-" 前缀
|
agent_full_name = billing_record.namespace[6:] # 去掉 "agent-" 前缀
|
||||||
logger.info(f"使用完整 Agent 名称删除: {agent_full_name} (原名: {name}, namespace: {billing_record.namespace})")
|
logger.info(f"使用完整 Agent 名称删除: {agent_full_name} (原名: {name}, namespace: {billing_record.namespace})")
|
||||||
|
|
||||||
@@ -3513,26 +3514,34 @@ async def delete_custom_agent(
|
|||||||
detail=f"删除 Agent 失败: {str(e)}"
|
detail=f"删除 Agent 失败: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ Step 2: Pod 删除成功后,更新计费记录
|
# ✅ Step 2+3: 结算计费记录(仅当找到运行中记录时)
|
||||||
billing_record.end_time = datetime.utcnow()
|
if billing_record:
|
||||||
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
billing_record.end_time = datetime.utcnow()
|
||||||
billing_record.duration_seconds = int(duration)
|
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
||||||
|
billing_record.duration_seconds = int(duration)
|
||||||
# ✅ Step 3: 计算成本并扣款(自定义Agent按资源使用量计费)
|
|
||||||
cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1
|
# 自定义Agent按资源使用量计费,cost 为 start→stop 全程累计成本
|
||||||
memory_gb = _parse_memory_to_gb(billing_record.memory_used) if billing_record.memory_used else 0.125
|
cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1
|
||||||
cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration))
|
memory_gb = _parse_memory_to_gb(billing_record.memory_used) if billing_record.memory_used else 0.125
|
||||||
billing_record.cost = float(cost)
|
full_cost = float(calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration)))
|
||||||
billing_record.eu_consumed = float(cost) # EU = Cost(1 EU = 1 美元)
|
|
||||||
|
# 🔧 修复双扣:周期任务已按累计成本"增量"扣过(已扣额=记录里旧的 cost)。
|
||||||
# 扣除用户余额
|
# 停机只补扣"末段增量 = 全程成本 − 已扣",绝不再整笔重扣(否则用户被多扣近一倍)。
|
||||||
success, message = await deduct_balance(
|
already_charged = float(billing_record.cost or 0)
|
||||||
user_id, cost, db,
|
increment = full_cost - already_charged
|
||||||
f"自定义Agent使用: {name} (运行{int(duration)}秒)",
|
billing_record.cost = full_cost
|
||||||
auto_commit=False # 🔧 修复:与billing_record和quota更新在同一事务中
|
billing_record.eu_consumed = full_cost # EU = Cost(1 EU = 1 美元)
|
||||||
)
|
|
||||||
if not success:
|
if increment > 0:
|
||||||
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
|
success, message = await deduct_balance(
|
||||||
|
user_id, increment, db,
|
||||||
|
f"自定义Agent停机结算(末段增量): {name} (运行{int(duration)}秒)",
|
||||||
|
auto_commit=False # 与billing_record和quota更新在同一事务中
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"未找到 Agent {name} 的运行中计费记录,跳过结算")
|
||||||
|
|
||||||
# ✅ Step 4: 释放配额(使用行锁防止并发问题)
|
# ✅ Step 4: 释放配额(使用行锁防止并发问题)
|
||||||
try:
|
try:
|
||||||
@@ -3597,13 +3606,20 @@ async def scale_custom_agent_api(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
billing_record = billing_result.scalar_one_or_none() # 🔧 修复:之前漏了取值,3623 用 .cpu_used 会 NameError
|
||||||
|
if not billing_record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
|
||||||
|
)
|
||||||
|
|
||||||
quota_result = await db.execute(
|
quota_result = await db.execute(
|
||||||
select(TenantCustomAgentQuota)
|
select(TenantCustomAgentQuota)
|
||||||
.where(TenantCustomAgentQuota.tenant_id == user_id)
|
.where(TenantCustomAgentQuota.tenant_id == user_id)
|
||||||
.with_for_update() # ✅ 添加行锁,防止并发扩缩容导致配额计算错误
|
.with_for_update() # ✅ 添加行锁,防止并发扩缩容导致配额计算错误
|
||||||
)
|
)
|
||||||
quota = quota_result.scalar_one_or_none()
|
quota = quota_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not quota:
|
if not quota:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class Settings(BaseSettings):
|
|||||||
# AI Agent Manager API 设置(K8s Pod 管理)
|
# AI Agent Manager API 设置(K8s Pod 管理)
|
||||||
agent_manager_url: str = os.getenv("AGENT_MANAGER_URL", "http://localhost:8000")
|
agent_manager_url: str = os.getenv("AGENT_MANAGER_URL", "http://localhost:8000")
|
||||||
agent_manager_timeout: float = 30.0 # 秒
|
agent_manager_timeout: float = 30.0 # 秒
|
||||||
|
# 服务身份令牌:调 agent-manager 时带 Authorization: Bearer(契约 §2.1 Option A)
|
||||||
|
# 值需与 agent-manager 的 AGNET_RUNTIME_SERVICE_TOKEN 等值,由 secret 注入
|
||||||
|
agent_manager_service_token: str = os.getenv("AGENT_MANAGER_SERVICE_TOKEN", "")
|
||||||
agent_default_cpu_request: str = "100m"
|
agent_default_cpu_request: str = "100m"
|
||||||
agent_default_cpu_limit: str = "500m"
|
agent_default_cpu_limit: str = "500m"
|
||||||
agent_default_memory_request: str = "128Mi"
|
agent_default_memory_request: str = "128Mi"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Migration 029: agent_billing_records.request_id 部分唯一索引(api_call 幂等)
|
||||||
|
--
|
||||||
|
-- 背景:
|
||||||
|
-- /agent-callback(API 调用计费)此前没有 request_id 去重,Agent Manager 重发同一
|
||||||
|
-- requestId 的回调会重复创建 api_call 记录并重复扣费。
|
||||||
|
-- 应用层已在 billing_webhook.py 加 SELECT 去重(处理顺序重试),本迁移再加 DB 级
|
||||||
|
-- 部分唯一索引作为并发安全网(防两个相同 requestId 的回调并发插入)。
|
||||||
|
--
|
||||||
|
-- ⚠️ 注意:本迁移会先删除已存在的重复 api_call 记录(同 request_id 仅保留最早一条),
|
||||||
|
-- 再建唯一索引。删除的是因历史 bug 产生的重复计费记录(不影响已扣的余额)。
|
||||||
|
|
||||||
|
-- 1. 去重:同一 request_id 的 api_call 记录只保留最早一条(按 created_at,平局取最小 id)
|
||||||
|
DELETE FROM agent_billing_records a
|
||||||
|
USING agent_billing_records b
|
||||||
|
WHERE a.record_type = 'api_call'
|
||||||
|
AND b.record_type = 'api_call'
|
||||||
|
AND a.request_id IS NOT NULL AND a.request_id <> ''
|
||||||
|
AND a.request_id = b.request_id
|
||||||
|
AND (a.created_at > b.created_at OR (a.created_at = b.created_at AND a.id > b.id));
|
||||||
|
|
||||||
|
-- 2. 部分唯一索引:api_call 且 request_id 非空时,request_id 唯一
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_billing_api_call_request_id
|
||||||
|
ON agent_billing_records (request_id)
|
||||||
|
WHERE record_type = 'api_call' AND request_id IS NOT NULL AND request_id <> '';
|
||||||
|
|
||||||
|
COMMENT ON INDEX uq_agent_billing_api_call_request_id IS
|
||||||
|
'api_call 计费按 request_id 幂等,防止 Agent Manager 重发回调重复扣费';
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration 029: agent_billing_records.request_id 部分唯一索引(api_call 幂等)
|
||||||
|
|
||||||
|
背景:
|
||||||
|
/agent-callback(API 调用计费)此前无 request_id 去重,Agent Manager 重发同一
|
||||||
|
requestId 会重复创建 api_call 记录并重复扣费。应用层已加 SELECT 去重,本迁移再加
|
||||||
|
DB 级部分唯一索引作为并发安全网。
|
||||||
|
|
||||||
|
⚠️ 会先删除重复的 api_call 记录(同 request_id 仅保留最早一条),再建唯一索引。
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
cd services/mcp-server
|
||||||
|
python migrations/run_029_add_api_call_request_id_unique.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
INDEX_NAME = "uq_agent_billing_api_call_request_id"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
import re
|
||||||
|
database_url = re.sub(r'[?&]sslmode=[^&]*', '', database_url)
|
||||||
|
database_url = 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 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'agent_billing_records' AND indexname = :name
|
||||||
|
"""), {"name": INDEX_NAME})
|
||||||
|
if result.fetchone():
|
||||||
|
print(f"✅ 索引 {INDEX_NAME} 已存在,跳过迁移")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. 统计重复
|
||||||
|
print("\n📊 统计重复的 api_call 记录(同 request_id)...")
|
||||||
|
result = await session.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(cnt - 1), 0) AS dup_rows
|
||||||
|
FROM (
|
||||||
|
SELECT request_id, COUNT(*) AS cnt
|
||||||
|
FROM agent_billing_records
|
||||||
|
WHERE record_type = 'api_call'
|
||||||
|
AND request_id IS NOT NULL AND request_id <> ''
|
||||||
|
GROUP BY request_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) t
|
||||||
|
"""))
|
||||||
|
dup_rows = result.scalar() or 0
|
||||||
|
print(f" 将删除的重复记录数: {dup_rows}")
|
||||||
|
|
||||||
|
# 3. 去重(同 request_id 仅保留最早一条)
|
||||||
|
print("\n🔄 去重 api_call 记录...")
|
||||||
|
result = await session.execute(text("""
|
||||||
|
DELETE FROM agent_billing_records a
|
||||||
|
USING agent_billing_records b
|
||||||
|
WHERE a.record_type = 'api_call'
|
||||||
|
AND b.record_type = 'api_call'
|
||||||
|
AND a.request_id IS NOT NULL AND a.request_id <> ''
|
||||||
|
AND a.request_id = b.request_id
|
||||||
|
AND (a.created_at > b.created_at
|
||||||
|
OR (a.created_at = b.created_at AND a.id > b.id))
|
||||||
|
"""))
|
||||||
|
print(f" 实际删除: {result.rowcount} 条")
|
||||||
|
|
||||||
|
# 4. 建部分唯一索引
|
||||||
|
print("\n🔄 创建部分唯一索引...")
|
||||||
|
await session.execute(text(f"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS {INDEX_NAME}
|
||||||
|
ON agent_billing_records (request_id)
|
||||||
|
WHERE record_type = 'api_call' AND request_id IS NOT NULL AND request_id <> ''
|
||||||
|
"""))
|
||||||
|
print(f"✅ 创建唯一索引: {INDEX_NAME}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
print("\n✅ 迁移 029 成功完成!api_call 现按 request_id DB 级幂等。")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
print(f"\n❌ 迁移失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(run_migration())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
Reference in New Issue
Block a user