chore: initial commit

This commit is contained in:
huyue
2026-04-24 17:46:03 +08:00
commit ce2070ce5a
203 changed files with 27769 additions and 0 deletions
+60
View File
@@ -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
+57
View File
@@ -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
View File
@@ -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/
+110
View File
@@ -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。
+51
View File
@@ -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/
```
+42
View File
@@ -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
+65
View File
@@ -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()
+27
View File
@@ -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"}
View File
@@ -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 ###
View File
View File
+50
View File
@@ -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)
View File
+60
View File
@@ -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())
+69
View File
@@ -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})
+51
View File
@@ -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()
+270
View File
@@ -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),
})
+72
View File
@@ -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,
},
})
+33
View File
@@ -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})
+153
View File
@@ -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))
+21
View File
@@ -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())
+41
View File
@@ -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()
+49
View File
@@ -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})
+41
View File
@@ -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()
+58
View File
@@ -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())
+39
View File
@@ -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()
+52
View File
@@ -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()
+43
View File
@@ -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()
+53
View File
@@ -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()
+52
View File
@@ -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()
+104
View File
@@ -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,
})
+93
View File
@@ -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))
+57
View File
@@ -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()
View File
+73
View File
@@ -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()
+29
View File
@@ -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"
+46
View File
@@ -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)
+72
View File
@@ -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
+69
View File
@@ -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, "服务器内部错误"),
)
+44
View File
@@ -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()
+192
View File
@@ -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)
+76
View File
@@ -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",
]
View File
+25
View File
@@ -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,
)
+29
View File
@@ -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,
)
+8
View File
@@ -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 + 超时 + 逐行日志回调)。
"""
+142
View File
@@ -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,
)
+86
View File
@@ -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,
)
+150
View File
@@ -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
+61
View File
@@ -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()
+53
View File
@@ -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",
]
+31
View File
@@ -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)
+120
View File
@@ -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
)
+29
View File
@@ -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)
+30
View File
@@ -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"
)
+34
View File
@@ -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
)
+26
View File
@@ -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"
)
+18
View File
@@ -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)
+34
View File
@@ -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)
+31
View File
@@ -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"
)
+67
View File
@@ -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"
)
+59
View File
@@ -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)
+25
View File
@@ -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
)
+89
View File
@@ -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
)
+92
View File
@@ -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
)
+38
View File
@@ -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"
)
+1
View File
@@ -0,0 +1 @@
"""Pydantic DTO。P2 起按模块补齐。"""
+64
View File
@@ -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]
+33
View File
@@ -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
+20
View File
@@ -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
+166
View File
@@ -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
+38
View File
@@ -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
+50
View File
@@ -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)
+40
View File
@@ -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)
+34
View File
@@ -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
+46
View File
@@ -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
+45
View File
@@ -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)
+49
View File
@@ -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
+53
View File
@@ -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
+41
View File
@@ -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
+78
View File
@@ -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
+51
View File
@@ -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
+46
View File
@@ -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)
View File
+250
View File
@@ -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())
+169
View File
@@ -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())
+121
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""业务服务层。P2 起按模块补齐。"""
+60
View File
@@ -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()
+409
View File
@@ -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)
+92
View File
@@ -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
+76
View File
@@ -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