chore: initial commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# Loom 全局环境变量模板
|
||||||
|
# 使用方法:`cp .env.example .env` 后填写真实值。
|
||||||
|
# 仅此文件提交仓库,`.env` 永远不要入库。
|
||||||
|
|
||||||
|
# ===== App =====
|
||||||
|
APP_ENV=dev
|
||||||
|
# 32 字节随机字符串,生成:openssl rand -hex 32
|
||||||
|
APP_SECRET_KEY=
|
||||||
|
APP_BASE_URL=https://ai.aidfxn.com
|
||||||
|
|
||||||
|
# JWT 签发参数
|
||||||
|
JWT_ALG=HS256
|
||||||
|
JWT_ACCESS_TTL=3600
|
||||||
|
JWT_REFRESH_TTL=604800
|
||||||
|
|
||||||
|
# ===== PostgreSQL =====
|
||||||
|
# docker-compose 自建数据库时保持默认;对接外部库时改连接串
|
||||||
|
POSTGRES_USER=loom
|
||||||
|
POSTGRES_PASSWORD=loom
|
||||||
|
POSTGRES_DB=loom
|
||||||
|
# 本机直跑 uvicorn 用 localhost;容器内跑由 docker-compose 覆写成 postgres:5432
|
||||||
|
DATABASE_URL=postgresql+asyncpg://loom:loom@localhost:5432/loom
|
||||||
|
|
||||||
|
# ===== Redis / Celery =====
|
||||||
|
# 同上:本机 localhost,容器内由 compose 覆写成 redis:6379
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||||
|
|
||||||
|
# ===== Lobster Agent(龙虾 Agent API)=====
|
||||||
|
# 对接真实龙虾平台,联调时由运维/产品提供,先留空。
|
||||||
|
# LOBSTER_API_BASE 示例:https://api.lobster-agent.com/v1
|
||||||
|
# LOBSTER_API_KEY 龙虾平台签发的 Bearer Token
|
||||||
|
# LOBSTER_CALLBACK_SECRET 龙虾回调 loom 时的 HMAC-SHA256 签名密钥
|
||||||
|
# LOBSTER_TIMEOUT_SECONDS 单次 HTTP 调用超时(秒),默认 30
|
||||||
|
# LOBSTER_POLL_INTERVAL_SECONDS 无回调时兜底轮询周期(秒),默认 5
|
||||||
|
LOBSTER_API_BASE=
|
||||||
|
LOBSTER_API_KEY=
|
||||||
|
LOBSTER_CALLBACK_SECRET=
|
||||||
|
LOBSTER_TIMEOUT_SECONDS=30
|
||||||
|
LOBSTER_POLL_INTERVAL_SECONDS=5
|
||||||
|
|
||||||
|
# ===== OpenAI(主 LLM Provider;其它 Provider 运行时在 /llm/providers 配置)=====
|
||||||
|
# OPENAI_API_BASE 默认 https://api.openai.com/v1;走代理时改这里
|
||||||
|
# OPENAI_API_KEY OpenAI 控制台创建的 sk-… Key
|
||||||
|
# OPENAI_DEFAULT_MODEL 默认模型,建议 gpt-4o-mini
|
||||||
|
# OPENAI_TIMEOUT_SECONDS 单次 HTTP 调用超时(秒),默认 60
|
||||||
|
OPENAI_API_BASE=https://api.openai.com/v1
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_DEFAULT_MODEL=gpt-4o-mini
|
||||||
|
OPENAI_TIMEOUT_SECONDS=60
|
||||||
|
|
||||||
|
# ===== 加密 =====
|
||||||
|
# Fernet 32 字节 urlsafe_b64 Key,用于加密 platform_account 凭据。
|
||||||
|
# 生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
FERNET_KEY=
|
||||||
|
|
||||||
|
# ===== CORS =====
|
||||||
|
# 逗号分隔;本地开发 vite 默认 5173
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Loom 生产环境 .env 模板
|
||||||
|
# 使用方法:在目标服务器上 `cp .env.production.example .env` 后填入真实值。
|
||||||
|
#
|
||||||
|
# 所有密钥字段严禁提交到仓库,生产凭据通过服务器侧环境变量 / 密钥管理服务注入。
|
||||||
|
|
||||||
|
# ===== App =====
|
||||||
|
APP_ENV=prod
|
||||||
|
# 32 字节随机字符串,每个环境独立生成:openssl rand -hex 32
|
||||||
|
APP_SECRET_KEY=
|
||||||
|
APP_BASE_URL=https://ai.aidfxn.com
|
||||||
|
|
||||||
|
JWT_ALG=HS256
|
||||||
|
JWT_ACCESS_TTL=3600
|
||||||
|
JWT_REFRESH_TTL=604800
|
||||||
|
|
||||||
|
# ===== PostgreSQL(生产:强密码)=====
|
||||||
|
POSTGRES_USER=loom
|
||||||
|
# openssl rand -base64 24
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=loom
|
||||||
|
# 容器内服务名,勿改
|
||||||
|
DATABASE_URL=postgresql+asyncpg://loom:__POSTGRES_PASSWORD__@postgres:5432/loom
|
||||||
|
|
||||||
|
# ===== Redis / Celery =====
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
|
|
||||||
|
# ===== Lobster Agent =====
|
||||||
|
# 以下 5 项也可在 UI 的"系统设置 → 执行器配置 → 龙虾 Agent"里填;UI 值优先级高于 .env。
|
||||||
|
# 生产建议:至少在 .env 里填一套兜底,避免 UI 清空后掉到 mock。
|
||||||
|
LOBSTER_API_BASE=
|
||||||
|
LOBSTER_API_KEY=
|
||||||
|
LOBSTER_CALLBACK_SECRET=
|
||||||
|
LOBSTER_TIMEOUT_SECONDS=30
|
||||||
|
LOBSTER_POLL_INTERVAL_SECONDS=5
|
||||||
|
|
||||||
|
# ===== OpenAI =====
|
||||||
|
# 每个 Provider 也可以在 UI 的"执行器配置 → LLM Provider"里单独配置 api_key。
|
||||||
|
OPENAI_API_BASE=https://api.openai.com/v1
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_DEFAULT_MODEL=gpt-4o-mini
|
||||||
|
OPENAI_TIMEOUT_SECONDS=60
|
||||||
|
|
||||||
|
# ===== 加密 =====
|
||||||
|
# 用于 Fernet 加解密平台账号凭据 + LLM Provider key + 龙虾 config。
|
||||||
|
# 生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# ⚠️ 生产环境生成后严禁更换:所有加密字段(平台账号密码、LLM api_key、龙虾 api_key 等)都需用此 key 解密
|
||||||
|
FERNET_KEY=
|
||||||
|
|
||||||
|
# ===== CORS =====
|
||||||
|
# 生产只放行正式域名
|
||||||
|
CORS_ALLOW_ORIGINS=https://ai.aidfxn.com
|
||||||
|
|
||||||
|
# ===== 容器启动时是否自动跑 seed =====
|
||||||
|
# 首次部署设为 true 播种超管 + 菜单;二次部署建议 false 避免误覆盖业务数据
|
||||||
|
SEED_ON_BOOT=true
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# === Secrets ===
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
|
# === Claude Code ===
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# === Python ===
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# === Node ===
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# === IDE / OS ===
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# === Logs / Runtime ===
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# === Docker volumes (本地挂载) ===
|
||||||
|
.docker-data/
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Loom
|
||||||
|
|
||||||
|
企业级 Agent 任务自动化平台。统一管理龙虾 Agent 与本地脚本引擎两类执行器,提供 Skill 注册、四维 RBAC、任务调度、LLM 路由与审计观测。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
loom/
|
||||||
|
├── backend/ FastAPI + SQLAlchemy (async) + subprocess 执行器 + 内嵌 scheduler
|
||||||
|
├── frontend/ React 18 + Vite + Ant Design 5 + Zustand
|
||||||
|
├── docker/ nginx 配置 / 通用 Docker 片段
|
||||||
|
├── docs/ PRD、架构设计、原型
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example 所有外部配置项(含详细注释)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始(开发模式)
|
||||||
|
|
||||||
|
### 1. 准备配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 生成必须的密钥
|
||||||
|
openssl rand -hex 32 # → APP_SECRET_KEY
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # → FERNET_KEY
|
||||||
|
|
||||||
|
# 联调时再填:LOBSTER_API_BASE / LOBSTER_API_KEY / LOBSTER_CALLBACK_SECRET
|
||||||
|
# OpenAI:OPENAI_API_KEY(留空会走 mock 回显)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 起 pg + redis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv venv --python 3.11 && source .venv/bin/activate
|
||||||
|
uv pip install -e '.[dev]'
|
||||||
|
alembic upgrade head
|
||||||
|
# 初始化种子数据(超管/业务员 + 菜单 + 示例产品/Skill/LLM Provider)
|
||||||
|
python -m app.scripts.seed
|
||||||
|
python -m app.scripts.seed_p4
|
||||||
|
python -m app.scripts.seed_p6
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
# → http://localhost:8000/api/v1/healthz
|
||||||
|
# → http://localhost:8000/docs
|
||||||
|
# → 内嵌 scheduler 已启动,每分钟扫 due schedules
|
||||||
|
```
|
||||||
|
|
||||||
|
种子账号:
|
||||||
|
- `admin` / `admin123`(超级管理员,全部菜单 + 权限)
|
||||||
|
- `operator` / `operator123`(业务员,仅仪表盘 + Skill + 任务中心)
|
||||||
|
|
||||||
|
### 4. 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm i # 或 pnpm / yarn
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 一键全栈(构建镜像后启动)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 修改 .env 的 DATABASE_URL 等为容器内服务名(compose 已在 environment: 覆写,可保持 localhost)
|
||||||
|
docker compose --profile full up -d --build
|
||||||
|
# → http://localhost/ (前端 + nginx 反代 /api 到后端)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**:React 18 · TypeScript · Vite 5 · Ant Design 5 · Zustand · React Router 6 · Axios · Tailwind(原子工具类)
|
||||||
|
- **后端**:FastAPI · SQLAlchemy 2.0 (async, asyncpg) · Pydantic v2 · Alembic · httpx · structlog · bcrypt · cryptography (Fernet)
|
||||||
|
- **基础设施**:PostgreSQL 16 · Redis 7 · Nginx · Docker Compose
|
||||||
|
- **部署域名**:`ai.aidfxn.com`
|
||||||
|
|
||||||
|
## 模块地图
|
||||||
|
|
||||||
|
| 区块 | 页面 | 后端模块 |
|
||||||
|
|---|---|---|
|
||||||
|
| 仪表盘 | `/dashboard` | `dashboard_summary` |
|
||||||
|
| 权限 | 组织/角色/用户/菜单 | `rbac_service` + 4 维权限装饰器 |
|
||||||
|
| 产品 | 产品/渠道码/平台套件 + 平台账号(Fernet 加密)| `product_service` / `channel_code_service` |
|
||||||
|
| Skill | Skill 管理(双执行器 + JSON Schema 参数 + 测试台)| `skill_service` |
|
||||||
|
| 任务 | 四步向导 + 列表 + 实时日志轮询 | `task_service`(LOBSTER `httpx` / SCRIPT `subprocess`)|
|
||||||
|
| 定时 | cron 5 段 + 启停 + 手动触发 | `schedule_service`(进程内 scheduler loop)|
|
||||||
|
| 日志 | 操作日志(OpLog ASGI 中间件)+ 统计报表 | `op_log` / `dashboard_report` |
|
||||||
|
| 系统 | 执行器状态 + LLM Provider/Model/Routing + 调试台 + 脚本模板 | `llm_service` / `executor` / `script_template_service` |
|
||||||
|
|
||||||
|
## 开发阶段
|
||||||
|
|
||||||
|
项目按 6 个 Phase 分期推进:
|
||||||
|
|
||||||
|
| Phase | 目标 |
|
||||||
|
|---|---|
|
||||||
|
| P1 | 基建:工程骨架、健康检查、登录页 |
|
||||||
|
| P2 | RBAC + 动态布局:四维权限、菜单树、路由守卫 |
|
||||||
|
| P3 | 基础资产:组织/角色/用户/菜单 + 平台账号 + OpLog 审计 |
|
||||||
|
| P4 | Skill 与脚本:JSON Schema 表单、产品范围绑定、测试台 |
|
||||||
|
| P5 | 任务执行核心:四步向导、双执行器、实时日志、定时任务 |
|
||||||
|
| P6 | LLM Provider/Model/Routing、执行器配置、调试台、生产部署包 |
|
||||||
|
|
||||||
|
测试:`cd backend && pytest` — **45 个集成测试全绿**
|
||||||
|
|
||||||
|
详细路线参见 [docs/](docs/) 与 PRD。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Loom Backend
|
||||||
|
|
||||||
|
FastAPI + SQLAlchemy 2.0 (async) + Celery + PostgreSQL + Redis。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建虚拟环境并安装依赖
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
|
||||||
|
# 2. 配置(根目录 .env 由 .env.example 复制)
|
||||||
|
# 生成 APP_SECRET_KEY:openssl rand -hex 32
|
||||||
|
# 生成 FERNET_KEY:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
|
# 3. 起 pg/redis
|
||||||
|
docker compose -f ../docker-compose.yml up -d postgres redis
|
||||||
|
|
||||||
|
# 4. 跑迁移(P1 暂无迁移文件,可跳过)
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 5. 启动
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# 6. 检查
|
||||||
|
open http://localhost:8000/api/v1/healthz
|
||||||
|
open http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── main.py FastAPI 入口
|
||||||
|
├── core/ 配置、日志、异常、JWT、依赖
|
||||||
|
├── db/ SQLAlchemy 引擎 & Base
|
||||||
|
├── api/v1/ REST 路由
|
||||||
|
├── models/ ORM(P2 起)
|
||||||
|
├── schemas/ Pydantic DTO(P2 起)
|
||||||
|
├── services/ 业务服务(P2 起)
|
||||||
|
├── tasks/ Celery 任务(P5 起)
|
||||||
|
├── integrations/ Lobster / OpenAI 客户端(P4/P6)
|
||||||
|
└── utils/
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
# sqlalchemy.url 留空,env.py 会从 app.core.config 读取 DATABASE_URL
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Alembic 环境:从 app.core.config 取 DATABASE_URL,支持 async 引擎。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
# Alembic Config
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# 把 DATABASE_URL 注入 alembic ini
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
# 导入所有模型,让 Base.metadata 能被 autogenerate 扫描
|
||||||
|
from app import models # noqa: F401, E402
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=settings.DATABASE_URL,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""p4_product_skill
|
||||||
|
|
||||||
|
Revision ID: 0b286bfebd6e
|
||||||
|
Revises: 7c8589fdbff1
|
||||||
|
Create Date: 2026-04-24 13:59:03.048452
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0b286bfebd6e'
|
||||||
|
down_revision: str | Sequence[str] | None = '7c8589fdbff1'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('channel_code',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('platform',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('url', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('product',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('platform_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('dept_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['platform_id'], ['platform.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_product_dept_id'), 'product', ['dept_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_product_owner_id'), 'product', ['owner_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_product_platform_id'), 'product', ['platform_id'], unique=False)
|
||||||
|
op.create_table('script_template',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('language', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timeout_seconds', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('product_channel_code',
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('channel_code_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['channel_code_id'], ['channel_code.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('product_id', 'channel_code_id')
|
||||||
|
)
|
||||||
|
op.create_table('skill',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('executor_type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('input_schema', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('param_mapping', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('lobster_template_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('script_template_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('doc', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['script_template_id'], ['script_template.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('skill_call_log',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('skill_code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('operator_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('operator_name', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('executor_type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('input_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('output_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('result', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('message', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('cost_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('source', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_skill_call_log_created_at'), 'skill_call_log', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_skill_call_log_operator_id'), 'skill_call_log', ['operator_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_skill_call_log_skill_id'), 'skill_call_log', ['skill_id'], unique=False)
|
||||||
|
op.create_table('skill_product_scope',
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('skill_id', 'product_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('skill_product_scope')
|
||||||
|
op.drop_index(op.f('ix_skill_call_log_skill_id'), table_name='skill_call_log')
|
||||||
|
op.drop_index(op.f('ix_skill_call_log_operator_id'), table_name='skill_call_log')
|
||||||
|
op.drop_index(op.f('ix_skill_call_log_created_at'), table_name='skill_call_log')
|
||||||
|
op.drop_table('skill_call_log')
|
||||||
|
op.drop_table('skill')
|
||||||
|
op.drop_table('product_channel_code')
|
||||||
|
op.drop_table('script_template')
|
||||||
|
op.drop_index(op.f('ix_product_platform_id'), table_name='product')
|
||||||
|
op.drop_index(op.f('ix_product_owner_id'), table_name='product')
|
||||||
|
op.drop_index(op.f('ix_product_dept_id'), table_name='product')
|
||||||
|
op.drop_table('product')
|
||||||
|
op.drop_table('platform')
|
||||||
|
op.drop_table('channel_code')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""p2_rbac
|
||||||
|
|
||||||
|
Revision ID: 2862beceb4e7
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-24 12:56:40.426760
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '2862beceb4e7'
|
||||||
|
down_revision: str | Sequence[str] | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('menu',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('path', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('component', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('perm_code', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('sort', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['menu.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('perm_code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_menu_parent_id'), 'menu', ['parent_id'], unique=False)
|
||||||
|
op.create_table('organization',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('sort', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['organization.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_organization_parent_id'), 'organization', ['parent_id'], unique=False)
|
||||||
|
op.create_table('role',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('dept_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('is_builtin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('dept_id', 'code', name='uq_role_dept_code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_role_dept_id'), 'role', ['dept_id'], unique=False)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('nickname', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('email', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('avatar', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('dept_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('is_superuser', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_dept_id'), 'user', ['dept_id'], unique=False)
|
||||||
|
op.create_table('role_account_scope',
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('role_id', 'account_id')
|
||||||
|
)
|
||||||
|
op.create_table('role_menu',
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('menu_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['menu_id'], ['menu.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('role_id', 'menu_id')
|
||||||
|
)
|
||||||
|
op.create_table('role_product_scope',
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('role_id', 'product_id')
|
||||||
|
)
|
||||||
|
op.create_table('role_skill_scope',
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('role_id', 'skill_id')
|
||||||
|
)
|
||||||
|
op.create_table('user_role',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'role_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('user_role')
|
||||||
|
op.drop_table('role_skill_scope')
|
||||||
|
op.drop_table('role_product_scope')
|
||||||
|
op.drop_table('role_menu')
|
||||||
|
op.drop_table('role_account_scope')
|
||||||
|
op.drop_index(op.f('ix_user_dept_id'), table_name='user')
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_index(op.f('ix_role_dept_id'), table_name='role')
|
||||||
|
op.drop_table('role')
|
||||||
|
op.drop_index(op.f('ix_organization_parent_id'), table_name='organization')
|
||||||
|
op.drop_table('organization')
|
||||||
|
op.drop_index(op.f('ix_menu_parent_id'), table_name='menu')
|
||||||
|
op.drop_table('menu')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""p6_llm
|
||||||
|
|
||||||
|
Revision ID: 3099be983359
|
||||||
|
Revises: a74b3a3db72f
|
||||||
|
Create Date: 2026-04-24 15:16:37.670788
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '3099be983359'
|
||||||
|
down_revision: str | Sequence[str] | None = 'a74b3a3db72f'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('llm_provider',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('vendor', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('api_base', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('api_key_cipher', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('llm_model',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('provider_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('model', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('max_context', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('price_input_per_1k', sa.Numeric(precision=10, scale=6), nullable=False),
|
||||||
|
sa.Column('price_output_per_1k', sa.Numeric(precision=10, scale=6), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['provider_id'], ['llm_provider.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_llm_model_provider_id'), 'llm_model', ['provider_id'], unique=False)
|
||||||
|
op.create_table('llm_routing_rule',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('conditions', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('model_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['model_id'], ['llm_model.id'], ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('llm_usage',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('provider_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('model_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('provider_code', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('model', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('operator_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('operator_name', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('source', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('task_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('prompt_tokens', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('completion_tokens', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('total_tokens', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('cost_usd', sa.Numeric(precision=12, scale=6), nullable=False),
|
||||||
|
sa.Column('result', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('message', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('cost_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['model_id'], ['llm_model.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['provider_id'], ['llm_provider.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_llm_usage_created_at'), 'llm_usage', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_llm_usage_model_id'), 'llm_usage', ['model_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_llm_usage_operator_id'), 'llm_usage', ['operator_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_llm_usage_provider_id'), 'llm_usage', ['provider_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_llm_usage_provider_id'), table_name='llm_usage')
|
||||||
|
op.drop_index(op.f('ix_llm_usage_operator_id'), table_name='llm_usage')
|
||||||
|
op.drop_index(op.f('ix_llm_usage_model_id'), table_name='llm_usage')
|
||||||
|
op.drop_index(op.f('ix_llm_usage_created_at'), table_name='llm_usage')
|
||||||
|
op.drop_table('llm_usage')
|
||||||
|
op.drop_table('llm_routing_rule')
|
||||||
|
op.drop_index(op.f('ix_llm_model_provider_id'), table_name='llm_model')
|
||||||
|
op.drop_table('llm_model')
|
||||||
|
op.drop_table('llm_provider')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""p6b_lobster_config
|
||||||
|
|
||||||
|
Revision ID: 4911d408555c
|
||||||
|
Revises: 3099be983359
|
||||||
|
Create Date: 2026-04-24 16:04:28.253699
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4911d408555c'
|
||||||
|
down_revision: str | Sequence[str] | None = '3099be983359'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('lobster_config',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('api_base', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('api_key_cipher', sa.Text(), nullable=True),
|
||||||
|
sa.Column('callback_secret_cipher', sa.Text(), nullable=True),
|
||||||
|
sa.Column('timeout_seconds', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('poll_interval_seconds', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('note', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('lobster_config')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""p3_platform_account_oplog
|
||||||
|
|
||||||
|
Revision ID: 7c8589fdbff1
|
||||||
|
Revises: 2862beceb4e7
|
||||||
|
Create Date: 2026-04-24 13:35:23.265444
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7c8589fdbff1'
|
||||||
|
down_revision: str | Sequence[str] | None = '2862beceb4e7'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('op_log',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('operator_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('operator_name', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('module', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('action', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('method', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('path', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('status_code', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ip', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('payload', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('cost_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_op_log_created_at'), 'op_log', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_op_log_module'), 'op_log', ['module'], unique=False)
|
||||||
|
op.create_index(op.f('ix_op_log_operator_id'), 'op_log', ['operator_id'], unique=False)
|
||||||
|
op.create_table('platform_account',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('platform_name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('product_code', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('login', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('password_cipher', sa.Text(), nullable=False),
|
||||||
|
sa.Column('google_key_cipher', sa.Text(), nullable=True),
|
||||||
|
sa.Column('remark', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('last_test_result', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('last_test_message', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_platform_account_owner_id'), 'platform_account', ['owner_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_platform_account_owner_id'), table_name='platform_account')
|
||||||
|
op.drop_table('platform_account')
|
||||||
|
op.drop_index(op.f('ix_op_log_operator_id'), table_name='op_log')
|
||||||
|
op.drop_index(op.f('ix_op_log_module'), table_name='op_log')
|
||||||
|
op.drop_index(op.f('ix_op_log_created_at'), table_name='op_log')
|
||||||
|
op.drop_table('op_log')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""p5_task_schedule
|
||||||
|
|
||||||
|
Revision ID: a74b3a3db72f
|
||||||
|
Revises: 0b286bfebd6e
|
||||||
|
Create Date: 2026-04-24 14:23:34.316948
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a74b3a3db72f'
|
||||||
|
down_revision: str | Sequence[str] | None = '0b286bfebd6e'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('schedule',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('input_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('cron', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('last_run_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('next_run_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_task_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_schedule_skill_id'), 'schedule', ['skill_id'], unique=False)
|
||||||
|
op.create_table('task',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('task_no', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('skill_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('skill_code', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executor_type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('input_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('resolved_input', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('result_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('external_ref', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('source', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('schedule_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('retry_of_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('operator_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('operator_name', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('cost_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timeout_seconds', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['retry_of_id'], ['task.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['schedule_id'], ['schedule.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_task_external_ref'), 'task', ['external_ref'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_operator_id'), 'task', ['operator_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_product_id'), 'task', ['product_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_schedule_id'), 'task', ['schedule_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_skill_id'), 'task', ['skill_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_status'), 'task', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_task_no'), 'task', ['task_no'], unique=True)
|
||||||
|
op.create_table('schedule_log',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('schedule_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('result', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('message', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('triggered_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('cost_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['schedule_id'], ['schedule.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_schedule_log_schedule_id'), 'schedule_log', ['schedule_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_schedule_log_triggered_at'), 'schedule_log', ['triggered_at'], unique=False)
|
||||||
|
op.create_table('task_log',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('task_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('level', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('external_event_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('task_id', 'external_event_id', name='uq_task_log_event')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_task_log_created_at'), 'task_log', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_task_log_task_id'), 'task_log', ['task_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_task_log_task_id'), table_name='task_log')
|
||||||
|
op.drop_index(op.f('ix_task_log_created_at'), table_name='task_log')
|
||||||
|
op.drop_table('task_log')
|
||||||
|
op.drop_index(op.f('ix_schedule_log_triggered_at'), table_name='schedule_log')
|
||||||
|
op.drop_index(op.f('ix_schedule_log_schedule_id'), table_name='schedule_log')
|
||||||
|
op.drop_table('schedule_log')
|
||||||
|
op.drop_index(op.f('ix_task_task_no'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_status'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_skill_id'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_schedule_id'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_product_id'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_operator_id'), table_name='task')
|
||||||
|
op.drop_index(op.f('ix_task_external_ref'), table_name='task')
|
||||||
|
op.drop_table('task')
|
||||||
|
op.drop_index(op.f('ix_schedule_skill_id'), table_name='schedule')
|
||||||
|
op.drop_table('schedule')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""聚合所有 v1 路由。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import (
|
||||||
|
auth,
|
||||||
|
callbacks,
|
||||||
|
channel_codes,
|
||||||
|
dashboard,
|
||||||
|
executor,
|
||||||
|
health,
|
||||||
|
llm,
|
||||||
|
lobster_config,
|
||||||
|
menus,
|
||||||
|
op_logs,
|
||||||
|
organizations,
|
||||||
|
platform_accounts,
|
||||||
|
platforms,
|
||||||
|
products,
|
||||||
|
roles,
|
||||||
|
schedules,
|
||||||
|
scripts,
|
||||||
|
skills,
|
||||||
|
tasks,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_router.include_router(health.router)
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(organizations.router)
|
||||||
|
api_router.include_router(roles.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
|
api_router.include_router(menus.router)
|
||||||
|
api_router.include_router(platform_accounts.router)
|
||||||
|
api_router.include_router(platforms.router)
|
||||||
|
api_router.include_router(channel_codes.router)
|
||||||
|
api_router.include_router(products.router)
|
||||||
|
api_router.include_router(scripts.router)
|
||||||
|
api_router.include_router(skills.router)
|
||||||
|
api_router.include_router(skills.log_router)
|
||||||
|
api_router.include_router(tasks.router)
|
||||||
|
api_router.include_router(schedules.router)
|
||||||
|
api_router.include_router(callbacks.router)
|
||||||
|
api_router.include_router(dashboard.router)
|
||||||
|
api_router.include_router(op_logs.router)
|
||||||
|
api_router.include_router(executor.router)
|
||||||
|
api_router.include_router(lobster_config.router)
|
||||||
|
api_router.include_router(llm.router)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Auth:登录 / 刷新 / me / 登出。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
MeResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
RoleBrief,
|
||||||
|
TokenPair,
|
||||||
|
UserInfo,
|
||||||
|
)
|
||||||
|
from app.services import auth_service
|
||||||
|
from app.services.rbac_service import build_menu_tree, get_user_menus, get_user_perm_codes
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginRequest, db: DbDep) -> dict:
|
||||||
|
_user, pair = await auth_service.login(db, body.username, body.password)
|
||||||
|
return ok(pair.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh(body: RefreshRequest, db: DbDep) -> dict:
|
||||||
|
pair: TokenPair = await auth_service.refresh(db, body.refresh_token)
|
||||||
|
return ok(pair.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(_user: CurrentUser) -> dict:
|
||||||
|
# P2:无状态 JWT,客户端清本地 token 即可。
|
||||||
|
# 未来可在 Redis 建 denylist 实现服务端吊销。
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: CurrentUser, db: DbDep) -> dict:
|
||||||
|
perm_codes = await get_user_perm_codes(db, user)
|
||||||
|
menus = await get_user_menus(db, user)
|
||||||
|
tree = build_menu_tree(menus)
|
||||||
|
|
||||||
|
user_info = UserInfo(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
nickname=user.nickname,
|
||||||
|
email=user.email,
|
||||||
|
avatar=user.avatar,
|
||||||
|
dept_id=user.dept_id,
|
||||||
|
dept_name=user.dept.name if user.dept else None,
|
||||||
|
is_superuser=user.is_superuser,
|
||||||
|
roles=[RoleBrief.model_validate(r) for r in user.roles],
|
||||||
|
perm_codes=perm_codes,
|
||||||
|
)
|
||||||
|
resp = MeResponse(user=user_info, menu_tree=tree)
|
||||||
|
return ok(resp.model_dump())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""龙虾回调端点:接收外部任务状态更新。
|
||||||
|
|
||||||
|
签名校验的 secret 来自 DB(lobster_config)优先,.env 兜底。未配置时跳过校验以便本地自测。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.deps import DbDep
|
||||||
|
from app.core.exceptions import AppError, ok
|
||||||
|
from app.models import Task
|
||||||
|
from app.services import lobster_config_service, task_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/callbacks", tags=["callback"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_signature(db, body: bytes, signature: str | None) -> None:
|
||||||
|
eff = await lobster_config_service.load_effective(db)
|
||||||
|
secret = eff.callback_secret
|
||||||
|
if not secret:
|
||||||
|
# 未配置 secret 时跳过校验,便于本地自测
|
||||||
|
return
|
||||||
|
if not signature:
|
||||||
|
raise AppError("签名缺失", code=401, http_status=401)
|
||||||
|
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected, signature):
|
||||||
|
raise AppError("签名无效", code=401, http_status=401)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lobster")
|
||||||
|
async def lobster_callback(
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
x_loom_signature: str | None = Header(default=None),
|
||||||
|
) -> dict:
|
||||||
|
body = await request.body()
|
||||||
|
await _verify_signature(db, body, x_loom_signature)
|
||||||
|
|
||||||
|
payload = await request.json()
|
||||||
|
run_id = str(payload.get("run_id") or "")
|
||||||
|
status_ = payload.get("status")
|
||||||
|
if not run_id:
|
||||||
|
raise AppError("run_id 缺失", code=400, http_status=400)
|
||||||
|
|
||||||
|
t = (await db.execute(select(Task).where(Task.external_ref == run_id))).scalar_one_or_none()
|
||||||
|
if t is None:
|
||||||
|
return ok({"handled": False, "reason": "unknown run_id"})
|
||||||
|
|
||||||
|
for log in payload.get("logs") or []:
|
||||||
|
await task_service._append_log(
|
||||||
|
db, t.id,
|
||||||
|
str(log.get("level", "INFO")),
|
||||||
|
str(log.get("message", "")),
|
||||||
|
str(log.get("event_id")) if log.get("event_id") else None,
|
||||||
|
)
|
||||||
|
if status_ == "SUCCEEDED":
|
||||||
|
t.result_data = payload.get("output")
|
||||||
|
await task_service._transition(db, t, "SUCCEEDED")
|
||||||
|
elif status_ == "FAILED":
|
||||||
|
t.error_message = payload.get("message")
|
||||||
|
await task_service._transition(db, t, "FAILED")
|
||||||
|
elif status_ == "TIMEOUT":
|
||||||
|
t.error_message = payload.get("message") or "timeout"
|
||||||
|
await task_service._transition(db, t, "TIMEOUT")
|
||||||
|
return ok({"handled": True})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.channel_code import ChannelCreate, ChannelRead, ChannelUpdate
|
||||||
|
from app.services import channel_code_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/channel-codes", tags=["channel-code"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("product:channel:view"))])
|
||||||
|
async def list_channels(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
type: str | None = Query(default=None, alias="type"),
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await channel_code_service.paginate(db, page, size, keyword, type)
|
||||||
|
return ok({
|
||||||
|
"list": [ChannelRead.model_validate(r).model_dump() for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", dependencies=[Depends(require_perm("product:channel:view"))])
|
||||||
|
async def list_all_channels(db: DbDep) -> dict:
|
||||||
|
rows = await channel_code_service.list_all(db)
|
||||||
|
return ok([ChannelRead.model_validate(r).model_dump() for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("product:channel:create"))])
|
||||||
|
async def create_channel(data: ChannelCreate, db: DbDep) -> dict:
|
||||||
|
c = await channel_code_service.create(db, data)
|
||||||
|
return ok(ChannelRead.model_validate(c).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{cid}", dependencies=[Depends(require_perm("product:channel:update"))])
|
||||||
|
async def update_channel(cid: int, data: ChannelUpdate, db: DbDep) -> dict:
|
||||||
|
c = await channel_code_service.update(db, cid, data)
|
||||||
|
return ok(ChannelRead.model_validate(c).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{cid}", dependencies=[Depends(require_perm("product:channel:delete"))])
|
||||||
|
async def delete_channel(cid: int, db: DbDep) -> dict:
|
||||||
|
await channel_code_service.delete(db, cid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""仪表盘 & 统计报表聚合接口。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.models import Organization, Schedule, Skill, Task, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _today_start_utc() -> datetime:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", dependencies=[Depends(require_perm("dashboard:view"))])
|
||||||
|
async def summary(db: DbDep) -> dict:
|
||||||
|
today = _today_start_utc()
|
||||||
|
|
||||||
|
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
|
||||||
|
total_skills = (
|
||||||
|
await db.execute(select(func.count(Skill.id)).where(Skill.status == "ACTIVE"))
|
||||||
|
).scalar() or 0
|
||||||
|
total_tasks = (await db.execute(select(func.count(Task.id)))).scalar() or 0
|
||||||
|
enabled_schedules = (
|
||||||
|
await db.execute(select(func.count(Schedule.id)).where(Schedule.enabled.is_(True)))
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
today_total = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(Task.created_at >= today)
|
||||||
|
)).scalar() or 0
|
||||||
|
today_succeeded = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(Task.created_at >= today, Task.status == "SUCCEEDED")
|
||||||
|
)).scalar() or 0
|
||||||
|
today_failed = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(
|
||||||
|
Task.created_at >= today, Task.status.in_(["FAILED", "TIMEOUT"])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
running = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(
|
||||||
|
Task.status.in_(["PENDING", "QUEUED", "RUNNING"])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# 活跃用户:近 7 天内有创建任务的 distinct 人数
|
||||||
|
active_start = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
active_users = (await db.execute(
|
||||||
|
select(func.count(func.distinct(Task.operator_id)))
|
||||||
|
.where(Task.created_at >= active_start, Task.operator_id.is_not(None))
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
status_rows = (await db.execute(
|
||||||
|
select(Task.status, func.count(Task.id)).group_by(Task.status)
|
||||||
|
)).all()
|
||||||
|
by_status = {s: int(c) for s, c in status_rows}
|
||||||
|
|
||||||
|
recent = (await db.execute(select(Task).order_by(Task.id.desc()).limit(8))).scalars().all()
|
||||||
|
recent_tasks = [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"task_no": t.task_no,
|
||||||
|
"name": t.name,
|
||||||
|
"skill_code": t.skill_code,
|
||||||
|
"executor_type": t.executor_type,
|
||||||
|
"product_id": t.product_id,
|
||||||
|
"operator_name": t.operator_name,
|
||||||
|
"status": t.status,
|
||||||
|
"cost_ms": t.cost_ms,
|
||||||
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||||
|
}
|
||||||
|
for t in recent
|
||||||
|
]
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"total_users": int(total_users),
|
||||||
|
"total_skills": int(total_skills),
|
||||||
|
"total_tasks": int(total_tasks),
|
||||||
|
"enabled_schedules": int(enabled_schedules),
|
||||||
|
|
||||||
|
"today_total": int(today_total),
|
||||||
|
"today_succeeded": int(today_succeeded),
|
||||||
|
"today_failed": int(today_failed),
|
||||||
|
"running": int(running),
|
||||||
|
"active_users_7d": int(active_users),
|
||||||
|
|
||||||
|
"by_status": by_status,
|
||||||
|
"recent_tasks": recent_tasks,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts", dependencies=[Depends(require_perm("dashboard:view"))])
|
||||||
|
async def charts(db: DbDep, days: int = Query(7, ge=1, le=30)) -> dict:
|
||||||
|
"""仪表盘图表:7d 趋势 + 执行器分布 + Top5 Skill + 按部门统计。"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start = (now - timedelta(days=days - 1)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1) 每天总量 + 成功数
|
||||||
|
total_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc("day", Task.created_at).label("day"),
|
||||||
|
func.count(Task.id),
|
||||||
|
)
|
||||||
|
.where(Task.created_at >= start)
|
||||||
|
.group_by("day")
|
||||||
|
)).all()
|
||||||
|
ok_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc("day", Task.created_at).label("day"),
|
||||||
|
func.count(Task.id),
|
||||||
|
)
|
||||||
|
.where(Task.created_at >= start, Task.status == "SUCCEEDED")
|
||||||
|
.group_by("day")
|
||||||
|
)).all()
|
||||||
|
total_by_day = {d.date().isoformat(): int(c) for d, c in total_rows if d}
|
||||||
|
ok_by_day = {d.date().isoformat(): int(c) for d, c in ok_rows if d}
|
||||||
|
trend: list[dict] = []
|
||||||
|
for i in range(days):
|
||||||
|
day_dt = start + timedelta(days=i)
|
||||||
|
day_key = day_dt.date().isoformat()
|
||||||
|
trend.append({
|
||||||
|
"day": day_key,
|
||||||
|
"label": day_dt.strftime("%m/%d"),
|
||||||
|
"total": total_by_day.get(day_key, 0),
|
||||||
|
"succeeded": ok_by_day.get(day_key, 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2) 执行器分布
|
||||||
|
exec_rows = (await db.execute(
|
||||||
|
select(Task.executor_type, func.count(Task.id)).group_by(Task.executor_type)
|
||||||
|
)).all()
|
||||||
|
exec_total = sum(int(c) for _, c in exec_rows) or 1
|
||||||
|
executor_split = [
|
||||||
|
{
|
||||||
|
"key": et,
|
||||||
|
"label": "龙虾 Agent" if et == "LOBSTER" else "脚本引擎",
|
||||||
|
"count": int(c),
|
||||||
|
"percent": round(int(c) / exec_total * 100, 1),
|
||||||
|
}
|
||||||
|
for et, c in exec_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3) Top 5 Skill(最近 days 天)
|
||||||
|
top_skill_rows = (await db.execute(
|
||||||
|
select(Task.skill_code, func.count(Task.id).label("cnt"))
|
||||||
|
.where(Task.created_at >= start)
|
||||||
|
.group_by(Task.skill_code)
|
||||||
|
.order_by(func.count(Task.id).desc())
|
||||||
|
.limit(5)
|
||||||
|
)).all()
|
||||||
|
top_max = max((int(c) for _, c in top_skill_rows), default=1)
|
||||||
|
top_skills = [
|
||||||
|
{
|
||||||
|
"skill_code": sc,
|
||||||
|
"count": int(c),
|
||||||
|
"percent": round(int(c) / top_max * 100, 1),
|
||||||
|
}
|
||||||
|
for sc, c in top_skill_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4) 按部门统计(通过 operator → User.dept_id → Organization.name)
|
||||||
|
op_rows = (await db.execute(
|
||||||
|
select(Task.operator_id, func.count(Task.id))
|
||||||
|
.where(Task.operator_id.is_not(None), Task.created_at >= start)
|
||||||
|
.group_by(Task.operator_id)
|
||||||
|
)).all()
|
||||||
|
dept_stats: list[dict] = []
|
||||||
|
if op_rows:
|
||||||
|
op_ids = [oid for oid, _ in op_rows]
|
||||||
|
user_rows = (await db.execute(
|
||||||
|
select(User.id, User.dept_id).where(User.id.in_(op_ids))
|
||||||
|
)).all()
|
||||||
|
user_dept = dict(user_rows)
|
||||||
|
dept_counts: dict[int | None, int] = {}
|
||||||
|
for oid, c in op_rows:
|
||||||
|
did = user_dept.get(oid)
|
||||||
|
dept_counts[did] = dept_counts.get(did, 0) + int(c)
|
||||||
|
dept_ids = [d for d in dept_counts if d is not None]
|
||||||
|
name_map: dict[int, str] = {}
|
||||||
|
if dept_ids:
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Organization.id, Organization.name).where(Organization.id.in_(dept_ids))
|
||||||
|
)).all()
|
||||||
|
name_map = dict(rows)
|
||||||
|
dept_total = sum(dept_counts.values()) or 1
|
||||||
|
dept_stats = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dept_id": did,
|
||||||
|
"dept_name": name_map.get(did) if did else "未分配部门",
|
||||||
|
"count": c,
|
||||||
|
"percent": round(c / dept_total * 100, 1),
|
||||||
|
}
|
||||||
|
for did, c in dept_counts.items()
|
||||||
|
],
|
||||||
|
key=lambda x: x["count"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"days": days,
|
||||||
|
"trend": trend,
|
||||||
|
"executor_split": executor_split,
|
||||||
|
"top_skills": top_skills,
|
||||||
|
"dept_stats": dept_stats,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report", dependencies=[Depends(require_perm("log:report:view"))])
|
||||||
|
async def report(
|
||||||
|
db: DbDep,
|
||||||
|
days: int = Query(7, ge=1, le=90),
|
||||||
|
) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start = now - timedelta(days=days)
|
||||||
|
|
||||||
|
daily_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc("day", Task.created_at).label("day"),
|
||||||
|
Task.status,
|
||||||
|
func.count(Task.id),
|
||||||
|
)
|
||||||
|
.where(Task.created_at >= start)
|
||||||
|
.group_by("day", Task.status)
|
||||||
|
.order_by("day")
|
||||||
|
)).all()
|
||||||
|
daily: dict[str, dict[str, int]] = {}
|
||||||
|
for day, s, c in daily_rows:
|
||||||
|
d = day.date().isoformat() if day else "n/a"
|
||||||
|
daily.setdefault(d, {})[s] = int(c)
|
||||||
|
|
||||||
|
top_skills_rows = (await db.execute(
|
||||||
|
select(Task.skill_code, func.count(Task.id).label("cnt"))
|
||||||
|
.where(Task.created_at >= start)
|
||||||
|
.group_by(Task.skill_code)
|
||||||
|
.order_by(func.count(Task.id).desc())
|
||||||
|
.limit(5)
|
||||||
|
)).all()
|
||||||
|
top_skills = [{"skill_code": s, "count": int(c)} for s, c in top_skills_rows]
|
||||||
|
|
||||||
|
avg_cost = (await db.execute(
|
||||||
|
select(func.avg(Task.cost_ms))
|
||||||
|
.where(Task.created_at >= start, Task.status == "SUCCEEDED")
|
||||||
|
)).scalar()
|
||||||
|
success = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(
|
||||||
|
Task.created_at >= start, Task.status == "SUCCEEDED"
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
failure = (await db.execute(
|
||||||
|
select(func.count(Task.id)).where(
|
||||||
|
Task.created_at >= start, Task.status.in_(["FAILED", "TIMEOUT"])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"days": days,
|
||||||
|
"daily": daily,
|
||||||
|
"top_skills": top_skills,
|
||||||
|
"avg_cost_ms": int(avg_cost or 0),
|
||||||
|
"success_count": int(success),
|
||||||
|
"failure_count": int(failure),
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""执行器健康状态聚合接口。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.models import LLMProvider, LLMUsage, Skill, Task
|
||||||
|
from app.services import lobster_config_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/executors", tags=["executor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def executor_status(db: DbDep) -> dict:
|
||||||
|
lobster_eff = await lobster_config_service.load_effective(db)
|
||||||
|
lobster_tasks = (await db.execute(
|
||||||
|
select(func.count()).select_from(Task).where(Task.executor_type == "LOBSTER")
|
||||||
|
)).scalar() or 0
|
||||||
|
script_tasks = (await db.execute(
|
||||||
|
select(func.count()).select_from(Task).where(Task.executor_type == "SCRIPT")
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
providers_active = (await db.execute(
|
||||||
|
select(func.count()).select_from(LLMProvider).where(LLMProvider.status == "ACTIVE")
|
||||||
|
)).scalar() or 0
|
||||||
|
llm_calls = (await db.execute(select(func.count(LLMUsage.id)))).scalar() or 0
|
||||||
|
llm_cost = float((await db.execute(select(func.sum(LLMUsage.cost_usd)))).scalar() or 0)
|
||||||
|
|
||||||
|
skill_total = (await db.execute(select(func.count(Skill.id)))).scalar() or 0
|
||||||
|
|
||||||
|
node = "localhost/subprocess"
|
||||||
|
python_bin = sys.executable
|
||||||
|
node_bin = shutil.which("node")
|
||||||
|
bash_bin = shutil.which("bash")
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"lobster": {
|
||||||
|
"configured": not lobster_eff.is_mock,
|
||||||
|
"mode": "real" if not lobster_eff.is_mock else "mock",
|
||||||
|
"api_base": lobster_eff.api_base or "(未配置)",
|
||||||
|
"api_base_source": lobster_eff.api_base_source,
|
||||||
|
"api_key_source": lobster_eff.api_key_source,
|
||||||
|
"timeout_seconds": lobster_eff.timeout_seconds,
|
||||||
|
"poll_interval_seconds": lobster_eff.poll_interval_seconds,
|
||||||
|
"callback_configured": bool(lobster_eff.callback_secret),
|
||||||
|
"callback_source": lobster_eff.callback_secret_source,
|
||||||
|
"task_count": int(lobster_tasks),
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"mode": "subprocess",
|
||||||
|
"node": node,
|
||||||
|
"python": python_bin,
|
||||||
|
"node_bin": node_bin,
|
||||||
|
"bash_bin": bash_bin,
|
||||||
|
"task_count": int(script_tasks),
|
||||||
|
},
|
||||||
|
"llm": {
|
||||||
|
"providers_active": int(providers_active),
|
||||||
|
"total_calls": int(llm_calls),
|
||||||
|
"total_cost_usd": round(llm_cost, 6),
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"skill_total": int(skill_total),
|
||||||
|
"app_env": settings.APP_ENV,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""健康检查:验证 DB + Redis 连通性。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.db.session import engine
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz")
|
||||||
|
async def healthz() -> dict:
|
||||||
|
db_status = "ok"
|
||||||
|
redis_status = "ok"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
db_status = f"error: {e.__class__.__name__}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = aioredis.from_url(settings.REDIS_URL)
|
||||||
|
await client.ping()
|
||||||
|
await client.aclose()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
redis_status = f"error: {e.__class__.__name__}"
|
||||||
|
|
||||||
|
return ok({"db": db_status, "redis": redis_status, "env": settings.APP_ENV})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.llm import (
|
||||||
|
ChatRequest,
|
||||||
|
ModelCreate,
|
||||||
|
ModelUpdate,
|
||||||
|
ProviderCreate,
|
||||||
|
ProviderUpdate,
|
||||||
|
RuleCreate,
|
||||||
|
RuleUpdate,
|
||||||
|
UsageRead,
|
||||||
|
)
|
||||||
|
from app.services import llm_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/llm", tags=["llm"])
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Providers --------
|
||||||
|
|
||||||
|
@router.get("/providers", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def list_providers(db: DbDep) -> dict:
|
||||||
|
return ok([p.model_dump() for p in await llm_service.list_providers(db)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers", dependencies=[Depends(require_perm("system:executor:update"))])
|
||||||
|
async def create_provider(data: ProviderCreate, db: DbDep) -> dict:
|
||||||
|
p = await llm_service.create_provider(db, data)
|
||||||
|
return ok(llm_service.provider_to_read(p).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/providers/{pid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def update_provider(pid: int, data: ProviderUpdate, db: DbDep) -> dict:
|
||||||
|
p = await llm_service.update_provider(db, pid, data)
|
||||||
|
return ok(llm_service.provider_to_read(p).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/providers/{pid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def delete_provider(pid: int, db: DbDep) -> dict:
|
||||||
|
await llm_service.delete_provider(db, pid)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Models --------
|
||||||
|
|
||||||
|
@router.get("/models", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def list_models(db: DbDep) -> dict:
|
||||||
|
return ok([m.model_dump() for m in await llm_service.list_models(db)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models", dependencies=[Depends(require_perm("system:executor:update"))])
|
||||||
|
async def create_model(data: ModelCreate, db: DbDep) -> dict:
|
||||||
|
m = await llm_service.create_model(db, data)
|
||||||
|
return ok((await llm_service.model_to_read(db, m)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/models/{mid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def update_model(mid: int, data: ModelUpdate, db: DbDep) -> dict:
|
||||||
|
m = await llm_service.update_model(db, mid, data)
|
||||||
|
return ok((await llm_service.model_to_read(db, m)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/models/{mid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def delete_model(mid: int, db: DbDep) -> dict:
|
||||||
|
await llm_service.delete_model(db, mid)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Routing --------
|
||||||
|
|
||||||
|
@router.get("/rules", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def list_rules(db: DbDep) -> dict:
|
||||||
|
return ok([r.model_dump() for r in await llm_service.list_rules(db)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules", dependencies=[Depends(require_perm("system:executor:update"))])
|
||||||
|
async def create_rule(data: RuleCreate, db: DbDep) -> dict:
|
||||||
|
r = await llm_service.create_rule(db, data)
|
||||||
|
return ok({
|
||||||
|
"id": r.id, "name": r.name, "priority": r.priority,
|
||||||
|
"conditions": r.conditions, "model_id": r.model_id,
|
||||||
|
"enabled": r.enabled, "description": r.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/rules/{rid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def update_rule(rid: int, data: RuleUpdate, db: DbDep) -> dict:
|
||||||
|
r = await llm_service.update_rule(db, rid, data)
|
||||||
|
return ok({
|
||||||
|
"id": r.id, "name": r.name, "priority": r.priority,
|
||||||
|
"conditions": r.conditions, "model_id": r.model_id,
|
||||||
|
"enabled": r.enabled, "description": r.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/rules/{rid}", dependencies=[Depends(require_perm("system:executor:update"))]
|
||||||
|
)
|
||||||
|
async def delete_rule(rid: int, db: DbDep) -> dict:
|
||||||
|
await llm_service.delete_rule(db, rid)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Chat / Usage --------
|
||||||
|
|
||||||
|
@router.post("/chat", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def chat(body: ChatRequest, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
resp = await llm_service.call_chat(
|
||||||
|
db,
|
||||||
|
operator=user,
|
||||||
|
messages=body.messages,
|
||||||
|
model_label=body.model,
|
||||||
|
conditions=body.conditions,
|
||||||
|
temperature=body.temperature,
|
||||||
|
max_tokens=body.max_tokens,
|
||||||
|
source=body.source,
|
||||||
|
)
|
||||||
|
return ok(resp.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def list_usage(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
provider_id: int | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await llm_service.paginate_usage(db, page, size, provider_id, source)
|
||||||
|
return ok({
|
||||||
|
"list": [UsageRead.model_validate(r).model_dump(mode="json") for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage/summary", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def usage_summary(db: DbDep) -> dict:
|
||||||
|
return ok(await llm_service.usage_summary(db))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""龙虾 Agent 运行时配置 API(UI 可编辑)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.lobster_config import LobsterConfigUpdate
|
||||||
|
from app.services import lobster_config_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/lobster-config", tags=["lobster-config"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("system:executor:view"))])
|
||||||
|
async def get_config(db: DbDep) -> dict:
|
||||||
|
return ok((await lobster_config_service.read(db)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", dependencies=[Depends(require_perm("system:executor:update"))])
|
||||||
|
async def update_config(data: LobsterConfigUpdate, db: DbDep) -> dict:
|
||||||
|
return ok((await lobster_config_service.update(db, data)).model_dump())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.menu import MenuCreate, MenuRead, MenuUpdate
|
||||||
|
from app.services import menu_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/menus", tags=["menu"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("auth:menu:view"))])
|
||||||
|
async def list_menus(db: DbDep) -> dict:
|
||||||
|
items = await menu_service.list_all(db)
|
||||||
|
return ok([MenuRead.model_validate(m).model_dump() for m in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tree", dependencies=[Depends(require_perm("auth:menu:view"))])
|
||||||
|
async def menu_tree(db: DbDep) -> dict:
|
||||||
|
items = await menu_service.list_all(db)
|
||||||
|
tree = menu_service.build_tree(items)
|
||||||
|
return ok([t.model_dump() for t in tree])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("auth:menu:create"))])
|
||||||
|
async def create_menu(data: MenuCreate, db: DbDep) -> dict:
|
||||||
|
m = await menu_service.create(db, data)
|
||||||
|
return ok(MenuRead.model_validate(m).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{mid}", dependencies=[Depends(require_perm("auth:menu:update"))])
|
||||||
|
async def update_menu(mid: int, data: MenuUpdate, db: DbDep) -> dict:
|
||||||
|
m = await menu_service.update(db, mid, data)
|
||||||
|
return ok(MenuRead.model_validate(m).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{mid}", dependencies=[Depends(require_perm("auth:menu:delete"))])
|
||||||
|
async def delete_menu(mid: int, db: DbDep) -> dict:
|
||||||
|
await menu_service.delete(db, mid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.models import OpLog
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/op-logs", tags=["op-log"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("log:op:view"))])
|
||||||
|
async def list_op_logs(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
module: str | None = None,
|
||||||
|
operator_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
stmt = select(OpLog)
|
||||||
|
if module:
|
||||||
|
stmt = stmt.where(OpLog.module == module)
|
||||||
|
if operator_id:
|
||||||
|
stmt = stmt.where(OpLog.operator_id == operator_id)
|
||||||
|
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).select_from(stmt.order_by(None).subquery())
|
||||||
|
)).scalar() or 0
|
||||||
|
stmt = stmt.order_by(OpLog.id.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
rows = list((await db.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
def _row(r: OpLog) -> dict:
|
||||||
|
return {
|
||||||
|
"id": r.id,
|
||||||
|
"operator_id": r.operator_id,
|
||||||
|
"operator_name": r.operator_name,
|
||||||
|
"module": r.module,
|
||||||
|
"action": r.action,
|
||||||
|
"method": r.method,
|
||||||
|
"path": r.path,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"ip": r.ip,
|
||||||
|
"cost_ms": r.cost_ms,
|
||||||
|
"payload": r.payload,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({"list": [_row(r) for r in rows], "total": total, "page": page, "size": size})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.organization import OrgCreate, OrgRead, OrgUpdate
|
||||||
|
from app.services import org_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations", tags=["organization"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("auth:org:view"))])
|
||||||
|
async def list_orgs(db: DbDep) -> dict:
|
||||||
|
items = await org_service.list_all(db)
|
||||||
|
return ok([OrgRead.model_validate(o).model_dump() for o in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tree", dependencies=[Depends(require_perm("auth:org:view"))])
|
||||||
|
async def org_tree(db: DbDep) -> dict:
|
||||||
|
items = await org_service.list_all(db)
|
||||||
|
tree = org_service.build_tree(items)
|
||||||
|
return ok([t.model_dump() for t in tree])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("auth:org:create"))])
|
||||||
|
async def create_org(data: OrgCreate, db: DbDep) -> dict:
|
||||||
|
o = await org_service.create(db, data)
|
||||||
|
return ok(OrgRead.model_validate(o).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{oid}", dependencies=[Depends(require_perm("auth:org:update"))])
|
||||||
|
async def update_org(oid: int, data: OrgUpdate, db: DbDep) -> dict:
|
||||||
|
o = await org_service.update(db, oid, data)
|
||||||
|
return ok(OrgRead.model_validate(o).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{oid}", dependencies=[Depends(require_perm("auth:org:delete"))])
|
||||||
|
async def delete_org(oid: int, db: DbDep) -> dict:
|
||||||
|
await org_service.delete(db, oid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.platform_account import PlatformAccountCreate, PlatformAccountUpdate
|
||||||
|
from app.services import platform_account_service as pa_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/platform-accounts", tags=["platform-account"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("platform-account:view"))])
|
||||||
|
async def list_accounts(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
platform_name: str | None = None,
|
||||||
|
owner_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await pa_service.paginate(db, page, size, keyword, platform_name, owner_id)
|
||||||
|
return ok({
|
||||||
|
"list": [(await pa_service.to_read(db, a)).model_dump() for a in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{aid}", dependencies=[Depends(require_perm("platform-account:view"))])
|
||||||
|
async def get_account(aid: int, db: DbDep) -> dict:
|
||||||
|
acc = await pa_service.get(db, aid)
|
||||||
|
return ok((await pa_service.to_read(db, acc)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("platform-account:create"))])
|
||||||
|
async def create_account(data: PlatformAccountCreate, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
acc = await pa_service.create(db, owner_id=user.id, data=data)
|
||||||
|
return ok((await pa_service.to_read(db, acc)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{aid}", dependencies=[Depends(require_perm("platform-account:update"))])
|
||||||
|
async def update_account(aid: int, data: PlatformAccountUpdate, db: DbDep) -> dict:
|
||||||
|
acc = await pa_service.update(db, aid, data)
|
||||||
|
return ok((await pa_service.to_read(db, acc)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{aid}", dependencies=[Depends(require_perm("platform-account:delete"))])
|
||||||
|
async def delete_account(aid: int, db: DbDep) -> dict:
|
||||||
|
await pa_service.delete(db, aid)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{aid}/test", dependencies=[Depends(require_perm("platform-account:test"))])
|
||||||
|
async def test_account(aid: int, db: DbDep) -> dict:
|
||||||
|
acc = await pa_service.test_connection(db, aid)
|
||||||
|
return ok((await pa_service.to_read(db, acc)).model_dump())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.platform import PlatformCreate, PlatformRead, PlatformUpdate
|
||||||
|
from app.services import platform_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/platforms", tags=["platform"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("product:suite:view"))])
|
||||||
|
async def list_platforms(db: DbDep) -> dict:
|
||||||
|
return ok([p.model_dump() for p in await platform_service.list_all(db)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("product:suite:create"))])
|
||||||
|
async def create_platform(data: PlatformCreate, db: DbDep) -> dict:
|
||||||
|
p = await platform_service.create(db, data)
|
||||||
|
return ok(PlatformRead(
|
||||||
|
id=p.id, code=p.code, name=p.name, url=p.url, description=p.description,
|
||||||
|
status=p.status, product_count=0,
|
||||||
|
).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{pid}", dependencies=[Depends(require_perm("product:suite:update"))])
|
||||||
|
async def update_platform(pid: int, data: PlatformUpdate, db: DbDep) -> dict:
|
||||||
|
p = await platform_service.update(db, pid, data)
|
||||||
|
return ok(PlatformRead(
|
||||||
|
id=p.id, code=p.code, name=p.name, url=p.url, description=p.description,
|
||||||
|
status=p.status, product_count=0,
|
||||||
|
).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{pid}", dependencies=[Depends(require_perm("product:suite:delete"))])
|
||||||
|
async def delete_platform(pid: int, db: DbDep) -> dict:
|
||||||
|
await platform_service.delete(db, pid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.product import ProductCreate, ProductUpdate
|
||||||
|
from app.services import product_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["product"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("product:view"))])
|
||||||
|
async def list_products(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
platform_id: int | None = None,
|
||||||
|
dept_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await product_service.paginate(db, page, size, keyword, platform_id, dept_id)
|
||||||
|
return ok({
|
||||||
|
"list": [(await product_service.to_read(db, p)).model_dump() for p in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{pid}", dependencies=[Depends(require_perm("product:view"))])
|
||||||
|
async def get_product(pid: int, db: DbDep) -> dict:
|
||||||
|
p = await product_service.get(db, pid)
|
||||||
|
return ok((await product_service.to_read(db, p)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("product:create"))])
|
||||||
|
async def create_product(data: ProductCreate, db: DbDep) -> dict:
|
||||||
|
p = await product_service.create(db, data)
|
||||||
|
return ok((await product_service.to_read(db, p)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{pid}", dependencies=[Depends(require_perm("product:update"))])
|
||||||
|
async def update_product(pid: int, data: ProductUpdate, db: DbDep) -> dict:
|
||||||
|
p = await product_service.update(db, pid, data)
|
||||||
|
return ok((await product_service.to_read(db, p)).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{pid}", dependencies=[Depends(require_perm("product:delete"))])
|
||||||
|
async def delete_product(pid: int, db: DbDep) -> dict:
|
||||||
|
await product_service.delete(db, pid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.role import RoleCreate, RoleRead, RoleUpdate
|
||||||
|
from app.services import role_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/roles", tags=["role"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("auth:role:view"))])
|
||||||
|
async def list_roles(db: DbDep) -> dict:
|
||||||
|
items = await role_service.list_all(db)
|
||||||
|
return ok([RoleRead.model_validate(r).model_dump() for r in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{rid}", dependencies=[Depends(require_perm("auth:role:view"))])
|
||||||
|
async def get_role(rid: int, db: DbDep) -> dict:
|
||||||
|
role = await role_service.get_with_menus(db, rid)
|
||||||
|
detail = await role_service.to_detail(db, role)
|
||||||
|
return ok(detail.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("auth:role:create"))])
|
||||||
|
async def create_role(data: RoleCreate, db: DbDep) -> dict:
|
||||||
|
role = await role_service.create(db, data)
|
||||||
|
detail = await role_service.to_detail(db, role)
|
||||||
|
return ok(detail.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{rid}", dependencies=[Depends(require_perm("auth:role:update"))])
|
||||||
|
async def update_role(rid: int, data: RoleUpdate, db: DbDep) -> dict:
|
||||||
|
role = await role_service.update(db, rid, data)
|
||||||
|
detail = await role_service.to_detail(db, role)
|
||||||
|
return ok(detail.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{rid}", dependencies=[Depends(require_perm("auth:role:delete"))])
|
||||||
|
async def delete_role(rid: int, db: DbDep) -> dict:
|
||||||
|
await role_service.delete(db, rid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate
|
||||||
|
from app.services import schedule_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedules", tags=["schedule"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("system:cron:view"))])
|
||||||
|
async def list_schedules(db: DbDep) -> dict:
|
||||||
|
return ok(await schedule_service.list_all(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}", dependencies=[Depends(require_perm("system:cron:view"))])
|
||||||
|
async def get_schedule(sid: int, db: DbDep) -> dict:
|
||||||
|
return ok(await schedule_service.get(db, sid))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}/logs", dependencies=[Depends(require_perm("system:cron:view"))])
|
||||||
|
async def list_schedule_logs(
|
||||||
|
sid: int, db: DbDep, limit: int = Query(50, ge=1, le=500)
|
||||||
|
) -> dict:
|
||||||
|
return ok(await schedule_service.list_logs(db, sid, limit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("system:cron:create"))])
|
||||||
|
async def create_schedule(data: ScheduleCreate, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
return ok(await schedule_service.create(db, data, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sid}", dependencies=[Depends(require_perm("system:cron:update"))])
|
||||||
|
async def update_schedule(sid: int, data: ScheduleUpdate, db: DbDep) -> dict:
|
||||||
|
return ok(await schedule_service.update(db, sid, data))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/toggle", dependencies=[Depends(require_perm("system:cron:update"))])
|
||||||
|
async def toggle_schedule(sid: int, enabled: bool, db: DbDep) -> dict:
|
||||||
|
return ok(await schedule_service.toggle(db, sid, enabled))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/trigger", dependencies=[Depends(require_perm("system:cron:update"))])
|
||||||
|
async def trigger_now(sid: int, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
return ok(await schedule_service.trigger_now(db, sid, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sid}", dependencies=[Depends(require_perm("system:cron:delete"))])
|
||||||
|
async def delete_schedule(sid: int, db: DbDep) -> dict:
|
||||||
|
await schedule_service.delete(db, sid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.script_template import ScriptCreate, ScriptRead, ScriptUpdate
|
||||||
|
from app.services import script_template_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/scripts", tags=["script-template"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("system:script:view"))])
|
||||||
|
async def list_scripts(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await script_template_service.paginate(db, page, size, keyword, language)
|
||||||
|
return ok({
|
||||||
|
"list": [ScriptRead.model_validate(r).model_dump() for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", dependencies=[Depends(require_perm("skill:view"))])
|
||||||
|
async def list_all(db: DbDep) -> dict:
|
||||||
|
"""供 Skill 编辑时选脚本模板用。"""
|
||||||
|
rows = await script_template_service.list_all(db)
|
||||||
|
return ok([ScriptRead.model_validate(r).model_dump() for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("system:script:create"))])
|
||||||
|
async def create_script(data: ScriptCreate, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
s = await script_template_service.create(db, data, owner_id=user.id)
|
||||||
|
return ok(ScriptRead.model_validate(s).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sid}", dependencies=[Depends(require_perm("system:script:update"))])
|
||||||
|
async def update_script(sid: int, data: ScriptUpdate, db: DbDep) -> dict:
|
||||||
|
s = await script_template_service.update(db, sid, data)
|
||||||
|
return ok(ScriptRead.model_validate(s).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sid}", dependencies=[Depends(require_perm("system:script:delete"))])
|
||||||
|
async def delete_script(sid: int, db: DbDep) -> dict:
|
||||||
|
await script_template_service.delete(db, sid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.skill import (
|
||||||
|
SkillCallLogRead,
|
||||||
|
SkillCreate,
|
||||||
|
SkillTestRequest,
|
||||||
|
SkillUpdate,
|
||||||
|
)
|
||||||
|
from app.services import skill_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/skills", tags=["skill"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("skill:view"))])
|
||||||
|
async def list_skills(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
executor_type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await skill_service.paginate(db, page, size, keyword, executor_type, category)
|
||||||
|
return ok({
|
||||||
|
"list": [skill_service.to_read(s).model_dump() for s in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}", dependencies=[Depends(require_perm("skill:view"))])
|
||||||
|
async def get_skill(sid: int, db: DbDep) -> dict:
|
||||||
|
s = await skill_service.get(db, sid)
|
||||||
|
return ok(skill_service.to_read(s).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("skill:create"))])
|
||||||
|
async def create_skill(data: SkillCreate, db: DbDep) -> dict:
|
||||||
|
s = await skill_service.create(db, data)
|
||||||
|
return ok(skill_service.to_read(s).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sid}", dependencies=[Depends(require_perm("skill:update"))])
|
||||||
|
async def update_skill(sid: int, data: SkillUpdate, db: DbDep) -> dict:
|
||||||
|
s = await skill_service.update(db, sid, data)
|
||||||
|
return ok(skill_service.to_read(s).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sid}", dependencies=[Depends(require_perm("skill:delete"))])
|
||||||
|
async def delete_skill(sid: int, db: DbDep) -> dict:
|
||||||
|
await skill_service.delete(db, sid)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/test", dependencies=[Depends(require_perm("skill:test"))])
|
||||||
|
async def test_skill(sid: int, body: SkillTestRequest, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
log = await skill_service.test_skill(db, sid, body.input_data, body.product_id, user)
|
||||||
|
return ok(_log_to_dict(log))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_to_dict(log) -> dict:
|
||||||
|
return {
|
||||||
|
"id": log.id,
|
||||||
|
"skill_id": log.skill_id,
|
||||||
|
"skill_code": log.skill_code,
|
||||||
|
"operator_id": log.operator_id,
|
||||||
|
"operator_name": log.operator_name,
|
||||||
|
"executor_type": log.executor_type,
|
||||||
|
"product_id": log.product_id,
|
||||||
|
"input_data": log.input_data,
|
||||||
|
"output_data": log.output_data,
|
||||||
|
"result": log.result,
|
||||||
|
"message": log.message,
|
||||||
|
"cost_ms": log.cost_ms,
|
||||||
|
"source": log.source,
|
||||||
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Skill 调用日志
|
||||||
|
log_router = APIRouter(prefix="/skill-logs", tags=["skill-log"])
|
||||||
|
|
||||||
|
|
||||||
|
@log_router.get("", dependencies=[Depends(require_perm("skill:log:view"))])
|
||||||
|
async def list_skill_logs(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
skill_id: int | None = None,
|
||||||
|
result: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await skill_service.paginate_logs(db, page, size, skill_id, result, source)
|
||||||
|
return ok({
|
||||||
|
"list": [_log_to_dict(r) for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.models import Product, Skill
|
||||||
|
from app.schemas.task import TaskCreate, TaskLogRead, TaskRead
|
||||||
|
from app.services import task_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tasks", tags=["task"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_read(db, t) -> dict:
|
||||||
|
skill = await db.get(Skill, t.skill_id)
|
||||||
|
product = await db.get(Product, t.product_id) if t.product_id else None
|
||||||
|
return TaskRead(
|
||||||
|
id=t.id, task_no=t.task_no, name=t.name,
|
||||||
|
skill_id=t.skill_id, skill_code=t.skill_code,
|
||||||
|
skill_name=skill.name if skill else None,
|
||||||
|
product_id=t.product_id,
|
||||||
|
product_name=product.name if product else None,
|
||||||
|
executor_type=t.executor_type, status=t.status,
|
||||||
|
error_message=t.error_message, external_ref=t.external_ref,
|
||||||
|
source=t.source,
|
||||||
|
operator_id=t.operator_id, operator_name=t.operator_name,
|
||||||
|
input_data=t.input_data, resolved_input=t.resolved_input,
|
||||||
|
result_data=t.result_data,
|
||||||
|
started_at=t.started_at, finished_at=t.finished_at,
|
||||||
|
cost_ms=t.cost_ms, timeout_seconds=t.timeout_seconds,
|
||||||
|
created_at=t.created_at,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("task:list"))])
|
||||||
|
async def list_tasks(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
skill_id: int | None = None,
|
||||||
|
product_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await task_service.paginate(db, page, size, keyword, status, skill_id, product_id)
|
||||||
|
return ok({
|
||||||
|
"list": [await _to_read(db, t) for t in rows],
|
||||||
|
"total": total, "page": page, "size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tid}", dependencies=[Depends(require_perm("task:list"))])
|
||||||
|
async def get_task(tid: int, db: DbDep) -> dict:
|
||||||
|
t = await task_service.get(db, tid)
|
||||||
|
return ok(await _to_read(db, t))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tid}/logs", dependencies=[Depends(require_perm("task:list"))])
|
||||||
|
async def get_task_logs(
|
||||||
|
tid: int, db: DbDep, after_id: int = Query(0, ge=0)
|
||||||
|
) -> dict:
|
||||||
|
logs = await task_service.get_logs(db, tid, after_id)
|
||||||
|
return ok([TaskLogRead.model_validate(l).model_dump(mode="json") for l in logs])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("task:create"))])
|
||||||
|
async def create_task(data: TaskCreate, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
t = await task_service.create_task(
|
||||||
|
db,
|
||||||
|
name=data.name, skill_id=data.skill_id, product_id=data.product_id,
|
||||||
|
input_data=data.input_data, timeout_seconds=data.timeout_seconds,
|
||||||
|
operator=user, source="wizard",
|
||||||
|
)
|
||||||
|
return ok(await _to_read(db, t))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tid}/run", dependencies=[Depends(require_perm("task:create"))])
|
||||||
|
async def run_task(tid: int, db: DbDep) -> dict:
|
||||||
|
t = await task_service.run_task(db, tid)
|
||||||
|
return ok(await _to_read(db, t))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tid}/cancel", dependencies=[Depends(require_perm("task:cancel"))])
|
||||||
|
async def cancel_task(tid: int, db: DbDep) -> dict:
|
||||||
|
t = await task_service.cancel_task(db, tid)
|
||||||
|
return ok(await _to_read(db, t))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tid}/retry", dependencies=[Depends(require_perm("task:create"))])
|
||||||
|
async def retry_task(tid: int, db: DbDep, user: CurrentUser) -> dict:
|
||||||
|
t = await task_service.retry_task(db, tid, user)
|
||||||
|
return ok(await _to_read(db, t))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import DbDep, require_perm
|
||||||
|
from app.core.exceptions import ok
|
||||||
|
from app.schemas.user import UserCreate, UserResetPwd, UserUpdate
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["user"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_perm("auth:user:view"))])
|
||||||
|
async def list_users(
|
||||||
|
db: DbDep,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
keyword: str | None = None,
|
||||||
|
dept_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows, total = await user_service.paginate(db, page, size, keyword, dept_id)
|
||||||
|
return ok({
|
||||||
|
"list": [user_service.to_read(u).model_dump() for u in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{uid}", dependencies=[Depends(require_perm("auth:user:view"))])
|
||||||
|
async def get_user(uid: int, db: DbDep) -> dict:
|
||||||
|
u = await user_service.get_with_rels(db, uid)
|
||||||
|
return ok(user_service.to_read(u).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_perm("auth:user:create"))])
|
||||||
|
async def create_user(data: UserCreate, db: DbDep) -> dict:
|
||||||
|
u = await user_service.create(db, data)
|
||||||
|
return ok(user_service.to_read(u).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{uid}", dependencies=[Depends(require_perm("auth:user:update"))])
|
||||||
|
async def update_user(uid: int, data: UserUpdate, db: DbDep) -> dict:
|
||||||
|
u = await user_service.update(db, uid, data)
|
||||||
|
return ok(user_service.to_read(u).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{uid}/reset-password", dependencies=[Depends(require_perm("auth:user:update"))])
|
||||||
|
async def reset_password(uid: int, data: UserResetPwd, db: DbDep) -> dict:
|
||||||
|
await user_service.reset_password(db, uid, data.password)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{uid}", dependencies=[Depends(require_perm("auth:user:delete"))])
|
||||||
|
async def delete_user(uid: int, db: DbDep) -> dict:
|
||||||
|
await user_service.delete(db, uid)
|
||||||
|
return ok()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""全局配置:从项目根 .env 加载环境变量,通过 `settings` 单例访问。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# 项目根(backend/ 上一级)
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=str(ROOT_DIR / ".env"),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=True,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_ENV: str = "dev"
|
||||||
|
APP_SECRET_KEY: str = "change-me"
|
||||||
|
APP_BASE_URL: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_ALG: str = "HS256"
|
||||||
|
JWT_ACCESS_TTL: int = 3600
|
||||||
|
JWT_REFRESH_TTL: int = 604800
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://loom:loom@localhost:5432/loom"
|
||||||
|
|
||||||
|
# Redis / Celery
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
|
||||||
|
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
|
||||||
|
|
||||||
|
# Lobster Agent(联调前留空)
|
||||||
|
LOBSTER_API_BASE: str = ""
|
||||||
|
LOBSTER_API_KEY: str = ""
|
||||||
|
LOBSTER_CALLBACK_SECRET: str = ""
|
||||||
|
LOBSTER_TIMEOUT_SECONDS: int = 30
|
||||||
|
LOBSTER_POLL_INTERVAL_SECONDS: int = 5
|
||||||
|
|
||||||
|
# OpenAI(联调前留空)
|
||||||
|
OPENAI_API_BASE: str = "https://api.openai.com/v1"
|
||||||
|
OPENAI_API_KEY: str = ""
|
||||||
|
OPENAI_DEFAULT_MODEL: str = "gpt-4o-mini"
|
||||||
|
OPENAI_TIMEOUT_SECONDS: int = 60
|
||||||
|
|
||||||
|
# 加密
|
||||||
|
FERNET_KEY: str = ""
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGINS: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dev(self) -> bool:
|
||||||
|
return self.APP_ENV.lower() in {"dev", "development", "local"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""全局枚举 & 常量。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
SUCCEEDED = "SUCCEEDED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
TIMEOUT = "TIMEOUT"
|
||||||
|
CANCELED = "CANCELED"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutorType(str, Enum):
|
||||||
|
LOBSTER = "LOBSTER"
|
||||||
|
SCRIPT = "SCRIPT"
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatus(str, Enum):
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
DISABLED = "DISABLED"
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelType(str, Enum):
|
||||||
|
INTERNAL = "INTERNAL"
|
||||||
|
NATURAL = "NATURAL"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""对称加密工具:用于平台账号凭据等敏感字段的落库加密。
|
||||||
|
|
||||||
|
Key 来自 `settings.FERNET_KEY`,`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
key = settings.FERNET_KEY
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"FERNET_KEY 未配置,无法加密/解密敏感字段。"
|
||||||
|
"生成:python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||||
|
)
|
||||||
|
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(value: str | None) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(value: str | None) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(value.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mask(value: str | None, keep: int = 2) -> str:
|
||||||
|
"""显示掩码:前 keep 位 + ****。"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) <= keep:
|
||||||
|
return "*" * len(value)
|
||||||
|
return value[:keep] + "*" * max(4, len(value) - keep)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""FastAPI 依赖注入:DB session、JWT 解析、权限校验。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.core.security import JWTError, decode_token
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import User
|
||||||
|
from app.services.rbac_service import get_user_perm_codes, load_user_with_perms
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_token(authorization: str | None) -> int:
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise AppError("未登录", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except JWTError as e:
|
||||||
|
raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e
|
||||||
|
if payload.get("type") not in (None, "access"):
|
||||||
|
raise AppError("token 类型错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
try:
|
||||||
|
return int(payload["sub"])
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
db: DbDep,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> User:
|
||||||
|
user_id = await _resolve_token(authorization)
|
||||||
|
user = await load_user_with_perms(db, user_id)
|
||||||
|
if user is None or user.status != "ACTIVE":
|
||||||
|
raise AppError("账号已失效", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_perm(code: str):
|
||||||
|
"""权限校验依赖:挂在路由上,没命中则 403。超管通过。"""
|
||||||
|
|
||||||
|
async def _check(db: DbDep, user: CurrentUser) -> User:
|
||||||
|
if user.is_superuser:
|
||||||
|
return user
|
||||||
|
codes = await get_user_perm_codes(db, user)
|
||||||
|
if code not in codes:
|
||||||
|
raise AppError(
|
||||||
|
f"无权限: {code}", code=403, http_status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _check
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""统一响应 + 全局异常处理。所有接口返回 {code, message, data}。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from app.core.logging import logger
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""业务异常基类。服务层抛出,由全局 handler 转成标准响应体。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
code: int = 1000,
|
||||||
|
http_status: int = status.HTTP_400_BAD_REQUEST,
|
||||||
|
data: Any = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.http_status = http_status
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data: Any = None, message: str = "ok") -> dict:
|
||||||
|
return {"code": 0, "message": message, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def fail(code: int, message: str, data: Any = None) -> dict:
|
||||||
|
return {"code": code, "message": message, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def _app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
logger.warning("app_error", path=request.url.path, code=exc.code, message=exc.message)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.http_status,
|
||||||
|
content=fail(exc.code, exc.message, exc.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def _http_exc(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=fail(exc.status_code, str(exc.detail)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def _validation_exc(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content=fail(422, "参数校验失败", exc.errors()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.exception("unhandled_exception", path=request.url.path)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content=fail(500, "服务器内部错误"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""structlog 配置:开发彩色、生产 JSON。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
shared_processors: list = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.is_dev:
|
||||||
|
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
||||||
|
else:
|
||||||
|
renderer = structlog.processors.JSONRenderer()
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[*shared_processors, renderer],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 让 uvicorn / fastapi 的标准 logging 也走 structlog
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=logging.INFO,
|
||||||
|
)
|
||||||
|
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "sqlalchemy.engine"):
|
||||||
|
logging.getLogger(name).handlers.clear()
|
||||||
|
logging.getLogger(name).propagate = True
|
||||||
|
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""请求审计中间件(纯 ASGI 实现)。
|
||||||
|
|
||||||
|
不使用 `BaseHTTPMiddleware`,因为它内部借助 anyio task group 执行 call_next,
|
||||||
|
会与 asyncpg 的底层 future 跨 loop(TestClient 场景)或出现 request body 重放坑。
|
||||||
|
|
||||||
|
对变更接口(POST/PATCH/PUT/DELETE)成功响应后异步落 OpLog。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app.core.logging import logger
|
||||||
|
from app.core.security import JWTError, decode_token
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import OpLog, User
|
||||||
|
|
||||||
|
MUTATION_METHODS = {"POST", "PATCH", "PUT", "DELETE"}
|
||||||
|
SKIP_PATHS = {
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
"/api/v1/healthz",
|
||||||
|
}
|
||||||
|
SENSITIVE_KEYS = {"password", "google_key", "password_hash", "refresh_token", "access_token"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(obj: Any) -> Any:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {
|
||||||
|
k: ("***" if k in SENSITIVE_KEYS and v else _sanitize(v))
|
||||||
|
for k, v in obj.items()
|
||||||
|
}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_sanitize(i) for i in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_module(path: str) -> str:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if len(parts) >= 3 and parts[0] == "api" and parts[1] == "v1":
|
||||||
|
return parts[2]
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_action(method: str, path: str) -> str:
|
||||||
|
if path.endswith("/reset-password"):
|
||||||
|
return "reset_password"
|
||||||
|
if path.endswith("/test"):
|
||||||
|
return "test"
|
||||||
|
return {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}.get(
|
||||||
|
method, method.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpLogMiddleware:
|
||||||
|
"""纯 ASGI 中间件:只在请求/响应 message 流动的边界记录必要字段。"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
method: str = scope.get("method", "GET").upper()
|
||||||
|
path: str = scope.get("path", "")
|
||||||
|
need_audit = (
|
||||||
|
method in MUTATION_METHODS
|
||||||
|
and path.startswith("/api/v1/")
|
||||||
|
and path not in SKIP_PATHS
|
||||||
|
)
|
||||||
|
|
||||||
|
if not need_audit:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# —— 收集请求体(一次性读取,再用缓存回放给下游)
|
||||||
|
body_chunks: list[bytes] = []
|
||||||
|
more = True
|
||||||
|
while more:
|
||||||
|
msg = await receive()
|
||||||
|
if msg["type"] == "http.request":
|
||||||
|
body_chunks.append(msg.get("body", b""))
|
||||||
|
more = msg.get("more_body", False)
|
||||||
|
else:
|
||||||
|
more = False
|
||||||
|
raw = b"".join(body_chunks)
|
||||||
|
payload: Any = None
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
payload = _sanitize(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {"_raw_bytes": len(raw)}
|
||||||
|
|
||||||
|
replayed = False
|
||||||
|
|
||||||
|
async def replay_receive() -> Message:
|
||||||
|
nonlocal replayed
|
||||||
|
if not replayed:
|
||||||
|
replayed = True
|
||||||
|
return {"type": "http.request", "body": raw, "more_body": False}
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
# —— 拦响应的 start,拿 status
|
||||||
|
status_code: int = 500
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
async def send_wrapper(message: Message) -> None:
|
||||||
|
nonlocal status_code
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
status_code = int(message.get("status", 500))
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, replay_receive, send_wrapper)
|
||||||
|
|
||||||
|
cost_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
headers = {
|
||||||
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||||
|
for k, v in scope.get("headers", [])
|
||||||
|
}
|
||||||
|
client = scope.get("client") or (None, None)
|
||||||
|
try:
|
||||||
|
asyncio.create_task(
|
||||||
|
_write_oplog(
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
status_code=status_code,
|
||||||
|
payload=payload,
|
||||||
|
ip=client[0] if client else None,
|
||||||
|
user_agent=headers.get("user-agent"),
|
||||||
|
authorization=headers.get("authorization"),
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# 测试环境下 loop 可能已关闭;忽略,保证主路径不受影响
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_oplog(
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
status_code: int,
|
||||||
|
payload: Any,
|
||||||
|
ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
authorization: str | None,
|
||||||
|
cost_ms: int,
|
||||||
|
) -> None:
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str | None = None
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
sub = decode_token(token).get("sub")
|
||||||
|
operator_id = int(sub) if sub is not None else None
|
||||||
|
except (JWTError, ValueError):
|
||||||
|
operator_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
if operator_id:
|
||||||
|
u = await db.get(User, operator_id)
|
||||||
|
operator_name = u.username if u else None
|
||||||
|
entry = OpLog(
|
||||||
|
operator_id=operator_id,
|
||||||
|
operator_name=operator_name,
|
||||||
|
module=_infer_module(path),
|
||||||
|
action=_infer_action(method, path),
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
status_code=status_code,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=(user_agent or "")[:255] or None,
|
||||||
|
payload=payload,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.exception("oplog_write_failed", path=path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("oplog_write_unhandled", path=path)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""密码哈希 + JWT 签发/校验。
|
||||||
|
|
||||||
|
不用 passlib(新版 bcrypt 4.x 会让 passlib 的 wrap-bug 探测崩溃),直接用 bcrypt。
|
||||||
|
对超过 72 字节的密码先做 SHA-256 + base64 预处理,规避 bcrypt 上限。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(password: str) -> bytes:
|
||||||
|
raw = password.encode("utf-8")
|
||||||
|
if len(raw) > 72:
|
||||||
|
# SHA-256 固定 32 字节 → base64 后 44 字节,始终 < 72
|
||||||
|
raw = base64.b64encode(hashlib.sha256(raw).digest())
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return bcrypt.hashpw(_prepare(plain), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(_prepare(plain), hashed.encode("ascii"))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str:
|
||||||
|
return _create_token(subject, settings.JWT_ACCESS_TTL, "access", extra)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(subject: str | int, extra: dict[str, Any] | None = None) -> str:
|
||||||
|
return _create_token(subject, settings.JWT_REFRESH_TTL, "refresh", extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_token(
|
||||||
|
subject: str | int, ttl_seconds: int, token_type: str, extra: dict[str, Any] | None
|
||||||
|
) -> str:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": str(subject),
|
||||||
|
"type": token_type,
|
||||||
|
"iat": _now(),
|
||||||
|
"exp": _now() + timedelta(seconds=ttl_seconds),
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
return jwt.encode(payload, settings.APP_SECRET_KEY, algorithm=settings.JWT_ALG)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""解码 JWT,失败抛 `JWTError`。"""
|
||||||
|
return jwt.decode(token, settings.APP_SECRET_KEY, algorithms=[settings.JWT_ALG])
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"hash_password",
|
||||||
|
"verify_password",
|
||||||
|
"create_access_token",
|
||||||
|
"create_refresh_token",
|
||||||
|
"decode_token",
|
||||||
|
"JWTError",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""SQLAlchemy 2.0 DeclarativeBase + 通用 Mixin。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""所有 ORM 模型的基类。"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""异步数据库引擎 & session factory。
|
||||||
|
|
||||||
|
注意:TestClient 每个请求会起独立 event loop,若连接池跨请求复用 asyncpg
|
||||||
|
连接则会 "Task attached to a different loop"。测试环境(检测 `PYTEST_CURRENT_TEST`)
|
||||||
|
使用 NullPool,每次 session 都新建连接。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_kwargs: dict = {"echo": False, "future": True}
|
||||||
|
if os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("LOOM_TESTING"):
|
||||||
|
_kwargs["poolclass"] = NullPool
|
||||||
|
else:
|
||||||
|
_kwargs["pool_pre_ping"] = True
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, **_kwargs)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""外部服务客户端。
|
||||||
|
|
||||||
|
- `lobster_client`:龙虾 Agent HTTP 客户端;`.env` 配置 `LOBSTER_API_BASE / LOBSTER_API_KEY`
|
||||||
|
后真调 `POST /runs` + `GET /runs/{id}`;未配置时客户端内置 mock 便于本地开发。
|
||||||
|
- `openai_client`:OpenAI Chat Completions;`.env` 的 `OPENAI_API_KEY` 未配置时走 mock 回显,
|
||||||
|
每个 LLM Provider 也可在 UI 里单独设置 key。
|
||||||
|
- `script_executor`:subprocess 脚本执行器(python/node/shell + 超时 + 逐行日志回调)。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""龙虾 Agent HTTP 客户端。
|
||||||
|
|
||||||
|
运行时配置来源优先级:DB (lobster_config 表) > .env (settings.LOBSTER_*)。
|
||||||
|
调用方通过 `build_lobster_client(db)` 或 `build_lobster_client_sync(api_base, api_key, timeout)`
|
||||||
|
获取实例;不再使用模块级单例,便于配置热更新。
|
||||||
|
|
||||||
|
- 当 api_base / api_key 任一为空时走 mock(便于本地自测)。
|
||||||
|
- 真调协议:POST {api_base}/runs 提交、GET {api_base}/runs/{run_id} 查状态。
|
||||||
|
实际字段由联调时与龙虾平台对齐。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import logger
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LobsterRunResult:
|
||||||
|
run_id: str
|
||||||
|
status: str # QUEUED / RUNNING / SUCCEEDED / FAILED / TIMEOUT
|
||||||
|
output: dict[str, Any] | None = None
|
||||||
|
logs: list[dict[str, Any]] | None = None # [{"event_id": str, "level": str, "message": str}]
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级共享 mock 状态机(同一 run_id 多次 poll 可继续推进)
|
||||||
|
_MOCK_RUNS: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class LobsterClient:
|
||||||
|
"""按运行时配置构造;API_BASE / API_KEY 任一为空时退化为 mock。"""
|
||||||
|
|
||||||
|
def __init__(self, api_base: str = "", api_key: str = "", timeout: int = 30) -> None:
|
||||||
|
self.base = (api_base or "").rstrip("/")
|
||||||
|
self.key = api_key or ""
|
||||||
|
self.timeout = timeout or 30
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_mock(self) -> bool:
|
||||||
|
return not self.base or not self.key
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
self, template_id: str, params: dict[str, Any], task_no: str
|
||||||
|
) -> LobsterRunResult:
|
||||||
|
if self.is_mock:
|
||||||
|
return await self._mock_submit(template_id, params, task_no)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.key}",
|
||||||
|
"X-Loom-Task": task_no,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {"template_id": template_id, "input": params, "task_no": task_no}
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
r = await client.post(f"{self.base}/runs", json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return LobsterRunResult(
|
||||||
|
run_id=str(data.get("run_id") or data.get("id") or ""),
|
||||||
|
status=str(data.get("status") or "QUEUED"),
|
||||||
|
message=data.get("message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poll(self, run_id: str) -> LobsterRunResult:
|
||||||
|
if self.is_mock:
|
||||||
|
return await self._mock_poll(run_id)
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {self.key}"}
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
r = await client.get(f"{self.base}/runs/{run_id}", headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return LobsterRunResult(
|
||||||
|
run_id=run_id,
|
||||||
|
status=str(data.get("status") or "RUNNING"),
|
||||||
|
output=data.get("output"),
|
||||||
|
logs=data.get("logs") or [],
|
||||||
|
message=data.get("message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- mock ----
|
||||||
|
|
||||||
|
async def _mock_submit(
|
||||||
|
self, template_id: str, params: dict[str, Any], task_no: str
|
||||||
|
) -> LobsterRunResult:
|
||||||
|
run_id = f"mock-{task_no}-{int(time.time()*1000)}"
|
||||||
|
_MOCK_RUNS[run_id] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"template_id": template_id,
|
||||||
|
"input": params,
|
||||||
|
"polls": 0,
|
||||||
|
}
|
||||||
|
logger.info("lobster_mock_submit", run_id=run_id, template_id=template_id)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return LobsterRunResult(run_id=run_id, status="QUEUED")
|
||||||
|
|
||||||
|
async def _mock_poll(self, run_id: str) -> LobsterRunResult:
|
||||||
|
state = _MOCK_RUNS.get(run_id)
|
||||||
|
if state is None:
|
||||||
|
return LobsterRunResult(run_id=run_id, status="FAILED", message="mock run_id 未知")
|
||||||
|
state["polls"] += 1
|
||||||
|
if state["polls"] == 1:
|
||||||
|
return LobsterRunResult(
|
||||||
|
run_id=run_id,
|
||||||
|
status="RUNNING",
|
||||||
|
logs=[{"event_id": f"{run_id}-1", "level": "INFO", "message": "mock: 开始执行"}],
|
||||||
|
)
|
||||||
|
if state["polls"] >= 3:
|
||||||
|
output = {"mock": True, "input": state["input"], "random": random.randint(1, 99)}
|
||||||
|
return LobsterRunResult(
|
||||||
|
run_id=run_id,
|
||||||
|
status="SUCCEEDED",
|
||||||
|
output=output,
|
||||||
|
logs=[{"event_id": f"{run_id}-2", "level": "INFO", "message": "mock: 完成"}],
|
||||||
|
message="mock 成功",
|
||||||
|
)
|
||||||
|
return LobsterRunResult(
|
||||||
|
run_id=run_id,
|
||||||
|
status="RUNNING",
|
||||||
|
logs=[{"event_id": f"{run_id}-poll-{state['polls']}", "level": "INFO",
|
||||||
|
"message": f"mock: 第 {state['polls']} 次轮询"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_lobster_client(db) -> LobsterClient:
|
||||||
|
"""根据当前 DB + .env 生效配置构造 client。调用方在执行任务前拿一次即可。"""
|
||||||
|
# 局部 import 避免模块循环依赖
|
||||||
|
from app.services import lobster_config_service
|
||||||
|
|
||||||
|
eff = await lobster_config_service.load_effective(db)
|
||||||
|
return LobsterClient(
|
||||||
|
api_base=eff.api_base,
|
||||||
|
api_key=eff.api_key,
|
||||||
|
timeout=eff.timeout_seconds,
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""OpenAI Chat Completions 客户端。
|
||||||
|
|
||||||
|
- 真实接入:httpx 直调 `/chat/completions`,使用各 provider 独立的 base_url 和 api_key
|
||||||
|
- mock:api_key 为空时回显一个简单响应并估算 token 数
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatResult:
|
||||||
|
content: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
raw: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tokens(text: str) -> int:
|
||||||
|
"""粗估:平均 4 个字符 ≈ 1 token。"""
|
||||||
|
return max(1, len(text) // 4)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_completion(
|
||||||
|
*,
|
||||||
|
api_base: str,
|
||||||
|
api_key: str | None,
|
||||||
|
model: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
timeout_seconds: int | None = None,
|
||||||
|
) -> ChatResult:
|
||||||
|
"""发起一次 Chat Completions。api_key 为空时走 mock。"""
|
||||||
|
timeout = timeout_seconds or settings.OPENAI_TIMEOUT_SECONDS or 60
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
joined = "\n".join(m.get("content", "") for m in messages if isinstance(m, dict))
|
||||||
|
reply = f"[mock:{model}] 接收到 {len(messages)} 条消息;配置 OPENAI_API_KEY 后将转为真实调用。"
|
||||||
|
prompt = sum(_estimate_tokens(m.get("content", "") or "") for m in messages)
|
||||||
|
comp = _estimate_tokens(reply)
|
||||||
|
return ChatResult(
|
||||||
|
content=reply,
|
||||||
|
prompt_tokens=prompt,
|
||||||
|
completion_tokens=comp,
|
||||||
|
total_tokens=prompt + comp,
|
||||||
|
raw={"mock": True, "preview": joined[:200]},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {"model": model, "messages": messages}
|
||||||
|
if temperature is not None:
|
||||||
|
payload["temperature"] = temperature
|
||||||
|
if max_tokens is not None:
|
||||||
|
payload["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
url = api_base.rstrip("/") + "/chat/completions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
r = await client.post(url, json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"] or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
content = ""
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
return ChatResult(
|
||||||
|
content=content,
|
||||||
|
prompt_tokens=int(usage.get("prompt_tokens") or _estimate_tokens(str(messages))),
|
||||||
|
completion_tokens=int(usage.get("completion_tokens") or _estimate_tokens(content)),
|
||||||
|
total_tokens=int(usage.get("total_tokens") or 0)
|
||||||
|
or _estimate_tokens(str(messages)) + _estimate_tokens(content),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""脚本执行器:subprocess 沙箱版。
|
||||||
|
|
||||||
|
Phase 5 简化实现:在 worker 进程内直接 subprocess 执行脚本字符串。
|
||||||
|
- 支持 python / shell / node
|
||||||
|
- 超时自动 kill
|
||||||
|
- 每行 stdout/stderr 作为一条 TaskLog
|
||||||
|
|
||||||
|
(Celery worker 版本为未来优化项;当前流程在 FastAPI 后台任务里执行即可。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_lang_cmd() -> dict[str, list[str]]:
|
||||||
|
"""启动时探测各语言解释器。Python 直接用当前 venv 的解释器,不依赖 PATH。"""
|
||||||
|
node = shutil.which("node") or "node"
|
||||||
|
bash = shutil.which("bash") or "/bin/bash"
|
||||||
|
return {
|
||||||
|
"python": [sys.executable, "-u"], # -u 保证 stdout 无缓冲
|
||||||
|
"node": [node],
|
||||||
|
"shell": [bash],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LANG_CMD: dict[str, list[str]] = _resolve_lang_cmd()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScriptChunk:
|
||||||
|
level: str # INFO / ERROR
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScriptResult:
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
status: str # SUCCEEDED / FAILED / TIMEOUT
|
||||||
|
output: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_script(
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
code: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
timeout_seconds: int = 60,
|
||||||
|
on_chunk: callable | None = None,
|
||||||
|
) -> ScriptResult:
|
||||||
|
if language not in LANG_CMD:
|
||||||
|
raise ValueError(f"不支持的脚本语言: {language}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
p = Path(tmpdir)
|
||||||
|
ext = {"python": ".py", "node": ".js", "shell": ".sh"}[language]
|
||||||
|
script_path = p / f"entry{ext}"
|
||||||
|
script_path.write_text(code, encoding="utf-8")
|
||||||
|
# 通过环境变量 LOOM_PARAMS 传入参数(JSON),脚本可 json.loads 读
|
||||||
|
env = {"LOOM_PARAMS": json.dumps(params, ensure_ascii=False, default=str)}
|
||||||
|
|
||||||
|
cmd = LANG_CMD[language] + [str(script_path)]
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=str(p),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={
|
||||||
|
**env,
|
||||||
|
"PATH": _os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_chunks: list[str] = []
|
||||||
|
stderr_chunks: list[str] = []
|
||||||
|
|
||||||
|
async def _pipe(stream, is_err: bool) -> None:
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
async for raw in stream:
|
||||||
|
try:
|
||||||
|
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
line = str(raw)
|
||||||
|
(stderr_chunks if is_err else stdout_chunks).append(line)
|
||||||
|
if on_chunk:
|
||||||
|
await on_chunk(ScriptChunk(level=("ERROR" if is_err else "INFO"), message=line))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(_pipe(proc.stdout, False), _pipe(proc.stderr, True)),
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
returncode = await proc.wait()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return ScriptResult(
|
||||||
|
returncode=-1,
|
||||||
|
stdout="\n".join(stdout_chunks),
|
||||||
|
stderr="\n".join(stderr_chunks) + "\n[timeout killed]",
|
||||||
|
status="TIMEOUT",
|
||||||
|
output={"timeout": True, "timeout_seconds": timeout_seconds},
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "SUCCEEDED" if returncode == 0 else "FAILED"
|
||||||
|
return ScriptResult(
|
||||||
|
returncode=returncode,
|
||||||
|
stdout="\n".join(stdout_chunks),
|
||||||
|
stderr="\n".join(stderr_chunks),
|
||||||
|
status=status,
|
||||||
|
output={
|
||||||
|
"returncode": returncode,
|
||||||
|
"stdout_tail": "\n".join(stdout_chunks[-20:]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_lines(
|
||||||
|
*, language: str, code: str, params: dict[str, Any], timeout_seconds: int = 60
|
||||||
|
) -> AsyncIterator[tuple[str, str]]:
|
||||||
|
"""供外部需要逐行消费时使用(当前 run_script 已支持 callback,这个保留扩展位)。"""
|
||||||
|
chunks: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
|
||||||
|
|
||||||
|
async def _on_chunk(c: ScriptChunk) -> None:
|
||||||
|
await chunks.put((c.level, c.message))
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
run_script(
|
||||||
|
language=language, code=code, params=params,
|
||||||
|
timeout_seconds=timeout_seconds, on_chunk=_on_chunk,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task.add_done_callback(lambda _: chunks.put_nowait(None))
|
||||||
|
while True:
|
||||||
|
item = await chunks.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
yield item
|
||||||
|
await task
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""FastAPI 入口:挂载路由、异常 handler、CORS、日志。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exceptions import register_exception_handlers
|
||||||
|
from app.core.logging import configure_logging, logger
|
||||||
|
from app.core.middleware import OpLogMiddleware
|
||||||
|
from app.services import schedule_service
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
configure_logging()
|
||||||
|
logger.info("loom_backend_start", env=settings.APP_ENV, base_url=settings.APP_BASE_URL)
|
||||||
|
|
||||||
|
# 测试环境不启动后台循环,避免 NullPool 循环申请连接 & TestClient 生命周期冲突
|
||||||
|
scheduler_task = None
|
||||||
|
if not (os.environ.get("LOOM_TESTING") or os.environ.get("PYTEST_CURRENT_TEST")):
|
||||||
|
scheduler_task = schedule_service.start_scheduler_task()
|
||||||
|
logger.info("scheduler_started")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if scheduler_task is not None:
|
||||||
|
scheduler_task.cancel()
|
||||||
|
logger.info("loom_backend_stop")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="Loom API",
|
||||||
|
description="Loom Agent 任务自动化平台 - 后端 API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
# OpLog 中间件注册在 CORS 之后:请求先经 CORS 鉴别来源,再进审计;
|
||||||
|
# starlette 的 middleware 栈是"最后 add 的最先执行",顺序符合预期。
|
||||||
|
app.add_middleware(OpLogMiddleware)
|
||||||
|
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.include_router(api_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""ORM 模型集中 import。确保 Base.metadata 认到所有表。"""
|
||||||
|
from app.models.channel_code import ChannelCode, product_channel_code
|
||||||
|
from app.models.llm import LLMModel, LLMProvider, LLMRoutingRule, LLMUsage
|
||||||
|
from app.models.lobster_config import LobsterConfig
|
||||||
|
from app.models.menu import Menu
|
||||||
|
from app.models.op_log import OpLog
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.platform import Platform
|
||||||
|
from app.models.platform_account import PlatformAccount
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.role import (
|
||||||
|
Role,
|
||||||
|
role_account_scope,
|
||||||
|
role_menu,
|
||||||
|
role_product_scope,
|
||||||
|
role_skill_scope,
|
||||||
|
)
|
||||||
|
from app.models.schedule import Schedule, ScheduleLog
|
||||||
|
from app.models.script_template import ScriptTemplate
|
||||||
|
from app.models.skill import Skill, SkillCallLog, skill_product_scope
|
||||||
|
from app.models.task import Task, TaskLog
|
||||||
|
from app.models.user import User, user_role
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChannelCode",
|
||||||
|
"LLMModel",
|
||||||
|
"LLMProvider",
|
||||||
|
"LLMRoutingRule",
|
||||||
|
"LLMUsage",
|
||||||
|
"LobsterConfig",
|
||||||
|
"Menu",
|
||||||
|
"OpLog",
|
||||||
|
"Organization",
|
||||||
|
"Platform",
|
||||||
|
"PlatformAccount",
|
||||||
|
"Product",
|
||||||
|
"Role",
|
||||||
|
"Schedule",
|
||||||
|
"ScheduleLog",
|
||||||
|
"ScriptTemplate",
|
||||||
|
"Skill",
|
||||||
|
"SkillCallLog",
|
||||||
|
"Task",
|
||||||
|
"TaskLog",
|
||||||
|
"User",
|
||||||
|
"product_channel_code",
|
||||||
|
"role_menu",
|
||||||
|
"role_product_scope",
|
||||||
|
"role_skill_scope",
|
||||||
|
"role_account_scope",
|
||||||
|
"skill_product_scope",
|
||||||
|
"user_role",
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""渠道码:内导(internal)/ 自然(natural)。与产品多对多。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String, Table
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
product_channel_code = Table(
|
||||||
|
"product_channel_code",
|
||||||
|
Base.metadata,
|
||||||
|
Column("product_id", Integer, ForeignKey("product.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column(
|
||||||
|
"channel_code_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("channel_code.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelCode(Base, TimestampMixin):
|
||||||
|
__tablename__ = "channel_code"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# INTERNAL / NATURAL
|
||||||
|
type: Mapped[str] = mapped_column(String(16), nullable=False, default="INTERNAL")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""LLM Provider / Model / RoutingRule / Usage。
|
||||||
|
|
||||||
|
支持多 Provider(openai / azure / 国产 LLM 等)统一管理;路由规则按优先级命中
|
||||||
|
provider + model;每次调用在 llm_usage 落 token / 成本。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(Base, TimestampMixin):
|
||||||
|
__tablename__ = "llm_provider"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# openai / azure / anthropic / custom
|
||||||
|
vendor: Mapped[str] = mapped_column(String(32), nullable=False, default="openai")
|
||||||
|
api_base: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
# 加密存储;由 service 层做加解密
|
||||||
|
api_key_cipher: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMModel(Base, TimestampMixin):
|
||||||
|
__tablename__ = "llm_model"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
provider_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("llm_provider.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# 对外展示名,如 "gpt-4o-mini"
|
||||||
|
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# 上下文窗口(tokens)
|
||||||
|
max_context: Mapped[int] = mapped_column(Integer, default=8192, nullable=False)
|
||||||
|
# 单价(每千 token,USD),用于成本粗估
|
||||||
|
price_input_per_1k: Mapped[float] = mapped_column(
|
||||||
|
Numeric(10, 6), default=0, nullable=False
|
||||||
|
)
|
||||||
|
price_output_per_1k: Mapped[float] = mapped_column(
|
||||||
|
Numeric(10, 6), default=0, nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
|
||||||
|
provider: Mapped[LLMProvider] = relationship("LLMProvider", lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRoutingRule(Base, TimestampMixin):
|
||||||
|
__tablename__ = "llm_routing_rule"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# 优先级:数字越小越先匹配
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||||
|
# 匹配条件(JSON dict):如 {"category": "social", "executor_type": "LOBSTER"};为空表示默认
|
||||||
|
conditions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
model_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("llm_model.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
model_obj: Mapped[LLMModel] = relationship("LLMModel", lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class LLMUsage(Base):
|
||||||
|
"""每次 LLM 调用的用量记账。"""
|
||||||
|
|
||||||
|
__tablename__ = "llm_usage"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
provider_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("llm_provider.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
model_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("llm_model.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
provider_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
# 任务 / Skill / playground
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="playground", nullable=False)
|
||||||
|
task_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
skill_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
total_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
cost_usd: Mapped[float] = mapped_column(Numeric(12, 6), default=0, nullable=False)
|
||||||
|
|
||||||
|
# ok / failed
|
||||||
|
result: Mapped[str] = mapped_column(String(16), default="ok", nullable=False)
|
||||||
|
message: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""龙虾 Agent 运行时配置(单行表 id=1,运行期可改)。
|
||||||
|
|
||||||
|
- 该表最多 1 行;服务端在首次读时自动 upsert。
|
||||||
|
- 字段为空/NULL 时 fallback 到 `.env`(`settings.LOBSTER_*`),做到 "UI 配 > .env 兜底"。
|
||||||
|
- api_key / callback_secret 走 Fernet 加密存储。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LobsterConfig(Base, TimestampMixin):
|
||||||
|
__tablename__ = "lobster_config"
|
||||||
|
|
||||||
|
# 单例:固定 id = 1
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||||
|
|
||||||
|
api_base: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# Fernet 密文(可为空,表示使用 .env 或未配置走 mock)
|
||||||
|
api_key_cipher: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
callback_secret_cipher: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
timeout_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""菜单 / 按钮权限树。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Menu(Base, TimestampMixin):
|
||||||
|
__tablename__ = "menu"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("menu.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# dir: 目录分组;menu: 页面;button: 按钮权限
|
||||||
|
type: Mapped[str] = mapped_column(String(16), nullable=False, default="menu")
|
||||||
|
path: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
component: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
icon: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# 业务权限码,如 task:list / task:create
|
||||||
|
perm_code: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True)
|
||||||
|
sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
|
||||||
|
parent: Mapped["Menu | None"] = relationship(
|
||||||
|
"Menu", remote_side="Menu.id", backref="children"
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""操作审计日志。由 middleware 在变更接口成功后异步写入。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OpLog(Base):
|
||||||
|
__tablename__ = "op_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# 业务模块:auth.user / auth.role / product ...
|
||||||
|
module: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
# 动作:create / update / delete / reset_password / test ...
|
||||||
|
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
method: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
path: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# 请求体 / 响应体(敏感字段需由写入方做脱敏)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""组织架构:公司 / 部门 / 小组 三级树。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organization"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
# company | department | group
|
||||||
|
type: Mapped[str] = mapped_column(String(16), nullable=False, default="department")
|
||||||
|
sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
parent: Mapped["Organization | None"] = relationship(
|
||||||
|
"Organization", remote_side="Organization.id", backref="children"
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""平台基础信息(平台套件)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Platform(Base, TimestampMixin):
|
||||||
|
__tablename__ = "platform"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""平台产品账号:用户维护每个平台的登录凭据。
|
||||||
|
|
||||||
|
敏感字段(password、google_key 等)服务端 Fernet 加密后再落库,API 仅返回掩码。
|
||||||
|
P3 简化:platform_name 用字符串而非 FK。P4 建完 Platform 主表再做硬关联。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAccount(Base, TimestampMixin):
|
||||||
|
__tablename__ = "platform_account"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
product_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
login: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# Fernet 密文(字符串形态)
|
||||||
|
password_cipher: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
google_key_cipher: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
# 最近一次连通测试结果:ok / failed / unknown
|
||||||
|
last_test_result: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
||||||
|
last_test_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""产品(平台下的业务实体)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
from app.models.channel_code import ChannelCode, product_channel_code
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base, TimestampMixin):
|
||||||
|
__tablename__ = "product"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
platform_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("platform.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
dept_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
owner_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
|
||||||
|
channel_codes: Mapped[list[ChannelCode]] = relationship(
|
||||||
|
"ChannelCode", secondary=product_channel_code, lazy="selectin"
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""角色 + 4 维权限关联表。
|
||||||
|
|
||||||
|
关联表设计:
|
||||||
|
- role_menu 菜单/按钮权限
|
||||||
|
- role_product_scope 产品数据范围(P3 建表后 product_id 再加 FK)
|
||||||
|
- role_skill_scope Skill 权限范围
|
||||||
|
- role_account_scope 平台账号权限范围
|
||||||
|
|
||||||
|
P2 仅强依赖 role_menu;其余三张为 role 的可选能力边界,等 P3/P4 相应主表建好后补 FK。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, Table, Column, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
# Role <-> Menu(含按钮级权限)
|
||||||
|
role_menu = Table(
|
||||||
|
"role_menu",
|
||||||
|
Base.metadata,
|
||||||
|
Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("menu_id", Integer, ForeignKey("menu.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Role 的产品 / Skill / 平台账号 范围(P3+ 再绑 FK)
|
||||||
|
role_product_scope = Table(
|
||||||
|
"role_product_scope",
|
||||||
|
Base.metadata,
|
||||||
|
Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("product_id", Integer, primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
role_skill_scope = Table(
|
||||||
|
"role_skill_scope",
|
||||||
|
Base.metadata,
|
||||||
|
Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("skill_id", Integer, primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
role_account_scope = Table(
|
||||||
|
"role_account_scope",
|
||||||
|
Base.metadata,
|
||||||
|
Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("account_id", Integer, primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base, TimestampMixin):
|
||||||
|
__tablename__ = "role"
|
||||||
|
__table_args__ = (UniqueConstraint("dept_id", "code", name="uq_role_dept_code"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
# 角色归属部门;null 表示全局角色(如超管)
|
||||||
|
dept_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
# 系统内置角色(如 super_admin)不允许用户删除
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
menus: Mapped[list["Menu"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
||||||
|
"Menu", secondary=role_menu, lazy="selectin"
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""定时任务 + 触发记录。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(Base, TimestampMixin):
|
||||||
|
__tablename__ = "schedule"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
|
||||||
|
skill_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("skill.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("product.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# 标准 5 段 cron:minute hour day month weekday
|
||||||
|
cron: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
|
# 开启 / 关闭
|
||||||
|
enabled: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
|
||||||
|
# 上一次 / 下一次触发时间
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# 非 FK(避免与 Task.schedule_id 构成循环依赖),仅做引用
|
||||||
|
last_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
owner_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleLog(Base):
|
||||||
|
__tablename__ = "schedule_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
schedule_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("schedule.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
task_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("task.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# ok / skipped / failed
|
||||||
|
result: Mapped[str] = mapped_column(String(16), default="ok", nullable=False)
|
||||||
|
message: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
triggered_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
|
cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""脚本模板(Python/Node/Shell)。版本由 `version` 字段自增。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptTemplate(Base, TimestampMixin):
|
||||||
|
__tablename__ = "script_template"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# python / node / shell
|
||||||
|
language: Mapped[str] = mapped_column(String(16), nullable=False, default="python")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
timeout_seconds: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
owner_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Skill:双执行器(龙虾 / 脚本)注册 + 参数 schema + 产品范围绑定。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
skill_product_scope = Table(
|
||||||
|
"skill_product_scope",
|
||||||
|
Base.metadata,
|
||||||
|
Column("skill_id", Integer, ForeignKey("skill.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("product_id", Integer, ForeignKey("product.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(Base, TimestampMixin):
|
||||||
|
__tablename__ = "skill"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
# LOBSTER / SCRIPT
|
||||||
|
executor_type: Mapped[str] = mapped_column(String(16), nullable=False, default="LOBSTER")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# 输入参数 JSON Schema(展示给用户填写)
|
||||||
|
input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# 参数映射表达式:{ "lobster_param_key": "{{input.field}}" } 之类
|
||||||
|
param_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# 龙虾模板 ID / 脚本模板 ID (二选一,由 executor_type 决定)
|
||||||
|
lobster_template_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
script_template_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("script_template.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
|
||||||
|
# 执行示例 / 说明文档(可选)
|
||||||
|
doc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
product_scope: Mapped[list["Product"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
||||||
|
"Product", secondary=skill_product_scope, lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCallLog(Base):
|
||||||
|
"""Skill 调用审计(由测试台 / 任务执行器写入)。"""
|
||||||
|
|
||||||
|
__tablename__ = "skill_call_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
skill_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("skill.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
skill_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
executor_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("product.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
output_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# ok / failed
|
||||||
|
result: Mapped[str] = mapped_column(String(16), nullable=False, default="ok")
|
||||||
|
message: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
# 是测试台调用还是真实任务调用
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="test", nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""任务 + 任务日志。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Task(Base, TimestampMixin):
|
||||||
|
__tablename__ = "task"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
# 任务编号(人类可读),由服务层生成,如 T20260424-0001
|
||||||
|
task_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
|
||||||
|
skill_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("skill.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
skill_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("product.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# LOBSTER / SCRIPT
|
||||||
|
executor_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
# 用户输入的参数(JSON Schema 校验过)
|
||||||
|
input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# 经过 param_mapping 翻译后传给执行器的实际参数
|
||||||
|
resolved_input: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# 执行器返回的结果
|
||||||
|
result_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# PENDING / QUEUED / RUNNING / SUCCEEDED / FAILED / TIMEOUT / CANCELED
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="PENDING", index=True
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# 外部执行系统返回的参考 ID(龙虾 run_id / celery task id)
|
||||||
|
external_ref: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
|
||||||
|
# 任务触发来源:wizard / retry / schedule
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="wizard", nullable=False)
|
||||||
|
schedule_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("schedule.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
retry_of_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("task.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
timeout_seconds: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLog(Base):
|
||||||
|
__tablename__ = "task_log"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("task_id", "external_event_id", name="uq_task_log_event"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("task.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# INFO / WARN / ERROR / STATUS
|
||||||
|
level: Mapped[str] = mapped_column(String(8), default="INFO", nullable=False)
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# 龙虾回调带的事件 id(同一事件不重复写)
|
||||||
|
external_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""用户 + 用户-角色关联。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String, Table
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
user_role = Table(
|
||||||
|
"user_role",
|
||||||
|
Base.metadata,
|
||||||
|
Column("user_id", Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TimestampMixin):
|
||||||
|
__tablename__ = "user"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
avatar: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
dept_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False)
|
||||||
|
# 是否超级管理员(绕过菜单权限校验,仅用于系统初始账号)
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
roles: Mapped[list["Role"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
||||||
|
"Role", secondary=user_role, lazy="selectin"
|
||||||
|
)
|
||||||
|
dept: Mapped["Organization | None"] = relationship( # type: ignore[name-defined] # noqa: F821
|
||||||
|
"Organization", lazy="selectin"
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic DTO。P2 起按模块补齐。"""
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""登录 / 用户画像 / 菜单树 DTO。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=64)
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
captcha: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPair(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "Bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class MenuTreeNode(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
parent_id: int | None = None
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
path: str | None = None
|
||||||
|
component: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
perm_code: str | None = None
|
||||||
|
sort: int = 0
|
||||||
|
children: list["MenuTreeNode"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleBrief(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
nickname: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
avatar: str | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
dept_name: str | None = None
|
||||||
|
is_superuser: bool = False
|
||||||
|
roles: list[RoleBrief] = Field(default_factory=list)
|
||||||
|
perm_codes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
menu_tree: list[MenuTreeNode]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelBase(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
type: str = Field(default="INTERNAL", pattern=r"^(INTERNAL|NATURAL)$")
|
||||||
|
description: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelCreate(ChannelBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
type: str | None = Field(default=None, pattern=r"^(INTERNAL|NATURAL)$")
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""通用 DTO。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class PageQuery(BaseModel):
|
||||||
|
page: int = Field(default=1, ge=1)
|
||||||
|
size: int = Field(default=20, ge=1, le=200)
|
||||||
|
|
||||||
|
|
||||||
|
class PageResp(BaseModel, Generic[T]):
|
||||||
|
list: list[T]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Provider --------
|
||||||
|
|
||||||
|
class ProviderCreate(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
vendor: str = Field(default="openai", pattern=r"^(openai|azure|anthropic|custom)$")
|
||||||
|
api_base: str = Field(min_length=1, max_length=255)
|
||||||
|
api_key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
vendor: str | None = Field(default=None, pattern=r"^(openai|azure|anthropic|custom)$")
|
||||||
|
api_base: str | None = None
|
||||||
|
# 留空 / undefined 不改;空串清空
|
||||||
|
api_key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
vendor: str
|
||||||
|
api_base: str
|
||||||
|
api_key_mask: str = ""
|
||||||
|
has_api_key: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Model --------
|
||||||
|
|
||||||
|
class ModelCreate(BaseModel):
|
||||||
|
provider_id: int
|
||||||
|
model: str = Field(min_length=1, max_length=128)
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str | None = None
|
||||||
|
max_context: int = Field(default=8192, ge=1024, le=2_000_000)
|
||||||
|
price_input_per_1k: float = Field(default=0.0, ge=0)
|
||||||
|
price_output_per_1k: float = Field(default=0.0, ge=0)
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
max_context: int | None = Field(default=None, ge=1024, le=2_000_000)
|
||||||
|
price_input_per_1k: float | None = Field(default=None, ge=0)
|
||||||
|
price_output_per_1k: float | None = Field(default=None, ge=0)
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
provider_id: int
|
||||||
|
provider_code: str | None = None
|
||||||
|
provider_name: str | None = None
|
||||||
|
model: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
max_context: int
|
||||||
|
price_input_per_1k: float
|
||||||
|
price_output_per_1k: float
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Routing --------
|
||||||
|
|
||||||
|
class RuleBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
priority: int = Field(default=100, ge=0, le=10000)
|
||||||
|
conditions: dict | None = None
|
||||||
|
model_id: int
|
||||||
|
enabled: bool = True
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RuleCreate(RuleBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RuleUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
priority: int | None = Field(default=None, ge=0, le=10000)
|
||||||
|
conditions: dict | None = None
|
||||||
|
model_id: int | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RuleRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
priority: int
|
||||||
|
conditions: dict | None = None
|
||||||
|
model_id: int
|
||||||
|
model_label: str | None = None # "provider/model"
|
||||||
|
enabled: bool
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Chat / Usage --------
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
"""面向任务执行器 / playground 的统一请求。
|
||||||
|
|
||||||
|
- 若 `model` 指定(形如 "provider_code/model"),跳过路由
|
||||||
|
- 否则 `conditions` 走路由命中
|
||||||
|
"""
|
||||||
|
|
||||||
|
messages: list[dict]
|
||||||
|
model: str | None = None
|
||||||
|
conditions: dict | None = None
|
||||||
|
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||||
|
max_tokens: int | None = Field(default=None, ge=1)
|
||||||
|
stream: bool = False # 目前仅 false
|
||||||
|
source: str = Field(default="playground", max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
content: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
cost_usd: float
|
||||||
|
cost_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class UsageRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
provider_id: int | None = None
|
||||||
|
provider_code: str | None = None
|
||||||
|
model_id: int | None = None
|
||||||
|
model: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str | None = None
|
||||||
|
source: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
cost_usd: float
|
||||||
|
result: str
|
||||||
|
message: str | None = None
|
||||||
|
cost_ms: int
|
||||||
|
created_at: datetime | None = None
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LobsterConfigUpdate(BaseModel):
|
||||||
|
"""所有字段可选;传值则覆盖 DB(空串 = 清除回落 .env);不传 = 保持不变。"""
|
||||||
|
|
||||||
|
api_base: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
callback_secret: str | None = None
|
||||||
|
timeout_seconds: int | None = Field(default=None, ge=1, le=600)
|
||||||
|
poll_interval_seconds: int | None = Field(default=None, ge=1, le=60)
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LobsterConfigRead(BaseModel):
|
||||||
|
"""回读。api_key / callback_secret 只返回掩码与是否配置标记;原文永不出口。"""
|
||||||
|
|
||||||
|
api_base: str
|
||||||
|
api_base_source: str # "db" | "env" | "unset"
|
||||||
|
|
||||||
|
api_key_mask: str
|
||||||
|
api_key_source: str
|
||||||
|
has_api_key: bool
|
||||||
|
|
||||||
|
callback_secret_mask: str
|
||||||
|
callback_secret_source: str
|
||||||
|
has_callback_secret: bool
|
||||||
|
|
||||||
|
timeout_seconds: int
|
||||||
|
timeout_source: str
|
||||||
|
|
||||||
|
poll_interval_seconds: int
|
||||||
|
poll_interval_source: str
|
||||||
|
|
||||||
|
mode: str # "real" / "mock"
|
||||||
|
note: str | None = None
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MenuBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
type: str = Field(pattern=r"^(dir|menu|button)$")
|
||||||
|
parent_id: int | None = None
|
||||||
|
path: str | None = None
|
||||||
|
component: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
perm_code: str | None = None
|
||||||
|
sort: int = 0
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class MenuCreate(MenuBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MenuUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
type: str | None = Field(default=None, pattern=r"^(dir|menu|button)$")
|
||||||
|
parent_id: int | None = None
|
||||||
|
path: str | None = None
|
||||||
|
component: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
perm_code: str | None = None
|
||||||
|
sort: int | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class MenuRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
parent_id: int | None = None
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
path: str | None = None
|
||||||
|
component: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
perm_code: str | None = None
|
||||||
|
sort: int = 0
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class MenuTreeNode(MenuRead):
|
||||||
|
children: list["MenuTreeNode"] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OrgBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
type: str = Field(default="department", pattern=r"^(company|department|group)$")
|
||||||
|
parent_id: int | None = None
|
||||||
|
sort: int = 0
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgCreate(OrgBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrgUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
type: str | None = Field(default=None, pattern=r"^(company|department|group)$")
|
||||||
|
parent_id: int | None = None
|
||||||
|
sort: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
parent_id: int | None = None
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
type: str
|
||||||
|
sort: int = 0
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgTreeNode(OrgRead):
|
||||||
|
children: list["OrgTreeNode"] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformBase(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformCreate(PlatformBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
product_count: int = 0
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAccountBase(BaseModel):
|
||||||
|
platform_name: str = Field(min_length=1, max_length=64)
|
||||||
|
product_code: str | None = None
|
||||||
|
login: str = Field(min_length=1, max_length=128)
|
||||||
|
remark: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAccountCreate(PlatformAccountBase):
|
||||||
|
password: str = Field(min_length=1, max_length=255)
|
||||||
|
google_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAccountUpdate(BaseModel):
|
||||||
|
platform_name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
product_code: str | None = None
|
||||||
|
login: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
password: str | None = None # 留空表示不改
|
||||||
|
google_key: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAccountRead(BaseModel):
|
||||||
|
"""仅回传掩码后的凭据,原文永不出库。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
owner_id: int
|
||||||
|
owner_name: str | None = None
|
||||||
|
platform_name: str
|
||||||
|
product_code: str | None = None
|
||||||
|
login: str
|
||||||
|
password_mask: str = ""
|
||||||
|
google_key_mask: str = ""
|
||||||
|
has_google_key: bool = False
|
||||||
|
remark: str | None = None
|
||||||
|
status: str
|
||||||
|
last_test_result: str = "unknown"
|
||||||
|
last_test_message: str | None = None
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProductBase(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
platform_id: int | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
owner_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(ProductBase):
|
||||||
|
channel_code_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
platform_id: int | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
owner_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
channel_code_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
platform_id: int | None = None
|
||||||
|
platform_name: str | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
dept_name: str | None = None
|
||||||
|
owner_id: int | None = None
|
||||||
|
owner_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
channel_code_ids: list[int] = Field(default_factory=list)
|
||||||
|
channel_code_names: list[str] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RoleBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
dept_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCreate(RoleBase):
|
||||||
|
menu_ids: list[int] = Field(default_factory=list)
|
||||||
|
product_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
skill_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
account_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
dept_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
menu_ids: list[int] | None = None
|
||||||
|
product_scope_ids: list[int] | None = None
|
||||||
|
skill_scope_ids: list[int] | None = None
|
||||||
|
account_scope_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
dept_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
is_builtin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RoleDetail(RoleRead):
|
||||||
|
menu_ids: list[int] = Field(default_factory=list)
|
||||||
|
product_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
skill_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
account_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
dept_name: str | None = None
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
skill_id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
input_data: dict = Field(default_factory=dict)
|
||||||
|
cron: str = Field(min_length=9, max_length=64)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
product_id: int | None = None
|
||||||
|
input_data: dict | None = None
|
||||||
|
cron: str | None = Field(default=None, min_length=9, max_length=64)
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
skill_id: int
|
||||||
|
skill_code: str | None = None
|
||||||
|
skill_name: str | None = None
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str | None = None
|
||||||
|
input_data: dict | None = None
|
||||||
|
cron: str
|
||||||
|
enabled: bool
|
||||||
|
last_run_at: datetime | None = None
|
||||||
|
next_run_at: datetime | None = None
|
||||||
|
last_task_id: int | None = None
|
||||||
|
owner_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
schedule_id: int
|
||||||
|
task_id: int | None = None
|
||||||
|
result: str
|
||||||
|
message: str | None = None
|
||||||
|
triggered_at: datetime | None = None
|
||||||
|
cost_ms: int = 0
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptBase(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
language: str = Field(default="python", pattern=r"^(python|node|shell)$")
|
||||||
|
description: str | None = None
|
||||||
|
content: str = ""
|
||||||
|
timeout_seconds: int = Field(default=60, ge=1, le=3600)
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptCreate(ScriptBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
language: str | None = Field(default=None, pattern=r"^(python|node|shell)$")
|
||||||
|
description: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
timeout_seconds: int | None = Field(default=None, ge=1, le=3600)
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
language: str
|
||||||
|
description: str | None = None
|
||||||
|
content: str
|
||||||
|
version: int
|
||||||
|
timeout_seconds: int
|
||||||
|
status: str
|
||||||
|
owner_id: int | None = None
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SkillBase(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
category: str | None = None
|
||||||
|
executor_type: str = Field(pattern=r"^(LOBSTER|SCRIPT)$")
|
||||||
|
description: str | None = None
|
||||||
|
input_schema: dict | None = None
|
||||||
|
param_mapping: dict | None = None
|
||||||
|
lobster_template_id: str | None = None
|
||||||
|
script_template_id: int | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
doc: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCreate(SkillBase):
|
||||||
|
product_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
category: str | None = None
|
||||||
|
executor_type: str | None = Field(default=None, pattern=r"^(LOBSTER|SCRIPT)$")
|
||||||
|
description: str | None = None
|
||||||
|
input_schema: dict | None = None
|
||||||
|
param_mapping: dict | None = None
|
||||||
|
lobster_template_id: str | None = None
|
||||||
|
script_template_id: int | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
doc: str | None = None
|
||||||
|
product_scope_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
category: str | None = None
|
||||||
|
executor_type: str
|
||||||
|
description: str | None = None
|
||||||
|
input_schema: dict | None = None
|
||||||
|
param_mapping: dict | None = None
|
||||||
|
lobster_template_id: str | None = None
|
||||||
|
script_template_id: int | None = None
|
||||||
|
status: str
|
||||||
|
doc: str | None = None
|
||||||
|
product_scope_ids: list[int] = Field(default_factory=list)
|
||||||
|
product_scope_names: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTestRequest(BaseModel):
|
||||||
|
input_data: dict = Field(default_factory=dict)
|
||||||
|
product_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCallLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
skill_id: int
|
||||||
|
skill_code: str
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str | None = None
|
||||||
|
executor_type: str
|
||||||
|
product_id: int | None = None
|
||||||
|
input_data: dict | None = None
|
||||||
|
output_data: dict | None = None
|
||||||
|
result: str
|
||||||
|
message: str | None = None
|
||||||
|
cost_ms: int
|
||||||
|
source: str
|
||||||
|
created_at: str | None = None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
skill_id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
input_data: dict = Field(default_factory=dict)
|
||||||
|
timeout_seconds: int = Field(default=60, ge=1, le=3600)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_no: str
|
||||||
|
name: str
|
||||||
|
skill_id: int
|
||||||
|
skill_code: str
|
||||||
|
skill_name: str | None = None
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str | None = None
|
||||||
|
executor_type: str
|
||||||
|
status: str
|
||||||
|
error_message: str | None = None
|
||||||
|
external_ref: str | None = None
|
||||||
|
source: str
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str | None = None
|
||||||
|
input_data: dict | None = None
|
||||||
|
resolved_input: dict | None = None
|
||||||
|
result_data: dict | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
cost_ms: int = 0
|
||||||
|
timeout_seconds: int = 60
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
created_at: datetime | None = None
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=64)
|
||||||
|
nickname: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str = Field(min_length=6, max_length=128)
|
||||||
|
role_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
nickname: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$")
|
||||||
|
role_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResetPwd(BaseModel):
|
||||||
|
password: str = Field(min_length=6, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserBrief(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
nickname: str | None = None
|
||||||
|
status: str
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBrief):
|
||||||
|
email: str | None = None
|
||||||
|
dept_id: int | None = None
|
||||||
|
dept_name: str | None = None
|
||||||
|
role_ids: list[int] = Field(default_factory=list)
|
||||||
|
role_names: list[str] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""P2 初始化种子:
|
||||||
|
|
||||||
|
- 1 个根公司(Loom)+ 1 个部门(总部)
|
||||||
|
- 超级管理员角色(is_builtin=True)
|
||||||
|
- 业务员角色(仅任务/Skill 基础权限,用于演示动态菜单差异)
|
||||||
|
- admin 超管账号(密码 admin123)
|
||||||
|
- operator 演示账号(密码 operator123,绑定业务员角色)
|
||||||
|
- 完整菜单树(覆盖 19 个业务页面 + 按钮权限)
|
||||||
|
|
||||||
|
执行:
|
||||||
|
python -m app.scripts.seed
|
||||||
|
可重复运行,根据 username/code 幂等处理。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import Menu, Organization, Role, User, role_menu, user_role
|
||||||
|
|
||||||
|
# ---------- 菜单结构 ----------
|
||||||
|
# 每项:(name, type, path, icon, perm_code, children)
|
||||||
|
MENU_TREE: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "仪表盘", "type": "menu", "path": "/dashboard",
|
||||||
|
"icon": "DashboardOutlined", "perm_code": "dashboard:view",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "权限管理", "type": "dir", "path": "/auth", "icon": "SafetyOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "组织架构", "type": "menu", "path": "/auth/org",
|
||||||
|
"perm_code": "auth:org:view"},
|
||||||
|
{"name": "角色管理", "type": "menu", "path": "/auth/role",
|
||||||
|
"perm_code": "auth:role:view"},
|
||||||
|
{"name": "用户管理", "type": "menu", "path": "/auth/user",
|
||||||
|
"perm_code": "auth:user:view"},
|
||||||
|
{"name": "菜单管理", "type": "menu", "path": "/auth/menu",
|
||||||
|
"perm_code": "auth:menu:view"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "产品管理", "type": "dir", "path": "/product", "icon": "AppstoreOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "产品", "type": "menu", "path": "/product/list",
|
||||||
|
"perm_code": "product:view"},
|
||||||
|
{"name": "渠道码", "type": "menu", "path": "/product/channel",
|
||||||
|
"perm_code": "product:channel:view"},
|
||||||
|
{"name": "平台套件", "type": "menu", "path": "/product/suite",
|
||||||
|
"perm_code": "product:suite:view"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "平台账号", "type": "menu", "path": "/platform-account",
|
||||||
|
"icon": "KeyOutlined", "perm_code": "platform-account:view",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill 中心", "type": "dir", "path": "/skill", "icon": "ApiOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "Skill 管理", "type": "menu", "path": "/skill/list",
|
||||||
|
"perm_code": "skill:view"},
|
||||||
|
{"name": "调用日志", "type": "menu", "path": "/skill/log",
|
||||||
|
"perm_code": "skill:log:view"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "任务中心", "type": "dir", "path": "/task", "icon": "ThunderboltOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "创建任务", "type": "menu", "path": "/task/create",
|
||||||
|
"perm_code": "task:create"},
|
||||||
|
{"name": "任务列表", "type": "menu", "path": "/task/list",
|
||||||
|
"perm_code": "task:list"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "日志观测", "type": "dir", "path": "/log", "icon": "LineChartOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "操作日志", "type": "menu", "path": "/log/op",
|
||||||
|
"perm_code": "log:op:view"},
|
||||||
|
{"name": "统计报表", "type": "menu", "path": "/log/report",
|
||||||
|
"perm_code": "log:report:view"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "系统设置", "type": "dir", "path": "/system", "icon": "SettingOutlined",
|
||||||
|
"children": [
|
||||||
|
{"name": "执行器配置", "type": "menu", "path": "/system/executor",
|
||||||
|
"perm_code": "system:executor:view"},
|
||||||
|
{"name": "脚本模板", "type": "menu", "path": "/system/script",
|
||||||
|
"perm_code": "system:script:view"},
|
||||||
|
{"name": "定时任务", "type": "menu", "path": "/system/cron",
|
||||||
|
"perm_code": "system:cron:view"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 业务员角色仅授予这些菜单/权限
|
||||||
|
OPERATOR_PERMS = {
|
||||||
|
"dashboard:view",
|
||||||
|
"task:create",
|
||||||
|
"task:list",
|
||||||
|
"skill:view",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_org(db: AsyncSession, code: str, name: str, type_: str,
|
||||||
|
parent_id: int | None = None) -> Organization:
|
||||||
|
stmt = select(Organization).where(Organization.code == code)
|
||||||
|
org = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if org is None:
|
||||||
|
org = Organization(code=code, name=name, type=type_, parent_id=parent_id)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_menu(db: AsyncSession, node: dict[str, Any],
|
||||||
|
parent_id: int | None, sort_start: int = 0) -> list[Menu]:
|
||||||
|
path = node.get("path")
|
||||||
|
name = node["name"]
|
||||||
|
stmt = select(Menu).where(
|
||||||
|
(Menu.path == path) if path else (Menu.name == name)
|
||||||
|
)
|
||||||
|
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if m is None:
|
||||||
|
m = Menu(
|
||||||
|
name=name,
|
||||||
|
type=node["type"],
|
||||||
|
path=path,
|
||||||
|
component=node.get("component"),
|
||||||
|
icon=node.get("icon"),
|
||||||
|
perm_code=node.get("perm_code"),
|
||||||
|
parent_id=parent_id,
|
||||||
|
sort=sort_start,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
await db.flush()
|
||||||
|
else:
|
||||||
|
m.parent_id = parent_id
|
||||||
|
m.sort = sort_start
|
||||||
|
m.type = node["type"]
|
||||||
|
m.icon = node.get("icon")
|
||||||
|
m.perm_code = node.get("perm_code")
|
||||||
|
created = [m]
|
||||||
|
for i, child in enumerate(node.get("children", [])):
|
||||||
|
created.extend(await upsert_menu(db, child, m.id, sort_start=i))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_role(db: AsyncSession, code: str, name: str, *,
|
||||||
|
dept_id: int | None, is_builtin: bool) -> Role:
|
||||||
|
stmt = select(Role).where(Role.code == code)
|
||||||
|
stmt = stmt.where(Role.dept_id.is_(None) if dept_id is None else Role.dept_id == dept_id)
|
||||||
|
role = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if role is None:
|
||||||
|
role = Role(code=code, name=name, dept_id=dept_id, is_builtin=is_builtin)
|
||||||
|
db.add(role)
|
||||||
|
await db.flush()
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_assoc(
|
||||||
|
db: AsyncSession, table, left_col: str, left_val: int, right_col: str, right_vals: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""清空再插入关联表,避免 async 下 relationship 集合属性的 lazy-load 限制。"""
|
||||||
|
await db.execute(delete(table).where(table.c[left_col] == left_val))
|
||||||
|
if right_vals:
|
||||||
|
await db.execute(
|
||||||
|
insert(table),
|
||||||
|
[{left_col: left_val, right_col: rv} for rv in right_vals],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_user(db: AsyncSession, username: str, password: str, *,
|
||||||
|
nickname: str, dept_id: int | None, is_superuser: bool) -> User:
|
||||||
|
stmt = select(User).where(User.username == username)
|
||||||
|
u = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if u is None:
|
||||||
|
u = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
nickname=nickname,
|
||||||
|
dept_id=dept_id,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
await db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# 组织
|
||||||
|
company = await upsert_org(db, "LOOM", "Loom 公司", "company")
|
||||||
|
hq = await upsert_org(db, "HQ", "总部", "department", parent_id=company.id)
|
||||||
|
|
||||||
|
# 菜单
|
||||||
|
all_menus: list[Menu] = []
|
||||||
|
for i, node in enumerate(MENU_TREE):
|
||||||
|
all_menus.extend(await upsert_menu(db, node, parent_id=None, sort_start=i))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 角色
|
||||||
|
super_role = await upsert_role(
|
||||||
|
db, "super_admin", "超级管理员", dept_id=None, is_builtin=True
|
||||||
|
)
|
||||||
|
operator_role = await upsert_role(
|
||||||
|
db, "operator", "业务员", dept_id=hq.id, is_builtin=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 把所有菜单授权给超管;业务员仅授权 OPERATOR_PERMS + 祖先目录
|
||||||
|
operator_menu_ids: set[int] = set()
|
||||||
|
menu_by_id = {m.id: m for m in all_menus}
|
||||||
|
for m in all_menus:
|
||||||
|
if m.perm_code in OPERATOR_PERMS:
|
||||||
|
cur: Menu | None = m
|
||||||
|
while cur and cur.id not in operator_menu_ids:
|
||||||
|
operator_menu_ids.add(cur.id)
|
||||||
|
cur = menu_by_id.get(cur.parent_id) if cur.parent_id else None
|
||||||
|
|
||||||
|
# 用直接操作关联表的方式写权限,避免 async session 对集合属性的 lazy-load 麻烦
|
||||||
|
await _replace_assoc(db, role_menu, "role_id", super_role.id, "menu_id",
|
||||||
|
[m.id for m in all_menus])
|
||||||
|
await _replace_assoc(db, role_menu, "role_id", operator_role.id, "menu_id",
|
||||||
|
list(operator_menu_ids))
|
||||||
|
|
||||||
|
# 用户
|
||||||
|
admin = await upsert_user(
|
||||||
|
db, "admin", "admin123",
|
||||||
|
nickname="超级管理员", dept_id=hq.id, is_superuser=True,
|
||||||
|
)
|
||||||
|
operator = await upsert_user(
|
||||||
|
db, "operator", "operator123",
|
||||||
|
nickname="业务员", dept_id=hq.id, is_superuser=False,
|
||||||
|
)
|
||||||
|
await _replace_assoc(db, user_role, "user_id", admin.id, "role_id", [super_role.id])
|
||||||
|
await _replace_assoc(db, user_role, "user_id", operator.id, "role_id", [operator_role.id])
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print("[seed] done.")
|
||||||
|
print(" 超管 admin / admin123 (全部菜单)")
|
||||||
|
print(" 业务员 operator / operator123 (仪表盘 + 任务 + Skill)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""P4 种子:默认平台 + 脚本模板 + 一个示例 Skill(LOBSTER 模式)。
|
||||||
|
|
||||||
|
执行:python -m app.scripts.seed_p4
|
||||||
|
可重复运行。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import (
|
||||||
|
ChannelCode,
|
||||||
|
Platform,
|
||||||
|
Product,
|
||||||
|
ScriptTemplate,
|
||||||
|
Skill,
|
||||||
|
product_channel_code,
|
||||||
|
skill_product_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PLATFORMS = [
|
||||||
|
{"code": "facebook", "name": "Facebook", "url": "https://facebook.com"},
|
||||||
|
{"code": "tiktok", "name": "TikTok", "url": "https://tiktok.com"},
|
||||||
|
{"code": "google", "name": "Google Ads", "url": "https://ads.google.com"},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_CHANNELS = [
|
||||||
|
{"code": "FB_INT_01", "name": "FB 内导 01", "type": "INTERNAL"},
|
||||||
|
{"code": "FB_NAT_01", "name": "FB 自然 01", "type": "NATURAL"},
|
||||||
|
{"code": "TT_INT_01", "name": "TT 内导 01", "type": "INTERNAL"},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_SCRIPTS = [
|
||||||
|
{
|
||||||
|
"code": "py_hello",
|
||||||
|
"name": "Python Hello",
|
||||||
|
"language": "python",
|
||||||
|
"description": "示例脚本,输出欢迎信息",
|
||||||
|
"content": 'print("hello from loom script")\n',
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sh_curl_demo",
|
||||||
|
"name": "Shell curl 演示",
|
||||||
|
"language": "shell",
|
||||||
|
"description": "示例 curl 调用",
|
||||||
|
"content": 'echo "fetching..."\ncurl -s https://httpbin.org/ip\n',
|
||||||
|
"timeout_seconds": 60,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_SKILL = {
|
||||||
|
"code": "fb_lobster_post",
|
||||||
|
"name": "Facebook 发帖(龙虾)",
|
||||||
|
"category": "social",
|
||||||
|
"executor_type": "LOBSTER",
|
||||||
|
"description": "调用龙虾模板完成 Facebook 发帖",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "content"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "title": "标题", "maxLength": 80},
|
||||||
|
"content": {"type": "string", "title": "正文", "format": "textarea"},
|
||||||
|
"image_url": {"type": "string", "title": "图片链接"},
|
||||||
|
"schedule_at": {"type": "string", "title": "定时时间", "format": "date-time"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"param_mapping": {
|
||||||
|
"post_title": "{{input.title}}",
|
||||||
|
"post_body": "{{input.content}}",
|
||||||
|
"image_url": "{{input.image_url}}",
|
||||||
|
"scheduled": "{{input.schedule_at}}",
|
||||||
|
},
|
||||||
|
"lobster_template_id": "lobster-demo-001",
|
||||||
|
"doc": "演示 Skill:LOBSTER 执行器 + 产品范围 + JSON Schema 参数。.env 配置 LOBSTER_API_KEY 后自动走真实龙虾 API,否则走客户端内置 mock。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert(db: AsyncSession, model, key_field: str, value, **defaults):
|
||||||
|
stmt = select(model).where(getattr(model, key_field) == value)
|
||||||
|
obj = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if obj is None:
|
||||||
|
obj = model(**{key_field: value}, **defaults)
|
||||||
|
db.add(obj)
|
||||||
|
await db.flush()
|
||||||
|
else:
|
||||||
|
for k, v in defaults.items():
|
||||||
|
setattr(obj, k, v)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
platforms = {}
|
||||||
|
for p in DEFAULT_PLATFORMS:
|
||||||
|
obj = await upsert(db, Platform, "code", p["code"],
|
||||||
|
name=p["name"], url=p["url"])
|
||||||
|
platforms[p["code"]] = obj
|
||||||
|
|
||||||
|
channels = {}
|
||||||
|
for c in DEFAULT_CHANNELS:
|
||||||
|
obj = await upsert(db, ChannelCode, "code", c["code"],
|
||||||
|
name=c["name"], type=c["type"])
|
||||||
|
channels[c["code"]] = obj
|
||||||
|
|
||||||
|
# 一个示例产品:FB / 产品代号 fb_demo
|
||||||
|
fb_product = await upsert(
|
||||||
|
db, Product, "code", "fb_demo",
|
||||||
|
name="FB 示例产品",
|
||||||
|
platform_id=platforms["facebook"].id,
|
||||||
|
description="P4 演示用产品",
|
||||||
|
)
|
||||||
|
# 绑定 2 个渠道码
|
||||||
|
await db.execute(
|
||||||
|
delete(product_channel_code).where(
|
||||||
|
product_channel_code.c.product_id == fb_product.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
insert(product_channel_code),
|
||||||
|
[
|
||||||
|
{"product_id": fb_product.id, "channel_code_id": channels["FB_INT_01"].id},
|
||||||
|
{"product_id": fb_product.id, "channel_code_id": channels["FB_NAT_01"].id},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
for s in DEFAULT_SCRIPTS:
|
||||||
|
await upsert(db, ScriptTemplate, "code", s["code"], **{
|
||||||
|
"name": s["name"],
|
||||||
|
"language": s["language"],
|
||||||
|
"description": s["description"],
|
||||||
|
"content": s["content"],
|
||||||
|
"timeout_seconds": s["timeout_seconds"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 示例 Skill
|
||||||
|
stmt = select(Skill).where(Skill.code == DEFAULT_SKILL["code"])
|
||||||
|
skill = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if skill is None:
|
||||||
|
skill = Skill(**DEFAULT_SKILL)
|
||||||
|
db.add(skill)
|
||||||
|
await db.flush()
|
||||||
|
else:
|
||||||
|
for k, v in DEFAULT_SKILL.items():
|
||||||
|
setattr(skill, k, v)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
delete(skill_product_scope).where(skill_product_scope.c.skill_id == skill.id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
insert(skill_product_scope),
|
||||||
|
[{"skill_id": skill.id, "product_id": fb_product.id}],
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print("[seed_p4] done.")
|
||||||
|
print(f" platforms: {[p['code'] for p in DEFAULT_PLATFORMS]}")
|
||||||
|
print(f" channels: {[c['code'] for c in DEFAULT_CHANNELS]}")
|
||||||
|
print(f" scripts: {[s['code'] for s in DEFAULT_SCRIPTS]}")
|
||||||
|
print(f" product: fb_demo (2 channels)")
|
||||||
|
print(f" skill: {DEFAULT_SKILL['code']} (LOBSTER, scope=[fb_demo])")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""P6 种子:OpenAI provider + 2 个模型 + 默认路由规则。
|
||||||
|
|
||||||
|
API Key 从 `.env` 的 `OPENAI_API_KEY` 读取;未配置也能建 provider(走 mock)。
|
||||||
|
执行:python -m app.scripts.seed_p6
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.crypto import encrypt
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import LLMModel, LLMProvider, LLMRoutingRule
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROVIDER = {
|
||||||
|
"code": "openai",
|
||||||
|
"name": "OpenAI",
|
||||||
|
"vendor": "openai",
|
||||||
|
"api_base": settings.OPENAI_API_BASE or "https://api.openai.com/v1",
|
||||||
|
"description": "OpenAI 官方;联调阶段如 .env 未配 OPENAI_API_KEY,调用将走 mock 回显",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_MODELS = [
|
||||||
|
{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"name": "GPT-4o mini",
|
||||||
|
"description": "通用场景推荐;响应快、成本低",
|
||||||
|
"max_context": 128_000,
|
||||||
|
"price_input_per_1k": 0.00015,
|
||||||
|
"price_output_per_1k": 0.00060,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"name": "GPT-4o",
|
||||||
|
"description": "高复杂度推理",
|
||||||
|
"max_context": 128_000,
|
||||||
|
"price_input_per_1k": 0.0025,
|
||||||
|
"price_output_per_1k": 0.01,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert(db: AsyncSession, model, key_field: str, value, **defaults):
|
||||||
|
stmt = select(model).where(getattr(model, key_field) == value)
|
||||||
|
obj = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if obj is None:
|
||||||
|
obj = model(**{key_field: value}, **defaults)
|
||||||
|
db.add(obj)
|
||||||
|
await db.flush()
|
||||||
|
else:
|
||||||
|
for k, v in defaults.items():
|
||||||
|
setattr(obj, k, v)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
prov = await upsert(
|
||||||
|
db, LLMProvider, "code", DEFAULT_PROVIDER["code"],
|
||||||
|
name=DEFAULT_PROVIDER["name"],
|
||||||
|
vendor=DEFAULT_PROVIDER["vendor"],
|
||||||
|
api_base=DEFAULT_PROVIDER["api_base"],
|
||||||
|
description=DEFAULT_PROVIDER["description"],
|
||||||
|
)
|
||||||
|
if settings.OPENAI_API_KEY:
|
||||||
|
prov.api_key_cipher = encrypt(settings.OPENAI_API_KEY)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
models: dict[str, LLMModel] = {}
|
||||||
|
for m in DEFAULT_MODELS:
|
||||||
|
stmt = select(LLMModel).where(
|
||||||
|
LLMModel.provider_id == prov.id, LLMModel.model == m["model"]
|
||||||
|
)
|
||||||
|
obj = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if obj is None:
|
||||||
|
obj = LLMModel(provider_id=prov.id, **m)
|
||||||
|
db.add(obj)
|
||||||
|
await db.flush()
|
||||||
|
else:
|
||||||
|
for k, v in m.items():
|
||||||
|
setattr(obj, k, v)
|
||||||
|
models[m["model"]] = obj
|
||||||
|
|
||||||
|
# 默认路由规则:无条件兜底 → gpt-4o-mini
|
||||||
|
await db.execute(
|
||||||
|
delete(LLMRoutingRule).where(LLMRoutingRule.name == "default")
|
||||||
|
)
|
||||||
|
db.add(LLMRoutingRule(
|
||||||
|
name="default",
|
||||||
|
priority=1000,
|
||||||
|
conditions=None,
|
||||||
|
model_id=models["gpt-4o-mini"].id,
|
||||||
|
enabled=True,
|
||||||
|
description="全局兜底路由",
|
||||||
|
))
|
||||||
|
# 高质量:category=ad 或 reasoning 命中 gpt-4o
|
||||||
|
await db.execute(
|
||||||
|
delete(LLMRoutingRule).where(LLMRoutingRule.name == "premium")
|
||||||
|
)
|
||||||
|
db.add(LLMRoutingRule(
|
||||||
|
name="premium",
|
||||||
|
priority=10,
|
||||||
|
conditions={"category": "reasoning"},
|
||||||
|
model_id=models["gpt-4o"].id,
|
||||||
|
enabled=True,
|
||||||
|
description="高复杂度推理使用 gpt-4o",
|
||||||
|
))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print("[seed_p6] done.")
|
||||||
|
print(f" provider: {prov.code} (api_key_configured={bool(settings.OPENAI_API_KEY)})")
|
||||||
|
print(f" models: {list(models.keys())}")
|
||||||
|
print(f" rules: default(gpt-4o-mini) / premium(category=reasoning → gpt-4o)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""业务服务层。P2 起按模块补齐。"""
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""登录 / Token 刷新业务逻辑。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.core.security import (
|
||||||
|
JWTError,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.auth import TokenPair
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def login(db: AsyncSession, username: str, password: str) -> tuple[User, TokenPair]:
|
||||||
|
stmt = select(User).where(User.username == username)
|
||||||
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if user is None or not verify_password(password, user.password_hash):
|
||||||
|
raise AppError("账号或密码错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
if user.status != "ACTIVE":
|
||||||
|
raise AppError("账号已禁用", code=403, http_status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
access = create_access_token(user.id)
|
||||||
|
refresh = create_refresh_token(user.id)
|
||||||
|
return user, TokenPair(
|
||||||
|
access_token=access,
|
||||||
|
refresh_token=refresh,
|
||||||
|
expires_in=settings.JWT_ACCESS_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh(db: AsyncSession, refresh_token: str) -> TokenPair:
|
||||||
|
try:
|
||||||
|
payload = decode_token(refresh_token)
|
||||||
|
except JWTError as e:
|
||||||
|
raise AppError(
|
||||||
|
"refresh_token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
) from e
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise AppError("token 类型错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
try:
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e
|
||||||
|
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None or user.status != "ACTIVE":
|
||||||
|
raise AppError("账号已失效", code=401, http_status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
return TokenPair(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
expires_in=settings.JWT_ACCESS_TTL,
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""渠道码 CRUD。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.models import ChannelCode
|
||||||
|
from app.schemas.channel_code import ChannelCreate, ChannelUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def paginate(
|
||||||
|
db: AsyncSession, page: int, size: int, keyword: str | None = None, type_: str | None = None
|
||||||
|
) -> tuple[list[ChannelCode], int]:
|
||||||
|
stmt = select(ChannelCode)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
stmt = stmt.where(or_(ChannelCode.code.ilike(like), ChannelCode.name.ilike(like)))
|
||||||
|
if type_:
|
||||||
|
stmt = stmt.where(ChannelCode.type == type_)
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).select_from(stmt.order_by(None).subquery())
|
||||||
|
)).scalar() or 0
|
||||||
|
stmt = stmt.order_by(ChannelCode.id.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
rows = list((await db.execute(stmt)).scalars().all())
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(db: AsyncSession) -> list[ChannelCode]:
|
||||||
|
res = await db.execute(select(ChannelCode).order_by(ChannelCode.id))
|
||||||
|
return list(res.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, cid: int) -> ChannelCode:
|
||||||
|
c = await db.get(ChannelCode, cid)
|
||||||
|
if c is None:
|
||||||
|
raise AppError("渠道码不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
async def create(db: AsyncSession, data: ChannelCreate) -> ChannelCode:
|
||||||
|
res = await db.execute(select(ChannelCode).where(ChannelCode.code == data.code))
|
||||||
|
if res.scalar_one_or_none():
|
||||||
|
raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
c = ChannelCode(**data.model_dump())
|
||||||
|
db.add(c)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
async def update(db: AsyncSession, cid: int, data: ChannelUpdate) -> ChannelCode:
|
||||||
|
c = await get(db, cid)
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(c, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, cid: int) -> None:
|
||||||
|
c = await get(db, cid)
|
||||||
|
await db.delete(c)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""LLM 服务:Provider / Model / RoutingRule CRUD、路由匹配、对话调用、用量记账。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.crypto import decrypt, encrypt, mask
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.integrations.openai_client import ChatResult, chat_completion
|
||||||
|
from app.models import LLMModel, LLMProvider, LLMRoutingRule, LLMUsage, User
|
||||||
|
from app.schemas.llm import (
|
||||||
|
ChatResponse,
|
||||||
|
ModelCreate,
|
||||||
|
ModelRead,
|
||||||
|
ModelUpdate,
|
||||||
|
ProviderCreate,
|
||||||
|
ProviderRead,
|
||||||
|
ProviderUpdate,
|
||||||
|
RuleCreate,
|
||||||
|
RuleRead,
|
||||||
|
RuleUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Provider --------
|
||||||
|
|
||||||
|
async def list_providers(db: AsyncSession) -> list[ProviderRead]:
|
||||||
|
res = await db.execute(select(LLMProvider).order_by(LLMProvider.id))
|
||||||
|
return [provider_to_read(p) for p in res.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def provider_to_read(p: LLMProvider) -> ProviderRead:
|
||||||
|
plain = decrypt(p.api_key_cipher) if p.api_key_cipher else None
|
||||||
|
return ProviderRead(
|
||||||
|
id=p.id,
|
||||||
|
code=p.code,
|
||||||
|
name=p.name,
|
||||||
|
vendor=p.vendor,
|
||||||
|
api_base=p.api_base,
|
||||||
|
api_key_mask=mask(plain) if plain else "",
|
||||||
|
has_api_key=bool(plain),
|
||||||
|
description=p.description,
|
||||||
|
status=p.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_provider(db: AsyncSession, pid: int) -> LLMProvider:
|
||||||
|
p = await db.get(LLMProvider, pid)
|
||||||
|
if p is None:
|
||||||
|
raise AppError("Provider 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
async def create_provider(db: AsyncSession, data: ProviderCreate) -> LLMProvider:
|
||||||
|
exists = await db.execute(select(LLMProvider).where(LLMProvider.code == data.code))
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
p = LLMProvider(
|
||||||
|
code=data.code,
|
||||||
|
name=data.name,
|
||||||
|
vendor=data.vendor,
|
||||||
|
api_base=data.api_base,
|
||||||
|
api_key_cipher=encrypt(data.api_key) if data.api_key else None,
|
||||||
|
description=data.description,
|
||||||
|
status=data.status,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
async def update_provider(
|
||||||
|
db: AsyncSession, pid: int, data: ProviderUpdate
|
||||||
|
) -> LLMProvider:
|
||||||
|
p = await get_provider(db, pid)
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
if "api_key" in changes:
|
||||||
|
val = changes.pop("api_key")
|
||||||
|
if val is not None:
|
||||||
|
p.api_key_cipher = encrypt(val) if val else None
|
||||||
|
for k, v in changes.items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_provider(db: AsyncSession, pid: int) -> None:
|
||||||
|
p = await get_provider(db, pid)
|
||||||
|
# 若有模型使用,阻止删除
|
||||||
|
cnt = (await db.execute(
|
||||||
|
select(func.count()).select_from(LLMModel).where(LLMModel.provider_id == pid)
|
||||||
|
)).scalar() or 0
|
||||||
|
if cnt > 0:
|
||||||
|
raise AppError(
|
||||||
|
f"该 Provider 下还有 {cnt} 个模型,不允许删除",
|
||||||
|
code=409, http_status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
await db.delete(p)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Model --------
|
||||||
|
|
||||||
|
async def list_models(db: AsyncSession) -> list[ModelRead]:
|
||||||
|
res = await db.execute(select(LLMModel).order_by(LLMModel.id))
|
||||||
|
out: list[ModelRead] = []
|
||||||
|
for m in res.scalars().all():
|
||||||
|
out.append(await model_to_read(db, m))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def model_to_read(db: AsyncSession, m: LLMModel) -> ModelRead:
|
||||||
|
prov = await db.get(LLMProvider, m.provider_id)
|
||||||
|
return ModelRead(
|
||||||
|
id=m.id,
|
||||||
|
provider_id=m.provider_id,
|
||||||
|
provider_code=prov.code if prov else None,
|
||||||
|
provider_name=prov.name if prov else None,
|
||||||
|
model=m.model,
|
||||||
|
name=m.name,
|
||||||
|
description=m.description,
|
||||||
|
max_context=m.max_context,
|
||||||
|
price_input_per_1k=float(m.price_input_per_1k),
|
||||||
|
price_output_per_1k=float(m.price_output_per_1k),
|
||||||
|
status=m.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_model(db: AsyncSession, data: ModelCreate) -> LLMModel:
|
||||||
|
await get_provider(db, data.provider_id)
|
||||||
|
m = LLMModel(**data.model_dump())
|
||||||
|
db.add(m)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def update_model(db: AsyncSession, mid: int, data: ModelUpdate) -> LLMModel:
|
||||||
|
m = await db.get(LLMModel, mid)
|
||||||
|
if m is None:
|
||||||
|
raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(m, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_model(db: AsyncSession, mid: int) -> None:
|
||||||
|
m = await db.get(LLMModel, mid)
|
||||||
|
if m is None:
|
||||||
|
raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
cnt = (await db.execute(
|
||||||
|
select(func.count()).select_from(LLMRoutingRule).where(LLMRoutingRule.model_id == mid)
|
||||||
|
)).scalar() or 0
|
||||||
|
if cnt > 0:
|
||||||
|
raise AppError(
|
||||||
|
f"该模型被 {cnt} 条路由规则引用", code=409,
|
||||||
|
http_status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
await db.delete(m)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Routing --------
|
||||||
|
|
||||||
|
async def list_rules(db: AsyncSession) -> list[RuleRead]:
|
||||||
|
res = await db.execute(select(LLMRoutingRule).order_by(LLMRoutingRule.priority, LLMRoutingRule.id))
|
||||||
|
out: list[RuleRead] = []
|
||||||
|
for r in res.scalars().all():
|
||||||
|
model = r.model_obj
|
||||||
|
prov = model.provider if model else None
|
||||||
|
out.append(
|
||||||
|
RuleRead(
|
||||||
|
id=r.id, name=r.name, priority=r.priority, conditions=r.conditions,
|
||||||
|
model_id=r.model_id,
|
||||||
|
model_label=f"{prov.code}/{model.model}" if prov and model else None,
|
||||||
|
enabled=r.enabled, description=r.description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def create_rule(db: AsyncSession, data: RuleCreate) -> LLMRoutingRule:
|
||||||
|
if (await db.get(LLMModel, data.model_id)) is None:
|
||||||
|
raise AppError("model 不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
r = LLMRoutingRule(**data.model_dump())
|
||||||
|
db.add(r)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
async def update_rule(db: AsyncSession, rid: int, data: RuleUpdate) -> LLMRoutingRule:
|
||||||
|
r = await db.get(LLMRoutingRule, rid)
|
||||||
|
if r is None:
|
||||||
|
raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(r, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_rule(db: AsyncSession, rid: int) -> None:
|
||||||
|
r = await db.get(LLMRoutingRule, rid)
|
||||||
|
if r is None:
|
||||||
|
raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
await db.delete(r)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Routing 匹配 --------
|
||||||
|
|
||||||
|
def _match(rule_conditions: dict | None, conditions: dict) -> bool:
|
||||||
|
if not rule_conditions:
|
||||||
|
return True # 默认规则永远命中
|
||||||
|
for k, v in rule_conditions.items():
|
||||||
|
if conditions.get(k) != v:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def route(
|
||||||
|
db: AsyncSession, conditions: dict | None = None
|
||||||
|
) -> tuple[LLMProvider, LLMModel]:
|
||||||
|
"""按优先级匹配路由;没命中时抛 400。"""
|
||||||
|
stmt = (
|
||||||
|
select(LLMRoutingRule)
|
||||||
|
.where(LLMRoutingRule.enabled.is_(True))
|
||||||
|
.order_by(LLMRoutingRule.priority, LLMRoutingRule.id)
|
||||||
|
)
|
||||||
|
rules = list((await db.execute(stmt)).scalars().all())
|
||||||
|
if not rules:
|
||||||
|
raise AppError("没有可用的 LLM 路由规则", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
cond = conditions or {}
|
||||||
|
picked = next((r for r in rules if _match(r.conditions, cond)), None)
|
||||||
|
if picked is None:
|
||||||
|
raise AppError(
|
||||||
|
f"没有匹配条件 {cond} 的路由规则", code=400,
|
||||||
|
http_status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
model = picked.model_obj
|
||||||
|
prov = model.provider
|
||||||
|
if model.status != "ACTIVE" or prov.status != "ACTIVE":
|
||||||
|
raise AppError(
|
||||||
|
"路由命中的 Provider/Model 已禁用", code=400,
|
||||||
|
http_status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
return prov, model
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_by_label(db: AsyncSession, label: str) -> tuple[LLMProvider, LLMModel]:
|
||||||
|
"""解析 `provider_code/model_name`。"""
|
||||||
|
if "/" not in label:
|
||||||
|
raise AppError("model 必须形如 provider_code/model_name",
|
||||||
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
code, name = label.split("/", 1)
|
||||||
|
prov = (await db.execute(
|
||||||
|
select(LLMProvider).where(LLMProvider.code == code)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if prov is None:
|
||||||
|
raise AppError(f"provider {code} 不存在",
|
||||||
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
m = (await db.execute(
|
||||||
|
select(LLMModel).where(LLMModel.provider_id == prov.id, LLMModel.model == name)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if m is None:
|
||||||
|
raise AppError(f"model {name} 不存在于 {code}",
|
||||||
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return prov, m
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Chat 调用 --------
|
||||||
|
|
||||||
|
def _compute_cost(model: LLMModel, prompt_t: int, completion_t: int) -> float:
|
||||||
|
return round(
|
||||||
|
float(model.price_input_per_1k) * prompt_t / 1000
|
||||||
|
+ float(model.price_output_per_1k) * completion_t / 1000,
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
model_label: str | None = None,
|
||||||
|
conditions: dict | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
source: str = "playground",
|
||||||
|
task_id: int | None = None,
|
||||||
|
skill_id: int | None = None,
|
||||||
|
) -> ChatResponse:
|
||||||
|
if model_label:
|
||||||
|
prov, model = await resolve_by_label(db, model_label)
|
||||||
|
else:
|
||||||
|
prov, model = await route(db, conditions)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
api_key = decrypt(prov.api_key_cipher) if prov.api_key_cipher else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result: ChatResult = await chat_completion(
|
||||||
|
api_base=prov.api_base,
|
||||||
|
api_key=api_key,
|
||||||
|
model=model.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
)
|
||||||
|
cost_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
cost_usd = _compute_cost(model, result.prompt_tokens, result.completion_tokens)
|
||||||
|
db.add(LLMUsage(
|
||||||
|
provider_id=prov.id, provider_code=prov.code,
|
||||||
|
model_id=model.id, model=model.model,
|
||||||
|
operator_id=operator.id, operator_name=operator.username,
|
||||||
|
source=source, task_id=task_id, skill_id=skill_id,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
total_tokens=result.total_tokens,
|
||||||
|
cost_usd=cost_usd, result="ok", cost_ms=cost_ms,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return ChatResponse(
|
||||||
|
provider=prov.code, model=model.model, content=result.content,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
total_tokens=result.total_tokens,
|
||||||
|
cost_usd=cost_usd, cost_ms=cost_ms,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
cost_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
db.add(LLMUsage(
|
||||||
|
provider_id=prov.id, provider_code=prov.code,
|
||||||
|
model_id=model.id, model=model.model,
|
||||||
|
operator_id=operator.id, operator_name=operator.username,
|
||||||
|
source=source, task_id=task_id, skill_id=skill_id,
|
||||||
|
result="failed", message=f"{e.__class__.__name__}: {e}",
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
raise AppError(
|
||||||
|
f"LLM 调用失败:{e}", code=502,
|
||||||
|
http_status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Usage 查询 --------
|
||||||
|
|
||||||
|
async def paginate_usage(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int,
|
||||||
|
size: int,
|
||||||
|
provider_id: int | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> tuple[list[LLMUsage], int]:
|
||||||
|
stmt = select(LLMUsage)
|
||||||
|
if provider_id:
|
||||||
|
stmt = stmt.where(LLMUsage.provider_id == provider_id)
|
||||||
|
if source:
|
||||||
|
stmt = stmt.where(LLMUsage.source == source)
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).select_from(stmt.order_by(None).subquery())
|
||||||
|
)).scalar() or 0
|
||||||
|
stmt = stmt.order_by(LLMUsage.id.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
rows = list((await db.execute(stmt)).scalars().all())
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
async def usage_summary(db: AsyncSession) -> dict:
|
||||||
|
by_model = (await db.execute(
|
||||||
|
select(
|
||||||
|
LLMUsage.provider_code,
|
||||||
|
LLMUsage.model,
|
||||||
|
func.count(LLMUsage.id),
|
||||||
|
func.sum(LLMUsage.prompt_tokens),
|
||||||
|
func.sum(LLMUsage.completion_tokens),
|
||||||
|
func.sum(LLMUsage.cost_usd),
|
||||||
|
)
|
||||||
|
.group_by(LLMUsage.provider_code, LLMUsage.model)
|
||||||
|
.order_by(func.sum(LLMUsage.total_tokens).desc())
|
||||||
|
)).all()
|
||||||
|
total_cost = (await db.execute(select(func.sum(LLMUsage.cost_usd)))).scalar() or 0
|
||||||
|
total_calls = (await db.execute(select(func.count(LLMUsage.id)))).scalar() or 0
|
||||||
|
return {
|
||||||
|
"total_calls": int(total_calls),
|
||||||
|
"total_cost_usd": round(float(total_cost), 6),
|
||||||
|
"by_model": [
|
||||||
|
{
|
||||||
|
"provider_code": pc,
|
||||||
|
"model": m,
|
||||||
|
"calls": int(c),
|
||||||
|
"prompt_tokens": int(pt or 0),
|
||||||
|
"completion_tokens": int(ct or 0),
|
||||||
|
"cost_usd": round(float(cu or 0), 6),
|
||||||
|
}
|
||||||
|
for pc, m, c, pt, ct, cu in by_model
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""龙虾运行时配置:读取(DB 优先 → .env 兜底)+ 更新。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.crypto import decrypt, encrypt, mask
|
||||||
|
from app.models import LobsterConfig
|
||||||
|
from app.schemas.lobster_config import LobsterConfigRead, LobsterConfigUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectiveLobsterConfig:
|
||||||
|
"""运行时真正生效的值(已合并 DB + .env)。"""
|
||||||
|
|
||||||
|
api_base: str
|
||||||
|
api_key: str
|
||||||
|
callback_secret: str
|
||||||
|
timeout_seconds: int
|
||||||
|
poll_interval_seconds: int
|
||||||
|
|
||||||
|
# 各字段的来源:db / env / unset,仅用于展示
|
||||||
|
api_base_source: str = "env"
|
||||||
|
api_key_source: str = "env"
|
||||||
|
callback_secret_source: str = "env"
|
||||||
|
timeout_source: str = "env"
|
||||||
|
poll_interval_source: str = "env"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_mock(self) -> bool:
|
||||||
|
return not self.api_base or not self.api_key
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_row(db: AsyncSession) -> LobsterConfig:
|
||||||
|
row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = LobsterConfig(id=1)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def load_effective(db: AsyncSession) -> EffectiveLobsterConfig:
|
||||||
|
row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none()
|
||||||
|
|
||||||
|
# DB 字段(可能 None)
|
||||||
|
db_api_base = (row.api_base if row else None) or None
|
||||||
|
db_api_key = decrypt(row.api_key_cipher) if row and row.api_key_cipher else None
|
||||||
|
db_callback_secret = (
|
||||||
|
decrypt(row.callback_secret_cipher) if row and row.callback_secret_cipher else None
|
||||||
|
)
|
||||||
|
db_timeout = row.timeout_seconds if row else None
|
||||||
|
db_poll = row.poll_interval_seconds if row else None
|
||||||
|
|
||||||
|
# env 兜底
|
||||||
|
env_api_base = settings.LOBSTER_API_BASE or ""
|
||||||
|
env_api_key = settings.LOBSTER_API_KEY or ""
|
||||||
|
env_callback_secret = settings.LOBSTER_CALLBACK_SECRET or ""
|
||||||
|
env_timeout = settings.LOBSTER_TIMEOUT_SECONDS or 30
|
||||||
|
env_poll = settings.LOBSTER_POLL_INTERVAL_SECONDS or 5
|
||||||
|
|
||||||
|
api_base = db_api_base or env_api_base
|
||||||
|
api_base_source = "db" if db_api_base else ("env" if env_api_base else "unset")
|
||||||
|
|
||||||
|
api_key = db_api_key or env_api_key
|
||||||
|
api_key_source = "db" if db_api_key else ("env" if env_api_key else "unset")
|
||||||
|
|
||||||
|
callback = db_callback_secret or env_callback_secret
|
||||||
|
callback_source = (
|
||||||
|
"db" if db_callback_secret else ("env" if env_callback_secret else "unset")
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = db_timeout or env_timeout
|
||||||
|
timeout_source = "db" if db_timeout else "env"
|
||||||
|
|
||||||
|
poll = db_poll or env_poll
|
||||||
|
poll_source = "db" if db_poll else "env"
|
||||||
|
|
||||||
|
return EffectiveLobsterConfig(
|
||||||
|
api_base=api_base,
|
||||||
|
api_key=api_key,
|
||||||
|
callback_secret=callback,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
poll_interval_seconds=poll,
|
||||||
|
api_base_source=api_base_source,
|
||||||
|
api_key_source=api_key_source,
|
||||||
|
callback_secret_source=callback_source,
|
||||||
|
timeout_source=timeout_source,
|
||||||
|
poll_interval_source=poll_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def read(db: AsyncSession) -> LobsterConfigRead:
|
||||||
|
eff = await load_effective(db)
|
||||||
|
note = None
|
||||||
|
row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
note = row.note
|
||||||
|
return LobsterConfigRead(
|
||||||
|
api_base=eff.api_base or "",
|
||||||
|
api_base_source=eff.api_base_source,
|
||||||
|
api_key_mask=mask(eff.api_key) if eff.api_key else "",
|
||||||
|
api_key_source=eff.api_key_source,
|
||||||
|
has_api_key=bool(eff.api_key),
|
||||||
|
callback_secret_mask=mask(eff.callback_secret) if eff.callback_secret else "",
|
||||||
|
callback_secret_source=eff.callback_secret_source,
|
||||||
|
has_callback_secret=bool(eff.callback_secret),
|
||||||
|
timeout_seconds=eff.timeout_seconds,
|
||||||
|
timeout_source=eff.timeout_source,
|
||||||
|
poll_interval_seconds=eff.poll_interval_seconds,
|
||||||
|
poll_interval_source=eff.poll_interval_source,
|
||||||
|
mode="real" if not eff.is_mock else "mock",
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update(db: AsyncSession, data: LobsterConfigUpdate) -> LobsterConfigRead:
|
||||||
|
row = await _get_or_create_row(db)
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# api_key / callback_secret 空串表示清空 → fallback env
|
||||||
|
if "api_key" in changes:
|
||||||
|
v = changes.pop("api_key")
|
||||||
|
if v is None:
|
||||||
|
pass # 不改
|
||||||
|
elif v == "":
|
||||||
|
row.api_key_cipher = None
|
||||||
|
else:
|
||||||
|
row.api_key_cipher = encrypt(v)
|
||||||
|
|
||||||
|
if "callback_secret" in changes:
|
||||||
|
v = changes.pop("callback_secret")
|
||||||
|
if v is None:
|
||||||
|
pass
|
||||||
|
elif v == "":
|
||||||
|
row.callback_secret_cipher = None
|
||||||
|
else:
|
||||||
|
row.callback_secret_cipher = encrypt(v)
|
||||||
|
|
||||||
|
# api_base 空串 = 清空回落 env
|
||||||
|
if "api_base" in changes:
|
||||||
|
v = changes.pop("api_base")
|
||||||
|
row.api_base = v if v else None
|
||||||
|
|
||||||
|
for k, v in changes.items():
|
||||||
|
setattr(row, k, v)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return await read(db)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""菜单 / 按钮权限 树 CRUD。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.models import Menu
|
||||||
|
from app.schemas.menu import MenuCreate, MenuTreeNode, MenuUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(db: AsyncSession) -> list[Menu]:
|
||||||
|
res = await db.execute(select(Menu).order_by(Menu.sort, Menu.id))
|
||||||
|
return list(res.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, mid: int) -> Menu:
|
||||||
|
m = await db.get(Menu, mid)
|
||||||
|
if m is None:
|
||||||
|
raise AppError("菜单不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def create(db: AsyncSession, data: MenuCreate) -> Menu:
|
||||||
|
if data.perm_code:
|
||||||
|
exists = await db.execute(select(Menu).where(Menu.perm_code == data.perm_code))
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
raise AppError("perm_code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
if data.parent_id and (await db.get(Menu, data.parent_id)) is None:
|
||||||
|
raise AppError("父菜单不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
m = Menu(**data.model_dump())
|
||||||
|
db.add(m)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def update(db: AsyncSession, mid: int, data: MenuUpdate) -> Menu:
|
||||||
|
m = await get(db, mid)
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
if "perm_code" in changes and changes["perm_code"] and changes["perm_code"] != m.perm_code:
|
||||||
|
exists = await db.execute(
|
||||||
|
select(Menu).where(Menu.perm_code == changes["perm_code"], Menu.id != mid)
|
||||||
|
)
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
raise AppError("perm_code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
if "parent_id" in changes and changes["parent_id"]:
|
||||||
|
if changes["parent_id"] == mid:
|
||||||
|
raise AppError("不能把自己设为父级", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
parent = await db.get(Menu, changes["parent_id"])
|
||||||
|
if parent is None:
|
||||||
|
raise AppError("父菜单不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for k, v in changes.items():
|
||||||
|
setattr(m, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, mid: int) -> None:
|
||||||
|
m = await get(db, mid)
|
||||||
|
res = await db.execute(select(Menu.id).where(Menu.parent_id == mid))
|
||||||
|
if res.first():
|
||||||
|
raise AppError("存在子菜单,无法删除", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
await db.delete(m)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(items: list[Menu]) -> list[MenuTreeNode]:
|
||||||
|
nodes: dict[int, MenuTreeNode] = {}
|
||||||
|
for m in items:
|
||||||
|
nodes[m.id] = MenuTreeNode(
|
||||||
|
id=m.id, parent_id=m.parent_id, name=m.name, type=m.type, path=m.path,
|
||||||
|
component=m.component, icon=m.icon, perm_code=m.perm_code,
|
||||||
|
sort=m.sort, status=m.status,
|
||||||
|
)
|
||||||
|
roots: list[MenuTreeNode] = []
|
||||||
|
for m in items:
|
||||||
|
n = nodes[m.id]
|
||||||
|
if m.parent_id and m.parent_id in nodes:
|
||||||
|
nodes[m.parent_id].children.append(n)
|
||||||
|
else:
|
||||||
|
roots.append(n)
|
||||||
|
|
||||||
|
def _sort(lst: list[MenuTreeNode]) -> None:
|
||||||
|
lst.sort(key=lambda x: (x.sort, x.id))
|
||||||
|
for it in lst:
|
||||||
|
_sort(it.children)
|
||||||
|
|
||||||
|
_sort(roots)
|
||||||
|
return roots
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""组织架构:CRUD + 树构建。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.models import Organization
|
||||||
|
from app.schemas.organization import OrgCreate, OrgTreeNode, OrgUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(db: AsyncSession) -> list[Organization]:
|
||||||
|
res = await db.execute(select(Organization).order_by(Organization.sort, Organization.id))
|
||||||
|
return list(res.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, oid: int) -> Organization:
|
||||||
|
org = await db.get(Organization, oid)
|
||||||
|
if org is None:
|
||||||
|
raise AppError("组织不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def create(db: AsyncSession, data: OrgCreate) -> Organization:
|
||||||
|
exists = await db.execute(select(Organization).where(Organization.code == data.code))
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
org = Organization(**data.model_dump())
|
||||||
|
db.add(org)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def update(db: AsyncSession, oid: int, data: OrgUpdate) -> Organization:
|
||||||
|
org = await get(db, oid)
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(org, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, oid: int) -> None:
|
||||||
|
org = await get(db, oid)
|
||||||
|
# 不允许删除有子节点的组织
|
||||||
|
res = await db.execute(select(Organization.id).where(Organization.parent_id == oid))
|
||||||
|
if res.first():
|
||||||
|
raise AppError("存在子组织,无法删除", code=409, http_status=status.HTTP_409_CONFLICT)
|
||||||
|
await db.delete(org)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(items: list[Organization]) -> list[OrgTreeNode]:
|
||||||
|
nodes: dict[int, OrgTreeNode] = {}
|
||||||
|
for o in items:
|
||||||
|
nodes[o.id] = OrgTreeNode(
|
||||||
|
id=o.id, parent_id=o.parent_id, name=o.name, code=o.code,
|
||||||
|
type=o.type, sort=o.sort, description=o.description,
|
||||||
|
)
|
||||||
|
roots: list[OrgTreeNode] = []
|
||||||
|
for o in items:
|
||||||
|
n = nodes[o.id]
|
||||||
|
if o.parent_id and o.parent_id in nodes:
|
||||||
|
nodes[o.parent_id].children.append(n)
|
||||||
|
else:
|
||||||
|
roots.append(n)
|
||||||
|
|
||||||
|
def _sort(lst: list[OrgTreeNode]) -> None:
|
||||||
|
lst.sort(key=lambda x: (x.sort, x.id))
|
||||||
|
for it in lst:
|
||||||
|
_sort(it.children)
|
||||||
|
|
||||||
|
_sort(roots)
|
||||||
|
return roots
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user