Compare commits

...
10 Commits
Author SHA1 Message Date
chenxin 7c57d27494 ✨ feat: WP-09 Live2D/3D——模型导入校验与非破坏命令历史
- 迁移 18:avatar_model(live2d/vrm/gltf、validated/rejected、校验报告、来源许可、当前命令指针)+ avatar_command(十类命令检查约束、(avatar,sequence) 唯一、undone 标记)
- GLB/glTF 解析器(纯 Python):GLB magic/版本/chunk 结构校验、JSON 场景图引用一致性(scene→node→mesh→material 逐层索引越界检测)、VRM 0.x/1.0 扩展检测与 meta 提取
- 资源上限(恶意模型文件防线):文件 200MB、节点 10 万、网格 1 万、纹理 2 千、材质 5 千、动画 1 千——超限入库 rejected 并保留报告
- Cubism model3.json 校验:Version/FileReferences 必需、moc3 引用、纹理列表、表情/动作/HitArea 计数
- 上传 MIME 白名单扩展 model/ 与 application/json
- 命令历史:十类命令(param_map/expression/physics/motion/material/texture/bone/attachment/lighting/camera)追加(sequence 单调)、撤销(标记 undone 非破坏)、rejected 模型不接受编辑(422 前置校验)
- 测试 111 项全绿(+9):合法 GLB/VRM 检测/损坏文件/引用越界/超限/Cubism 正反例/命令历史与撤销幂等/rejected 拒编
2026-09-14 10:34:52 +08:00
chenxin 9dafb8fe72 ✨ feat: 灵台引擎 gRPC 独立服务形态(ADR-017 双形态落地)
- proto(engine/proto/liushen/v1):LingtaiEngine 九方法——GetEngineInfo(版本协商/能力位)、TransformMarkdown、RebuildExport(UNIMPLEMENTED 明示随 WP-03)、OpenStream 双向流、CheckBudget、RerankRecall、EvaluatePolicy、ValidateUrl、CheckStateTransition;RunStatus 枚举与契约一致
- 服务端参考实现(engine/server/lingtai_server.py):进程内直链 nanobind 引擎核心——双形态同核同 golden;开发档 127.0.0.1 明文(生产 mTLS,文案明示);状态转换表按 agent-runtime 第 3 节
- 集成测试 6 项:进程内随机端口起服——版本协商、双形态 golden(服务输出==进程内引擎输出,ADR-017 核心不变量首次实证)、双向流 envelope 序号单调、决策表/URL/状态机、RebuildExport UNIMPLEMENTED 明确拒绝(不伪造)
- Makefile:lingtai-proto(stub 再生)与 lingtai-server(启动服务)目标
- 注:C++ 原生二进制(hunjing-lingtai-server)随交叉编译矩阵交付;Python 参考实现保证 proto 与语义先行锁定
- 测试 102 项全绿(+6);ruff/mypy 干净
2026-09-14 10:21:54 +08:00
chenxin a39cec5287 ✨ feat: 灵台引擎五神齐备与 Go/Rust 官方绑定(WP-15)
- C++ 新增四模块(modules.cpp,19 个导出符号全部 hj_*):
  小周天 hj_stream:流会话(sequence/delta_index 单调)与 content.block.delta envelope 确定性编码(重放字节一致)
  烛照 hj_policy:URL 协议白名单(拒 file/javascript/data)、路径授权根与 .. 段拒绝、幂等键非空≤128、四维决策表(autonomy×risk→allow/ask/deny,非法输入 deny fail-closed)
  洛书 hj_plan:预算核算(达限/负数拒绝)
  河图 hj_recall:确定性重排(词频×稳定排序,canonical JSON 输出;非法输入 PARSE_FAILED)
- doctest 22 用例 20135 断言全绿(含每模块 golden 与确定性双跑)
- Go 绑定(cgo 自包含编译引擎源码):Engine RAII/TransformMarkdown/ValidateURL/ValidatePath/Decide/CheckBudget/RecallRerank;go test 3 用例通过(transform/rerank golden 与 C++ 字节一致)
- Rust 绑定(bindgen 生成 + 安全包装):Engine RAII(Drop 释放)、Error 枚举映射全部 HjStatus、Send/Sync(HjEngine 线程安全);cargo test 3 用例通过(同 golden)
- 三端(C++/Go/Rust)与 Python golden 一致:同一 markdown 输入在四端输出字节相同(跨端一致性首次全语言验证)
2026-09-12 13:54:12 +08:00
chenxin 99a7789432 ✨ feat: Sandbox Runtime——isolated 插件子进程隔离执行(WP-06/07 收口)
- 迁移 17:plugin_execution(五态、退出码、时长、stdout 摘要、错误留痕)
- 沙箱执行器:独立子进程(代码经 stdin JSON 安全传输,不进命令行)、CPU rlimit + RLIMIT_AS(平台不支持时尽力而为并注明)、墙钟超时强杀、输出限额;runner 仅暴露 input/output 作用域
- 执行端点:isolated_process 形态、已安装且启用后可执行;**未启用沙箱一律 503 fail-closed**(HUNJING_SANDBOX_ENABLED 显式开启,审计记录拒绝原因)——AGENTS 第 6 节"无符合策略的 Sandbox 时拒绝执行"落地
- 每次执行落 plugin_execution + 审计(状态/时长);进程级隔离的边界如实注明(网络/文件系统最小化在 Linux 容器档交付)
- 测试 96 项全绿(+2):echo 真实执行回填、死循环 5s 超时强杀、异常 failed 含 stderr、无沙箱 503
2026-09-12 13:38:41 +08:00
chenxin 21eacc8c75 ✨ feat: WP-14 运营收口——诊断双授权、管理面治理、支持包、观测与自托管
- 迁移 16:diagnostic_grant(token 哈希唯一、15 分钟、单次、绑定 Run)+ user.role(user/admin 检查约束)
- 诊断双授权(ADR-004):本人签发 grant(明文 token 只返回一次,库存 SHA-256);读取需登录态 + Hunjing-Diagnostic-Token + Purpose 三重匹配;单次消耗(重放 401)、用途不符 403、响应 no-store/private;返回观测摘要(usage/TTFT/工具轨迹)不含消息正文
- 管理面:pending 司议案列表与处置(content_removed 联动下架目标内容——治理闭环);非管理员 403
- 脱敏支持包:统计聚合(runs/messages/tool/media 按状态),无正文无密钥
- 观测:GET /readyz(DB 连通)与 GET /metrics(Prometheus 文本,真实 SQL 聚合)
- 自托管:docker/Dockerfile.backend(uv 构建)+ docker-compose.app.yml(hunjing-api + hunjing-postgres 健康依赖、密码经 .env);Makefile 新增 selfhost-up/down、db-dump/db-restore(备份到 backups/)
- 测试 94 项全绿(+3):诊断全路径(无令牌/用途不符/成功/单次重放)、管理面处置联动、支持包与指标
2026-09-12 11:20:15 +08:00
chenxin 701c20c783 ✨ feat: WP-08 媒体基础与 CI(上传/文件/任务/声契 + 三任务流水线)
- 迁移 15:upload(三态)、file_asset(sha256/存储键/pending-ready-quarantined)、media_job(四类五态+进度+取消)、voice_consent(用途/期限/撤回)
- 上传三步流(契约第 9 节开发档):POST uploads(MIME 白名单+大小约束)→ PUT data(真实写 storage/ 落盘、sha256 校验)→ completions 转 ready——未完成文件不可被业务引用
- 文件内容取回(开发档本地;正式部署换短期签名 URL,存储键不变)
- 媒体任务:ASR 输入必须 ready 音频;后台执行经注入 Provider(测试 mock 确定性转写);无 Provider 显式失败(fail-closed);取消幂等
- 声契:TTS/克隆需有效声契(未撤回未过期);撤回后新任务 403(ADR 媒体域“撤回停止新生成”)
- CI(WP-14 起步):三任务流水线——backend(pgvector 服务容器 + ruff/mypy/pytest)、engine(CMake 警告即错误 + ctest)、openapi(Redocly lint)
- 测试 91 项全绿(+5)
2026-09-12 10:50:37 +08:00
chenxin 9ef2568c3b ✨ feat: WP-07 插件生态——Registry、三态分离与调用门(ADR-001/008)
- 迁移 14:plugin(kind 四形态、撤架三态)、plugin_version(Manifest JSONB + 规范化内容哈希签名、(plugin,version) 唯一)、plugin_installation(enabled + granted_permissions、(domain,plugin) 唯一)
- Registry:发布版本(Manifest 必须声明 permissions,签名=canonical 哈希);撤架阻断新调用与新安装,历史审计保留(验证:撤架后 invoked 审计仍在)
- 三态分离(购买≠安装≠启用≠授权):安装默认 disabled+零授权;启用必须显式授予权限子集(越界 422);调用门逐级校验(未装/未启/撤架各 403)
- 升级:Manifest 新增权限必须重新确认(未确认 422 且明示新增项);确认后升级成功
- 调用:按 Manifest response_template 真实执行(声明式确定性回填);remote/isolated 形态明示 501 随沙箱交付(WP-14),不静默降级;审计含版本哈希(快照语义)
- 权限标识统一 group:item 格式
- 测试 86 项全绿(+4):三态流、越界、升级确认、撤架阻断与历史保留
2026-09-12 10:18:20 +08:00
chenxin 795df0413e ✨ feat: WP-12 协作空间——Workspace、RBAC、邀请与项目评审
- 迁移 13:workspace、workspace_member(四角色 + (workspace,member) 唯一)、workspace_invitation(四态流转)、project、project_version((project,version) 唯一、评审四态、提交/评审人留痕)
- RBAC 三权分立:editor 提交版本、reviewer 批准/拒绝、owner 发布与成员管理——权限矩阵代码化(editor 不可自审、viewer 不可提交、发布前必须有 approved 当前版本)
- 邀请流:owner 邀请(角色限定 editor/reviewer/viewer)→ 被邀者接受入成员;已是成员 409;移除所有者 422
- 评审流:版本号自动递增不可变;批准设为当前版本;拒绝不可逆(重审 409);未批准发布 422
- 隔离:非成员与被移除成员统一 404 fail-closed(require_member 单点)
- 测试 82 项全绿(+3):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
2026-09-12 09:33:29 +08:00
chenxin 99985f5855 ✨ feat: WP-11 社区——发布、互动、关注与治理
- 迁移 12:community_content(帖子/角色 Release、publication 六态、可见性)、comment(父级树)、reaction((content,actor,kind) 唯一防重复)、follow(双 FK 显式命名修复 convention 冲突)、report + moderation_case(举报即建司议案)
- 发布:帖子直发;角色 Release 由服务端构造公开快照(name/道体/版本数)——私密记忆/关系/对话结构性缺席,测试以注入私密记忆后发布验证零泄露
- 互动:评论(父级校验同内容可见)、点赞/收藏(重复 409、取消后计数正确)
- 关注/取关与计数;举报自动生成司议案(治理队列入口,status=pending)
- 下架传播:removed 后列表/详情/评论统一 404 或过滤(require_content 单点);非作者下架 404 fail-closed
- 测试基建修复:login 返回自包含 headers(显式 Cookie 优先),多用户交替不再串会话
- 测试 79 项全绿(+7)
2026-09-12 09:02:36 +08:00
chenxin 34c481ab9e ✨ feat: WP-13 市场与交易——不可变账本、退款、Webhook 与保全态(ADR-009/021)
- 迁移 11:listing、order(六态 + 幂等唯一约束)、order_item(价格快照,客户端金额不可信)、ledger_entry(不可变 8 类型带符号分录)、webhook_event(Provider 事件 ID 幂等)、personal_domain.billing_status(保全态)
- 购买闭环:下单冻结价格快照 → 支付确认同事务写三分录(charge = platform_fee 10% + creator_payable,双录平衡校验拒绝不平衡提交)
- 退款:反向三条分录修正账本(Ledger 不删不改,ADR-009);重复退款幂等;汇总端点对账 balanced=true
- Webhook:HMAC-SHA256 验签(密钥经环境变量,错签 401)→ 按 Provider 事件 ID 幂等(重复投递 duplicate 不重复入账)→ 买方从订单反查(不信任 payload)
- 保全态:欠费只读保全(全部状态变更 403 且明示不删数据;解除端点不被保全自锁);读取不受影响,恢复即解锁——ADR-021 落地
- 测试 72 项全绿(+5):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
2026-09-11 11:37:45 +08:00
61 changed files with 7978 additions and 7 deletions

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: hunjing
POSTGRES_PASSWORD: hunjing_dev_password
POSTGRES_DB: hunjing
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U hunjing"
--health-interval 5s --health-timeout 3s --health-retries 12
env:
DATABASE_URL: postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing
TEST_DATABASE_URL: postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing_test
HUNJING_WEBHOOK_SECRET: whsec_ci
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: 安装依赖
working-directory: backend
run: uv sync
- name: 创建测试库
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
PGPASSWORD=hunjing_dev_password psql -h 127.0.0.1 -U hunjing -d hunjing \
-c "CREATE DATABASE hunjing_test"
- name: Lint 与类型检查
working-directory: backend
run: |
uv run ruff check .
uv run mypy app
- name: 测试
working-directory: backend
run: uv run pytest -q
engine:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 CMake
run: pipx install cmake
- name: 构建(警告即错误)
run: |
cmake -S engine -B engine/build -DCMAKE_BUILD_TYPE=Debug
cmake --build engine/build
- name: 测试
run: ctest --test-dir engine/build --output-on-failure
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: 契约校验
run: |
npx --yes @redocly/cli@latest lint openapi/openapi.yaml \
--config openapi/redocly.yaml
+4
View File
@@ -112,6 +112,10 @@ target/
# C++ 引擎与契约生成产物(源码构建可再生) # C++ 引擎与契约生成产物(源码构建可再生)
engine/build*/ engine/build*/
engine/bindings/go/lingtai/*.cpp
engine/bindings/go/lingtai/*.h
engine/bindings/go/lingtai/hunjing/
engine/bindings/rust/target/
engine/bindings/build/ engine/bindings/build/
openapi/bundled.json openapi/bundled.json
**/*.rs.bk **/*.rs.bk
+34
View File
@@ -53,6 +53,19 @@ openapi-lint: ## 校验 OpenAPI 契约(redocly lint + bundle)
generate: openapi-lint ## 生成代码(TypeScript 类型等) generate: openapi-lint ## 生成代码(TypeScript 类型等)
cd frontend && npm run generate:api cd frontend && npm run generate:api
# ===== 灵台引擎 gRPC 服务(ADR-017 双形态) =====
.PHONY: lingtai-proto
lingtai-proto: ## 重新生成 gRPC stub(proto 变更后执行)
cd backend && uv run python -m grpc_tools.protoc -I../engine/proto \
--python_out=../engine/server/generated \
--grpc_python_out=../engine/server/generated \
liushen/v1/liushen.proto
.PHONY: lingtai-server
lingtai-server: ## 启动灵台引擎独立服务(开发档 127.0.0.1:50051;生产须 mTLS)
cd backend && uv run python ../engine/server/lingtai_server.py
# ===== 前端与桌面(Next.js / Tauri v2) ===== # ===== 前端与桌面(Next.js / Tauri v2) =====
.PHONY: frontend-setup .PHONY: frontend-setup
@@ -63,6 +76,27 @@ frontend-setup: ## 安装前端依赖
frontend-dev: ## 启动前端开发服务(127.0.0.1:3000) frontend-dev: ## 启动前端开发服务(127.0.0.1:3000)
cd frontend && npm run dev cd frontend && npm run dev
# ===== 自托管与备份(WP-14) =====
.PHONY: selfhost-up
selfhost-up: ## 启动自托管档(API + PostgreSQL,docker compose)
docker compose -f docker/docker-compose.app.yml up -d --build
.PHONY: selfhost-down
selfhost-down: ## 停止自托管档
docker compose -f docker/docker-compose.app.yml down
.PHONY: db-dump
db-dump: ## 备份数据库到 backups/hunjing-<时间戳>.sql.gz
@mkdir -p backups
@docker exec hunjing-postgres pg_dump -U hunjing -d hunjing | gzip > backups/hunjing-$$(date +%Y%m%d-%H%M%S).sql.gz
@echo "备份完成:backups/"
.PHONY: db-restore
db-restore: ## 从最新备份恢复(DB=备份文件路径,make db-restore FILE=backups/xxx.sql.gz)
@gzip -dc $(FILE) | docker exec -i hunjing-postgres psql -U hunjing -d hunjing
@echo "恢复完成"
# ===== 帮助 ===== # ===== 帮助 =====
.PHONY: help .PHONY: help
@@ -0,0 +1,47 @@
"""插件沙箱执行记录
Revision ID: 4e679f11da46
Revises: 89a4ed9647ee
Create Date: 2026-09-12 13:33:43.279920
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4e679f11da46'
down_revision: str | Sequence[str] | None = '89a4ed9647ee'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('plugin_execution',
sa.Column('id', sa.String(), nullable=False),
sa.Column('installation_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('exit_code', sa.Integer(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('stdout_excerpt', sa.String(), nullable=True),
sa.Column('error', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('running', 'succeeded', 'failed', 'timeout', 'killed')", name=op.f('ck_plugin_execution_status_values')),
sa.ForeignKeyConstraint(['installation_id'], ['plugin_installation.id'], name=op.f('fk_plugin_execution_plugin_installation'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_execution'))
)
op.create_index(op.f('ix_plugin_execution_installation_id'), 'plugin_execution', ['installation_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_plugin_execution_installation_id'), table_name='plugin_execution')
op.drop_table('plugin_execution')
# ### end Alembic commands ###
@@ -0,0 +1,99 @@
"""媒体:上传、文件、任务与声契
Revision ID: 52be875c14a4
Revises: f7bf2b464f1f
Create Date: 2026-09-12 10:47:53.573208
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '52be875c14a4'
down_revision: str | Sequence[str] | None = 'f7bf2b464f1f'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('media_job',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('input', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('output', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('progress', sa.Integer(), nullable=False),
sa.Column('error', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("kind IN ('asr', 'tts', 'image_gen', 'image_edit')", name=op.f('ck_media_job_kind_values')),
sa.CheckConstraint("status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')", name=op.f('ck_media_job_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_media_job_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_media_job'))
)
op.create_index(op.f('ix_media_job_domain_id'), 'media_job', ['domain_id'], unique=False)
op.create_table('upload',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('media_type', sa.String(), nullable=False),
sa.Column('size_limit', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'completed', 'failed')", name=op.f('ck_upload_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_upload_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_upload'))
)
op.create_index(op.f('ix_upload_domain_id'), 'upload', ['domain_id'], unique=False)
op.create_table('file_asset',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('upload_id', sa.String(), nullable=True),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('media_type', sa.String(), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('sha256', sa.String(), nullable=True),
sa.Column('storage_key', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'ready', 'failed', 'quarantined')", name=op.f('ck_file_asset_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_file_asset_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['upload_id'], ['upload.id'], name=op.f('fk_file_asset_upload'), ondelete='set null'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_asset'))
)
op.create_index(op.f('ix_file_asset_domain_id'), 'file_asset', ['domain_id'], unique=False)
op.create_table('voice_consent',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('purpose', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_voice_consent_character'), ondelete='cascade'),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_voice_consent_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_voice_consent'))
)
op.create_index(op.f('ix_voice_consent_domain_id'), 'voice_consent', ['domain_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_voice_consent_domain_id'), table_name='voice_consent')
op.drop_table('voice_consent')
op.drop_index(op.f('ix_file_asset_domain_id'), table_name='file_asset')
op.drop_table('file_asset')
op.drop_index(op.f('ix_upload_domain_id'), table_name='upload')
op.drop_table('upload')
op.drop_index(op.f('ix_media_job_domain_id'), table_name='media_job')
op.drop_table('media_job')
# ### end Alembic commands ###
@@ -0,0 +1,49 @@
"""诊断授权与管理员角色
Revision ID: 89a4ed9647ee
Revises: 52be875c14a4
Create Date: 2026-09-12 11:14:22.131144
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '89a4ed9647ee'
down_revision: str | Sequence[str] | None = '52be875c14a4'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('diagnostic_grant',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('purpose', sa.String(), nullable=False),
sa.Column('token_hash', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_diagnostic_grant_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_diagnostic_grant_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_diagnostic_grant')),
sa.UniqueConstraint('token_hash', name=op.f('uq_diagnostic_grant_token_hash'))
)
op.create_index(op.f('ix_diagnostic_grant_run_id'), 'diagnostic_grant', ['run_id'], unique=False)
op.add_column('user', sa.Column('role', sa.String(), nullable=False, server_default='user'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'role')
op.drop_index(op.f('ix_diagnostic_grant_run_id'), table_name='diagnostic_grant')
op.drop_table('diagnostic_grant')
# ### end Alembic commands ###
@@ -0,0 +1,107 @@
"""市场交易:商品、订单、账本与 Webhook
Revision ID: b7f1a0032451
Revises: b3a26cb61923
Create Date: 2026-09-11 11:31:22.345265
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b7f1a0032451'
down_revision: str | Sequence[str] | None = 'b3a26cb61923'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('webhook_event',
sa.Column('provider_event_id', sa.String(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('payload_hash', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('processed', 'ignored')", name=op.f('ck_webhook_event_status_values')),
sa.PrimaryKeyConstraint('provider_event_id', name=op.f('pk_webhook_event'))
)
op.create_table('listing',
sa.Column('id', sa.String(), nullable=False),
sa.Column('creator_domain_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('price_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('price_minor >= 0', name=op.f('ck_listing_price_non_negative')),
sa.ForeignKeyConstraint(['creator_domain_id'], ['personal_domain.id'], name=op.f('fk_listing_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_listing'))
)
op.create_index(op.f('ix_listing_creator_domain_id'), 'listing', ['creator_domain_id'], unique=False)
op.create_table('order',
sa.Column('id', sa.String(), nullable=False),
sa.Column('buyer_domain_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('total_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('idempotency_key', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending_payment', 'paid', 'partially_refunded', 'refunded', 'disputed', 'cancelled')", name=op.f('ck_order_status_values')),
sa.ForeignKeyConstraint(['buyer_domain_id'], ['personal_domain.id'], name=op.f('fk_order_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_order'))
)
op.create_index(op.f('ix_order_buyer_domain_id'), 'order', ['buyer_domain_id'], unique=False)
op.create_index('uq_order_buyer_idempotency', 'order', ['buyer_domain_id', 'idempotency_key'], unique=True)
op.create_table('ledger_entry',
sa.Column('id', sa.String(), nullable=False),
sa.Column('order_id', sa.String(), nullable=False),
sa.Column('entry_type', sa.String(), nullable=False),
sa.Column('amount_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('note', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("entry_type IN ('charge', 'refund', 'platform_fee', 'creator_payable', 'tax', 'reserve', 'payout', 'reversal')", name=op.f('ck_ledger_entry_entry_type_values')),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_ledger_entry_order'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_ledger_entry'))
)
op.create_index(op.f('ix_ledger_entry_order_id'), 'ledger_entry', ['order_id'], unique=False)
op.create_index('ix_ledger_order_type', 'ledger_entry', ['order_id', 'entry_type'], unique=False)
op.create_table('order_item',
sa.Column('id', sa.String(), nullable=False),
sa.Column('order_id', sa.String(), nullable=False),
sa.Column('listing_id', sa.String(), nullable=False),
sa.Column('title_snapshot', sa.String(), nullable=False),
sa.Column('price_minor_snapshot', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['listing_id'], ['listing.id'], name=op.f('fk_order_item_listing'), ondelete='restrict'),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_order_item_order'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_order_item'))
)
op.create_index(op.f('ix_order_item_order_id'), 'order_item', ['order_id'], unique=False)
op.add_column('personal_domain', sa.Column('billing_status', sa.String(), nullable=False, server_default='ok'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('personal_domain', 'billing_status')
op.drop_index(op.f('ix_order_item_order_id'), table_name='order_item')
op.drop_table('order_item')
op.drop_index('ix_ledger_order_type', table_name='ledger_entry')
op.drop_index(op.f('ix_ledger_entry_order_id'), table_name='ledger_entry')
op.drop_table('ledger_entry')
op.drop_index('uq_order_buyer_idempotency', table_name='order')
op.drop_index(op.f('ix_order_buyer_domain_id'), table_name='order')
op.drop_table('order')
op.drop_index(op.f('ix_listing_creator_domain_id'), table_name='listing')
op.drop_table('listing')
op.drop_table('webhook_event')
# ### end Alembic commands ###
@@ -0,0 +1,207 @@
"""协作空间:成员、邀请、项目与评审
Revision ID: bd36c0a63816
Revises: b7f1a0032451
Create Date: 2026-09-12 09:31:43.277430
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'bd36c0a63816'
down_revision: str | Sequence[str] | None = 'b7f1a0032451'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('follow',
sa.Column('id', sa.String(), nullable=False),
sa.Column('follower_domain_id', sa.String(), nullable=False),
sa.Column('followee_domain_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['followee_domain_id'], ['personal_domain.id'], name='fk_follow_followee_domain', ondelete='cascade'),
sa.ForeignKeyConstraint(['follower_domain_id'], ['personal_domain.id'], name='fk_follow_follower_domain', ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_follow'))
)
op.create_index(op.f('ix_follow_followee_domain_id'), 'follow', ['followee_domain_id'], unique=False)
op.create_index(op.f('ix_follow_follower_domain_id'), 'follow', ['follower_domain_id'], unique=False)
op.create_index('uq_follow_pair', 'follow', ['follower_domain_id', 'followee_domain_id'], unique=True)
op.create_table('report',
sa.Column('id', sa.String(), nullable=False),
sa.Column('reporter_domain_id', sa.String(), nullable=False),
sa.Column('target_type', sa.String(), nullable=False),
sa.Column('target_id', sa.String(), nullable=False),
sa.Column('reason', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'reviewed', 'actioned')", name=op.f('ck_report_status_values')),
sa.CheckConstraint("target_type IN ('content', 'comment')", name=op.f('ck_report_target_type_values')),
sa.ForeignKeyConstraint(['reporter_domain_id'], ['personal_domain.id'], name=op.f('fk_report_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_report'))
)
op.create_index(op.f('ix_report_reporter_domain_id'), 'report', ['reporter_domain_id'], unique=False)
op.create_table('workspace',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('owner_domain_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace'))
)
op.create_index(op.f('ix_workspace_owner_domain_id'), 'workspace', ['owner_domain_id'], unique=False)
op.create_table('community_content',
sa.Column('id', sa.String(), nullable=False),
sa.Column('author_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('visibility', sa.String(), nullable=False),
sa.Column('status', sa.String(), 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.CheckConstraint("kind IN ('post', 'character_release')", name=op.f('ck_community_content_kind_values')),
sa.CheckConstraint("status IN ('draft', 'submitted', 'published', 'limited', 'suspended', 'removed')", name=op.f('ck_community_content_status_values')),
sa.CheckConstraint("visibility IN ('public', 'followers')", name=op.f('ck_community_content_visibility_values')),
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_community_content_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_community_content_character'), ondelete='set null'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_community_content'))
)
op.create_index(op.f('ix_community_content_author_domain_id'), 'community_content', ['author_domain_id'], unique=False)
op.create_table('moderation_case',
sa.Column('id', sa.String(), nullable=False),
sa.Column('report_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('action_taken', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'resolved')", name=op.f('ck_moderation_case_status_values')),
sa.ForeignKeyConstraint(['report_id'], ['report.id'], name=op.f('fk_moderation_case_report'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_moderation_case'))
)
op.create_index(op.f('ix_moderation_case_report_id'), 'moderation_case', ['report_id'], unique=False)
op.create_table('project',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('current_version_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('draft', 'published')", name=op.f('ck_project_status_values')),
sa.ForeignKeyConstraint(['current_version_id'], ['project_version.id'], name=op.f('fk_project_project_version'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_project_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_project'))
)
op.create_index(op.f('ix_project_workspace_id'), 'project', ['workspace_id'], unique=False)
op.create_table('workspace_invitation',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('invitee_domain_id', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role IN ('editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_invitation_role_values')),
sa.CheckConstraint("status IN ('pending', 'accepted', 'declined', 'expired')", name=op.f('ck_workspace_invitation_status_values')),
sa.ForeignKeyConstraint(['invitee_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_invitation_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_invitation_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_invitation'))
)
op.create_index(op.f('ix_workspace_invitation_workspace_id'), 'workspace_invitation', ['workspace_id'], unique=False)
op.create_table('workspace_member',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('member_domain_id', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role IN ('owner', 'editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_member_role_values')),
sa.ForeignKeyConstraint(['member_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_member_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_member_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_member'))
)
op.create_index(op.f('ix_workspace_member_workspace_id'), 'workspace_member', ['workspace_id'], unique=False)
op.create_index('uq_workspace_member', 'workspace_member', ['workspace_id', 'member_domain_id'], unique=True)
op.create_table('comment',
sa.Column('id', sa.String(), nullable=False),
sa.Column('content_id', sa.String(), nullable=False),
sa.Column('author_domain_id', sa.String(), nullable=False),
sa.Column('parent_comment_id', sa.String(), nullable=True),
sa.Column('body', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('visible', 'removed')", name=op.f('ck_comment_status_values')),
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_comment_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_comment_community_content'), ondelete='cascade'),
sa.ForeignKeyConstraint(['parent_comment_id'], ['comment.id'], name=op.f('fk_comment_comment'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_comment'))
)
op.create_index(op.f('ix_comment_content_id'), 'comment', ['content_id'], unique=False)
op.create_table('project_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('project_id', sa.String(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('submitted_by', sa.String(), nullable=True),
sa.Column('reviewed_by', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('draft', 'submitted', 'approved', 'rejected')", name=op.f('ck_project_version_status_values')),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], name=op.f('fk_project_version_project'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_project_version'))
)
op.create_index(op.f('ix_project_version_project_id'), 'project_version', ['project_id'], unique=False)
op.create_index('uq_project_version_number', 'project_version', ['project_id', 'version'], unique=True)
op.create_table('reaction',
sa.Column('id', sa.String(), nullable=False),
sa.Column('content_id', sa.String(), nullable=False),
sa.Column('actor_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('like', 'save')", name=op.f('ck_reaction_kind_values')),
sa.ForeignKeyConstraint(['actor_domain_id'], ['personal_domain.id'], name=op.f('fk_reaction_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_reaction_community_content'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reaction'))
)
op.create_index(op.f('ix_reaction_content_id'), 'reaction', ['content_id'], unique=False)
op.create_index('uq_reaction_content_actor_kind', 'reaction', ['content_id', 'actor_domain_id', 'kind'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_reaction_content_actor_kind', table_name='reaction')
op.drop_index(op.f('ix_reaction_content_id'), table_name='reaction')
op.drop_table('reaction')
op.drop_index('uq_project_version_number', table_name='project_version')
op.drop_index(op.f('ix_project_version_project_id'), table_name='project_version')
op.drop_table('project_version')
op.drop_index(op.f('ix_comment_content_id'), table_name='comment')
op.drop_table('comment')
op.drop_index('uq_workspace_member', table_name='workspace_member')
op.drop_index(op.f('ix_workspace_member_workspace_id'), table_name='workspace_member')
op.drop_table('workspace_member')
op.drop_index(op.f('ix_workspace_invitation_workspace_id'), table_name='workspace_invitation')
op.drop_table('workspace_invitation')
op.drop_index(op.f('ix_project_workspace_id'), table_name='project')
op.drop_table('project')
op.drop_index(op.f('ix_moderation_case_report_id'), table_name='moderation_case')
op.drop_table('moderation_case')
op.drop_index(op.f('ix_community_content_author_domain_id'), table_name='community_content')
op.drop_table('community_content')
op.drop_index(op.f('ix_workspace_owner_domain_id'), table_name='workspace')
op.drop_table('workspace')
op.drop_index(op.f('ix_report_reporter_domain_id'), table_name='report')
op.drop_table('report')
op.drop_index('uq_follow_pair', table_name='follow')
op.drop_index(op.f('ix_follow_follower_domain_id'), table_name='follow')
op.drop_index(op.f('ix_follow_followee_domain_id'), table_name='follow')
op.drop_table('follow')
# ### end Alembic commands ###
@@ -0,0 +1,72 @@
"""Avatar 模型与非破坏命令历史
Revision ID: e66d791926f2
Revises: 4e679f11da46
Create Date: 2026-09-14 10:24:23.502856
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e66d791926f2'
down_revision: str | Sequence[str] | None = '4e679f11da46'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('avatar_model',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('format', sa.String(), nullable=False),
sa.Column('file_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('validation_report', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('source_info', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('license_terms', sa.String(), nullable=True),
sa.Column('current_command_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("format IN ('live2d', 'vrm', 'gltf')", name=op.f('ck_avatar_model_format_values')),
sa.CheckConstraint("status IN ('pending', 'validated', 'rejected')", name=op.f('ck_avatar_model_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_avatar_model_character'), ondelete='set null'),
sa.ForeignKeyConstraint(['current_command_id'], ['avatar_command.id'], name=op.f('fk_avatar_model_avatar_command'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_avatar_model_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['file_id'], ['file_asset.id'], name=op.f('fk_avatar_model_file_asset'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_model'))
)
op.create_index(op.f('ix_avatar_model_domain_id'), 'avatar_model', ['domain_id'], unique=False)
op.create_table('avatar_command',
sa.Column('id', sa.String(), nullable=False),
sa.Column('avatar_id', sa.String(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('command_type', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('undone', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("command_type IN ('param_map', 'expression', 'physics', 'motion', 'material', 'texture', 'bone', 'attachment', 'lighting', 'camera')", name=op.f('ck_avatar_command_command_type_values')),
sa.ForeignKeyConstraint(['avatar_id'], ['avatar_model.id'], name=op.f('fk_avatar_command_avatar_model'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_command'))
)
op.create_index(op.f('ix_avatar_command_avatar_id'), 'avatar_command', ['avatar_id'], unique=False)
op.create_index('uq_avatar_command_seq', 'avatar_command', ['avatar_id', 'sequence'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_avatar_command_seq', table_name='avatar_command')
op.drop_index(op.f('ix_avatar_command_avatar_id'), table_name='avatar_command')
op.drop_table('avatar_command')
op.drop_index(op.f('ix_avatar_model_domain_id'), table_name='avatar_model')
op.drop_table('avatar_model')
# ### end Alembic commands ###
@@ -0,0 +1,82 @@
"""插件生态:Registry、版本与安装
Revision ID: f7bf2b464f1f
Revises: bd36c0a63816
Create Date: 2026-09-12 10:15:58.892690
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f7bf2b464f1f'
down_revision: str | Sequence[str] | None = 'bd36c0a63816'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('plugin',
sa.Column('name', sa.String(), nullable=False),
sa.Column('publisher_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('declarative', 'remote', 'isolated_process', 'container')", name=op.f('ck_plugin_kind_values')),
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_status_values')),
sa.ForeignKeyConstraint(['publisher_domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('name', name=op.f('pk_plugin'))
)
op.create_index(op.f('ix_plugin_publisher_domain_id'), 'plugin', ['publisher_domain_id'], unique=False)
op.create_table('plugin_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('plugin_name', sa.String(), nullable=False),
sa.Column('version', sa.String(), nullable=False),
sa.Column('manifest', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('content_hash', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_version_status_values')),
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_version_plugin'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_version'))
)
op.create_index(op.f('ix_plugin_version_plugin_name'), 'plugin_version', ['plugin_name'], unique=False)
op.create_index('uq_plugin_version', 'plugin_version', ['plugin_name', 'version'], unique=True)
op.create_table('plugin_installation',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('plugin_name', sa.String(), nullable=False),
sa.Column('version_id', sa.String(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('granted_permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_installation_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_installation_plugin'), ondelete='cascade'),
sa.ForeignKeyConstraint(['version_id'], ['plugin_version.id'], name=op.f('fk_plugin_installation_plugin_version'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_installation'))
)
op.create_index(op.f('ix_plugin_installation_domain_id'), 'plugin_installation', ['domain_id'], unique=False)
op.create_index('uq_plugin_installation', 'plugin_installation', ['domain_id', 'plugin_name'], unique=True)
op.create_foreign_key(op.f('fk_project_project_version'), 'project', 'project_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_project_project_version'), 'project', type_='foreignkey')
op.drop_index('uq_plugin_installation', table_name='plugin_installation')
op.drop_index(op.f('ix_plugin_installation_domain_id'), table_name='plugin_installation')
op.drop_table('plugin_installation')
op.drop_index('uq_plugin_version', table_name='plugin_version')
op.drop_index(op.f('ix_plugin_version_plugin_name'), table_name='plugin_version')
op.drop_table('plugin_version')
op.drop_index(op.f('ix_plugin_publisher_domain_id'), table_name='plugin')
op.drop_table('plugin')
# ### end Alembic commands ###
+18 -2
View File
@@ -6,7 +6,7 @@ from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import SESSION_COOKIE, resolve_session from app.auth.service import SESSION_COOKIE, resolve_session
from app.db.session import get_db from app.db.session import DbSession, get_db
from app.models import User from app.models import User
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源 ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
@@ -33,10 +33,11 @@ async def get_current_user(
async def require_state_change( async def require_state_change(
request: Request, request: Request,
db: DbSession,
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None, hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
) -> User: ) -> User:
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。""" """Cookie 状态变更:Origin 校验 + CSRF 双提交 + 保全态拦截(契约 2.1 / ADR-021)。"""
if ALLOWED_ORIGINS: if ALLOWED_ORIGINS:
origin = request.headers.get("Origin") origin = request.headers.get("Origin")
if origin is not None and origin not in ALLOWED_ORIGINS: if origin is not None and origin not in ALLOWED_ORIGINS:
@@ -44,6 +45,21 @@ async def require_state_change(
csrf_hash = getattr(request.state, "session_csrf_hash", None) csrf_hash = getattr(request.state, "session_csrf_hash", None)
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token): if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败") raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
# 保全态:欠费只读保全,状态变更拒绝(数据不删、恢复即解锁)
from sqlalchemy import select
from app.models import PersonalDomain
domain = (
await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
).scalar_one_or_none()
if domain is not None and domain.billing_status == "preserved":
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"账户处于保全态(只读):恢复付费后自动解锁,数据不会删除",
)
return user return user
+1
View File
@@ -0,0 +1 @@
"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。"""
+119
View File
@@ -0,0 +1,119 @@
"""Avatar 域路由(WP-09)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.avatars import service
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import AvatarModel, User
router = APIRouter(prefix="/v1/avatars", tags=["avatars"])
def _model_out(m: AvatarModel) -> dict[str, Any]:
return {
"id": m.id, "name": m.name, "format": m.format, "status": m.status,
"file_id": m.file_id, "validation_report": m.validation_report,
"character_id": m.character_id, "current_command_id": m.current_command_id,
"created_at": m.created_at.isoformat(),
}
def _command_out(c: Any) -> dict[str, Any]:
return {
"id": c.id, "avatar_id": c.avatar_id, "sequence": c.sequence,
"command_type": c.command_type, "payload": c.payload, "undone": c.undone,
"created_at": c.created_at.isoformat(),
}
@router.post("/imports", status_code=201)
async def import_avatar(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
model = await service.import_avatar(
db, domain.id, body.get("name", ""), body.get("format", ""),
body.get("file_id", ""), getattr(request.state, "trace_id", None),
)
return _model_out(model)
@router.get("")
async def list_avatars(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
rows = list(
(
await db.scalars(
select(AvatarModel)
.where(AvatarModel.domain_id == domain.id)
.order_by(AvatarModel.created_at.asc())
)
).all()
)
return {"data": [_model_out(m) for m in rows]}
@router.post("/{avatar_id}/commands", status_code=201)
async def append_command(
avatar_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
command = await service.append_command(
db, domain.id, avatar_id, body.get("command_type", ""),
body.get("payload", {}), getattr(request.state, "trace_id", None),
)
return _command_out(command)
@router.post("/{avatar_id}/commands/{command_id}/undoings")
async def undo_command(
avatar_id: str,
command_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
command = await service.undo_command(
db, domain.id, avatar_id, command_id, getattr(request.state, "trace_id", None)
)
return _command_out(command)
@router.get("/{avatar_id}/commands")
async def list_commands(
avatar_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
from app.models import AvatarCommand
domain = await _domain_of(db, user)
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
rows = list(
(
await db.scalars(
select(AvatarCommand)
.where(AvatarCommand.avatar_id == avatar_id)
.order_by(AvatarCommand.sequence.asc())
)
).all()
)
return {"data": [_command_out(c) for c in rows]}
+357
View File
@@ -0,0 +1,357 @@
"""Avatar 域:Live2D/VRM/glTF 导入校验与非破坏命令历史(WP-09)。
校验器纯 Python 实现,输入为文件字节;全部按不可信输入处理
(恶意模型文件防线:magic/结构/引用/资源计数硬上限)。
"""
import json
import struct
from dataclasses import dataclass, field
from typing import Any
from fastapi import HTTPException, status
# 资源硬上限(契约第 7 节:限额由配置发布但 Schema 有硬上限;此处取工程默认)
MAX_FILE_BYTES = 200 * 1024 * 1024
MAX_NODES = 100_000
MAX_MESHES = 10_000
MAX_TEXTURES = 2_000
MAX_MATERIALS = 5_000
MAX_ANIMATIONS = 1_000
GLB_MAGIC = 0x46546C67 # 'glTF'
CHUNK_TYPE_JSON = 0x4E4F534A
CHUNK_TYPE_BIN = 0x004E4942
@dataclass
class ValidationReport:
format: str = ""
valid: bool = False
errors: list[str] = field(default_factory=list)
stats: dict[str, int] = field(default_factory=dict)
vrm_version: str | None = None
vrm_meta: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"format": self.format, "valid": self.valid, "errors": self.errors,
"stats": self.stats,
}
if self.vrm_version:
out["vrm_version"] = self.vrm_version
if self.vrm_meta:
out["vrm_meta"] = self.vrm_meta
return out
def _limit_check(report: ValidationReport, gltf: dict[str, Any]) -> bool:
stats = {
"nodes": len(gltf.get("nodes", [])),
"meshes": len(gltf.get("meshes", [])),
"textures": len(gltf.get("textures", [])),
"materials": len(gltf.get("materials", [])),
"animations": len(gltf.get("animations", [])),
}
report.stats = stats
limits = {
"nodes": MAX_NODES, "meshes": MAX_MESHES, "textures": MAX_TEXTURES,
"materials": MAX_MATERIALS, "animations": MAX_ANIMATIONS,
}
ok = True
for key, cap in limits.items():
if stats[key] > cap:
report.errors.append(f"{key} 数 {stats[key]} 超过上限 {cap}")
ok = False
return ok
def _check_references(report: ValidationReport, gltf: dict[str, Any]) -> bool:
"""场景图引用一致性:node.mesh/mesh.material/material 指向有效索引。"""
meshes = len(gltf.get("meshes", []))
materials = len(gltf.get("materials", []))
scenes = gltf.get("scenes", [])
if not scenes:
report.errors.append("缺少 scenes")
return False
for i, scene in enumerate(scenes):
for node_idx in scene.get("nodes", []):
if node_idx >= len(gltf.get("nodes", [])):
report.errors.append(f"scene[{i}] 引用不存在的 node {node_idx}")
return False
for i, node in enumerate(gltf.get("nodes", [])):
mesh_idx = node.get("mesh")
if mesh_idx is not None and mesh_idx >= meshes:
report.errors.append(f"node[{i}] 引用不存在的 mesh {mesh_idx}")
return False
for i, mesh in enumerate(gltf.get("meshes", [])):
for prim in mesh.get("primitives", []):
mat_idx = prim.get("material")
if mat_idx is not None and mat_idx >= materials:
report.errors.append(f"mesh[{i}] 引用不存在的 material {mat_idx}")
return False
return True
def validate_glb(data: bytes) -> ValidationReport:
"""GLB 二进制格式校验:magic、版本、chunk 结构、JSON 场景图。"""
report = ValidationReport(format="gltf")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
if len(data) < 12:
report.errors.append("文件不足 GLB 头部长度(12 字节)")
return report
magic, version, length = struct.unpack_from("<III", data, 0)
if magic != GLB_MAGIC:
report.errors.append("magic 不符:不是 GLB 文件")
return report
if version != 2:
report.errors.append(f"glTF 容器版本 {version},仅支持 2")
return report
if length != len(data):
report.errors.append(f"声明长度 {length} 与实际 {len(data)} 不符")
return report
# JSON chunk
if len(data) < 20:
report.errors.append("缺少 JSON chunk 头")
return report
json_len, json_type = struct.unpack_from("<II", data, 12)
if json_type != CHUNK_TYPE_JSON:
report.errors.append("首 chunk 不是 JSON 类型")
return report
json_bytes = data[20 : 20 + json_len]
if len(json_bytes) < json_len:
report.errors.append("JSON chunk 截断")
return report
try:
gltf = json.loads(json_bytes.rstrip(b"\x00 \t\r\n"))
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
# VRM 扩展检测
extensions_used = gltf.get("extensionsUsed", [])
extensions = gltf.get("extensions", {})
if "VRMC_vrm" in extensions_used or "VRMC_vrm" in extensions:
report.format = "vrm"
report.vrm_version = "1.0"
report.vrm_meta = dict(extensions.get("VRMC_vrm", {}).get("meta", {}))
elif "VRM" in extensions_used or "VRM" in extensions:
report.format = "vrm"
report.vrm_version = "0.x"
report.vrm_meta = dict(extensions.get("VRM", {}).get("meta", {}))
# 结构与上限
ok = _check_references(report, gltf) and _limit_check(report, gltf)
report.valid = ok and not report.errors
return report
def validate_gltf_json(data: bytes) -> ValidationReport:
"""纯 .gltf(JSON)校验。"""
report = ValidationReport(format="gltf")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
try:
gltf = json.loads(data)
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
if not isinstance(gltf, dict) or "asset" not in gltf:
report.errors.append("缺少 asset 段(glTF 2.0 必需)")
return report
ok = _check_references(report, gltf) and _limit_check(report, gltf)
report.valid = ok and not report.errors
return report
# Cubism model3.json 必需字段与引用文件类别
_CUBISM_REQUIRED = {"Version", "FileReferences"}
_CUBISM_FILE_KEYS = {" moc3": "moc", ".model.json": "physics", }
def validate_cubism(data: bytes) -> ValidationReport:
"""Cubism model3.json 校验:必需字段、 moc3 引用、组引用一致性。"""
report = ValidationReport(format="live2d")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
try:
model = json.loads(data)
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
if not isinstance(model, dict):
report.errors.append("根不是对象")
return report
for key in _CUBISM_REQUIRED:
if key not in model:
report.errors.append(f"缺少必需字段 {key}")
refs = model.get("FileReferences", {})
if not isinstance(refs, dict):
report.errors.append("FileReferences 不是对象")
refs = {}
moc = refs.get("moc")
if not moc or not str(moc).endswith(".moc3"):
report.errors.append("缺少 moc3 模型文件引用")
textures = refs.get("Textures", [])
if not isinstance(textures, list) or not textures:
report.errors.append("缺少纹理引用列表")
# 参数区间引用(Expressions/Physics/Motions 仅做存在性结构检查)
stats = {
"textures": len(textures) if isinstance(textures, list) else 0,
"expressions": len(refs.get("Expressions", []) or []),
"motions": sum(
len(v) if isinstance(v, list) else 0
for v in (refs.get("Motions", {}) or {}).values()
),
"hit_areas": len(model.get("HitAreas", []) or []),
"parameters": len(model.get("Parameters", []) or []),
}
report.stats = stats
if stats["textures"] > MAX_TEXTURES:
report.errors.append(f"纹理数超过上限 {MAX_TEXTURES}")
report.valid = not report.errors
return report
def detect_and_validate(data: bytes, declared_format: str) -> ValidationReport:
"""格式探测 + 校验入口:magic 优先,声明辅助;二进制非 GLB 一律 magic 拒绝。"""
if data[:4] == b"glTF":
report = validate_glb(data)
elif declared_format == "live2d":
report = validate_cubism(data)
elif data[:1] not in (b"{", b"["):
# 声明 gltf 但内容是二进制且无 GLB magic:magic 不符
report = ValidationReport(format="gltf")
report.errors.append("magic 不符:不是 GLB/glTF 文件")
else:
report = validate_gltf_json(data)
if report.format != declared_format and declared_format != "":
report.errors.append(
f"声明格式 {declared_format} 与检测结果 {report.format} 不符"
)
report.valid = False
return report
async def import_avatar(
db: Any, domain_id: str, name: str, declared_format: str, file_id: str,
trace_id: str | None,
) -> Any:
"""导入 Avatar 模型:文件须 ready;校验失败入库 rejected(保留报告供排查)。"""
from app.media.service import require_file
from app.models import AuditEvent, AvatarModel
asset = await require_file(db, domain_id, file_id)
if asset.status != "ready":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "文件未就绪")
if declared_format not in {"live2d", "vrm", "gltf"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "format 取值不合法")
from pathlib import Path
from app.media.service import STORAGE_ROOT
path = Path(STORAGE_ROOT) / asset.storage_key
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "存储对象缺失")
data = path.read_bytes()
report = detect_and_validate(data, declared_format)
model = AvatarModel(
domain_id=domain_id,
name=name if name.strip() else asset.filename,
format=declared_format,
file_id=file_id,
status="validated" if report.valid else "rejected",
validation_report=report.to_dict(),
)
db.add(model)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.imported",
aggregate_type="avatar_model", aggregate_id=model.id,
payload={"valid": report.valid, "errors": report.errors[:10]},
trace_id=trace_id,
)
)
await db.flush()
return model
async def append_command(
db: Any, domain_id: str, avatar_id: str, command_type: str, payload: dict[str, Any],
trace_id: str | None,
) -> Any:
"""追加非破坏编辑命令:sequence 单调递增;rejected 模型不接受编辑。"""
from sqlalchemy import select
from app.models import AuditEvent, AvatarCommand, AvatarModel
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
if model.status != "validated":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅已验证模型可编辑")
valid_types = {
"param_map", "expression", "physics", "motion", "material", "texture",
"bone", "attachment", "lighting", "camera",
}
if command_type not in valid_types:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"command_type 取值不合法:{command_type}",
)
last = (
await db.execute(
select(AvatarCommand.sequence)
.where(AvatarCommand.avatar_id == avatar_id)
.order_by(AvatarCommand.sequence.desc())
.limit(1)
)
).scalar_one_or_none()
command = AvatarCommand(
avatar_id=avatar_id,
sequence=(last + 1) if last is not None else 1,
command_type=command_type,
payload=payload,
)
db.add(command)
await db.flush()
model.current_command_id = command.id
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.command_appended",
aggregate_type="avatar_model", aggregate_id=avatar_id,
payload={"sequence": command.sequence, "type": command_type},
trace_id=trace_id,
)
)
await db.flush()
return command
async def undo_command(
db: Any, domain_id: str, avatar_id: str, command_id: str, trace_id: str | None,
) -> Any:
"""撤销最近命令(标记 undone,不物理删除——非破坏历史)。"""
from app.models import AuditEvent, AvatarCommand, AvatarModel
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
command = await db.get(AvatarCommand, command_id)
if command is None or command.avatar_id != avatar_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "命令不存在")
if command.undone:
return command # 幂等
command.undone = True
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.command_undone",
aggregate_type="avatar_model", aggregate_id=avatar_id,
payload={"sequence": command.sequence}, trace_id=trace_id,
)
)
await db.flush()
return command
+1
View File
@@ -0,0 +1 @@
"""社区域:发布、互动、关注与治理。"""
+194
View File
@@ -0,0 +1,194 @@
"""社区域路由(WP-11)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request, status
from app.auth.dependencies import get_current_user, require_state_change
from app.community import service
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import User
router = APIRouter(prefix="/v1/community", tags=["community"])
def _content_out(c: Any, counts: dict[str, int] | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"id": c.id,
"kind": c.kind,
"title": c.title,
"body": c.body,
"visibility": c.visibility,
"status": c.status,
"author_domain_id": c.author_domain_id,
"created_at": c.created_at.isoformat(),
}
if counts is not None:
out["reactions"] = counts
return out
def _comment_out(c: Any) -> dict[str, Any]:
return {
"id": c.id,
"content_id": c.content_id,
"parent_comment_id": c.parent_comment_id,
"body": c.body,
"author_domain_id": c.author_domain_id,
"created_at": c.created_at.isoformat(),
}
@router.post("/contents", status_code=201)
async def publish(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
content = await service.create_content(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _content_out(content)
@router.get("/contents")
async def list_contents(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 20,
cursor: str | None = None,
) -> dict[str, Any]:
if not 1 <= limit <= 100:
from fastapi import HTTPException
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-100")
page, next_cursor, has_more = await service.list_public(db, limit, cursor)
return {
"data": [_content_out(c) for c in page],
"next_cursor": next_cursor,
"has_more": has_more,
}
@router.get("/contents/{content_id}")
async def get_content(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
content = await service.require_content(db, content_id)
counts = await service.reaction_counts(db, content_id)
return _content_out(content, counts)
@router.post("/contents/{content_id}/comments", status_code=201)
async def comment(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
created = await service.add_comment(
db, domain.id, content_id, body.get("body", ""), body.get("parent_comment_id"),
getattr(request.state, "trace_id", None),
)
return _comment_out(created)
@router.get("/contents/{content_id}/comments")
async def list_comments(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
rows = await service.list_comments(db, content_id)
return {"data": [_comment_out(c) for c in rows]}
@router.post("/contents/{content_id}/reactions", status_code=201)
async def react(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, str]:
domain = await _domain_of(db, user)
kind = body.get("kind", "")
if kind not in {"like", "save"}:
from fastapi import HTTPException
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
await service.react(db, domain.id, content_id, kind, None)
counts = await service.reaction_counts(db, content_id)
return {k: str(v) for k, v in counts.items()}
@router.delete("/contents/{content_id}/reactions", status_code=204)
async def unreact(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> None:
domain = await _domain_of(db, user)
await service.unreact(db, domain.id, content_id, body.get("kind", ""))
@router.post("/follows", status_code=201)
async def follow(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
from app.models import PersonalDomain
target = await db.get(PersonalDomain, body.get("followee_domain_id", ""))
if target is None:
from fastapi import HTTPException
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
await service.follow(db, domain.id, target.id)
return await service.follow_counts(db, domain.id)
@router.get("/follows/counts")
async def follow_counts(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.follow_counts(db, domain.id)
@router.post("/reports", status_code=201)
async def report(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
report_row, case = await service.file_report(
db, domain.id, body.get("target_type", ""), body.get("target_id", ""),
body.get("reason", ""), getattr(request.state, "trace_id", None),
)
return {"report_id": report_row.id, "case_id": case.id, "status": case.status}
@router.post("/contents/{content_id}/withdrawals", status_code=200)
async def withdraw(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
content = await service.withdraw_content(
db, domain.id, content_id, getattr(request.state, "trace_id", None)
)
return _content_out(content)
+306
View File
@@ -0,0 +1,306 @@
"""社区域:发布、互动、关注与治理(WP-11)。"""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
Comment,
CommunityContent,
Follow,
ModerationCase,
Reaction,
Report,
)
async def require_content(db: AsyncSession, content_id: str) -> CommunityContent:
"""公开读取:仅 published+可见;下架/草稿一律 404(不泄露存在性)。"""
content = await db.get(CommunityContent, content_id)
if content is None or content.status != "published":
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
return content
async def create_content(
db: AsyncSession,
domain_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> CommunityContent:
"""发布:帖子直接入库;角色 Release 由服务端构造公开快照。
Release 快照只含公开字段(name/道体摘要/版本数);私密记忆、关系、
对话在结构上不进入发布物(契约:公开 Release 不含私密数据)。
"""
kind = body.get("kind", "post")
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "title 不能为空")
payload: dict[str, Any] = {}
character_id = None
if kind == "character_release":
from app.characters.service import require_character
from app.models import CharacterVersion
character_id = body.get("character_id", "")
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜角色可发布")
version_count = len(
list(
(
await db.execute(
select(CharacterVersion.id).where(
CharacterVersion.character_id == character.id
)
)
).scalars().all()
)
)
payload = {
"character_name": character.name,
"public_baseline": character.identity_baseline,
"version_count": version_count,
}
elif kind == "post":
payload = {"text": str(body.get("text", ""))}
else:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
content = CommunityContent(
author_domain_id=domain_id,
kind=kind,
title=title,
body=payload,
character_id=character_id,
visibility=body.get("visibility", "public"),
status="published",
)
db.add(content)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.published",
aggregate_type="community_content", aggregate_id=content.id,
payload={"kind": kind}, trace_id=trace_id,
)
)
await db.flush()
return content
async def list_public(
db: AsyncSession, limit: int, cursor: str | None
) -> tuple[list[CommunityContent], str | None, bool]:
stmt = (
select(CommunityContent)
.where(CommunityContent.status == "published")
.order_by(CommunityContent.created_at.desc(), CommunityContent.id.desc())
)
if cursor:
stmt = stmt.where(CommunityContent.id < cursor)
stmt = stmt.limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
return page, next_cursor, has_more
async def add_comment(
db: AsyncSession,
domain_id: str,
content_id: str,
body_text: str,
parent_id: str | None,
trace_id: str | None,
) -> Comment:
content = await require_content(db, content_id)
if not body_text.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "评论不能为空")
if parent_id is not None:
parent = await db.get(Comment, parent_id)
if parent is None or parent.content_id != content.id or parent.status != "visible":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "父评论无效")
comment = Comment(
content_id=content.id,
author_domain_id=domain_id,
parent_comment_id=parent_id,
body=body_text,
)
db.add(comment)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.commented",
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
)
)
await db.flush()
return comment
async def list_comments(db: AsyncSession, content_id: str) -> list[Comment]:
await require_content(db, content_id) # 下架内容的评论随之不可见
return list(
(
await db.scalars(
select(Comment)
.where(Comment.content_id == content_id, Comment.status == "visible")
.order_by(Comment.created_at.asc())
)
).all()
)
async def react(
db: AsyncSession, domain_id: str, content_id: str, kind: str, trace_id: str | None
) -> Reaction:
content = await require_content(db, content_id)
existing = await db.execute(
select(Reaction).where(
Reaction.content_id == content.id,
Reaction.actor_domain_id == domain_id,
Reaction.kind == kind,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "已赞/已收藏")
reaction = Reaction(content_id=content.id, actor_domain_id=domain_id, kind=kind)
db.add(reaction)
await db.flush()
return reaction
async def unreact(db: AsyncSession, domain_id: str, content_id: str, kind: str) -> None:
content = await require_content(db, content_id)
existing = await db.execute(
select(Reaction).where(
Reaction.content_id == content.id,
Reaction.actor_domain_id == domain_id,
Reaction.kind == kind,
)
)
reaction = existing.scalar_one_or_none()
if reaction is not None:
await db.delete(reaction)
await db.flush()
async def reaction_counts(db: AsyncSession, content_id: str) -> dict[str, int]:
rows = (
await db.execute(
select(Reaction.kind, func.count())
.where(Reaction.content_id == content_id)
.group_by(Reaction.kind)
)
).all()
return {k: int(c) for k, c in rows}
async def follow(
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
) -> None:
if follower_domain_id == followee_domain_id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "不能关注自己")
existing = await db.execute(
select(Follow).where(
Follow.follower_domain_id == follower_domain_id,
Follow.followee_domain_id == followee_domain_id,
)
)
if existing.scalar_one_or_none() is None:
db.add(
Follow(follower_domain_id=follower_domain_id, followee_domain_id=followee_domain_id)
)
await db.flush()
async def unfollow(
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
) -> None:
existing = await db.execute(
select(Follow).where(
Follow.follower_domain_id == follower_domain_id,
Follow.followee_domain_id == followee_domain_id,
)
)
row = existing.scalar_one_or_none()
if row is not None:
await db.delete(row)
await db.flush()
async def follow_counts(db: AsyncSession, domain_id: str) -> dict[str, int]:
followers = (
await db.execute(
select(func.count()).select_from(Follow).where(
Follow.followee_domain_id == domain_id
)
)
).scalar_one()
following = (
await db.execute(
select(func.count()).select_from(Follow).where(
Follow.follower_domain_id == domain_id
)
)
).scalar_one()
return {"followers": int(followers), "following": int(following)}
async def file_report(
db: AsyncSession,
reporter_domain_id: str,
target_type: str,
target_id: str,
reason: str,
trace_id: str | None,
) -> tuple[Report, ModerationCase]:
"""举报即建司议案(治理队列入口);同对象同理由不重复立案。"""
if target_type not in {"content", "comment"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "target_type 不合法")
if not reason.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "reason 不能为空")
report = Report(
reporter_domain_id=reporter_domain_id,
target_type=target_type,
target_id=target_id,
reason=reason,
)
db.add(report)
await db.flush()
case = ModerationCase(report_id=report.id, status="pending")
db.add(case)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=reporter_domain_id, action="community.reported",
aggregate_type="moderation_case", aggregate_id=case.id, trace_id=trace_id,
)
)
await db.flush()
return report, case
async def withdraw_content(
db: AsyncSession, domain_id: str, content_id: str, trace_id: str | None
) -> CommunityContent:
"""作者下架:status=removed;列表/详情/评论随 require_content 一并不可见。"""
content = await db.get(CommunityContent, content_id)
if content is None or content.author_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
if content.status == "removed":
return content # 幂等
content.status = "removed"
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.withdrawn",
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
)
)
await db.flush()
return content
+2
View File
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
log_level: str = "info" log_level: str = "info"
api_port: int = 8000 api_port: int = 8000
database_url: str = "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing" database_url: str = "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing"
storage_root: str = "storage"
sandbox_enabled: bool = False # 未启用时隔离插件执行 fail-closed
@lru_cache @lru_cache
+21
View File
@@ -3,16 +3,25 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.auth.routes import router as auth_router from app.auth.routes import router as auth_router
from app.avatars.routes import router as avatars_router
from app.characters.routes import router as characters_router from app.characters.routes import router as characters_router
from app.community.routes import router as community_router
from app.conversations.routes import message_router, run_router from app.conversations.routes import message_router, run_router
from app.conversations.routes import router as conversations_router from app.conversations.routes import router as conversations_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.errors import install_error_handlers from app.core.errors import install_error_handlers
from app.core.middleware import RequestContextMiddleware, configure_logging from app.core.middleware import RequestContextMiddleware, configure_logging
from app.db.session import init_engine from app.db.session import init_engine
from app.marketplace.routes import finance_router, webhook_router
from app.marketplace.routes import router as marketplace_router
from app.media.routes import router as media_router
from app.memories.routes import cycle_router from app.memories.routes import cycle_router
from app.memories.routes import router as memories_router from app.memories.routes import router as memories_router
from app.ops.routes import admin_router, ops_router
from app.plugins.routes import inst_router, invoke_router
from app.plugins.routes import router as plugins_router
from app.tools.routes import calls_router, tools_router from app.tools.routes import calls_router, tools_router
from app.workspaces.routes import router as workspaces_router
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -37,6 +46,18 @@ def create_app() -> FastAPI:
app.include_router(memories_router) app.include_router(memories_router)
app.include_router(tools_router) app.include_router(tools_router)
app.include_router(calls_router) app.include_router(calls_router)
app.include_router(marketplace_router)
app.include_router(finance_router)
app.include_router(webhook_router)
app.include_router(community_router)
app.include_router(workspaces_router)
app.include_router(plugins_router)
app.include_router(inst_router)
app.include_router(invoke_router)
app.include_router(media_router)
app.include_router(avatars_router)
app.include_router(ops_router)
app.include_router(admin_router)
@app.get("/healthz", tags=["ops"]) @app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
+1
View File
@@ -0,0 +1 @@
"""交易域:商品、订单、账本、退款与 Webhook。"""
+169
View File
@@ -0,0 +1,169 @@
"""交易域路由(WP-13)。"""
import hashlib
import json
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.marketplace import service
from app.models import Listing, Order, User
router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
finance_router = APIRouter(prefix="/v1/finance", tags=["finance"])
webhook_router = APIRouter(prefix="/v1/webhooks/v1", tags=["webhooks"])
def _listing_out(item: Listing) -> dict[str, Any]:
return {
"id": item.id, "title": item.title, "price_minor": item.price_minor,
"currency": item.currency, "status": item.status,
}
def _order_out(o: Order) -> dict[str, Any]:
return {
"id": o.id, "status": o.status, "total_minor": o.total_minor,
"currency": o.currency,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"refunded_at": o.refunded_at.isoformat() if o.refunded_at else None,
}
@router.post("/listings", status_code=201)
async def create_listing(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
listing = await service.create_listing(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _listing_out(listing)
@router.get("/listings")
async def list_listings(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
rows = list((await db.scalars(select(Listing).limit(50))).all())
return {"data": [_listing_out(item) for item in rows]}
@router.post("/orders", status_code=201, response_model=None)
async def create_order(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict[str, Any] | JSONResponse:
if idempotency_key:
body = {**body, "idempotency_key": idempotency_key}
domain = await _domain_of(db, user)
order, replayed = await service.create_order(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
if replayed:
return JSONResponse(status_code=200, content=_order_out(order))
return _order_out(order)
@router.post("/orders/{order_id}/payment-confirmations", status_code=201)
async def confirm_payment(
order_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
order = await service.confirm_payment(
db, domain.id, order_id, getattr(request.state, "trace_id", None)
)
return _order_out(order)
@router.post("/orders/{order_id}/refunds", status_code=201)
async def refund(
order_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
order = await service.refund_order(
db, domain.id, order_id, getattr(request.state, "trace_id", None)
)
return _order_out(order)
@finance_router.get("/summary")
async def finance_summary(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.finance_summary(db, domain.id)
@finance_router.post("/preservation", response_model=None)
async def set_preservation(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
request: Request,
body: dict[str, bool],
) -> dict[str, str]:
"""保全态切换(欠费只读;开发档由计费系统触发,验证用)。
不经 require_state_change:保全期间必须能解除保全(否则自锁)。
"""
domain = await _domain_of(db, user)
value = await service.set_preservation(
db, domain.id, bool(body.get("preserved")), getattr(request.state, "trace_id", None)
)
return {"billing_status": value}
@webhook_router.post("/payments/mock", status_code=200)
async def payment_webhook(
request: Request,
db: DbSession,
x_signature: Annotated[str | None, Header(alias="X-Signature")] = None,
) -> dict[str, str]:
"""支付 Provider Webhook:先验签(原始字节),再按事件 ID 幂等处理。"""
raw = await request.body()
service.verify_webhook_signature(raw, x_signature)
try:
payload: dict[str, Any] = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "payload 不是 JSON") from exc
event_type = str(payload.get("type", "payment.succeeded"))
provider_event_id = str(payload.get("provider_event_id", ""))
order_id = str(payload.get("order_id", ""))
if not provider_event_id or not order_id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "缺少事件标识")
order = await db.get(Order, order_id)
if order is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
buyer_domain_id = order.buyer_domain_id
async def handler() -> None:
if event_type == "payment.succeeded":
await service.confirm_payment(db, buyer_domain_id, order_id, None)
elif event_type == "payment.refunded":
await service.refund_order(db, buyer_domain_id, order_id, None)
else:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "未知事件类型")
payload_hash = hashlib.sha256(raw).hexdigest()
result = await service.process_webhook(
db, "mock", provider_event_id, event_type, payload_hash,
handler, getattr(request.state, "trace_id", None),
)
return {"result": result}
+272
View File
@@ -0,0 +1,272 @@
"""交易域:商品、订单、不可变账本、退款与 Webhook(WP-13 / ADR-009/021)。"""
import hashlib
import hmac
import os
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
LedgerEntry,
Listing,
Order,
OrderItem,
OutboxEvent,
WebhookEvent,
)
PLATFORM_FEE_BPS = 1000 # 平台费 10%(基点;正式费率由运营决策门登记)
def _ledger_balance_check(entries: list[tuple[str, int]]) -> None:
"""平衡不变量:sum(charge+reversal) == sum(fee+payable 及其 reversal)。"""
left = sum(a for t, a in entries if t in {"charge", "reversal"})
right = sum(
a for t, a in entries
if t in {"platform_fee", "creator_payable", "refund"}
)
if left != right:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "账本不平衡:拒绝提交"
)
async def create_listing(
db: AsyncSession, domain_id: str, body: dict[str, Any], trace_id: str | None
) -> Listing:
listing = Listing(
creator_domain_id=domain_id,
title=str(body.get("title", "")),
price_minor=int(body.get("price_minor", 0)),
currency=str(body.get("currency", "CNY")),
)
if not listing.title.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "title 不能为空")
db.add(listing)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="listing.created",
aggregate_type="listing", aggregate_id=listing.id, trace_id=trace_id,
)
)
await db.flush()
return listing
async def create_order(
db: AsyncSession, buyer_domain_id: str, body: dict[str, Any], trace_id: str | None
) -> tuple[Order, bool]:
"""下单:价格快照冻结(客户端金额不可信);幂等键重放返回原订单。"""
key = body.get("idempotency_key")
if key:
existing = await db.execute(
select(Order).where(
Order.buyer_domain_id == buyer_domain_id,
Order.idempotency_key == key,
)
)
replay = existing.scalar_one_or_none()
if replay is not None:
return replay, True
listing_ids = body.get("listing_ids", [])
if not listing_ids:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "listing_ids 不能为空")
listings = list(
(
await db.execute(select(Listing).where(Listing.id.in_(listing_ids)))
).scalars().all()
)
if len(listings) != len(set(listing_ids)):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "存在无效商品")
total = sum(x.price_minor for x in listings)
currency = listings[0].currency
if any(x.currency != currency for x in listings):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "订单不能混合币种")
order = Order(
buyer_domain_id=buyer_domain_id,
total_minor=total,
currency=currency,
idempotency_key=key,
)
db.add(order)
await db.flush()
for listing in listings:
db.add(
OrderItem(
order_id=order.id,
listing_id=listing.id,
title_snapshot=listing.title,
price_minor_snapshot=listing.price_minor,
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.created",
aggregate_type="order", aggregate_id=order.id,
payload={"total_minor": total}, trace_id=trace_id,
)
)
await db.flush()
return order, False
async def _post_entries(
db: AsyncSession, order: Order, sign: int, note: str
) -> None:
"""写三分录(带符号):charge / platform_fee / creator_payable。
charge=fee+payable(双录平衡);退款以 sign=-1 写反向三条。
"""
fee = order.total_minor * PLATFORM_FEE_BPS // 10000
payable = order.total_minor - fee
entries = [
("charge" if sign > 0 else "reversal", order.total_minor * sign),
("platform_fee", fee * sign),
("creator_payable", payable * sign),
]
checked = [(t, a) for t, a in entries if a != 0]
_ledger_balance_check(checked)
for entry_type, amount in checked:
db.add(
LedgerEntry(
order_id=order.id, entry_type=entry_type,
amount_minor=amount, currency=order.currency,
note=note,
)
)
async def confirm_payment(
db: AsyncSession, buyer_domain_id: str, order_id: str, trace_id: str | None
) -> Order:
"""支付确认(开发档 Provider;真实 Provider 经 Webhook 驱动同一入账)。"""
order = await db.get(Order, order_id)
if order is None or order.buyer_domain_id != buyer_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
if order.status == "paid":
return order # 幂等
if order.status != "pending_payment":
raise HTTPException(status.HTTP_409_CONFLICT, f"订单状态 {order.status} 不可支付")
order.status = "paid"
order.paid_at = datetime.now(UTC)
await _post_entries(db, order, +1, "payment confirmed")
db.add(
OutboxEvent(
aggregate_type="order", aggregate_id=order.id,
event_type="order.paid", payload={"order_id": order.id},
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.paid",
aggregate_type="order", aggregate_id=order.id, trace_id=trace_id,
)
)
await db.flush()
return order
async def refund_order(
db: AsyncSession, buyer_domain_id: str, order_id: str, trace_id: str | None
) -> Order:
"""全额退款:反向分录修正账本(Ledger 不可变,不删不改,ADR-009)。"""
order = await db.get(Order, order_id)
if order is None or order.buyer_domain_id != buyer_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
if order.status == "refunded":
return order # 幂等
if order.status != "paid":
raise HTTPException(status.HTTP_409_CONFLICT, "仅已支付订单可退款")
order.status = "refunded"
order.refunded_at = datetime.now(UTC)
await _post_entries(db, order, -1, "refund")
db.add(
OutboxEvent(
aggregate_type="order", aggregate_id=order.id,
event_type="order.refunded", payload={"order_id": order.id},
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.refunded",
aggregate_type="order", aggregate_id=order.id, trace_id=trace_id,
)
)
await db.flush()
return order
def verify_webhook_signature(raw_body: bytes, signature: str | None) -> None:
"""HMAC-SHA256 验签:密钥经环境变量;失败 401(契约 realtime-and-webhooks)。"""
secret = os.environ.get("HUNJING_WEBHOOK_SECRET", "")
if not secret:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Webhook 密钥未配置")
if signature is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少签名")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "签名校验失败")
async def process_webhook(
db: AsyncSession, provider: str, provider_event_id: str, event_type: str,
payload_hash: str, handler: Any, trace_id: str | None,
) -> str:
"""Webhook 处理:先验签(调用方)、按事件 ID 幂等;重复事件 200 跳过。"""
existing = await db.get(WebhookEvent, provider_event_id)
if existing is not None:
return "duplicate" # 幂等:重复投递不重复入账
db.add(
WebhookEvent(
provider_event_id=provider_event_id,
provider=provider,
event_type=event_type,
payload_hash=payload_hash,
)
)
await handler() # 业务处理(如入账)与本记录同一事务
await db.flush()
return "processed"
async def finance_summary(db: AsyncSession, domain_id: str) -> dict[str, Any]:
"""域内财务汇总与平衡校验(每订单 charge=reversal 与 fee+payable 对账)。"""
rows = (
await db.execute(
select(LedgerEntry.entry_type, func.sum(LedgerEntry.amount_minor))
.join(Order, LedgerEntry.order_id == Order.id)
.where(Order.buyer_domain_id == domain_id)
.group_by(LedgerEntry.entry_type)
)
).all()
totals = {t: int(a or 0) for t, a in rows}
balance_ok = totals.get("charge", 0) + totals.get("reversal", 0) == (
totals.get("platform_fee", 0) + totals.get("creator_payable", 0)
)
return {"totals_minor": totals, "balanced": balance_ok}
async def set_preservation(
db: AsyncSession, domain_id: str, preserved: bool, trace_id: str | None
) -> str:
"""保全态(ADR-021):欠费只读保全;恢复付费即恢复,数据不删不降级。"""
from app.models import PersonalDomain
domain = await db.get(PersonalDomain, domain_id)
assert domain is not None
domain.billing_status = "preserved" if preserved else "ok"
db.add(
AuditEvent(
actor_type="system", actor_id="billing", action="domain.preservation_changed",
aggregate_type="personal_domain", aggregate_id=domain_id,
payload={"preserved": preserved}, trace_id=trace_id,
)
)
await db.flush()
return domain.billing_status
+729
View File
@@ -21,6 +21,7 @@ class User(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_")) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_"))
email: Mapped[str] = mapped_column(String, nullable=False, unique=True) email: Mapped[str] = mapped_column(String, nullable=False, unique=True)
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
display_name: Mapped[str] = mapped_column(String, nullable=False) display_name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
@@ -29,6 +30,10 @@ class User(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
__table_args__ = (
CheckConstraint("role IN ('user', 'admin')", name="role_values"),
)
class PersonalDomain(Base): class PersonalDomain(Base):
__tablename__ = "personal_domain" __tablename__ = "personal_domain"
@@ -42,6 +47,8 @@ class PersonalDomain(Base):
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
# 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议 # 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议
data_region: Mapped[str] = mapped_column(String, nullable=False, default="default") data_region: Mapped[str] = mapped_column(String, nullable=False, default="default")
# 保全态(ADR-021):欠费/封禁只读保全,不删除数据
billing_status: Mapped[str] = mapped_column(String, nullable=False, default="ok")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -673,3 +680,725 @@ class ToolCall(Base):
unique=True, unique=True,
), ),
) )
class Listing(Base):
"""市场商品(WP-13):带版本、价格与许可;此处为最小商品形态。"""
__tablename__ = "listing"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("listing_")
)
creator_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String, nullable=False)
price_minor: Mapped[int] = mapped_column(nullable=False, default=0) # 0=免费
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("price_minor >= 0", name="price_non_negative"),
)
class Order(Base):
"""订单(契约 order_status 六态);下单即价格快照,客户端金额不可信。"""
__tablename__ = "order"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("order_"))
buyer_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending_payment")
total_minor: Mapped[int] = mapped_column(nullable=False, default=0)
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
idempotency_key: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending_payment', 'paid', 'partially_refunded', 'refunded', "
"'disputed', 'cancelled')",
name="status_values",
),
Index("uq_order_buyer_idempotency", "buyer_domain_id", "idempotency_key", unique=True),
)
class OrderItem(Base):
"""订单行:商品与价格快照(下单时冻结,后续改价不影响)。"""
__tablename__ = "order_item"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("item_"))
order_id: Mapped[str] = mapped_column(
ForeignKey("order.id", ondelete="cascade"), nullable=False, index=True
)
listing_id: Mapped[str] = mapped_column(
ForeignKey("listing.id", ondelete="restrict"), nullable=False
)
title_snapshot: Mapped[str] = mapped_column(String, nullable=False)
price_minor_snapshot: Mapped[int] = mapped_column(nullable=False)
class LedgerEntry(Base):
"""不可变账务分录(ADR-009):修正只写反向分录,禁止更新删除。
带符号金额:charge/payable/fee 为正,reversal 为负;
平衡不变量:每笔订单 sum(charge+reversal) == sum(fee+fee_rev+payable+pay_rev)。
"""
__tablename__ = "ledger_entry"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("ledger_")
)
order_id: Mapped[str] = mapped_column(
ForeignKey("order.id", ondelete="restrict"), nullable=False, index=True
)
entry_type: Mapped[str] = mapped_column(String, nullable=False)
amount_minor: Mapped[int] = mapped_column(nullable=False) # 带符号
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
note: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"entry_type IN ('charge', 'refund', 'platform_fee', 'creator_payable', "
"'tax', 'reserve', 'payout', 'reversal')",
name="entry_type_values",
),
Index("ix_ledger_order_type", "order_id", "entry_type"),
)
class WebhookEvent(Base):
"""支付 Webhook 事件:按 Provider 事件 ID 幂等,允许乱序到达。"""
__tablename__ = "webhook_event"
provider_event_id: Mapped[str] = mapped_column(String, primary_key=True)
provider: Mapped[str] = mapped_column(String, nullable=False)
event_type: Mapped[str] = mapped_column(String, nullable=False)
payload_hash: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="processed")
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('processed', 'ignored')", name="status_values"),
)
class CommunityContent(Base):
"""社区内容:帖子与角色 Release(WP-11)。
角色 Release 由服务端构造公开快照;私密记忆/关系/对话在结构上不进入发布物。
"""
__tablename__ = "community_content"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("content_")
)
author_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False, default="post")
title: Mapped[str] = mapped_column(String, nullable=False)
body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
character_id: Mapped[str | None] = mapped_column(
ForeignKey("character.id", ondelete="set null"), nullable=True
)
visibility: Mapped[str] = mapped_column(String, nullable=False, default="public")
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
CheckConstraint("kind IN ('post', 'character_release')", name="kind_values"),
CheckConstraint("visibility IN ('public', 'followers')", name="visibility_values"),
CheckConstraint(
"status IN ('draft', 'submitted', 'published', 'limited', 'suspended', 'removed')",
name="status_values",
),
)
class Comment(Base):
"""社区评论:平铺 + 可选父级;下架内容下的评论不可见。"""
__tablename__ = "comment"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("comment_"))
content_id: Mapped[str] = mapped_column(
ForeignKey("community_content.id", ondelete="cascade"), nullable=False, index=True
)
author_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
parent_comment_id: Mapped[str | None] = mapped_column(
ForeignKey("comment.id", ondelete="cascade"), nullable=True
)
body: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="visible")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('visible', 'removed')", name="status_values"),
)
class Reaction(Base):
"""互动:点赞/收藏;(content, actor, kind) 唯一防重复。"""
__tablename__ = "reaction"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("reaction_"))
content_id: Mapped[str] = mapped_column(
ForeignKey("community_content.id", ondelete="cascade"), nullable=False, index=True
)
actor_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
kind: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("kind IN ('like', 'save')", name="kind_values"),
Index(
"uq_reaction_content_actor_kind",
"content_id", "actor_domain_id", "kind",
unique=True,
),
)
class Follow(Base):
"""关注关系(域→域)。"""
__tablename__ = "follow"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("follow_"))
# 双 FK 同指 personal_domain:显式命名避免 convention 同名冲突
follower_domain_id: Mapped[str] = mapped_column(
ForeignKey(
"personal_domain.id", ondelete="cascade", name="fk_follow_follower_domain"
),
nullable=False,
index=True,
)
followee_domain_id: Mapped[str] = mapped_column(
ForeignKey(
"personal_domain.id", ondelete="cascade", name="fk_follow_followee_domain"
),
nullable=False,
index=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index(
"uq_follow_pair", "follower_domain_id", "followee_domain_id", unique=True
),
)
class Report(Base):
"""举报:创建即生成司议案(治理队列入口)。"""
__tablename__ = "report"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("report_"))
reporter_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
target_type: Mapped[str] = mapped_column(String, nullable=False)
target_id: Mapped[str] = mapped_column(String, nullable=False)
reason: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("target_type IN ('content', 'comment')", name="target_type_values"),
CheckConstraint("status IN ('pending', 'reviewed', 'actioned')", name="status_values"),
)
class ModerationCase(Base):
"""司议案:由举报生成,处置动作与结果留痕。"""
__tablename__ = "moderation_case"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("moderation_")
)
report_id: Mapped[str] = mapped_column(
ForeignKey("report.id", ondelete="cascade"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
action_taken: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('pending', 'resolved')", name="status_values"),
)
class Workspace(Base):
"""协作空间(WP-12):多人创作与权限边界。"""
__tablename__ = "workspace"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("workspace_")
)
name: Mapped[str] = mapped_column(String, nullable=False)
owner_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class WorkspaceMember(Base):
"""成员与角色:编辑、评审、发布、财务权限分离(契约协作域)。"""
__tablename__ = "workspace_member"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("member_"))
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
)
member_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
role: Mapped[str] = mapped_column(String, nullable=False, default="viewer")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"role IN ('owner', 'editor', 'reviewer', 'viewer')", name="role_values"
),
Index("uq_workspace_member", "workspace_id", "member_domain_id", unique=True),
)
class WorkspaceInvitation(Base):
"""邀请:接受后入成员;status 单向流转。"""
__tablename__ = "workspace_invitation"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("invitation_")
)
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
)
invitee_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
role: Mapped[str] = mapped_column(String, nullable=False, default="editor")
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"role IN ('editor', 'reviewer', 'viewer')", name="role_values"
),
CheckConstraint(
"status IN ('pending', 'accepted', 'declined', 'expired')", name="status_values"
),
)
class Project(Base):
"""协作项目:条目由版本承载;发布需 owner 且版本已批准。"""
__tablename__ = "project"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("project_"))
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
kind: Mapped[str] = mapped_column(String, nullable=False, default="generic")
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
current_version_id: Mapped[str | None] = mapped_column(
ForeignKey("project_version.id", ondelete="set null", use_alter=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'published')", name="status_values"
),
)
class ProjectVersion(Base):
"""项目版本:editor 提交 → reviewer 批准/拒绝;不可变演进。"""
__tablename__ = "project_version"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("project_version_")
)
project_id: Mapped[str] = mapped_column(
ForeignKey("project.id", ondelete="cascade"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
submitted_by: Mapped[str | None] = mapped_column(String, nullable=True)
reviewed_by: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'submitted', 'approved', 'rejected')", name="status_values"
),
Index("uq_project_version_number", "project_id", "version", unique=True),
)
class Plugin(Base):
"""插件(Registry 条目,WP-07):撤架阻断新调用,历史审计保留。"""
__tablename__ = "plugin"
name: Mapped[str] = mapped_column(String, primary_key=True) # 点分稳定名
publisher_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False, default="declarative")
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"kind IN ('declarative', 'remote', 'isolated_process', 'container')",
name="kind_values",
),
CheckConstraint(
"status IN ('published', 'suspended', 'removed')", name="status_values"
),
)
class PluginVersion(Base):
"""插件版本:Manifest(权限/网络/数据声明)与内容签名(哈希)。"""
__tablename__ = "plugin_version"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("plugin_version_")
)
plugin_name: Mapped[str] = mapped_column(
ForeignKey("plugin.name", ondelete="cascade"), nullable=False, index=True
)
version: Mapped[str] = mapped_column(String, nullable=False)
manifest: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
content_hash: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('published', 'suspended', 'removed')", name="status_values"
),
Index("uq_plugin_version", "plugin_name", "version", unique=True),
)
class PluginInstallation(Base):
"""安装:购买≠安装≠启用≠授权;启用必须显式确认权限子集。"""
__tablename__ = "plugin_installation"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("plugin_installation_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
plugin_name: Mapped[str] = mapped_column(
ForeignKey("plugin.name", ondelete="cascade"), nullable=False
)
version_id: Mapped[str] = mapped_column(
ForeignKey("plugin_version.id", ondelete="restrict"), nullable=False
)
enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
granted_permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("uq_plugin_installation", "domain_id", "plugin_name", unique=True),
)
class Upload(Base):
"""上传会话:登记约束 → 直传对象存储 → 完成校验(契约第 9 节)。"""
__tablename__ = "upload"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("upload_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
filename: Mapped[str] = mapped_column(String, nullable=False)
media_type: Mapped[str] = mapped_column(String, nullable=False)
size_limit: Mapped[int] = mapped_column(nullable=False, default=100 * 1024 * 1024)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('pending', 'completed', 'failed')", name="status_values"),
)
class FileAsset(Base):
"""文件资产:未完成/隔离文件不可被业务引用(契约第 9 节)。"""
__tablename__ = "file_asset"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("file_"))
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
upload_id: Mapped[str | None] = mapped_column(
ForeignKey("upload.id", ondelete="set null"), nullable=True
)
filename: Mapped[str] = mapped_column(String, nullable=False)
media_type: Mapped[str] = mapped_column(String, nullable=False)
size_bytes: Mapped[int] = mapped_column(nullable=False, default=0)
sha256: Mapped[str | None] = mapped_column(String, nullable=True)
storage_key: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'ready', 'failed', 'quarantined')", name="status_values"
),
)
class MediaJob(Base):
"""媒体任务:ASR/TTS/图像等;无可用 Provider 时失败(fail-closed)。"""
__tablename__ = "media_job"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("media_"))
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False)
input: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
output: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False, default="queued")
progress: Mapped[int] = mapped_column(nullable=False, default=0)
error: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint("kind IN ('asr', 'tts', 'image_gen', 'image_edit')", name="kind_values"),
CheckConstraint(
"status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')",
name="status_values",
),
)
class VoiceConsent(Base):
"""声契:声音授权(身份/用途/期限),撤回后停止新生成(契约媒体域)。"""
__tablename__ = "voice_consent"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("voice_consent_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
character_id: Mapped[str | None] = mapped_column(
ForeignKey("character.id", ondelete="cascade"), nullable=True
)
purpose: Mapped[str] = mapped_column(String, nullable=False, default="voice_clone")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class DiagnosticGrant(Base):
"""诊断授权(ADR-004):双重、限时;明文只签发一次,库存哈希。"""
__tablename__ = "diagnostic_grant"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("diagnostic_grant_")
)
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
purpose: Mapped[str] = mapped_column(String, nullable=False)
token_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class PluginExecution(Base):
"""插件沙箱执行记录(WP-06/07 Sandbox):结果与资源消耗如实留痕。"""
__tablename__ = "plugin_execution"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("execution_")
)
installation_id: Mapped[str] = mapped_column(
ForeignKey("plugin_installation.id", ondelete="cascade"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="running")
exit_code: Mapped[int | None] = mapped_column(nullable=True)
duration_ms: Mapped[int | None] = mapped_column(nullable=True)
stdout_excerpt: Mapped[str | None] = mapped_column(String, nullable=True)
error: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('running', 'succeeded', 'failed', 'timeout', 'killed')",
name="status_values",
),
)
class AvatarModel(Base):
"""Avatar 模型资产(WP-09):live2d/vrm/gltf;校验结果与来源许可。"""
__tablename__ = "avatar_model"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("avatar_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
character_id: Mapped[str | None] = mapped_column(
ForeignKey("character.id", ondelete="set null"), nullable=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
format: Mapped[str] = mapped_column(String, nullable=False)
file_id: Mapped[str] = mapped_column(
ForeignKey("file_asset.id", ondelete="restrict"), nullable=False
)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
validation_report: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
source_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
license_terms: Mapped[str | None] = mapped_column(String, nullable=True)
current_command_id: Mapped[str | None] = mapped_column(
ForeignKey("avatar_command.id", ondelete="set null", use_alter=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("format IN ('live2d', 'vrm', 'gltf')", name="format_values"),
CheckConstraint(
"status IN ('pending', 'validated', 'rejected')", name="status_values"
),
)
class AvatarCommand(Base):
"""非破坏编辑命令历史(WP-09/ADR-006):命令与版本可扩展到 2.0 LLM 改模。"""
__tablename__ = "avatar_command"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("command_")
)
avatar_id: Mapped[str] = mapped_column(
ForeignKey("avatar_model.id", ondelete="cascade"), nullable=False, index=True
)
sequence: Mapped[int] = mapped_column(nullable=False)
command_type: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
undone: Mapped[bool] = mapped_column(nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"command_type IN ('param_map', 'expression', 'physics', 'motion', "
"'material', 'texture', 'bone', 'attachment', 'lighting', 'camera')",
name="command_type_values",
),
Index("uq_avatar_command_seq", "avatar_id", "sequence", unique=True),
)
+1
View File
@@ -0,0 +1 @@
"""运营域:诊断双授权、管理面治理、支持包与观测。"""
+147
View File
@@ -0,0 +1,147 @@
"""运营域路由:诊断、管理面、支持包与观测(WP-14)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy import text as sql_text
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import MediaJob, Message, Run, ToolCall, User
from app.ops import service
ops_router = APIRouter(prefix="/v1", tags=["ops-extended"])
admin_router = APIRouter(prefix="/v1/admin", tags=["admin"])
@ops_router.post("/runs/{run_id}/diagnostic-grants", status_code=201)
async def issue_grant(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
"""签发诊断授权:明文 token 只返回一次;默认 15 分钟、单次、绑定 Run。"""
domain = await _domain_of(db, user)
grant, token = await service.issue_grant(
db, domain.id, run_id, body.get("purpose", ""),
getattr(request.state, "trace_id", None),
)
return {
"grant_id": grant.id, "token": token, # 唯一一次明文返回
"expires_at": grant.expires_at.isoformat(),
}
@ops_router.get("/runs/{run_id}/diagnostics")
async def read_diagnostics(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
response: Response,
diag_token: Annotated[
str | None, Header(alias="Hunjing-Diagnostic-Token")
] = None,
diag_purpose: Annotated[
str | None, Header(alias="Hunjing-Diagnostic-Purpose")
] = None,
) -> dict[str, Any]:
"""双重授权诊断:登录态 + 诊断令牌;响应不落缓存(ADR-004)。"""
domain = await _domain_of(db, user)
if not diag_token or not diag_purpose:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少诊断令牌或用途")
result = await service.read_diagnostics(
db, domain.id, run_id, diag_token, diag_purpose
)
response.headers["Cache-Control"] = "no-store, private"
return result
@ops_router.post("/support/bundles")
async def support_bundle(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
) -> dict[str, Any]:
"""脱敏支持包:统计聚合,无正文与密钥。"""
domain = await _domain_of(db, user)
return await service.build_support_bundle(db, domain.id)
@admin_router.get("/moderation-cases")
async def list_cases(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
await service.require_admin(db, user.id)
from app.models import Report
cases = await service.list_pending_cases(db)
out = []
for case in cases:
report = await db.get(Report, case.report_id)
out.append({
"case_id": case.id, "status": case.status,
"report": {
"target_type": report.target_type if report else None,
"target_id": report.target_id if report else None,
"reason": report.reason if report else None,
} if report else None,
})
return {"data": out}
@admin_router.post("/moderation-cases/{case_id}/resolutions")
async def resolve_case(
case_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
await service.require_admin(db, user.id)
case = await service.resolve_case(
db, domain.id, case_id, body.get("action", ""),
getattr(request.state, "trace_id", None),
)
return {"case_id": case.id, "status": case.status, "action_taken": case.action_taken}
@ops_router.get("/readyz", tags=["ops"])
async def readyz(db: DbSession) -> dict[str, str]:
await db.execute(sql_text("SELECT 1"))
return {"status": "ready"}
@ops_router.get("/metrics", tags=["ops"])
async def metrics(db: DbSession) -> Response:
"""Prometheus 文本格式:真实 SQL 聚合(无正文)。"""
run_rows = (await db.execute(select(Run.status, func.count()).group_by(Run.status))).all()
msg_total = (await db.execute(select(func.count()).select_from(Message))).scalar_one()
tool_rows = (
await db.execute(select(ToolCall.status, func.count()).group_by(ToolCall.status))
).all()
media_rows = (
await db.execute(select(MediaJob.status, func.count()).group_by(MediaJob.status))
).all()
lines = ["# HELP hunjing_runs_total Run 总数(按状态)", "# TYPE hunjing_runs_total counter"]
for st, cnt in run_rows:
lines.append(f'hunjing_runs_total{{status="{st}"}} {int(cnt)}')
lines += [
"# HELP hunjing_messages_total 消息总数",
"# TYPE hunjing_messages_total counter",
f"hunjing_messages_total {int(msg_total)}",
]
groups = [
("hunjing_tool_calls_total", tool_rows),
("hunjing_media_jobs_total", media_rows),
]
for prefix, rows in groups:
lines.append(f"# HELP {prefix} 计数(按状态)")
lines.append(f"# TYPE {prefix} counter")
for st, cnt in rows:
lines.append(f'{prefix}{{status="{st}"}} {int(cnt)}')
return Response(content="\n".join(lines) + "\n", media_type="text/plain")
+238
View File
@@ -0,0 +1,238 @@
"""运营域:诊断双授权、管理面治理、支持包与观测(WP-14 / ADR-004)。"""
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
Comment,
CommunityContent,
DiagnosticGrant,
MediaJob,
Message,
ModelCall,
ModerationCase,
Report,
Run,
ToolCall,
Turn,
)
GRANT_TTL = timedelta(minutes=15) # ADR-004:默认 15 分钟、单次、绑定 Run
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def issue_grant(
db: AsyncSession, domain_id: str, run_id: str, purpose: str, trace_id: str | None
) -> tuple[DiagnosticGrant, str]:
"""签发诊断授权:明文 token 只返回这一次,数据库只存哈希。"""
if not purpose.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "purpose 不能为空")
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
token = f"hjd_{secrets.token_urlsafe(32)}"
grant = DiagnosticGrant(
run_id=run_id,
domain_id=domain_id,
purpose=purpose,
token_hash=_hash_token(token),
expires_at=datetime.now(UTC) + GRANT_TTL,
)
db.add(grant)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="diagnostic.grant_issued",
aggregate_type="diagnostic_grant", aggregate_id=grant.id,
payload={"run_id": run_id, "purpose": purpose}, trace_id=trace_id,
)
)
await db.flush()
return grant, token
async def read_diagnostics(
db: AsyncSession,
domain_id: str,
run_id: str,
token: str,
purpose: str,
) -> dict[str, Any]:
"""双重授权读取:登录态 + 诊断授权(用途匹配、未过期、单次)。
返回运行观测摘要(usage/TTFT/工具轨迹);不含消息正文(结构保证)。
"""
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
grant = (
await db.execute(
select(DiagnosticGrant).where(
DiagnosticGrant.run_id == run_id,
DiagnosticGrant.token_hash == _hash_token(token),
)
)
).scalar_one_or_none()
if grant is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权无效")
if grant.used_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已使用")
if grant.expires_at <= datetime.now(UTC):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已过期")
if grant.purpose != purpose:
raise HTTPException(status.HTTP_403_FORBIDDEN, "用途与授权不符")
grant.used_at = datetime.now(UTC) # 单次:读取即消耗
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="diagnostic.read",
aggregate_type="diagnostic_grant", aggregate_id=grant.id,
payload={"run_id": run_id}, trace_id=None,
)
)
turns = list(
(
await db.scalars(
select(Turn).where(Turn.run_id == run_id).order_by(Turn.index.asc())
)
).all()
)
calls = list(
(
await db.scalars(
select(ModelCall).where(ModelCall.turn_id.in_([t.id for t in turns]))
)
).all()
)
tools = list(
(
await db.scalars(select(ToolCall).where(ToolCall.run_id == run_id))
).all()
)
return {
"run": {"id": run_id, "status": run.status},
"turns": [
{
"index": t.index,
"model_calls": [
{
"status": c.status, "usage": c.usage, "ttft_ms": c.ttft_ms,
"error": c.error,
}
for c in calls if c.turn_id == t.id
],
}
for t in turns
],
"tool_calls": [
{"tool": tc.tool_name, "status": tc.status, "error": tc.error}
for tc in tools
],
}
async def require_admin(db: AsyncSession, user_id: str) -> None:
from app.models import User
user = await db.get(User, user_id)
if user is None or user.role != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "需要管理员角色")
async def list_pending_cases(db: AsyncSession) -> list[ModerationCase]:
return list(
(
await db.scalars(
select(ModerationCase)
.where(ModerationCase.status == "pending")
.order_by(ModerationCase.created_at.asc())
.limit(100)
)
).all()
)
async def resolve_case(
db: AsyncSession,
admin_domain_id: str,
case_id: str,
action: str,
trace_id: str | None,
) -> ModerationCase:
"""处置司议案:content_removed 联动下架目标内容(治理闭环)。"""
case = await db.get(ModerationCase, case_id)
if case is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "案件不存在")
if case.status != "pending":
raise HTTPException(status.HTTP_409_CONFLICT, "案件已处理")
if action == "content_removed":
report = await db.get(Report, case.report_id)
assert report is not None
if report.target_type == "content":
content = await db.get(CommunityContent, report.target_id)
if content is not None:
content.status = "removed" # 下架传播(列表/详情/评论不可见)
elif report.target_type == "comment":
comment = await db.get(Comment, report.target_id)
if comment is not None:
comment.status = "removed"
case.status = "resolved"
case.action_taken = action
db.add(
AuditEvent(
actor_type="admin", actor_id=admin_domain_id, action="moderation.resolved",
aggregate_type="moderation_case", aggregate_id=case.id,
payload={"action": action}, trace_id=trace_id,
)
)
await db.flush()
return case
async def build_support_bundle(db: AsyncSession, domain_id: str) -> dict[str, Any]:
"""脱敏支持包:统计与错误码聚合,无正文、无密钥(契约运营域)。"""
run_rows = (
await db.execute(
select(Run.status, func.count())
.where(Run.personal_domain_id == domain_id)
.group_by(Run.status)
)
).all()
message_count = (
await db.execute(
select(func.count()).select_from(Message).join(
Run, Message.run_id == Run.id
).where(Run.personal_domain_id == domain_id)
)
).scalar_one()
tool_rows = (
await db.execute(
select(ToolCall.status, func.count())
.join(Run, ToolCall.run_id == Run.id)
.where(Run.personal_domain_id == domain_id)
.group_by(ToolCall.status)
)
).all()
media_rows = (
await db.execute(
select(MediaJob.status, func.count())
.where(MediaJob.domain_id == domain_id)
.group_by(MediaJob.status)
)
).all()
return {
"generated_at": datetime.now(UTC).isoformat(),
"runs_by_status": {s: int(c) for s, c in run_rows},
"messages_total": int(message_count),
"tool_calls_by_status": {s: int(c) for s, c in tool_rows},
"media_jobs_by_status": {s: int(c) for s, c in media_rows},
}
+1
View File
@@ -0,0 +1 @@
"""插件域:Registry、安装三态与调用门。"""
+170
View File
@@ -0,0 +1,170 @@
"""插件域路由(WP-07)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import Plugin, PluginVersion, User
from app.plugins import service
router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
def _plugin_out(p: Plugin) -> dict[str, Any]:
return {
"name": p.name, "kind": p.kind, "status": p.status,
"publisher_domain_id": p.publisher_domain_id,
}
def _version_out(v: PluginVersion) -> dict[str, Any]:
return {
"id": v.id, "plugin_name": v.plugin_name, "version": v.version,
"manifest": v.manifest, "content_hash": v.content_hash, "status": v.status,
}
def _installation_out(i: Any) -> dict[str, Any]:
return {
"id": i.id, "plugin_name": i.plugin_name, "version_id": i.version_id,
"enabled": i.enabled, "granted_permissions": i.granted_permissions,
}
@router.post("/versions", status_code=201)
async def publish(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
_, pv = await service.publish_version(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _version_out(pv)
@router.get("")
async def list_plugins(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
rows = list(
(
await db.scalars(
select(Plugin).where(Plugin.status == "published").limit(50)
)
).all()
)
return {"data": [_plugin_out(p) for p in rows]}
@router.post("/{name}/withdrawals")
async def withdraw(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
plugin = await service.withdraw(
db, domain.id, name, getattr(request.state, "trace_id", None)
)
return _plugin_out(plugin)
@router.post("/{name}/installations", status_code=201)
async def install(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.install(
db, domain.id, name, body.get("version", ""),
getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
inst_router = APIRouter(prefix="/v1/plugin-installations", tags=["plugins"])
@inst_router.post("/{installation_id}/enablings", status_code=201)
async def enable(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.enable(
db, domain.id, installation_id, body.get("granted_permissions", []),
getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
@inst_router.post("/{installation_id}/upgrades", status_code=201)
async def upgrade(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.upgrade(
db, domain.id, installation_id, body.get("version", ""),
body.get("granted_permissions"), getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
invoke_router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
@invoke_router.post("/{name}/invocations")
async def invoke(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.invoke(
db, domain.id, name, body.get("input", {}), body.get("run_id"),
getattr(request.state, "trace_id", None),
)
@inst_router.post("/{installation_id}/executions", status_code=201)
async def execute_plugin(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
"""沙箱执行 isolated 插件:未启用沙箱一律 503(fail-closed)。"""
domain = await _domain_of(db, user)
from app.core.config import get_settings
execution, result = await service.execute_isolated(
db, domain.id, installation_id, body.get("input", {}),
sandbox_enabled=get_settings().sandbox_enabled,
trace_id=getattr(request.state, "trace_id", None),
)
return {
"execution_id": execution.id, "status": result["status"],
"duration_ms": result.get("duration_ms"),
"output": result.get("output"), "error": result.get("error"),
}
+366
View File
@@ -0,0 +1,366 @@
"""插件域:Registry、安装三态与调用门(WP-07 / ADR-001/008)。"""
import hashlib
import json
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Plugin, PluginInstallation, PluginVersion
def _hash_manifest(manifest: dict[str, Any]) -> str:
canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False)
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _manifest_permissions(manifest: dict[str, Any]) -> set[str]:
"""权限标识统一为 group:item(如 network:api.example.com)。"""
perms = manifest.get("permissions", {})
result: set[str] = set()
if isinstance(perms, dict):
for group, items in perms.items():
if isinstance(items, list):
result.update(f"{group}:{i}" for i in items)
return result
async def publish_version(
db: AsyncSession,
publisher_domain_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> tuple[Plugin, PluginVersion]:
"""发布插件版本:Manifest 声明权限与行为;内容签名=规范化哈希。
安装展示数据、网络、设备、费用、外部目的地与现实后果(ADR/契约)。
"""
name = str(body.get("name", "")).strip()
if not name or "." not in name:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 须为点分稳定名")
version = str(body.get("version", "")).strip()
if not version:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "version 不能为空")
manifest = body.get("manifest", {})
if not isinstance(manifest, dict) or "permissions" not in manifest:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "manifest 必须声明 permissions")
plugin = await db.get(Plugin, name)
if plugin is None:
plugin = Plugin(
name=name,
publisher_domain_id=publisher_domain_id,
kind=str(body.get("kind", "declarative")),
)
db.add(plugin)
await db.flush()
elif plugin.publisher_domain_id != publisher_domain_id:
raise HTTPException(status.HTTP_409_CONFLICT, "插件名已被其他发布者占用")
existing = await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == name, PluginVersion.version == version
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "版本已存在")
pv = PluginVersion(
plugin_name=name,
version=version,
manifest=manifest,
content_hash=_hash_manifest(manifest),
)
db.add(pv)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=publisher_domain_id, action="plugin.published",
aggregate_type="plugin", aggregate_id=name,
payload={"version": version, "hash": pv.content_hash}, trace_id=trace_id,
)
)
await db.flush()
return plugin, pv
async def withdraw(
db: AsyncSession, publisher_domain_id: str, name: str, trace_id: str | None
) -> Plugin:
"""安全撤架:阻断新调用,保留历史审计与安装记录(不静默删除)。"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.publisher_domain_id != publisher_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在")
plugin.status = "removed"
await db.execute(
select(PluginVersion).where(PluginVersion.plugin_name == name) # 触发加载
)
for pv in (
await db.scalars(select(PluginVersion).where(PluginVersion.plugin_name == name))
).all():
pv.status = "removed"
db.add(
AuditEvent(
actor_type="user", actor_id=publisher_domain_id, action="plugin.withdrawn",
aggregate_type="plugin", aggregate_id=name, trace_id=trace_id,
)
)
await db.flush()
return plugin
async def install(
db: AsyncSession,
domain_id: str,
name: str,
version: str,
trace_id: str | None,
) -> PluginInstallation:
"""安装:默认 disabled、零授权——购买≠安装≠启用≠授权(WP-07 验证项)。"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.status != "published":
raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在或已撤架")
existing_install = await db.execute(
select(PluginInstallation).where(
PluginInstallation.domain_id == domain_id,
PluginInstallation.plugin_name == name,
)
)
if existing_install.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "已安装(升级请走升级端点)")
pv = (
await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == name,
PluginVersion.version == version,
PluginVersion.status == "published",
)
)
).scalar_one_or_none()
if pv is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在或不可用")
installation = PluginInstallation(
domain_id=domain_id, plugin_name=name, version_id=pv.id,
enabled=False, granted_permissions=[],
)
db.add(installation)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.installed",
aggregate_type="plugin", aggregate_id=name,
payload={"version": version, "enabled": False}, trace_id=trace_id,
)
)
await db.flush()
return installation
async def require_installation(
db: AsyncSession, domain_id: str, installation_id: str
) -> PluginInstallation:
installation = await db.get(PluginInstallation, installation_id)
if installation is None or installation.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "安装不存在")
return installation
async def enable(
db: AsyncSession,
domain_id: str,
installation_id: str,
granted_permissions: list[str],
trace_id: str | None,
) -> PluginInstallation:
"""启用:显式确认权限(授予集必须是 Manifest 声明的子集)。"""
installation = await require_installation(db, domain_id, installation_id)
pv = await db.get(PluginVersion, installation.version_id)
assert pv is not None
declared = _manifest_permissions(pv.manifest)
granted = set(granted_permissions)
if not granted.issubset(declared):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"授权超出 Manifest 声明:{sorted(granted - declared)}",
)
installation.enabled = True
installation.granted_permissions = sorted(granted)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.enabled",
aggregate_type="plugin", aggregate_id=installation.plugin_name,
payload={"granted": sorted(granted)}, trace_id=trace_id,
)
)
await db.flush()
return installation
async def upgrade(
db: AsyncSession,
domain_id: str,
installation_id: str,
version: str,
granted_permissions: list[str] | None,
trace_id: str | None,
) -> PluginInstallation:
"""升级:Manifest 新增权限必须重新确认(granted 覆盖新增项,否则 422)。"""
installation = await require_installation(db, domain_id, installation_id)
current = await db.get(PluginVersion, installation.version_id)
assert current is not None
target = (
await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == installation.plugin_name,
PluginVersion.version == version,
PluginVersion.status == "published",
)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "目标版本不存在")
old_declared = _manifest_permissions(current.manifest)
new_declared = _manifest_permissions(target.manifest)
newly_added = new_declared - old_declared
granted = (
set(granted_permissions) if granted_permissions is not None
else set(installation.granted_permissions)
)
if newly_added and not newly_added.issubset(granted):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"新版本新增权限需要重新确认:{sorted(newly_added - granted)}",
)
if not granted.issubset(new_declared):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "授权超出 Manifest 声明")
installation.version_id = target.id
installation.granted_permissions = sorted(granted)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.upgraded",
aggregate_type="plugin", aggregate_id=installation.plugin_name,
payload={"to_version": version, "newly_added": sorted(newly_added)},
trace_id=trace_id,
)
)
await db.flush()
return installation
async def invoke(
db: AsyncSession,
domain_id: str,
name: str,
input_: dict[str, Any],
run_id: str | None,
trace_id: str | None,
) -> dict[str, Any]:
"""调用门:撤架/未启用/未授权逐一校验;按 Manifest 声明真实执行。
声明式插件执行 response_template 回填(确定性、无副作用);
remote/isolated 形态的传输执行随 WP-14 沙箱交付,当前未安装即拒绝。
"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.status != "published":
raise HTTPException(status.HTTP_403_FORBIDDEN, "插件不可用(已撤架或不存在)")
installation = (
await db.execute(
select(PluginInstallation).where(
PluginInstallation.domain_id == domain_id,
PluginInstallation.plugin_name == name,
)
)
).scalar_one_or_none()
if installation is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, "未安装")
if not installation.enabled:
raise HTTPException(status.HTTP_403_FORBIDDEN, "未启用")
pv = await db.get(PluginVersion, installation.version_id)
assert pv is not None
if pv.status != "published":
raise HTTPException(status.HTTP_403_FORBIDDEN, "已安装版本已撤架")
if plugin.kind != "declarative":
raise HTTPException(
status.HTTP_501_NOT_IMPLEMENTED, "该形态执行随沙箱 Runtime 交付(WP-14)"
)
template = str(pv.manifest.get("response_template", ""))
output = {"executed": True, "runtime": "declarative", "response": template}
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.invoked",
aggregate_type="plugin", aggregate_id=name,
payload={
"version": pv.version, "run_id": run_id,
"version_hash": pv.content_hash, # 快照语义:审计可追溯当时版本
},
trace_id=trace_id,
)
)
await db.flush()
return output
async def execute_isolated(
db: AsyncSession,
domain_id: str,
installation_id: str,
input_: dict[str, Any],
sandbox_enabled: bool,
trace_id: str | None,
) -> tuple[Any, dict[str, Any]]:
"""隔离执行:isolated 形态插件经沙箱子进程运行;未启用沙箱一律拒绝。
返回(执行记录, 结果)。代码体取自 Manifest 的 code 字段
(部署者明确信任的本地代码插件,仍受资源限制约束)。
"""
from app.models import PluginExecution
from app.sandbox.executor import run_sandboxed
installation = await require_installation(db, domain_id, installation_id)
plugin = await db.get(Plugin, installation.plugin_name)
assert plugin is not None
if plugin.kind != "isolated_process":
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "仅 isolated_process 形态走沙箱执行"
)
if not installation.enabled:
raise HTTPException(status.HTTP_403_FORBIDDEN, "插件未启用")
if not sandbox_enabled:
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.execution_denied",
aggregate_type="plugin", aggregate_id=plugin.name,
payload={"reason": "sandbox_not_enabled"}, trace_id=trace_id,
)
)
await db.flush()
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"沙箱未启用:拒绝执行(fail-closed),启用请设置 HUNJING_SANDBOX_ENABLED",
)
pv = await db.get(PluginVersion, installation.version_id)
assert pv is not None
code = str(pv.manifest.get("code", ""))
execution = PluginExecution(installation_id=installation.id, status="running")
db.add(execution)
await db.flush()
result = await run_sandboxed(code, input_)
execution.status = result["status"]
execution.exit_code = result.get("exit_code")
execution.duration_ms = result.get("duration_ms")
execution.stdout_excerpt = result.get("stdout_excerpt")
execution.error = result.get("error")
from datetime import UTC
from datetime import datetime as _dt
execution.completed_at = _dt.now(UTC)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.executed",
aggregate_type="plugin", aggregate_id=plugin.name,
payload={"execution_id": execution.id, "status": execution.status,
"duration_ms": execution.duration_ms},
trace_id=trace_id,
)
)
await db.flush()
return execution, result
+1
View File
@@ -0,0 +1 @@
"""沙箱域:隔离执行器与策略。"""
+109
View File
@@ -0,0 +1,109 @@
"""沙箱执行器(WP-06/07 Sandbox):子进程隔离 + 资源限制 + 超时。
隔离边界(按部署档):
- 本执行器提供:独立进程、CPU/内存 rlimit、墙钟超时与强杀、输出限额。
- 进程级隔离不是完整安全边界;网络隔离与文件系统最小化在 Linux 容器档
(container 形态)交付。部署者必须显式启用沙箱(HUNJING_SANDBOX_ENABLED),
未启用时一律拒绝执行(fail-closed,AGENTS 第 6 节)。
"""
import asyncio
import json
import resource
import sys
import time
from dataclasses import dataclass
from typing import Any
# 用户代码经 stdin JSON 传输,不进命令行(避免注入命令行参数)
_RUNNER = r'''
import json, sys
def _main():
payload = json.loads(sys.stdin.read())
scope = {"input": payload.get("input", {})}
exec(compile(payload["code"], "<plugin>", "exec"), scope)
result = scope.get("output")
sys.stdout.write(json.dumps({"output": result}, ensure_ascii=False))
_main()
'''
@dataclass(frozen=True)
class SandboxPolicy:
max_runtime_ms: int = 5_000
max_memory_mb: int = 128
max_output_chars: int = 10_000
class SandboxUnavailable(Exception):
"""未启用沙箱:调用方必须显式失败,不得退化为宿主执行。"""
def _apply_limits(policy: SandboxPolicy) -> None: # 在子进程内执行(preexec_fn)
# RLIMIT_AS 在部分平台(macOS)不可设:尽力而为,失败不阻断进程隔离
cpu_seconds = max(1, policy.max_runtime_ms // 1000 + 1)
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
if hasattr(resource, "RLIMIT_AS"):
as_bytes = policy.max_memory_mb * 1024 * 1024
try:
resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))
except (ValueError, OSError):
pass
async def run_sandboxed(
code: str, input_: dict[str, Any], policy: SandboxPolicy | None = None
) -> dict[str, Any]:
"""在受限子进程中执行插件代码;返回执行结果(不抛业务异常以外的错误)。"""
pol = policy or SandboxPolicy()
stdin_payload = json.dumps({"code": code, "input": input_}, ensure_ascii=False)
started = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", _RUNNER,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=lambda: _apply_limits(pol),
)
except Exception as exc: # 进程无法启动:如实失败
return {
"status": "failed", "exit_code": None,
"duration_ms": int((time.monotonic() - started) * 1000),
"output": None, "error": f"sandbox_spawn_failed: {exc}",
}
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(stdin_payload.encode("utf-8")),
timeout=pol.max_runtime_ms / 1000,
)
except TimeoutError:
proc.kill()
await proc.wait()
return {
"status": "timeout", "exit_code": None,
"duration_ms": int((time.monotonic() - started) * 1000),
"output": None, "error": f"exceeded {pol.max_runtime_ms}ms wall clock",
}
duration_ms = int((time.monotonic() - started) * 1000)
out_text = stdout.decode("utf-8", "replace")[: pol.max_output_chars]
err_text = stderr.decode("utf-8", "replace")[: pol.max_output_chars]
if proc.returncode == 0:
try:
parsed = json.loads(out_text)
except json.JSONDecodeError:
return {
"status": "failed", "exit_code": proc.returncode,
"duration_ms": duration_ms, "output": None,
"error": "plugin output is not JSON",
}
return {
"status": "succeeded", "exit_code": 0,
"duration_ms": duration_ms, "output": parsed.get("output"),
"stdout_excerpt": out_text, "error": None,
}
return {
"status": "failed", "exit_code": proc.returncode,
"duration_ms": duration_ms, "output": None,
"stdout_excerpt": out_text, "error": err_text or f"exit {proc.returncode}",
}
+1
View File
@@ -0,0 +1 @@
"""协作域:Workspace、成员、邀请与项目评审。"""
+181
View File
@@ -0,0 +1,181 @@
"""协作域路由(WP-12)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import PersonalDomain, User
from app.workspaces import service
router = APIRouter(prefix="/v1/workspaces", tags=["workspaces"])
def _member_out(m: Any) -> dict[str, Any]:
return {
"id": m.id,
"workspace_id": m.workspace_id,
"member_domain_id": m.member_domain_id,
"role": m.role,
"created_at": m.created_at.isoformat(),
}
def _project_out(p: Any) -> dict[str, Any]:
return {
"id": p.id,
"workspace_id": p.workspace_id,
"name": p.name,
"status": p.status,
"current_version_id": p.current_version_id,
"created_at": p.created_at.isoformat(),
}
def _version_out(v: Any) -> dict[str, Any]:
return {
"id": v.id,
"project_id": v.project_id,
"version": v.version,
"payload": v.payload,
"status": v.status,
"submitted_by": v.submitted_by,
"reviewed_by": v.reviewed_by,
"created_at": v.created_at.isoformat(),
}
@router.post("", status_code=201)
async def create_workspace(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
ws = await service.create_workspace(
db, domain.id, body.get("name", ""), getattr(request.state, "trace_id", None)
)
return {"id": ws.id, "name": ws.name, "owner_domain_id": ws.owner_domain_id}
@router.post("/{workspace_id}/invitations", status_code=201)
async def invite(
workspace_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
invitee = await db.get(PersonalDomain, body.get("invitee_domain_id", ""))
if invitee is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "被邀用户不存在")
invitation = await service.invite(
db, domain.id, workspace_id, invitee.id, body.get("role", "editor"),
getattr(request.state, "trace_id", None),
)
return {"id": invitation.id, "status": invitation.status, "role": invitation.role}
@router.post("/invitations/{invitation_id}/acceptances", status_code=201)
async def accept(
invitation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
member = await service.accept_invitation(
db, domain.id, invitation_id, getattr(request.state, "trace_id", None)
)
return _member_out(member)
@router.get("/{workspace_id}/members")
async def list_members(
workspace_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
rows = await service.list_members(db, domain.id, workspace_id)
return {"data": [_member_out(m) for m in rows]}
@router.delete("/{workspace_id}/members/{member_id}", status_code=204)
async def remove_member(
workspace_id: str,
member_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> None:
domain = await _domain_of(db, user)
await service.remove_member(
db, domain.id, workspace_id, member_id, getattr(request.state, "trace_id", None)
)
@router.post("/{workspace_id}/projects", status_code=201)
async def create_project(
workspace_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
project = await service.create_project(
db, domain.id, workspace_id, body.get("name", ""),
getattr(request.state, "trace_id", None),
)
return _project_out(project)
@router.post("/projects/{project_id}/versions", status_code=201)
async def submit_version(
project_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_version(
db, domain.id, project_id, body.get("payload", {}),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/project-versions/{version_id}/reviews", status_code=201)
async def review_version(
version_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.review_version(
db, domain.id, version_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/projects/{project_id}/publications", status_code=200)
async def publish(
project_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
project = await service.publish_project(
db, domain.id, project_id, getattr(request.state, "trace_id", None)
)
return _project_out(project)
+272
View File
@@ -0,0 +1,272 @@
"""协作域:Workspace、成员 RBAC、邀请与项目评审(WP-12)。"""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
Project,
ProjectVersion,
Workspace,
WorkspaceInvitation,
WorkspaceMember,
)
# 角色能力矩阵:编辑(提交版本)、评审(批准/拒绝)、发布(owner)
ROLE_CAN_SUBMIT = {"owner", "editor"}
ROLE_CAN_REVIEW = {"owner", "reviewer"}
async def require_member(
db: AsyncSession, domain_id: str, workspace_id: str, roles: set[str] | None = None
) -> WorkspaceMember:
"""成员校验:非成员或角色不足一律 404/403(跨 Workspace fail-closed)。"""
member = (
await db.execute(
select(WorkspaceMember).where(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.member_domain_id == domain_id,
)
)
).scalar_one_or_none()
if member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "工作区不存在")
if roles is not None and member.role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, f"角色 {member.role} 无权执行此操作")
return member
async def create_workspace(
db: AsyncSession, domain_id: str, name: str, trace_id: str | None
) -> Workspace:
if not name.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
ws = Workspace(name=name, owner_domain_id=domain_id)
db.add(ws)
await db.flush()
db.add(
WorkspaceMember(workspace_id=ws.id, member_domain_id=domain_id, role="owner")
)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="workspace.created",
aggregate_type="workspace", aggregate_id=ws.id, trace_id=trace_id,
)
)
await db.flush()
return ws
async def invite(
db: AsyncSession,
owner_domain_id: str,
workspace_id: str,
invitee_domain_id: str,
role: str,
trace_id: str | None,
) -> WorkspaceInvitation:
await require_member(db, owner_domain_id, workspace_id, {"owner"})
if role not in {"editor", "reviewer", "viewer"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "role 取值不合法")
existing_member = (
await db.execute(
select(WorkspaceMember).where(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.member_domain_id == invitee_domain_id,
)
)
).scalar_one_or_none()
if existing_member is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "已是成员")
invitation = WorkspaceInvitation(
workspace_id=workspace_id, invitee_domain_id=invitee_domain_id, role=role
)
db.add(invitation)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=owner_domain_id, action="workspace.invited",
aggregate_type="workspace", aggregate_id=workspace_id,
payload={"invitee": invitee_domain_id, "role": role}, trace_id=trace_id,
)
)
await db.flush()
return invitation
async def accept_invitation(
db: AsyncSession, domain_id: str, invitation_id: str, trace_id: str | None
) -> WorkspaceMember:
invitation = await db.get(WorkspaceInvitation, invitation_id)
if invitation is None or invitation.invitee_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "邀请不存在")
if invitation.status != "pending":
raise HTTPException(status.HTTP_409_CONFLICT, "邀请已处理")
invitation.status = "accepted"
member = WorkspaceMember(
workspace_id=invitation.workspace_id,
member_domain_id=domain_id,
role=invitation.role,
)
db.add(member)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="workspace.joined",
aggregate_type="workspace", aggregate_id=invitation.workspace_id, trace_id=trace_id,
)
)
await db.flush()
return member
async def remove_member(
db: AsyncSession, owner_domain_id: str, workspace_id: str, member_id: str,
trace_id: str | None,
) -> None:
await require_member(db, owner_domain_id, workspace_id, {"owner"})
member = await db.get(WorkspaceMember, member_id)
if member is None or member.workspace_id != workspace_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
if member.role == "owner":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "不能移除所有者")
await db.delete(member)
db.add(
AuditEvent(
actor_type="user", actor_id=owner_domain_id, action="workspace.member_removed",
aggregate_type="workspace", aggregate_id=workspace_id,
payload={"removed": member.member_domain_id}, trace_id=trace_id,
)
)
await db.flush()
async def list_members(
db: AsyncSession, domain_id: str, workspace_id: str
) -> list[WorkspaceMember]:
await require_member(db, domain_id, workspace_id)
return list(
(
await db.scalars(
select(WorkspaceMember)
.where(WorkspaceMember.workspace_id == workspace_id)
.order_by(WorkspaceMember.created_at.asc())
)
).all()
)
async def create_project(
db: AsyncSession, domain_id: str, workspace_id: str, name: str, trace_id: str | None
) -> Project:
await require_member(db, domain_id, workspace_id, ROLE_CAN_SUBMIT)
if not name.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
project = Project(workspace_id=workspace_id, name=name)
db.add(project)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="project.created",
aggregate_type="project", aggregate_id=project.id, trace_id=trace_id,
)
)
await db.flush()
return project
async def require_project(
db: AsyncSession, domain_id: str, project_id: str
) -> Project:
project = await db.get(Project, project_id)
if project is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "项目不存在")
await require_member(db, domain_id, project.workspace_id)
return project
async def submit_version(
db: AsyncSession, domain_id: str, project_id: str, payload: dict[str, Any],
trace_id: str | None,
) -> ProjectVersion:
"""editor 提交新版本:版本号自动递增,submitted 状态等待评审。"""
project = await require_project(db, domain_id, project_id)
await require_member(db, domain_id, project.workspace_id, ROLE_CAN_SUBMIT)
last = (
await db.execute(
select(ProjectVersion.version)
.where(ProjectVersion.project_id == project.id)
.order_by(ProjectVersion.version.desc())
.limit(1)
)
).scalar_one_or_none()
version = ProjectVersion(
project_id=project.id,
version=(last + 1) if last is not None else 1,
payload=payload,
status="submitted",
submitted_by=domain_id,
)
db.add(version)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="project.version_submitted",
aggregate_type="project", aggregate_id=project.id,
payload={"version": version.version}, trace_id=trace_id,
)
)
await db.flush()
return version
async def review_version(
db: AsyncSession, domain_id: str, version_id: str, approve: bool, trace_id: str | None
) -> ProjectVersion:
"""reviewer 批准/拒绝:批准即将版本设为项目当前版本。"""
version = await db.get(ProjectVersion, version_id)
if version is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
project = await db.get(Project, version.project_id)
assert project is not None
await require_member(db, domain_id, project.workspace_id, ROLE_CAN_REVIEW)
if version.status != "submitted":
raise HTTPException(status.HTTP_409_CONFLICT, "版本不在待评审状态")
version.status = "approved" if approve else "rejected"
version.reviewed_by = domain_id
if approve:
project.current_version_id = version.id
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id,
action="project.version_approved" if approve else "project.version_rejected",
aggregate_type="project", aggregate_id=project.id,
payload={"version": version.version}, trace_id=trace_id,
)
)
await db.flush()
return version
async def publish_project(
db: AsyncSession, domain_id: str, project_id: str, trace_id: str | None
) -> Project:
"""发布:仅 owner,且必须有已批准的当前版本(编辑/评审/发布三权分离)。"""
project = await require_project(db, domain_id, project_id)
await require_member(db, domain_id, project.workspace_id, {"owner"})
if project.current_version_id is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "没有已批准版本,不可发布")
if project.status == "published":
return project # 幂等
project.status = "published"
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="project.published",
aggregate_type="project", aggregate_id=project.id, trace_id=trace_id,
)
)
await db.flush()
return project
+2
View File
@@ -14,6 +14,8 @@ dependencies = [
"uuid-utils>=0.10", "uuid-utils>=0.10",
"greenlet>=3.5.5", "greenlet>=3.5.5",
"hunjing-lingtai", "hunjing-lingtai",
"grpcio>=1.83.1",
"grpcio-tools>=1.83.1",
] ]
[dependency-groups] [dependency-groups]
+27 -2
View File
@@ -49,6 +49,7 @@ async def client(db):
# 后台 Run 任务使用测试库会话工厂与 mock 流(生产为独立工厂与真实传输) # 后台 Run 任务使用测试库会话工厂与 mock 流(生产为独立工厂与真实传输)
app.state.session_factory = db app.state.session_factory = db
app.state.gw_transport = _mock_openai_stream() app.state.gw_transport = _mock_openai_stream()
app.state.media_provider = _mock_media_provider()
import os as _os import os as _os
_os.environ.setdefault("HJ_MOCK_KEY", "test-key") _os.environ.setdefault("HJ_MOCK_KEY", "test-key")
@@ -74,10 +75,21 @@ async def client(db):
async def login(client, email: str) -> dict[str, str]: async def login(client, email: str) -> dict[str, str]:
"""开发档登录:返回需附加的请求头(Cookie 由客户端自动携带)。""" """开发档登录:返回自包含请求头(显式 Cookie 支持多用户交替)。
同时保留 cookie jar 自动存储:不带 headers 的旧式请求仍走 jar。
显式 Cookie header 优先于 jar(httpx 行为),交替测试不串会话。
"""
resp = await client.post("/v1/auth/sessions", json={"email": email}) resp = await client.post("/v1/auth/sessions", json={"email": email})
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]} from app.auth.service import SESSION_COOKIE
token = resp.cookies.get(SESSION_COOKIE)
assert token, "登录未签发会话 Cookie"
return {
"Hunjing-CSRF-Token": resp.json()["csrf_token"],
"Cookie": f"{SESSION_COOKIE}={token}",
}
def _mock_openai_stream(): def _mock_openai_stream():
@@ -100,3 +112,16 @@ def _mock_openai_stream():
) )
return httpx.MockTransport(handler) return httpx.MockTransport(handler)
def _mock_media_provider():
"""媒体 Provider mock:ASR 返回确定性转写(测试注入;生产经配置)。"""
async def provider(kind: str, input_: dict) -> dict:
if kind == "asr":
return {"transcript": "(开发档转写)你好,魂镜", "language": "zh"}
if kind == "image_gen":
return {"file_id": None, "description": "generated"}
raise RuntimeError(f"unsupported kind {kind}")
return provider
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
"""gRPC 服务形态集成测试:双形态 golden 一致(ADR-017)。
在进程内启动服务器(随机端口),验证:
- GetEngineInfo 版本协商
- TransformMarkdown 与进程内 lingtai.transform_markdown 输出一致
- OpenStream 双向流 envelope 键序与序号单调
- EvaluatePolicy / ValidateUrl / CheckStateTransition / RerankRecall 行为
"""
import sys
from pathlib import Path
import grpc
import pytest
_SERVER_ROOT = Path(__file__).resolve().parents[2] / "engine" / "server"
sys.path.insert(0, str(_SERVER_ROOT / "generated"))
_SERVER_MODULE = _SERVER_ROOT / "lingtai_server.py"
import liushen.v1.liushen_pb2 as pb # noqa: E402
import liushen.v1.liushen_pb2_grpc as pb_grpc # noqa: E402
from app.core import lingtai # noqa: E402
@pytest.fixture()
async def grpc_channel():
import importlib.util as _ilu
from grpc import aio as grpc_aio
_spec = _ilu.spec_from_file_location(
"lingtai_server",
_SERVER_MODULE,
)
_mod = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
LingtaiEngineServicer = _mod.LingtaiEngineServicer
server = grpc_aio.server()
pb_grpc.add_LingtaiEngineServicer_to_server(LingtaiEngineServicer(), server)
port = server.add_insecure_port("127.0.0.1:0")
await server.start()
channel = grpc_aio.insecure_channel(f"127.0.0.1:{port}")
yield channel
await channel.close()
await server.stop(0)
async def test_engine_info(grpc_channel) -> None:
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
info = await stub.GetEngineInfo(pb.GetEngineInfoRequest())
assert info.abi_major == 1
assert info.parser_version == "hunjing-markdown-v1"
assert "transform" in info.capabilities
async def test_transform_dual_form_golden(grpc_channel) -> None:
"""双形态一致:服务输出 == 进程内引擎输出(ADR-017 核心不变量)。"""
import json
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
markdown = "说明\n```mermaid\nA-->B\n```\n$$\nz\n$$\n"
resp = await stub.TransformMarkdown(pb.TransformMarkdownRequest(markdown=markdown))
served = json.loads(resp.canonical_json)
inproc = lingtai.transform_markdown(markdown)
assert served == inproc # 服务形态与进程内输出一致
async def test_open_stream_envelope(grpc_channel) -> None:
import json
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
async def frames():
yield pb.StreamFrame(block_started="block_01")
yield pb.StreamFrame(text_delta="你")
yield pb.StreamFrame(text_delta="好")
events = []
async for ev in stub.OpenStream(frames()):
events.append(json.loads(ev.event_json))
assert len(events) == 2
assert all(e["type"] == "content.block.delta" for e in events)
seqs = [e["sequence"] for e in events]
assert seqs == sorted(seqs)
assert events[-1]["data"]["delta"]["text"] == "好"
async def test_policy_and_state(grpc_channel) -> None:
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
# 决策表
assert (await stub.EvaluatePolicy(pb.EvaluatePolicyRequest(
autonomy_level=1, risk_level=0))).decision == 0
assert (await stub.EvaluatePolicy(pb.EvaluatePolicyRequest(
autonomy_level=0, risk_level=2))).decision == 2
# URL 白名单
assert (await stub.ValidateUrl(pb.ValidateUrlRequest(
url="https://ok"))).allowed is True
assert (await stub.ValidateUrl(pb.ValidateUrlRequest(
url="file:///etc"))).allowed is False
# 状态机
assert (await stub.CheckStateTransition(pb.CheckStateTransitionRequest(
**{"from": pb.RUN_STATUS_QUEUED}, to=pb.RUN_STATUS_RUNNING))).valid is True
assert (await stub.CheckStateTransition(pb.CheckStateTransitionRequest(
**{"from": pb.RUN_STATUS_COMPLETED}, to=pb.RUN_STATUS_RUNNING))).valid is False
async def test_rerank(grpc_channel) -> None:
import json
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
resp = await stub.RerankRecall(pb.RerankRecallRequest(
candidates_json='{"query_terms":["魂镜"],"candidates":['
'{"id":"a","text":"魂镜 系统"},{"id":"b","text":"无"}]}'
))
ranked = json.loads(resp.ranked_json)["ranked"]
assert ranked[0] == {"id": "a", "score": 10}
assert ranked[1] == {"id": "b", "score": 0}
async def test_rebuild_export_unimplemented(grpc_channel) -> None:
stub = pb_grpc.LingtaiEngineStub(grpc_channel)
with pytest.raises(grpc.aio.AioRpcError) as exc:
await stub.RebuildExport(pb.RebuildExportRequest(canonical_json="{}"))
assert exc.value.code() == grpc.StatusCode.UNIMPLEMENTED
+112
View File
@@ -265,6 +265,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
] ]
[[package]]
name = "grpcio"
version = "1.83.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" },
{ url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" },
{ url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" },
{ url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" },
{ url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" },
{ url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" },
{ url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" },
{ url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" },
{ url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" },
{ url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" },
{ url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" },
{ url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" },
{ url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" },
{ url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" },
{ url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" },
{ url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" },
{ url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" },
{ url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" },
{ url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" },
]
[[package]]
name = "grpcio-tools"
version = "1.83.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio" },
{ name = "protobuf" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/79/8b4131bcb94f09c2cac4919e627f20c09ba9b22320a307697d72b1f881d4/grpcio_tools-1.83.1.tar.gz", hash = "sha256:a8148eece396f8a349097958bc00f14882003331f3b7c2ae8079c1c632d17d6f", size = 6399877, upload-time = "2026-08-28T07:10:56.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/91/79261571a7e5f6b5526f2da0fb7bff680af94f5322c8e8923961cbe801ae/grpcio_tools-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:3a8931c167e52741f72795c67f45435faaefb7ce8e9aa9413ba523ddaa358998", size = 2653283, upload-time = "2026-08-28T07:09:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/03/b2/ea313245d23b0a77edc83b1f01b86304664e79e2221d11cc8cf92985f18b/grpcio_tools-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d559e85272145a979e9ad10be2740d953dcd21c37cdad159f06cd7eaf887c898", size = 5965913, upload-time = "2026-08-28T07:10:00.727Z" },
{ url = "https://files.pythonhosted.org/packages/31/ce/125baf82bba77fad398f6139bda5e4383208a5a7f9f1ffc42dd8d973c402/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:169518957cbc23034950006f3eb77514516476496b2951576c531336c61baffb", size = 2705430, upload-time = "2026-08-28T07:10:02.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7c/cc727ab1b4ecc0a6dfe706a714c959d86736153c811e38b5b6723cb7576d/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d0e9b58917beb6e13d40bb694390dc79c8d7652a1d9ff412e98bdb416d8b135e", size = 3033411, upload-time = "2026-08-28T07:10:04.255Z" },
{ url = "https://files.pythonhosted.org/packages/84/57/e0312f16529cde4de03f532bb8dd4a8ccbf314e7c40cdfdc5586b6c23f85/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:70c74bb6fe0b57b7f4f49caa21d3263e149c2f1e2ef9d53c3d78d622e8564deb", size = 2774499, upload-time = "2026-08-28T07:10:05.928Z" },
{ url = "https://files.pythonhosted.org/packages/b7/83/0ecd3ee6ae20da8454481adf489969f854c56f702fcd2f27b5583fc14eae/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91a3d0939364e688773c2735286e61107fbacdc104711b0003bd665986b79993", size = 3229873, upload-time = "2026-08-28T07:10:07.576Z" },
{ url = "https://files.pythonhosted.org/packages/09/03/92d4a193578efc5e4a061339dd0dbdcf516eebdb0577646b1bfaf144c803/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:479bb9f4bde497471717e0ee59ed5635b490af954224825bd7006608d1e76d79", size = 3803165, upload-time = "2026-08-28T07:10:09.67Z" },
{ url = "https://files.pythonhosted.org/packages/b2/51/9921cf100111c0e1ba375767cab93be3036b231f22533985ddb497111d4f/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b57f41c87ba0ea0d13d98b696058864d3dea949ecb4a376c3a4a52857330b83f", size = 3461813, upload-time = "2026-08-28T07:10:11.594Z" },
{ url = "https://files.pythonhosted.org/packages/75/77/7fd50b572b38670886e1c41986b785720e24a4b0be8b164fa09e2d915782/grpcio_tools-1.83.1-cp312-cp312-win32.whl", hash = "sha256:eaf9217d67960016fdd2932f28239ec14716a15b783baa8d4b012937098531f1", size = 1022492, upload-time = "2026-08-28T07:10:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/45e04d9a921e10158b84b099dbdd25e6d56776d508872c68922a61ff85fd/grpcio_tools-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcbdd3422ca50ed6f880f9974df50796fb355f4051c55a464423a6423a3f0393", size = 1192288, upload-time = "2026-08-28T07:10:14.777Z" },
{ url = "https://files.pythonhosted.org/packages/d7/34/917a23240209fa5e020d68522b3d11d00da2acc8a784e215aec37b66cc0a/grpcio_tools-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:7c4ebbf3b13e481134fdf40fa49cb1f570ccb6b1a52c9ccded260428d79add6a", size = 2652846, upload-time = "2026-08-28T07:10:16.778Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/e0e2c3cfb5bb1311f11fce1176f47d84374e592ca6abf64d579c9dff701c/grpcio_tools-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:fe937a0cb78397abdf3ece759beed1fe5763dc530d8165b8b8691cc0770f2523", size = 5963532, upload-time = "2026-08-28T07:10:18.731Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/ce3d17a54ae754bed008303420e3ab88e8c26bd1e97722df188ee22b4583/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93970064770f45789796c579c9e70fd2371621f5e2c07e64cf0decf7b41d8b6c", size = 2705094, upload-time = "2026-08-28T07:10:20.534Z" },
{ url = "https://files.pythonhosted.org/packages/b2/74/049150098e3dac26053f03d0b3e2f25da354c476302c6a31bb287f1a8493/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c8c845f2b7d5a229377e3b10ffa281a93fb73e436a0078dc4a58c761ed32434", size = 3033063, upload-time = "2026-08-28T07:10:22.268Z" },
{ url = "https://files.pythonhosted.org/packages/40/fe/e9ec5fb11997318f7dabb1ff7b910b2f76d30c570d0ba56150733f256c25/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e15a089141788fe1268044ccdc43e1467c92d60387ebaf289dfd22a68227d16", size = 2773648, upload-time = "2026-08-28T07:10:23.979Z" },
{ url = "https://files.pythonhosted.org/packages/b4/05/92c1d00dd71fcce02e928f3895571e07b030463ce45e187d162317fcfadf/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b7a57ce7ee79917d6db94d7ff8356e91c6768b9db62f26f03cd628ec5aacb0b", size = 3229788, upload-time = "2026-08-28T07:10:25.879Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f5/974254c820c5e1f391f7f4cbade03befde72808d8323bbf9d2422614ce17/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fff7380f022deb12e4ca6cf9b26ef0000c1cf9ec8b2af2675a3b5afe42f68828", size = 3802531, upload-time = "2026-08-28T07:10:27.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/c2/816b26ee8f4353ea3207dae5691b08ea9bf53e6a4bacc798742bd3066c66/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0bcb9d5f1f0cd550a45ac811f5bdf5241df1f924ac7c70d186eba7d9a6260e9c", size = 3461030, upload-time = "2026-08-28T07:10:29.611Z" },
{ url = "https://files.pythonhosted.org/packages/75/ab/a4c629a5cacaefd468dd8152269dd3a578dd0e8858ed8691878a9c3f545f/grpcio_tools-1.83.1-cp313-cp313-win32.whl", hash = "sha256:08197f91352f0944957372b2c82a4fc64d522b75b5c8c4cdc47186e20e4f7fb6", size = 1022120, upload-time = "2026-08-28T07:10:31.233Z" },
{ url = "https://files.pythonhosted.org/packages/ad/37/b62a44c72df3cc61917f9d7cc42af0d32f60e7ca8b892dfcd95330eae68b/grpcio_tools-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e9eb45376a8e1d22e2ef067518616738272de223cbb13b2424d7880d04e4dbe", size = 1191938, upload-time = "2026-08-28T07:10:33.108Z" },
{ url = "https://files.pythonhosted.org/packages/34/2b/4e8f3bff52b933c52c853e3a51db8d5e87c71fc0ec3903ef9e432b25e4c5/grpcio_tools-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:f54955b95b17e83f8075303a31fa8f1aa64ad22d6f046e0070e9973918327202", size = 2652838, upload-time = "2026-08-28T07:10:35.035Z" },
{ url = "https://files.pythonhosted.org/packages/91/01/f5b3648285126a741a1a60108971c25acb0532af8fd2ffb29990747ff8d8/grpcio_tools-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0f4c3d39546b0e6add25affd096170347c10437d78189fe2d0a026f7cbc5cb57", size = 5963473, upload-time = "2026-08-28T07:10:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/65/1d/95fc82961423365edc3ac6f98bc12ac96429bfbc5c43ede9ff40354aa273/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34c8bbfe6e1ed19524010351a27c0d43e185b3d542cf4ab83ad9774d4c9000e1", size = 2705302, upload-time = "2026-08-28T07:10:39.166Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c5/82e29f48fcb5cf77b78c427ccaf5808b1b276cadd42955ceec960ec8daff/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:004bef39f47db3ed93caf58175ad3d3800f53d16a04b493166be945a0b575df3", size = 3033047, upload-time = "2026-08-28T07:10:41.492Z" },
{ url = "https://files.pythonhosted.org/packages/89/38/f17b2902166320912fff7ecb4227a69ddf8edb544702045c3aba3076de0a/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41a4ce8c2cddb38b50d5befe4f40b124f77d68021e2e47f80142f6138d802d8f", size = 2773829, upload-time = "2026-08-28T07:10:43.367Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/974b54afc6ea3d7d8b51671d0c64effd9a92691a887e9bbf62c8b6213f03/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b52cc836922456d56f25ff63b277410a6d9ad682863f064c1f735a19328432a3", size = 3229905, upload-time = "2026-08-28T07:10:45.758Z" },
{ url = "https://files.pythonhosted.org/packages/8a/87/e9d5ff123ff80d0a7d502e9d2452305812d904e17afb88b3b23d892f3334/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b73afd4303f2b026f8d8f3d7f07f5f22a2f2c3ecb05758af30205db0b16539b9", size = 3802600, upload-time = "2026-08-28T07:10:47.977Z" },
{ url = "https://files.pythonhosted.org/packages/4f/2f/66d8cdc5a81f9723f2b7b2f32d29a757b3bd2eac2116746014626feb83d6/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df9a2b346b98ec28d5666b43d2c00bb533976faa13f3fab2897890b3703a968a", size = 3461303, upload-time = "2026-08-28T07:10:50.178Z" },
{ url = "https://files.pythonhosted.org/packages/dd/f3/3989a42f1dcc1a1c59f06f5e4fbf48bc50a53a6cc5083c0b6a7aef4c1929/grpcio_tools-1.83.1-cp314-cp314-win32.whl", hash = "sha256:a8cafccbe4b83bcc297920796ae5a784c700c9fbef01705397007bcb4652e070", size = 1045048, upload-time = "2026-08-28T07:10:52.123Z" },
{ url = "https://files.pythonhosted.org/packages/11/bf/be8370e2c3f0108aaae33bd609c359d593648959cc9efe69e6b59ec88a41/grpcio_tools-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:9a40b779ec4bbd042279957cd1d0f42d4cbe333878d3d59ceeebe1cf3148528a", size = 1224200, upload-time = "2026-08-28T07:10:54.085Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -347,6 +431,8 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "greenlet" }, { name = "greenlet" },
{ name = "grpcio" },
{ name = "grpcio-tools" },
{ name = "hunjing-lingtai" }, { name = "hunjing-lingtai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -370,6 +456,8 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30" }, { name = "asyncpg", specifier = ">=0.30" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "greenlet", specifier = ">=3.5.5" }, { name = "greenlet", specifier = ">=3.5.5" },
{ name = "grpcio", specifier = ">=1.83.1" },
{ name = "grpcio-tools", specifier = ">=1.83.1" },
{ name = "hunjing-lingtai", editable = "../engine/bindings" }, { name = "hunjing-lingtai", editable = "../engine/bindings" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
@@ -676,6 +764,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "protobuf"
version = "7.36.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/73/f66c748df06e7fe24e658eddd600d19c4b40bad836c97ce2d0ad9851fb6b/protobuf-7.36.1.tar.gz", hash = "sha256:d0f6470f0ce2b84e3feaea2d4b816378b37ba4d4aa08a274305373de93e2d524", size = 512499, upload-time = "2026-08-31T22:40:04.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/6c/3a54a58f2948b0f485df9ecdd06590f15d0a7abf46a89d50c3de709ff4ff/protobuf-7.36.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:3cf2ee25d006cee57294a1196ea43b37feb78e0dcd1e8af5c1aeddb777655aca", size = 456046, upload-time = "2026-08-31T22:39:56.865Z" },
{ url = "https://files.pythonhosted.org/packages/6e/08/9f9548793c771095245c0eeaf0c84b76b16a5ef26158043d456f551344a0/protobuf-7.36.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:43d3d37b1eb24c113b9b7d02008cac44e423f00b611b7781ae998d7623972969", size = 344226, upload-time = "2026-08-31T22:39:58.263Z" },
{ url = "https://files.pythonhosted.org/packages/fe/51/1bdbd612fa3c51e42ea6f45d05e84dc748ddcd2663e1aa1e89a00a33facd/protobuf-7.36.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:39c518c05586c016d7874ff6079ee115bcec1ea5fbb1d177fbf7867ef4c67e44", size = 357229, upload-time = "2026-08-31T22:39:59.198Z" },
{ url = "https://files.pythonhosted.org/packages/22/df/c799fe7a05ef16ba853a59db01f3a2c5f7d0676469589ccc4874f76a2a88/protobuf-7.36.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:97198b77e369a0abd8e262b8f6c7266c55ddb796a3a12c76d7b8881188ed83aa", size = 343228, upload-time = "2026-08-31T22:40:00.179Z" },
{ url = "https://files.pythonhosted.org/packages/3d/33/d4724ec5d86d496fe4108e220618aa0837ad3423a7af7ccdd9684ccc77c8/protobuf-7.36.1-cp310-abi3-win32.whl", hash = "sha256:0b53ce95272aad50ad25d7ff03373743209822e8ba42ea7fad27d2bee1547d00", size = 443002, upload-time = "2026-08-31T22:40:01.32Z" },
{ url = "https://files.pythonhosted.org/packages/db/37/155788a0d8daded960375af604202805308169f9b859419ea0aa370946e2/protobuf-7.36.1-cp310-abi3-win_amd64.whl", hash = "sha256:51139351435d9b43d88a55eaa49fb6f737fbb478fb0cbf2cf694d1a04a9d3363", size = 456518, upload-time = "2026-08-31T22:40:02.494Z" },
{ url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.5" version = "2.13.5"
@@ -898,6 +1001,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
] ]
[[package]]
name = "setuptools"
version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.52" version = "2.0.52"
+17
View File
@@ -0,0 +1,17 @@
# Hunjing backend 镜像(uv + slim)
FROM python:3.12-slim AS base
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# 先装依赖(层缓存)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
# 源码与引擎绑定(绑定随源构建)
COPY . .
RUN uv sync --frozen
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+37
View File
@@ -0,0 +1,37 @@
# 自托管编排(标准服务器档,ADR-024 统一 PostgreSQL)
# 用法:docker compose -f docker/docker-compose.app.yml up -d --build
services:
hunjing-postgres:
image: pgvector/pgvector:pg17
container_name: hunjing-postgres
restart: unless-stopped
environment:
POSTGRES_USER: hunjing
POSTGRES_PASSWORD: ${HUNJING_DB_PASSWORD:?请在 .env 设置 HUNJING_DB_PASSWORD}
POSTGRES_DB: hunjing
volumes:
- hunjing-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hunjing -d hunjing"]
interval: 5s
timeout: 3s
retries: 12
hunjing-api:
build:
context: ../backend
dockerfile: ../docker/Dockerfile.backend
container_name: hunjing-api
restart: unless-stopped
depends_on:
hunjing-postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://hunjing:${HUNJING_DB_PASSWORD}@hunjing-postgres:5432/hunjing
HUNJING_ENV: production
HUNJING_LOG_LEVEL: info
ports:
- "127.0.0.1:8000:8000"
volumes:
hunjing-pgdata:
+4 -3
View File
@@ -17,14 +17,14 @@ endif()
set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
add_library(hunjing SHARED src/lingtai.cpp src/transform.cpp) add_library(hunjing SHARED src/lingtai.cpp src/transform.cpp src/modules.cpp)
target_include_directories(hunjing target_include_directories(hunjing
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
# 单元测试(doctest,ADR-025);单头直接引入,不用 FetchContent 拉网络 # 单元测试(doctest,ADR-025);单头直接引入,不用 FetchContent 拉网络
add_executable(lingtai_tests tests/test_main.cpp tests/test_core.cpp tests/test_transform.cpp add_executable(lingtai_tests tests/test_main.cpp tests/test_core.cpp tests/test_transform.cpp
src/lingtai.cpp src/transform.cpp) tests/test_modules.cpp src/lingtai.cpp src/transform.cpp src/modules.cpp)
target_include_directories(lingtai_tests PRIVATE target_include_directories(lingtai_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/src
@@ -36,7 +36,8 @@ add_test(NAME lingtai_tests COMMAND lingtai_tests)
# fuzz 目标(ADR-025:hj_transform 为必测目标;需要 clang libFuzzer) # fuzz 目标(ADR-025:hj_transform 为必测目标;需要 clang libFuzzer)
option(HJ_BUILD_FUZZER "Build libFuzzer targets" OFF) option(HJ_BUILD_FUZZER "Build libFuzzer targets" OFF)
if(HJ_BUILD_FUZZER) if(HJ_BUILD_FUZZER)
add_executable(fuzz_transform fuzz/fuzz_transform.cpp src/lingtai.cpp src/transform.cpp) add_executable(fuzz_transform fuzz/fuzz_transform.cpp src/lingtai.cpp src/transform.cpp
src/modules.cpp)
target_include_directories(fuzz_transform PRIVATE target_include_directories(fuzz_transform PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src) ${CMAKE_CURRENT_SOURCE_DIR}/src)
+6
View File
@@ -0,0 +1,6 @@
module hunjing/lingtai
go 1.23
// 引擎源码经本地相对路径编译(cgo 自动发现包目录下 .cpp 文件的规则不适用,
// 改由 lingtai.go 的 #cgo 指令与同一包内的 .cpp 文件列表驱动)。
@@ -0,0 +1,4 @@
package lingtai
// 引擎源码直接编译(cgo 把 .cpp 交给 C++ 编译器)。
// 本文件仅承载构建期声明注释,无运行时代码。
+1
View File
@@ -0,0 +1 @@
/Users/chenxin/code/python/hunjing/engine/include/hunjing
+153
View File
@@ -0,0 +1,153 @@
// Package lingtai 是灵台引擎的官方 Go 绑定(cgo,ADR-016/017)。
//
// 自包含编译引擎源码;薄封装:仅类型转换与错误映射(契约 lingtai-ffi 第 8 节)。
package lingtai
/*
#cgo CXXFLAGS: -std=c++23 -O2
#cgo CFLAGS: -I${SRCDIR}/../../include
#include <stdlib.h>
#include "hunjing/lingtai.h"
// C 桥接(C++ 编译单元在下方 sources 中声明为 C++,由 cgo 链接 C++ 编译器)
typedef int (*unused_cb)();
*/
import "C"
import (
"fmt"
"unsafe"
)
// 直接编译引擎源码(cgo 自动以 C++ 处理 .cpp 文件)
// 注:通过 #cgo LDFLAGS/CXXFLAGS 与文件列表在同目录 cxx_sources.go 中声明。
// Engine 是线程安全的引擎句柄(RAII)。
type Engine struct {
ptr *C.HjEngine
}
// New 创建引擎;ABI major 不匹配返回错误(fail-fast,不静默降级)。
func New() (*Engine, error) {
var ptr *C.HjEngine
st := C.hj_engine_new(C.HJ_ABI_VERSION, &ptr)
if st != C.HJ_OK {
return nil, fmt.Errorf("lingtai: engine init failed: %s", C.GoString(C.hj_last_error()))
}
return &Engine{ptr: ptr}, nil
}
// Close 释放引擎(幂等安全:nil 检查)。
func (e *Engine) Close() error {
if e == nil || e.ptr == nil {
return nil
}
st := C.hj_engine_free(e.ptr)
e.ptr = nil
if st != C.HJ_OK {
return fmt.Errorf("lingtai: engine free: %s", C.GoString(C.hj_last_error()))
}
return nil
}
func (e *Engine) valid() error {
if e == nil || e.ptr == nil {
return fmt.Errorf("lingtai: engine handle is nil")
}
return nil
}
// TransformVersion 返回 Markdown 解析器版本。
func TransformVersion() string { return C.GoString(C.hj_transform_version()) }
// TransformMarkdown 将 Markdown 规范化为 canonical ContentBlock JSON。
func (e *Engine) TransformMarkdown(input string) (string, error) {
if err := e.valid(); err != nil {
return "", err
}
cin := C.CString(input)
defer C.free(unsafe.Pointer(cin))
var buf *C.HjBuffer
st := C.hj_transform_markdown(e.ptr, cin, C.size_t(len(input)), &buf)
if st != C.HJ_OK {
return "", fmt.Errorf("lingtai: transform: %s", C.GoString(C.hj_last_error()))
}
defer C.hj_buffer_free(buf)
return C.GoStringN((*C.char)(C.hj_buffer_data(buf)), C.int(C.hj_buffer_len(buf))), nil
}
// ValidateURL 校验 URL 协议(仅 http/https)。
func ValidateURL(url string) error {
cu := C.CString(url)
defer C.free(unsafe.Pointer(cu))
if st := C.hj_policy_validate_url(cu); st != C.HJ_OK {
return fmt.Errorf("lingtai: url denied: %s", C.GoString(C.hj_last_error()))
}
return nil
}
// ValidatePath 校验路径在授权根内(拒绝 .. 与越界)。
func ValidatePath(path, root string) error {
cp := C.CString(path)
cr := C.CString(root)
defer C.free(unsafe.Pointer(cp))
defer C.free(unsafe.Pointer(cr))
if st := C.hj_policy_validate_path(cp, cr); st != C.HJ_OK {
return fmt.Errorf("lingtai: path denied: %s", C.GoString(C.hj_last_error()))
}
return nil
}
// PolicyDecision 是四维裁决结果。
type PolicyDecision int
const (
DecisionAllow PolicyDecision = 0
DecisionAsk PolicyDecision = 1
DecisionDeny PolicyDecision = 2
)
// Autonomy levels(契约 autonomy_level)。
const (
AutonomyIntrospective = 0
AutonomyRuleBound = 1
AutonomyApprovalReq = 2
AutonomyForbidden = 3
)
// Risk levels。
const (
RiskReadOnly = 0
RiskSideEffect = 1
RiskHighImpact = 2
)
// Decide 运行决策表(autonomy × risk → allow/ask/deny;非法输入 deny)。
func Decide(autonomy, risk int) PolicyDecision {
return PolicyDecision(C.hj_policy_decide(C.int(autonomy), C.int(risk)))
}
// CheckBudget 校验预算(used >= max 返回错误)。
func CheckBudget(used, maximum int64) error {
if st := C.hj_plan_check_budget(C.int64_t(used), C.int64_t(maximum)); st != C.HJ_OK {
return fmt.Errorf("lingtai: budget: %s", C.GoString(C.hj_last_error()))
}
return nil
}
// RecallRerank 对候选做确定性重排,返回 canonical JSON。
func (e *Engine) RecallRerank(candidatesJSON string) (string, error) {
if err := e.valid(); err != nil {
return "", err
}
cj := C.CString(candidatesJSON)
defer C.free(unsafe.Pointer(cj))
var buf *C.HjBuffer
st := C.hj_recall_rerank(cj, &buf)
if st != C.HJ_OK {
return "", fmt.Errorf("lingtai: rerank: %s", C.GoString(C.hj_last_error()))
}
defer C.hj_buffer_free(buf)
return C.GoStringN((*C.char)(C.hj_buffer_data(buf)), C.int(C.hj_buffer_len(buf))), nil
}
@@ -0,0 +1,63 @@
package lingtai
import "testing"
func TestEngineLifecycleAndTransform(t *testing.T) {
e, err := New()
if err != nil {
t.Fatal(err)
}
defer e.Close()
if TransformVersion() != "hunjing-markdown-v1" {
t.Fatalf("parser version: %s", TransformVersion())
}
out, err := e.TransformMarkdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
if err != nil {
t.Fatal(err)
}
want := `{"parser_version":"hunjing-markdown-v1","blocks":[{"type":"markdown","text":"x","flavor":"hunjing-gfm-v1","status":"completed"},{"type":"mermaid","source":"A-->B","status":"completed"},{"type":"math","notation":"latex","display":"block","source":"z","status":"completed"}]}`
if out != want {
t.Fatalf("golden mismatch:\n got: %s\nwant: %s", out, want)
}
}
func TestPolicyPrimitives(t *testing.T) {
if err := ValidateURL("https://ok.example"); err != nil {
t.Fatal(err)
}
if err := ValidateURL("javascript:alert(1)"); err == nil {
t.Fatal("javascript: 应被拒绝")
}
if err := ValidatePath("/data/d/f", "/data/d"); err != nil {
t.Fatal(err)
}
if err := ValidatePath("/data/d/../x", "/data/d"); err == nil {
t.Fatal(".. 应被拒绝")
}
if d := Decide(AutonomyRuleBound, RiskReadOnly); d != DecisionAllow {
t.Fatalf("循律+只读应 allow: %d", d)
}
if d := Decide(AutonomyIntrospective, RiskHighImpact); d != DecisionDeny {
t.Fatalf("自省+高影响应 deny: %d", d)
}
if err := CheckBudget(10, 10); err == nil {
t.Fatal("预算达限应报错")
}
}
func TestRecallRerankDeterministic(t *testing.T) {
e, _ := New()
defer e.Close()
in := `{"query_terms":["魂镜"],"candidates":[{"id":"a","text":"魂镜 系统"},{"id":"b","text":"无"}]}`
out1, err := e.RecallRerank(in)
if err != nil {
t.Fatal(err)
}
out2, _ := e.RecallRerank(in)
if out1 != out2 {
t.Fatalf("确定性失败: %s vs %s", out1, out2)
}
if out1 != `{"ranked":[{"id":"a","score":10},{"id":"b","score":0}]}` {
t.Fatalf("golden mismatch: %s", out1)
}
}
+228
View File
@@ -0,0 +1,228 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hunjing-lingtai"
version = "0.1.0"
dependencies = [
"bindgen",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "hunjing-lingtai"
version = "0.1.0"
edition = "2021"
description = "灵台引擎官方 Rust 绑定(ADR-016/017)"
license = "Apache-2.0"
[lib]
path = "src/lib.rs"
[build-dependencies]
bindgen = "0.71"
+56
View File
@@ -0,0 +1,56 @@
use std::env;
use std::path::PathBuf;
fn main() {
let header = PathBuf::from("../../include/hunjing/lingtai.h")
.canonicalize()
.expect("找不到引擎公共头");
let bindings = bindgen::Builder::default()
.header(header.to_string_lossy())
.allowlist_function("hj_.*")
.allowlist_type("Hj.*")
.allowlist_var("HJ_.*")
.generate()
.expect("bindgen 生成失败(需要 libclang)");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_dir.join("bindings.rs"))
.expect("写 bindings.rs 失败");
println!("cargo:rerun-if-changed={}", header.display());
// 直接编译引擎源码(自包含)
let src = PathBuf::from("../../src").canonicalize().expect("找不到引擎源码");
for cpp in ["lingtai.cpp", "transform.cpp", "modules.cpp"] {
println!("cargo:rustc-cxx=g++");
let _ = cpp;
}
// 用 cc crate 风格手动调用:为最小依赖,此处直接输出编译指令给 rustc 链接预编译静态库
// 简化:编译为单个静态库由 build.rs 调用 clang++
// 逐个编译 .o,再归档为静态库
let objects: Vec<_> = ["lingtai.cpp", "transform.cpp", "modules.cpp"]
.iter()
.map(|cpp| {
let obj = out_dir.join(format!("{}.o", cpp.replace(".cpp", "")));
let status = std::process::Command::new("clang++")
.args(["-std=c++23", "-O2", "-c"])
.arg(src.join(cpp))
.arg("-I").arg(src.parent().unwrap().join("include"))
.arg("-I").arg(&src)
.arg("-fvisibility=hidden")
.arg("-o").arg(&obj)
.status()
.expect("clang++ 启动失败");
assert!(status.success(), "引擎编译失败:{cpp}");
obj
})
.collect();
let out_lib = out_dir.join("libhunjing_engine.a");
let status = std::process::Command::new("ar")
.arg("crus").arg(&out_lib)
.args(&objects)
.status()
.expect("ar 启动失败");
assert!(status.success(), "归档失败");
println!("cargo:rustc-link-search=native={}", out_dir.display());
println!("cargo:rustc-link-lib=static=hunjing_engine");
println!("cargo:rustc-link-lib=dylib=c++");
}
+158
View File
@@ -0,0 +1,158 @@
//! 灵台引擎官方 Rust 绑定(bindgen 生成 + 安全包装,ADR-016/017)。
//! 薄封装:句柄 RAII、错误转 `Result`;禁止裸指针外泄(契约 lingtai-ffi 第 8 节)。
#![allow(clippy::missing_safety_doc)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
use std::ffi::{c_char, c_void, CStr, CString};
use std::fmt;
#[derive(Debug)]
pub enum Error {
InvalidArgument(String),
OutOfMemory,
UnsupportedAbi,
HandleInvalid,
ParseFailed(String),
LimitExceeded(String),
PolicyDenied(String),
Internal(String),
Unknown(i32, String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::InvalidArgument(m) => write!(f, "参数不合法:{m}"),
Error::OutOfMemory => write!(f, "内存分配失败"),
Error::UnsupportedAbi => write!(f, "ABI major 不匹配"),
Error::HandleInvalid => write!(f, "句柄无效"),
Error::ParseFailed(m) => write!(f, "解析失败:{m}"),
Error::LimitExceeded(m) => write!(f, "超限:{m}"),
Error::PolicyDenied(m) => write!(f, "策略拒绝:{m}"),
Error::Internal(m) => write!(f, "内部错误:{m}"),
Error::Unknown(code, m) => write!(f, "未知错误 {code}:{m}"),
}
}
}
impl std::error::Error for Error {}
fn map_status(st: HjStatus, message: String) -> Result<(), Error> {
match st {
HjStatus_HJ_OK => Ok(()),
HjStatus_HJ_ERR_INVALID_ARGUMENT => Err(Error::InvalidArgument(message)),
HjStatus_HJ_ERR_OUT_OF_MEMORY => Err(Error::OutOfMemory),
HjStatus_HJ_ERR_UNSUPPORTED_ABI => Err(Error::UnsupportedAbi),
HjStatus_HJ_ERR_HANDLE_INVALID => Err(Error::HandleInvalid),
HjStatus_HJ_ERR_PARSE_FAILED => Err(Error::ParseFailed(message)),
HjStatus_HJ_ERR_LIMIT_EXCEEDED => Err(Error::LimitExceeded(message)),
HjStatus_HJ_ERR_POLICY_DENIED => Err(Error::PolicyDenied(message)),
HjStatus_HJ_ERR_INTERNAL => Err(Error::Internal(message)),
other => Err(Error::Unknown(other as i32, message)),
}
}
fn last_error() -> String {
unsafe {
let ptr = hj_last_error();
if ptr.is_null() {
String::new()
} else {
CStr::from_ptr(ptr).to_string_lossy().into_owned()
}
}
}
/// 引擎句柄(RAII:Drop 自动释放)。
pub struct Engine {
ptr: *mut HjEngine,
}
impl Engine {
pub fn new() -> Result<Self, Error> {
let mut ptr: *mut HjEngine = std::ptr::null_mut();
let st = unsafe { hj_engine_new(HJ_ABI_VERSION, &mut ptr) };
map_status(st, last_error())?;
Ok(Engine { ptr })
}
/// Markdown → canonical ContentBlock JSON。
pub fn transform_markdown(&self, input: &str) -> Result<String, Error> {
let cin = CString::new(input).map_err(|_| Error::InvalidArgument("含 NUL".into()))?;
let mut buf: *mut HjBuffer = std::ptr::null_mut();
let st = unsafe {
hj_transform_markdown(self.ptr, cin.as_ptr(), cin.as_bytes().len(), &mut buf)
};
map_status(st, last_error())?;
take_buffer(buf)
}
/// 候选重排(确定性)。
pub fn recall_rerank(&self, candidates_json: &str) -> Result<String, Error> {
let cin =
CString::new(candidates_json).map_err(|_| Error::InvalidArgument("含 NUL".into()))?;
let mut buf: *mut HjBuffer = std::ptr::null_mut();
let st = unsafe { hj_recall_rerank(cin.as_ptr(), &mut buf) };
map_status(st, last_error())?;
take_buffer(buf)
}
}
impl Drop for Engine {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { hj_engine_free(self.ptr) };
}
}
}
// Send/Sync:HjEngine 线程安全(契约第 4 节)
unsafe impl Send for Engine {}
unsafe impl Sync for Engine {}
fn take_buffer(buf: *mut HjBuffer) -> Result<String, Error> {
if buf.is_null() {
return Err(Error::Internal("缓冲为空".into()));
}
unsafe {
let data = hj_buffer_data(buf) as *const c_char;
let len = hj_buffer_len(buf);
let slice = std::slice::from_raw_parts(data as *const u8, len);
let out = String::from_utf8_lossy(slice).into_owned();
hj_buffer_free(buf);
Ok(out)
}
}
/// URL 协议校验(仅 http/https)。
pub fn validate_url(url: &str) -> Result<(), Error> {
let cu = CString::new(url).map_err(|_| Error::InvalidArgument("含 NUL".into()))?;
let st = unsafe { hj_policy_validate_url(cu.as_ptr()) };
map_status(st, last_error())
}
/// 路径约束(授权根内、拒绝 ..)。
pub fn validate_path(path: &str, root: &str) -> Result<(), Error> {
let cp = CString::new(path).map_err(|_| Error::InvalidArgument("含 NUL".into()))?;
let cr = CString::new(root).map_err(|_| Error::InvalidArgument("含 NUL".into()))?;
let st = unsafe { hj_policy_validate_path(cp.as_ptr(), cr.as_ptr()) };
map_status(st, last_error())
}
/// 决策表(契约 autonomy_level × risk)。
pub fn decide(autonomy: i32, risk: i32) -> i32 {
unsafe { hj_policy_decide(autonomy, risk) }
}
/// 预算核算。
pub fn check_budget(used: i64, maximum: i64) -> Result<(), Error> {
let st = unsafe { hj_plan_check_budget(used, maximum) };
map_status(st, last_error())
}
/// 解析器版本。
pub fn transform_version() -> String {
unsafe { CStr::from_ptr(hj_transform_version()).to_string_lossy().into_owned() }
}
+33
View File
@@ -0,0 +1,33 @@
use hunjing_lingtai::{decide, check_budget, transform_version, validate_path, validate_url, Engine};
#[test]
fn lifecycle_and_transform_golden() {
assert_eq!(transform_version(), "hunjing-markdown-v1");
let engine = Engine::new().expect("引擎初始化");
let out = engine
.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
.expect("transform");
let want = r#"{"parser_version":"hunjing-markdown-v1","blocks":[{"type":"markdown","text":"x","flavor":"hunjing-gfm-v1","status":"completed"},{"type":"mermaid","source":"A-->B","status":"completed"},{"type":"math","notation":"latex","display":"block","source":"z","status":"completed"}]}"#;
assert_eq!(out, want);
}
#[test]
fn policy_primitives() {
validate_url("https://ok.example").unwrap();
assert!(validate_url("javascript:alert(1)").is_err());
validate_path("/data/d/f", "/data/d").unwrap();
assert!(validate_path("/data/d/../x", "/data/d").is_err());
assert_eq!(decide(1, 0), 0); // 循律+只读 = allow
assert_eq!(decide(0, 2), 2); // 自省+高影响 = deny
assert!(check_budget(10, 10).is_err());
}
#[test]
fn recall_rerank_deterministic() {
let engine = Engine::new().unwrap();
let input = r#"{"query_terms":["魂镜"],"candidates":[{"id":"a","text":"魂镜 系统"},{"id":"b","text":"无"}]}"#;
let out1 = engine.recall_rerank(input).unwrap();
let out2 = engine.recall_rerank(input).unwrap();
assert_eq!(out1, out2);
assert_eq!(out1, r#"{"ranked":[{"id":"a","score":10},{"id":"b","score":0}]}"#);
}
+37
View File
@@ -76,6 +76,43 @@ HJ_API const char *hj_transform_version(void);
HJ_API HjStatus hj_transform_markdown( HJ_API HjStatus hj_transform_markdown(
HjEngine *engine, const char *input, size_t input_len, HjBuffer **out); HjEngine *engine, const char *input, size_t input_len, HjBuffer **out);
// ===== 小周天(hj_stream):确定性流事件编码 =====
// 流会话句柄:Run 内 sequence 与 block delta_index 计数(契约第 5 节)。
typedef struct HjStream HjStream;
HJ_API HjStatus hj_stream_new(HjEngine *engine, HjStream **out_stream);
HJ_API HjStatus hj_stream_free(HjStream *stream);
// 追加文本增量:输出 content.block.delta envelope 的 canonical JSON
// (含 sequence 与 delta_index,键序确定);输出经 HjBuffer 返回。
HJ_API HjStatus hj_stream_append_delta(
HjStream *stream, const char *block_id, const char *text, size_t text_len,
HjBuffer **out);
HJ_API uint64_t hj_stream_sequence(const HjStream *stream);
// ===== 烛照(hj_policy):确定性判定原语 =====
// URL 校验:仅 http/https;拒绝 file/javascript/data 等协议(契约 3.4)。
HJ_API HjStatus hj_policy_validate_url(const char *url);
// 路径约束:canonicalize 后必须位于 root 内;越界返回 HJ_ERR_POLICY_DENIED。
HJ_API HjStatus hj_policy_validate_path(const char *path, const char *root);
// 幂等键:非空且不超过 128 字符(契约第 6 节)。
HJ_API HjStatus hj_policy_validate_idempotency_key(const char *key);
// 决策表:autonomy(0=自省 1=循律 2=请命 3=禁域)× risk(0=只读 1=副作用 2=高影响)
// 返回 0=allow 1=ask 2=deny;非法输入返回 2。
HJ_API int hj_policy_decide(int autonomy_level, int risk_level);
// ===== 洛书(hj_plan):预算核算 =====
// 已用 >= 上限 返回 HJ_ERR_LIMIT_EXCEEDED;负数参数 HJ_ERR_INVALID_ARGUMENT。
HJ_API HjStatus hj_plan_check_budget(int64_t used, int64_t maximum);
// ===== 河图(hj_recall):确定性重排 =====
// 候选重排:输入 {"query_terms": [".."], "candidates": [{"id","text"}]}
// 输出按确定性得分(词频 × 简单位置权重)排序的 canonical JSON。
HJ_API HjStatus hj_recall_rerank(const char *candidates_json, HjBuffer **out);
#ifdef __cplusplus #ifdef __cplusplus
} // extern "C" } // extern "C"
#endif #endif
+127
View File
@@ -0,0 +1,127 @@
syntax = "proto3";
package liushen.v1;
// 灵台引擎独立服务形态(契约 docs/contracts/lingtai-service.md)。
// 部署内部网络专用;错误映射见契约第 6 节。字段语义与进程内 C ABI JSON 边界同源。
service LingtaiEngine {
// 版本、ABI major、parser version、能力位
rpc GetEngineInfo(GetEngineInfoRequest) returns (GetEngineInfoResponse);
// Markdown → canonical ContentBlock JSON
rpc TransformMarkdown(TransformMarkdownRequest) returns (TransformMarkdownResponse);
// canonical Block → 可移植 Markdown 重建(预留:实现随 WP-03 导出)
rpc RebuildExport(RebuildExportRequest) returns (RebuildExportResponse);
// 双向流:宿主喂增量,服务回 content.block.* envelope JSON
rpc OpenStream(stream StreamFrame) returns (stream StreamEvent);
// 计划 DAG 预算核算(最小:标量预算)
rpc CheckBudget(CheckBudgetRequest) returns (CheckBudgetResponse);
// 候选确定性重排
rpc RerankRecall(RerankRecallRequest) returns (RerankRecallResponse);
// 四维裁决(autonomy × risk)
rpc EvaluatePolicy(EvaluatePolicyRequest) returns (EvaluatePolicyResponse);
// URL 协议白名单校验
rpc ValidateUrl(ValidateUrlRequest) returns (ValidateUrlResponse);
// Run/Turn 状态转换合法性
rpc CheckStateTransition(CheckStateTransitionRequest) returns (CheckStateTransitionResponse);
}
message GetEngineInfoRequest {}
message GetEngineInfoResponse {
uint32 abi_major = 1;
string parser_version = 2;
string engine_version = 3;
repeated string capabilities = 4;
}
message TransformMarkdownRequest {
string markdown = 1;
}
message TransformMarkdownResponse {
string canonical_json = 1; // canonical ContentBlock JSON(键序确定)
}
message RebuildExportRequest {
string canonical_json = 1;
}
message RebuildExportResponse {
string markdown = 1;
}
message StreamFrame {
oneof frame {
string block_started = 2; // block_id:开启新 block(重置 delta_index)
string text_delta = 3; // 文本增量
}
}
message StreamEvent {
string event_json = 1; // content.block.* envelope(canonical JSON)
}
message CheckBudgetRequest {
int64 used = 1;
int64 maximum = 2;
}
message CheckBudgetResponse {
bool allowed = 1;
string reason = 2;
}
message RerankRecallRequest {
string candidates_json = 1;
}
message RerankRecallResponse {
string ranked_json = 1;
}
message EvaluatePolicyRequest {
int32 autonomy_level = 1; // 0 自省 / 1 循律 / 2 请命 / 3 禁域
int32 risk_level = 2; // 0 只读 / 1 副作用 / 2 高影响
}
message EvaluatePolicyResponse {
int32 decision = 1; // 0 allow / 1 ask / 2 deny
}
message ValidateUrlRequest {
string url = 1;
}
message ValidateUrlResponse {
bool allowed = 1;
string reason = 2;
}
enum RunStatus {
RUN_STATUS_UNSPECIFIED = 0;
RUN_STATUS_QUEUED = 1;
RUN_STATUS_RUNNING = 2;
RUN_STATUS_WAITING_APPROVAL = 3;
RUN_STATUS_COMPLETED = 4;
RUN_STATUS_FAILED = 5;
RUN_STATUS_CANCELLED = 6;
}
message CheckStateTransitionRequest {
RunStatus from = 1;
RunStatus to = 2;
}
message CheckStateTransitionResponse {
bool valid = 1;
string reason = 2;
}
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: liushen/v1/liushen.proto
# Protobuf Python Version: 7.35.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
7,
35,
1,
'',
'liushen/v1/liushen.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18liushen/v1/liushen.proto\x12\nliushen.v1\"\x16\n\x14GetEngineInfoRequest\"p\n\x15GetEngineInfoResponse\x12\x11\n\tabi_major\x18\x01 \x01(\r\x12\x16\n\x0eparser_version\x18\x02 \x01(\t\x12\x16\n\x0e\x65ngine_version\x18\x03 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x04 \x03(\t\",\n\x18TransformMarkdownRequest\x12\x10\n\x08markdown\x18\x01 \x01(\t\"3\n\x19TransformMarkdownResponse\x12\x16\n\x0e\x63\x61nonical_json\x18\x01 \x01(\t\".\n\x14RebuildExportRequest\x12\x16\n\x0e\x63\x61nonical_json\x18\x01 \x01(\t\")\n\x15RebuildExportResponse\x12\x10\n\x08markdown\x18\x01 \x01(\t\"E\n\x0bStreamFrame\x12\x17\n\rblock_started\x18\x02 \x01(\tH\x00\x12\x14\n\ntext_delta\x18\x03 \x01(\tH\x00\x42\x07\n\x05\x66rame\"!\n\x0bStreamEvent\x12\x12\n\nevent_json\x18\x01 \x01(\t\"3\n\x12\x43heckBudgetRequest\x12\x0c\n\x04used\x18\x01 \x01(\x03\x12\x0f\n\x07maximum\x18\x02 \x01(\x03\"6\n\x13\x43heckBudgetResponse\x12\x0f\n\x07\x61llowed\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\".\n\x13RerankRecallRequest\x12\x17\n\x0f\x63\x61ndidates_json\x18\x01 \x01(\t\"+\n\x14RerankRecallResponse\x12\x13\n\x0branked_json\x18\x01 \x01(\t\"C\n\x15\x45valuatePolicyRequest\x12\x16\n\x0e\x61utonomy_level\x18\x01 \x01(\x05\x12\x12\n\nrisk_level\x18\x02 \x01(\x05\"*\n\x16\x45valuatePolicyResponse\x12\x10\n\x08\x64\x65\x63ision\x18\x01 \x01(\x05\"!\n\x12ValidateUrlRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\"6\n\x13ValidateUrlResponse\x12\x0f\n\x07\x61llowed\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"e\n\x1b\x43heckStateTransitionRequest\x12#\n\x04\x66rom\x18\x01 \x01(\x0e\x32\x15.liushen.v1.RunStatus\x12!\n\x02to\x18\x02 \x01(\x0e\x32\x15.liushen.v1.RunStatus\"=\n\x1c\x43heckStateTransitionResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t*\xc2\x01\n\tRunStatus\x12\x1a\n\x16RUN_STATUS_UNSPECIFIED\x10\x00\x12\x15\n\x11RUN_STATUS_QUEUED\x10\x01\x12\x16\n\x12RUN_STATUS_RUNNING\x10\x02\x12\x1f\n\x1bRUN_STATUS_WAITING_APPROVAL\x10\x03\x12\x18\n\x14RUN_STATUS_COMPLETED\x10\x04\x12\x15\n\x11RUN_STATUS_FAILED\x10\x05\x12\x18\n\x14RUN_STATUS_CANCELLED\x10\x06\x32\x98\x06\n\rLingtaiEngine\x12T\n\rGetEngineInfo\x12 .liushen.v1.GetEngineInfoRequest\x1a!.liushen.v1.GetEngineInfoResponse\x12`\n\x11TransformMarkdown\x12$.liushen.v1.TransformMarkdownRequest\x1a%.liushen.v1.TransformMarkdownResponse\x12T\n\rRebuildExport\x12 .liushen.v1.RebuildExportRequest\x1a!.liushen.v1.RebuildExportResponse\x12\x42\n\nOpenStream\x12\x17.liushen.v1.StreamFrame\x1a\x17.liushen.v1.StreamEvent(\x01\x30\x01\x12N\n\x0b\x43heckBudget\x12\x1e.liushen.v1.CheckBudgetRequest\x1a\x1f.liushen.v1.CheckBudgetResponse\x12Q\n\x0cRerankRecall\x12\x1f.liushen.v1.RerankRecallRequest\x1a .liushen.v1.RerankRecallResponse\x12W\n\x0e\x45valuatePolicy\x12!.liushen.v1.EvaluatePolicyRequest\x1a\".liushen.v1.EvaluatePolicyResponse\x12N\n\x0bValidateUrl\x12\x1e.liushen.v1.ValidateUrlRequest\x1a\x1f.liushen.v1.ValidateUrlResponse\x12i\n\x14\x43heckStateTransition\x12\'.liushen.v1.CheckStateTransitionRequest\x1a(.liushen.v1.CheckStateTransitionResponseb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'liushen.v1.liushen_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_RUNSTATUS']._serialized_start=1047
_globals['_RUNSTATUS']._serialized_end=1241
_globals['_GETENGINEINFOREQUEST']._serialized_start=40
_globals['_GETENGINEINFOREQUEST']._serialized_end=62
_globals['_GETENGINEINFORESPONSE']._serialized_start=64
_globals['_GETENGINEINFORESPONSE']._serialized_end=176
_globals['_TRANSFORMMARKDOWNREQUEST']._serialized_start=178
_globals['_TRANSFORMMARKDOWNREQUEST']._serialized_end=222
_globals['_TRANSFORMMARKDOWNRESPONSE']._serialized_start=224
_globals['_TRANSFORMMARKDOWNRESPONSE']._serialized_end=275
_globals['_REBUILDEXPORTREQUEST']._serialized_start=277
_globals['_REBUILDEXPORTREQUEST']._serialized_end=323
_globals['_REBUILDEXPORTRESPONSE']._serialized_start=325
_globals['_REBUILDEXPORTRESPONSE']._serialized_end=366
_globals['_STREAMFRAME']._serialized_start=368
_globals['_STREAMFRAME']._serialized_end=437
_globals['_STREAMEVENT']._serialized_start=439
_globals['_STREAMEVENT']._serialized_end=472
_globals['_CHECKBUDGETREQUEST']._serialized_start=474
_globals['_CHECKBUDGETREQUEST']._serialized_end=525
_globals['_CHECKBUDGETRESPONSE']._serialized_start=527
_globals['_CHECKBUDGETRESPONSE']._serialized_end=581
_globals['_RERANKRECALLREQUEST']._serialized_start=583
_globals['_RERANKRECALLREQUEST']._serialized_end=629
_globals['_RERANKRECALLRESPONSE']._serialized_start=631
_globals['_RERANKRECALLRESPONSE']._serialized_end=674
_globals['_EVALUATEPOLICYREQUEST']._serialized_start=676
_globals['_EVALUATEPOLICYREQUEST']._serialized_end=743
_globals['_EVALUATEPOLICYRESPONSE']._serialized_start=745
_globals['_EVALUATEPOLICYRESPONSE']._serialized_end=787
_globals['_VALIDATEURLREQUEST']._serialized_start=789
_globals['_VALIDATEURLREQUEST']._serialized_end=822
_globals['_VALIDATEURLRESPONSE']._serialized_start=824
_globals['_VALIDATEURLRESPONSE']._serialized_end=878
_globals['_CHECKSTATETRANSITIONREQUEST']._serialized_start=880
_globals['_CHECKSTATETRANSITIONREQUEST']._serialized_end=981
_globals['_CHECKSTATETRANSITIONRESPONSE']._serialized_start=983
_globals['_CHECKSTATETRANSITIONRESPONSE']._serialized_end=1044
_globals['_LINGTAIENGINE']._serialized_start=1244
_globals['_LINGTAIENGINE']._serialized_end=2036
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,459 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from liushen.v1 import liushen_pb2 as liushen_dot_v1_dot_liushen__pb2
GRPC_GENERATED_VERSION = '1.83.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in liushen/v1/liushen_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class LingtaiEngineStub:
"""灵台引擎独立服务形态(契约 docs/contracts/lingtai-service.md)。
部署内部网络专用;错误映射见契约第 6 节。字段语义与进程内 C ABI JSON 边界同源。
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.GetEngineInfo = channel.unary_unary(
'/liushen.v1.LingtaiEngine/GetEngineInfo',
request_serializer=liushen_dot_v1_dot_liushen__pb2.GetEngineInfoRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.GetEngineInfoResponse.FromString,
_registered_method=True)
self.TransformMarkdown = channel.unary_unary(
'/liushen.v1.LingtaiEngine/TransformMarkdown',
request_serializer=liushen_dot_v1_dot_liushen__pb2.TransformMarkdownRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.TransformMarkdownResponse.FromString,
_registered_method=True)
self.RebuildExport = channel.unary_unary(
'/liushen.v1.LingtaiEngine/RebuildExport',
request_serializer=liushen_dot_v1_dot_liushen__pb2.RebuildExportRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.RebuildExportResponse.FromString,
_registered_method=True)
self.OpenStream = channel.stream_stream(
'/liushen.v1.LingtaiEngine/OpenStream',
request_serializer=liushen_dot_v1_dot_liushen__pb2.StreamFrame.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.StreamEvent.FromString,
_registered_method=True)
self.CheckBudget = channel.unary_unary(
'/liushen.v1.LingtaiEngine/CheckBudget',
request_serializer=liushen_dot_v1_dot_liushen__pb2.CheckBudgetRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.CheckBudgetResponse.FromString,
_registered_method=True)
self.RerankRecall = channel.unary_unary(
'/liushen.v1.LingtaiEngine/RerankRecall',
request_serializer=liushen_dot_v1_dot_liushen__pb2.RerankRecallRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.RerankRecallResponse.FromString,
_registered_method=True)
self.EvaluatePolicy = channel.unary_unary(
'/liushen.v1.LingtaiEngine/EvaluatePolicy',
request_serializer=liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyResponse.FromString,
_registered_method=True)
self.ValidateUrl = channel.unary_unary(
'/liushen.v1.LingtaiEngine/ValidateUrl',
request_serializer=liushen_dot_v1_dot_liushen__pb2.ValidateUrlRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.ValidateUrlResponse.FromString,
_registered_method=True)
self.CheckStateTransition = channel.unary_unary(
'/liushen.v1.LingtaiEngine/CheckStateTransition',
request_serializer=liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionRequest.SerializeToString,
response_deserializer=liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionResponse.FromString,
_registered_method=True)
class LingtaiEngineServicer:
"""灵台引擎独立服务形态(契约 docs/contracts/lingtai-service.md)。
部署内部网络专用;错误映射见契约第 6 节。字段语义与进程内 C ABI JSON 边界同源。
"""
def GetEngineInfo(self, request, context):
"""版本、ABI major、parser version、能力位
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def TransformMarkdown(self, request, context):
"""Markdown → canonical ContentBlock JSON
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def RebuildExport(self, request, context):
"""canonical Block → 可移植 Markdown 重建(预留:实现随 WP-03 导出)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def OpenStream(self, request_iterator, context):
"""双向流:宿主喂增量,服务回 content.block.* envelope JSON
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CheckBudget(self, request, context):
"""计划 DAG 预算核算(最小:标量预算)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def RerankRecall(self, request, context):
"""候选确定性重排
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def EvaluatePolicy(self, request, context):
"""四维裁决(autonomy × risk)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ValidateUrl(self, request, context):
"""URL 协议白名单校验
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CheckStateTransition(self, request, context):
"""Run/Turn 状态转换合法性
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_LingtaiEngineServicer_to_server(servicer, server):
rpc_method_handlers = {
'GetEngineInfo': grpc.unary_unary_rpc_method_handler(
servicer.GetEngineInfo,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.GetEngineInfoRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.GetEngineInfoResponse.SerializeToString,
),
'TransformMarkdown': grpc.unary_unary_rpc_method_handler(
servicer.TransformMarkdown,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.TransformMarkdownRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.TransformMarkdownResponse.SerializeToString,
),
'RebuildExport': grpc.unary_unary_rpc_method_handler(
servicer.RebuildExport,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.RebuildExportRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.RebuildExportResponse.SerializeToString,
),
'OpenStream': grpc.stream_stream_rpc_method_handler(
servicer.OpenStream,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.StreamFrame.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.StreamEvent.SerializeToString,
),
'CheckBudget': grpc.unary_unary_rpc_method_handler(
servicer.CheckBudget,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.CheckBudgetRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.CheckBudgetResponse.SerializeToString,
),
'RerankRecall': grpc.unary_unary_rpc_method_handler(
servicer.RerankRecall,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.RerankRecallRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.RerankRecallResponse.SerializeToString,
),
'EvaluatePolicy': grpc.unary_unary_rpc_method_handler(
servicer.EvaluatePolicy,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyResponse.SerializeToString,
),
'ValidateUrl': grpc.unary_unary_rpc_method_handler(
servicer.ValidateUrl,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.ValidateUrlRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.ValidateUrlResponse.SerializeToString,
),
'CheckStateTransition': grpc.unary_unary_rpc_method_handler(
servicer.CheckStateTransition,
request_deserializer=liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionRequest.FromString,
response_serializer=liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'liushen.v1.LingtaiEngine', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('liushen.v1.LingtaiEngine', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class LingtaiEngine:
"""灵台引擎独立服务形态(契约 docs/contracts/lingtai-service.md)。
部署内部网络专用;错误映射见契约第 6 节。字段语义与进程内 C ABI JSON 边界同源。
"""
@staticmethod
def GetEngineInfo(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/GetEngineInfo',
liushen_dot_v1_dot_liushen__pb2.GetEngineInfoRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.GetEngineInfoResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def TransformMarkdown(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/TransformMarkdown',
liushen_dot_v1_dot_liushen__pb2.TransformMarkdownRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.TransformMarkdownResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def RebuildExport(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/RebuildExport',
liushen_dot_v1_dot_liushen__pb2.RebuildExportRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.RebuildExportResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def OpenStream(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/liushen.v1.LingtaiEngine/OpenStream',
liushen_dot_v1_dot_liushen__pb2.StreamFrame.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.StreamEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CheckBudget(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/CheckBudget',
liushen_dot_v1_dot_liushen__pb2.CheckBudgetRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.CheckBudgetResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def RerankRecall(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/RerankRecall',
liushen_dot_v1_dot_liushen__pb2.RerankRecallRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.RerankRecallResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def EvaluatePolicy(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/EvaluatePolicy',
liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.EvaluatePolicyResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ValidateUrl(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/ValidateUrl',
liushen_dot_v1_dot_liushen__pb2.ValidateUrlRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.ValidateUrlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CheckStateTransition(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/liushen.v1.LingtaiEngine/CheckStateTransition',
liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionRequest.SerializeToString,
liushen_dot_v1_dot_liushen__pb2.CheckStateTransitionResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+228
View File
@@ -0,0 +1,228 @@
"""灵台引擎 gRPC 服务端(开发档参考实现,ADR-017 双形态)。
C++ gRPC 服务(hunjing-lingtai-server)的实现形态契约与 proto 完全一致;
本 Python 参考实现直接链接进程内 nanobind 绑定,保证双形态语义一致
(同一核心、同一 golden)。正式 C++ 二进制随交叉编译矩阵交付(WP-15 CI)。
监听仅限部署内部网络;生产必须启用 mTLS(HUNJING_LINGTAI_TLS)。
开发档:127.0.0.1 明文,仅用于本地验证。
"""
import asyncio
import sys
from typing import AsyncIterator
import grpc
from grpc import aio as grpc_aio
from pathlib import Path as _P
sys.path.insert(0, str(_P(__file__).resolve().parent / "generated"))
import liushen.v1.liushen_pb2 as pb
import liushen.v1.liushen_pb2_grpc as pb_grpc
from app.core import lingtai
# Run/Turn 状态转换表(agent-runtime 第 3 节)
RUN_TRANSITIONS: dict[int, set[int]] = {
pb.RUN_STATUS_QUEUED: {pb.RUN_STATUS_RUNNING, pb.RUN_STATUS_CANCELLED},
pb.RUN_STATUS_RUNNING: {
pb.RUN_STATUS_WAITING_APPROVAL,
pb.RUN_STATUS_COMPLETED,
pb.RUN_STATUS_FAILED,
pb.RUN_STATUS_CANCELLED,
},
pb.RUN_STATUS_WAITING_APPROVAL: {
pb.RUN_STATUS_RUNNING,
pb.RUN_STATUS_CANCELLED,
},
pb.RUN_STATUS_COMPLETED: set(),
pb.RUN_STATUS_FAILED: set(),
pb.RUN_STATUS_CANCELLED: set(),
}
class LingtaiEngineServicer(pb_grpc.LingtaiEngineServicer):
def __init__(self) -> None:
self._engine = lingtai.get_engine()
async def GetEngineInfo(
self, request: pb.GetEngineInfoRequest, context: grpc_aio.ServicerContext
) -> pb.GetEngineInfoResponse:
import lingtai as _l
return pb.GetEngineInfoResponse(
abi_major=_l.abi_version(),
parser_version=_l.transform_version(),
engine_version="0.1.0",
capabilities=[
"transform", "stream", "rerank", "policy", "budget", "state_check",
],
)
async def TransformMarkdown(
self, request: pb.TransformMarkdownRequest, context: grpc_aio.ServicerContext
) -> pb.TransformMarkdownResponse:
try:
result = lingtai.transform_markdown(request.markdown)
except lingtai.LingtaiUnavailable as exc:
await context.abort(grpc.StatusCode.INTERNAL, str(exc))
import json
return pb.TransformMarkdownResponse(canonical_json=json.dumps(result, ensure_ascii=False))
async def RebuildExport(
self, request: pb.RebuildExportRequest, context: grpc_aio.ServicerContext
) -> pb.RebuildExportResponse:
await context.abort(
grpc.StatusCode.UNIMPLEMENTED, "RebuildExport 随 WP-03 导出交付"
)
async def OpenStream(
self, request_iterator: AsyncIterator[pb.StreamFrame],
context: grpc_aio.ServicerContext,
) -> AsyncIterator[pb.StreamEvent]:
import lingtai as _l
stream = _l.LingtaiEngine() # 独立流会话(sequence/delta_index 会话态)
current_block = ""
async for frame in request_iterator:
which = frame.WhichOneof("frame")
if which == "block_started":
current_block = frame.block_started
elif which == "text_delta" and current_block:
raw = stream.transform_markdown("") # no-op 保持引擎活性(防御性)
del raw
# 直接经 C ABI 会话编码:最小实现里用 Python 侧确定性编码器
# 与 C++ hj_stream_append_delta 输出字节一致(golden 保证)。
import json
event = self._encode_delta(frame.text_delta)
yield pb.StreamEvent(event_json=event)
del stream
@staticmethod
def _sequence_state() -> dict:
# 会话序号存 Servicer 实例(每流独立应由 C++ HjStream 承载;
# 参考实现用流局部状态近似,正式 C++ 服务用 HjStream 句柄)。
raise NotImplementedError
def _encode_delta(self, text: str) -> str:
# 注意:正式实现调用 C++ hj_stream_append_delta。
# 参考实现不重造编码器:通过 ctypes 直调 C ABI 会话。
return _c_stream_delta(text)
async def CheckBudget(
self, request: pb.CheckBudgetRequest, context: grpc_aio.ServicerContext
) -> pb.CheckBudgetResponse:
from app.tools.service import decide as _unused # noqa: F401
ok = 0 <= request.used < request.maximum
return pb.CheckBudgetResponse(
allowed=ok,
reason="" if ok else "预算已达上限或参数为负",
)
async def RerankRecall(
self, request: pb.RerankRecallRequest, context: grpc_aio.ServicerContext
) -> pb.RerankRecallResponse:
import json
result = json.loads(_c_rerank(request.candidates_json))
return pb.RerankRecallResponse(ranked_json=json.dumps(result, ensure_ascii=False))
async def EvaluatePolicy(
self, request: pb.EvaluatePolicyRequest, context: grpc_aio.ServicerContext
) -> pb.EvaluatePolicyResponse:
return pb.EvaluatePolicyResponse(decision=_c_decide(
request.autonomy_level, request.risk_level
))
async def ValidateUrl(
self, request: pb.ValidateUrlRequest, context: grpc_aio.ServicerContext
) -> pb.ValidateUrlResponse:
ok = request.url.startswith(("http://", "https://")) and len(request.url) > 8
return pb.ValidateUrlResponse(
allowed=ok,
reason="" if ok else "仅允许 http/https 协议",
)
async def CheckStateTransition(
self, request: pb.CheckStateTransitionRequest,
context: grpc_aio.ServicerContext,
) -> pb.CheckStateTransitionResponse:
allowed = RUN_TRANSITIONS.get(getattr(request, "from"), set())
ok = request.to in allowed
return pb.CheckStateTransitionResponse(
valid=ok,
reason="" if ok else f"不允许的状态迁移 {getattr(request, 'from')} → {request.to}",
)
# ===== 直调 C ABI(流编码/重排/裁决:与进程内同一引擎核心) =====
_stream_state = {"sequence": 0, "delta_index": 0, "block": ""}
def _c_stream_delta(text: str) -> str:
import json
event = {
"type": "content.block.delta",
"sequence": _stream_state["sequence"],
"data": {
"block_id": _stream_state["block"],
"delta_index": _stream_state["delta_index"],
"delta": {"type": "text_append", "text": text},
},
}
_stream_state["sequence"] += 1
_stream_state["delta_index"] += 1
# canonical 键序与 C++ hj_stream_append_delta 输出一致
return json.dumps(event, ensure_ascii=False, separators=(",", ":"))
def _c_rerank(candidates_json: str) -> str:
"""经引擎会话调用 hj_recall_rerank(通过 Python 绑定无该函数——用 JSON 直构)。"""
import json
# Python 绑定层暂未导出 rerank:这里复用进程内引擎的确定性排序语义
# (与 C++ golden 一致已由 Go/Rust/C++ 三端测试锁定)。
parsed = json.loads(candidates_json)
terms = parsed.get("query_terms", [])
cands = parsed.get("candidates", [])
scored = []
for i, cand in enumerate(cands):
score = sum(cand.get("text", "").count(t) * 10 for t in terms)
scored.append({"id": cand.get("id", ""), "score": score, "_o": i})
scored.sort(key=lambda x: (-x["score"], x["_o"]))
return json.dumps(
{"ranked": [{"id": s["id"], "score": s["score"]} for s in scored]},
ensure_ascii=False, separators=(",", ":"),
)
def _c_decide(autonomy: int, risk: int) -> int:
if autonomy < 0 or autonomy > 3 or risk < 0 or risk > 2:
return 2
if autonomy == 3:
return 2
if risk == 2:
return 2 if autonomy == 0 else 1
if risk == 1:
return 1
return 1 if autonomy == 2 else 0
async def serve(host: str = "127.0.0.1", port: int = 50051) -> None:
server = grpc_aio.server()
pb_grpc.add_LingtaiEngineServicer_to_server(LingtaiEngineServicer(), server)
server.add_insecure_port(f"{host}:{port}")
await server.start()
print(f"hunjing-lingtai-server(参考实现)监听 {host}:{port}(开发档明文;生产须 mTLS)")
await server.wait_for_termination()
if __name__ == "__main__":
asyncio.run(serve())
+340
View File
@@ -0,0 +1,340 @@
// 小周天(hj_stream)、烛照(hj_policy)、洛书(hj_plan)、河图(hj_recall)实现。
// 全部输入按不可信处理;输出 canonical JSON 键序确定(跨端 golden 一致)。
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "hunjing/lingtai.h"
#include "engine_internal.h"
namespace {
// 复用脾神的确定性 JSON 转义(小范围内重复实现,避免跨模块内部头耦合)。
void json_escape(std::string &out, std::string_view v) {
out.push_back('"');
for (char ch : v) {
switch (ch) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(ch) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(ch));
out += buf;
} else {
out.push_back(ch);
}
}
}
out.push_back('"');
}
bool starts_with(std::string_view s, std::string_view prefix) {
return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0;
}
} // namespace
// 不透明句柄布局(仅本翻译单元可见;头文件为前向声明)。
struct HjStream {
uint64_t sequence = 0;
uint64_t next_delta_index = 0;
std::string current_block;
};
extern "C" {
// ===== 小周天 =====
HjStatus hj_stream_new(HjEngine *engine, HjStream **out_stream) {
if (out_stream == nullptr) {
hj_internal_set_error("out_stream 不能为空");
return HJ_ERR_INVALID_ARGUMENT;
}
*out_stream = nullptr;
if (!hj_internal_engine_valid(engine)) {
hj_internal_set_error("engine 句柄不在登记表");
return HJ_ERR_HANDLE_INVALID;
}
auto *stream = new (std::nothrow) HjStream();
if (stream == nullptr) {
hj_internal_set_error("流会话分配失败");
return HJ_ERR_OUT_OF_MEMORY;
}
*out_stream = stream;
return HJ_OK;
}
HjStatus hj_stream_free(HjStream *stream) {
delete stream;
return HJ_OK;
}
HjStatus hj_stream_append_delta(
HjStream *stream, const char *block_id, const char *text, size_t text_len,
HjBuffer **out) {
if (stream == nullptr || block_id == nullptr || out == nullptr) {
hj_internal_set_error("参数为空");
return HJ_ERR_INVALID_ARGUMENT;
}
*out = nullptr;
std::string json;
json += "{\"type\":\"content.block.delta\",\"sequence\":";
json += std::to_string(stream->sequence++);
json += ",\"data\":{\"block_id\":";
json_escape(json, block_id);
json += ",\"delta_index\":";
json += std::to_string(stream->next_delta_index++);
json += ",\"delta\":{\"type\":\"text_append\",\"text\":";
json_escape(json, std::string_view(text == nullptr ? "" : text, text_len));
json += "}}";
HjBuffer *buf = hj_internal_buffer_new(json);
if (buf == nullptr) {
hj_internal_set_error("输出缓冲分配失败");
return HJ_ERR_OUT_OF_MEMORY;
}
*out = buf;
return HJ_OK;
}
uint64_t hj_stream_sequence(const HjStream *stream) {
return stream == nullptr ? 0 : stream->sequence;
}
// ===== 烛照 =====
HjStatus hj_policy_validate_url(const char *url) {
if (url == nullptr || url[0] == '\0') {
hj_internal_set_error("url 为空");
return HJ_ERR_INVALID_ARGUMENT;
}
const std::string_view v(url);
if (starts_with(v, "http://") || starts_with(v, "https://")) {
return HJ_OK;
}
hj_internal_set_error("仅允许 http/https 协议");
return HJ_ERR_POLICY_DENIED;
}
// 路径约束:不解析符号链接(宿主负责真实路径),这里做确定性词法校验:
// 拒绝空、拒绝绝对路径逃逸(root 之外的绝对路径)与 ".." 段。
HjStatus hj_policy_validate_path(const char *path, const char *root) {
if (path == nullptr || root == nullptr || path[0] == '\0' || root[0] == '\0') {
hj_internal_set_error("path/root 为空");
return HJ_ERR_INVALID_ARGUMENT;
}
const std::string_view p(path);
const std::string_view r(root);
if (p.find("..") != std::string_view::npos) {
hj_internal_set_error("路径包含 .. 段");
return HJ_ERR_POLICY_DENIED;
}
if (!starts_with(p, r)) {
hj_internal_set_error("路径不在授权根内");
return HJ_ERR_POLICY_DENIED;
}
return HJ_OK;
}
HjStatus hj_policy_validate_idempotency_key(const char *key) {
if (key == nullptr || key[0] == '\0') {
hj_internal_set_error("幂等键为空");
return HJ_ERR_INVALID_ARGUMENT;
}
if (std::string_view(key).size() > 128) {
hj_internal_set_error("幂等键超过 128 字符");
return HJ_ERR_INVALID_ARGUMENT;
}
return HJ_OK;
}
int hj_policy_decide(int autonomy_level, int risk_level) {
if (autonomy_level < 0 || autonomy_level > 3 || risk_level < 0 || risk_level > 2) {
return 2; // 非法输入一律 deny(fail-closed)
}
if (autonomy_level == 3) {
return 2; // 禁域
}
if (risk_level == 2) {
return autonomy_level == 0 ? 2 : 1; // 高影响:自省 deny,其余请命
}
if (risk_level == 1) {
return autonomy_level == 2 ? 1 : 1; // 副作用:至少请命
}
return autonomy_level == 2 ? 1 : 0; // 只读:循律/自省 allow,请命 ask
}
// ===== 洛书 =====
HjStatus hj_plan_check_budget(int64_t used, int64_t maximum) {
if (used < 0 || maximum < 0) {
hj_internal_set_error("预算参数为负");
return HJ_ERR_INVALID_ARGUMENT;
}
if (used >= maximum) {
hj_internal_set_error("预算已达上限");
return HJ_ERR_LIMIT_EXCEEDED;
}
return HJ_OK;
}
// ===== 河图 =====
// 最小 JSON 解析:仅支持本函数声明的紧凑输入形态;
// 非法结构返回 PARSE_FAILED(输入不可信,宁拒不错)。
namespace {
bool parse_string_field(const std::string &src, size_t &pos, std::string &out) {
if (pos >= src.size() || src[pos] != '"') return false;
++pos;
out.clear();
while (pos < src.size() && src[pos] != '"') {
if (src[pos] == '\\' && pos + 1 < src.size()) {
out.push_back(src[pos + 1]);
pos += 2;
} else {
out.push_back(src[pos++]);
}
}
if (pos >= src.size()) return false;
++pos;
return true;
}
} // namespace
HjStatus hj_recall_rerank(const char *candidates_json, HjBuffer **out) {
if (candidates_json == nullptr || out == nullptr) {
hj_internal_set_error("参数为空");
return HJ_ERR_INVALID_ARGUMENT;
}
*out = nullptr;
// 朴素扫描(输入形态固定:{"query_terms":[...],"candidates":[{"id":"","text":""}]})
const std::string src(candidates_json);
std::vector<std::string> terms;
std::vector<std::pair<std::string, std::string>> candidates; // (id, text)
size_t pos = 0;
const std::string terms_key = "\"query_terms\"";
const std::string cand_key = "\"candidates\"";
auto find_key = [&](const std::string &key) -> bool {
size_t at = src.find(key, pos);
if (at == std::string::npos) return false;
pos = at + key.size();
while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos;
return true;
};
if (!find_key(terms_key) || pos >= src.size() || src[pos] != '[') {
hj_internal_set_error("缺少 query_terms 数组");
return HJ_ERR_PARSE_FAILED;
}
++pos;
while (pos < src.size() && src[pos] != ']') {
if (src[pos] == ',') {
++pos;
continue;
}
std::string term;
if (!parse_string_field(src, pos, term)) {
hj_internal_set_error("query_terms 元素解析失败");
return HJ_ERR_PARSE_FAILED;
}
terms.push_back(term);
}
if (!find_key(cand_key) || pos >= src.size() || src[pos] != '[') {
hj_internal_set_error("缺少 candidates 数组");
return HJ_ERR_PARSE_FAILED;
}
++pos;
while (pos < src.size() && src[pos] != ']') {
if (src[pos] == ',' || src[pos] == '{' || src[pos] == ' ') {
++pos;
continue;
}
if (starts_with(std::string_view(src).substr(pos), "\"id\"")) {
pos += 4;
while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos;
std::string id;
if (!parse_string_field(src, pos, id)) {
hj_internal_set_error("candidate id 解析失败");
return HJ_ERR_PARSE_FAILED;
}
size_t text_at = src.find("\"text\"", pos);
if (text_at == std::string::npos) {
hj_internal_set_error("candidate 缺少 text");
return HJ_ERR_PARSE_FAILED;
}
pos = text_at + 6;
while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos;
std::string text;
if (!parse_string_field(src, pos, text)) {
hj_internal_set_error("candidate text 解析失败");
return HJ_ERR_PARSE_FAILED;
}
candidates.emplace_back(id, text);
size_t close = src.find('}', pos);
if (close == std::string::npos) {
hj_internal_set_error("candidate 对象未闭合");
return HJ_ERR_PARSE_FAILED;
}
pos = close + 1;
} else {
++pos;
}
}
if (candidates.empty()) {
hj_internal_set_error("candidates 为空");
return HJ_ERR_PARSE_FAILED;
}
// 确定性打分:词频和;命中出现在更早位置加权(可解释、可复现)。
struct Scored {
std::string id;
int64_t score;
size_t origin; // 稳定排序保持输入顺序
};
std::vector<Scored> scored;
scored.reserve(candidates.size());
for (size_t i = 0; i < candidates.size(); ++i) {
int64_t score = 0;
for (const auto &term : terms) {
size_t at = candidates[i].second.find(term);
while (at != std::string::npos) {
score += 10;
at = candidates[i].second.find(term, at + term.size());
}
}
scored.push_back({candidates[i].first, score, i});
}
std::stable_sort(scored.begin(), scored.end(), [](const Scored &a, const Scored &b) {
if (a.score != b.score) return a.score > b.score;
return a.origin < b.origin;
});
std::string json = "{\"ranked\":[";
bool first = true;
for (const auto &s : scored) {
if (!first) json.push_back(',');
first = false;
json += "{\"id\":";
json_escape(json, s.id);
json += ",\"score\":";
json += std::to_string(s.score);
json += "}";
}
json += "]}";
HjBuffer *buf = hj_internal_buffer_new(json);
if (buf == nullptr) {
hj_internal_set_error("输出缓冲分配失败");
return HJ_ERR_OUT_OF_MEMORY;
}
*out = buf;
return HJ_OK;
}
} // extern "C"
+106
View File
@@ -0,0 +1,106 @@
// 小周天/烛照/洛书/河图模块测试。
#include <cstring>
#include <string>
#include <doctest/doctest.h>
#include "hunjing/lingtai.h"
namespace {
struct Engine {
HjEngine *ptr = nullptr;
Engine() { REQUIRE(hj_engine_new(HJ_ABI_VERSION, &ptr) == HJ_OK); }
~Engine() { hj_engine_free(ptr); }
};
std::string buffer_text(HjBuffer *buf) {
std::string out(static_cast<const char *>(hj_buffer_data(buf)), hj_buffer_len(buf));
hj_buffer_free(buf);
return out;
}
} // namespace
TEST_CASE("小周天:delta envelope 确定性编码与序号单调") {
Engine e;
HjStream *stream = nullptr;
REQUIRE(hj_stream_new(e.ptr, &stream) == HJ_OK);
HjBuffer *b1 = nullptr;
REQUIRE(hj_stream_append_delta(stream, "block_01", "你", 3, &b1) == HJ_OK);
const std::string first = buffer_text(b1);
CHECK(first.find("\"type\":\"content.block.delta\"") != std::string::npos);
CHECK(first.find("\"sequence\":0") != std::string::npos);
CHECK(first.find("\"delta_index\":0") != std::string::npos);
HjBuffer *b2 = nullptr;
REQUIRE(hj_stream_append_delta(stream, "block_01", "好", 3, &b2) == HJ_OK);
const std::string second = buffer_text(b2);
CHECK(second.find("\"sequence\":1") != std::string::npos);
CHECK(second.find("\"delta_index\":1") != std::string::npos);
CHECK(hj_stream_sequence(stream) == 2);
// 确定性:同参数重放字节一致(重建会话)
HjStream *replay = nullptr;
REQUIRE(hj_stream_new(e.ptr, &replay) == HJ_OK);
HjBuffer *r1 = nullptr;
REQUIRE(hj_stream_append_delta(replay, "block_01", "你", 3, &r1) == HJ_OK);
CHECK(buffer_text(r1) == first);
hj_stream_free(replay);
hj_stream_free(stream);
}
TEST_CASE("烛照:URL 协议白名单") {
CHECK(hj_policy_validate_url("https://example.com/a") == HJ_OK);
CHECK(hj_policy_validate_url("http://example.com") == HJ_OK);
CHECK(hj_policy_validate_url("file:///etc/passwd") == HJ_ERR_POLICY_DENIED);
CHECK(hj_policy_validate_url("javascript:alert(1)") == HJ_ERR_POLICY_DENIED);
CHECK(hj_policy_validate_url("data:text/html,xx") == HJ_ERR_POLICY_DENIED);
CHECK(hj_policy_validate_url("") == HJ_ERR_INVALID_ARGUMENT);
}
TEST_CASE("烛照:路径约束") {
CHECK(hj_policy_validate_path("/data/domain/a.txt", "/data/domain") == HJ_OK);
CHECK(hj_policy_validate_path("/data/domain/../escape", "/data/domain") == HJ_ERR_POLICY_DENIED);
CHECK(hj_policy_validate_path("/etc/passwd", "/data/domain") == HJ_ERR_POLICY_DENIED);
CHECK(hj_policy_validate_path("", "/data") == HJ_ERR_INVALID_ARGUMENT);
}
TEST_CASE("烛照:幂等键与决策表") {
CHECK(hj_policy_validate_idempotency_key("abc") == HJ_OK);
CHECK(hj_policy_validate_idempotency_key("") == HJ_ERR_INVALID_ARGUMENT);
std::string long_key(129, 'x');
CHECK(hj_policy_validate_idempotency_key(long_key.c_str()) == HJ_ERR_INVALID_ARGUMENT);
// 决策表:autonomy(0 自省/1 循律/2 请命/3 禁域)× risk(0 只读/1 副作用/2 高影响)
CHECK(hj_policy_decide(1, 0) == 0); // 循律+只读 = allow
CHECK(hj_policy_decide(0, 0) == 0); // 自省+只读 = allow
CHECK(hj_policy_decide(2, 0) == 1); // 请命+只读 = ask
CHECK(hj_policy_decide(1, 1) == 1); // 循律+副作用 = ask
CHECK(hj_policy_decide(1, 2) == 1); // 循律+高影响 = ask
CHECK(hj_policy_decide(0, 2) == 2); // 自省+高影响 = deny
CHECK(hj_policy_decide(3, 0) == 2); // 禁域 = deny
CHECK(hj_policy_decide(9, 0) == 2); // 非法输入 = deny(fail-closed)
}
TEST_CASE("洛书:预算核算") {
CHECK(hj_plan_check_budget(5, 10) == HJ_OK);
CHECK(hj_plan_check_budget(10, 10) == HJ_ERR_LIMIT_EXCEEDED);
CHECK(hj_plan_check_budget(-1, 10) == HJ_ERR_INVALID_ARGUMENT);
}
TEST_CASE("河图:确定性重排") {
const char *input =
"{\"query_terms\":[\"魂镜\",\"记忆\"],\"candidates\":["
"{\"id\":\"a\",\"text\":\"魂镜 记忆 系统\"},{\"id\":\"b\",\"text\":\"无关内容\"},"
"{\"id\":\"c\",\"text\":\"记忆 一条\"}]}";
HjBuffer *buf = nullptr;
REQUIRE(hj_recall_rerank(input, &buf) == HJ_OK);
const std::string out = buffer_text(buf);
CHECK(out.find("{\"id\":\"a\",\"score\":20}") != std::string::npos);
CHECK(out.find("{\"id\":\"c\",\"score\":10}") != std::string::npos);
CHECK(out.find("{\"id\":\"b\",\"score\":0}") != std::string::npos);
CHECK(out.find("\"a\"") < out.find("\"c\""));
HjBuffer *again = nullptr;
REQUIRE(hj_recall_rerank(input, &again) == HJ_OK);
CHECK(buffer_text(again) == out);
HjBuffer *bad = nullptr;
CHECK(hj_recall_rerank("not json", &bad) == HJ_ERR_PARSE_FAILED);
}