Compare commits
10
Commits
dfce60f7db
...
7c57d27494
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c57d27494 | ||
|
|
9dafb8fe72 | ||
|
|
a39cec5287 | ||
|
|
99a7789432 | ||
|
|
21eacc8c75 | ||
|
|
701c20c783 | ||
|
|
9ef2568c3b | ||
|
|
795df0413e | ||
|
|
99985f5855 | ||
|
|
34c481ab9e |
No files matched your search
@@ -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
|
||||
@@ -112,6 +112,10 @@ target/
|
||||
|
||||
# C++ 引擎与契约生成产物(源码构建可再生)
|
||||
engine/build*/
|
||||
engine/bindings/go/lingtai/*.cpp
|
||||
engine/bindings/go/lingtai/*.h
|
||||
engine/bindings/go/lingtai/hunjing/
|
||||
engine/bindings/rust/target/
|
||||
engine/bindings/build/
|
||||
openapi/bundled.json
|
||||
**/*.rs.bk
|
||||
|
||||
@@ -53,6 +53,19 @@ openapi-lint: ## 校验 OpenAPI 契约(redocly lint + bundle)
|
||||
generate: openapi-lint ## 生成代码(TypeScript 类型等)
|
||||
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) =====
|
||||
|
||||
.PHONY: frontend-setup
|
||||
@@ -63,6 +76,27 @@ frontend-setup: ## 安装前端依赖
|
||||
frontend-dev: ## 启动前端开发服务(127.0.0.1:3000)
|
||||
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
|
||||
|
||||
@@ -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 ###
|
||||
@@ -6,7 +6,7 @@ from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
|
||||
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
|
||||
@@ -33,10 +33,11 @@ async def get_current_user(
|
||||
|
||||
async def require_state_change(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
|
||||
) -> User:
|
||||
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。"""
|
||||
"""Cookie 状态变更:Origin 校验 + CSRF 双提交 + 保全态拦截(契约 2.1 / ADR-021)。"""
|
||||
if ALLOWED_ORIGINS:
|
||||
origin = request.headers.get("Origin")
|
||||
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)
|
||||
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。"""
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""社区域:发布、互动、关注与治理。"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
|
||||
log_level: str = "info"
|
||||
api_port: int = 8000
|
||||
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
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
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.community.routes import router as community_router
|
||||
from app.conversations.routes import message_router, run_router
|
||||
from app.conversations.routes import router as conversations_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import install_error_handlers
|
||||
from app.core.middleware import RequestContextMiddleware, configure_logging
|
||||
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 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.workspaces.routes import router as workspaces_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -37,6 +46,18 @@ def create_app() -> FastAPI:
|
||||
app.include_router(memories_router)
|
||||
app.include_router(tools_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"])
|
||||
async def healthz() -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""交易域:商品、订单、账本、退款与 Webhook。"""
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -21,6 +21,7 @@ class User(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_"))
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
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()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("role IN ('user', 'admin')", name="role_values"),
|
||||
)
|
||||
|
||||
|
||||
class PersonalDomain(Base):
|
||||
__tablename__ = "personal_domain"
|
||||
@@ -42,6 +47,8 @@ class PersonalDomain(Base):
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议
|
||||
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(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -673,3 +680,725 @@ class ToolCall(Base):
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""运营域:诊断双授权、管理面治理、支持包与观测。"""
|
||||
@@ -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")
|
||||
@@ -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},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""插件域:Registry、安装三态与调用门。"""
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""沙箱域:隔离执行器与策略。"""
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""协作域:Workspace、成员、邀请与项目评审。"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -14,6 +14,8 @@ dependencies = [
|
||||
"uuid-utils>=0.10",
|
||||
"greenlet>=3.5.5",
|
||||
"hunjing-lingtai",
|
||||
"grpcio>=1.83.1",
|
||||
"grpcio-tools>=1.83.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -49,6 +49,7 @@ async def client(db):
|
||||
# 后台 Run 任务使用测试库会话工厂与 mock 流(生产为独立工厂与真实传输)
|
||||
app.state.session_factory = db
|
||||
app.state.gw_transport = _mock_openai_stream()
|
||||
app.state.media_provider = _mock_media_provider()
|
||||
import os as _os
|
||||
|
||||
_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]:
|
||||
"""开发档登录:返回需附加的请求头(Cookie 由客户端自动携带)。"""
|
||||
"""开发档登录:返回自包含请求头(显式 Cookie 支持多用户交替)。
|
||||
|
||||
同时保留 cookie jar 自动存储:不带 headers 的旧式请求仍走 jar。
|
||||
显式 Cookie header 优先于 jar(httpx 行为),交替测试不串会话。
|
||||
"""
|
||||
resp = await client.post("/v1/auth/sessions", json={"email": email})
|
||||
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():
|
||||
@@ -100,3 +112,16 @@ def _mock_openai_stream():
|
||||
)
|
||||
|
||||
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
@@ -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
|
||||
Generated
+112
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -347,6 +431,8 @@ dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-tools" },
|
||||
{ name = "hunjing-lingtai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
@@ -370,6 +456,8 @@ requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.30" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ 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 = "pydantic", specifier = ">=2.9" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.52"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -17,14 +17,14 @@ endif()
|
||||
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
|
||||
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
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
|
||||
# 单元测试(doctest,ADR-025);单头直接引入,不用 FetchContent 拉网络
|
||||
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
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
@@ -36,7 +36,8 @@ add_test(NAME lingtai_tests COMMAND lingtai_tests)
|
||||
# fuzz 目标(ADR-025:hj_transform 为必测目标;需要 clang libFuzzer)
|
||||
option(HJ_BUILD_FUZZER "Build libFuzzer targets" OFF)
|
||||
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
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/Users/chenxin/code/python/hunjing/engine/include/hunjing
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+228
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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++");
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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}]}"#);
|
||||
}
|
||||
@@ -76,6 +76,43 @@ HJ_API const char *hj_transform_version(void);
|
||||
HJ_API HjStatus hj_transform_markdown(
|
||||
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
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user