From 1f1bccf3b3d9089e1bd3c512005145a42e7d282f Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Thu, 21 May 2026 22:03:08 +0800 Subject: [PATCH] feat: add tenant-scoped rule and permission management --- ...026-05-21-rule-dsl-extract-preservation.md | 76 + .../文档图片质量校验模块-页码级模糊预警简化版.md | 350 ++ docs/权限与地区隔离/Pytest全链路验收说明.md | 178 + docs/权限与地区隔离/中高低风险修复优化计划.md | 301 ++ .../入口模块租户配置表迁移方案.md | 461 +++ .../冻结版进度盘点与最小冒烟验收-2026-05-21.md | 356 ++ .../地区到租户编码映射清洗清单.md | 335 ++ .../地区租户化与自定义租户扩展改造方案.md | 635 ++++ .../大版本发布验收总表-2026-05-21.md | 409 +++ .../新平台主链路租户改造实施任务单.md | 302 ++ .../新平台主链路租户边界扫描报告.md | 340 ++ .../新平台评查点与评查组真实模型梳理.md | 247 ++ .../权限、租户能力与数据范围职责边界说明.md | 316 ++ docs/权限与地区隔离/权限与地区隔离文档导航.md | 5 + .../权限与租户改造当前进度总览.md | 672 ++++ .../权限字段映射与SQL改造规范.md | 830 +++++ .../权限接口矩阵与数据边界清单.md | 367 ++ .../权限改造实施任务拆解与排期.md | 520 +++ .../权限文档总导航与阅读顺序.md | 259 ++ .../权限文档补充清单与编写建议.md | 436 +++ .../权限架构全面优化改造方案.md | 1519 +++++++++ .../权限测试验收与回归用例清单.md | 408 +++ docs/权限与地区隔离/模块级开发任务单.md | 335 ++ .../模块级真实落地清单与下一步动作.md | 558 ++++ .../租户与角色权限关系真实案例说明.md | 250 ++ docs/权限与地区隔离/租户主数据模型设计.md | 501 +++ .../统一执行器落地代码骨架与接入示例.md | 1150 +++++++ docs/权限与地区隔离/统一数据范围执行器设计.md | 709 ++++ .../自定义租户功能连带影响深度补充.md | 325 ++ .../规则域多租户方案A实施计划.md | 531 +++ docs/权限与地区隔离/角色去硬编码迁移清单.md | 254 ++ .../角色硬编码与接口影响专项补充分析.md | 607 ++++ .../评查点数据库执行说明与验证SQL.md | 222 ++ docs/权限与地区隔离/评查点模块收尾清单.md | 170 + docs/权限与地区隔离/评查点预检结果判读模板.md | 253 ++ .../高风险数据库迁移清单与执行顺序.md | 285 ++ .../rule-domain-before-reset-20260521-214554.sql | 265 ++ .../rule-domain-before-reset-20260521-214608.sql | 265 ++ .../leaudit-oss-yaml-files规则体检报告.md | 261 ++ docs/规则编辑/规则配置正确设置规范.md | 572 ++++ fastapi_admin/celery_app.py | 15 +- fastapi_admin/config/__init__.pyi | 4 + fastapi_admin/config/_settings.py | 4 + .../fastapi_common_security/jwtService.py | 6 + .../controllers/contractTemplateController.py | 12 +- .../controllers/documentController.py | 17 +- .../controllers/entryModuleController.py | 11 +- .../controllers/evaluationPointController.py | 62 +- .../evaluationPointGroupController.py | 37 +- .../controllers/govdocController.py | 28 +- .../controllers/pageQualityController.py | 62 + .../controllers/ragChatController.py | 156 +- .../controllers/rbacAdminController.py | 18 +- .../controllers/ruleConfigController.py | 9 +- .../controllers/ruleController.py | 100 +- .../controllers/tenantController.py | 80 + .../domian/Dto/contractTemplateDto.py | 9 +- .../domian/Dto/entryModuleDto.py | 17 +- .../domian/Dto/evaluationPointDto.py | 2 + .../domian/Dto/rbacAdminDto.py | 6 + .../domian/Dto/ruleBindingDto.py | 2 +- .../fastapi_leaudit/domian/Dto/tenantDto.py | 51 + .../domian/vo/contractTemplateVo.py | 4 +- .../domian/vo/crossReviewVo.py | 10 +- .../fastapi_leaudit/domian/vo/documentVo.py | 19 + .../domian/vo/entryModuleAdminVo.py | 9 +- .../domian/vo/evaluationPointGroupVo.py | 7 + .../domian/vo/evaluationPointVo.py | 2 + .../fastapi_leaudit/domian/vo/homeVo.py | 10 + .../domian/vo/pageQualityVo.py | 41 + .../fastapi_leaudit/domian/vo/ragChatVo.py | 2 + .../fastapi_leaudit/domian/vo/ragDatasetVo.py | 4 + .../fastapi_leaudit/domian/vo/rbacAdminVo.py | 12 + .../fastapi_leaudit/domian/vo/ruleConfigVo.py | 9 + .../fastapi_leaudit/domian/vo/ruleVo.py | 4 + .../fastapi_leaudit/domian/vo/usageStatsVo.py | 6 + .../fastapi_leaudit/govdoc_bridge/runner.py | 4 +- .../leaudit_bridge/storage_adapter.py | 209 +- .../fastapi_leaudit/leaudit_bridge/tasks.py | 21 + .../fastapi_leaudit/models/leauditAuditRun.py | 5 + .../fastapi_leaudit/models/leauditDocument.py | 1 + .../models/leauditRagChatApp.py | 1 + .../models/leauditRagDataset.py | 1 + .../fastapi_leaudit/page_quality/runner.py | 16 + .../fastapi_leaudit/page_quality/tasks.py | 48 + .../fastapi_leaudit/services/__init__.py | 4 + .../services/documentService.py | 5 +- .../services/entryModuleAdminService.py | 9 +- .../services/evaluationPointGroupService.py | 30 +- .../services/evaluationPointService.py | 50 +- .../fastapi_leaudit/services/govdocService.py | 24 +- .../services/impl/auditServiceImpl.py | 164 +- .../services/impl/authServiceImpl.py | 132 +- .../impl/contractTemplateServiceImpl.py | 364 +- .../services/impl/crossReviewServiceImpl.py | 306 +- .../services/impl/documentServiceImpl.py | 482 ++- .../impl/entryModuleAdminServiceImpl.py | 629 +++- .../impl/evaluationPointGroupServiceImpl.py | 651 +++- .../impl/evaluationPointServiceImpl.py | 893 ++++- .../services/impl/govdocServiceImpl.py | 552 ++- .../services/impl/homeServiceImpl.py | 348 +- .../services/impl/pageQualityServiceImpl.py | 555 +++ .../services/impl/permissionServiceImpl.py | 20 +- .../services/impl/ragChatServiceImpl.py | 457 ++- .../services/impl/ragDatasetServiceImpl.py | 612 +++- .../services/impl/rbacAdminServiceImpl.py | 868 ++++- .../services/impl/rbacServiceImpl.py | 52 +- .../services/impl/ruleConfigServiceImpl.py | 391 ++- .../services/impl/ruleGroupSupport.py | 8 +- .../services/impl/ruleServiceImpl.py | 1095 +++++- .../services/impl/ruleTenantMaterializer.py | 307 ++ .../services/impl/ruleTenantScope.py | 54 + .../services/impl/ssoUserCompat.py | 68 + .../services/impl/tenantResolver.py | 204 ++ .../services/impl/tenantServiceImpl.py | 868 +++++ .../services/impl/usageStatsServiceImpl.py | 553 ++- .../services/pageQualityService.py | 54 + .../services/ragChatService.py | 89 +- .../services/ragDatasetService.py | 65 +- .../services/rbacAdminService.py | 28 +- .../services/ruleConfigService.py | 6 +- .../fastapi_leaudit/services/ruleService.py | 26 +- .../fastapi_leaudit/services/tenantService.py | 48 + .../1.2/rules.yaml | 543 +++ .../v2/rules.yaml | 15 + .../v3/rules.yaml | 612 ++++ .../contract.entrust/2.0/rules.yaml | 1489 +++++++++ .../contract.entrust/v2/rules.yaml | 1474 ++++++++ .../contract.entrust/v3/rules.yaml | 1414 ++++++++ .../contract.entrust/v4/rules.yaml | 1438 ++++++++ .../contract.entrust/v5/rules.yaml | 1462 ++++++++ .../contract.entrust/v6/rules.yaml | 1466 ++++++++ .../contract.entrust/v7/rules.yaml | 1495 +++++++++ .../contract.entrust/v8/rules.yaml | 1519 +++++++++ .../contract.entrust/v9/rules.yaml | 1606 +++++++++ .../0.1/rules.yaml | 197 ++ .../contract.gift.charity/1.0/rules.yaml | 788 +++++ .../contract.gift.general/1.0/rules.yaml | 673 ++++ .../contract.lease/2.0/rules.yaml | 1750 ++++++++++ .../contract.loan.general/1.0/rules.yaml | 553 +++ .../contract.purchase.general/1.0/rules.yaml | 627 ++++ .../contract.sale/2.1/rules.yaml | 1101 ++++++ .../contract.tech/1.0/rules.yaml | 1636 +++++++++ .../govdoc.general/0.1/rules.yaml | 760 +++++ leaudit-oss-yaml-files/合同yaml修改列表.md | 1 + .../行政卷宗.行政处罚/1.0/rules.yaml | 2972 +++++++++++++++++ .../行政卷宗.行政处罚/1.0/rules.yaml.old | 2898 ++++++++++++++++ .../行政卷宗.行政许可.停业/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.变更/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.延续/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.恢复营业/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.收回/1.0/rules.yaml | 519 +++ .../行政卷宗.行政许可.新办/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.歇业/1.0/rules.yaml | 410 +++ .../行政卷宗.行政许可.注销/1.0/rules.yaml | 451 +++ .../行政卷宗.行政许可.补办/1.0/rules.yaml | 410 +++ legal-platform-frontend | 2 +- pyproject.toml | 5 + pytest.ini | 8 + scripts/import_oss_yaml_rules.py | 591 ++++ ...un_rag_public_orphan_defaults_migration.sh | 151 + .../migrate_rag_public_orphan_defaults.sql | 129 + ...recheck_evaluation_points_tenant_cleanup.sql | 282 ++ .../precheck_rag_public_orphan_defaults.sql | 134 + .../precheck_rule_domain_tenant_phase1.sql | 118 + .../schema_add_page_quality_module.sql | 38 + .../创建sql/schema_entry_module_tenants.sql | 78 + .../schema_evaluation_points_tenant_cleanup.sql | 198 ++ .../schema_rule_domain_tenant_phase1.sql | 230 ++ .../schema_tenant_code_high_risk_phase1.sql | 453 +++ scripts/创建sql/schema_tenant_foundation.sql | 163 + .../创建sql/seed_page_quality_permissions.sql | 1 + scripts/创建sql/seed_page_quality_routes.sql | 1 + scripts/创建sql/user_rbac_seed.sql | 4 + .../verify_rule_domain_tenant_phase1.sql | 69 + tests/__init__.py | 1 + tests/release/__init__.py | 1 + tests/release/conftest.py | 479 +++ tests/release/helpers.py | 134 + tests/release/test_g1_rbac_context.py | 35 + .../release/test_g2_g3_tenant_entry_chain.py | 90 + tests/release/test_g4_documents.py | 146 + tests/release/test_g5_rag.py | 175 + .../test_g5_rule_cross_review_matrix.py | 222 ++ .../test_g6_rule_version_management.py | 159 + tests/release/test_role_tenant_matrix.py | 105 + tests/test_rule_config_source_fields.py | 174 + tests/test_rule_group_binding_scope.py | 65 + tests/test_rule_scoped_asset_resolution.py | 50 + tests/test_rule_tenant_materializer.py | 44 + tests/test_rule_tenant_resolution.py | 55 + tests/test_rule_version_lineage.py | 35 + tests/test_rule_write_scope.py | 221 ++ 193 files changed, 64463 insertions(+), 1771 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-21-rule-dsl-extract-preservation.md create mode 100644 docs/文档图片质量校验模块/文档图片质量校验模块-页码级模糊预警简化版.md create mode 100644 docs/权限与地区隔离/Pytest全链路验收说明.md create mode 100644 docs/权限与地区隔离/中高低风险修复优化计划.md create mode 100644 docs/权限与地区隔离/入口模块租户配置表迁移方案.md create mode 100644 docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md create mode 100644 docs/权限与地区隔离/地区到租户编码映射清洗清单.md create mode 100644 docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md create mode 100644 docs/权限与地区隔离/大版本发布验收总表-2026-05-21.md create mode 100644 docs/权限与地区隔离/新平台主链路租户改造实施任务单.md create mode 100644 docs/权限与地区隔离/新平台主链路租户边界扫描报告.md create mode 100644 docs/权限与地区隔离/新平台评查点与评查组真实模型梳理.md create mode 100644 docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md create mode 100644 docs/权限与地区隔离/权限与租户改造当前进度总览.md create mode 100644 docs/权限与地区隔离/权限字段映射与SQL改造规范.md create mode 100644 docs/权限与地区隔离/权限接口矩阵与数据边界清单.md create mode 100644 docs/权限与地区隔离/权限改造实施任务拆解与排期.md create mode 100644 docs/权限与地区隔离/权限文档总导航与阅读顺序.md create mode 100644 docs/权限与地区隔离/权限文档补充清单与编写建议.md create mode 100644 docs/权限与地区隔离/权限架构全面优化改造方案.md create mode 100644 docs/权限与地区隔离/权限测试验收与回归用例清单.md create mode 100644 docs/权限与地区隔离/模块级开发任务单.md create mode 100644 docs/权限与地区隔离/模块级真实落地清单与下一步动作.md create mode 100644 docs/权限与地区隔离/租户与角色权限关系真实案例说明.md create mode 100644 docs/权限与地区隔离/租户主数据模型设计.md create mode 100644 docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md create mode 100644 docs/权限与地区隔离/统一数据范围执行器设计.md create mode 100644 docs/权限与地区隔离/自定义租户功能连带影响深度补充.md create mode 100644 docs/权限与地区隔离/规则域多租户方案A实施计划.md create mode 100644 docs/权限与地区隔离/角色去硬编码迁移清单.md create mode 100644 docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md create mode 100644 docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md create mode 100644 docs/权限与地区隔离/评查点模块收尾清单.md create mode 100644 docs/权限与地区隔离/评查点预检结果判读模板.md create mode 100644 docs/权限与地区隔离/高风险数据库迁移清单与执行顺序.md create mode 100644 docs/规则编辑/backups/rule-domain-before-reset-20260521-214554.sql create mode 100644 docs/规则编辑/backups/rule-domain-before-reset-20260521-214608.sql create mode 100644 docs/规则编辑/leaudit-oss-yaml-files规则体检报告.md create mode 100644 docs/规则编辑/规则配置正确设置规范.md create mode 100644 fastapi_modules/fastapi_leaudit/controllers/pageQualityController.py create mode 100644 fastapi_modules/fastapi_leaudit/controllers/tenantController.py create mode 100644 fastapi_modules/fastapi_leaudit/domian/Dto/tenantDto.py create mode 100644 fastapi_modules/fastapi_leaudit/domian/vo/pageQualityVo.py create mode 100644 fastapi_modules/fastapi_leaudit/page_quality/runner.py create mode 100644 fastapi_modules/fastapi_leaudit/page_quality/tasks.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/pageQualityServiceImpl.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/ruleTenantMaterializer.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/ruleTenantScope.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py create mode 100644 fastapi_modules/fastapi_leaudit/services/pageQualityService.py create mode 100644 fastapi_modules/fastapi_leaudit/services/tenantService.py create mode 100644 leaudit-oss-yaml-files/contract.construction.general/1.2/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.construction.general/v2/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.construction.general/v3/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/2.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v2/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v3/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v4/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v5/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v6/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v7/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v8/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.entrust/v9/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.evaluation.delegation/0.1/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.gift.charity/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.gift.general/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.lease/2.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.loan.general/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.purchase.general/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.sale/2.1/rules.yaml create mode 100644 leaudit-oss-yaml-files/contract.tech/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/govdoc.general/0.1/rules.yaml create mode 100644 leaudit-oss-yaml-files/合同yaml修改列表.md create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml.old create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.停业/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.变更/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.延续/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.恢复营业/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.收回/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.新办/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.歇业/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.注销/1.0/rules.yaml create mode 100644 leaudit-oss-yaml-files/行政卷宗.行政许可.补办/1.0/rules.yaml create mode 100644 pytest.ini create mode 100644 scripts/import_oss_yaml_rules.py create mode 100755 scripts/run_rag_public_orphan_defaults_migration.sh create mode 100644 scripts/创建sql/migrate_rag_public_orphan_defaults.sql create mode 100644 scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql create mode 100644 scripts/创建sql/precheck_rag_public_orphan_defaults.sql create mode 100644 scripts/创建sql/precheck_rule_domain_tenant_phase1.sql create mode 100644 scripts/创建sql/schema_add_page_quality_module.sql create mode 100644 scripts/创建sql/schema_entry_module_tenants.sql create mode 100644 scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql create mode 100644 scripts/创建sql/schema_rule_domain_tenant_phase1.sql create mode 100644 scripts/创建sql/schema_tenant_code_high_risk_phase1.sql create mode 100644 scripts/创建sql/schema_tenant_foundation.sql create mode 100644 scripts/创建sql/seed_page_quality_permissions.sql create mode 100644 scripts/创建sql/seed_page_quality_routes.sql create mode 100644 scripts/创建sql/verify_rule_domain_tenant_phase1.sql create mode 100644 tests/__init__.py create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/helpers.py create mode 100644 tests/release/test_g1_rbac_context.py create mode 100644 tests/release/test_g2_g3_tenant_entry_chain.py create mode 100644 tests/release/test_g4_documents.py create mode 100644 tests/release/test_g5_rag.py create mode 100644 tests/release/test_g5_rule_cross_review_matrix.py create mode 100644 tests/release/test_g6_rule_version_management.py create mode 100644 tests/release/test_role_tenant_matrix.py create mode 100644 tests/test_rule_config_source_fields.py create mode 100644 tests/test_rule_group_binding_scope.py create mode 100644 tests/test_rule_scoped_asset_resolution.py create mode 100644 tests/test_rule_tenant_materializer.py create mode 100644 tests/test_rule_tenant_resolution.py create mode 100644 tests/test_rule_version_lineage.py create mode 100644 tests/test_rule_write_scope.py diff --git a/docs/superpowers/plans/2026-05-21-rule-dsl-extract-preservation.md b/docs/superpowers/plans/2026-05-21-rule-dsl-extract-preservation.md new file mode 100644 index 0000000..5bbf6f0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-rule-dsl-extract-preservation.md @@ -0,0 +1,76 @@ +# Rule DSL Extract Preservation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent the rule editor from deleting `extract` declarations when backend rule summaries are present. + +**Architecture:** Parse the full YAML into the frontend pack first, then overlay backend rule summaries only for the rule list. Add a save-time guard so a lossy editor model cannot overwrite existing YAML sections with empty generated sections. + +**Tech Stack:** Next.js frontend utilities, TypeScript, Node test runner, `yaml` package. + +--- + +### Task 1: Preserve parsed YAML fields when summary rules are returned + +**Files:** +- Modify: `legal-platform-frontend/lib/utils/rules-config-packs.server.ts` +- Test: `legal-platform-frontend/tests/govdoc-audit/rule-config-pack-summary-preserve.test.mts` + +- [ ] **Step 1: Write failing test** + +Create a test that calls `mapApiPackToRuleYamlPack` with both `yamlText` and `rules`, then asserts that `fields` still contains `委托方`. + +- [ ] **Step 2: Run failing test** + +Run: `cd legal-platform-frontend && node --import tsx --test tests/govdoc-audit/rule-config-pack-summary-preserve.test.mts` + +Expected: FAIL because current summary branch returns `fields: []`. + +- [ ] **Step 3: Implement minimal mapping fix** + +Change `mapApiPackToRuleYamlPack` to always build `basePack = buildRuleYamlPack(...)`, then return `{ ...basePack, rules: normalizedRules, stats: { ...basePack.stats, ruleCount: normalizedRules.length } }`. + +- [ ] **Step 4: Run passing test** + +Run: `cd legal-platform-frontend && node --import tsx --test tests/govdoc-audit/rule-config-pack-summary-preserve.test.mts` + +Expected: PASS. + +### Task 2: Add save-time lossy section guard + +**Files:** +- Modify: `legal-platform-frontend/lib/utils/rules-config-editor.ts` +- Test: `legal-platform-frontend/tests/govdoc-audit/rule-config-editor-preserve.test.mts` + +- [ ] **Step 1: Write failing test** + +Create a test that serializes a config with original `yamlSource.extract` but empty `fields`, and assert it throws a clear error instead of returning YAML with `extract: []`. + +- [ ] **Step 2: Run failing test** + +Run: `cd legal-platform-frontend && node --import tsx --test tests/govdoc-audit/rule-config-editor-preserve.test.mts` + +Expected: FAIL because current serializer silently overwrites `extract`. + +- [ ] **Step 3: Implement guard** + +Before assigning `root.extract`, check whether the original YAML section exists and the editor model is empty. Throw `字段抽取配置未加载完成,已阻止保存,避免覆盖原 YAML extract。`. + +- [ ] **Step 4: Run passing test** + +Run: `cd legal-platform-frontend && node --import tsx --test tests/govdoc-audit/rule-config-editor-preserve.test.mts` + +Expected: PASS. + +### Task 3: Verify current JY rule remains valid after frontend serialization + +**Files:** +- No production code changes expected. + +- [ ] **Step 1: Run focused frontend tests** + +Run both new tests plus existing rule YAML parser tests. + +- [ ] **Step 2: Run backend validator smoke test** + +Use `RuleValidator` on a representative YAML to confirm `extract` and rule field references are coherent. diff --git a/docs/文档图片质量校验模块/文档图片质量校验模块-页码级模糊预警简化版.md b/docs/文档图片质量校验模块/文档图片质量校验模块-页码级模糊预警简化版.md new file mode 100644 index 0000000..e8cd6b6 --- /dev/null +++ b/docs/文档图片质量校验模块/文档图片质量校验模块-页码级模糊预警简化版.md @@ -0,0 +1,350 @@ +# 文档图片质量校验模块定制开发计划 + +## 1. 目标收口 + +本次版本只做“页码级模糊预警”,不做图片级定位。 + +系统最终只需要给出这类提示: + +- `第3页疑似模糊` +- `第2页、第5页疑似模糊` +- `第4页建议重拍` + +不需要输出: + +- 第几张图片 +- 图片框选位置 +- bbox +- 裁剪图回显 +- OCR 图块级关联 + +这次方案的核心原则只有两条: + +- 只做预警,不阻断主流程 +- 只做到页码,不追求图级精确定位 + +## 2. 与当前 OCR / 评查链路的关系 + +本模块必须独立于当前 OCR 抽取、评查流程之外。 + +现有主流程保持不变: + +1. 用户上传文档 +2. 文档进入 OCR / 抽取 / 评查 pipeline +3. 系统继续正常落库、展示、评查 + +新增流程只是一条旁路异步任务: + +1. 文档上传成功 +2. 异步投递“页级图片质量检测任务” +3. 后台生成页码级预警结果 +4. 前端在列表页、详情页显示提示 + +明确约束: + +- 图片质量检测失败,不影响上传 +- 图片质量检测超时,不影响 OCR +- 图片质量检测发现模糊页,不影响评查 +- 整个模块只作为 warning,不参与主流程 gate + +## 3. 检测范围与判定结果 + +### 3.1 启用策略 + +建议保留三层控制: + +- 全局总开关 +- 文档类型维度开关 +- 地区维度开关 + +判定顺序: + +1. 先看全局总开关 +2. 再看文档类型是否命中 +3. 再看地区是否命中 + +只要任一环节不命中,就直接跳过。 + +### 3.2 文档类型跳过策略 + +纯文本型文档直接跳过,不做页级图片质量检测。 + +适用检测的主要是: + +- PDF 扫描件 +- 图片型案卷 +- 转 PDF 后包含大量扫描页的 doc/docx/wps + +不适合检测的纯文本类文档,直接标记为 `skipped`。 + +### 3.3 结果分档 + +仍保留三档结果,便于后续扩展: + +- `pass`:通过 +- `review`:疑似模糊,待人工确认 +- `reject`:不通过,建议重拍 + +前端展示文案建议直接对应: + +- `通过` +- `疑似模糊` +- `建议重拍` + +## 4. 定位策略 + +这次不做“定位到哪张图片”,只做“定位到第几页”。 + +也就是说: + +- PDF:直接逐页检测 +- 图片:按单页处理 +- doc/docx/wps:统一先转为稳定的页级 PDF,再逐页检测 + +最后只返回页码列表,例如: + +- `pages=[2,5,8]` +- `warningText=第2页、第5页、第8页疑似模糊` + +这样实现最稳,也最符合你现在已经确认的需求收口。 + +## 5. 当前仓库的落点 + +按目前仓库结构,模块建议挂在 `fastapi_leaudit` 下,和当前评查链路平行存在。 + +当前新增代码落点如下: + +- `fastapi_modules/fastapi_leaudit/domian/vo/pageQualityVo.py` +- `fastapi_modules/fastapi_leaudit/services/pageQualityService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/pageQualityServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/controllers/pageQualityController.py` +- `fastapi_modules/fastapi_leaudit/page_quality/tasks.py` +- `fastapi_modules/fastapi_leaudit/page_quality/runner.py` +- `scripts/创建sql/schema_add_page_quality_module.sql` +- `scripts/创建sql/seed_page_quality_permissions.sql` +- `scripts/创建sql/seed_page_quality_routes.sql` + +## 6. 当前实现方案 + +### 6.1 数据表 + +当前方案只保留两张表,足够支撑第一版: + +`leaudit_page_quality_runs` + +- 记录一次文档级检测任务 +- 保存任务状态、总页数、问题页统计、摘要状态 + +`leaudit_page_quality_results` + +- 记录每一页的检测结果 +- 每页只保留一条结果 + +这版不新增图片明细表,不做图级索引。 + +### 6.2 Service 职责 + +`PageQualityServiceImpl` + +- `DispatchForDocument` + - 创建 run + - 投递 Celery 异步任务 +- `ExecuteRun` + - 读取文件 + - 转为页级图片 + - 逐页检测 + - 落库结果 + - 汇总文档级状态 +- `GetDocumentSummary` + - 返回文档的页级检测摘要 +- `GetDocumentDetail` + - 返回页级问题页详情 +- `RecheckDocument` + - 手工重跑 + +### 6.3 Controller 路由 + +当前建议保留 3 个接口: + +- `GET /documents/{DocumentId}/page-quality/summary` +- `GET /documents/{DocumentId}/page-quality` +- `POST /documents/{DocumentId}/page-quality/recheck` + +这样够前端做: + +- 列表轻提示 +- 详情展开 +- 手工重检 + +### 6.4 Celery 任务链路 + +当前任务入口: + +- `leaudit.page_quality.process_document` + +队列配置: + +- `LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL` +- `LEAUDIT_PAGE_QUALITY_QUEUE_URGENT` + +它和当前 OCR / 评查队列分开,避免互相影响。 + +## 7. 与现有 DocumentService 的集成方式 + +按当前项目代码风格,集成点放在 `DocumentServiceImpl`。 + +### 7.1 上传成功后自动触发 + +接入点: + +- `DocumentServiceImpl.Upload` + +处理方式: + +- 文档主文件落库成功后 +- 使用 `try/except` 异步投递页级质量检测 +- 失败只记日志,不抛阻断异常 + +### 7.2 补充附件后重跑 + +接入点: + +- `DocumentServiceImpl.AppendAttachments` + +处理方式: + +- 附件写入完成后 +- 触发一次 `Force=True` 的重跑任务 +- 仍然不影响主链路返回 + +### 7.3 文档接口回填摘要 + +当前需要在这些返回对象上挂页级质量摘要字段: + +- `DocumentUploadVO` +- `DocumentListItemVO` +- `DocumentStatusItemVO` +- `DocumentDetailVO` + +建议统一补这些字段: + +- `pageQualityRunId` +- `pageQualityRunStatus` +- `pageQualitySummaryStatus` +- `pageQualityIssueCount` +- `pageQualityWarningText` + +详情页额外补: + +- `pageQualitySummary` + +## 8. 页级检测执行逻辑 + +### 8.1 输入统一 + +统一转成“按页检测”的输入: + +- `pageNum` +- `pageImageBytes` + +这样后面的检测器不关心文件来源,只关心这一页图像本身。 + +### 8.2 文件处理策略 + +建议如下: + +- `pdf`:直接渲染每页 +- `png/jpg/jpeg/bmp/tiff/webp`:按 1 页处理 +- `doc/docx/wps`:先转 PDF,再逐页渲染 + +### 8.3 并发策略 + +第一版可以做“页级并发”,但不建议过度并发。 + +建议原则: + +- 只在单文档内部做有限并发 +- 队列级别仍由 Celery 控制 +- 优先保证稳定,不追求极限吞吐 + +### 8.4 检测结果汇总 + +汇总规则固定如下: + +- 只要有 `reject` 页,文档摘要状态就是 `reject` +- 否则只要有 `review` 页,文档摘要状态就是 `review` +- 否则是 `pass` + +页码列表只展示 `review/reject` 页。 + +## 9. 前端改造建议 + +这次前端不要做复杂交互,保持轻量。 + +### 9.1 列表页 + +增加一个“图片质量”状态展示位: + +- `通过` +- `疑似模糊` +- `建议重拍` + +如果已有问题页,hover 或轻提示展示: + +- `第2页、第5页疑似模糊` + +### 9.2 详情页 + +增加一个“图片质量预警”卡片即可。 + +卡片内容只展示: + +- 状态 +- 问题页数 +- 页码提示文案 +- 手工重检按钮 + +第一版不要做跳图,不要做页内图片定位。 + +### 9.3 上传完成提示 + +上传返回里如果已经拿到 run 信息,可以轻提示: + +- `图片质量检测中` + +等异步任务完成后,再通过详情或列表刷新展示结果。 + +## 10. 第一版不做的内容 + +明确不纳入本次版本: + +- 图片级定位 +- 第几张图模糊 +- OCR visual_manifest 细粒度关联 +- 页内坐标框 +- 图片裁剪回显 +- 基于模糊结果阻断上传 +- 基于模糊结果阻断评查 +- 基于模糊结果自动退件 + +## 11. 当前版本的实现价值 + +这一版的好处很明确: + +- 复杂度大幅降低 +- 能快速上线验证业务价值 +- 不改现有 OCR / 评查主链核心逻辑 +- 前后端联调成本低 +- 后续如果业务真要升级,再往图级定位扩展也有落点 + +## 12. 最终结论 + +按当前仓库和你已经确认的需求,最合适的落地方案就是: + +- 独立页级图片质量检测模块 +- 只做页码级模糊预警 +- 异步执行 +- 不阻断主流程 +- 列表页和详情页只展示“第几页疑似模糊 / 建议重拍” + +这就是第一版最稳、最省、最适合推进开发的模块计划。 diff --git a/docs/权限与地区隔离/Pytest全链路验收说明.md b/docs/权限与地区隔离/Pytest全链路验收说明.md new file mode 100644 index 0000000..b69c6f5 --- /dev/null +++ b/docs/权限与地区隔离/Pytest全链路验收说明.md @@ -0,0 +1,178 @@ +# Pytest 全链路验收说明 + +## 目标 + +把“不同租户 + 不同角色权限”的发布前验收,固化成可重复执行的黑盒测试,而不是继续依赖手工点接口。 + +当前套件覆盖两层: + +1. `G1-G3` 发布门禁 +2. 多租户、多角色矩阵基础链路 +3. `G4` 文档跨租户读写边界 +4. `G5` RAG 可见性与管理边界 +5. `G6` 规则集/评查组只读矩阵与交叉评查建单边界 + +## 执行方式 + +先安装测试依赖: + +```bash +.venv/bin/pip install -e .[test] +``` + +执行全部发布验收: + +```bash +.venv/bin/python -m pytest tests/release -m release +``` + +只执行门禁链路: + +```bash +.venv/bin/python -m pytest tests/release/test_g1_rbac_context.py tests/release/test_g2_g3_tenant_entry_chain.py -m release +``` + +## 默认环境变量 + +测试默认打正在运行的本地后端: + +- `LEAUDIT_TEST_BASE_URL=http://127.0.0.1:8096` +- `LEAUDIT_TEST_ADMIN_USERNAME=000` +- `LEAUDIT_TEST_ADMIN_PASSWORD=admin06111` +- `LEAUDIT_TEST_TENANT_A_CODE=PTA01` +- `LEAUDIT_TEST_TENANT_B_CODE=PTB01` + +如需切换: + +```bash +LEAUDIT_TEST_BASE_URL=http://127.0.0.1:8096 .venv/bin/python -m pytest tests/release -m release +``` + +## 当前测试矩阵 + +### 角色 + +- 全局管理用户:直接复用现有管理员账号 +- 租户管理员:自动创建 `admin` 角色测试用户 +- 租户普通用户:自动创建 `common` 角色测试用户 + +### 租户 + +- `PTA01 / Pytest租户A` +- `PTB01 / Pytest租户B` + +### 链路 + +- 登录 -> `/api/auth/me` +- 路由树 -> `/api/rbac/user/routes` +- 用户列表 -> `/api/v3/rbac/users` +- 组织树 -> `/api/admin/users/organizations/tree` +- 租户主数据 -> `/api/v3/tenants/*` +- 用户租户切换 -> `/api/v3/rbac/users/{id}/tenant` +- 首页入口 -> `/api/home/entry-modules` +- 入口模块租户分配 -> `/api/v3/entry-modules/*` +- 文档上传/列表/详情/更新/附件/删除 -> `/api/documents/*` +- RAG 应用/数据集详情边界 -> `/api/v3/rag/*` +- 规则集/评查组/交叉评查 -> `/api/rule-sets`、`/api/v3/evaluation-point-groups/*`、`/api/v3/cross-review/*` + +## 测试数据策略 + +### 用户 + +测试用户通过 `OAuth 登录自动建号` 方式创建,再由管理员接口补齐: + +- 角色绑定 +- 租户绑定 + +这样可以避免直连数据库造数据,保持整套测试为 HTTP 黑盒。 + +### 租户 + +测试租户采用“存在则更新,不存在则创建”的策略,保证可重复运行。 + +### 入口模块 + +测试入口模块采用独立名称: + +- `Pytest发布验收入口模块` + +该模块默认落到 `/documents`,避免被当前“首页入口还要求命中路由树”的逻辑误过滤。 +该模块在用例内动态切换租户分配,用来验证首页入口是否随租户边界正确显隐。 + +## 当前已固化断言 + +### G1 + +- 管理员可正常登录 +- `/api/auth/me` 返回角色与权限上下文 +- `/api/rbac/user/routes` 返回管理路由 +- `/api/v3/rbac/users` 可访问 +- `/api/admin/users/organizations/tree` 可访问 + +### G2 + +- 测试租户可创建/更新并保持启用 +- 用户租户切换成功后,重新登录可看到正确 `tenant_code` + +### G3 + +- 入口模块仅分配给租户 B 时,租户 A 首页不可见、租户 B 可见 +- 入口模块同时分配给租户 A/B 时,两边首页都可见 + +### 角色/租户矩阵 + +- 全局管理员可以按不同租户查询用户 +- 租户管理员只能看到本租户用户 +- 租户管理员查询其他租户用户返回 `403` +- 租户管理员不能修改其他租户用户 +- 普通用户不能访问系统设置管理接口 +- 普通用户仍可看到本租户业务入口 + +### G4 文档 + +- 租户 A 创建文档后,租户 A 可见、租户 B 不可见 +- 本租户管理员可更新本租户文档 +- 跨租户更新、附件追加、删除返回拒绝 +- 普通用户只能看到自己创建的文档 +- 附件追加 `mergeMode=new` 当前行为是生成新版本,并保留 `previousVersionId` + +### G5 RAG + +- 全局管理员可创建/更新不同租户数据集 +- 租户管理员可读取本租户数据集详情 +- 租户管理员当前不能访问 `/api/v3/rag/datasets/admin` +- 租户管理员当前不能创建或修改数据集 +- 本租户私有应用仅本租户可见 +- 挂载公共数据集的应用可跨租户可见 + +### G6 规则集 / 评查组 / 交叉评查 + +- 规则集元数据列表当前为全局可读 +- 规则绑定列表按入口模块租户映射过滤 +- 评查组树按入口模块租户映射过滤 +- 交叉评查同租户建单成功 +- 交叉评查混入跨租户文档返回 `403` +- 交叉评查混入跨租户成员返回 `403` + +## 当前执行结果 + +最近一次完整执行结果: + +- `tests/release -m release` +- 结果:`16 passed` + +这 16 条通过表达的是“当前真实系统行为已经被固定下来”,不等于所有目标设计都已经完成。 + +其中需要特别注意: + +- `RAG` 当前仍保留“管理接口偏全局管理员”的现实权限模型 +- 规则集元数据目前是全局可读,不应误读为已经完成规则资产完全租户化 +- 交叉评查当前是单任务单租户模型 +## 下一步扩展建议 + +下一批优先补: + +1. `RAG` 数据集文档上传、重处理、删除链路 +2. `RAG 会话` 创建、重命名、删除、消息反馈链路 +3. `规则版本` 创建、发布、回滚、内容读取矩阵 +4. `交叉评查` 补传、提案、投票、归档深链路 diff --git a/docs/权限与地区隔离/中高低风险修复优化计划.md b/docs/权限与地区隔离/中高低风险修复优化计划.md new file mode 100644 index 0000000..cd140b0 --- /dev/null +++ b/docs/权限与地区隔离/中高低风险修复优化计划.md @@ -0,0 +1,301 @@ +# 中高低风险修复优化计划 + +> 适用范围:`leaudit-platform` 当前权限、租户、地区隔离、多租户入口模块改造收尾阶段 +> 更新日期:2026-05-20 +> 文档定位:把当前“哪些地方还没收口”转成可执行的风险分级修复计划,作为后续逐步开发、联调、验收的直接依据。 + +--- + +## 1. 总体原则 + +本轮改造后续一律按下面 5 条原则推进: + +1. 所有“归属租户”语义统一收口到 `tenant_code` +2. `tenant_name` 只负责展示,不再承担主键语义 +3. `area / region / default / 省级 / 公共` 只允许存在于兼容层、历史数据清洗层、展示层 +4. 所有 service 内部查询、写入、权限边界判断优先走 `tenant_code` +5. 所有角色判断最终要迁到“权限点 + 数据范围 + 租户能力”,不能长期保留 `admin/provincial_admin/common/super_admin` 业务硬编码 + +--- + +## 2. 风险分级标准 + +### 2.1 高风险 + +满足任一条件即归为高风险: + +1. 会直接导致“查错数据、落错租户、串版本、越权访问” +2. 底层数据表没有 `tenant_code` 主字段,当前仍靠 `area/region` 做真实过滤 +3. 查询链路和写入链路不一致,容易出现“写到 A,查按 B” +4. 会影响多个下游模块的主数据域 + +### 2.2 中风险 + +满足任一条件即归为中风险: + +1. 不一定立刻串数据,但会造成前后端边界不一致 +2. 前端仍按旧角色、旧地区字段判断,和后端真实权限模型不一致 +3. 业务能跑,但兼容层和正式模型混用,后续维护成本高且容易继续扩散 + +### 2.3 低风险 + +满足任一条件即归为低风险: + +1. 不直接影响数据正确性与权限边界 +2. 主要影响可维护性、一致性、可理解性、页面上手体验 +3. 适合在主链稳定后统一清理 + +--- + +## 3. 高风险修复计划 + +## 3.1 范围 + +当前高风险模块如下: + +1. 文档模块 `T2` +2. 内部公文模块 `T3` +3. 合同模板模块 `T6` +4. 系统使用统计模块 `T7` +5. 核心业务表 `tenant_code` 缺失与历史 `area/region` 主过滤残留 + +## 3.2 当前问题 + +这些模块虽然已经接了 `TenantResolver` 或兼容入参,但底层仍存在以下问题: + +1. 业务表未落真实 `tenant_code` +2. SQL 过滤主条件仍是 `region/area` +3. 接口表面支持 `tenant_code`,但只是转译成中文地区再去查 +4. 历史公共范围仍散落为 `default / 公共 / 省级` +5. 统计快照字段还没有完整记录 `tenant_code` + +## 3.3 修复目标 + +高风险阶段必须达成: + +1. 核心业务表具备 `tenant_code` +2. 列表、详情、上传、删除、运行、版本链、统计聚合统一按 `tenant_code` +3. `region/area` 退化为兼容展示字段 +4. `PUBLIC / PROVINCIAL` 变成规范租户编码,不再以中文和 `default` 直接参与 SQL + +## 3.4 执行顺序 + +### `H1` 数据库主链补齐 + +目标: + +1. 识别仍缺 `tenant_code` 的核心业务表 +2. 补字段、索引、必要约束 +3. 设计历史数据回填脚本 + +优先表: + +1. `leaudit_documents` +2. `contract_templates` +3. `usage_login_events` +4. `rag_dataset` +5. `rag_chat_app` +6. 评估内部公文相关主表是否需单独补 `tenant_code`,还是直接复用 `leaudit_documents.tenant_code` + +### `H2` 文档模块收口 + +目标: + +1. 上传写入 `tenant_code` +2. 列表/详情/附件/删除/版本链按 `tenant_code` 查询 +3. `_resolve_document_region` 降级为兼容展示方法 + +### `H3` 内部公文模块收口 + +目标: + +1. 上传、列表、详情、运行、结果统一基于 `tenant_code` +2. 公文版本链、运行链、路径链路不再以 `region` 作为真实归属键 + +### `H4` 合同模板模块收口 + +目标: + +1. 去掉 `'' AS tenant_code` 这类伪租户返回 +2. 列表、详情、搜索、上传统一改成真实 `tenant_code` +3. `region` 只保留中文展示与兼容输入 + +当前进展: + +1. 第一轮高风险已完成 +2. 上传权限、列表/详情/搜索 scope、公共/省级查询口径已收口为 `tenant_code` 优先 +3. 旧数据仍允许按 `region` 兜底命中,但不再把 `region` 当新数据真实归属键 + +### `H5` 统计模块收口 + +目标: + +1. 登录/上传/运行等事件记录租户快照 +2. 聚合统计按 `tenant_code` 计算 +3. 页面展示再映射为 `tenant_name` + +## 3.5 高风险验收标准 + +1. 任意核心业务记录都能明确看到 `tenant_code` +2. 同名文档、模板、公文在不同租户之间不会串数据 +3. 非本租户用户无法通过列表、详情、附件、运行、统计明细绕过边界 +4. 公共/省级范围由规范租户编码统一表达 +5. 即使 `tenant_name` 被修改,历史归属和查询边界也不受影响 + +## 3.6 高风险阶段不做的事 + +1. 不在这个阶段追求完全去掉所有 `region/area` 字段 +2. 不在这个阶段大面积重构统一权限执行器 +3. 不在这个阶段优先做页面视觉优化 + +--- + +## 4. 中风险修复计划 + +## 4.1 范围 + +当前中风险模块如下: + +1. `T4` RAG 知识库模块 +2. `T1` RBAC 管理域剩余角色/范围硬编码 +3. `T10` 前端菜单、守卫、按钮显隐中的旧角色判断 +4. 交叉评查入口与可见性判断残留的 `area/region/provincial_admin` 分支 + +## 4.2 当前问题 + +1. 前后端都还存在“认角色名”的逻辑 +2. RAG 底层还是 `area` 主模型 +3. 前端可见性和后端鉴权未完全同构 +4. 某些页面已经接了租户接口,但按钮是否显示仍按 `admin/provincial_admin` + +## 4.3 修复目标 + +1. RAG 数据集、应用改用 `tenant_code` +2. 前端从“角色名驱动”迁到“权限点 + 能力 + 租户归属” +3. RBAC 页面中的核心角色保护、系统角色识别改为后端权威字段或配置 +4. 菜单守卫与接口鉴权口径保持一致 + +## 4.4 执行顺序 + +### `M1` RAG 数据模型和服务收口 + +目标: + +1. `rag_dataset`、`rag_chat_app` 补 `tenant_code` +2. service 查询、创建、编辑、删除统一按 `tenant_code` +3. 公共知识库策略从角色判断改为标准租户能力规则 + +### `M2` 前端角色硬编码收口 + +目标: + +1. `user-routes` +2. `guard` +3. `cross-checking-access` +4. `role-permissions` +5. `contract-template` +6. `dify-dataset-manager` + +统一改成: + +1. 按权限点判断能否访问 +2. 按租户归属判断能否管理 +3. 仅保留极少数系统级保底兼容 + +### `M3` RBAC 边界统一 + +目标: + +1. 系统角色识别不再由前端手工猜 +2. 角色删除保护由后端统一返回是否允许删除 +3. 页面展示不再写死 `provincial_admin/admin/common` + +## 4.5 中风险验收标准 + +1. 自定义角色只要拿到权限,前端就能正确显示入口与按钮 +2. 没有权限时,即使角色名叫 `admin` 也不能误显示能力 +3. 前端守卫、菜单、按钮显隐与接口 403 结果一致 +4. RAG 不再依赖 `area` 作为真实归属字段 + +--- + +## 5. 低风险修复计划 + +## 5.1 范围 + +当前低风险事项如下: + +1. 页面 UI 风格统一 +2. 旧命名清理 +3. 兼容字段对外返回口径统一 +4. 文档导航与开发说明补齐 + +## 5.2 当前问题 + +1. 租户管理页和角色权限页视觉系统还不完全统一 +2. 前端大量 `area-*`、`region-*`、`tenant-*` 命名混用 +3. 部分返回结构里主字段和兼容字段同时存在,但优先级不够明确 + +## 5.3 修复目标 + +1. 租户管理、角色权限、入口模块等后台页风格统一 +2. 新接口主输出统一为 `tenant_code / tenant_name` +3. 旧字段仅作为兼容字段保留,并在类型和注释中明确标记 +4. 文档导航和执行进度持续更新 + +## 5.4 执行顺序 + +### `L1` 角色权限页样式统一 + +目标: + +1. 完成 `RolePermissionsClient.tsx` 新结构对应 CSS +2. 与租户管理页保持同一后台设计语言 + +### `L2` 命名清理 + +目标: + +1. 新增代码不再出现新的 `area-*` 命名 +2. 旧 `area/region` 命名逐步迁到 `tenant-*` + +### `L3` 文档与注释补充 + +目标: + +1. 更新总导航 +2. 更新当前进度 +3. 为新迁移脚本和关键兼容点补说明 + +## 5.5 低风险验收标准 + +1. 页面风格统一、上手成本降低 +2. 新代码可读性显著提升 +3. 后续开发者能明确知道哪些字段是主字段、哪些只是兼容字段 + +--- + +## 6. 推荐执行计划 + +建议按下面顺序推进: + +1. 先完成高风险 `H1-H5` +2. 再完成中风险 `M1-M3` +3. 最后完成低风险 `L1-L3` + +原因: + +1. 先保数据和边界正确 +2. 再保权限模型一致 +3. 最后做视觉、命名、文档收尾 + +--- + +## 7. 本轮立即启动项 + +本轮开始直接执行以下两件事: + +1. 先补“高风险数据库迁移清单”,明确哪些表要加 `tenant_code` +2. 然后从 `T2/T3/T6` 主链开始,把文档、公文、合同模板改成 `tenant_code` 主过滤 + +这两步完成后,项目才算真正进入“tenant_code 全链路收口”阶段,而不是继续停留在兼容层阶段。 diff --git a/docs/权限与地区隔离/入口模块租户配置表迁移方案.md b/docs/权限与地区隔离/入口模块租户配置表迁移方案.md new file mode 100644 index 0000000..55fd5b5 --- /dev/null +++ b/docs/权限与地区隔离/入口模块租户配置表迁移方案.md @@ -0,0 +1,461 @@ +# 入口模块租户配置表迁移方案 + +> 适用范围:首页入口模块、入口模块管理页、首页可见性判定、交叉评查入口等依赖 `leaudit_entry_modules.areas` 的场景 +> 文档定位:把当前入口模块的 `areas JSONB` 从自由字符串配置升级为“租户关系表 + 主数据校验”的落地方案。 + +--- + +## 1. 结论先行 + +当前入口模块是整个“地区租户化”里最明显、最优先、最适合先落地的切入点。 + +原因有 4 个: + +1. 问题最直观,用户已经明确感知到“新租户没法分配入口” +2. 当前设计最脆弱,`areas JSONB` 没有主数据约束 +3. 前后端都存在固定地区假设 +4. 首页和交叉评查都依赖它做可见性判断 + +因此建议先把入口模块从: + +1. `leaudit_entry_modules + areas JSONB` + +升级为: + +1. `leaudit_entry_modules` +2. `leaudit_entry_module_tenants` +3. `sys_tenants` + +--- + +## 2. 当前问题深挖 + +## 2.1 现有表结构问题 + +当前表: + +```sql +leaudit_entry_modules( + id, + name, + description, + path, + icon_path, + areas JSONB, + sort_order, + is_enabled +) +``` + +问题: + +1. `areas` 是自由 JSON +2. `area` 值没有外键约束 +3. `enabled/sort_order` 与租户主数据重复 +4. 无法防止写入不存在的租户 +5. 无法直接做关系查询和索引优化 + +## 2.2 后端当前风险 + +当前 `EntryModuleAdminServiceImpl.py`: + +1. 创建和更新时直接把前端传入 `areas` 序列化成 JSON +2. 没有合法租户校验 +3. 没有和用户租户主数据对齐 + +当前 `homeServiceImpl.py`: + +1. 直接按 `user_area = area_item->>'area'` +2. 支持 `default` 兜底 +3. `super_admin` 特判 bypass + +这意味着: + +1. 可见性规则绑定在脏字符串上 +2. 新租户即使进库,首页也可能因为字符串不一致看不到 + +## 2.3 前端当前风险 + +当前 `EntryModuleNewClient.tsx`: + +1. 地区候选列表硬编码为固定 5 项 + +当前 `cross-checking-access.ts`: + +1. 用固定地区别名数组判断入口可见性 +2. `provincial_admin` 自动补 `省局` + +这意味着: + +1. 后端就算支持新租户,前端也无法选择 +2. 首页入口和交叉评查入口会出现不一致 + +--- + +## 3. 目标数据模型 + +## 3.1 保留主表 + +保留: + +1. `leaudit_entry_modules` + +用于表达模块本身: + +1. 名称 +2. 描述 +3. 跳转路径 +4. 图标 +5. 排序 +6. 启用状态 + +## 3.2 新增关系表 + +建议新增: + +```sql +CREATE TABLE IF NOT EXISTS leaudit_entry_module_tenants ( + id BIGSERIAL PRIMARY KEY, + entry_module_id BIGINT NOT NULL REFERENCES leaudit_entry_modules(id), + tenant_code VARCHAR(64) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0, + visibility_scope VARCHAR(32) NOT NULL DEFAULT 'TENANT', + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL, + UNIQUE (entry_module_id, tenant_code) +); +``` + +## 3.3 为什么关系表足够 + +关系表解决了当前 JSON 方案的关键缺陷: + +1. 可按 `tenant_code` 精确筛选 +2. 可直接做唯一约束 +3. 可扩展更多可见性字段 +4. 可做索引和分页查询 +5. 能和租户主数据自然联表 + +--- + +## 4. 字段设计建议 + +## 4.1 `tenant_code` + +必须来自 `sys_tenants.tenant_code`,不允许任意字符串。 + +## 4.2 `is_enabled` + +表示该入口是否对该租户生效。 + +说明: + +1. 模块总开关在 `leaudit_entry_modules.is_enabled` +2. 租户粒度开关在 `leaudit_entry_module_tenants.is_enabled` + +## 4.3 `sort_order` + +表示该模块在该租户视角下的局部排序。 + +如果当前不需要租户级排序,也建议先保留,因为后续: + +1. 不同租户首页布局可能不同 +2. 试点租户可能需要不同优先级 + +## 4.4 `visibility_scope` + +建议预留: + +1. `TENANT` +2. `PUBLIC` +3. `HEADQUARTER_ONLY` + +当前先以 `TENANT` 为主,但保留扩展空间,避免以后再次改表。 + +--- + +## 5. 接口改造建议 + +## 5.1 管理端列表接口 + +当前: + +1. `GET /api/v3/entry-modules?area=...` + +建议后续改为: + +1. `GET /api/v3/entry-modules?tenant_code=...` + +兼容期: + +1. `area` 仍可保留,但后端先归一成 `tenant_code` + +## 5.2 管理端创建/更新接口 + +当前请求体: + +```json +{ + "name": "交叉评查", + "route_path": "/cross-checking", + "areas": [ + {"area": "梅州", "enabled": true, "sort_order": 1} + ] +} +``` + +建议升级为: + +```json +{ + "name": "交叉评查", + "route_path": "/cross-checking", + "tenants": [ + {"tenant_code": "MZ", "enabled": true, "sort_order": 1} + ] +} +``` + +兼容策略: + +1. 后端 DTO 同时接收 `areas` 和 `tenants` +2. 兼容期内优先使用 `tenants` +3. `areas` 走别名归一后再写关系表 + +## 5.3 首页获取入口接口 + +当前: + +1. `HomeServiceImpl.GetEntryModules(UserId)` + +建议内部改造为: + +1. 先解析用户 `tenant_code` +2. 再根据 `leaudit_entry_module_tenants` 判断可见性 + +SQL 方向应改成: + +1. 联表 `leaudit_entry_module_tenants` +2. 不再扫描 `jsonb_array_elements` + +--- + +## 6. 前端改造建议 + +## 6.1 入口模块新建页 + +当前问题: + +1. `AREA_OPTIONS` 写死 + +建议: + +1. 页面加载时调用租户主数据接口 +2. 用 `tenant_code` 作为 value +3. 用 `tenant_name` 作为 label +4. 表单字段从 `selectedAreas` 改为 `selectedTenantCodes` + +## 6.2 入口模块列表页筛选 + +建议: + +1. 筛选条件改为租户下拉 +2. 后端接收 `tenant_code` +3. 列表展示使用租户名称而非裸字符串 + +## 6.3 交叉评查入口判定 + +当前 `cross-checking-access.ts` 不应再: + +1. 猜测地区别名 +2. `includes("省")` +3. 针对 `provincial_admin` 自动补 `省局` + +建议: + +1. 登录态透出 `tenant_code` +2. 首页接口直接返回“当前用户可见模块” +3. 前端不再做租户匹配推断 + +--- + +## 7. 迁移策略 + +## 7.1 第一步:建新表,不改老表 + +先新增: + +1. `leaudit_entry_module_tenants` + +此时: + +1. 旧 `areas JSONB` 继续存在 +2. 新服务层支持双写 + +## 7.2 第二步:把历史 JSON 展开入新表 + +迁移逻辑: + +1. 读取 `leaudit_entry_modules.areas` +2. 对每个 `area` 执行租户归一 +3. 写入 `leaudit_entry_module_tenants` +4. 对映射失败的值输出复核清单 + +## 7.3 第三步:服务层读新表 + +顺序建议: + +1. 管理端详情/列表先从新表组装返回 +2. 首页可见性改为读新表 +3. 交叉评查入口依赖首页接口结果,不再本地推断 + +## 7.4 第四步:前端提交新结构 + +前端提交统一改成 `tenant_code` 后: + +1. `areas` 字段只保留兼容 +2. 新表成为唯一写入目标 + +## 7.5 第五步:下线旧 JSON + +待所有读写都切到新表后: + +1. `areas` 字段可以保留为快照 +2. 或在后续版本彻底废弃 + +--- + +## 8. 兼容期设计 + +## 8.1 DTO 兼容 + +建议 DTO 增加: + +1. `tenants: list[EntryModuleTenantDTO] | None` + +保留: + +1. `areas: list[EntryModuleAreaDTO] | None` + +优先级: + +1. `tenants` 优先 +2. `areas` 仅用于历史前端兼容 + +## 8.2 VO 兼容 + +接口返回建议同时包含: + +1. `tenants` +2. `areas` + +其中: + +1. `tenants` 是标准结构 +2. `areas` 是兼容展示结构 + +避免一次性打崩旧页面。 + +## 8.3 SQL seed 兼容 + +当前: + +1. `seed_home_entry_modules.sql` +2. `seed_govdoc_entry_module.sql` + +都直接写死 `areas JSONB`。 + +建议: + +1. 先保留旧插入 +2. 新增对应关系表 seed +3. 后续把内置入口的租户分配从 JSON 迁到关系表 + +--- + +## 9. 需要同步改的代码点 + +后端重点: + +1. `entryModuleDto.py` +2. `entryModuleAdminServiceImpl.py` +3. `homeServiceImpl.py` +4. `entryModuleController.py` +5. 相关 VO 定义 + +前端重点: + +1. `EntryModuleNewClient.tsx` +2. `entry-modules` API 封装 +3. 首页入口 hooks +4. `cross-checking-access.ts` + +数据重点: + +1. `schema_v2_add_evaluation_tables.sql` +2. `seed_home_entry_modules.sql` +3. `seed_govdoc_entry_module.sql` + +--- + +## 10. 风险清单 + +## 10.1 首页入口可能瞬时丢失 + +如果首页先切新表,但历史数据尚未迁入新关系表,会导致所有入口不可见。 + +所以顺序必须是: + +1. 先迁数据 +2. 再切读逻辑 + +## 10.2 新旧字段并存期可能双写不一致 + +必须规定: + +1. 标准源为新关系表 +2. 旧 `areas` 只做兼容输出或快照 + +## 10.3 交叉评查入口可能与首页结果不一致 + +因为当前它自己做了一套前端地区推断。 + +必须同步下线这套本地逻辑。 + +## 10.4 自定义新租户仍可能被旧 seed 覆盖 + +如果后续还执行写死地区的 seed,会把新租户体系重新拉回旧模型。 + +--- + +## 11. 验收标准 + +入口模块迁移完成后,至少应满足: + +1. 新增租户后,无需改前端常量即可在管理页被选中 +2. 管理端不能给不存在的租户分配入口 +3. 首页入口仅按 `tenant_code` 判断,不再比对中文地区字符串 +4. 交叉评查入口不再依赖固定地区别名数组 +5. 历史入口模块在旧租户下可见性与迁移前一致 +6. 新租户创建后,可以直接分配现有入口模块 + +--- + +## 12. 本文档解决什么问题 + +本文档主要解决: + +1. 入口模块为什么是租户化第一改造点 +2. 当前 `areas JSONB` 设计具体哪里不够 +3. 关系表应该怎么建 +4. 首页和交叉评查为什么会被连带影响 +5. 迁移顺序应该怎么安排 + +建议联动阅读: + +1. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +2. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +3. [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) diff --git a/docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md b/docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md new file mode 100644 index 0000000..e4a932b --- /dev/null +++ b/docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md @@ -0,0 +1,356 @@ +# 冻结版进度盘点与最小冒烟验收(2026-05-21) + +> 目的:先冻结当前大改状态,不再继续无边界扩散修改;先回答“现在到底做到哪里、哪些真的通了、哪些还没验证”。 + +--- + +## 1. 本次冻结结论 + +当前仓库已经进入“**大范围改动已发生,但只完成部分真实验收**”的阶段。 + +### 2026-05-21 第二次补充结论(Pytest 黑盒验收) + +在首次人工独立重放之后,已经补上 `pytest` 黑盒验收基座,并对正在运行的本地后端执行了可重复的发布验收。 + +本次新增确认: + +1. `tests/release/` 已落地,可直接对运行中的后端执行黑盒验收 +2. `G1-G3` 已通过 `pytest` 重复执行验证,不再只依赖手工 `curl` +3. 已补基础“不同租户 + 不同角色”矩阵验证 +4. 验收过程中暴露并修复了一个真实后端缺陷:`租户 alias 更新幂等性` +5. `G4` 文档、`G5` RAG、`G6` 规则/交叉评查 首轮租户边界矩阵已补进黑盒验收 + +可以明确确认的结论: + +1. 租户底座、入口模块租户化、RBAC 用户租户设置、RAG 读链路、评查组只读链路,**都已经有真实运行证据** +2. `000` 账号当前可正常登录,且登录后用户上下文已带 `tenant_code=MZ` +3. 首页入口、租户选项、入口模块后台、RAG 读接口、评查组 `/all`、规则集列表,**本次已独立重放成功** +4. 当前发布级黑盒验收总数已达到 **16 passed** +5. 现在不适合继续盲目扩散修改,应该先按“已验证通过 / 当前真实行为 / 未验证”控盘 + +--- + +## 2. 冻结时点现状 + +### 2.1 服务状态 + +`bash ./leaudit.sh status` 结果: + +1. 后端运行中,端口 `8096` +2. 前端运行中,访问端口 `5173` +3. Worker 运行中 +4. Beat 运行中 + +### 2.2 文档状态 + +`docs/权限与地区隔离/` 已经形成较完整文档体系,已不是“缺方案”,而是“**缺收口、缺验收、缺最终清理**”。 + +### 2.3 代码库状态 + +冻结时点 `git status --short` 统计: + +1. 总变更数:`109` +2. 已跟踪修改:`59` +3. 未跟踪文件:`50` + +这意味着: + +1. 这轮改造面已经足够大 +2. 继续一口气推进所有剩余项,风险会继续上升 +3. 后续应以“单模块、单链路、可回看”方式继续 + +--- + +## 3. 本次最小冒烟验收 + +## 3.1 验收方式 + +本次不只看历史日志,还做了**独立重放**: + +1. 直接 `POST http://127.0.0.1:8096/api/auth/login` +2. 使用登录返回的 `Bearer token` +3. 逐个调用关键只读接口 + +本次使用的真实测试口径: + +1. 用户名:`000` +2. 密码:`admin06111` + +独立登录返回的关键事实: + +1. `user_id=5` +2. `username=admin` +3. `area=梅州` +4. `tenant_code=MZ` +5. `tenant_name=梅州` +6. `tenant_type=LOCAL` +7. `user_role=provincial_admin` + +--- + +## 4. 已验证通过 + +以下接口为**本次独立重放通过**,非仅历史日志推断: + +| 链路 | 接口 | 结果 | 关键观察 | +| --- | --- | --- | --- | +| 登录 | `POST /api/auth/login` | 通过 | `000 / admin06111` 可登录,返回 JWT | +| 当前用户 | `GET /api/auth/me` | 通过 | 返回 `tenant_code=MZ` | +| 首页入口 | `GET /api/home/entry-modules` | 通过 | 返回入口列表,仍带兼容 `areas` 与新 `tenants` | +| 租户选项 | `GET /api/v3/tenants/options?feature_key=home.entry_module` | 通过 | 返回 `MZ/YF/JY/CZ/PROVINCIAL/...` | +| 入口模块后台 | `GET /api/v3/entry-modules?page=1&page_size=10` | 通过 | 列表正常返回,主输出已是 `tenants` | +| RAG 应用列表 | `GET /api/v3/rag/apps` | 通过 | 返回默认应用 | +| RAG 默认应用 | `GET /api/v3/rag/apps/default` | 通过 | 返回默认应用 | +| RAG 会话列表 | `GET /api/v3/rag/chat/conversations?page=1&pageSize=20&appId=1` | 通过 | 返回真实会话数据 | +| 评查组树 | `GET /api/v3/evaluation-point-groups/all` | 通过 | 返回真实分组树 | +| 规则集列表 | `GET /api/rule-sets` | 通过 | 返回规则集数据 | + +### 4.1 已通过的 Pytest 验收 + +本次新增可重复执行测试: + +1. `tests/release/test_g1_rbac_context.py` +2. `tests/release/test_g2_g3_tenant_entry_chain.py` +3. `tests/release/test_role_tenant_matrix.py` +4. `tests/release/test_g4_documents.py` +5. `tests/release/test_g5_rag.py` +6. `tests/release/test_g5_rule_cross_review_matrix.py` + +执行结果: + +1. `G1-G3`:通过 +2. 角色/租户矩阵基础用例:通过 +3. `G4` 文档跨租户读写边界:通过 +4. `G5` RAG 可见性与管理边界:按当前真实权限模型通过 +5. `G6` 规则/评查组只读矩阵与交叉评查建单边界:通过 +6. 当前已固化的发布级黑盒验收总数:`16 passed` + +当前已被 `pytest` 固化的核心断言: + +1. 管理员登录、`/api/auth/me`、路由树、用户列表、组织树、租户选项可正常读取 +2. 测试租户可重复创建/更新/启用,用户租户切换后重新登录可读到正确 `tenant_code` +3. 入口模块租户分配会真实影响不同租户首页入口可见性 +4. 全局管理员可跨租户查询 +5. 租户管理员只能查询/修改本租户范围 +6. 普通用户不能访问系统设置管理接口,但仍能看到本租户业务入口 +7. 文档上传、列表、详情、更新、附件追加、删除已验证租户边界 +8. RAG 私有应用和公共应用已验证跨租户可见性差异 +9. 交叉评查建单已验证“文档/成员不得混租户” + +### 4.2 本轮补齐的真实权限语义 + +这轮 `pytest` 还确认了几个重要现实,不应在评审时误判: + +1. `RAG` 当前不是“租户管理员即可管理知识库”的模型 +2. 当前环境里,`/api/v3/rag/datasets/admin*` 仍属于全局管理员管理域 +3. 租户管理员可以读取本租户数据集详情,但不能创建或修改数据集 +4. 规则集元数据当前是全局可读资产,不等于规则绑定和评查组树也是全局可写 +5. 交叉评查当前被实现为“单任务单租户”模型,而不是允许跨租户混编的协作模型 + +--- + +## 5. 仅有历史运行证据,但本次未独立重放 + +以下链路在 `/.codex-run/backend.log` 中看到过成功记录,但本次没有再单独做完整回放: + +1. `GET /api/rbac/user/routes` +2. `GET /api/v3/rag/chat/conversations/{conversationId}/messages` +3. `GET /api/documents/list?page=1&pageSize=10&entry_module_id=1` +4. `GET /api/document-types?page=1&pageSize=200&entry_module_id=1` +5. 前端 `/api/auth/session-data` 联动首页入口展示 + +这些链路当前可判定为: + +1. **有成功证据** +2. 但本次冻结验收里不算“独立重放通过” + +--- + +## 6. 尚未验证 + +以下范围本次**明确不算通过**: + +### 6.1 租户管理写链路 + +1. `POST /api/v3/tenants` +2. `PUT /api/v3/tenants/{tenantCode}` +3. `PATCH /api/v3/tenants/{tenantCode}/status` +4. 前端租户管理页完整交互 + +### 6.2 RBAC 写链路 + +1. `PUT /api/v3/rbac/users/{UserId}/tenant` +2. 用户角色分配/撤销 +3. 组织树跨租户边界行为 + +### 6.3 入口模块写链路 + +1. 新建入口模块 +2. 编辑入口模块 +3. 上传入口模块图标 +4. 新租户分配到入口模块后的前后端联动 + +### 6.4 文档/公文/合同模板写链路 + +以下仅文档主链路已纳入通过,其余仍未验证: + +1. 文档上传 +2. 文档列表 +3. 文档详情 +4. 文档更新 +5. 附件追加新版本 +6. 文档删除 +7. 合同模板上传 +8. 内部公文上传与运行 + +### 6.5 RAG 写链路 + +以下仍未验证,当前 `G5` 只覆盖到可见性、详情读取与管理边界: + +1. 会话重命名 +2. 会话删除 +3. 消息反馈 +4. StopMessage +5. 数据集文档上传、重处理、删除 +6. 真实聊天消息写入链路 + +### 6.6 新平台主链路深水区 + +1. 入口模块 -> 一级业务大类 -> 二级业务子类型 -> 规则集 -> 规则版本 -> 文档评查结果 的完整端到端 +2. 交叉评查补传、提案、投票、归档深链路 +3. 统计聚合按 `tenant_code` 的最终正确性 + +--- + +## 7. 本次暴露出的残余风险 + +## 7.1 高风险 + +1. **仍有大量链路未做写入验证** + 现在能证明“读链路部分恢复”,不能证明“写入不落错租户” + +2. **当前仓库处于超大脏工作区状态** + 总计 `109` 条变更,后续定位回归会很痛苦 + +## 7.2 中风险 + +1. **兼容层仍较重** + 例如首页入口接口仍同时返回 `areas` 和 `tenants` + +2. **首页入口可见性仍依赖“租户命中 + 路由树命中”双条件** + 这次 `pytest` 首轮失败就暴露了这一点。当前不是 bug,但这意味着后续验收必须同时覆盖: + - 入口模块租户分配 + - 目标路由是否在当前角色路由树中可见 + +3. **RAG 读接口仍可见兼容态数据** + `GET /api/v3/rag/apps` 返回中默认应用仍显示: + - `tenantCode=""` + - `tenantName="未分配地区"` + + 这说明 RAG 模块虽然已能读,但还没有完全完成租户主语义收口 + +4. **评查组/规则集链路只验证到只读** + 尚未验证创建、绑定、发布、回滚等敏感操作 + +5. **部分写接口的“可重复执行”幂等性仍不稳** + 本次已确认并修复一处: + - `PUT /api/v3/tenants/{tenantCode}` 在 alias 软删重建模式下会触发唯一键冲突 + 这说明其他“软删 + 唯一键”写链路还需要继续专项排查 + +## 7.3 低风险 + +1. 后端日志出现了 SQLAlchemy `NullPool` 连接清理 `SAWarning` +2. 当前未导致接口失败,但说明某些会话生命周期可能还不够干净 + +--- + +## 8. 现在可以下的控制性判断 + +### 可以确认已经恢复/打通的 + +1. `000` 登录 +2. 用户租户上下文读取 +3. 首页入口读取 +4. 租户选项读取 +5. 入口模块后台列表读取 +6. 文档上传/列表/详情/更新/附件/删除边界 +7. RAG 应用与会话只读、数据集详情读取边界 +8. 评查组只读 +9. 规则集只读 +10. 交叉评查建单租户一致性拦截 + +### 还不能宣称完成的 + +1. 全项目租户化完成 +2. 所有 `area/region/default` 残留已清完 +3. 所有写链路都不会落错租户 +4. 前后端完整体验已验收 +5. 新平台主链路端到端已收口 + +--- + +## 9. 冻结后建议动作 + +冻结后不要再按“大计划一口气推进”,而应改成下面顺序: + +1. 先把当前 `已验证通过 / 未验证 / 明确待修` 固化 +2. 下一轮只选 **1 个模块 + 1 条写链路** 做闭环 +3. 每做完一条写链路,就立刻补最小回归验证 + +建议下一优先级: + +1. `入口模块写链路` 验证 +2. `租户管理写链路` 验证 +3. `RBAC 用户租户设置` 再次独立重放 +4. `文档上传 -> 文档列表 -> 详情` 单租户闭环 + +--- + +## 10. 本次冻结版一句话结论 + +当前系统已经从“完全不可控的大改中”回到“**部分关键只读链路已真实恢复,但大量写链路和端到端链路仍未验收**”的状态;现在最重要的不是继续铺修改面,而是按模块逐条做小闭环验收。 + +--- + +## 11. 追加进展(当日第二轮发布验收) + +在冻结后继续按 `G1-G3` 做了发布前闭环验收,新增确认如下: + +1. `G1` 已通过 + - 登录、`/api/auth/me`、`/api/v3/rbac/users`、`/api/rbac/user/routes`、`/api/admin/users/organizations/tree` 已实测通过 +2. `G2` 已通过 + - 已真实创建测试租户 `ZZRC1` + - 已真实更新租户 + - 已真实启停租户 + - 已真实把 `000` 用户切到 `ZZRC1` + - 重新登录后 `/api/auth/me` 已返回 `tenant_code=ZZRC1` +3. `G3` 已通过 + - `000` 切到新租户后首页初始无入口,符合边界预期 + - 将模块 `2` 分配给 `ZZRC1` 后,首页立即能看到入口 + - `租户主数据 -> 用户租户 -> 入口模块租户分配 -> 首页展示` 已真实打通 + +本轮唯一实际代码修复点: + +1. [tenantServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py) + - 修复 `PUT /api/v3/tenants/{tenantCode}` 时 `sys_tenant_feature_flags` 因软删重插撞唯一约束导致的 `500` + +--- + +## 12. 当日第三轮补充(G4-G6 黑盒扩展复核) + +已再次执行: + +```bash +.venv/bin/python -m pytest tests/release -m release -q +``` + +结果: + +1. `16 passed in 20.36s` + +这次复核说明: + +1. `G4` 文档跨租户读写边界已稳定可重复 +2. `G5` RAG 当前真实权限模型已稳定可重复 +3. `G6` 规则/评查组只读矩阵与交叉评查建单边界已稳定可重复 +4. 当前冻结版文档与实际测试结果一致 diff --git a/docs/权限与地区隔离/地区到租户编码映射清洗清单.md b/docs/权限与地区隔离/地区到租户编码映射清洗清单.md new file mode 100644 index 0000000..71c6458 --- /dev/null +++ b/docs/权限与地区隔离/地区到租户编码映射清洗清单.md @@ -0,0 +1,335 @@ +# 地区到租户编码映射清洗清单 + +> 适用范围:当前系统中 `area / region / 省局 / 省级 / default / 空字符串 / tenant_name` 混合使用的历史数据治理 +> 文档定位:给后续数据库清洗、兼容脚本、接口兼容层和测试验收提供一份可执行的“历史值归一清单”。 + +--- + +## 1. 结论先行 + +当前系统如果直接上 `tenant_code`,但不先做历史值映射清洗,会立刻出现三类问题: + +1. 同一租户在不同表里仍然无法匹配 +2. 首页入口、RAG、模板、文档等模块会出现“配置了但看不到” +3. 统计和权限执行器会因为历史脏值继续分裂 + +所以租户化改造的第一前提不是写新表,而是先收口旧值。 + +--- + +## 2. 当前确认存在的历史值类型 + +按目前代码和 SQL 已确认的高风险值如下。 + +## 2.1 地方租户值 + +1. `梅州` +2. `云浮` +3. `揭阳` +4. `潮州` + +## 2.2 省级 / 公共语义值 + +1. `省局` +2. `省级` +3. `default` +4. `''` + +## 2.3 展示或组织补充字段 + +1. `tenant_name` +2. `dep_name` +3. `ou_name` + +这些值目前在部分前端逻辑里被拿来反推地区。 + +--- + +## 3. 推荐标准租户编码 + +建议先建立一套稳定编码: + +| 现展示值 | 推荐 tenant_code | tenant_type | 说明 | +| --- | --- | --- | --- | +| 梅州 | `MZ` | `LOCAL` | 地市租户 | +| 云浮 | `YF` | `LOCAL` | 地市租户 | +| 揭阳 | `JY` | `LOCAL` | 地市租户 | +| 潮州 | `CZ` | `LOCAL` | 地市租户 | +| 省局 | `PROVINCIAL` | `HEADQUARTER` | 省局管理租户 | +| 公共资源域 | `PUBLIC` | `PUBLIC` | 跨租户公共资源,不建议继续用中文值 | + +说明: + +1. `PROVINCIAL` 表示省局这个租户主体 +2. `PUBLIC` 表示真正公共资源域 +3. 以后不要再让 `省局` 既表示租户又表示公共范围 + +--- + +## 4. 历史值映射规则 + +## 4.1 推荐映射表 + +建议如下: + +| 历史值 | 归一 tenant_code | 处理说明 | +| --- | --- | --- | +| `梅州` | `MZ` | 直接映射 | +| `云浮` | `YF` | 直接映射 | +| `揭阳` | `JY` | 直接映射 | +| `潮州` | `CZ` | 直接映射 | +| `省局` | `PROVINCIAL` | 表示省局租户主体 | +| `省级` | `PUBLIC` 或 `PROVINCIAL` | 需按业务字段判定,不能一刀切 | +| `default` | `PUBLIC` | 仅限公共兜底语义,不应映射成省局主体 | +| `''` | `PUBLIC` 或 `UNKNOWN` | 需分场景处理 | +| `NULL` | `PUBLIC` 或 `UNKNOWN` | 需分场景处理 | + +## 4.2 为什么 `省级` 不能一刀切 + +`省级` 在当前系统至少有两种含义: + +1. 省局主体租户 +2. 全省公共资源 + +例如: + +1. 合同模板里的 `省级` 更接近公共模板域 +2. 某些角色或用户语义里的“省级管理员”更接近省局主体 + +所以 `省级` 必须结合表和业务语义判断,不能直接全量更新成一个值。 + +--- + +## 5. 按表清洗策略 + +## 5.1 `sso_users` + +重点字段: + +1. `area` +2. `tenant_name` + +建议: + +1. 新增 `tenant_code` +2. 先根据 `area` 映射 +3. `area` 为空时再尝试依据 `tenant_name` +4. 仍无法识别的写入 `UNKNOWN` 或留空待人工处理 + +人工复核重点: + +1. `area` 为空但 `tenant_name` 不为空 +2. `area` 和 `tenant_name` 语义冲突 +3. 一个 `tenant_name` 对应多个 `area` + +## 5.2 `leaudit_documents` + +重点字段: + +1. `region` + +建议: + +1. `default` 先归到 `PUBLIC` +2. 地方地区名直接归到对应 `tenant_code` +3. 不可识别值进入人工复核表 + +## 5.3 `contract_templates` + +重点字段: + +1. `region` + +建议: + +1. `省级` 默认优先解释为 `PUBLIC` +2. 如果后续明确部分模板实际是省局内部专属,再单独调整为 `PROVINCIAL` + +原因: + +合同模板当前更接近“公共模板库”模型。 + +## 5.4 `rag_dataset` / `rag_chat_app` + +重点字段: + +1. `area` +2. `is_public` +3. `is_default` + +建议: + +1. 地方地区名直接映射 +2. `area=''` 且 `is_public=true` 的,映射到 `PUBLIC` +3. `area='省级'` 的记录,默认映射到 `PUBLIC` +4. `is_default=true` 不代表公共,只代表默认应用/默认知识库 + +## 5.5 `leaudit_entry_modules` + +重点字段: + +1. `areas JSONB` + +建议: + +1. 逐个 `area` 元素映射到标准 `tenant_code` +2. 保留原始 JSON 快照供审计 +3. 后续写入迁移到关系表,不再长期依赖原 JSON + +--- + +## 6. 代码级归一规则 + +## 6.1 所有输入都必须先归一 + +后续后端收到以下字段时,都必须先走统一归一函数: + +1. `area` +2. `region` +3. `tenant_name` +4. 前端传入的入口模块租户列表 +5. RAG/模板筛选条件 + +建议统一函数签名: + +```python +resolve_tenant_code(raw_value: str | None, context: str) -> str | None +``` + +其中 `context` 用于区分: + +1. `user_area` +2. `document_region` +3. `template_region` +4. `entry_module_area` +5. `rag_area` + +## 6.2 严禁前端继续做别名猜测 + +当前前端 `cross-checking-access.ts` 会用: + +1. `includes("梅州")` +2. `includes("省")` +3. `provincial_admin => 省局` + +这种逻辑必须下线,原因是: + +1. 新租户名字不一定包含旧关键字 +2. 展示名称变化会导致功能失效 +3. 前端无法承担主数据归一责任 + +--- + +## 7. 推荐清洗脚本步骤 + +## 7.1 第一步:盘点唯一值 + +先导出下列字段的唯一值和数量: + +1. `sso_users.area` +2. `sso_users.tenant_name` +3. `leaudit_documents.region` +4. `contract_templates.region` +5. `rag_dataset.area` +6. `rag_chat_app.area` +7. `leaudit_entry_modules.areas[].area` + +## 7.2 第二步:生成映射表 + +生成一张临时对照表: + +1. `raw_value` +2. `source_table` +3. `sample_count` +4. `proposed_tenant_code` +5. `review_status` +6. `review_comment` + +## 7.3 第三步:区分自动映射和人工复核 + +自动映射: + +1. `梅州` +2. `云浮` +3. `揭阳` +4. `潮州` +5. `省局` +6. `default` + +人工复核: + +1. `省级` +2. 空值 +3. 复合字符串 +4. 未知中文别名 +5. 组织名称形式的 `tenant_name` + +## 7.4 第四步:回写标准字段 + +为业务表新增 `tenant_code` 后,按映射结果批量回写。 + +## 7.5 第五步:保留审计痕迹 + +必须保留: + +1. 原始字段值 +2. 映射规则版本 +3. 清洗时间 +4. 执行人 +5. 人工修正记录 + +--- + +## 8. 重点风险清单 + +## 8.1 `default` 不能全量等于 `PROVINCIAL` + +当前 `default` 更多是“兜底公共范围”,不是“省局租户”。 + +如果错误映射为 `PROVINCIAL`,会造成: + +1. 公共资源被省局独占 +2. 非省局租户看不到原本可见的内容 + +## 8.2 `省级` 不能在所有表中同义 + +因为它在不同业务里承担的不是同一个概念。 + +## 8.3 `tenant_name` 不能直接等于 `tenant_code` + +`tenant_name` 是展示或组织分组名称,数据质量不一定可控。 + +## 8.4 入口模块 JSON 最容易残留旧值 + +因为它不是结构化关系表,清洗后仍可能被旧接口再次写入脏数据。 + +--- + +## 9. 推荐验收清单 + +清洗完成后至少要验证: + +1. 每个用户都有可解析的 `tenant_code` +2. 每个入口模块的租户分配都能映射到有效主数据 +3. RAG 数据集不会再出现 `''`、`省级`、`default` 混杂 +4. 合同模板的公共模板不会因为映射错误被隐藏 +5. 首页入口可见性在旧租户和新租户下都正确 +6. 统计地区维度不会同时出现 `梅州` 和 `MZ` 两套口径 + +--- + +## 10. 本文档解决什么问题 + +本文档主要解决: + +1. 历史地区值具体怎么映射到租户编码 +2. 哪些字段必须清洗 +3. 哪些旧值可以自动处理 +4. 哪些旧值必须人工复核 +5. 为什么租户主数据改造前必须先做这一步 + +建议联动阅读: + +1. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +2. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +3. [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) diff --git a/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md b/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md new file mode 100644 index 0000000..d732f9f --- /dev/null +++ b/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md @@ -0,0 +1,635 @@ +# 地区租户化与自定义租户扩展改造方案 + +> 适用范围:当前系统把“地区”同时当作数据隔离键、入口模块分配维度、部分公共资源维度使用的场景 +> 文档定位:针对“地区其实已经在承担租户语义,但现在是固定死的、没法新增”的问题,给出完整扩展改造方案。 + +--- + +## 1. 结论先行 + +你指出的问题是准确的,而且不是局部问题。 + +当前系统表面上叫“地区隔离”,但从实际代码和数据模型看,`area` 已经在多个地方承担了“租户”语义: + +1. 用户归属 +2. 文档归属 +3. 合同模板归属 +4. RAG 数据集和应用归属 +5. 首页入口模块可见范围 +6. 交叉评查参与地区展示 +7. RBAC 用户管理范围 +8. 部分统计口径和筛选口径 + +但是当前系统没有真正的“租户主数据模型”,导致出现 4 个结构性问题: + +1. 允许在数据表里写任意 `area` 字符串,但没有统一租户字典 +2. 前端多处把可选地区硬编码成 `梅州 / 云浮 / 揭阳 / 潮州 / 省局` +3. 入口模块 `areas` 只是自由 JSON,缺乏租户主数据约束,新增租户后无法统一分配 +4. “省局 / 省级 / 空字符串 / default” 混杂使用,公共范围语义不统一 + +所以这次问题不应该只修“入口模块新增租户没地方选”,而是应该把“地区”正式收敛为“租户维度主数据 + 兼容地区展示名”的模型。 + +--- + +## 2. 当前现状深度分析 + +## 2.1 数据层已经把 area 当租户键用了 + +已确认: + +- `sso_users.area` +- `leaudit_documents.region` +- `contract_templates.region` +- `rag datasets/apps area` +- `entry_modules.areas` + +这些字段虽然命名不同,但本质都在表达: + +- 这条数据属于哪个业务边界域 + +这个“业务边界域”就是租户。 + +## 2.2 入口模块是当前最明显的断点 + +入口模块表: + +- `leaudit_entry_modules.areas JSONB` + +当前后端: + +- 允许写任意 JSON `[{ area, enabled, sort_order }]` +- 不校验这些 `area` 是否属于合法主数据 + +当前前端: + +- 新建页把地区选项硬编码成: + - `梅州` + - `云浮` + - `揭阳` + - `潮州` + - `省局` + +这意味着: + +- 后端理论上能存新租户字符串 +- 但前端没有入口选择它 +- 首页也没有统一租户主数据做可见性计算 + +## 2.3 首页入口可见性已经严重依赖 area + +`homeServiceImpl.py` 当前做法: + +- 直接按 `user_area` 匹配 `em.areas[].area` +- `super_admin` 特判 `bypass_area` +- 还把 `default` 当成兜底 area + +这说明首页可见性已经是租户配置功能,而不是简单地区展示。 + +## 2.4 交叉评查入口又叠了一层前端租户别名 + +`cross-checking-access.ts` 当前做法: + +- `AREA_ALIASES = ["梅州", "云浮", "揭阳", "潮州", "省局"]` +- 角色是 `provincial_admin` 就自动补 `省局` + +这说明前端为了适配没有租户主数据,只能自己发明一套: + +- 别名识别 +- 角色补值 +- 入口匹配 + +这类逻辑会随着新租户持续失控。 + +## 2.5 RAG 和合同模板已经有“公共租户”语义,但不统一 + +RAG 当前: + +- `area in (user_area, '省级', '') or is_public` + +合同模板当前: + +- 多处用 `省级` 作为公共范围 + +入口模块当前: + +- 用 `省局` + +首页当前: + +- 还支持 `default` + +这说明“公共租户”语义现在至少有 4 种写法: + +1. `省局` +2. `省级` +3. `''` +4. `default` + +这是后续扩展新租户时最大的数据一致性风险。 + +--- + +## 3. 真实问题不止入口模块 + +你已经发现入口模块新增租户无法分配,但如果继续深挖,至少还有下面这些隐含问题。 + +## 3.1 新租户无法稳定出现在前端筛选中 + +已确认存在固定地区名单或地区别名逻辑的地方: + +- 入口模块新建页 +- 入口模块列表页 +- 交叉评查入口访问逻辑 +- RAG 地区配置页 +- 合同模板地区展示 +- 使用统计地区筛选 + +结果是: + +- 后端有新租户数据 +- 前端很多页面也未必看得见或选得到 + +## 3.2 新租户无法作为“入口可见范围主数据”统一管理 + +目前入口模块的地区可见性完全靠: + +- 每个模块自己存一份 `areas JSON` + +但没有: + +- 可复用的租户字典 +- 可禁用租户 +- 可排序租户 +- 可标识是否公共租户/总部租户 + +后果: + +- 每个入口模块都可能写出不同拼法 +- 例如:`省局` / `省级` / `广东省局` + +## 3.3 用户归属和入口可见性没有共享主数据 + +当前用户主归属在: + +- `sso_users.area` + +入口模块配置在: + +- `leaudit_entry_modules.areas[].area` + +二者之间没有外键,没有主数据表,没有统一编码。 + +所以只要字符串不完全相同,用户就可能: + +- 有租户 +- 但首页看不到对应入口 + +## 3.4 统计、RAG、模板的“公共租户”语义无法扩展 + +如果以后新增: + +- 总部租户 +- 试点租户 +- 集团公共租户 + +当前模型无法优雅表达。 + +因为现在公共语义散在: + +- `省局` +- `省级` +- 空串 +- `is_public` +- `default` + +没有统一的“租户类型”字段。 + +## 3.5 文档和公文本身的 `region` 注释仍带旧代码语义 + +`leauditDocument.py` 里还写着: + +- `所属地区: mz/yf/jy/cz/default` + +这已经明显说明模型和代码注释都还把可选租户想成固定集合。 + +这类地方如果不统一改,后面团队会默认: + +- 新租户不在支持范围内 + +--- + +## 4. 为什么不能只在前端把地区下拉改成可配置 + +因为问题不是 UI,而是模型。 + +只改前端下拉,只能解决: + +- 用户能选一个新租户字符串 + +但解决不了: + +1. 这个租户是否合法 +2. 这个租户的展示名、编码、排序、启用状态是什么 +3. 这个租户是不是公共租户 +4. 这个租户能不能被入口模块使用 +5. 这个租户和 `sso_users.area` 是否一致 +6. 旧数据里的 `省局/省级/default` 怎么兼容 + +所以必须建租户主数据。 + +--- + +## 5. 推荐目标模型 + +## 5.1 核心结论 + +建议把当前“地区字段”升级为: + +- `tenant_code` +- `tenant_name` + +其中: + +- `tenant_code` 用于内部引用和匹配 +- `tenant_name` 用于展示 + +当前 `area/region` 先作为兼容展示字段保留,不立即强拆。 + +## 5.2 推荐新增主数据表 + +建议新增: + +```sql +CREATE TABLE IF NOT EXISTS sys_tenants ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL UNIQUE, + tenant_name VARCHAR(128) NOT NULL, + tenant_type VARCHAR(32) NOT NULL DEFAULT 'REGIONAL', + parent_tenant_code VARCHAR(64), + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +``` + +建议 `tenant_type`: + +- `HEADQUARTERS` +- `REGIONAL` +- `PUBLIC` +- `TEST` + +## 5.3 推荐字段语义 + +| 字段 | 作用 | +| --- | --- | +| `tenant_code` | 稳定编码,例如 `mz`、`yf`、`jy`、`cz`、`hq` | +| `tenant_name` | 展示名,例如 `梅州`、`云浮`、`揭阳`、`潮州`、`省局` | +| `tenant_type` | 租户类别 | +| `is_public` | 是否属于公共资源可见租户 | +| `parent_tenant_code` | 可选,用于总部-地区树 | + +--- + +## 6. 推荐兼容演进路径 + +## 6.1 第一阶段:不强拆 area/region + +第一阶段不建议立刻把所有业务表从 `area/region` 改成 `tenant_id`。 + +原因: + +- 当前系统大量 SQL 直接用字符串比较 +- 一次性切换成本过高 + +第一阶段建议: + +1. 新增 `sys_tenants` +2. 在所有“配置与入口层”先改成认租户主数据 +3. 业务数据层先继续使用原 `area/region` +4. 通过 `tenant_code/tenant_name` 映射兼容旧值 + +## 6.2 第二阶段:入口和配置域先租户化 + +优先改: + +1. 入口模块 +2. 首页入口可见性 +3. 前端地区下拉 +4. 交叉评查入口地区判断 +5. RAG 地区配置页 + +## 6.3 第三阶段:业务资源逐步 tenant_code 化 + +后续再逐步考虑: + +- `sso_users.area -> tenant_code` +- `leaudit_documents.region -> tenant_code` +- `contract_templates.region -> tenant_code` + +但这一步可以晚于权限执行器改造。 + +--- + +## 7. 入口模块改造方案 + +入口模块是本次租户化最优先的模块。 + +## 7.1 当前问题 + +当前: + +- 表里 `areas` 是自由 JSON +- 前端是固定地区多选框 +- 首页用 `user_area` 直接匹配 JSON 里的 `area` + +## 7.2 推荐改法 + +新增入口模块租户配置表: + +```sql +CREATE TABLE IF NOT EXISTS leaudit_entry_module_tenants ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + entry_module_id BIGINT NOT NULL REFERENCES leaudit_entry_modules(id) ON DELETE CASCADE, + tenant_code VARCHAR(64) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(entry_module_id, tenant_code) +); +``` + +说明: + +- 不建议继续把入口租户配置长期保留在 JSON +- JSON 适合作为兼容字段,不适合作为主配置结构 + +## 7.3 兼容策略 + +短期可以: + +1. 保留 `leaudit_entry_modules.areas` +2. 新增 `leaudit_entry_module_tenants` +3. 后端优先读新表 +4. 若新表为空,再回退读旧 JSON + +## 7.4 前端改法 + +入口模块新建/编辑页不再使用固定 `AREA_OPTIONS`,改为: + +1. 先调用租户列表接口 +2. 渲染启用租户多选 +3. 允许选择新增租户 + +--- + +## 8. 首页入口改造方案 + +## 8.1 当前问题 + +`homeServiceImpl.py` 当前按: + +- `user_area` +- `em.areas[].area` +- `default` +- `super_admin bypass` + +混合判定。 + +## 8.2 推荐改法 + +引入: + +- `current_user.tenant_code` +- `entry_module_tenants.tenant_code` + +首页判断改为: + +1. 用户可见租户集合 +2. 模块绑定租户集合 +3. 交集判断 + +公共模块单独处理: + +- 通过 `sys_tenants.is_public` +- 或入口模块配置的公共租户绑定 + +## 8.3 不要再用 `default` + +建议: + +- `default` 语义废弃 +- 若是“公共可见”,显式建公共租户 + +--- + +## 9. 前端固定地区名单改造方案 + +## 9.1 当前固定名单热点 + +已确认典型位置: + +- `EntryModuleNewClient.tsx` +- `EntryModulesClient.tsx` +- `cross-checking-access.ts` +- `RAG` 地区配置相关页面 +- 其他地区筛选组件 + +## 9.2 推荐统一方案 + +新增前端租户 API: + +- `GET /api/v3/tenants` + +返回: + +```json +[ + { + "tenant_code": "mz", + "tenant_name": "梅州", + "tenant_type": "REGIONAL", + "is_public": false, + "is_enabled": true, + "sort_order": 10 + } +] +``` + +前端所有租户下拉统一改成: + +- 用这个接口拉 +- 不再本地硬编码 + +## 9.3 别名逻辑收缩 + +`cross-checking-access.ts` 这类 `AREA_ALIASES` 逻辑建议删除,改成: + +- 使用 `tenant_code` +- `tenant_name` 只展示,不参与判断 + +--- + +## 10. 用户模型改造建议 + +## 10.1 当前情况 + +用户当前有: + +- `area` +- `tenant_name` + +但这两个字段并不等价: + +- `area` 更像当前业务租户边界 +- `tenant_name` 更像组织树分组信息 + +## 10.2 推荐方向 + +建议新增: + +- `sso_users.tenant_code` + +并定义: + +- `tenant_code`:业务租户边界 +- `tenant_name`:组织展示名称,可保留 +- `area`:兼容旧字段,逐步弱化 + +## 10.3 为什么不能直接复用 tenant_name + +因为现有 `tenant_name` 明显被当作组织分组字段在用: + +- RBAC 组织树 +- 用户管理分组 +- 交叉评查成员展示 + +它不适合作为稳定权限租户编码。 + +--- + +## 11. RAG、合同模板、统计的联动影响 + +## 11.1 RAG + +当前: + +- `省级` +- `''` +- `is_public` + +混合表达公共范围。 + +建议改造: + +1. 引入公共租户 +2. 统一 `PUBLIC_MIXED` 的租户集合来源 +3. 不再靠字符串字面值 `省级` + +## 11.2 合同模板 + +当前: + +- `省级` 被当作公共模板归属 + +建议: + +- 用 `public tenant` 或 `headquarters tenant` +- 不再把展示名硬编码进判断逻辑 + +## 11.3 使用统计 + +统计筛选当前虽然不是入口配置,但未来如果租户扩展: + +- 地区筛选必须改成租户筛选 +- area 文本不能再假定固定集合 + +--- + +## 12. 交叉评查的潜在影响 + +交叉评查主权限模型是成员关系,不是地区模型。 + +但它仍会受租户化影响: + +1. 入口模块可见性 +2. 任务参与地区展示 +3. 成员树的租户展示 + +所以它不是第一优先改造对象,但必须纳入兼容分析。 + +--- + +## 13. 数据治理问题 + +在进入租户化之前,必须先做一次脏数据巡检。 + +建议巡检: + +1. `sso_users.area` 去重值 +2. `leaudit_documents.region` 去重值 +3. `contract_templates.region` 去重值 +4. `rag datasets/apps area` 去重值 +5. `entry_modules.areas[].area` 去重值 + +目的: + +- 找出 `省局 / 省级 / default / 空串 / 其他历史拼法` + +只有先做这一步,后续租户映射表才能准确建立。 + +--- + +## 14. 推荐新增文档与接口 + +建议后续再补: + +1. `租户主数据模型设计.md` +2. `地区到租户编码映射清洗清单.md` +3. `入口模块租户配置表迁移方案.md` + +建议新增接口: + +1. `GET /v3/tenants` +2. `POST /v3/tenants` +3. `PUT /v3/tenants/{TenantCode}` +4. `GET /v3/tenants/options` + +其中 `options` 可专门给前端下拉用。 + +--- + +## 15. 推荐实施顺序 + +建议按下面顺序做,而不是一次性全改: + +1. 建 `sys_tenants` +2. 做租户脏数据巡检 +3. 入口模块改成租户主数据驱动 +4. 首页入口可见性改成租户集合判断 +5. 前端所有固定地区下拉改成租户接口 +6. 交叉评查/RAG/合同模板里的字符串公共语义收口 +7. 再评估业务主表是否逐步从 `area/region` 迁到 `tenant_code` + +--- + +## 16. 最终建议 + +当前系统不应再把“地区”视为一个永远固定的枚举集合。 + +从代码和业务实际上看,它已经是“租户边界”了,只是还没有主数据模型支撑。 + +如果现在不做这层抽象,后面每新增一个租户,都会反复踩同一类问题: + +1. 前端没地方选 +2. 入口模块分配不了 +3. 首页看不到 +4. 公共范围语义冲突 +5. 老字符串拼法继续扩散 + +因此,建议把“地区租户化”作为权限架构之后的下一优先级平台改造项,且入口模块应作为第一落点。 diff --git a/docs/权限与地区隔离/大版本发布验收总表-2026-05-21.md b/docs/权限与地区隔离/大版本发布验收总表-2026-05-21.md new file mode 100644 index 0000000..0e01d05 --- /dev/null +++ b/docs/权限与地区隔离/大版本发布验收总表-2026-05-21.md @@ -0,0 +1,409 @@ +# 大版本发布验收总表(2026-05-21) + +> 目标:把当前超大改动面压缩成可验收、可证明、可发布的 `RC` 版本。 +> 原则:不再按“继续开发”推进,而按“关卡验收 -> 暴露故障 -> 定点修复 -> 回归通过”推进。 + +--- + +## 1. 发布前总原则 + +本次版本只有在以下条件同时满足时,才允许进入大版本推送: + +1. 数据库结构与 SQL 脚本执行状态已确认 +2. 所有 `P0` 关卡全部通过 +3. 所有 `P1` 关卡全部通过 +4. 关键页面与关键接口无 `500` +5. 无 `UndefinedTable / UndefinedColumn` 级别错误 +6. 核心租户边界验证完成 +7. 发布说明、执行 SQL、上线步骤、观察项已整理完毕 + +--- + +## 2. 关卡定义 + +| 关卡 | 优先级 | 范围 | 当前状态 | +| --- | --- | --- | --- | +| `G0` | `P0` | 数据库结构与迁移脚本对齐 | 待验收 | +| `G1` | `P0` | 登录 / 当前用户 / RBAC / 租户上下文 | 已通过(人工重放 + Pytest) | +| `G2` | `P0` | 租户管理 + 用户租户设置 | 已通过(人工重放 + Pytest) | +| `G3` | `P0` | 首页入口 + 入口模块后台 + 新租户分配 | 已通过(人工重放 + Pytest) | +| `G4` | `P0` | 文档 / 合同模板 / 公文 上传-列表-详情-边界 | 已通过(Pytest,文档主链路) | +| `G5` | `P1` | RAG 应用 / 知识库 / 会话 读写边界 | 部分通过(Pytest,当前真实权限模型) | +| `G6` | `P1` | 评查组 / 规则集 / 交叉评查 主链路 | 部分通过(Pytest,只读矩阵 + 建单边界) | +| `G7` | `P1` | 使用统计 / 审计快照 / 租户聚合 | 未开始 | +| `G8` | `P2` | 前端去角色硬编码与菜单/按钮一致性 | 未开始 | + +--- + +## 3. 通过标准 + +每个关卡只允许有三种判定: + +1. `通过` +2. `失败` +3. `未验证` + +判定标准: + +1. `通过` + - 有真实接口或真实页面操作证据 + - 有明确返回结果 + - 无日志级严重错误 +2. `失败` + - 出现 4xx/5xx 且不符合预期 + - 数据写错、查错、串租户、越权 + - 页面与接口口径不一致 +3. `未验证` + - 没有实际执行 + - 只有历史日志,无本次独立重放 + +--- + +## 4. G0 数据库结构与脚本对齐 + +### 验收目标 + +确认这轮租户化与权限改造依赖的数据库对象,在**新数据库**中完整存在。 + +### 必验对象 + +1. `sys_tenants` +2. `sys_tenant_aliases` +3. `sys_tenant_feature_flags` +4. `leaudit_entry_module_tenants` +5. `sso_users.tenant_code` +6. 高风险阶段新增 `tenant_code` 字段与索引 + +### 通过标准 + +1. 表存在 +2. 关键列存在 +3. 索引存在或已确认可延后 +4. 初始化种子不缺失到影响主链路 + +--- + +## 5. G1 登录 / RBAC / 租户上下文 + +### 核心用例 + +1. `POST /api/auth/login` +2. `GET /api/auth/me` +3. `GET /api/v3/rbac/users` +4. `GET /v3/rbac/roles` +5. `GET /api/rbac/user/routes` +6. `GET /admin/users/organizations/tree` + +### 关注点 + +1. 登录是否正常签发 token +2. 当前用户是否返回正确 `tenant_code` +3. 用户列表是否按租户边界收敛 +4. 路由列表是否正常返回 +5. 组织树是否仍能正常读取 + +### 通过标准 + +1. `000` 可登录 +2. `/api/auth/me` 返回 `tenant_code` +3. `RBAC` 只读接口无 `500` +4. 日志无 `UndefinedTable / UndefinedColumn` + +--- + +## 6. G2 租户管理 + 用户租户设置 + +### 核心用例 + +1. `GET /api/v3/tenants` +2. `GET /api/v3/tenants/options` +3. `GET /api/v3/tenants/{tenantCode}` +4. `POST /api/v3/tenants` +5. `PUT /api/v3/tenants/{tenantCode}` +6. `PATCH /api/v3/tenants/{tenantCode}/status` +7. `PUT /api/v3/rbac/users/{userId}/tenant` + +### 关注点 + +1. 租户能否真实新增 +2. 新增后能否查到详情 +3. 功能开关与能力字段能否正确保存 +4. 用户租户更新后重新登录是否生效 +5. 禁用租户时是否有引用保护 + +### 通过标准 + +1. 能新增一个测试租户 +2. 能更新测试租户 +3. 能启停测试租户 +4. 能把用户切到测试租户并重新读取到正确上下文 + +--- + +## 7. G3 首页入口 + 入口模块后台 + 新租户分配 + +### 核心用例 + +1. `GET /api/home/entry-modules` +2. `GET /api/v3/entry-modules` +3. `GET /api/v3/entry-modules/{id}` +4. `POST /api/v3/entry-modules` +5. `PUT /api/v3/entry-modules/{id}` +6. `GET /api/v3/tenants/options?feature_key=home.entry_module` + +### 关注点 + +1. 首页是否按当前用户租户展示入口 +2. 后台列表/详情是否以 `tenants` 为主输出 +3. 能否把新租户分配到入口模块 +4. 新分配后首页是否能看到入口 + +### 通过标准 + +1. 现有入口读取正常 +2. 新建或更新入口模块时可写入 `tenants` +3. 新租户分配后首页展示正确 +4. 管理端和首页口径一致 + +--- + +## 8. G4 文档 / 合同模板 / 公文 + +### 核心用例 + +1. 文档上传 +2. 文档列表 +3. 文档详情 +4. 附件追加 +5. 合同模板上传 +6. 公文上传与运行 + +### 通过标准 + +1. 写入不落错租户 +2. 同租户可查可读 +3. 跨租户不可见 +4. 详情、附件、状态、运行链路不绕边界 + +--- + +## 9. G5 RAG + +### 核心用例 + +1. 应用列表 / 默认应用 +2. 会话列表 / 消息列表 +3. 会话重命名 / 删除 +4. 消息反馈 / StopMessage +5. 知识库创建 / 更新 / 删除 + +### 通过标准 + +1. 读链路正常 +2. 写链路按租户边界收敛 +3. 不再因角色名误放行 + +--- + +## 10. G6 评查组 / 规则集 / 交叉评查 + +### 核心用例 + +1. 评查组只读 +2. 规则集只读 +3. 规则版本创建 / 发布 / 回滚 +4. 交叉评查建单 +5. 补传文档 +6. 成员与文档混租户拦截 + +### 通过标准 + +1. 只读链路正常 +2. 敏感写操作不越权 +3. 任务维度不串租户 + +--- + +## 11. 当前执行策略 + +本轮先执行并已固化为黑盒验收: + +1. `G1` +2. `G2` +3. `G3` +4. `G4` +5. `G5` +6. `G6` 的首轮主链路矩阵 + +### 当前已完成补充 + +已新增 `pytest` 黑盒验收基座: + +1. `tests/release/test_g1_rbac_context.py` +2. `tests/release/test_g2_g3_tenant_entry_chain.py` +3. `tests/release/test_role_tenant_matrix.py` +4. `tests/release/test_g4_documents.py` +5. `tests/release/test_g5_rag.py` +6. `tests/release/test_g5_rule_cross_review_matrix.py` + +已通过结果: + +1. 发布级黑盒验收总数:`16 passed` +2. `G1-G3`:已通过 +3. 角色/租户矩阵基础:已通过 +4. `G4` 文档跨租户读写边界:已通过 +5. `G5` RAG 可见性与管理边界:按当前真实权限模型通过 +6. `G6` 规则集/评查组只读矩阵与交叉评查建单边界:已通过 + +本轮验收中暴露并已修复: + +1. `tenantServiceImpl._replaceTenantAliases()` 由“软删 + 重插”改为“物理删 + 重插”,解决 `PUT /api/v3/tenants/{tenantCode}` 的重复更新 `500` + +执行规则: + +1. 先验收 +2. 出故障就只修该故障 +3. 修完立刻回归 +4. 不顺手扩散 + +--- + +## 12. 发布阻塞定义 + +以下任一项存在,直接判定为**不可推大版本**: + +1. 关键写链路未验证 +2. 出现 `500` +3. 出现 `UndefinedTable / UndefinedColumn` +4. 租户边界存在串数据或落错数据 +5. 管理端与首页、前端与后端口径不一致 +6. SQL 执行顺序和上线步骤未确认 + +--- + +## 13. 当前备注 + +当前仓库处于大改冻结态,后续所有推进都必须回写本总表和冻结版验收文档: + +1. [冻结版进度盘点与最小冒烟验收-2026-05-21.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md) +2. 本文档 + +--- + +## 14. 本轮实测结果(G1-G3) + +### `G1` 登录 / RBAC / 租户上下文 + +判定:`通过` + +实测通过: + +1. `POST /api/auth/login` +2. `GET /api/auth/me` +3. `GET /api/v3/rbac/users?page=1&page_size=5` +4. `GET /api/rbac/user/routes` +5. `GET /api/admin/users/organizations/tree?include_users=false` + +关键结果: + +1. `000 / admin06111` 可正常登录 +2. `/api/auth/me` 返回 `tenant_code=MZ`,后续切换后返回 `tenant_code=ZZRC1` +3. 组织树真实可用路径是 `/api/admin/users/organizations/tree` + +说明: + +1. 之前打 `/admin/users/organizations/tree` 得到 `404`,属于验收口径错误,不是后端能力缺失 + +### `G2` 租户管理 + 用户租户设置 + +判定:`通过` + +实测通过: + +1. `POST /api/v3/tenants` +2. `GET /api/v3/tenants/ZZRC1` +3. `PUT /api/v3/tenants/ZZRC1` +4. `PATCH /api/v3/tenants/ZZRC1/status` +5. `PUT /api/v3/rbac/users/5/tenant` +6. 重新登录后 `GET /api/auth/me` + +本轮实际创建测试租户: + +1. `tenant_code=ZZRC1` +2. `tenant_name=发布验收租户1-更新` + +本轮修复: + +1. 修复 [tenantServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py) 中租户更新 `feature_flags` 时,软删后重插撞唯一约束导致的 `500` + +### `G3` 首页入口 + 入口模块后台 + 新租户分配 + +判定:`通过` + +实测通过: + +1. `GET /api/home/entry-modules` +2. `GET /api/v3/tenants/options?feature_key=home.entry_module` +3. `GET /api/v3/entry-modules/2` +4. `PUT /api/v3/entry-modules/2` +5. 更新后再次 `GET /api/home/entry-modules` + +关键结果: + +1. `000` 切到 `ZZRC1` 后,首页初始返回空入口 +2. 给模块 `2` 补充 `ZZRC1` 租户分配后,首页立即返回该入口 + +结论: + +1. `租户主数据 -> 用户租户 -> 入口模块租户分配 -> 首页展示` 链路已被真实打通 + +--- + +## 15. 本轮新增实测结果(G4-G6) + +### `G4` 文档上传 / 列表 / 详情 / 附件 / 删除边界 + +判定:`通过` + +已由 `tests/release/test_g4_documents.py` 黑盒验证: + +1. 租户 A 创建文档后,租户 A 可见、租户 B 不可见 +2. 本租户管理员可更新本租户文档 +3. 跨租户更新、追加附件、删除均被拒绝 +4. 普通用户仅能看到自己创建的文档 +5. 附件追加 `mergeMode=new` 当前真实行为为“生成新版本”,不是在原文档上原地追加 + +### `G5` RAG 应用 / 知识库 / 会话边界 + +判定:`部分通过` + +已由 `tests/release/test_g5_rag.py` 黑盒验证的当前真实行为: + +1. 全局管理员可创建并更新不同租户数据集 +2. 租户管理员可读取本租户数据集详情 +3. 租户管理员当前不能访问 `/api/v3/rag/datasets/admin` +4. 租户管理员当前不能创建或修改数据集 +5. 本租户私有应用仅本租户可见,公共数据集挂载应用可跨租户可见 + +说明: + +1. 这表示 `RAG` 当前已经具备租户可见性边界 +2. 但“管理能力”仍保留在当前权限种子对应的全局管理侧,不能宣称已完成最终去角色化或最终权限化 + +### `G6` 评查组 / 规则集 / 交叉评查主链路 + +判定:`部分通过` + +已由 `tests/release/test_g5_rule_cross_review_matrix.py` 黑盒验证: + +1. 规则集元数据列表当前为全局可读 +2. 规则绑定与评查组树已按入口模块租户映射做过滤 +3. 交叉评查任务建单支持同租户正常创建 +4. 建单时混入跨租户文档会返回 `403` +5. 建单时混入跨租户成员会返回 `403` + +未纳入本轮通过范围: + +1. 规则版本创建 +2. 规则发布/回滚 +3. 交叉评查后续提案、投票、归档深链路 diff --git a/docs/权限与地区隔离/新平台主链路租户改造实施任务单.md b/docs/权限与地区隔离/新平台主链路租户改造实施任务单.md new file mode 100644 index 0000000..23092e7 --- /dev/null +++ b/docs/权限与地区隔离/新平台主链路租户改造实施任务单.md @@ -0,0 +1,302 @@ +# 新平台主链路租户改造实施任务单 + +> 目标:把新平台主链路从“半租户化”推进到“关键链路 tenant-first” +> 当前优先级:先修会落错数据、查错范围、跨租户误改的链路 + +--- + +## T1 文档上传 tenant-first 收口 + +### 数据库变更 + +- 无强制新表 +- 如当前库中还没有文档业务快照字段,需要补充: + - `leaudit_documents.root_group_id` + - `leaudit_documents.entry_module_id` + +### 后端改造 + +- `Upload` 改为显式以 `tenant_code` 为主,不再依赖 `region` 主导归属 +- 上传时校验: + - `group_id` 必须属于 `document_type_id` + - `group_id` 必须属于当前入口模块业务树 + - `group_id` 必须属于当前租户允许范围 +- 上传落库时持久化: + - `tenant_code` + - `group_id` + - `root_group_id` + - `entry_module_id` + +### 前端改造 + +- 上传页显式提交 `tenant_code` +- 上传页不再仅传 `region` +- 上传页当前租户显式展示 + +### 验收点 + +- 同一文档类型、不同租户上传,不会再混入同一版本链 +- 上传请求缺少 `tenant_code` 时,行为明确可控 +- 非当前租户二级组 ID 无法上传成功 + +### 风险 + +- 历史旧文档若仍无 `tenant_code`,需要兼容迁移窗口 + +--- + +## T2 文档列表/详情停止 `tenant_code + region` 混查 + +### 数据库变更 + +- 如缺索引,确认: + - `idx_leaudit_documents_tenant_code` + - `idx_leaudit_documents_group_id` + +### 后端改造 + +- 收紧 `_document_tenant_filter_sql` +- 收紧 `_buildDocumentScopeFilters` +- 收紧 `_find_latest_version_candidate` +- 停止危险的“`tenant_code` 不命中就退回 `region`” + +### 前端改造 + +- 文档列表请求显式带 `tenant_code` +- 列表页显示当前租户范围 + +### 验收点 + +- 同地区旧数据不会再被当成当前租户数据读出来 +- 文档列表和详情在同一租户下口径一致 + +### 风险 + +- 某些历史脏数据可能在收紧后“暂时查不到”,需要预先判定为预期行为 + +--- + +## T3 文档业务边界快照固化 + +### 数据库变更 + +- 补字段: + - `leaudit_documents.root_group_id` + - `leaudit_documents.entry_module_id` + +### 后端改造 + +- 上传落库时写入业务快照 +- 列表/详情优先读持久化快照,不再运行时推断 +- 减少 `COALESCE(d.group_id, inferred_group_id)` 这类推导 + +### 前端改造 + +- 无必须改动 +- 后续可显示上传时绑定的业务子类型 + +### 验收点 + +- 分组树配置变化后,历史文档仍保持原业务归属 + +### 风险 + +- 需要一次性回填历史文档快照 + +--- + +## T4 RuleController 鉴权与权限补齐 + +### 数据库变更 + +- 无 + +### 后端改造 + +- `RuleController` 全量加 `verify_access_token` +- 补功能权限校验 +- 统一透传当前租户上下文 + +### 前端改造 + +- 无必须改动 + +### 验收点 + +- 未登录不能直接访问规则接口 +- 无权限用户不能读写规则版本和绑定 + +### 风险 + +- 可能暴露前端现有页面实际依赖“裸接口”的问题 + +--- + +## T5 评查组读链路 tenant-first 收口 + +### 数据库变更 + +- 短期可先不改表结构 +- 中期需要评估给 `leaudit_evaluation_point_groups` 增加 `tenant_code` + +### 后端改造 + +- `ListGroups` +- `ListAllGroups` +- `GetGroup` +- `GetChildren` +- `ListGroupsByDocumentTypes` + +以上全部接入租户过滤 + +### 前端改造 + +- `rule-groups` 页面查询显式带 `tenant_code` +- 页面头部显示当前租户 + +### 验收点 + +- 不同租户打开分组页,看到的根组/子组不再混杂 + +### 风险 + +- 如果当前业务暂时仍共享分组树,需要先定义共享策略 + +--- + +## T6 评查组写链路 tenant-first 收口 + +### 数据库变更 + +- 中期大概率需要: + - `leaudit_evaluation_point_groups.tenant_code` + - `leaudit_rule_group_bindings.tenant_code` + +### 后端改造 + +- `CreateGroup` +- `UpdateGroup` +- `DeleteGroup` +- `BatchDelete` +- `BatchUpdateStatus` +- `RebindGroup` +- `CreateBinding` +- `UpdateBinding` +- `DeleteBinding` + +全部改成租户内操作 + +### 前端改造 + +- 编辑、删除、迁移前提示当前租户范围 + +### 验收点 + +- 无法跨租户误删、误绑、误迁移分组 + +### 风险 + +- `RebindGroup` 是最高危操作,必须单独加保护 + +--- + +## T7 规则版本与规则集归属模型重构 + +### 数据库变更 + +- 评估是否新增: + - `leaudit_rule_sets.tenant_code` + - `leaudit_rule_versions.tenant_code` + +### 后端改造 + +- `CreateVersion` +- `PublishRuleVersion` +- `RollbackRuleVersion` +- `ListSets` +- `GetVersions` +- `GetContent` + +全部明确租户归属模型 + +### 前端改造 + +- 规则列表/版本查看显示当前租户 + +### 验收点 + +- 一个租户发布规则版本,不会影响另一个租户同名规则类型 + +### 风险 + +- 这是最深层模型调整,需要最后做,不适合第一批直接大改 + +--- + +## T8 前端作用域上下文统一 + +### 数据库变更 + +- 无 + +### 后端改造 + +- 允许并鼓励所有主链路查询显式接受 `tenant_code` + +### 前端改造 + +- 首页点击入口时带上 `tenant_code` +- sessionStorage 记录 `selectedTenantCode` +- 上传页、列表页、分组页、文档类型页全部统一读取/透传 + +### 验收点 + +- 跨页面刷新、跨模块进入后,租户上下文不丢失 + +### 风险 + +- 需要统一多页面的 scope 来源,避免出现多个来源互相覆盖 + +--- + +## T9 交叉评查跨租户策略明确化 + +### 数据库变更 + +- 可选补快照字段到 `leaudit_review_point_audits` + +### 后端改造 + +- 明确 `_hasCrossReviewDocumentAccess` 是否允许跨租户 +- 如果允许: + - 做显式特权链路 + - 做审计快照 +- 如果不允许: + - 收紧 bypass scope + +### 前端改造 + +- 交叉评查页展示任务涉及租户 + +### 验收点 + +- 跨租户访问行为是“明确允许”而不是“意外绕过” + +### 风险 + +- 这是业务策略题,改之前必须先拍板 + +--- + +## 当前建议执行顺序 + +1. `T1 文档上传 tenant-first 收口` +2. `T2 文档列表/详情停止 tenant_code + region 混查` +3. `T4 RuleController 鉴权与权限补齐` +4. `T5 评查组读链路 tenant-first 收口` +5. `T8 前端作用域上下文统一` +6. `T3 文档业务边界快照固化` +7. `T6 评查组写链路 tenant-first 收口` +8. `T9 交叉评查跨租户策略明确化` +9. `T7 规则版本与规则集归属模型重构` + diff --git a/docs/权限与地区隔离/新平台主链路租户边界扫描报告.md b/docs/权限与地区隔离/新平台主链路租户边界扫描报告.md new file mode 100644 index 0000000..d0e93f4 --- /dev/null +++ b/docs/权限与地区隔离/新平台主链路租户边界扫描报告.md @@ -0,0 +1,340 @@ +# 新平台主链路租户边界扫描报告 + +> 扫描日期:2026-05-21 +> 范围:新平台主链路 +> 不含:旧 `evaluation_points` 兼容链路、旧 `rules/list`、`rules/new` + +--- + +## 1. 总结论 + +当前新平台主链路已经不是“完全没租户化”,而是进入了一个更危险的阶段: + +- 文档主表已经开始写 `tenant_code` +- 首页入口模块已经开始按租户过滤 +- 但评查组/规则集/前端上传作用域仍没有完整切到 `tenant_code` + +这意味着系统现在处于一种“半租户化”状态: + +1. 一部分链路按租户工作 +2. 一部分链路仍按 `region/area/document_type/entry_module_id` +3. 还有一部分链路默认假设主数据是全局共享 + +真正的风险不是“功能不能用”,而是: + +- 会把数据落错边界 +- 会把旧脏数据和新租户数据串到同一条版本链 +- 会在多租户复用同入口模块/同文档类型时,把用户带到错误的业务树或规则树 + +--- + +## 2. 三个最危险的核心问题 + +## 2.1 评查组/规则集主数据仍然是全局共享模型 + +当前: + +- `leaudit_evaluation_point_groups` 没有 `tenant_code` +- `leaudit_rule_group_bindings` 没有 `tenant_code` +- `leaudit_rule_sets` / `rule_type` 仍按全局唯一思路工作 + +结果: + +- 不同租户可能共享同一套分组树 +- 不同租户可能共享同一个 `rule_type -> rule_set` +- 一个租户新建规则版本,可能覆盖另一个租户当前使用的规则资产 + +这不是“显示错了”,而是规则资产归属模型本身还没分租户。 + +## 2.2 文档链路仍存在 `tenant_code + region` 混查 + +当前: + +- 文档上传虽然开始写 `tenant_code` +- 但历史版本命中、列表过滤、详情过滤仍允许 `tenant_code` 不命中时回退 `region` + +结果: + +- 同地区旧脏数据可能被续成新租户文档的新版本 +- 列表和详情在历史数据上可能出现租户边界漂移 +- 只要旧数据 `tenant_code` 为空,系统就会继续依赖中文展示名做范围匹配 + +这是当前最容易造成“查对了表面、查错了真实归属”的问题。 + +## 2.3 前端上传和页面作用域仍未显式传递 `tenant_code` + +当前: + +- 上传页提交时主要传 `region` +- 首页跳转主要传 `entryModuleId + documentTypeIds` +- 子类型分组查询只按 `document_type_id` +- 文档列表读范围也主要依赖 URL/sessionStorage 中的模块/文档类型 + +结果: + +- 当前租户上下文在前端不是一等主语义 +- 很多页面只能“假设后端已经帮你隔离好了” +- 一旦后端返回混合数据,前端没有第二道确认防线 + +--- + +## 3. 高风险问题清单 + +## 3.1 评查组/规则集后端高风险 + +### 3.1.1 `RuleController` 基本是裸接口 + +文件: + +- [ruleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleController.py) + +问题: + +- 缺少统一 `verify_access_token` +- 缺少功能权限校验 +- 也没有数据范围/租户上下文透传 + +影响: + +- 规则集列表、版本内容、发布、回滚、绑定增删改都可能绕过租户边界 + +### 3.1.2 `CreateVersion` 仍按全局 `rule_type` 工作 + +文件: + +- [ruleServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py) + +问题: + +- 规则版本创建仍按全局 `rule_type` 命中 `rule_set` +- 没有 `tenant_code` + +影响: + +- 不同租户只要规则类型编码一致,就可能共用同一个规则集和版本链 + +### 3.1.3 评查组树所有读写都没有租户谓词 + +文件: + +- [evaluationPointGroupServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py) + +问题: + +- `ListGroups` +- `ListAllGroups` +- `GetGroup` +- `GetChildren` +- `ListGroupsByDocumentTypes` +- `CreateGroup` +- `UpdateGroup` +- `DeleteGroup` +- `BatchDelete` +- `RebindGroup` +- `CreateBinding` +- `UpdateBinding` +- `DeleteBinding` + +以上全部没有显式 `tenant_code` 约束。 + +影响: + +- 管理页看到的可能是跨租户混合分组 +- 修改和删除可能直接作用到其他租户的业务树 + +### 3.1.4 `RebindGroup` 是最高危错挂点 + +问题: + +- 直接按 `pid` 批量迁移子分组 +- 只校验层级,不校验租户一致性 + +影响: + +- 一次操作可能把多个二级子类型整体迁入错误根分组 + +### 3.1.5 分组/绑定表结构本身没有 `tenant_code` + +文件: + +- [ruleGroupSupport.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py) + +问题: + +- `ensure_rule_group_schema` 建表时就没有租户字段 + +影响: + +- 即使服务层开始传租户,也没有稳定持久化载体 + +## 3.2 文档后端主链路高风险 + +### 3.2.1 历史版本命中仍会 `tenant_code` 回退 `region` + +文件: + +- [documentServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py) + +问题: + +- `_find_latest_version_candidate` +- `_document_tenant_filter_sql` +- `_buildDocumentScopeFilters` + +都还允许 `tenant_code` 不足时回退 `region` + +影响: + +- 新租户文档可能接到旧地区文档的历史版本链 + +### 3.2.2 `group_id / entry_module_id` 仍在查询时反推 + +问题: + +- 列表和详情展示并不完全依赖文档上传时的稳定快照 +- 而是会根据当前分组树状态推断 + +影响: + +- 配置一变,历史文档显示的业务树就变了 +- 容易造成“文档看起来属于当前子类型,但上传时并不属于” + +### 3.2.3 上传仅校验 `group_id` 属于 `document_type_id` + +问题: + +- 没有进一步校验: + - 是否属于当前入口模块 + - 是否属于当前租户允许范围 + - 是否属于当前业务树 + +影响: + +- 只要知道一个合法分组 ID,就可能落错业务边界 + +### 3.2.4 交叉评查链路会绕过原租户范围 + +问题: + +- `GetReviewPoints` +- `AuditReviewPoint` +- `ConfirmReviewResults` + +在交叉评查任务成员命中时会 bypass scope + +影响: + +- 如果业务上不允许跨租户交叉评查,这就是直接越界读取/更新 + +## 3.3 前端主链路高风险 + +### 3.3.1 上传页没有显式提交 `tenant_code` + +文件: + +- [FilesUploadClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/files/upload/FilesUploadClient.tsx) +- [files-upload.ts](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/lib/api/legacy/files/files-upload.ts) + +问题: + +- 当前前端主要把 `tenant_name/area` 当 `region` 传 +- 没有直接传 `tenant_code` + +影响: + +- 后端仍要猜 +- 租户显示名不唯一时最容易落错数据 + +### 3.3.2 子类型分组下拉没有按 `tenant_code` 拉取 + +问题: + +- `/api/v3/evaluation-point-groups/by-document-types` +- 只按 `document_type_ids` +- 最多本地再按 `entryModuleId` 过滤 + +影响: + +- 不同租户复用同文档类型时,前端会把别的租户二级组混到下拉框 + +### 3.3.3 首页跳转/文档列表作用域没有显式携带租户 + +问题: + +- 首页只传 `selectedModuleId/documentTypeIds` +- 文档列表和上传页继续从 sessionStorage/URL 恢复范围 + +影响: + +- 模块是对的,不代表租户也是对的 +- 最容易在跨页、刷新、缓存残留时串范围 + +--- + +## 4. 中风险问题清单 + +## 4.1 后端中风险 + +1. `_ensure_entry_module_valid` 只校验入口模块存在,不校验租户可用性 +2. `_ensure_document_type_valid` 仍是全局文种唯一/存在校验 +3. `_ensure_code_unique` 仍是全局唯一 +4. `bootstrap_rule_groups`、`ensure_group_for_doc_type` 仍是假设全局分组树 +5. 文档当前用户上下文仍依赖 `area` 兼容与硬编码管理角色 +6. `review_point_audits` 没有租户/入口/分组快照 + +## 4.2 前端中风险 + +1. `rule-groups` 页面没有当前租户确认层 +2. `document-types` 页面只围绕入口模块,不围绕租户 +3. 首页系统概览仍用硬编码 `documentTypeIds` 推断业务 + +--- + +## 5. 低风险问题清单 + +1. 首页仍把 `user_area` 写入本地缓存 +2. `region` 仍是多个接口 VO 的正式字段 +3. 上传接口同时暴露 `typeId/typeCode/groupId/region/tenant_code` 多套口径 +4. 多个管理页缺少“当前租户/当前作用域”显式展示 + +--- + +## 6. 正确的改造顺序 + +## 第一阶段:堵住最容易落错/查错数据的链路 + +1. 上传前端显式提交 `tenant_code` +2. 后端上传与列表停止 `tenant_code -> region` 混查 +3. 上传时强校验 `group_id + document_type_id + entry_module_id + tenant_code` +4. 文档持久化真实 `group_id/root_group_id/entry_module_id/tenant_code` + +## 第二阶段:补评查组/规则集的入口防线与读写边界 + +1. `RuleController` 全面加鉴权与权限 +2. 评查组 controller/service 全链路透传租户 +3. 分组树和绑定查询全部加租户过滤 +4. `RebindGroup`、`CreateBinding`、`CreateRuleDraft` 改成租户内操作 + +## 第三阶段:补主数据模型 + +1. 给 `leaudit_evaluation_point_groups` 增加租户归属 +2. 给 `leaudit_rule_group_bindings` 增加租户归属 +3. 重新定义 `rule_set` 是否按租户隔离 +4. 重做 `bootstrap_rule_groups / ensure_group_for_doc_type` + +## 第四阶段:补界面确认层 + +1. 首页跳转带上租户上下文 +2. 上传页/文档列表/评查组页显示当前租户 +3. 子类型、规则集、文档列表全部按租户查询 + +--- + +## 7. 建议接下来直接执行的第一批任务 + +1. 后端:收紧文档主链路,去掉危险的 `tenant_code + region` 混查 +2. 前端:上传显式传 `tenant_code`,文档列表显式带 `tenant_code` +3. 后端:给 `RuleController` 全量补 token 和权限 +4. 后端:评查组接口开始透传 `tenant_code` +5. 文档:补“新平台主链路租户改造实施任务单” + diff --git a/docs/权限与地区隔离/新平台评查点与评查组真实模型梳理.md b/docs/权限与地区隔离/新平台评查点与评查组真实模型梳理.md new file mode 100644 index 0000000..8609978 --- /dev/null +++ b/docs/权限与地区隔离/新平台评查点与评查组真实模型梳理.md @@ -0,0 +1,247 @@ +# 新平台评查点与评查组真实模型梳理 + +## 1. 先给结论 + +前面把“新平台评查点”理解成老库 `evaluation_points` 主表,这是错误的。 + +当前代码里实际上并存两套链路: + +1. 新平台主链路 + `入口模块 -> 一级业务大类分组 -> 二级业务子类型分组 -> 规则集 -> 规则版本(YAML) -> 文档评查结果` + +2. 旧兼容链路 + `/api/v3/evaluation-points` -> `docauditai.evaluation_points` + +也就是说: + +- 新平台“评查组/评查点分组”是真正的主配置骨架。 +- 新平台运行时真正承载评查规则内容的,不是 `evaluation_points` 行数据,而是 `leaudit_rule_sets + leaudit_rule_versions` 中的 YAML 规则。 +- 旧的“评查点管理”页面和 `/v3/evaluation-points` 目前仍然挂在老库兼容链路上,还没有完全切到新平台主模型。 + +## 2. 新平台真实对象关系 + +### 2.1 一级分组不是老地区分组 + +新平台一级分组已经不是“梅州/揭阳/省级”这种地区概念,而是业务大类根: + +- 合同 +- 行政卷宗 +- 后续新增业务大类 + +对应证据: + +- `scripts/创建sql/migrate_rule_groups_to_business_roots.sql` +- `fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py` + +其中迁移脚本已经把目标写得很清楚: + +- 一级分组 = 业务大类 +- 二级分组 = 具体业务类型 +- 规则集 = 挂在二级分组下 +- 入口模块 = 绑定一级分组 + +### 2.2 二级分组才是运行时的“业务子类型” + +二级分组必须绑定具体 `document_type_id`,用于表达: + +- 建设工程合同 +- 买卖合同 +- 行政许可-新办 +- 行政许可-延续 + +上传文档时,后端校验 `group_id` 是否属于当前 `document_type_id`,见: + +- `documentServiceImpl.py` 中 `_resolveDocumentGroupId` + +说明当前系统真实运行时,文档已经开始依赖: + +- 文档类型 +- 二级分组 +- 一级根分组 + +而不是依赖老 `evaluation_points` 主表。 + +### 2.3 真正承载“评查点内容”的是规则集与规则版本 + +新平台分组页不是在维护老式点表,而是在维护: + +- `leaudit_rule_group_bindings` +- `leaudit_rule_sets` +- `leaudit_rule_versions` + +并且“新建规则集/YAML”动作会: + +1. 根据二级分组推导 `rule_type` +2. 调用 `RuleService.CreateVersion(...)` +3. 生成规则版本 YAML +4. 反查对应 `rule_set_id` +5. 自动把规则集绑定到当前二级分组 + +对应代码: + +- `evaluationPointGroupServiceImpl.py` 中 `CreateRuleDraft` +- `ruleServiceImpl.py` 中 `CreateVersion` / `ListSets` / `GetContent` + +这说明新平台里的“评查点”更接近: + +- 规则 YAML 中的一条条规则项 +- 或某个规则集里的规则集合 + +而不是老系统那种独立的 `evaluation_points` 业务主表记录。 + +## 3. 前端页面语义已经分叉 + +### 3.1 `rule-groups` 页面是新平台主入口 + +前端 `RuleGroupsClient.tsx` 的语义已经非常明确: + +- 展示一级业务大类 +- 展示二级子类型 +- 给二级子类型绑定规则集 +- 直接创建规则 YAML 草稿 +- 查看规则集可用版本和可用规则数 + +这页对应的是新平台主模型,不是老分组页换皮。 + +### 3.2 `rules/list`、`rules/new` 仍然是旧评查点页面 + +前端这两页仍在调用: + +- `/api/v3/evaluation-points` + +后端服务 `EvaluationPointServiceImpl` 明确连接: + +- `LEGACY_RULE_DB_NAME` +- 默认库 `docauditai` + +说明这两页还是旧兼容页面,并没有真正迁到新平台主链路。 + +## 4. 文档评查运行链路怎么命中分组 + +当前文档链路已经和新分组模型发生真实耦合: + +1. 文档上传时可带 `group_id` +2. 后端校验该 `group_id` 必须是有效二级分组 +3. 系统再反解一级根分组,用于归档和版本链路 +4. 文档列表、详情、统计也会回查 `leaudit_evaluation_point_groups` + +对应代码集中在: + +- `documentServiceImpl.py` + +这意味着: + +- 分组模型已经进入实际业务运行面 +- 它不是“还没启用的设计稿” + +## 5. 为什么我前面会判断错 + +因为仓库里仍保留了“评查点管理”老接口与老页面: + +- `EvaluationPointController` +- `EvaluationPointServiceImpl` +- `legal-platform-frontend/app/(audit)/rules/*` + +它们让表面上看起来仍像“评查点主表驱动”。 + +但继续往下看新分组页、规则集页、文档上传与文档评查链路,就会发现当前新平台实际已经是: + +- 分组承接业务结构 +- 规则集承接规则内容 +- 文档按分组与规则集运行 + +所以真正的问题不是“给老 `evaluation_points` 补 tenant_code 就完了”,而是: + +- 哪些页面仍停留在旧兼容链路 +- 哪些运行链路已经切到新分组模型 +- 租户边界应该落在新分组/文档/规则集哪一层 + +## 6. 当前最重要的纠偏结论 + +### 6.1 不应再把旧 `evaluation_points` 当成新平台主模型 + +旧链路只能视为: + +- 兼容管理页 +- 过渡接口 +- 待迁移遗留 + +不能再把它作为后续“租户化/权限化/评查模块改造”的主战场。 + +### 6.2 新平台真正要分析的核心表 + +后续围绕评查点/评查组,应优先分析这些对象: + +- `leaudit_evaluation_point_groups` +- `leaudit_rule_group_bindings` +- `leaudit_rule_sets` +- `leaudit_rule_versions` +- `leaudit_documents.group_id` +- `leaudit_review_point_audits` + +### 6.3 “评查点”一词在当前系统里有双重含义 + +当前仓库里“评查点”至少有两层语义: + +1. 旧语义 + 老库 `evaluation_points` 里的单条配置记录 + +2. 新语义 + 规则集/YAML 里的具体规则项,以及文档评查结果里的单个 review point + +后续文档和代码改造必须把这两个概念拆开命名,否则一定继续误判。 + +建议统一命名: + +- 旧链路:`legacy_evaluation_point` +- 新链路配置骨架:`rule_group / subtype_group` +- 新链路规则内容:`rule_set / rule_version / yaml_rule` +- 运行结果:`review_point_result` + +## 7. 对后续改造的直接影响 + +### 7.1 评查模块后续工作应拆成两条线 + +第一条:新平台主链路 + +- 分组 +- 文档类型 +- 入口模块 +- 文档上传 +- 规则集绑定 +- 评查运行结果 + +第二条:旧兼容链路 + +- `/v3/evaluation-points` +- 旧评查点列表/新增/编辑页面 +- 老库 `docauditai.evaluation_points` + +### 7.2 现在优先级更高的不是“继续补旧点评租户字段” + +而是先回答这几个问题: + +1. 新平台二级分组是否需要直接租户化 +2. 规则集绑定是否需要租户边界 +3. 文档上传时的 `group_id` 选择是否需要按租户过滤 +4. 文档评查结果是否需要按租户隔离回查 +5. 旧 `rules/list` 与 `rules/new` 是否继续保留,还是迁入新规则集页 + +## 8. 下一步建议 + +建议后续按下面顺序继续: + +1. 先梳理“新平台主链路的租户边界设计” + 重点看:入口模块、文档类型、一级分组、二级分组、文档上传、文档列表、规则集绑定 + +2. 再单独梳理“旧评查点兼容链路去留方案” + 明确: + - 继续兼容多久 + - 是否只读 + - 是否要做迁移映射 + +3. 最后才决定是否还要继续改 `/v3/evaluation-points` + +否则会继续在“旧兼容页”和“新运行主链路”之间来回误伤。 + diff --git a/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md b/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md new file mode 100644 index 0000000..446b6f7 --- /dev/null +++ b/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md @@ -0,0 +1,316 @@ +# 权限、租户能力与数据范围职责边界说明 + +> 适用范围:`leaudit-platform` 当前 `RBAC + 租户主数据 + 多租户业务挂载` 体系 +> 更新日期:2026-05-21 +> 文档定位:专门解决“角色权限配置”和“租户管理里的功能开关/承载能力”看起来重复的问题,明确三者各自负责什么、不负责什么。 + +--- + +## 1. 先看结论 + +当前系统里确实存在“语义重叠感”,但不应该把这三类配置看成同一层: + +1. `角色权限` + - 控制“人能不能操作” +2. `租户功能开关` + - 控制“某租户是否开放某类业务能力” +3. `租户业务承载能力` + - 控制“某租户能不能作为某类业务的数据归属方/挂载方” +4. `数据范围` + - 控制“当前人最终能看到哪些租户、哪些数据” + +如果只看页面字段,不看后端职责边界,就会误以为“租户页和角色权限页都在管入口模块、文档上传、知识库”。 +这正是当前用户感知混乱的根因。 + +--- + +## 2. 三层控制分别管什么 + +## 2.1 角色权限:管人 + +角色权限回答的问题是: + +- 这个用户能不能进入某页面 +- 这个用户能不能新建、编辑、删除、发布、导出 +- 这个用户能不能调用某个接口 + +典型例子: + +- `entry_module:create` +- `entry_module:update` +- `documents:upload:create` +- `rag:dataset:manage` +- `rbac:tenants:update` + +结论: + +- 角色权限只决定“用户有没有操作资格” +- 不决定“数据应该挂在哪个租户下” +- 也不决定“某个租户是否承载某类业务” + +--- + +## 2.2 租户功能开关:管租户是否开放业务能力 + +租户功能开关回答的问题是: + +- 这个租户是否启用了入口模块能力 +- 这个租户是否启用了文档上传能力 +- 这个租户是否启用了知识库能力 + +当前主字段: + +- `sys_tenant_feature_flags.feature_key` +- 前端对应 `feature_keys` + +当前真实语义应理解为: + +- “该租户是否开放该业务能力” +- 更偏向租户产品能力配置,不是用户授权配置 + +结论: + +- 功能开关不授予用户权限 +- 它只决定“租户是否开放某业务” + +--- + +## 2.3 租户业务承载能力:管数据能不能落到该租户 + +租户承载能力回答的问题是: + +- 新建入口模块时,这个租户能不能被选为适用租户 +- 新建文档、模板、知识库时,这个租户能不能作为归属租户 + +当前主字段: + +- `can_host_entry_module` +- `can_host_documents` +- `can_host_rag` +- `can_host_templates` + +这类字段和 `feature_keys` 很像,但不是一回事: + +- `feature_keys` 更偏“租户是否开放该业务” +- `can_host_*` 更偏“租户能否被选为该业务的数据挂载对象” + +结论: + +- 承载能力不等于操作权限 +- 也不完全等于功能开关 +- 它主要服务于“业务归属”和“数据落点”控制 + +--- + +## 2.4 数据范围:管最终可见边界 + +数据范围回答的问题是: + +- 这个用户最终能看到哪些租户的数据 +- 这个用户能看全部、部门、自身、公共、关系型资源中的哪些部分 + +它最终会把下面几件事合并起来: + +1. 用户身份 +2. 角色权限 +3. 用户归属租户 +4. 目标资源归属租户 +5. 模块策略 + +结论: + +- 数据范围是最终访问边界 +- 它不是租户主数据页里的布尔开关 +- 也不是角色权限页里的单个 permission 就能表达清楚的 + +## 2.5 三层控制的防护性区别 + +这三层不是“配置位置不同的同一件事”,而是三道不同的防护闸: + +1. `租户功能开关` + - 属于业务准入防护 + - 防的是“该租户根本不该开放这类业务,却仍被看见或被使用” +2. `角色权限` + - 属于操作授权防护 + - 防的是“租户虽然开放了该业务,但不是任何人都能新增、编辑、删除、发布” +3. `数据范围` + - 属于数据边界防护 + - 防的是“有权限的人在操作时串看、串改到不属于自己范围的数据” + +可以直接这样理解: + +- `租户功能开关 = 模块级总闸` +- `角色权限 = 人的操作闸` +- `数据范围 = 数据行级边界闸` + +任意一层缺失,风险都不同: + +1. 只有功能开关,没有角色权限 + - 结果:租户开了功能后,可能任何角色都能乱用 +2. 只有角色权限,没有功能开关 + - 结果:本不该承载该业务的租户,仍可能被误开放入口或误创建数据 +3. 只有前两层,没有数据范围 + - 结果:拥有权限的用户仍可能看到其他租户数据,形成越权或串租户泄漏 + +所以最终判断某个操作是否允许,不能只看一层,而应按下面顺序串起来判断: + +1. 目标租户是否开放该业务 +2. 当前用户是否具备该业务动作权限 +3. 当前用户的数据范围是否允许访问该目标租户或目标资源 + +--- + +## 3. 为什么当前会让人觉得重复 + +当前用户会觉得重复,原因不是理解错了,而是系统现状确实还没完全收口: + +1. 前端租户页把: + - `启用租户` + - `公共租户` + - `功能开关` + - `承载能力` + 混在同一个表单区域里 +2. 业务名称上和角色权限页高度重合 +3. 部分下游模块还没有把 `can_host_*` 做成强执行链路 +4. `feature_keys` 已经有使用点,但并不是所有模块都用同一模式接入 + +因此现在最准确的判断不是“完全重复”,而是: + +- 设计上应该分层 +- 落地上还处于过渡态 +- 页面表达还不够清楚 + +补一句更贴近当前现状的话: + +- 当前系统已经有三层模型雏形 +- 但还没有在所有模块做到“租户能力先拦、权限再拦、数据范围最后收口”的稳定统一执行顺序 + +--- + +## 4. 当前代码里的真实分工 + +## 4.1 已经落到真实用途的部分 + +当前已经能确认的真实用途: + +1. `feature_keys` + - 已被入口模块租户选择器消费 + - `home.entry_module` 已有真实用途 +2. `leaudit_entry_module_tenants` + - 已作为入口模块适用租户关系表使用 +3. 用户当前上下文 + - 已开始统一输出 `tenant_code / tenant_name` +4. 首页入口模块 + - 已按用户租户 + 模块租户关系过滤 + +## 4.2 仍未完全闭环的部分 + +以下部分仍需继续补强: + +1. `can_host_documents` +2. `can_host_rag` +3. `can_host_templates` +4. `feature_keys` 的统一消费规范 + +结论: + +- 现在租户页里的部分字段已经不是摆设 +- 但也还没有做到所有字段都形成“写入即生效”的全链路闭环 + +--- + +## 5. 最终建议的收口规则 + +## 5.1 角色权限页只做“人”的授权 + +角色权限页只保留: + +- 页面访问权限 +- 菜单权限 +- 按钮权限 +- 接口动作权限 +- 数据范围策略 + +不要在角色权限页里表达: + +- 某租户是否开放业务 +- 某租户是否能承载业务数据 + +## 5.2 租户管理页只做“租户侧能力配置” + +租户管理页应拆成 3 组: + +1. 基础状态 + - `启用租户` + - `公共租户` +2. 功能开关 + - 某租户是否开放某业务能力 +3. 业务承载能力 + - 某租户能否作为某业务的数据归属方 + +并且页面必须明确提示: + +- 这里不授予用户操作权限 +- 用户是否能操作,仍由角色权限决定 + +## 5.3 数据范围执行器单独收口 + +数据范围不要继续分散在各业务模块里各写一套: + +- 应把用户权限 +- 用户租户 +- 资源租户 +- 公共数据策略 +- 关系型特例 + +统一并入执行器。 + +--- + +## 6. 推荐页面文案 + +### 功能开关 + +说明: + +- 决定该租户是否开放某类业务能力 +- 不直接授予用户页面或按钮操作权限 + +### 业务承载能力 + +说明: + +- 决定该租户能否作为该类业务的数据归属租户 +- 会影响新建/编辑时该租户是否可被选中 + +### 角色权限 + +说明: + +- 控制当前用户能不能访问页面、调用接口、执行动作 +- 与租户侧能力配置是两层控制 + +--- + +## 7. 后续工程动作建议 + +优先级建议: + +1. 前端租户页分组与文案改清楚 +2. 为 `can_host_documents / can_host_rag / can_host_templates` 补下游强校验 +3. 梳理 `feature_keys` 的统一消费规范 +4. 统一把“租户是否可选”沉到服务层,不让前端自己猜 +5. 最后再把数据范围执行器接到更多业务域 + +--- + +## 8. 一句话原则 + +后续所有实现都应遵守下面这个原则: + +- `角色权限` 决定“谁能操作” +- `租户功能开关` 决定“租户开不开这类业务” +- `租户承载能力` 决定“数据能不能挂到这个租户” +- `数据范围` 决定“最终能看到哪些数据” + +只要这四层不再混写,后面的权限和多租户改造就不会继续失控。 diff --git a/docs/权限与地区隔离/权限与地区隔离文档导航.md b/docs/权限与地区隔离/权限与地区隔离文档导航.md index 9812f79..a08810c 100644 --- a/docs/权限与地区隔离/权限与地区隔离文档导航.md +++ b/docs/权限与地区隔离/权限与地区隔离文档导航.md @@ -3,6 +3,11 @@ > 最后整理:2026-05-04 > 说明:后端权限文档现在按“现行设计 / 接口与权限点 / 老系统背景”三层来读,避免再被历史 TaskList 干扰。 +> 补充说明:本文档已经是旧导航口径。当前权限与租户改造的总入口请优先查看 +> [权限文档总导航与阅读顺序.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限文档总导航与阅读顺序.md) +> 和 +> [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md)。 + ## 建议阅读顺序 1. `docs/HANDOFF.md` diff --git a/docs/权限与地区隔离/权限与租户改造当前进度总览.md b/docs/权限与地区隔离/权限与租户改造当前进度总览.md new file mode 100644 index 0000000..abf0a22 --- /dev/null +++ b/docs/权限与地区隔离/权限与租户改造当前进度总览.md @@ -0,0 +1,672 @@ +# 权限与租户改造当前进度总览 + +> 适用范围:`leaudit-platform` 当前权限、地区隔离、租户化、多租户入口模块改造现状 +> 更新日期:2026-05-21 +> 文档定位:给当前研发/评审快速判断“已经做到哪里、哪些只是方案、哪些已经进代码/数据库、下一步该做什么”。 + +> 冻结验收入口: +> - [冻结版进度盘点与最小冒烟验收-2026-05-21.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/冻结版进度盘点与最小冒烟验收-2026-05-21.md) +> - [Pytest全链路验收说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/Pytest全链路验收说明.md) + +--- + +## 1. 当前结论 + +目前这轮改造已经不是纯方案阶段,已经进入: + +1. 文档体系基本齐全 +2. 数据库租户底座已补齐 +3. 登录/当前用户/租户解析/入口模块读取链路,已经有第一轮兼容代码 +4. `RBAC` 用户租户设置与首页入口显示,已经完成真实联调修复 +5. `RBAC` 管理域与交叉评查任务建单入口,已完成一轮 tenant-first 高风险边界收口 +6. 统一权限执行器、统一数据范围收口、角色去硬编码全域治理,还没有完成 + +换句话说: + +- `租户主数据底座 + 兼容层`:已开始落地 +- `入口模块租户化`:已部分落地,首页读链路已打通 +- `RBAC 用户租户维护`:已完成首轮接口与前端联调 +- `RBAC/CrossReview 高风险边界`:已完成首轮 tenant-first 收口 +- `权限统一执行器`:仍主要停留在设计和骨架层 +- `业务模块全量 scope 化`:仅零散改动,未形成统一闭环 +- `前端去角色化`:未系统完成 + +补充一个必须纠偏的认知: + +- `评查点/评查组` 当前不能再被简单理解成老 `evaluation_points` 主表治理问题 +- 新平台真实运行主链路,已经转为 `入口模块 -> 一级业务大类 -> 二级业务子类型 -> 规则集 -> YAML 规则版本 -> 文档评查结果` +- 老 `/api/v3/evaluation-points` 与老评查点页面,只能视为兼容链路,不再是新平台主模型 + +对应专项文档: + +- [新平台评查点与评查组真实模型梳理.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/新平台评查点与评查组真实模型梳理.md) + +--- + +## 2. 文档层进度 + +## 2.1 已完成的核心文档 + +`docs/权限与地区隔离/` 下当前已具备以下主文档: + +1. 架构总方案 + - [权限架构全面优化改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限架构全面优化改造方案.md) +2. 现行权限模型 + - [用户与地区权限完整设计方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户与地区权限完整设计方案.md) + - [用户权限与权限点清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户权限与权限点清单.md) +3. 统一执行器设计 + - [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) + - [统一执行器落地代码骨架与接入示例.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md) +4. 接口/SQL/测试/排期 + - [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) + - [权限字段映射与SQL改造规范.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限字段映射与SQL改造规范.md) + - [权限测试验收与回归用例清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限测试验收与回归用例清单.md) + - [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +5. 角色去硬编码专题 + - [角色硬编码与接口影响专项补充分析.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md) + - [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +6. 地区租户化/多租户专题 + - [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) + - [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) + - [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) + - [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) + - [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) + - [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) + +结论: + +- 文档覆盖面已经够全 +- 现在缺的不是“再补大方向设计稿” +- 现在更缺“按文档逐步落代码、落表、落验证” + +## 2.2 文档导航状态 + +当前存在两套导航: + +1. 新总导航 + - [权限文档总导航与阅读顺序.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限文档总导航与阅读顺序.md) +2. 旧导航 + - [权限与地区隔离文档导航.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与地区隔离文档导航.md) + +其中旧导航仍保留了这类旧口径: + +- 只做 `RBAC + 单地区数据隔离` +- 用户地区只认 `sso_users.area` +- 角色主线只保留 `provincial_admin/admin/common` + +这和当前已经推进的“地区租户化、tenant_code、租户主数据、入口模块租户映射”方向已经不一致。 + +结论: + +- 旧导航不能再作为现行总入口 +- 后续阅读应以 [权限文档总导航与阅读顺序.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限文档总导航与阅读顺序.md) 为准 + +补充一个当前必须单列的新专题: + +- [规则域多租户方案A实施计划.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/规则域多租户方案A实施计划.md) + +这个文档的作用不是重复讲租户大方向,而是专门收口: + +1. 新平台规则配置 +2. 评查组与规则绑定 +3. 规则集与规则版本 +4. 评查运行结果链路 + +它已经明确把规则域定案为: + +1. 共享业务树 +2. 租户规则绑定 +3. `TENANT -> PROVINCIAL -> PUBLIC` 生效顺序 +4. 运行结果快照 tenant-first + +当前规则域代码进度已推进到: + +1. 读侧 tenant-first 已完成 +2. 规则组绑定写侧 tenant 化已完成 +3. 规则配置来源态字段已完成 +4. 规则版本 lineage 快照已完成第一轮 +5. 剩余主要是新库执行迁移 SQL 与前端来源态联调 + +--- + +## 3. 数据库层进度 + +## 3.1 已完成 + +数据库底座已经补齐并执行过: + +1. [schema_tenant_foundation.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_tenant_foundation.sql) +2. [schema_entry_module_tenants.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_entry_module_tenants.sql) + +已落地对象包括: + +1. `sys_tenants` +2. `sys_tenant_aliases` +3. `sys_tenant_feature_flags` +4. `leaudit_entry_module_tenants` +5. `sso_users.tenant_code` + +同时已做过: + +1. 基础租户种子数据初始化 +2. 历史 `area -> tenant_code` 回填 +3. 入口模块旧 `areas` JSON 到租户关系表的初始化迁移 + +## 3.2 当前意义 + +这一步很关键,因为它说明当前系统已经不再只是“设计租户化”,而是数据库已经具备: + +1. 租户主数据查询基础 +2. 历史地区别名兼容基础 +3. 功能级租户启用开关基础 +4. 入口模块按租户配置的关系化基础 + +## 3.3 仍未完成 + +数据库层目前仍不是“全量租户化完毕”,主要还差: + +1. 更多业务表是否需要补 `tenant_code` +2. 历史 `area/region/default/省级/空值` 残留数据的全量清洗 +3. 其他模块是否仍只靠中文地区名落库/查询 +4. 更完整的租户写接口与后台维护能力 + +--- + +## 4. 代码层进度 + +## 4.1 已明确落地的后端能力 + +当前已经落地了一批“租户兼容层”代码,不再只是文档: + +1. 统一租户解析器 + - [tenantResolver.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py) +2. 旧 `sso_users` 结构兼容 + - [ssoUserCompat.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py) +3. 租户主数据服务 + - [tenantServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py) +4. 租户接口控制器 + - [tenantController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/tenantController.py) +5. 登录/当前用户租户解析接入 + - [authServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py) +6. 首页入口模块按租户过滤读取 + - [homeServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py) +7. 入口模块管理页租户化读写兼容 + - [entryModuleAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py) +8. `RBAC` 用户租户设置能力与首轮 scope 收口 + - [rbacAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py) + - [rbacAdminController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py) +9. 交叉评查任务建单租户边界收口 + - [crossReviewServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py) +10. 首页入口前端兼容修复 + - [home.ts](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/lib/api/legacy/home/home.ts) + - [minimal-scope.ts](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/lib/config/minimal-scope.ts) +11. 发布级 `pytest` 黑盒验收基座 + - [tests/release/conftest.py](/home/wren-dev/Porject/leaudit-platform/tests/release/conftest.py) + - [tests/release/test_g1_rbac_context.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_g1_rbac_context.py) + - [tests/release/test_g2_g3_tenant_entry_chain.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_g2_g3_tenant_entry_chain.py) + - [tests/release/test_role_tenant_matrix.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_role_tenant_matrix.py) + - [tests/release/test_g4_documents.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_g4_documents.py) + - [tests/release/test_g5_rag.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_g5_rag.py) + - [tests/release/test_g5_rule_cross_review_matrix.py](/home/wren-dev/Porject/leaudit-platform/tests/release/test_g5_rule_cross_review_matrix.py) + +## 4.2 当前已经解决了什么 + +这些代码至少已经开始处理下面几类真实问题: + +1. 旧环境 `sso_users` 没有 `tenant_code` 时,避免直接 SQL 崩溃 +2. 旧环境没有 `sys_tenants/sys_tenant_aliases/leaudit_entry_module_tenants` 时,服务层可降级 +3. 登录返回和当前用户接口,开始统一产出 `tenant_code/tenant_name/tenant_type` +4. 首页入口模块和后台入口模块管理,开始从“按 areas JSON”过渡到“按租户关系表” +5. `RBAC` 用户已可通过标准接口设置 `tenant_code` +6. 首页入口前端已兼容识别 `route_path / routePath / target_path / targetPath / path` +7. 入口模块管理端列表/详情接口已收口为 `tenants` 主输出,不再对管理端返回 `areas` +8. 首页交叉评查入口判断已切到 `tenants` 优先,首页后端租户过滤已收紧过宽 fallback +9. `T8 首页入口 + 入口模块后台收口` 已可视为完成 +10. 租户页与角色权限页的职责边界已补专项文档,后续实现统一按“权限管人、租户能力管挂载、数据范围管边界”收口 +11. `RBAC` 组织树节点已改为 `tenant_code` 优先分组,目标用户角色查询/分配已补跨租户拦截 +12. `CrossReview` 创建任务前的“成员用户 + 任务文档”校验已改为 `tenant_code` 优先,旧 `area/region` 仅作兼容回退 +13. `T5 评查点` 首轮已切到 `tenant_code` 优先读取/写入,旧 `area` 仅在遗留表无字段时兼容回退 +14. `CrossReview` 已补“单任务不得混挂多租户成员/文档”约束,补传文档开始按任务真实挂载内容反推租户边界 +15. `CrossReview` 任务列表已开始同时返回标准化 `evaluationTenants`,前端仍保留 `evaluationRegion` 兼容消费 +16. `T5-1 评查点读链路` 已完成:当前用户租户上下文与显式筛选租户条件已拆分,读范围判断不再互相污染 +17. `T5-2 评查点写链路` 已完成第一轮:创建/更新不再把原始 `area` 文本直接作为归属主语义,统一从解析后的 `writable_scope` 写入 `area/tenant_code/tenant_name` +18. `T5-2 评查点写链路` 已完成第一轮:`PUBLIC/PROVINCIAL` 与 `公共/省级/default` 共享域写入已统一归一化到标准租户编码,未显式指定归属范围的全局写入会被直接拒绝 +19. `T5-3 评查点读链路` 已完成第一轮:共享域筛选已优先按 `PUBLIC/PROVINCIAL` 匹配,旧 `公共/default/空值/省局/省级` 仅作为无 `tenant_code` 历史数据 fallback +20. `T5-4 评查点角色判定` 已完成第一轮:主链路的全局/管理能力已改为 `roles.data_scope + evaluation_point:*` 权限推断,不再直接硬编码 `super_admin/provincial_admin/admin` +21. `T5-5 评查点 fallback 收口` 已完成第一轮:查不到数据库用户上下文时,已移除 `payload.user_role` 角色名兜底;fallback 默认不再推断全局范围 +22. 发布门禁 `G1-G3` 已不再只靠人工回放,已沉淀为可重复执行 `pytest` 黑盒验收 +23. 多租户、多角色的基础矩阵验收已落地,可直接验证: + - 全局管理员跨租户查询 + - 租户管理员本租户范围限制 + - 普通用户管理面 `403` 与业务入口可见性 +24. `tenantServiceImpl` 的租户 alias 重复更新 `500` 已修复,租户更新写链路幂等性补齐了一处高频故障点 +25. `Document` 主链路已通过发布级黑盒验收: + - 上传、列表、详情、更新、附件追加、删除均已验证租户边界 + - `mergeMode=new` 附件追加的当前真实行为已确认为“生成新版本” +26. `RAG` 第一轮黑盒验收已完成: + - 本租户与公共应用可见性边界已验证 + - 数据集详情读取边界已验证 + - 当前权限种子下,数据集管理接口仍偏全局管理员模型,这一现实已被测试固化 +27. `规则集 / 评查组 / 交叉评查` 第一轮黑盒验收已完成: + - 规则集元数据全局可读 + - 规则绑定与评查组树按入口模块租户映射过滤 + - 交叉评查任务建单已验证“成员/文档不得混租户” + +### 4.2.2 新平台主链路本轮新增进度 + +围绕新平台真实主链路 `入口模块 -> 一级业务大类 -> 二级业务子类型 -> 规则集 -> 规则版本 -> 文档评查结果`,本轮又补了 3 个高风险收口点: + +1. `T4 规则控制器鉴权` 已落代码 + - [ruleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleController.py) + - 已补 `verify_access_token` + - 已补 `PermissionServiceImpl` + - 权限键已严格对齐 `scripts/创建sql/user_rbac_seed.sql` 中现有 `rules:*` 种子 + - `CreateRuleVersion / Publish / Rollback` 的操作人已改为只认当前 token 用户,不再信任前端 body 传入用户ID +2. `T1 上传显式 tenant_code` 已落代码 + - [FilesUploadClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/files/upload/FilesUploadClient.tsx) + - [files-upload.ts](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/lib/api/legacy/files/files-upload.ts) + - 上传前端已开始同时传 `tenant_code + region` + - 其中 `tenant_code` 作为主边界,`region` 仅保留给旧链路兼容展示与后端兼容解析 +3. `T2 文档 tenant/region 混用收口` 已完成第一轮 + - [documentServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py) + - `_document_tenant_filter_sql` 已去掉“`tenant_code` 非空时仍放宽匹配空 `tenant_code + region`”逻辑 + - `_find_latest_version_candidate` 已去掉“有正式 `tenant_code` 仍可把历史空租户同 region 文档并入版本链”的放宽分支 + - 当前版本归并边界已经收紧为: + - 有 `tenant_code`:只认同编码文档 + - 无 `tenant_code`:才允许按 `region` 兼容命中历史空租户数据 +4. `T3 评查组/规则绑定租户边界` 已完成第一轮 + - [evaluationPointGroupController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py) + - [evaluationPointGroupService.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py) + - [evaluationPointGroupServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py) + - [ruleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleController.py) + - [ruleService.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/ruleService.py) + - [ruleServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py) + - `evaluation-point-groups` 全部 controller -> service 入口已显式传入 `CurrentUserId` + - `evaluationPointGroupServiceImpl` 已补“当前用户租户上下文 -> 可访问 entry_module -> 可访问 group/binding”的第一轮统一收口 + - `ListGroups / ListAllGroups / ListGroupsByDocumentTypes / GetGroup / GetChildren` 已按 `entry_module -> leaudit_entry_module_tenants` 过滤 + - `Create/Update/Delete/Rebind/Batch*` 及 `CreateBinding/UpdateBinding/DeleteBinding/GetRuleTemplate/CreateRuleDraft` 已补当前租户可访问性校验 + - `ruleServiceImpl` 当前只对 `ListBindings/CreateBinding/UpdateBinding/DeleteBinding` 四个绑定面接口补租户收口,仍未把 `ListSets/GetVersions/GetContent` 这种全局规则资产接口租户化 +5. `T3-1 rule-groups SSR 缓存串租户风险` 已完成修补 + - [page.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/rule-groups/page.tsx) + - 进程级 `__ruleGroupsTreeCache__` 已从单桶改为按 token 分桶 + - 后端开启按租户收口后,不再把 A 租户分组树缓存直接复用于 B 租户 SSR 请求 + +这一轮的定位不是“把整条新平台主链路全部租户化完成”,而是先收掉 3 个最危险问题: + +1. 规则管理接口裸奔无鉴权 +2. 上传链路不显式提交 `tenant_code` +3. 文档与历史版本归并仍可能因为 `region` fallback 错并到错误租户 +4. 评查组/规则绑定仍可跨入口模块查看和写入 +5. `rule-groups` SSR 缓存可能跨租户串数据 + +### 4.2.3 新平台主链路当前仍未完成的点 + +截至本轮,以下问题仍然存在,且属于后续优先级较高的剩余项: + +1. `ruleServiceImpl` / `ruleGroupSupport.py` 仍以全局规则集与分组关系为主,未形成真正的“规则集/版本资产级”租户边界 +2. `evaluationPointGroupServiceImpl` 当前第一轮收口完全依赖 `entry_module -> leaudit_entry_module_tenants`;历史未绑定 `entry_module` 的脏数据,对非全局用户会直接不可见,仍需专项清洗 +3. 文档列表/详情虽然已开始 tenant-first,但部分链路仍保留 `region` 兼容消费,需要继续审计调用点是否会把展示值误当边界键 +4. 首页入口、上传页、文档列表页前端 scope 仍有 `sessionStorage + documentTypeIds + selectedModuleId` 主导的旧思路,尚未做到“显式 tenant scope 全链路透传” +5. 旧兼容评查点链路与新平台主链路之间的权限/租户语义还没有彻底拆开 +6. 规则域多租户仍未整体完成,数据库脚本已准备但尚未在新库执行,当前只完成“方案 A 定案 + 第一轮运行链路 + 只读侧最小落地” +7. `T11-R1/R4` 已开始首轮落地: + - 已新增规则域迁移 SQL: + - [schema_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_rule_domain_tenant_phase1.sql) + - [precheck_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_rule_domain_tenant_phase1.sql) + - [verify_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/verify_rule_domain_tenant_phase1.sql) + - `auditServiceImpl` 已接入 `TENANT -> PROVINCIAL -> PUBLIC` 的最小运行时选择逻辑 + - `storage_adapter` 已支持结果/错误/指标写入租户快照字段的兼容模式 + - `ruleServiceImpl / ruleConfigServiceImpl` 已完成第一轮只读侧租户作用域接入: + - `ListSets / GetVersions / GetContent` 已支持按当前用户 `tenant_code` 解析有效规则集 + - `ruleConfig` 的 `ListPacks / ListPackSummaries / GetPack` 已支持按当前用户命中有效绑定 + - 规则配置摘要缓存已按用户维度隔离,避免串租户 + - `ruleServiceImpl` 已完成第一轮写侧收口: + - `CreateVersion` 已接入当前用户租户上下文,不再默认全局命中 `rule_type -> rule_set` + - `Publish / Rollback` 已增加“当前租户可写规则集”校验,避免跨租户切换生效链 + - 当前仍未进入规则派生复制完整闭环、业务组绑定写侧租户化、前端“来源态展示”的阶段 + +### 4.2.1 评查点模块状态纠偏 + +这里需要单独说明,避免后续继续按错方向推进: + +1. 当前仓库里确实还保留旧评查点链路 + - `EvaluationPointController` + - `EvaluationPointServiceImpl` + - 前端 `rules/list`、`rules/new` + - 后端仍连老库 `docauditai.evaluation_points` +2. 但新平台真实主链路已经不是这套 + - 前端 `rule-groups` 页 + - 后端 `evaluationPointGroupServiceImpl` + - `leaudit_evaluation_point_groups` + - `leaudit_rule_group_bindings` + - `leaudit_rule_sets` + - `leaudit_rule_versions` + - `leaudit_documents.group_id` +3. 因此前面 `T5 评查点` 的第一轮改动,只能定义为: + - “旧兼容评查点链路的 tenant-first 收口” + - 不能定义为“新平台评查点主链路改造完成” + +当前正确判断应该是: + +- `T5-legacy`:旧评查点兼容链路已做首轮 tenant-first 收口 +- `T5-main`:新平台评查组/规则集主链路的租户边界、权限边界、数据范围边界,还需要重新单列分析和排期 + +当前这部分已经不再是“待分析”状态,而是: + +- 已完成单列方案定案 +- 已形成专项实施计划 +- 下一步应直接按 [规则域多租户方案A实施计划.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/规则域多租户方案A实施计划.md) 执行 + +## 4.4 最近一次真实联调结论 + +本轮已用真实接口和真实前端链路验证通过: + +1. `000 / admin06111` 可登录后端和前端 +2. `PUT /api/v3/rbac/users/5/tenant` 可成功更新用户租户 +3. `/api/auth/me` 已能返回正确 `tenant_code=MZ` +4. 后端 `/api/home/entry-modules` 对 `000` 实际返回 5 个入口模块 +5. 前端此前“首页无入口”的根因不是后端过滤,而是首页兼容层只认 `route_path` +6. 修复前端兼容层后,`/api/auth/session-data` 已真实返回 `entryModules=5` + +因此当前关于“000 用户首页没入口”这一问题,已经不是待分析状态,而是已修复并已验证状态。 + +## 4.3 当前还不能说“已完成”的部分 + +虽然代码有落地,但整体仍不能算改造完成,原因是: + +1. 统一权限决策模型还没成为全项目公共底座 +2. `Document/Govdoc/UsageStats/RAG/RBAC` 还没全量切到统一执行器 +3. 角色硬编码还存在于多个 controller/service/前端 guard +4. 现在很多地方是“兼容层 + fallback”,不是最终收口态 + +## 4.4 高风险第一批最新进展 + +当前已正式开始按“高风险优先”执行,不再停留在方案阶段。 + +本轮已新增并推进: + +1. [中高低风险修复优化计划.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/中高低风险修复优化计划.md) +2. [高风险数据库迁移清单与执行顺序.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/高风险数据库迁移清单与执行顺序.md) +3. [schema_tenant_code_high_risk_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_tenant_code_high_risk_phase1.sql) + +其中已经实际落地的第一批代码变更包括: + +1. 合同模板服务已从“接口表面支持 `tenant_code`、实际按 `region` 查”推进到“写入真实 `tenant_code/tenant_name`,查询优先 `tenant_code`,旧数据按 `region` 兼容回退” +2. 合同模板上传去重已从 `region + template_code` 收口为“优先 `tenant_code + template_code`,旧数据兼容 `region`” +3. 合同模板列表、详情返回已不再伪造空 `tenant_code` +4. 高风险 SQL 迁移脚本已从不安全的多匹配回填写法,修正为“按主键逐表稳定回填”的确定性写法 +5. 评查点模块已补专用数据库草案: + - [schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql) +6. 评查点模块已补专用收尾文档: + - [评查点模块收尾清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点模块收尾清单.md) +7. 评查点模块已补执行前预检与执行说明: + - [precheck_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql) + - [评查点数据库执行说明与验证SQL.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md) +8. 评查点模块已补预检结果判读模板: + - [评查点预检结果判读模板.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点预检结果判读模板.md) + +这意味着: + +- `T6 合同模板模块` 已完成第一轮高风险 tenant-first 收口 +- `schema_tenant_code_high_risk_phase1.sql` 已具备进入数据库执行评审的基础 +- `T5 旧评查点兼容链路` 已进入“代码首轮收口完成,待决定是否继续兼容 / 迁移 / 只读化”的阶段 +- `T5 新平台评查组主链路` 不能再按“给 `evaluation_points` 补字段”推进,需改按新模型重新拆租户边界 +- 下一步应继续推进 `RBAC 管理域 scope 化` 与剩余业务派生查询的统一执行器接入 + +--- + +## 5. 按实施排期对照当前状态 + +以 [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) 为基线,当前大致进度如下。 + +## 5.1 P1 能力层建设 + +状态:`未完成` + +判断依据: + +1. 文档里定义了 `ScopeContext / PermissionGrant / PermissionDecision / ScopeClause` +2. 但当前代码里还没有形成稳定、统一、被各模块复用的完整执行器主链 +3. 现有改动以租户兼容和入口模块租户过滤为主,不是统一权限执行器成型 + +结论: + +- `P1` 仍处于“设计已完成,工程底座未完整落地” + +## 5.2 P2 RAG 双轨治理 + +状态:`进行中` + +判断依据: + +1. 当前已实际落地 `rag_dataset / rag_chat_app` 的 `tenant_code` 字段兼容补齐与索引补齐 +2. 数据集后台创建、更新、删除、列表、详情已经改为 `tenant_code` 优先,`area` 退为兼容展示/回退 +3. 数据集可见性与聊天应用可见性已开始按 `tenant_code + PUBLIC/PROVINCIAL + public dataset` 收口 +4. 前端 `/api/v3/dify/area-datasets*` 代理链路已改为 `tenant_code` 优先,知识库配置页已接入真实租户选项源,不再只靠已有知识库反推地区列表 +5. service 内部大部分显式 `provincial_admin/admin/super_admin` 白名单已移除,但统一 `permission + decision + policy` 公共执行器仍未彻底接入 + +结论: + +- `P2` 已从“仅触达代码”进入“后端主链已收口一轮、前端管理链路已改 tenant_code 优先、首轮黑盒已完成”的阶段 +- 但当前黑盒也明确暴露出一个现实:`RAG` 管理接口在现有权限种子下仍主要由全局管理员持有 +- 剩余重点在于: + 1. 更深层文档/分段/检索派生接口的统一策略收口 + 2. 会话写链路与消息链路补测 + 3. 前端页面彻底去 `area` 命名心智 + 4. 统一执行器正式接入 `RagPolicy` + +## 5.3 P3 文档、公文、统计接入 + +状态:`部分完成` + +判断依据: + +1. 这些文件目前有修改痕迹: + - `documentServiceImpl.py` + - `govdocServiceImpl.py` + - `usageStatsServiceImpl.py` +2. 其中 `usageStatsServiceImpl.py` 已完成一轮真实租户收口: + - 登录审计补 `tenant_code_snapshot / tenant_name_snapshot` + - 用户、文档、审计聚合查询改为 `tenant_code` 优先 + - `GetUsers/GetAreas/GetDetails` 的跨租户统计串数问题已修正 +3. `Document/Govdoc` 已完成第一轮高风险 tenant-first 收口,但尚未全部统一接入一套执行器 +4. 当前更需要防范“半改状态”,因为最容易出现范围判断不一致 + +结论: + +- `P3` 不能整体宣称完成 +- 但 `T7 使用统计` 可以视为本轮已完成 +- `Document` 主链路已从“第一轮代码收口”进入“发布级黑盒已验证”的阶段 +- `Govdoc/合同模板` 仍未进入同等强度的黑盒验收 +- `Document/Govdoc` 后续重点转向统一执行器接入、剩余派生查询复核与验收 + +## 5.4 P4 RBAC、合同模板、首页接入 + +状态:`部分进行中` + +判断依据: + +1. 首页相关租户读取逻辑已开始落地,且前端首页兼容层问题已修复 +2. `rbacAdminServiceImpl.py`、`rbacAdminController.py` 已新增用户租户设置链路并完成联调 +3. `RBAC` 组织树、目标用户角色分配/查询已完成一轮 tenant-first 收口 +4. `contractTemplateServiceImpl.py` 第一轮高风险已完成,但还没接入统一策略闭环 +5. 还没形成“统一策略 + 统一范围判定”的闭环 + +结论: + +- `P4` 中的“RBAC 首轮租户设置能力”“RBAC 首轮 scope 收口”和“首页入口可见性修复”已落地 +- `P4` 中的“合同模板统一策略接入”和“RBAC policy 化”仍需继续推进 + +## 5.5 P5 前端去角色化与验收收口 + +状态:`基本未完成` + +判断依据: + +1. 前端目录有租户 API 接入痕迹 +2. 但没有证据表明菜单、按钮、guard 已系统改成按权限/能力快照 +3. 验收矩阵文档虽已写完,自动化和完整联调未见闭环 + +--- + +## 6. 当前最真实的完成度分层 + +如果按“真实可用进度”划分,当前可以这样看: + +## 6.1 已完成 + +1. 权限与租户改造文档体系 +2. 租户主数据基础表与入口模块租户关系表建表脚本 +3. 目标库 schema 执行与基础数据初始化 +4. `sso_users.tenant_code` 字段补齐与历史回填 +5. `RBAC` 用户租户设置接口与前端弹窗联调 +6. 首页入口前端标准化兼容修复 + +## 6.2 已落地但属于第一轮兼容 + +1. 登录/当前用户租户解析 +2. 租户解析器与旧字段兼容 +3. 租户选项服务 fallback +4. 首页入口模块读链路租户过滤 +5. 后台入口模块读写链路租户关系表兼容 +6. 首页前端会话聚合链路兼容 `targetPath/routePath` +7. 入口模块管理端消费层已完成 tenants 化,首页链路保留 `areas` 仅作兼容展示 +8. 首页入口主判断链路已改为 `tenants` 优先,`areas` 已退为兼容展示 + +## 6.3 正在改但还不能验收 + +1. RAG +2. 文档的派生链路 +3. 公文 +4. RBAC 管理域全量 scope 化 +5. 合同模板 +6. 交叉评查任务与文档关系链统一策略接入 + +补充说明: + +1. `统计` 已从“正在改”转为“本轮后端收口已完成,待后续统一执行器接入时再做第二轮整合” +2. `RAG` 当前属于“后端主链 + 前端管理链路第一轮完成,且首轮黑盒验收已完成;但还未做最终权限模型收口” +3. `文档/公文` 当前属于“文档主链路黑盒已通过;公文与更多派生链路尚未完成统一执行器接入与全链路回归” +4. `RBAC` 当前属于“用户租户设置 + 用户列表/组织树/角色分配第一轮 tenant-first 已完成;但 policy 化和统一执行器未完成” +5. `CrossReview` 当前属于“任务建单入口高风险边界已收口且首轮黑盒已验证;但任务详情、关系列表、后续动作链路尚未统一 policy” + +## 6.4 仍未系统展开 + +1. 全局统一权限执行器正式接入 +2. 全模块去角色硬编码 +3. 前端去角色化 +4. 多业务表租户字段标准化 +5. 交叉评查/评查点等剩余 tenant-name 边界残留收口 +6. 回归用例逐条落测 + +## 6.5 当前黑盒验收基线 + +截至 2026-05-21,发布级黑盒验收基线已经扩展为 `16 passed`。 + +这 16 条通过目前表达的是: + +1. `G1-G3` 门禁链路已经稳定 +2. `G4` 文档跨租户读写边界已被可重复执行测试固定 +3. `G5` RAG 当前真实权限模型已被固定 +4. `G6` 规则集/评查组只读矩阵与交叉评查建单边界已被固定 + +同时也要明确: + +1. 这不代表所有设计稿都已实现 +2. 这不代表规则资产已经完全租户化 +3. 这不代表 `RAG` 已经完全去角色化 +4. 这不代表交叉评查后续深链路已经验收完成 + +--- + +## 7. 当前主要风险 + +## 7.1 文档口径和代码口径并存 + +风险点: + +旧导航还在强调“只认 `area`、单地区隔离”,会误导后续开发继续写旧逻辑。 + +## 7.2 代码处于半改状态 + +风险点: + +有些链路已经开始认 `tenant_code`,有些链路仍认 `area/region/default`,很容易出现: + +1. 查得到列表,查不到详情 +2. 登录后有租户,实际查询仍按旧地区字段 +3. 新入口模块配置了租户,但其他下游仍用旧 JSON 或旧地区名 + +## 7.3 统一执行器未成型前,范围逻辑容易继续分叉 + +风险点: + +如果继续一口气在各模块手写过滤,很可能又会堆出第二套、第三套范围判断。 + +## 7.4 租户接口虽然有代码,但还要看联通性 + +当前已存在 [tenantController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/tenantController.py),控制器扫描注册机制也支持自动注册。 + +但仍需联调确认: + +1. `/api/v3/tenants` +2. `/api/v3/tenants/options` +3. `/api/v3/tenants/{tenantCode}` + +是否都已真实可用并满足前端需要。 + +--- + +## 8. 下一步建议顺序 + +基于当前状态,后续不建议再先写大而全新方案,而应按下面顺序推进: + +1. 进入 `T9` 租户主数据维护写能力 + - 补租户新增、编辑、启停、别名、feature flag 管理接口 +2. 再做“剩余高风险残留扫描” + - 重点扫 `region/area/default/省级/省局` 是否仍会导致落错数据或查错范围 +3. 然后按主数据风险顺序推进业务模块 + - 交叉评查后续关系链 + - 评查点配置 + - RBAC policy 化 + - 文档/公文剩余派生查询与统一执行器接入 + - RAG 二轮收口 +4. 最后再进入统一执行器全量收口和前端去角色化 + +--- + +## 9. 建议你现在把它理解成什么阶段 + +最准确的描述是: + +“权限与租户改造已经完成方案设计和第一批数据库/兼容层落地,当前处于从兼容接入向统一执行器正式收口过渡的中间阶段。” + +补充一句更贴近当前真实状态的话: + +“入口模块首页可见性和 RBAC 用户租户维护已经打通,但业务主数据域仍大量停留在 `area/region` 兼容阶段。” + +不是: + +- 还停留在纸面方案 + +也不是: + +- 已经完成全量权限租户化改造 + +--- + +## 10. 当前阅读建议 + +如果你现在只想判断“接下来该盯哪里”,建议直接看这几份: + +1. [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md) +2. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +3. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +4. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +5. [模块级开发任务单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/模块级开发任务单.md) + +如果你要判断“某个模块现在到底能不能继续开发”,再回头对照: + +1. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +2. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +3. [模块级真实落地清单与下一步动作.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md) diff --git a/docs/权限与地区隔离/权限字段映射与SQL改造规范.md b/docs/权限与地区隔离/权限字段映射与SQL改造规范.md new file mode 100644 index 0000000..0bde03e --- /dev/null +++ b/docs/权限与地区隔离/权限字段映射与SQL改造规范.md @@ -0,0 +1,830 @@ +# 权限字段映射与 SQL 改造规范 + +> 适用范围:`leaudit-platform` 后端权限改造中的查询语句、资源详情、导出下载、统计聚合、管理列表 +> 文档定位:为统一数据范围执行器的接入提供字段映射标准、SQL 拼接规范、资源回溯规范和反模式约束。 + +--- + +## 1. 文档目标 + +这份文档解决的是一个非常具体的问题: + +统一数据范围执行器设计出来以后,后端各模块到底怎么把它安全、稳定地接到现有 SQL 上。 + +当前项目最大风险不是“不会写 scope”,而是: + +1. 同一个模块里不同查询使用了不同字段口径 +2. 列表、详情、下载、导出没有复用同一主资源边界 +3. 各 service 自己拼 `1 = 0`、`region`、`created_by`,可维护性很差 +4. 多表 join 时,究竟该按 `u.area` 还是 `d.region`,目前没有统一规范 + +所以这份文档的目标是: + +1. 定义统一字段映射标准 +2. 定义统一 alias 规范 +3. 定义统一 scope 子句拼接方式 +4. 定义详情、下载、导出等派生资源的“主资源回溯”规则 +5. 定义改造时哪些 SQL 能保留,哪些必须重构 + +--- + +## 2. 当前代码里已经暴露出的典型问题 + +结合现有实现,已经确认有以下模式: + +- `documentServiceImpl` / `govdocServiceImpl` + - 通过 `d.region` + `f.created_by` 控制范围 +- `usageStatsServiceImpl` + - 有时按 `u.area` + - 有时按 `d.region` + - 有时还要按 `e.area_snapshot` +- `contractTemplateServiceImpl` + - 既有 `t.region` + - 又有 `t.created_by` + - 还混入“省级模板可见” +- `rbacAdminServiceImpl` + - 用户列表、组织树本质上按 `u.area` + +这说明项目不是“没有规则”,而是规则散了。 + +--- + +## 3. 核心原则 + +## 3.1 先确定主资源,再确定 scope 字段 + +SQL 改造第一步不是拼条件,而是先回答: + +- 这个接口的主资源是谁? + +例如: + +- 文档列表:主资源是 `document` +- 文档状态:主资源仍是 `document` +- 公文报告下载:主资源不是 `report_artifact`,而是 `govdoc document` +- RAG 文档分段列表:主资源不是 `segment`,而是 `dataset` +- 交叉评查提案导出:主资源不是 `proposal`,而是“用户参与的任务文档” + +只有先确定主资源,scope 才不会跑偏。 + +## 3.2 范围条件必须挂在主资源口径上 + +同一个接口里如果出现多表: + +- `document d` +- `document_file f` +- `sso_users u` + +必须先规定: + +- area 看哪个表 +- self 看哪个表 + +不能在同一个模块里一会儿 `u.area`,一会儿 `d.region`,又没有解释。 + +## 3.3 列表、详情、删除、下载、导出必须继承同一套 scope + +这条必须强制执行。 + +不允许出现: + +- 列表按文档范围过滤 +- 详情直接按 `id` +- 下载直接按附件 `id` +- 导出直接按 run `id` + +凡是派生资源,都必须回溯主资源。 + +--- + +## 4. 统一字段映射标准 + +## 4.1 标准字段分类 + +建议统一按 5 类字段管理: + +1. `area_field` +2. `creator_field` +3. `owner_field` +4. `user_field` +5. `public_field` + +建议结构: + +```python +@dataclass +class ScopeFieldMapping: + area_field: str | None = None + creator_field: str | None = None + owner_field: str | None = None + user_field: str | None = None + public_field: str | None = None +``` + +## 4.2 字段语义定义 + +### `area_field` + +表示资源的地区归属字段。 + +例如: + +- `d.region` +- `t.region` +- `dataset.area` +- `app.area` +- `u.area` + +### `creator_field` + +表示“创建该资源的用户”。 + +例如: + +- `f.created_by` +- `t.created_by` +- `proposal.created_by` + +### `owner_field` + +表示“资源所有者”,和创建者不一定相同。 + +如果当前项目没有成熟 owner 模型,可以先不启用。 + +### `user_field` + +用于用户本身或用户快照类数据。 + +例如: + +- `u.id` +- `e.user_id` + +### `public_field` + +表示是否公共可见。 + +例如: + +- `dataset.is_public` +- `app.is_public` + +--- + +## 5. 各模块字段映射表 + +## 5.1 文档模块 + +主资源:`leaudit_documents d` + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `d.region` | +| `creator_field` | `f.created_by` | +| `owner_field` | 暂无 | +| `user_field` | `f.created_by` | + +说明: + +- 当前代码事实口径就是 `d.region + f.created_by` +- 文档的“自己数据”不建议改成 `d.created_by`,因为现有实现明显依赖文件记录 + +## 5.2 公文模块 + +主资源:`govdoc document / leaudit_documents d` + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `d.region` | +| `creator_field` | `f.created_by` | +| `user_field` | `f.created_by` | + +说明: + +- 公文 run、报告、原文都必须回溯到 `d.region` 和 `f.created_by` + +## 5.3 使用统计模块 + +这块必须拆成两个口径。 + +### 用户口径统计 + +主资源:`sso_users u` 或 `usage_login_events e` + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `u.area` 或 `e.area_snapshot` | +| `user_field` | `u.id` 或 `e.user_id` | + +### 文档口径统计 + +主资源:文档上传/评查事件 + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `d.region` | +| `creator_field` | `f.created_by` | +| `user_field` | `f.created_by` | + +说明: + +- `areaScope=user` 和 `areaScope=document` 本质上是两套字段映射切换 +- 不能在一个 builder 里靠字符串替换硬凑 + +## 5.4 合同模板模块 + +主资源:`contract_template t` + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `t.region` | +| `creator_field` | `t.created_by` | +| `public_field` | 暂无;如后续有省级公共模板逻辑,应单独建模 | + +说明: + +- 当前模块有“省级模板 + 本地区模板”可见语义 +- 这不是 `is_public` +- 应在 `ContractTemplatePolicy` 中定义成“系统公共范围”,不要混同布尔公开字段 + +## 5.5 RBAC 用户管理 + +主资源:`sso_users u` + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `u.area` | +| `user_field` | `u.id` | + +说明: + +- 角色对象本身不按 area +- 但“查看哪个用户、给谁分配角色”按 `u.area` + +## 5.6 RAG 模块 + +### 数据集 + +主资源:`dataset` + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `dataset.area` | +| `creator_field` | `dataset.created_by` | +| `public_field` | `dataset.is_public` | + +### 聊天应用 + +主资源:`app` + +| 类型 | 字段 | +| --- | --- | +| `area_field` | `app.area` | +| `public_field` | `dataset.is_public` 或显式 `app.is_public` | + +说明: + +- 如果 app 的公开性来自关联 dataset,就要在 policy 层明确写清,不要分散在查询里隐式推断 + +## 5.7 交叉评查 + +主资源:任务关系,而不是单表字段。 + +推荐映射: + +| 类型 | 字段 | +| --- | --- | +| `user_field` | `tm.user_id` | +| `creator_field` | `proposal.created_by` | + +说明: + +- 交叉评查以 `RELATION` 模型处理 +- 这块不强制要求 `area_field` + +--- + +## 6. Alias 统一规范 + +统一执行器要稳定接入,SQL alias 必须统一。 + +建议标准: + +| 资源 | 推荐 alias | +| --- | --- | +| 文档主表 | `d` | +| 文档文件表 | `f` | +| 用户表 | `u` | +| 审查运行表 | `r` | +| 统计登录事件表 | `e` | +| 合同模板表 | `t` | +| RAG 数据集 | `dataset` | +| RAG 应用 | `app` | +| 交叉评查任务 | `task` | +| 交叉评查成员 | `tm` | +| 交叉评查提案 | `proposal` | + +目的不是强迫重命名所有 SQL,而是: + +- 新增或重构 SQL 时尽量统一 +- `QueryScopeBuilder` 和 `ModulePolicy` 才能更容易复用 + +--- + +## 7. 参数命名规范 + +当前代码里已经有: + +- `requested_region` +- `scope_region` +- `scope_user_id` +- `requested_user_id` + +建议固化为统一标准: + +| 参数 | 含义 | +| --- | --- | +| `requested_area` | 请求方传入的地区过滤 | +| `scope_area` | 当前 scope 决策后的有效地区 | +| `requested_user_id` | 请求方显式筛选的用户 | +| `scope_user_id` | scope 决策后的当前用户 ID | +| `resource_id` | 主资源 ID | +| `visible_areas` | `PUBLIC_MIXED` 或公共范围列表 | + +不建议再混用: + +- `query_area` +- `user_area` +- `region` +- `scope_region` + +除非当前模块确实已经固定使用 `region` 作为数据库字段名,而不是参数名。 + +建议规则: + +- 参数命名一律使用 `area` +- 数据库字段保留 `region/area` 原名 + +即: + +```sql +COALESCE(d.region, '') = :scope_area +``` + +而不是: + +```sql +COALESCE(d.region, '') = :scope_region +``` + +这样更利于统一执行器复用。 + +--- + +## 8. 标准 SQL 子句模板 + +## 8.1 `ALL` + +无地区限制时: + +```sql +1 = 1 +``` + +带请求地区过滤时: + +```sql +COALESCE({area_field}, '') = :requested_area +``` + +## 8.2 `DEPT` + +```sql +COALESCE({area_field}, '') = :scope_area +``` + +并且在进入 SQL 前先做规则: + +- 若 `requested_area` 非空且不等于 `scope_area`,直接拒绝或生成 `1 = 0` + +## 8.3 `SELF` + +```sql +{creator_field} = :scope_user_id +``` + +若请求还传了 `requested_user_id`: + +- 与 `scope_user_id` 不相等时直接拒绝 + +## 8.4 `PUBLIC_MIXED` + +```sql +( + COALESCE({area_field}, '') IN :visible_areas + OR {public_field} = TRUE +) +``` + +推荐 `visible_areas` 至少包含: + +- 当前用户地区 +- `省级` +- `''` + +具体由 `RagPolicy` 决定。 + +## 8.5 `RELATION` + +不建议硬编码为通用模板。 + +应由 `CrossReviewPolicy` 输出,例如: + +```sql +EXISTS ( + SELECT 1 + FROM leaudit_cross_review_task_member tm + WHERE tm.task_id = task.id + AND tm.user_id = :scope_user_id + AND tm.deleted_at IS NULL +) +``` + +--- + +## 9. 主资源回溯规范 + +这是本轮 SQL 改造里最重要的一条。 + +## 9.1 什么叫主资源回溯 + +当接口操作的不是主资源表,而是主资源衍生物时,必须回溯到主资源做 scope 判断。 + +## 9.2 必须回溯的场景 + +### 文档状态 + +虽然接口直接拿文档 ID 列表查状态,但本质仍是文档资源。 + +必须回溯: + +- `document` +- `file` + +### 公文 run / report / original + +不能只按: + +- `run_id` +- `artifact_id` +- `document_id` + +直接查。 + +必须回溯到: + +- `govdoc document d` +- `original file f` + +### RAG dataset document / segment + +不能只按: + +- `document_id` +- `segment_id` + +直接做可见性判断。 + +必须回溯到: + +- `dataset` + +### 交叉评查 proposal export + +不能只按 `DocumentId` 导出。 + +必须回溯到: + +- 当前用户是否属于该任务关系链 + +--- + +## 10. 推荐接入方式 + +## 10.1 列表接口 + +推荐模式: + +1. 构造基础查询 +2. 获取 `PermissionDecision` +3. 由 `QueryScopeBuilder` 生成 `scope_clause` +4. 将 `scope_clause.sql` 注入 `WHERE` +5. 业务筛选条件作为附加条件继续追加 + +示意: + +```python +decision = await permissionScopeFacade.require(...) +scope_clause = queryScopeBuilder.build_by_mapping(...) + +where_clauses = [ + "d.deleted_at IS NULL", + scope_clause.sql, +] +params = { + **scope_clause.params, +} +``` + +## 10.2 详情接口 + +推荐模式: + +不要先查,再判断。 + +而要直接: + +```sql +SELECT ... +FROM ... +WHERE id = :resource_id + AND {scope_clause.sql} +``` + +这样天然避免“查到了但后面忘记拒绝”的风险。 + +## 10.3 删除/更新接口 + +推荐两种方式: + +### 方式 A:先用带 scope 的 SELECT 锁定资源 + +```sql +SELECT id +FROM ... +WHERE id = :resource_id + AND {scope_clause.sql} +FOR UPDATE +``` + +### 方式 B:直接带 scope 执行 UPDATE/DELETE + +```sql +UPDATE ... +SET ... +WHERE id = :resource_id + AND {scope_clause.sql} +``` + +建议优先用方式 A,更利于错误提示和审计。 + +## 10.4 下载/导出接口 + +推荐模式: + +1. 先回溯主资源并带 scope 查出合法记录 +2. 再根据合法记录生成下载地址或导出文件 + +不允许: + +1. 先按附件 ID 或 artifact ID 取到 OSS URL +2. 再事后补判断 + +--- + +## 11. 统计聚合改造规范 + +统计类 SQL 最容易出错,因为不是简单查明细。 + +## 11.1 先收 scope,再聚合 + +推荐: + +```sql +WITH scoped_docs AS ( + SELECT d.id, d.region, f.created_by + FROM ... + WHERE ... + AND {scope_clause.sql} +) +SELECT ... +FROM scoped_docs +GROUP BY ... +``` + +不推荐: + +先全量聚合,再在外层做地区过滤。 + +原因: + +- 易错 +- 性能差 +- `SELF` 范围很容易被漏掉 + +## 11.2 用户口径和文档口径必须拆分 + +不要写一个函数同时隐式支持: + +- `u.area` +- `d.region` +- `e.area_snapshot` + +建议明确: + +- `build_user_area_scope_clause()` +- `build_document_area_scope_clause()` +- `build_login_snapshot_scope_clause()` + +或者在 `UsageStatsPolicy` 内按口径分发。 + +--- + +## 12. 反模式清单 + +以下写法在本轮改造中应视为禁用或逐步清理对象。 + +## 12.1 反模式:角色名直接拼 SQL 范围 + +```sql +bool_or(r.role_key IN ('super_admin', 'provincial_admin')) +``` + +问题: + +- 角色名直接耦合能力 +- 新角色无法扩展 + +## 12.2 反模式:详情先查后判 + +```python +row = await session.execute(...) +if row and current_user["can_manage"]: + ... +``` + +问题: + +- 非法资源已经被读出来 +- 派生接口最容易漏判 + +## 12.3 反模式:用 `1 = 0` 到处散落表达拒绝 + +`1 = 0` 可以作为最终 SQL 结果,但不应成为业务逻辑表达方式。 + +更推荐: + +- 在决策层直接拒绝 +- 或由 builder 明确返回 `denied clause` + +## 12.4 反模式:同一模块 area 字段口径漂移 + +例如: + +- 文档列表按 `u.area` +- 文档详情按 `d.region` + +这种写法必须统一。 + +## 12.5 反模式:下载 URL 先取后判 + +尤其是: + +- 原文下载 +- 报告下载 +- 导出文件 + +必须先判 scope,再产出 URL。 + +--- + +## 13. 索引建议 + +统一执行器接入后,范围过滤会更集中,需要补齐索引。 + +建议优先确认以下索引: + +### 文档/公文 + +- `documents(region)` +- `document_files(created_by)` +- `document_files(document_id, created_by)` + +### 用户 + +- `sso_users(area)` +- `sso_users(status, deleted_at, area)` + +### 合同模板 + +- `contract_templates(region)` +- `contract_templates(created_by)` + +### RAG + +- `rag_datasets(area, is_public)` +- `rag_apps(area)` + +### 交叉评查 + +- `task_member(task_id, user_id)` +- `proposal(document_id, created_by)` + +说明: + +- 索引不是这轮文档的主体,但如果没有,会直接影响统一执行器上线后的查询性能 + +--- + +## 14. 推荐代码组织 + +建议新增或统一下面这类方法: + +```python +build_scope_clause_for_document(...) +build_scope_clause_for_user(...) +build_scope_clause_for_dataset(...) +resolve_primary_resource_scope(...) +``` + +更推荐的最终形态是: + +```python +decision = await permissionDecisionService.decide(ctx) +scope_clause = modulePolicy.build_clause(ctx, decision) +``` + +这样业务 service 只负责: + +1. 自身业务查询 +2. 接入 scope 子句 +3. 做结果组装 + +而不是自己解释权限模型。 + +--- + +## 15. 分模块改造建议 + +## 15.1 文档与公文 + +优先动作: + +1. 抽出统一 `DocumentScopeMapping` +2. 统一 `d.region + f.created_by` +3. 所有详情/状态/run/report/download 统一回溯主文档 + +## 15.2 使用统计 + +优先动作: + +1. 拆分用户口径和文档口径 +2. 不再通过字符串替换实现 `area_snapshot` +3. 将 `SELF` 范围明确化 + +## 15.3 合同模板 + +优先动作: + +1. 把“省级模板可见”收敛为 policy +2. 列表/搜索/详情共享同一范围规则 + +## 15.4 RBAC 用户管理 + +优先动作: + +1. 用户列表、组织树、角色分配统一按 `u.area` +2. 角色元数据接口不强加 area scope + +## 15.5 RAG + +优先动作: + +1. `PUBLIC_MIXED` 统一化 +2. dataset 子资源全部回溯 dataset + +--- + +## 16. 最终要求 + +后续所有权限改造相关 SQL,应满足以下要求: + +1. 能明确说清主资源是谁 +2. 能明确说清 `area_field` 和 `creator_field` 是哪个 +3. 能明确说清详情/下载/导出是否回溯主资源 +4. 不再依赖角色名派生范围 +5. 新增接口时可以直接挂到统一执行器,而不是复制旧判断 + +如果做不到这 5 条,即使逻辑暂时跑通,也不算完成“统一权限架构”的 SQL 层落地。 diff --git a/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md b/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md new file mode 100644 index 0000000..d3a1a42 --- /dev/null +++ b/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md @@ -0,0 +1,367 @@ +# 权限接口矩阵与数据边界清单 + +> 适用范围:当前 `leaudit-platform` 已落地后端接口 +> 文档定位:把“接口 -> permission -> data_scope -> 模块策略 -> 风险点”串成联调、改造、测试共用清单。 + +--- + +## 1. 使用说明 + +本清单按“模块”组织,每个接口至少标注: + +- 接口路径 +- 当前权限点 +- 是否需要统一数据范围执行器 +- scope 类型 +- 是否存在模块特例策略 +- 当前风险和改造备注 + +字段解释: + +- `是否需要 scope` + - `是`:必须进入统一执行器 + - `否`:仅做功能权限即可 +- `scope 类型` + - `ALL/DEPT/SELF` + - `PUBLIC_MIXED` + - `RELATION` + - `NONE` +- `模块策略` + - 指是否需要 `ModulePolicy` + +--- + +## 2. 认证与会话 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `/auth/login` | 无 | 否 | `NONE` | 否 | 登录接口,返回完整 `roles/permissions` | +| `/auth/me` | 登录态 | 否 | `NONE` | 否 | 只返回当前用户信息,不涉及横向数据 | +| JWT 鉴权链路 | 登录态 | 否 | `NONE` | 否 | JWT 只存最小身份,不存完整权限 | + +结论: + +- 认证域不接数据范围执行器 +- 但 `/auth/me` 建议后续补充“能力快照/有效 scope 摘要”,供前端去角色化 + +--- + +## 3. 文档模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/documentController.py` + +## 3.1 文档主接口 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `POST /upload` | 当前未显式走 permission | 是 | `DEPT/SELF` | `DocumentPolicy` | 上传时 `region` 不能越权指定;需补 permission 显式化 | +| `GET /documents/list` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 当前在 service 内自行按 `is_global/can_manage/created_by` 控制 | +| `GET /documents/status` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 批量状态接口容易成为绕过列表边界的侧门 | +| `GET /documents/{DocumentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 详情必须和列表边界一致 | +| `PUT /documents/{DocumentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 写操作应先做资源在 scope 内校验 | +| `DELETE /documents/{DocumentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 删除边界必须与详情一致 | +| `POST /documents/{DocumentId}/attachments` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 附件追加不能绕过文档属地 | +| `POST /upload/upload_contract_template` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 实际依赖文档归属,容易与合同模板域耦合 | + +## 3.2 评查结果相关 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/review-points/{DocumentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 必须回溯文档归属 | +| `PATCH /v3/review-points/{ReviewPointResultId}/audit` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 审核动作要基于文档 scope,不应仅按结果 ID | +| `PATCH /v3/documents/{DocumentId}/confirm` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `DocumentPolicy` | 文档确认权限与详情边界需一致 | + +文档模块结论: + +- 当前后端主要依赖 service 内部范围判断,不够显式 +- 应补齐 `documents:*:*`、`review_points:*:*` 一类 permission_key 与 scope 执行 +- 文档详情、状态、附件、确认都必须视为“文档主资源的派生资源” + +--- + +## 4. 公文模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/govdocController.py` + +## 4.1 公文文档 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `POST /govdoc/documents` | 当前未显式走 permission | 是 | `DEPT/SELF` | `GovdocPolicy` | 上传时 `region` 需受用户 scope 约束 | +| `GET /govdoc/documents` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 现有逻辑与文档模块相似,但未统一 | +| `GET /govdoc/documents/{documentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 详情边界必须与列表一致 | +| `PATCH /govdoc/documents/{documentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 更新动作必须做资源属地校验 | +| `DELETE /govdoc/documents/{documentId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 删除不能只凭 ID 存在 | +| `GET /govdoc/documents/{documentId}/original` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 原文下载是高风险侧门,必须回溯文档 scope | + +## 4.2 审查运行与结果 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `POST /govdoc/runs` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 创建 run 必须先验证文档是否在 scope 内 | +| `GET /govdoc/runs/{runId}` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 必须通过 run 反查 document | +| `GET /govdoc/runs/{runId}/result` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/findings` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/entities` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/structure` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/outline` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/paragraphs` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 同上 | +| `GET /govdoc/runs/{runId}/report/html` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 报告查看必须回溯文档 scope | +| `GET /govdoc/runs/{runId}/report/docx` | 当前未显式走 permission | 是 | `ALL/DEPT/SELF` | `GovdocPolicy` | 报告下载是重点回归项 | + +## 4.3 规则查看 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /govdoc/rules` | 当前未显式走 permission | 否 | `NONE` | 否 | 更偏规则元数据 | +| `GET /govdoc/rules/{ruleId}` | 当前未显式走 permission | 否 | `NONE` | 否 | 不依赖 area | + +公文模块结论: + +- 最关键不是列表,而是所有 `run/result/report/original` 派生接口必须继承文档边界 +- 这部分非常容易出现“列表收住了,下载没收住”的问题 + +--- + +## 5. 使用统计模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/usageStatsController.py` + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/usage-stats/overview` | `usage_stats:overview:read` | 是 | `ALL/DEPT/SELF` | `UsageStatsPolicy` | 当前 service 内已区分 areaScope=user/document | +| `GET /v3/usage-stats/trends` | `usage_stats:trends:read` | 是 | `ALL/DEPT/SELF` | `UsageStatsPolicy` | 趋势与概览边界必须一致 | +| `GET /v3/usage-stats/by-users` | `usage_stats:users:read` | 是 | `ALL/DEPT/SELF` | `UsageStatsPolicy` | `SELF` 时只能看本人 | +| `GET /v3/usage-stats/by-departments` | `usage_stats:departments:read` | 是 | `ALL/DEPT` | `UsageStatsPolicy` | 普通用户是否允许进入,建议改为无权限或只看自身部门汇总 | +| `GET /v3/usage-stats/by-areas` | `usage_stats:areas:read` | 是 | `ALL/DEPT` | `UsageStatsPolicy` | 地区汇总对 `SELF` 没有清晰业务意义,建议禁止 | +| `GET /v3/usage-stats/details` | `usage_stats:details:read` | 是 | `ALL/DEPT/SELF` | `UsageStatsPolicy` | 明细接口是高风险越权点 | + +统计模块结论: + +- 当前实现已经比较接近统一策略,但仍是模块内自实现 +- `areaScope=document` 和 `areaScope=user` 必须在统一执行器中显式挂接字段映射 +- `details` 是重点保护接口 + +--- + +## 6. RAG 模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/ragChatController.py` + +## 6.1 聊天应用 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/rag/apps` | `rag:app:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 当前按 `area in (user_area, 省级, '') or is_public` | +| `GET /v3/rag/apps/default` | `rag:app:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 默认应用与列表边界必须一致 | +| 聊天发送相关接口 | `rag:chat:use` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 发送前要校验目标 app/dataset 可见 | +| 会话列表/详情 | `rag:conversation:read` | 是 | `SELF` | `RagPolicy` | 会话一般只看自己的 | +| 会话重命名 | `rag:conversation:update` | 是 | `SELF` | `RagPolicy` | 仅自己会话 | +| 会话删除 | `rag:conversation:delete` | 是 | `SELF` | `RagPolicy` | 仅自己会话 | +| 消息反馈 | `rag:message:feedback` | 是 | `SELF` | `RagPolicy` | 仅自己消息链路 | + +## 6.2 知识库读接口 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/rag/datasets/my` | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 现有命名是 my,但行为已不是纯 self | +| `GET /v3/rag/datasets/{DatasetId}` | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 详情需继承地区+公开规则 | +| `GET /v3/rag/datasets/{DatasetId}/documents` | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 文档列表需继承数据集可见性 | +| `GET /v3/rag/datasets/{DatasetId}/documents/{DocumentId}` | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 单文档详情要回溯数据集 | +| `GET /v3/rag/datasets/{DatasetId}/segments` 等派生接口 | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 所有派生资源必须回溯 dataset | +| `POST /v3/rag/datasets/{DatasetId}/retrieve` | `rag:dataset:read` | 是 | `PUBLIC_MIXED` | `RagPolicy` | 检索测试属于读权限扩展 | + +## 6.3 知识库管理接口 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/rag/datasets/admin` | `rag:dataset:manage` | 是 | `ALL/DEPT` | `RagPolicy` | 当前 controller 按 permission 放行,service 又按 `UserRole` 二次拒绝 | +| `POST /v3/rag/datasets/admin` | `rag:dataset:create` | 是 | `ALL/DEPT` | `RagPolicy` | 创建时 area 指定受 scope 限制 | +| `PUT /v3/rag/datasets/admin/{DatasetId}` | `rag:dataset:update` | 是 | `ALL/DEPT` | `RagPolicy` | 目标资源必须在 scope 内 | +| `DELETE /v3/rag/datasets/admin/{DatasetId}` | `rag:dataset:delete` | 是 | `ALL/DEPT` | `RagPolicy` | 同上 | +| `PATCH /v3/rag/datasets/{DatasetId}` | `rag:dataset:update` | 是 | `ALL/DEPT/SELF` | `RagPolicy` | 需统一 admin 版与通用版接口边界 | +| `POST /v3/rag/datasets/{DatasetId}/documents` | `rag:dataset:update` | 是 | `ALL/DEPT/SELF` | `RagPolicy` | 文档上传需继承 dataset scope | +| `POST /v3/rag/datasets/{DatasetId}/documents/{DocumentId}/update-by-file` | `rag:dataset:update` | 是 | `ALL/DEPT/SELF` | `RagPolicy` | 同上 | +| `DELETE /v3/rag/datasets/{DatasetId}/documents/{DocumentId}` | `rag:dataset:update/delete` | 是 | `ALL/DEPT/SELF` | `RagPolicy` | 以数据集为主资源边界 | + +RAG 模块结论: + +- 是当前权限改造的第一优先级 +- 最大问题不是 permission 缺失,而是“controller 按 permission、service 按角色”的双轨冲突 +- 所有 `UserRole` 白名单都要迁到 `permission + scope + RagPolicy` + +--- + +## 7. 交叉评查模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py` + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `POST /v3/cross-review/tasks` | `cross_review:task:create` | 是 | `RELATION` | `CrossReviewPolicy` | 创建任务后需自动建立成员关系 | +| `POST /v3/cross-review/tasks/query` | `cross_review:task:read` | 是 | `RELATION` | `CrossReviewPolicy` | 不按 area,而按 `task_member` | +| `GET /v3/cross-review/tasks/{TaskId}/progress` | `cross_review:progress:view` | 是 | `RELATION` | `CrossReviewPolicy` | 需验证当前用户是任务参与方 | +| `GET /v3/cross-review/tasks/{TaskId}/documents` | `cross_review:task:read` + `cross_review:document:read` | 是 | `RELATION` | `CrossReviewPolicy` | 文档列表由任务关系派生 | +| `GET /v3/cross-review/tasks/{TaskId}/can-confirm` | `cross_review:document:complete` | 是 | `RELATION` | `CrossReviewPolicy` | 不能只看 permission,要看用户在任务中的职责 | +| `POST /v3/cross-review/tasks/{TaskId}/documents/{DocumentId}/complete` | `cross_review:document:complete` | 是 | `RELATION` | `CrossReviewPolicy` | 高风险写接口 | +| `POST /v3/cross-review/tasks/{TaskId}/documents/upload` | `cross_review:document:complete` | 是 | `RELATION` | `CrossReviewPolicy` | 上传文档须确认任务成员关系 | +| `POST /v3/cross-review/tasks/{TaskId}/documents/{DocumentId}/attachments` | `cross_review:document:complete` | 是 | `RELATION` | `CrossReviewPolicy` | 同上 | +| `POST /v3/cross-review/proposals` | `cross_review:proposal:create` | 是 | `RELATION` | `CrossReviewPolicy` | 提案创建需验证目标文档在任务关系内 | +| `POST /v3/cross-review/proposals/{ProposalId}/votes` | `cross_review:proposal:vote` | 是 | `RELATION` | `CrossReviewPolicy` | 投票边界不应只看提案 ID | +| `DELETE /v3/cross-review/proposals/{ProposalId}` | `cross_review:proposal:delete` | 是 | `RELATION` | `CrossReviewPolicy` | 需限定提案创建者或具备特定任务身份 | +| `GET /v3/cross-review/documents/{DocumentId}/proposals` | `cross_review:proposal:read` | 是 | `RELATION` | `CrossReviewPolicy` | 通过文档反查任务关系 | +| `GET /v3/cross-review/documents/{DocumentId}/pending-votes` | `cross_review:proposal:read` + `cross_review:document:complete` | 是 | `RELATION` | `CrossReviewPolicy` | 组合权限接口 | +| `GET /v3/cross-review/documents/{DocumentId}/proposals/export` | `cross_review:proposal:read` | 是 | `RELATION` | `CrossReviewPolicy` | 导出接口重点防越权 | + +交叉评查模块结论: + +- 不能硬塞 `ALL/DEPT/SELF` +- 但必须接入统一执行框架 +- 最终统一的是“决策入口”,不是“所有模块都长得一样” + +--- + +## 8. RBAC 管理域 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py`、`rbacController.py` + +## 8.1 当前用户菜单 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /rbac/user/routes` | 登录态 + 数据库路由 | 否 | `NONE` | 否 | 主要是菜单授权,不直接做数据范围 | + +## 8.2 角色、用户、授权管理 + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/rbac/roles` | `rbac:roles:read` | 否 | `NONE` | `RbacAdminPolicy` | 角色列表本身通常不按 area 分裂 | +| `POST /v3/rbac/roles` | `rbac:roles:create` | 否 | `NONE` | `RbacAdminPolicy` | 但角色可配置的 data_scope 要受管控 | +| `PUT /v3/rbac/roles/{RoleId}` | `rbac:roles:update` | 否 | `NONE` | `RbacAdminPolicy` | 系统角色修改需附加约束 | +| `DELETE /v3/rbac/roles/{RoleId}` | `rbac:roles:delete` | 否 | `NONE` | `RbacAdminPolicy` | 同上 | +| `GET /v3/rbac/users` | `rbac:users:read` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 当前 service 内按 `u.area` 收敛 | +| `GET /admin/users/organizations/tree` | `rbac:users:read` / `rbac:org:read` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 组织树也要受 area 控制 | +| `GET /v3/rbac/roles/{RoleId}/users` | `rbac:role_users:read` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 角色关联用户列表受 area 约束 | +| `POST /v3/rbac/users/{UserId}/roles` | `rbac:user_roles:write` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 不能给越权地区用户分配角色 | +| `DELETE /v3/rbac/users/{UserId}/roles/{RoleId}` | `rbac:user_roles:write` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 同上 | +| `GET /v3/rbac/users/{UserId}/roles` | `rbac:user_roles:read` | 是 | `ALL/DEPT` | `RbacAdminPolicy` | 同上 | +| `GET /v3/routes` | `rbac:routes:read` | 否 | `NONE` | `RbacAdminPolicy` | 路由元数据接口 | +| `GET /rbac/roles/{RoleId}/routes` | `rbac:role_routes:read` | 否 | `NONE` | `RbacAdminPolicy` | 配置域接口 | +| `PUT /rbac/roles/{RoleId}/routes` | `rbac:role_routes:write` | 否 | `NONE` | `RbacAdminPolicy` | 配置域接口 | +| `GET /v3/rbac/role-permissions` | `rbac:role_permissions:read` | 否 | `NONE` | `RbacAdminPolicy` | 配置域接口 | +| `POST /v3/rbac/role-permissions` | `rbac:role_permissions:write` | 否 | `NONE` | `RbacAdminPolicy` | 可直接影响 scope,需审计 | +| `POST /v3/rbac/roles/{RoleId}/access` | `rbac:role_access:write` | 否 | `NONE` | `RbacAdminPolicy` | 原子保存授权接口 | +| `GET /v3/routes/{RouteId}/permissions` | `rbac:route_permissions:read` | 否 | `NONE` | `RbacAdminPolicy` | 配置域接口 | + +RBAC 域结论: + +- 角色/路由/权限配置本身更多是功能权限 +- 用户相关接口则需要显式纳入 area scope +- 当前 `_assertManagePermission` 与 `_assertPermission` 双层耦合,需统一 + +--- + +## 9. 合同模板模块 + +控制器:`fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py` + +| 接口 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `GET /v3/contract-templates/categories` | `contract_template:list:read` / `contract_template:search:read` | 否 | `NONE` | 否 | 分类基本可视为元数据 | +| `GET /v3/contract-templates` | `contract_template:list:read` | 是 | `ALL/DEPT/SELF` | `ContractTemplatePolicy` | 当前 service 内按 `is_global/can_manage/region/created_by` 控制 | +| `POST /v3/contract-templates` | `contract_template:create:write` | 是 | `ALL/DEPT` | `ContractTemplatePolicy` | 当前前端还写死 `role === admin` 才能上传 | +| `GET /v3/contract-templates/search` | `contract_template:search:read` | 是 | `ALL/DEPT/SELF` | `ContractTemplatePolicy` | 搜索与列表边界必须一致 | +| `GET /v3/contract-templates/{TemplateId}` | `contract_template:detail:read` / `contract_template:list:read` | 是 | `ALL/DEPT/SELF` | `ContractTemplatePolicy` | 详情要回溯模板属地 | +| `DELETE /v3/contract-templates/{TemplateId}` | `contract_template:delete:delete` | 是 | `ALL/DEPT/SELF` | `ContractTemplatePolicy` | 删除不能只靠按钮隐藏 | + +合同模板模块结论: + +- 后端已有一定范围逻辑,但前端仍强依赖角色名 +- 是典型“后端可改,前端也必须同步去硬编码”的模块 + +--- + +## 10. 首页与入口模块 + +相关实现:`homeServiceImpl.py` + +| 接口/能力 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 首页入口模块列表 | 登录态 / 配置域权限 | 是 | `ALL/DEPT` | `HomePolicy` | 当前有 `super_admin` 绕过 `areas` 配置逻辑 | +| 入口模块地区配置 | 配置域权限 | 否 | `NONE` | `HomePolicy` | 主要是管理配置,不是数据读取 | + +首页域结论: + +- 不是典型数据表 scope +- 但入口是否可见,实质上仍是一种 area 边界 +- 后续应复用统一能力快照,而不是继续写 `bypass_area` + +--- + +## 11. 规则、规则配置、评查点分组 + +控制器:`ruleController.py`、`ruleConfigController.py`、`evaluationPointGroupController.py` + +| 接口类型 | 当前权限点 | 是否需要 scope | scope 类型 | 模块策略 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 规则列表/版本/内容 | `rules:*:*` | 否 | `NONE` | 否 | 偏元数据和配置域 | +| 规则校验/发布/回滚 | `rules:*:*` | 否 | `NONE` | 否 | 高权限但不依赖 area | +| 规则绑定 | `rules:*:*` | 否 | `NONE` | 否 | 配置域 | +| 评查点分组列表 | `evaluation_group:list:read` / `rules:list:read` | 否 | `NONE` | 否 | 当前主要走功能权限 | +| 评查点分组增删改 | `evaluation_group:*:*` | 否 | `NONE` | 否 | 配置域 | + +结论: + +- 这一组接口主要关注功能权限,不是本轮统一 scope 的重点 +- 但要进入接口矩阵,防止后续误把它们也做成地区隔离 + +--- + +## 12. 风险接口总表 + +以下接口属于本轮改造最需要重点回归的高风险接口: + +1. 文档详情、文档删除、附件追加、评查确认 +2. 公文原文下载、报告下载、run 结果查看 +3. 使用统计明细接口 `details` +4. RAG 管理接口 `/datasets/admin*` +5. 交叉评查导出接口 `/documents/{DocumentId}/proposals/export` +6. RBAC 用户角色分配接口 +7. 合同模板创建与删除接口 + +这些接口共同特征是: + +- 容易绕过列表边界 +- 多数不是“菜单是否可见”能防住的 +- 很多地方当前还依赖 service 内部硬编码角色 + +--- + +## 13. 改造原则 + +接口矩阵层面建议统一采用以下原则: + +1. 列表、详情、下载、导出、删除、更新属于同一资源链路,必须复用同一 scope 决策 +2. 组合权限接口应先判功能权限,再判资源边界 +3. 任何通过子资源 ID 访问父资源内容的接口,都必须回溯父资源做 scope 校验 +4. 前端按钮可见性不能替代后端数据边界 +5. RAG 和交叉评查虽然是特例模块,也必须进入统一执行框架 + +--- + +## 14. 本清单的使用方式 + +研发使用: + +- 作为统一执行器接入清单 +- 明确哪些接口先接、哪些接口只做功能权限 + +测试使用: + +- 作为接口级回归矩阵底稿 +- 按模块逐项校验“列表/详情/下载/导出/删除”一致性 + +产品和实施使用: + +- 明确哪些功能受角色去硬编码影响 +- 明确哪些模块需要前端同步联调 diff --git a/docs/权限与地区隔离/权限改造实施任务拆解与排期.md b/docs/权限与地区隔离/权限改造实施任务拆解与排期.md new file mode 100644 index 0000000..6442208 --- /dev/null +++ b/docs/权限与地区隔离/权限改造实施任务拆解与排期.md @@ -0,0 +1,520 @@ +# 权限改造实施任务拆解与排期 + +> 适用范围:权限统一改造项目实施阶段 +> 文档定位:把权限方案拆成可执行、可排期、可协作、可验收的阶段任务。 + +--- + +## 1. 项目目标 + +本轮改造的项目目标不是“再补几个权限判断”,而是完成下面 5 件事: + +1. 建立统一数据范围执行器 +2. 清理后端服务层角色硬编码,尤其是 RAG 双轨冲突 +3. 将文档、公文、统计、合同模板、RBAC 管理域纳入统一 scope 决策 +4. 将前端菜单、按钮、guard 从“认角色”迁移到“认权限/能力” +5. 建立完整回归矩阵,确保详情、下载、导出不再越权 + +--- + +## 2. 实施原则 + +## 2.1 不推翻重做 + +保留现有: + +- `roles` +- `permissions` +- `role_permissions` +- `sys_routes` +- `role_route` +- `sso_users.area` + +本轮主做执行层、接入层、迁移层。 + +## 2.2 先能力层,后业务层 + +顺序必须是: + +1. 统一决策能力 +2. 模块接入 +3. 前端去角色化 +4. 回收 fallback + +不能一边没有统一执行器,一边直接改所有业务。 + +## 2.3 先高风险模块 + +优先级顺序: + +1. RAG +2. 文档、公文、统计 +3. RBAC 管理域、合同模板、首页 +4. 前端兼容层收口 + +--- + +## 3. 阶段总览 + +建议分 5 个 Phase。 + +| Phase | 名称 | 目标 | 建议周期 | +| --- | --- | --- | --- | +| `P1` | 能力层建设 | 建统一执行器与权限决策能力 | 1 周 | +| `P2` | RAG 双轨治理 | 清理 controller/service 双轨与角色白名单 | 1 周 | +| `P3` | 文档、公文、统计接入 | 核心数据模块统一 scope 化 | 1.5 周 | +| `P4` | RBAC、合同模板、首页接入 | 管理域与配置域边界收敛 | 1 周 | +| `P5` | 前端去角色化与验收收口 | 菜单、guard、按钮、fallback 收口 | 1 周 | + +总建议周期:`5.5 周` + +如果人力有限,可压缩到 `4 周`,但风险会明显上升。 + +--- + +## 4. Phase 1:能力层建设 + +## 4.1 目标 + +提供统一执行器和统一能力快照,形成后续所有模块的接入底座。 + +## 4.2 任务拆解 + +### `P1-T1` 统一权限决策模型 + +产出: + +- `ScopeContext` +- `PermissionGrant` +- `PermissionDecision` +- `ScopeClause` + +交付标准: + +- 支持返回 `allowed + effective_scope + matched_roles` + +### `P1-T2` 扩展 PermissionService + +任务: + +- 在 `PermissionServiceImpl` 基础上新增“返回授权明细”的接口 +- 保留现有 `CheckPermission()` 兼容接口 + +交付标准: + +- 不破坏现有 controller 调用 +- 能返回 `grant_type/data_scope` + +### `P1-T3` DataScopeResolver + +任务: + +- 实现 `role_permissions.data_scope > roles.data_scope > 默认值` +- 实现多角色范围合并 +- 实现 `DENY` 优先 + +交付标准: + +- 覆盖 `ALL/DEPT/SELF/GROUP(兼容)` + +### `P1-T4` QueryScopeBuilder + +任务: + +- 实现通用字段映射拼接 +- 支持 `ALL/DEPT/SELF` +- 为模块策略预留扩展入口 + +### `P1-T5` ModulePolicyRegistry + +任务: + +- 定义 `ModulePolicy` 接口 +- 接入首批策略骨架: + - `DocumentPolicy` + - `GovdocPolicy` + - `UsageStatsPolicy` + - `RagPolicy` + - `CrossReviewPolicy` + - `RbacAdminPolicy` + +### `P1-T6` 能力快照接口 + +任务: + +- 为 `/auth/me` 或新能力接口补充当前用户有效权限/能力摘要 + +目的: + +- 给前端去角色化提供统一来源 + +## 4.3 风险 + +- 范围优先级定义不清,后续模块接入会反复返工 +- 若仍只返回布尔权限,后续模块无法平台化 + +## 4.4 验收 + +- 能独立跑通一条“给用户某 permission + data_scope -> 生成 scope clause”的链路 + +--- + +## 5. Phase 2:RAG 双轨治理 + +## 5.1 目标 + +优先清理最突出的“controller 按 permission,service 按角色名”双轨问题。 + +## 5.2 范围 + +后端: + +- `ragChatController.py` +- `ragDatasetServiceImpl.py` +- `ragChatServiceImpl.py` + +前端: + +- `components/dify-dataset-manager/index.tsx` +- `components/dify-dataset-manager/area-dataset-config.tsx` +- `hooks/use-area-dataset-config.ts` + +## 5.3 任务拆解 + +### `P2-T1` 去除 RAG service 中角色白名单 + +任务: + +- 去除 `UserRole in (...)` +- 改为 `permission + PermissionDecision + RagPolicy` + +### `P2-T2` 建立 `PUBLIC_MIXED` 策略 + +任务: + +- 统一 `app/dataset` 可见规则: + - 本地区 + - 省级 + - 公共 + +### `P2-T3` 管理接口 scope 化 + +任务: + +- `/datasets/admin` +- `create/update/delete` +- dataset 文档上传/更新/删除 + +统一按数据集属地判定。 + +### `P2-T4` 前端知识库管理去角色化 + +任务: + +- 删除 `provincial_admin/super_admin/admin` 作为唯一判断条件 +- 改为读取权限/能力快照 + +### `P2-T5` 联调与专项回归 + +重点回归: + +- 自定义角色拥有 RAG 管理权限时,能正常使用 +- 角色名是 `admin` 但无权限时,必须拒绝 + +## 5.4 风险 + +- RAG 前端历史兼容逻辑较多 +- 现有接口命名有 `my/admin` 混合语义,需避免前后不一致 + +## 5.5 验收 + +- RAG 管理域不再依赖角色名 +- 所有 `/datasets/admin*` 接口仅按 permission + scope 生效 + +--- + +## 6. Phase 3:文档、公文、统计接入 + +## 6.1 目标 + +把项目最核心的数据域统一纳入执行器,解决“范围逻辑分散”和“派生接口越权风险”。 + +## 6.2 范围 + +- `documentServiceImpl.py` +- `govdocServiceImpl.py` +- `usageStatsServiceImpl.py` + +## 6.3 任务拆解 + +### `P3-T1` 文档模块接入统一执行器 + +任务: + +- 替换 `_getCurrentUserContext` +- 替换 `_buildDocumentScopeFilters` +- 对列表、详情、状态、确认、附件、删除统一使用 `DocumentPolicy` + +### `P3-T2` 公文模块接入统一执行器 + +任务: + +- 统一文档级 scope +- 所有 `run/result/report/original` 通过文档回溯校验 + +### `P3-T3` 使用统计模块接入统一执行器 + +任务: + +- 抽离 `_build_user_scope_condition` +- 把 `areaScope=user/document` 切换逻辑平台化 + +### `P3-T4` 补权限点缺口 + +任务: + +- 对当前未显式 permission 化的接口补充 permission_key 定义 + +### `P3-T5` 补日志与审计 + +任务: + +- 对权限拒绝原因打结构化日志 +- 至少记录: + - `user_id` + - `permission_key` + - `scope_mode` + - `resource_id` + +## 6.4 风险 + +- 文档与公文都存在大量派生接口,若只改列表极易留下侧门 +- 统计模块口径复杂,需先确定 `SELF` 用户是否允许部分汇总 + +## 6.5 验收 + +- 列表、详情、下载、导出、删除链路边界一致 +- `details`、`report/docx`、`original` 等高风险接口通过专项回归 + +--- + +## 7. Phase 4:RBAC、合同模板、首页接入 + +## 7.1 目标 + +收敛管理域、配置域和入口域中的角色派生逻辑。 + +## 7.2 范围 + +- `rbacAdminServiceImpl.py` +- `contractTemplateServiceImpl.py` +- `homeServiceImpl.py` + +## 7.3 任务拆解 + +### `P4-T1` RBAC 管理域去上下文硬编码 + +任务: + +- 替换 `bool_or(role_key IN (...))` 得到的 `is_global/can_manage` +- 统一用管理域权限和 scope 决策 + +### `P4-T2` 用户管理接口 scope 化 + +任务: + +- 用户列表 +- 角色用户列表 +- 用户角色查询 +- 用户角色分配/移除 + +全部走 `RbacAdminPolicy` + +### `P4-T3` 合同模板模块接入 + +任务: + +- 替换 `is_global/can_manage/created_by` +- 统一列表、搜索、详情、创建、删除边界 + +### `P4-T4` 首页入口 area 能力收敛 + +任务: + +- 替换 `bypass_area` +- 统一入口可见性与用户能力快照 + +## 7.4 风险 + +- RBAC 管理域影响系统管理操作,错误边界会直接影响后台可用性 +- 合同模板前端已有明显角色硬编码,需要同步推进 + +## 7.5 验收 + +- RBAC 用户管理只按 permission + scope 生效 +- 合同模板创建不再依赖前端角色名 + +--- + +## 8. Phase 5:前端去角色化与收口 + +## 8.1 目标 + +让前端从“角色猜能力”迁移为“服务端返回权限/能力,前端只消费结果”。 + +## 8.2 范围 + +- `Sidebar.tsx` +- `lib/auth/user-routes.ts` +- `lib/api/legacy/auth/user-routes.ts` +- `guard.ts` +- `cross-checking-access.ts` +- RAG 相关前端 +- 合同模板页 +- 角色权限管理页中的兼容逻辑 + +## 8.3 任务拆解 + +### `P5-T1` 菜单能力统一来源 + +任务: + +- 菜单只认数据库路由和 permission_map +- 收缩静态 `FALLBACK_MENU_DATA` + +### `P5-T2` guard 去 developer/provincial_admin 白名单 + +任务: + +- `requireDeveloper` 改为认对应后台 permission + +### `P5-T3` 交叉评查入口可见性去角色化 + +任务: + +- 继续保留 route + entry module 双条件 +- 删除 `provincial_admin -> 省局` 这类特殊派生 + +### `P5-T4` 页面按钮去角色化 + +任务: + +- RAG 页面 +- 合同模板上传 +- RBAC 页部分核心角色写死逻辑 + +### `P5-T5` fallback 收口 + +任务: + +- 明确哪些 fallback 允许暂留 +- 明确哪些在本期必须移除 + +## 8.4 风险 + +- 前端 localStorage 中仍存 `user_role` +- 若后端能力接口不先给到位,前端改造会停在一半 + +## 8.5 验收 + +- 自定义角色用户在前端能正确看到菜单和按钮 +- 前端不再要求“必须是 admin/provincial_admin 才能操作” + +--- + +## 9. 建议排期 + +建议以周为单位: + +| 周期 | 任务 | +| --- | --- | +| 第 1 周 | `P1` 能力层建设 | +| 第 2 周 | `P2` RAG 双轨治理 | +| 第 3 周上半 | `P3` 文档、公文接入 | +| 第 3 周下半 | `P3` 统计接入 + 回归 | +| 第 4 周 | `P4` RBAC、合同模板、首页 | +| 第 5 周 | `P5` 前端去角色化 + 全量回归 | +| 第 5.5 周 | 灰度观察与问题收口 | + +--- + +## 10. 人力建议 + +建议最少投入: + +1. 后端 2 人 +2. 前端 1 人 +3. 测试 1 人 + +更稳妥的配置: + +1. 平台/后端 1 人负责统一执行器 +2. 业务后端 1 人负责文档、公文、统计接入 +3. 业务后端 1 人负责 RAG、RBAC、合同模板 +4. 前端 1 人负责菜单、guard、页面按钮去角色化 +5. 测试 1 人负责权限矩阵与回归 + +--- + +## 11. 依赖关系 + +必须严格遵守下面依赖: + +1. `P1` 不完成,`P2-P4` 只能局部试改,无法稳定落地 +2. `P2` 必须优先于前端 RAG 去角色化 +3. `P3` 完成后才能开展核心数据域全量回归 +4. `P5` 必须依赖能力快照/统一路由权限接口 + +--- + +## 12. 回滚策略 + +每个 Phase 都要有可回滚边界。 + +建议: + +- `P1`:新增能力,不替换旧逻辑,可回滚 +- `P2-P4`:模块按开关切换新旧 scope 执行器 +- `P5`:前端保留短期兼容 fallback,但只做兜底,禁止新增依赖 + +建议增加配置开关: + +- `PERMISSION_SCOPE_EXECUTOR_ENABLED` +- `PERMISSION_SCOPE_EXECUTOR_MODULES` + +支持按模块灰度: + +- `rag` +- `documents` +- `govdoc` +- `usage_stats` +- `rbac_admin` +- `contract_templates` + +--- + +## 13. 交付物清单 + +本轮最终交付应包括: + +1. 统一执行器代码 +2. 模块策略代码 +3. 权限接口矩阵文档 +4. 权限测试与回归文档 +5. 角色去硬编码迁移清单 +6. 灰度开关与日志方案 +7. 回归测试记录 + +--- + +## 14. 完成定义 + +只有满足以下条件,才能认为本轮权限改造完成: + +1. RAG 不再按角色白名单决策 +2. 文档、公文、统计已接统一执行器 +3. RBAC 用户管理和合同模板已去主要角色硬编码 +4. 前端菜单/按钮不再依赖核心旧角色名 +5. 详情/下载/导出专项回归通过 + +如果只完成其中一部分,只能算“阶段性接入”,不能算权限架构改造完成。 diff --git a/docs/权限与地区隔离/权限文档总导航与阅读顺序.md b/docs/权限与地区隔离/权限文档总导航与阅读顺序.md new file mode 100644 index 0000000..2af2ccd --- /dev/null +++ b/docs/权限与地区隔离/权限文档总导航与阅读顺序.md @@ -0,0 +1,259 @@ +# 权限文档总导航与阅读顺序 + +> 适用范围:`docs/权限与地区隔离/` 当前整套权限、地区隔离、统一执行器、去硬编码、实施文档 +> 文档定位:给研发、测试、产品、评审、实施同事提供统一阅读顺序,避免只看到局部文档、漏掉关键约束。 + +--- + +## 1. 先看结论 + +现在这套文档已经不只是“权限设计稿”,而是完整覆盖了: + +1. 现行模型 +2. 权限点与接口 +3. 架构问题 +4. 去硬编码 +5. 统一执行器 +6. 字段和 SQL 规范 +7. 地区租户化 +8. 自定义租户扩展 +9. 实施排期 +10. 测试回归 +11. 代码骨架 +12. 后续扩展方向 + +因此不建议再按旧导航只看 2-3 份文档。 + +如果你当前最关心的是“已经做到哪里”,先看: + +1. [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md) +2. 再按本文档进入专题阅读 + +--- + +## 2. 按角色的阅读顺序 + +## 2.1 架构评审 / 技术负责人 + +建议顺序: + +1. [用户与地区权限完整设计方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户与地区权限完整设计方案.md) +2. [权限架构全面优化改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限架构全面优化改造方案.md) +3. [角色硬编码与接口影响专项补充分析.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md) +4. [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) +5. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +6. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +7. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +8. [权限字段映射与SQL改造规范.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限字段映射与SQL改造规范.md) +9. [统一执行器落地代码骨架与接入示例.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md) + +目的: + +- 先看现状和改造方向 +- 再看执行器设计 +- 最后看代码级落地方式 + +## 2.2 后端研发 + +建议顺序: + +1. [用户权限与权限点清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户权限与权限点清单.md) +2. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +3. [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) +4. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +5. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +6. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +7. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +8. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +9. [权限字段映射与SQL改造规范.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限字段映射与SQL改造规范.md) +10. [统一执行器落地代码骨架与接入示例.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md) +11. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +12. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) + +目的: + +- 先知道接口和 permission +- 再知道 scope 规则 +- 再知道代码怎么改 + +## 2.3 前端研发 + +建议顺序: + +1. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +2. [角色硬编码与接口影响专项补充分析.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md) +3. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +4. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +5. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +6. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +7. [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) +8. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) + +目的: + +- 先理解后端边界 +- 再理解哪些按钮、菜单、guard 需要去角色化 + +## 2.4 测试 / 验收 + +建议顺序: + +1. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +2. [权限测试验收与回归用例清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限测试验收与回归用例清单.md) +3. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) + +目的: + +- 先知道该测哪些接口 +- 再知道怎么测 +- 再知道哪些地方最容易回归出问题 + +## 2.5 产品 / 业务 / 实施 + +建议顺序: + +1. [用户与地区权限完整设计方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户与地区权限完整设计方案.md) +2. [权限架构全面优化改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限架构全面优化改造方案.md) +3. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +4. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +5. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +6. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +7. [权限测试验收与回归用例清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限测试验收与回归用例清单.md) + +--- + +## 3. 按主题的阅读顺序 + +## 3.1 现行模型与背景 + +1. [用户与地区权限完整设计方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户与地区权限完整设计方案.md) +2. [用户权限与权限点清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户权限与权限点清单.md) + +回答问题: + +- 现在系统权限模型到底是什么 +- 角色、菜单、权限点、地区字段分别在哪 + +## 3.2 架构问题与改造方向 + +1. [权限架构全面优化改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限架构全面优化改造方案.md) +2. [角色硬编码与接口影响专项补充分析.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md) + +回答问题: + +- 为什么当前系统不能继续靠分散 if 判断演进 +- 哪些硬编码会联动影响接口和前端 + +## 3.3 统一执行器与后端落地 + +1. [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) +2. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +3. [权限字段映射与SQL改造规范.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限字段映射与SQL改造规范.md) +4. [统一执行器落地代码骨架与接入示例.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md) + +回答问题: + +- scope 怎么算 +- SQL 怎么接 +- 代码怎么改 + +## 3.4 接口、测试、排期 + +1. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +2. [权限测试验收与回归用例清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限测试验收与回归用例清单.md) +3. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) + +回答问题: + +- 哪些接口受影响 +- 怎么验收 +- 怎么拆阶段实施 + +## 3.5 去硬编码执行清单 + +1. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) + +回答问题: + +- 具体哪些文件要改 +- 按什么优先级改 +- 会影响哪些接口和页面 + +## 3.6 地区租户化与自定义租户扩展 + +1. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +2. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +3. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +4. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +5. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +6. [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) + +回答问题: + +- 为什么“地区”本质已经是租户 +- 为什么新增租户不能只改前端下拉 +- 租户主数据应该怎么建 +- 历史 `省局 / 省级 / default / 空值` 应该怎么清洗 +- 入口模块为什么必须从 JSON 改成关系表 + +--- + +## 4. 推荐总阅读顺序 + +如果是第一次完整阅读,建议按下面顺序: + +1. [用户与地区权限完整设计方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户与地区权限完整设计方案.md) +2. [用户权限与权限点清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/用户权限与权限点清单.md) +3. [权限架构全面优化改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限架构全面优化改造方案.md) +4. [角色硬编码与接口影响专项补充分析.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md) +5. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +6. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +7. [权限、租户能力与数据范围职责边界说明.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限、租户能力与数据范围职责边界说明.md) +8. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +9. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +10. [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) +11. [统一数据范围执行器设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一数据范围执行器设计.md) +12. [权限字段映射与SQL改造规范.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限字段映射与SQL改造规范.md) +13. [统一执行器落地代码骨架与接入示例.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md) +14. [权限接口矩阵与数据边界清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限接口矩阵与数据边界清单.md) +15. [权限测试验收与回归用例清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限测试验收与回归用例清单.md) +16. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +17. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) + +--- + +## 5. 当前文档体系解决了什么 + +这套文档现在已经能解决下面这些问题: + +1. 当前权限架构到底是什么 +2. data_scope 为什么现在不统一 +3. 角色硬编码具体散在哪里 +4. RAG 为什么是优先治理区 +5. 文档、公文、统计、RBAC、合同模板、交叉评查分别怎么接统一执行器 +6. SQL 层应该怎么收敛 +7. 测试应该怎么验收 +8. 地区为什么本质已经是租户 +9. 自定义租户为什么会连带影响入口、RAG、模板、统计和 RBAC +10. 实施应该怎么排期 +11. 租户能力与角色权限为什么看起来重复、实际应如何分层 + +--- + +## 6. 当前仍建议继续补充的方向 + +如果后续继续扩展,优先级建议如下: + +1. 租户接口设计与返回结构规范 +2. 统一租户解析器与兼容层设计 +3. 文档/公文/RAG 的真实代码实施记录 + +其中前两项优先级最高,因为当前“地区”在很多业务里已经实际承担了“租户”的角色,但还没有统一主数据接口和统一解析层。 + +--- + +## 7. 对旧导航的替代说明 + +旧的 [权限与地区隔离文档导航.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与地区隔离文档导航.md) 仍可以保留,但它已经只覆盖早期文档,不足以指导当前完整改造。 + +建议以后以本文档作为新的总导航入口。 diff --git a/docs/权限与地区隔离/权限文档补充清单与编写建议.md b/docs/权限与地区隔离/权限文档补充清单与编写建议.md new file mode 100644 index 0000000..ccad5b4 --- /dev/null +++ b/docs/权限与地区隔离/权限文档补充清单与编写建议.md @@ -0,0 +1,436 @@ +# 权限文档补充清单与编写建议 + +> 适用范围:`docs/权限与地区隔离/` 当前文档体系 +> 目标:明确“现有文档已经覆盖了什么”“还缺哪些关键文档”“每份补充文档应该写什么”。 + +--- + +## 1. 当前文档覆盖情况 + +目前这个目录下已经有: + +- `权限与地区隔离文档导航.md` +- `用户与地区权限完整设计方案.md` +- `用户权限与权限点清单.md` +- `用户权限初始化SQL.sql` +- `权限架构全面优化改造方案.md` +- `角色硬编码与接口影响专项补充分析.md` + +这批文档已经覆盖了 3 个层面: + +1. **现行设计层** + - 系统最终采用什么权限模型 +2. **权限点与初始化层** + - 角色、菜单、权限点、SQL +3. **改造策略层** + - 架构问题、改造方向、硬编码角色、接口影响面 + +所以当前并不是“文档太少”,而是**还缺少落地执行层文档**。 + +换句话说: + +- “为什么这么改”已经基本够了 +- “具体怎么一批批改、怎么验收、怎么防回归”还不够 + +--- + +## 2. 当前最缺的文档类型 + +当前最缺的不是再写一份大而全方案,而是补下面 5 类文档: + +1. **统一执行器设计文档** +2. **实施任务拆解文档** +3. **接口权限矩阵文档** +4. **测试验收文档** +5. **迁移灰度与回滚文档** + +这 5 类文档补齐后,权限体系文档才会从“设计完整”进入“可实施、可协作、可验收”状态。 + +--- + +## 3. 建议优先补充的文档 + +下面按优先级给出建议。 + +## 3.1 P0:统一数据范围执行器设计.md + +建议文件名: + +- `docs/权限与地区隔离/统一数据范围执行器设计.md` + +为什么必须补: + +- 当前最大缺口就是 `data_scope` 已建模但没有统一执行层 +- 主方案已经讲了方向,但还没有到“类设计/接口设计/接入方式”的粒度 + +建议文档内容: + +1. 背景与目标 +2. 当前问题 +3. 核心对象设计 + - `PermissionDecision` + - `ScopeContext` + - `DataScopeResolver` + - `QueryScopeBuilder` + - `ModulePolicy` +4. scope 计算规则 +5. 多角色合并规则 +6. `DENY` 与 `GRANT` 优先级 +7. 通用字段映射规范 + - `region` + - `created_by` + - `owner_id` +8. 模块接入方式 +9. 代码落点建议 +10. 第一批接入模块 + +这份文档的作用: + +- 把“方案级描述”变成“研发可直接开工的设计稿” + +## 3.2 P0:权限改造实施任务拆解与排期.md + +建议文件名: + +- `docs/权限与地区隔离/权限改造实施任务拆解与排期.md` + +为什么必须补: + +- 当前总方案里有阶段建议,但还不够任务化 +- 真正落地时需要明确每阶段交付、负责人边界、依赖顺序 + +建议文档内容: + +1. 总目标 +2. Phase 划分 +3. 每阶段任务清单 +4. 每阶段输入输出 +5. 依赖关系 +6. 关键风险 +7. 验收门槛 +8. 建议排期 + +建议拆成: + +- Phase 1:统一能力层 +- Phase 2:RAG 去双轨 +- Phase 3:文档/公文/统计统一 scope +- Phase 4:RBAC 管理域与首页入口 +- Phase 5:前端去角色化与 fallback 收口 + +## 3.3 P0:权限测试验收与回归用例清单.md + +建议文件名: + +- `docs/权限与地区隔离/权限测试验收与回归用例清单.md` + +为什么必须补: + +- 权限改造最怕“改对了一处,放大了另一处” +- 现在还缺一份真正可执行的验收清单 + +建议文档内容: + +1. 测试目标 +2. 用户角色矩阵 +3. 数据边界矩阵 +4. 模块覆盖矩阵 +5. 接口级用例 +6. 越权回归用例 +7. 菜单/页面/按钮一致性用例 +8. 下载/详情/导出接口专项用例 +9. 灰度观察指标 + +重点必须覆盖: + +- 文档 +- 公文 +- 统计 +- RAG +- RBAC 管理 +- 交叉评查 + +## 3.4 P0:权限接口矩阵与数据边界清单.md + +建议文件名: + +- `docs/权限与地区隔离/权限接口矩阵与数据边界清单.md` + +为什么必须补: + +- 目前有“权限点清单”,但还缺“接口到数据边界”的完整矩阵 +- 这个文档是联调、测试、排查越权最直接的依据 + +建议文档内容: + +每个接口至少写清: + +- 接口路径 +- 对应 permission_key +- 是否需要 data_scope +- scope 类型 +- 是否有模块 policy +- 允许哪些角色/能力访问 +- 是否有下载/详情/导出子资源 + +建议按模块分章节: + +- 认证 +- 文档 +- 公文 +- 统计 +- RAG +- 交叉评查 +- RBAC +- 合同模板 +- 规则/评查点 + +这份文档的价值非常高,因为它会成为: + +- 后端改造依据 +- 前端联调依据 +- 测试验收依据 + +## 3.5 P0:角色去硬编码迁移清单.md + +建议文件名: + +- `docs/权限与地区隔离/角色去硬编码迁移清单.md` + +为什么还要单独再补一份: + +- 现在已经有“专项分析” +- 但还没有“逐文件、逐逻辑、逐替换策略”的迁移执行清单 + +建议文档内容: + +1. 当前硬编码点总表 +2. 每个硬编码点所属类型 +3. 替换目标 +4. 替换优先级 +5. 是否影响接口行为 +6. 是否影响前端显示 +7. 是否需要兼容过渡 + +建议按表格列: + +- 文件 +- 位置 +- 当前写法 +- 问题类型 +- 替换方式 +- 优先级 +- 风险等级 + +--- + +## 4. 建议第二批补充的文档 + +这些不是第一优先,但补了会明显提升协作效率。 + +## 4.1 P1:前端权限消费改造方案.md + +建议文件名: + +- `docs/权限与地区隔离/前端权限消费改造方案.md` + +为什么建议补: + +- 当前前端既有 permission 消费,也有角色硬编码,也有 route fallback +- 需要一份单独文档把前端边界讲清楚 + +建议内容: + +- 菜单权限来源 +- `permission_map` 的定位 +- `user_role` 的新定位 +- UI 按钮可见性策略 +- 页面 guard 改造策略 +- fallback 收口路径 + +## 4.2 P1:RBAC管理台改造细化方案.md + +建议文件名: + +- `docs/权限与地区隔离/RBAC管理台改造细化方案.md` + +为什么建议补: + +- RBAC 管理台不是普通业务模块 +- 它是权限体系的“运营端” + +建议内容: + +- 角色管理 +- 路由管理 +- 权限点管理 +- scope 展示 +- 用户最终权限预览 +- 冲突检测 +- 灰度观察 + +## 4.3 P1:权限可观测性与审计日志方案.md + +建议文件名: + +- `docs/权限与地区隔离/权限可观测性与审计日志方案.md` + +为什么建议补: + +- 当前方案里已经指出缺观测能力 +- 但还没有专门说明“记录什么、怎么记录、怎么查” + +建议内容: + +- 权限决策日志 +- data_scope 解析日志 +- deny 原因日志 +- 管理操作审计日志 +- 命中/拒绝统计指标 + +## 4.4 P1:权限迁移灰度发布与回滚手册.md + +建议文件名: + +- `docs/权限与地区隔离/权限迁移灰度发布与回滚手册.md` + +为什么建议补: + +- 权限改造是高风险变更 +- 需要单独文档说明如何灰度和回滚 + +建议内容: + +- 灰度开关 +- 新旧逻辑并行方式 +- shadow compare 方案 +- 回滚条件 +- 回滚操作步骤 + +--- + +## 5. 建议第三批补充的文档 + +这些文档不一定马上要写,但后续如果项目进入实施期,会很有价值。 + +## 5.1 P2:模块Policy设计清单.md + +建议文件名: + +- `docs/权限与地区隔离/模块Policy设计清单.md` + +适用场景: + +- 当系统开始明确区分 `CrossReviewPolicy`、`RagDatasetPolicy`、`RuleConfigPolicy` 时 + +作用: + +- 统一记录哪些模块走通用 scope,哪些模块走关系型访问模型 + +## 5.2 P2:权限术语表与统一口径说明.md + +建议文件名: + +- `docs/权限与地区隔离/权限术语表与统一口径说明.md` + +适用场景: + +- 项目成员较多、跨前后端协作频繁时 + +作用: + +- 统一 `ALL/DEPT/SELF` +- 统一 `area/region` +- 统一 `role/permission/capability/policy` + +## 5.3 P2:legacy权限治理遗留清单.md + +建议文件名: + +- `docs/权限与地区隔离/legacy权限治理遗留清单.md` + +适用场景: + +- 实施进入中后期,需要记录还没收掉的旧逻辑时 + +作用: + +- 防止历史兼容逻辑永久留在系统里无人认领 + +--- + +## 6. 当前最推荐立刻补的 5 份文档 + +如果只选最重要的,我建议立刻补下面 5 份: + +1. `统一数据范围执行器设计.md` +2. `权限改造实施任务拆解与排期.md` +3. `权限测试验收与回归用例清单.md` +4. `权限接口矩阵与数据边界清单.md` +5. `角色去硬编码迁移清单.md` + +原因很简单: + +- 这 5 份补齐后,就能真正开始实施 +- 如果只继续写“大方案”,信息会越来越多,但落地抓手不够 + +--- + +## 7. 当前目录建议结构 + +建议后续把这个目录里的文档分成 4 组理解: + +### A. 现行设计 + +- `用户与地区权限完整设计方案.md` +- `用户权限与权限点清单.md` +- `用户权限初始化SQL.sql` + +### B. 架构改造 + +- `权限架构全面优化改造方案.md` +- `角色硬编码与接口影响专项补充分析.md` + +### C. 需补的实施文档 + +- `统一数据范围执行器设计.md` +- `权限改造实施任务拆解与排期.md` +- `权限测试验收与回归用例清单.md` +- `权限接口矩阵与数据边界清单.md` +- `角色去硬编码迁移清单.md` + +### D. 后续治理文档 + +- `前端权限消费改造方案.md` +- `RBAC管理台改造细化方案.md` +- `权限可观测性与审计日志方案.md` +- `权限迁移灰度发布与回滚手册.md` + +--- + +## 8. 最终建议 + +当前权限文档体系的状态可以概括为: + +- **设计层基本够了** +- **改造方向层基本够了** +- **执行层明显还不够** + +所以后续补文档时,不建议继续优先写“大而全总方案”。 + +最优先应该补的是: + +- 可直接指导写代码的设计文档 +- 可直接指导排期的实施文档 +- 可直接指导测试的验收文档 +- 可直接指导联调和排查的接口矩阵文档 + +如果按价值排序,我的建议顺序是: + +1. `统一数据范围执行器设计.md` +2. `权限接口矩阵与数据边界清单.md` +3. `权限测试验收与回归用例清单.md` +4. `权限改造实施任务拆解与排期.md` +5. `角色去硬编码迁移清单.md` + +这 5 份一旦补齐,当前权限体系就从“分析充分”进入“可工程化推进”的状态。 diff --git a/docs/权限与地区隔离/权限架构全面优化改造方案.md b/docs/权限与地区隔离/权限架构全面优化改造方案.md new file mode 100644 index 0000000..4f508a2 --- /dev/null +++ b/docs/权限与地区隔离/权限架构全面优化改造方案.md @@ -0,0 +1,1519 @@ +# 权限架构全面优化改造方案 + +> 适用范围:`leaudit-platform` 当前“角色权限 + 地区隔离”体系 +> 目标:在不推翻现有 RBAC 表结构和已落地业务逻辑的前提下,把分散的权限判断、数据范围控制、菜单兼容逻辑收敛为一套可持续演进的平台化能力。 + +--- + +## 1. 结论先行 + +当前项目的权限架构不是“缺权限系统”,而是已经形成了比较清晰的主干: + +- 认证层:`JWT + sso_users` +- 功能权限层:`roles / user_role / permissions / role_permissions` +- 菜单权限层:`sys_routes / role_route` +- 数据隔离层:`area + role/data_scope + 业务模块自定义过滤` + +从现状看,项目的真实成熟度是: + +- **功能权限成熟度较高** +- **菜单权限已基本成型,但仍有兼容 fallback** +- **数据范围权限设计完整,但平台统一执行能力不足** +- **部分模块已经自行实现范围控制,但实现风格不统一** + +因此,本次改造**不建议推翻现有表设计重做**,而应采取“保留模型、统一执行、逐步收敛”的路径: + +1. 保留现有 `RBAC + 单地区隔离` 总体模型 +2. 新增统一的“数据范围解析器 + 查询过滤构建器” +3. 将业务模块里的手写 `is_global/can_manage/area/created_by` 逻辑逐步平台化 +4. 逐步取消前后端路由 fallback,最终只认数据库路由与权限点 +5. 将“管理能力”从角色硬编码逐步迁移到显式权限点驱动 +6. 对 legacy 模块和特殊访问模型做分层治理,而不是强行一刀切 + +--- + +## 2. 当前代码架构全景 + +## 2.1 认证与登录态 + +当前 JWT 只保留鉴权链路所需的最小字段,不把完整 `roles/permissions` 写进 token。 + +关键实现: + +- `fastapi_common/fastapi_common_security/jwtService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py` + +已确认的行为: + +- JWT 中保存:`user_id`、`username`、`area`、`user_role` 等最小身份信息 +- 登录响应和 `/auth/me` 返回完整 `roles`、`permissions` +- 这样避免了权限过多导致前端 session/cookie 体积膨胀 + +这是合理设计,应保留。 + +## 2.2 RBAC 核心数据模型 + +当前权限核心表由 `scripts/创建sql/user_rbac_schema_patch.sql` 定义,结构完整,且和现有业务实现一致: + +- `sso_users` +- `roles` +- `user_role` +- `sys_routes` +- `role_route` +- `permissions` +- `role_permissions` + +其中最关键的设计语义是: + +- 用户地区主字段:`sso_users.area` +- 角色默认数据范围:`roles.data_scope` +- 角色-权限点级数据范围:`role_permissions.data_scope` +- 拒绝优先机制:`role_permissions.grant_type = GRANT / DENY` +- `DEPT` 在本项目语义里其实是“同地区”,不是传统组织部门 + +这套模型本身没有明显结构性错误,问题主要出在“平台执行层不统一”。 + +## 2.3 后端功能权限校验 + +当前后端权限判断主要分两类: + +1. 通用 permission 校验 +2. 某些管理域额外加角色型上下文判断 + +关键实现: + +- `fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py` + +现状特点: + +- `PermissionServiceImpl` 已支持: + - 数据库动态查权 + - `DENY` 优先 + - wildcard 权限匹配 +- 但它**只返回“有没有该权限点”**,**不解析 data_scope** + +这意味着: + +- 平台层只完成了“功能准入” +- 没完成“数据范围落地” + +## 2.4 菜单与页面权限 + +当前菜单权限总体是数据库驱动,但仍带兼容降级逻辑。 + +关键实现: + +- 后端:`fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py` +- 前端:`legal-platform-frontend/lib/api/legacy/auth/user-routes.ts` +- 前端侧边栏:`legal-platform-frontend/components/layout/Sidebar.tsx` + +现状行为: + +- 后端优先从 `role_route + sys_routes` 生成当前用户路由树 +- 若数据库路由集合未达到“前端真实路径集”的预期,则回退到 `_COMPAT_ROUTE_BLUEPRINTS` +- 前端仍保留静态 fallback menu 和本地权限 map 兼容逻辑 + +这说明系统已经向“数据库驱动权限菜单”演进,但尚未完全完成切换。 + +## 2.5 数据范围控制 + +当前数据范围控制最关键的事实是: + +- **设计层已经有 data_scope** +- **平台层没有统一数据范围执行器** +- **业务层各模块自行实现范围过滤** + +已确认的代表实现: + +- 文档:`fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py` +- 公文:`fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- 统计:`fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py` + +这些模块大量采用类似逻辑: + +- `is_global` => 全量 +- `can_manage + area` => 本地区 +- 普通用户 => `created_by/current_user_id` + +这套逻辑本身没有错,但它是“业务重复实现”,不是“权限平台统一执行”。 + +## 2.6 特殊模块访问模型 + +项目里并非所有模块都纯粹按 `area + data_scope` 控制,存在三类特殊形态: + +### 2.6.1 RAG 模块 + +关键实现: + +- `fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py` + +访问特点: + +- 管理侧按 `UserRole + UserArea` 控制可管理知识库范围 +- 使用侧按 `area in (user_area, '省级', '') or is_public` 控制可见应用和知识库 +- 这是一种“地区 + 公共可见”混合模型 + +### 2.6.2 交叉评查模块 + +关键实现: + +- `fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py` + +访问特点: + +- 主要按“任务成员关系”控制 +- 用户是否能看任务、提案、投票、文档,核心依赖 `task_member` +- 这不是纯 area 访问模型,而是典型 ABAC/关系型访问模型 + +### 2.6.3 规则与规则配置模块 + +关键实现: + +- `fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py` + +访问特点: + +- 更偏“配置域”和“元数据域” +- 当前更多依赖功能权限,不强依赖 area/data_scope +- 但与评查点分组、文档类型、入口模块存在管理域耦合 + +--- + +## 3. 当前架构的优点 + +## 3.1 主模型已经稳定 + +项目已经从复杂化、多地区、多入口的旧思路收敛到: + +- 一个用户一个主地区 +- 少量角色 +- 菜单权限和动作权限分层 +- 数据权限依赖地区和用户归属 + +这个方向是正确的。 + +## 3.2 功能权限表设计足够支撑未来扩展 + +当前 `permissions + role_permissions` 已经具备: + +- API 权限点建模 +- route 关联能力 +- `GRANT / DENY` +- `data_scope` +- `condition_filter` + +说明现有模型不是只能做静态角色,而是具备平台化扩展空间。 + +## 3.3 多个业务模块已经形成真实可用的数据隔离逻辑 + +虽然还不统一,但文档、公文、统计等核心模块已经不是裸奔状态。 + +这带来两个好处: + +- 当前系统并非需要从零补安全 +- 可直接抽象这些已验证逻辑,作为统一执行器的来源 + +## 3.4 登录态瘦身思路正确 + +JWT 只存最小字段、详细身份通过接口返回,这一点对后续权限扩展非常重要,应保持不变。 + +--- + +## 4. 当前关键问题与风险 + +## 4.1 最大问题:`data_scope` 已建模,但没有平台级统一执行 + +这是整个权限架构当前最大的结构性缺口。 + +现象: + +- `roles.data_scope`、`role_permissions.data_scope` 已存在 +- 但 `PermissionServiceImpl` 不返回 scope +- 业务层查询时拿不到统一 scope 解析结果 +- 各业务模块只能自己写过滤逻辑 + +后果: + +- 同一权限点在不同模块中可能出现不同数据边界 +- 新模块开发时容易漏加范围过滤 +- 审计和排查越权时需要逐模块人工追代码 +- 无法形成统一测试基线 + +## 4.2 第二大问题:管理能力仍部分依赖角色硬编码 + +典型例子: + +- `documentServiceImpl.py` +- `rbacAdminServiceImpl.py` +- `ragDatasetServiceImpl.py` + +这些实现大量依赖: + +- `role_key in ('super_admin', 'provincial_admin')` +- `role_key in ('super_admin', 'provincial_admin', 'admin')` + +风险: + +- 后续若新增“某领域管理员”或更细角色,代码要到处改 +- 角色含义和权限含义耦合 +- 难以实现真正的“显式授权优先” + +## 4.3 菜单权限仍存在 fallback 路径 + +这会带来两个风险: + +1. 数据库配置与前端实际展示不完全一致 +2. “菜单看得见/看不见”与“数据库已授权/未授权”之间可能出现认知偏差 + +虽然 fallback 是合理的迁移兼容机制,但不应长期存在于最终架构中。 + +## 4.4 数据范围规则语义未彻底统一 + +当前系统里至少并存以下几种语义: + +- `ALL / DEPT / SELF` +- `is_global / can_manage / is_super_admin` +- `area` +- `created_by` +- `owner` +- `public` +- `task_member` + +这些语义没有错,但缺少统一分类: + +- 哪些属于通用 scope +- 哪些属于模块级关系规则 +- 哪些属于资源属性规则 + +这会使平台边界越来越模糊。 + +## 4.5 legacy 模块治理程度不一致 + +评查点、规则、旧库桥接模块仍带有比较重的历史包袱,典型风险包括: + +- 仍依赖旧库结构 +- 通过参数而不是统一 user context 控制 area +- 控制器层做了 permission 校验,但服务层范围收口不足 + +这种模块最容易成为越权和维护复杂度的来源。 + +## 4.6 缺少统一的权限可观测性 + +当前系统基本没有形成统一的: + +- 权限命中日志 +- data_scope 解析日志 +- 被拒绝原因模型 +- 权限矩阵巡检工具 + +结果是: + +- 越权排查慢 +- 权限配置错误定位难 +- 测试覆盖很难自动化 + +--- + +## 5. 目标架构设计 + +## 5.1 总体原则 + +目标架构不是“把所有访问控制都压成一个万能函数”,而是分三层: + +1. **功能权限层** + - 判断用户能不能调用某个动作 +2. **通用数据范围层** + - 判断该动作默认可以看到哪些数据 +3. **模块关系规则层** + - 处理 `public`、`task_member`、`owner`、`state` 这类非通用访问规则 + +最终形成: + +- RBAC 负责“能不能做” +- Scope Resolver 负责“默认能看多少” +- Module Policy 负责“该模块的特殊访问规则” + +## 5.2 建议新增的核心组件 + +建议在后端新增一组统一能力: + +### 5.2.1 `PermissionDecisionService` + +职责: + +- 输入:`user_id + permission_key` +- 输出:完整授权决策对象,而不是简单布尔值 + +建议返回: + +- `allowed` +- `grant_source_roles` +- `grant_type` +- `effective_data_scope` +- `condition_filter` +- `is_super_admin` + +### 5.2.2 `DataScopeResolver` + +职责: + +- 解析用户当前对某权限点的最终数据范围 + +规则建议: + +1. 汇总用户所有命中角色 +2. 处理 `DENY` 优先 +3. 若权限点级 `data_scope` 存在,优先使用权限点级规则 +4. 若不存在,回退到角色默认 `roles.data_scope` +5. 多角色命中时,对 `GRANT` 结果按最大可见范围求并集 + +建议统一优先级: + +- `ALL > DEPT > SELF` + +`GROUP` 当前仅保留兼容,不建议作为第一阶段重点。 + +### 5.2.3 `ScopeContextProvider` + +职责: + +- 统一加载当前用户上下文 + +建议提供: + +- `user_id` +- `area` +- `roles` +- `is_super_admin` +- `is_global_admin` +- `is_area_admin` + +注意:这里的 `is_global_admin/is_area_admin` 只是兼容派生属性,不能替代显式权限本身。 + +### 5.2.4 `QueryScopeBuilder` + +职责: + +- 把 scope 结果编译成 SQLAlchemy/SQL 过滤条件 + +建议能力: + +- `apply_area_scope(table.region)` +- `apply_creator_scope(table.created_by)` +- `apply_owner_scope(table.owner_id)` +- `apply_union_scope(...)` +- `apply_public_fallback(...)` + +这样业务模块不再自己拼接“同样一套 if/else”。 + +### 5.2.5 `ModulePolicy` + +职责: + +- 承接模块级特殊规则 + +例如: + +- `CrossReviewPolicy`:任务成员可见 +- `RagDatasetPolicy`:地区可见 + 公开资源可见 +- `RuleConfigPolicy`:配置域权限,不强制 area + +这一步非常关键,因为它避免把所有特殊访问规则硬塞进通用 data_scope。 + +--- + +## 6. 分层改造方案 + +## 6.1 认证层改造 + +### 保留 + +- JWT 只存最小身份字段 +- 完整角色/权限通过 `/auth/me` 和登录响应返回 + +### 优化项 + +1. 增加统一身份上下文对象 +2. 登录/鉴权后将 `user_id/area/user_role/roles` 统一注入请求上下文 +3. 补充权限版本号或 identity revision 字段,便于缓存失效 +4. 为 `/auth/me` 增加可观测字段: + - `roles` + - `permissions` + - `primary_role` + - `area` + +### 不建议做的事 + +- 不建议把全部权限点重新塞回 JWT +- 不建议让前端自己根据角色推导数据范围 + +## 6.2 RBAC 模型层改造 + +### 保留 + +- 现有 7 张核心权限表 + +### 优化项 + +1. 明确 `DEPT` 在项目中的正式定义就是“同地区” +2. 明确 `GROUP` 仅兼容保留,除非有真实业务场景再启用 +3. 为 `permissions` 增加更强的治理约束: + - 命名规范 + - route 绑定规范 + - 是否参与 data_scope 控制 +4. 为 `role_permissions` 制定使用规则: + - 默认只用 `GRANT` + - `DENY` 只用于少数高风险覆盖场景 + - `data_scope` 必须显式说明是否覆盖角色默认范围 + +### 建议新增字段 + +如后续需要,可增量补充: + +- `permissions.scope_strategy` + - `none` + - `area` + - `creator` + - `owner` + - `module_policy` +- `permissions.resource_code` + - 标记该权限点对应的数据资源模型 + +这不是第一阶段必须项,但非常利于后续平台化。 + +## 6.3 权限决策层改造 + +建议把当前 `PermissionServiceImpl` 从: + +- `CheckPermission(UserId, PermissionKey) -> bool` + +扩展为: + +- `GetPermissionDecision(UserId, PermissionKey) -> PermissionDecision` + +旧布尔接口保留,作为新决策接口的简化包装。 + +建议新对象至少包括: + +- `allowed` +- `effective_scope` +- `granted_by_roles` +- `denied_by_roles` +- `condition_filter` + +这样业务代码就不再需要: + +- 先查 permission +- 再自己查角色 +- 再自己推导 scope + +## 6.4 数据范围执行层改造 + +这是本次改造优先级最高的部分。 + +### 第一步:沉淀统一 scope 语义 + +统一三类通用 scope: + +- `ALL` +- `DEPT` +- `SELF` + +统一基础含义: + +- `ALL`:不过滤地区/用户 +- `DEPT`:按 `业务表地区字段 = 当前用户.area` +- `SELF`:按 `created_by / owner_id / uploaded_by = 当前用户.id` + +### 第二步:沉淀统一查询构建器 + +把文档、公文、统计中已经重复出现的模式抽象出来,避免每个模块重复写: + +- `if is_global` +- `if can_manage` +- `else current_user` + +### 第三步:支持“通用 scope + 模块 policy”组合 + +例如: + +- 文档:scope 直接决定 +- RAG:scope + `is_public` +- 交叉评查:scope 不是主轴,改由 task-member policy 主导 + +## 6.5 前端权限消费层改造 + +目标是让前端变成“消费后端权限结果”,而不是“自己做权限解释器”。 + +### 改造目标 + +1. 前端菜单只消费后端 `/rbac/user/routes` +2. 前端不再长期依赖静态 fallback menu +3. `permission_map` 只作为缓存,不作为真相源 +4. 页面级和按钮级权限统一从同一权限模型读取 + +### 渐进策略 + +1. 先补齐数据库路由集合 +2. 再让后端 `GetCurrentUserRoutes` 永远走数据库分支 +3. 最后删除前后端 fallback 蓝图和静态菜单 + +## 6.6 管理后台配置层改造 + +当前 RBAC 管理模块已经具备较强基础,但应继续完善为“可运营权限中心”。 + +建议新增或加强: + +1. 角色数据范围可视化 +2. 权限点是否覆盖角色默认范围的展示 +3. 某角色最终可见菜单、权限点、scope 的预览 +4. 某用户最终权限展开页 +5. 权限冲突检测 +6. 未绑定 route 的 permission 提示 +7. 已配置但代码未消费的 permission 提示 + +--- + +## 7. 重点模块改造建议 + +## 7.1 文档模块 + +当前状态: + +- 数据范围实现较成熟 +- 逻辑已具备抽象价值 + +建议: + +1. 把 `_getCurrentUserContext` 提升为公共能力 +2. 把 `_buildDocumentScopeFilters` 抽到统一 `QueryScopeBuilder` +3. 文档列表、详情、历史版本、删除、评查结果全部统一走同一 scope 解析逻辑 +4. 把 `created_by`、`region`、交叉评查任务访问拆成: + - 通用文档 scope + - 跨任务关系访问补充 + +目标: + +- 文档模块作为第一批标准化样板模块 + +## 7.2 公文模块 + +当前状态: + +- 与文档模块类似,也已有地区/用户过滤实现 + +建议: + +1. 复用文档统一 scope builder +2. 不再保留模块私有版本的同构过滤逻辑 +3. 把差异保留在字段映射层,而不是权限判断层 + +## 7.3 统计模块 + +当前状态: + +- 已有 area/user/document 维度过滤 +- 对数据口径一致性要求高 + +建议: + +1. 明确“统计可见范围必须不大于源数据可见范围” +2. 所有统计查询先通过同一 scope resolver 得到可见边界 +3. 再在统计 SQL 中应用该边界 +4. 对 overview/trends/by-users/by-areas/details 建立统一基线测试 + +## 7.4 RAG 模块 + +当前状态: + +- 使用了 `UserArea + UserRole + is_public` +- 具备自己的可见性模型 + +建议: + +1. 把 RAG 归类为“area + public mixed policy” +2. 通用层负责给出: + - 当前用户是否全省 + - 当前用户地区 + - 当前 permission 的基础 scope +3. RAG 模块 policy 再补: + - `is_public = TRUE` 时的跨区可见 + - `'省级'`、空地区默认资源的可见规则 +4. 后续将“是否可管理知识库”从角色硬编码逐步迁移到显式 permission 决策 + +目标: + +- RAG 继续保留业务特性,但不再分散复制 `UserRole` 判断 + +## 7.5 交叉评查模块 + +当前状态: + +- 主要按任务成员关系控制 +- 属于关系型访问控制,不应生硬纳入 `ALL/DEPT/SELF` + +建议: + +1. 将其定义为独立 `CrossReviewPolicy` +2. `permission_key` 只决定用户能否进入该功能域 +3. 具体资源访问由: + - `task_member` + - `assigner` + - `principal` + - `proposal_owner` + 等关系决定 +4. 需要补充与文档主访问边界的关系定义: + - 交叉评查任务中的文档是否可突破原始文档 `SELF` + - 若可突破,突破边界必须仅限任务上下文 + +目标: + +- 把交叉评查明确定位为“关系型权限域”,而不是强行纳入普通地区 scope + +## 7.6 评查点 / 规则 / legacy 模块 + +当前状态: + +- 部分实现仍偏 legacy +- 与旧库桥接较多 +- 权限收口不够彻底 + +建议: + +1. 先做权限与数据流梳理 +2. 梳理所有入口: + - 列表 + - 详情 + - 新增 + - 更新 + - 删除 + - 导入导出 +3. 对每个接口明确: + - 只需要功能权限 + - 还是还要 data_scope +4. 对旧库访问统一增加 user context 入参约束 +5. 禁止仅依赖外部传入 `area` 参数作为最终数据边界 + +--- + +## 8. 管理权限治理方案 + +当前系统里“能否管理”仍较多通过角色推导: + +- `provincial_admin` +- `admin` +- `super_admin` + +建议分两阶段治理。 + +## 8.1 第一阶段 + +保留现有角色硬编码兼容判断,但所有新功能必须同时引入显式 permission。 + +即: + +- 角色硬编码只作为兼容 guard +- 新方案以 permission 为主 + +## 8.2 第二阶段 + +将“管理能力”全面迁移为权限点驱动: + +- `system:settings:manage` +- `rbac:role_permissions:write` +- `rag:dataset:manage` +- `rules:config:manage` + +最终目标: + +- “你是不是 admin”不再直接等于“你能不能管理 X” +- 改成“你是否拥有该领域管理权限” + +--- + +## 9. 硬编码角色改造专项分析 + +这部分必须单独拿出来,因为它不是“代码风格问题”,而是当前权限架构演进时最大的联动风险来源之一。 + +当前项目中的角色硬编码,至少存在 4 种不同形态: + +1. **服务层派生上下文硬编码** +2. **服务层直接放行业务动作硬编码** +3. **前端展示/编辑能力硬编码** +4. **前端 guard / fallback / role mapping 硬编码** + +如果不把这 4 类拆开治理,只改其中一层,会出现“后端收敛了,前端仍按旧角色逻辑放大/缩小能力”的不一致问题。 + +## 9.1 已确认的后端硬编码角色热点 + +### A. 通用上下文派生型 + +这些地方不是直接判断某个 permission,而是先把角色硬编码推导为: + +- `is_global` +- `can_manage` +- `is_super_admin` +- `bypass_area` + +代表位置: + +- `documentServiceImpl.py` +- `govdocServiceImpl.py` +- `usageStatsServiceImpl.py` +- `contractTemplateServiceImpl.py` +- `rbacAdminServiceImpl.py` +- `homeServiceImpl.py` + +典型模式: + +- `role_key IN ('super_admin', 'provincial_admin') => is_global` +- `role_key IN ('super_admin', 'provincial_admin', 'admin') => can_manage` +- `role_key = 'super_admin' => is_super_admin / bypass_area` + +问题本质: + +- 这是把“角色名称”直接当成“能力模型” +- 后续新增任意领域型管理员时,所有上下文派生代码都得重复修改 + +### B. RAG 管理动作直判型 + +代表位置: + +- `ragDatasetServiceImpl.py` +- `ragChatServiceImpl.py` + +典型模式: + +- `UserRole not in ("provincial_admin", "admin", "super_admin")` +- `user_role == "provincial_admin"` +- `UserRole == "admin"` + +问题本质: + +- 控制器层已经有 permission 校验 +- 服务层又追加了一层角色白名单校验 +- 结果是“permission 允许但角色名不在白名单”时仍会被拒绝 + +这会直接阻断未来“非 admin 角色但拥有 RAG 管理权限”的扩展能力。 + +### C. 首页和特殊入口绕过型 + +代表位置: + +- `homeServiceImpl.py` + +典型模式: + +- `super_admin` 直接 `bypass_area` + +问题本质: + +- 首页入口可见性实际上也属于权限结果的一部分 +- 不应长期依赖单角色绕过 + +### D. 业务规则附带角色语义型 + +代表位置: + +- `contractTemplateServiceImpl.py` + +典型模式: + +- 只有“地区管理员”可上传模板 +- 但实现仍由 `can_manage/is_global/is_area_admin` 这类角色派生结果承担 + +问题本质: + +- 这是典型“业务域管理能力” +- 应转换成合同模板域显式权限,而不是继续绑死在 admin/provincial_admin 上 + +## 9.2 已确认的前端硬编码角色热点 + +### A. RAG 知识库管理 UI + +代表位置: + +- `components/dify-dataset-manager/index.tsx` +- `components/dify-dataset-manager/area-dataset-config.tsx` +- `hooks/use-area-dataset-config.ts` + +典型模式: + +- `provincial_admin` 可编辑全部 +- `super_admin` 可编辑全部 +- `admin` 仅可编辑本地区 +- `canManageDataset = roleCanManageDataset || permissionBased` + +问题本质: + +- 当前前端同时混用了“角色白名单”和“权限点判断” +- 即使后端后续去角色化,前端 UI 仍会保留旧角色语义 + +### B. 路由 fallback 与角色映射 + +代表位置: + +- `legal-platform-frontend/lib/auth/user-routes.ts` +- `legal-platform-frontend/lib/api/legacy/auth/user-routes.ts` + +典型模式: + +- `provincial_admin -> admin` +- `super_admin -> admin` +- `developer -> admin` + +问题本质: + +- 这是典型迁移兼容代码 +- 如果长期保留,会把真实角色体系压扁成前端的少数桶位 + +### C. 页面 guard + +代表位置: + +- `legal-platform-frontend/lib/auth/guard.ts` + +典型模式: + +- `developer` 或 `provincial_admin` 才能进入某些设置页 + +问题本质: + +- 这是明显的角色名授权 +- 应改成 route/permission 授权,不应继续靠用户主角色字符串 + +### D. 特殊入口可见性辅助逻辑 + +代表位置: + +- `legal-platform-frontend/lib/auth/cross-checking-access.ts` + +典型模式: + +- `provincial_admin` 自动加入“省局” area 候选 + +问题本质: + +- 这类逻辑属于“前端对后端权限模型的二次解释” +- 如果保留过多,会持续制造边界分叉 + +## 9.3 为什么这些硬编码不能直接一刀删除 + +因为它们承担的职责并不相同。 + +### 第一类:可立即替换 + +- 服务层“动作白名单式”角色判断 +- 例如 RAG 管理接口中的 `UserRole not in (...)` + +这类逻辑可以优先替换为: + +- 控制器层 permission 决策 +- 服务层 scope/policy 决策 + +### 第二类:需先抽象再替换 + +- `is_global` +- `can_manage` +- `is_super_admin` +- `is_area_admin` + +这些并不是最终目标,但当前很多模块都依赖它们构建 SQL 过滤条件。 + +因此正确做法不是直接删掉,而是先把它们提升为统一上下文派生结果: + +- `ScopeContextProvider` +- `AdminCapabilityResolver` + +之后再逐步把其实现从“角色硬编码”替换为“权限/能力决策”。 + +### 第三类:应保留为兼容层,但必须收缩边界 + +- 前端 role mapping +- fallback menu +- 部分特殊入口兼容逻辑 + +这些逻辑在迁移期可以保留,但必须: + +1. 标记为兼容层 +2. 只允许出现在前端适配层 +3. 不允许继续扩散到服务层和新功能 + +## 9.4 推荐改造策略 + +### 第一阶段:建立统一能力派生层 + +新增统一能力对象,例如: + +- `is_super_admin` +- `has_global_scope` +- `can_manage_area_resources` +- `can_manage_rbac` +- `can_manage_rag_dataset` +- `can_view_usage_stats` + +这些字段应由: + +- `roles` +- `permissions` +- `data_scope` +- 可选 `condition_filter` + +综合解析,而不是仅看 `role_key`。 + +### 第二阶段:逐模块替换服务层角色直判 + +优先顺序建议: + +1. `ragDatasetServiceImpl.py` +2. `rbacAdminServiceImpl.py` +3. `homeServiceImpl.py` +4. `contractTemplateServiceImpl.py` +5. 文档/公文/统计共用上下文派生 + +### 第三阶段:前端去角色化 + +前端改造原则: + +1. UI 可见性优先使用 permission +2. 可编辑性使用后端返回的 capability 或 policy 结果 +3. `user_role` 只保留展示和极少数兼容用途 +4. 不再让前端自行推导“谁是管理员” + +## 9.5 不同硬编码的替换目标 + +建议按下表理解: + +- `super_admin` 全局绕过 + 替换为:`is_super_admin` capability,仅在极少数系统级接口保留 +- `provincial_admin => is_global` + 替换为:`effective_scope == ALL` +- `admin => can_manage` + 替换为:领域型 manage permission + `effective_scope == DEPT` +- `common => self only` + 替换为:`effective_scope == SELF` +- `provincial_admin/super_admin/admin` 共同白名单 + 替换为:某领域 `manage/create/update/delete` permission + +这一步很关键,因为它把“角色名语义”拆成了“能力语义”。 + +--- + +## 10. 受影响接口与联动面分析 + +角色硬编码改造不会只影响几个 service,而会沿着“认证 -> 控制器 -> 服务层 -> 前端 UI -> 菜单/guard”整条链路传播。 + +因此必须提前识别影响面。 + +## 10.1 高影响后端接口组 + +### A. 文档模块 + +受影响接口包括但不限于: + +- `POST /api/upload` +- `GET /api/documents/list` +- `GET /api/documents/status` +- `GET /api/documents/{DocumentId}` +- `GET /api/v3/review-points/{DocumentId}` +- `PATCH /api/v3/review-points/{ReviewPointResultId}/audit` +- `PATCH /api/v3/documents/{DocumentId}/confirm` +- `POST /api/documents/{DocumentId}/attachments` +- `PUT /api/documents/{DocumentId}` +- `DELETE /api/documents/{DocumentId}` + +影响原因: + +- 这些接口都依赖文档服务里的用户上下文派生和范围过滤 +- 一旦 `is_global/can_manage` 计算方式改变,所有文档读写边界都会联动变化 + +### B. 公文模块 + +受影响接口包括但不限于: + +- `POST /api/govdoc/documents` +- `GET /api/govdoc/documents` +- `GET /api/govdoc/documents/{documentId}` +- `PATCH /api/govdoc/documents/{documentId}` +- `DELETE /api/govdoc/documents/{documentId}` +- `POST /api/govdoc/runs` +- `GET /api/govdoc/runs/{runId}` +- `GET /api/govdoc/runs/{runId}/result` +- `GET /api/govdoc/runs/{runId}/findings` +- `GET /api/govdoc/runs/{runId}/entities` +- `GET /api/govdoc/runs/{runId}/structure` +- `GET /api/govdoc/runs/{runId}/outline` +- `GET /api/govdoc/runs/{runId}/paragraphs` +- `GET /api/govdoc/runs/{runId}/report/html` +- `GET /api/govdoc/runs/{runId}/report/docx` +- `GET /api/govdoc/documents/{documentId}/original` + +影响原因: + +- 公文模块沿用了与文档模块相似的范围控制模型 +- run/result/report 等接口如果没有统一回挂到 document scope,容易出现“知道 runId 就能读结果”的风险 + +### C. 统计模块 + +受影响接口包括: + +- `GET /api/v3/usage-stats/overview` +- `GET /api/v3/usage-stats/trends` +- `GET /api/v3/usage-stats/by-users` +- `GET /api/v3/usage-stats/by-departments` +- `GET /api/v3/usage-stats/by-areas` +- `GET /api/v3/usage-stats/details` + +影响原因: + +- 当前统计服务对“只有管理员可看”存在上下文派生逻辑 +- 如果未来改成 permission 决策,统计域会是最先受影响的读接口集合之一 + +### D. RAG 模块 + +受影响接口包括: + +- `GET /api/v3/rag/apps` +- `GET /api/v3/rag/apps/default` +- `GET /api/v3/rag/datasets/my` +- `GET /api/v3/rag/datasets/admin` +- `POST /api/v3/rag/datasets/admin` +- `PUT /api/v3/rag/datasets/admin/{DatasetId}` +- `DELETE /api/v3/rag/datasets/admin/{DatasetId}` +- `GET /api/v3/rag/datasets/{DatasetId}` +- `PATCH /api/v3/rag/datasets/{DatasetId}` +- 以及数据集文档、分段、检索测试等一系列 `/datasets/{DatasetId}/...` 接口 +- `POST /api/v3/rag/chat/messages` +- 会话相关接口 + +影响原因: + +- 控制器层已经按 permission 放行 +- 服务层仍按 `UserRole` 二次限流 +- 这是最典型的“双轨权限模型冲突”区域 + +### E. RBAC 管理模块 + +受影响接口包括: + +- `GET /api/v3/rbac/roles` +- `POST /api/v3/rbac/roles` +- `PUT /api/v3/rbac/roles/{RoleId}` +- `DELETE /api/v3/rbac/roles/{RoleId}` +- `GET /api/v3/rbac/users` +- `GET /api/admin/users/organizations/tree` +- `GET /api/v3/rbac/roles/{RoleId}/users` +- `POST /api/v3/rbac/users/{UserId}/roles` +- `DELETE /api/v3/rbac/users/{UserId}/roles/{RoleId}` +- `GET /api/v3/rbac/users/{UserId}/roles` +- `GET /api/v3/routes` +- `GET/PUT /api/rbac/roles/{RoleId}/routes` +- `GET/POST /api/v3/rbac/role-permissions` +- `POST /api/v3/rbac/roles/{RoleId}/access` +- `GET /api/v3/routes/{RouteId}/permissions` + +影响原因: + +- 当前先 `_assertManagePermission`,再 `_assertPermission` +- 其中 `_assertManagePermission` 仍依赖角色派生 `can_manage` +- 未来若不调整,会出现“拥有 RBAC 写权限但不是 admin 角色”仍被拒绝的问题 + +### F. 首页入口模块 + +受影响接口: + +- `GET /api/home/entry-modules` + +影响原因: + +- 首页可见入口当前含 `super_admin` 的 area bypass 语义 +- 改动后会影响首页模块展示和交叉评查入口暴露范围 + +### G. 合同模板模块 + +受影响接口包括: + +- `GET /api/v3/contract-templates/categories` +- `GET /api/v3/contract-templates` +- `POST /api/v3/contract-templates` +- `GET /api/v3/contract-templates/search` +- `GET /api/v3/contract-templates/{TemplateId}` +- `DELETE /api/v3/contract-templates/{TemplateId}` + +影响原因: + +- 业务文案和服务层逻辑都在强调“地区管理员才能上传” +- 这类业务域权限必须显式建模,否则角色去硬编码后会发生语义漂移 + +## 10.2 中影响后端接口组 + +### A. 交叉评查控制器 + +控制器层目前主要通过 permission 做功能准入,再由服务层按 task-member 关系控权。 + +受影响点: + +- 若未来新增“交叉评查协调员”等角色 +- 控制器层 permission、服务层关系权限、前端入口策略三者都要同步 + +### B. 评查点 / 评查点分组 / 规则配置 + +这些模块当前更多是 permission 驱动,但仍存在: + +- OR 型 permission 校验 +- 路由 fallback +- 某些 legacy 行为仍按旧角色认知理解的隐患 + +这类模块短期不一定要先改,但必须纳入联调验证范围。 + +## 10.3 前端受影响面 + +### A. 菜单与路由 + +受影响位置: + +- `Sidebar.tsx` +- `user-routes.ts` +- `check-route-permission.ts` +- route fallback mapping + +改造后可能出现的联动: + +- 某些角色映射桶失效 +- fallback 菜单与真实 route-permission 不一致 +- 页面可见但接口被拒绝,或页面不可见但后端已授权 + +### B. 知识库管理相关页面 + +受影响位置: + +- `components/dify-dataset-manager/*` +- `hooks/use-area-dataset-config.ts` + +改造后必须改为: + +- 以后端返回的 permission/capability 作为真相源 +- 不能继续由前端自己判定“你是不是省级管理员” + +### C. 页面 guard 和 session typing + +受影响位置: + +- `lib/auth/guard.ts` +- `lib/auth/session-user.ts` +- `lib/auth/jwt.ts` + +改造重点: + +- `user_role` 类型定义不能继续隐式代表完整能力模型 +- guard 逻辑要逐步转成 permission 驱动 + +## 10.4 最容易遗漏的影响点 + +有 4 类点最容易在改造时被漏掉: + +1. **服务层动作二次校验** + 典型如 RAG:控制器已查 permission,但服务层又按角色拒绝 +2. **只读接口的资源详情/下载接口** + 典型如公文的 report/docx/original 下载 +3. **首页和入口模块可见性** + 这类功能通常不在权限改造 checklist 里,但实际也是访问控制 +4. **前端本地缓存的 permission_map / user_role** + 后端改完,前端缓存解释逻辑若不改,会出现脏权限体验 + +## 10.5 推荐联动改造顺序 + +为了降低波及风险,建议按下面顺序推进: + +1. 先改后端统一决策层,不动前端行为 +2. 接入 RAG 服务层,消除“permission 通过但角色名拒绝”的双轨冲突 +3. 接入文档/公文/统计通用 scope +4. 接入 RBAC 管理域 `_assertManagePermission` +5. 接入首页入口与合同模板这类特殊管理域 +6. 最后清理前端角色映射、UI 角色判断和 guard + +这样可以避免一开始就同时触碰: + +- 数据边界 +- 功能权限 +- 菜单展示 +- 页面 guard + +导致联调失控。 + +## 10.6 额外建议:建立“角色去硬编码回归清单” + +建议在实际实施时,单独维护一份回归清单,至少覆盖: + +- 某用户有 permission 但主角色不是 `admin/provincial_admin` 时,是否仍可正确访问 +- 某用户被赋予新领域管理角色后,是否无需改代码即可生效 +- 某用户菜单、按钮、接口、详情、下载、导出是否边界一致 +- 前端是否仍存在基于 `user_role` 的旧判断把能力放大或缩小 + +--- + +## 11. 数据库迁移方案 + +## 9.1 第一阶段可不改表,仅改执行层 + +这是最推荐的路径。 + +先做: + +- 新增决策服务 +- 新增 scope resolver +- 新增 query builder +- 业务模块接入 + +在这一步,现有表足够使用。 + +## 9.2 第二阶段再做增强字段 + +若后续希望更强平台能力,再增量考虑: + +- `permissions.scope_strategy` +- `permissions.resource_code` +- `permissions.policy_code` +- `role_permissions.scope_override_reason` + +这些字段用于治理和解释,不是首批必要项。 + +## 9.3 数据迁移原则 + +1. 不删除旧字段 +2. 不重命名核心字段 +3. 先兼容运行,再切换调用方 +4. 所有新增字段默认 nullable,避免一次性大面积回填风险 + +--- + +## 12. 渐进式实施路径 + +## 10.1 Phase 1:统一决策能力 + +目标: + +- 先把平台底座搭好 + +交付: + +- `PermissionDecisionService` +- `DataScopeResolver` +- `ScopeContextProvider` +- `QueryScopeBuilder` + +收益: + +- 新老模块都可开始接入 + +## 10.2 Phase 2:样板模块接入 + +建议顺序: + +1. 文档 +2. 公文 +3. 统计 + +原因: + +- 这三类模块已经有成熟范围逻辑 +- 抽象收益最高 +- 改造风险相对可控 + +## 10.3 Phase 3:菜单权限彻底数据库化 + +目标: + +- 删除路由 fallback +- 删除静态菜单兼容 + +前提: + +- 数据库路由集合补齐 +- 权限点-route 映射完整 + +## 10.4 Phase 4:特殊模块治理 + +建议顺序: + +1. RAG +2. 交叉评查 +3. 评查点/规则/legacy 桥接模块 + +目标: + +- 形成“通用 scope + 模块 policy”模式 + +## 10.5 Phase 5:治理与可观测性 + +补齐: + +- 权限命中日志 +- 越权拒绝原因 +- 权限矩阵导出 +- 自动化巡检任务 + +--- + +## 13. 测试与验收方案 + +## 11.1 必做测试矩阵 + +每个核心权限点至少覆盖以下用户组合: + +- `super_admin` +- `provincial_admin` +- `admin` +- `common` +- 多角色用户 +- 无 area 用户 +- 被禁用用户 + +每个资源至少覆盖以下数据组合: + +- 同地区数据 +- 异地区数据 +- 自己创建数据 +- 他人创建数据 +- 公共数据 +- 关系型共享数据 + +## 11.2 必做接口验收 + +至少覆盖: + +- 登录与 `/auth/me` +- 菜单获取 +- 文档列表/详情/历史/删除 +- 公文列表/详情 +- 统计总览/趋势/明细 +- RAG 应用/知识库 +- 交叉评查任务/提案/投票 +- RBAC 管理台 + +## 11.3 必做回归断言 + +1. 有 permission 但无 scope 不应越权看全量 +2. 菜单可见不等于接口可调用,接口仍需后端鉴权 +3. 仅知道资源 ID 不应绕过数据范围校验 +4. 跨模块聚合查询不能扩大原始资源可见范围 +5. `DENY` 必须稳定优先于 `GRANT` + +## 11.4 建议增加自动化 + +建议建立: + +- permission fixture +- user-role fixture +- region fixture +- resource ownership fixture + +让权限测试不再依赖手工造数。 + +--- + +## 14. 风险与回滚方案 + +## 12.1 主要风险 + +1. 抽象过度,导致特殊模块被错误套入通用逻辑 +2. 改造过程中菜单和接口权限短期不一致 +3. scope 规则切换后产生历史行为变化 +4. 多角色并集规则理解不一致,导致边界扩大或缩小 + +## 12.2 风险控制 + +1. 先在样板模块试点,不直接全量切 +2. 新旧逻辑并行一段时间,输出对比日志 +3. 为关键接口加“旧逻辑 vs 新逻辑” shadow compare +4. 管理台提供用户最终权限预览 + +## 12.3 回滚策略 + +1. 决策服务接入采用开关控制 +2. 模块级按 feature flag 切换 +3. 菜单 fallback 在数据库路由完全稳定前不删除 +4. 新增字段不影响旧逻辑运行 + +--- + +## 15. 推荐实施排期 + +## 第 1 周 + +- 完成权限现状盘点和权限点清单冻结 +- 设计 `PermissionDecisionService` 和 `DataScopeResolver` +- 输出统一 scope 规则说明 + +## 第 2 周 + +- 落地统一决策与查询构建器 +- 接入文档模块 +- 建立第一批自动化权限测试 + +## 第 3 周 + +- 接入公文与统计模块 +- 补齐管理台权限预览能力 +- 开始新旧逻辑对比日志 + +## 第 4 周 + +- 接入 RAG +- 梳理交叉评查 policy +- 补齐数据库路由集合 + +## 第 5 周 + +- 切换菜单只走数据库路由 +- 处理 legacy 规则/评查点模块 +- 清理前端 fallback + +## 第 6 周 + +- 全量回归 +- 安全测试 +- 观察期与灰度发布 + +--- + +## 16. 最终推荐方案 + +如果只给出一条最重要的改造主线,我的建议是: + +**不要推翻现有 RBAC 结构,优先补齐统一的数据范围执行平台。** + +更具体地说,正确的改造顺序应该是: + +1. 先保留现有 `sso_users/roles/permissions/routes` 模型 +2. 把 `data_scope` 从“设计字段”变成“统一可执行能力” +3. 把文档、公文、统计里的重复数据范围逻辑抽成平台底座 +4. 把 RAG、交叉评查这类特殊模块归入独立 policy,而不是粗暴并表 +5. 最后再清理菜单 fallback 和角色硬编码 + +这样做的收益最大: + +- 改造风险最低 +- 与现有代码最兼容 +- 能最快提升一致性和安全性 +- 能为后续模块扩展提供稳定底座 + +--- + +## 17. 建议立项清单 + +建议把后续工作拆成 8 个明确任务: + +1. 建立权限决策对象与统一接口 +2. 实现 data_scope 解析器 +3. 实现通用查询 scope builder +4. 文档模块接入统一 scope +5. 公文与统计模块接入统一 scope +6. RAG / 交叉评查 policy 化 +7. 数据库路由补齐并移除 fallback +8. 建立权限可观测性与自动化测试矩阵 + +这 8 项完成后,当前项目的角色权限架构就会从“可用但分散”,升级为“稳定、统一、可治理、可演进”的平台化体系。 diff --git a/docs/权限与地区隔离/权限测试验收与回归用例清单.md b/docs/权限与地区隔离/权限测试验收与回归用例清单.md new file mode 100644 index 0000000..e47e075 --- /dev/null +++ b/docs/权限与地区隔离/权限测试验收与回归用例清单.md @@ -0,0 +1,408 @@ +# 权限测试验收与回归用例清单 + +> 适用范围:权限架构统一改造后的联调、验收、灰度、回归阶段 +> 文档定位:把“角色、权限、地区、模块、接口、菜单、下载、导出”的测试口径一次性拉平。 + +--- + +## 1. 测试目标 + +本轮权限改造的核心验收目标不是“接口能用”,而是以下 6 件事同时成立: + +1. 功能权限正确 +2. 数据范围正确 +3. 多角色合并正确 +4. `DENY` 优先正确 +5. 菜单、页面、按钮、接口边界一致 +6. 详情、下载、导出、删除等派生接口不绕过主资源边界 + +--- + +## 2. 测试范围 + +本次至少覆盖以下模块: + +- 文档 +- 公文 +- 使用统计 +- RAG +- 交叉评查 +- RBAC 管理 +- 合同模板 +- 首页入口与菜单 + +不作为本轮重点但要做冒烟确认: + +- 规则 +- 规则配置 +- 评查点分组 + +--- + +## 3. 测试前置准备 + +## 3.1 角色准备 + +建议准备最少 6 类测试账号: + +1. `super_admin` +2. `provincial_admin` +3. `admin`-A 地区 +4. `admin`-B 地区 +5. `common`-A 地区 +6. `common`-B 地区 + +建议再补 3 类组合账号: + +1. `common + admin` 同地区多角色用户 +2. `admin + DENY 某权限` 用户 +3. 自定义角色用户:不叫 `admin/provincial_admin`,但拥有相同 permission 与 scope + +最后这类用户极关键,用于验证“去角色硬编码后系统是否仍正确工作”。 + +## 3.2 数据准备 + +建议准备以下数据: + +1. A 地区文档 3 份 +2. B 地区文档 3 份 +3. A 地区公文 3 份 +4. B 地区公文 3 份 +5. A 地区合同模板 2 份 +6. B 地区合同模板 2 份 +7. 省级 RAG 数据集 2 个 +8. A 地区 RAG 数据集 2 个 +9. B 地区 RAG 数据集 2 个 +10. 公共 RAG 数据集 1 个 +11. 交叉评查任务至少 2 个,分别覆盖成员 A/B 差异 + +## 3.3 授权准备 + +建议为每类 permission 准备三种授权状态: + +1. `GRANT + ALL` +2. `GRANT + DEPT` +3. `GRANT + SELF` + +同时至少准备一个 `DENY` 用例: + +- 对同一个用户,某权限被更高优先级角色 `DENY` + +--- + +## 4. 验收口径 + +验收统一按三层判断: + +1. `403` + - 说明功能权限被拒绝 +2. `200 + 空数据` + - 说明功能权限有,但数据范围无命中 +3. `200 + 仅返回应有数据` + - 说明 scope 正确 + +不能把 `空列表` 和 `403` 混为一谈。 + +--- + +## 5. 角色矩阵用例 + +## 5.1 全局范围用户 + +用例编号:`ROLE-ALL-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `ROLE-ALL-01` | `super_admin` 查询文档列表 | 可见全部地区文档 | +| `ROLE-ALL-02` | `provincial_admin` 查询使用统计地区汇总 | 可见全部地区 | +| `ROLE-ALL-03` | `provincial_admin` 管理 RAG 数据集 | 可管理全部地区或省级数据集 | +| `ROLE-ALL-04` | 全局用户下载任意本系统有权限公文报告 | 成功 | + +## 5.2 地区范围用户 + +用例编号:`ROLE-DEPT-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `ROLE-DEPT-01` | A 地区 `admin` 查文档列表 | 仅可见 A 地区 | +| `ROLE-DEPT-02` | A 地区 `admin` 指定 `region=B` 查文档 | 拒绝或自动收敛,不能看到 B | +| `ROLE-DEPT-03` | A 地区 `admin` 查 RBAC 用户列表 | 仅可见 A 地区用户 | +| `ROLE-DEPT-04` | A 地区 `admin` 创建 RAG 数据集并指定 B 地区 | 拒绝 | + +## 5.3 自身范围用户 + +用例编号:`ROLE-SELF-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `ROLE-SELF-01` | `common` 查文档列表 | 仅本人上传文档 | +| `ROLE-SELF-02` | `common` 查使用统计明细 | 仅本人登录/上传/评查明细 | +| `ROLE-SELF-03` | `common` 查合同模板详情 | 仅在本人可见范围内成功 | +| `ROLE-SELF-04` | `common` 删除他人文档 | 拒绝 | + +## 5.4 去角色硬编码验证 + +用例编号:`ROLE-DEC-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `ROLE-DEC-01` | 自定义角色用户拥有 `rag:dataset:manage + DEPT` | 应能管理本地区 RAG 数据集,即便角色名不是 `admin` | +| `ROLE-DEC-02` | 自定义角色用户拥有 `contract_template:create:write + DEPT` | 应可创建本地区模板 | +| `ROLE-DEC-03` | 自定义角色用户拥有 `rbac:user_roles:write + DEPT` | 应可管理本地区用户角色 | +| `ROLE-DEC-04` | 角色名是 `admin` 但缺少目标 permission | 应被拒绝,不能因角色名放行 | + +--- + +## 6. 多角色与 DENY 用例 + +用例编号:`MERGE-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `MERGE-01` | 用户同时拥有 `documents:list:read + SELF` 与 `documents:list:read + DEPT` | 取更大范围 `DEPT` | +| `MERGE-02` | 用户同时拥有 `documents:list:read + DEPT` 与 `documents:list:read + ALL` | 取 `ALL` | +| `MERGE-03` | 用户有 `documents:list:read + ALL`,另一角色 `DENY documents:list:read` | 最终拒绝 | +| `MERGE-04` | 用户拥有 `rag:dataset:read + PUBLIC_MIXED`,另一角色 `DENY rag:dataset:update` | 可读不可改 | +| `MERGE-05` | 用户有 `rbac:users:read + DEPT` 和 `rbac:roles:read + NONE` | 用户列表仍限本地区,角色列表正常 | + +--- + +## 7. 文档模块用例 + +用例编号:`DOC-*` + +## 7.1 列表与详情一致性 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `DOC-01` | 列表可见文档点进详情 | 成功 | +| `DOC-02` | 列表不可见文档直接访问详情 ID | 拒绝或 404 | +| `DOC-03` | 列表不可见文档请求状态接口 | 不返回该文档状态 | +| `DOC-04` | 列表不可见文档请求评查点聚合 | 拒绝 | + +## 7.2 写操作一致性 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `DOC-05` | 本人文档追加附件 | 成功 | +| `DOC-06` | 非本人、非本地区文档追加附件 | 拒绝 | +| `DOC-07` | 本地区管理员删除本地区文档 | 成功 | +| `DOC-08` | 本地区管理员删除外地区文档 | 拒绝 | +| `DOC-09` | `SELF` 用户确认他人文档评查结果 | 拒绝 | + +## 7.3 上传边界 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `DOC-10` | `DEPT` 用户上传文档时指定本地区 | 成功 | +| `DOC-11` | `DEPT` 用户上传文档时指定外地区 | 拒绝或强制改写为本地区 | +| `DOC-12` | `ALL` 用户上传并指定任意地区 | 成功 | + +--- + +## 8. 公文模块用例 + +用例编号:`GOV-*` + +## 8.1 文档链路 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `GOV-01` | 可见公文在列表、详情均可访问 | 成功 | +| `GOV-02` | 不可见公文详情直连 | 拒绝或 404 | +| `GOV-03` | 删除可见公文 | 成功 | +| `GOV-04` | 删除不可见公文 | 拒绝 | + +## 8.2 运行结果与报告链路 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `GOV-05` | 对可见公文发起 run | 成功 | +| `GOV-06` | 对不可见公文发起 run | 拒绝 | +| `GOV-07` | 通过 `runId` 查看结果,但所属公文不在 scope | 拒绝 | +| `GOV-08` | 通过 `runId` 下载 docx 报告,但所属公文不在 scope | 拒绝 | +| `GOV-09` | 下载原始公文,但所属公文不在 scope | 拒绝 | + +--- + +## 9. 使用统计模块用例 + +用例编号:`STAT-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `STAT-01` | 全局用户看 `overview` | 全量数据 | +| `STAT-02` | 地区用户看 `overview` | 仅本地区 | +| `STAT-03` | `SELF` 用户看 `overview` | 仅自己相关数据或受限简版 | +| `STAT-04` | 地区用户 `areaScope=user` 指定外地区 | 拒绝或无数据 | +| `STAT-05` | 地区用户 `areaScope=document` 指定外地区 | 拒绝或无数据 | +| `STAT-06` | `by-users` 指定他人 `userId`,但不在当前 scope | 拒绝或无数据 | +| `STAT-07` | `details` 接口查看他人上传明细 | 按 scope 正确收敛 | +| `STAT-08` | `by-areas` 对 `SELF` 用户 | 建议拒绝或返回受限结果 | + +--- + +## 10. RAG 模块用例 + +用例编号:`RAG-*` + +## 10.1 阅读边界 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `RAG-01` | A 地区用户查看 A 地区数据集 | 成功 | +| `RAG-02` | A 地区用户查看 B 地区私有数据集 | 拒绝 | +| `RAG-03` | A 地区用户查看省级数据集 | 成功 | +| `RAG-04` | A 地区用户查看 `is_public=true` 数据集 | 成功 | +| `RAG-05` | 默认应用与应用列表展示边界一致 | 一致 | + +## 10.2 管理边界 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `RAG-06` | 拥有 `rag:dataset:manage + DEPT` 的自定义角色访问 `/datasets/admin` | 成功 | +| `RAG-07` | 无 `manage` 但角色名是 `admin` 的用户访问 `/datasets/admin` | 拒绝 | +| `RAG-08` | `DEPT` 用户创建外地区数据集 | 拒绝 | +| `RAG-09` | `DEPT` 用户修改本地区数据集 | 成功 | +| `RAG-10` | `DEPT` 用户删除外地区数据集 | 拒绝 | +| `RAG-11` | 数据集文档上传、重处理、删除边界与数据集边界一致 | 一致 | + +## 10.3 会话边界 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `RAG-12` | 查看自己会话 | 成功 | +| `RAG-13` | 查看他人会话 | 拒绝 | +| `RAG-14` | 删除他人会话 | 拒绝 | + +--- + +## 11. 交叉评查模块用例 + +用例编号:`CR-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `CR-01` | 任务成员查询自己参与任务 | 成功 | +| `CR-02` | 非成员查询任务 | 拒绝或无数据 | +| `CR-03` | 任务成员查看任务文档 | 成功 | +| `CR-04` | 非成员查看任务文档 | 拒绝 | +| `CR-05` | 非负责角色调用 `can-confirm` | 拒绝或返回不可确认 | +| `CR-06` | 非成员上传任务文档 | 拒绝 | +| `CR-07` | 非成员追加附件 | 拒绝 | +| `CR-08` | 可投票成员投票 | 成功 | +| `CR-09` | 非可投票成员投票 | 拒绝 | +| `CR-10` | 非成员导出提案 | 拒绝 | + +重点: + +- 交叉评查不能按地区矩阵替代成员关系矩阵 +- 这组测试必须围绕“关系模型”设计 + +--- + +## 12. RBAC 管理域用例 + +用例编号:`RBAC-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `RBAC-01` | 地区管理员查看用户列表 | 仅本地区 | +| `RBAC-02` | 地区管理员查询外地区用户角色 | 拒绝或无数据 | +| `RBAC-03` | 地区管理员给本地区用户分配角色 | 成功 | +| `RBAC-04` | 地区管理员给外地区用户分配角色 | 拒绝 | +| `RBAC-05` | 无 `rbac:user_roles:write` 但角色名是 `admin` | 拒绝 | +| `RBAC-06` | 角色权限配置保存后,权限即时生效或在缓存期内按预期生效 | 正确 | +| `RBAC-07` | `DENY` 权限配置后,用户访问对应接口 | 被拒绝 | + +--- + +## 13. 合同模板模块用例 + +用例编号:`TPL-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `TPL-01` | 全局用户查看全部模板 | 成功 | +| `TPL-02` | 地区用户查看模板列表 | 仅本地区 + 公共/省级范围内模板 | +| `TPL-03` | `SELF` 用户查看本人模板 | 仅本人可见 | +| `TPL-04` | 拥有 `contract_template:create:write + DEPT` 的自定义角色上传模板 | 成功 | +| `TPL-05` | 前端角色名不是 `admin` 但拥有权限 | 页面上传入口应可见 | +| `TPL-06` | 无权限但前端强行调创建接口 | 后端拒绝 | +| `TPL-07` | 删除外地区模板 | 拒绝 | + +--- + +## 14. 菜单、页面、按钮一致性用例 + +用例编号:`UI-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `UI-01` | 菜单可见页面,接口也应可访问 | 一致 | +| `UI-02` | 菜单不可见页面,直接输入 URL | 后端仍能防越权 | +| `UI-03` | 按钮不可见时,直调接口 | 后端仍能防越权 | +| `UI-04` | 数据库路由撤销后,前端 fallback 不应继续展示旧菜单 | 一致 | +| `UI-05` | 自定义角色用户拥有权限但不是旧硬编码角色 | 菜单、按钮按 permission 展示 | + +--- + +## 15. 下载、导出、详情专项 + +用例编号:`RES-*` + +这组是本轮最重要回归项。 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `RES-01` | 文档详情不可见时,评查点接口也不可见 | 一致 | +| `RES-02` | 公文详情不可见时,原文下载不可用 | 一致 | +| `RES-03` | 公文详情不可见时,报告下载不可用 | 一致 | +| `RES-04` | 交叉评查文档不可见时,提案导出不可用 | 一致 | +| `RES-05` | RAG 数据集不可见时,其文档和分段明细不可见 | 一致 | + +--- + +## 16. 缓存与生效时效用例 + +用例编号:`CACHE-*` + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| `CACHE-01` | 给用户新增 permission | 在预期缓存时间内生效 | +| `CACHE-02` | 给用户新增 `DENY` | 在预期缓存时间内生效,且优先于旧 `GRANT` | +| `CACHE-03` | 修改用户地区 `area` | 列表、详情、菜单能力按新地区收敛 | +| `CACHE-04` | 修改角色 `data_scope` | 对应接口边界变化符合预期 | + +--- + +## 17. 灰度观察指标 + +灰度期建议至少看以下指标: + +1. `403` 数量按模块趋势 +2. RAG `/datasets/admin` 相关接口报错量 +3. 公文 `report/docx`、`original` 下载失败量 +4. 交叉评查导出失败量 +5. RBAC 用户角色分配失败量 +6. 前端菜单请求与接口拒绝不一致量 + +若出现明显上涨,应优先排查: + +- scope 决策错误 +- 前端仍沿用旧角色判断 +- 详情/下载类接口未接统一执行器 + +--- + +## 18. 通过标准 + +验收通过建议满足以下门槛: + +1. P0 模块用例通过率 100% +2. 所有 `DENY` 用例通过 +3. 自定义角色去硬编码用例通过 +4. 列表/详情/下载/导出一致性用例通过 +5. 灰度期无明显越权投诉或异常日志 + +如果以上任一项未达标,本轮权限改造不应视为完成。 diff --git a/docs/权限与地区隔离/模块级开发任务单.md b/docs/权限与地区隔离/模块级开发任务单.md new file mode 100644 index 0000000..88f9ea8 --- /dev/null +++ b/docs/权限与地区隔离/模块级开发任务单.md @@ -0,0 +1,335 @@ +# 模块级开发任务单 + +> 适用范围:`leaudit-platform` 权限、地区、租户、多租户入口模块改造执行阶段 +> 更新日期:2026-05-21 +> 文档定位:把当前改造拆成可逐项推进的模块级开发任务单,便于研发、联调、验收逐条跟进。 + +--- + +## 1. 使用方式 + +本文档只做执行,不重复讲大方案。 + +每个模块统一拆成五栏: + +1. 数据库变更 +2. 后端改造 +3. 前端改造 +4. 验收点 +5. 风险 + +状态建议: + +- `未开始` +- `进行中` +- `待联调` +- `已完成` + +--- + +## 2. 推荐执行顺序 + +建议按下面顺序推进: + +1. `T8` 首页入口 + 入口模块后台收口 +2. `T9` 租户主数据维护能力 +3. `T2` 文档模块 +4. `T3` 内部公文模块 +5. `T6` 合同模板模块 +6. `T7` 系统使用统计模块 +7. `T4` RAG 知识库模块 +8. `T5` 评查点模块 +9. `T11` 规则域多租户方案 A 落地 +10. `T1` RBAC 管理域全量 scope 收口 +11. `T10` 前端去角色化收口 + +--- + +## 3. 模块任务单 + +## 3.1 T1 RBAC 管理域 + +当前状态:`已完成首轮联调,待全量收口` + +涉及文件: + +- [rbacAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py) +- [rbacAdminController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 明确 `sso_users.tenant_code` 作为用户主归属租户字段。 2. 如现有用户管理结果必须展示租户名,确保 `tenant_code -> tenant_name` 可稳定关联。 3. 如后续组织树要租户化,评估是否新增“租户组织映射”或直接用租户主数据树。 | +| 后端改造 | 1. 用户列表、用户树、按地区筛选逻辑从 `u.area` 迁到 `tenant_code`。 2. 保留 `area` 仅做兼容展示,不再作为管理边界主条件。 3. 补用户租户设置/修改接口。 4. 管理员查询范围统一为“当前用户可管理租户集合”。 | +| 前端改造 | 1. 用户管理页新增标准租户选择器。 2. 地区筛选改为租户筛选。 3. 用户详情、编辑弹窗展示 `tenant_code + tenant_name`。 4. 组织树若仍展示地区名,需明确它是租户展示名。 | +| 验收点 | 1. `000` 用户可被稳定设置租户。 2. 设置后重新登录 `/auth/me` 返回正确 `tenant_code`。 3. 不同租户管理员看不到其他租户用户。 4. 省级/超级管理员可按租户查看所有用户。 5. 已完成 `PUT /api/v3/rbac/users/{UserId}/tenant` 与前端弹窗的真实联调。 | +| 风险 | 1. 这是全局入口模块,改错会影响登录后所有租户边界。 2. 历史 `area` 和 `tenant_name` 不一致时,容易出现列表对了、详情错了。 | + +--- + +## 3.2 T2 文档模块 + +当前状态:`首轮 tenant-first 收口已完成,仍处于过渡态` + +涉及文件: + +- [documentServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py) +- [documentController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/documentController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 为文档主表补 `tenant_code`。 2. 为文档附件/版本链相关查询建立 `tenant_code` 联动索引。 3. 历史 `region -> tenant_code` 回填。 4. 保留 `region` 作为兼容展示字段。 | +| 后端改造 | 1. 上传、列表、详情、状态、确认、附件、删除统一按 `tenant_code` 过滤。 2. `_resolve_document_region` 一类方法逐步退化为兼容层。 3. 版本链、重复上传判重、路径归属判断不再只依赖 `region`。 4. 接入统一 `DocumentPolicy`。 | +| 前端改造 | 1. 上传页、列表筛选、详情页统一传 `tenant_code`。 2. 地区筛选改成租户筛选。 3. 展示仍可显示中文名,但请求层不能只传中文地区。 | +| 验收点 | 1. 同名文档在不同租户下互不冲突。 2. 用户只能看到自己租户和授权范围内文档。 3. 不可见文档的详情、状态、附件接口也必须拒绝。 4. 历史文档不丢失、旧数据可查。 | +| 风险 | 1. 文档是主数据域,改动会联动统计、评查、公文、交叉评查。 2. 若只改列表不改详情/附件,会形成新的越权口子。 | + +--- + +## 3.3 T3 内部公文模块 + +当前状态:`T5-1 读链路收口已完成,T5-2 待开始` + +涉及文件: + +- [govdocServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py) +- [govdocController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/govdocController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 公文文档链路补 `tenant_code`。 2. 历史 `region` 数据回填到 `tenant_code`。 3. 为版本链查询增加租户索引。 | +| 后端改造 | 1. 上传、列表、详情、运行、结果查询统一按 `tenant_code`。 2. `_resolve_upload_region` 和版本查重逻辑切换为租户编码优先。 3. OSS 路径生成保留展示地区,但归属判断改成 `tenant_code`。 | +| 前端改造 | 1. 上传页和列表筛选统一使用租户选择器。 2. 老 `region` 参数只做兼容透传,页面主模型改为 `tenant_code`。 | +| 验收点 | 1. 不同租户同名公文互不串版本。 2. 非本租户用户无法查看或触发运行。 3. 历史公文可正常展示原地区中文名。 | +| 风险 | 1. 版本链归属如果处理不干净,最容易串数据。 2. OSS/报告路径如果强依赖旧地区名,迁移时要防回归。 | + +--- + +## 3.4 T4 RAG 知识库模块 + +当前状态:`进行中` + +涉及文件: + +- [ragDatasetServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py) +- [ragChatServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py) +- [ragChatController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py) +- `legal-platform-frontend/components/dify-dataset-manager/*` + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 如 `rag_dataset` 仍只存 `area`,补 `tenant_code`。 2. 历史 `area` 回填标准租户编码。 3. 若默认应用与数据集绑定依赖地区,需同步补租户归属字段。 | +| 后端改造 | 1. 删除 service 中 `provincial_admin/admin/super_admin` 角色白名单。 2. 改为 `permission + tenant scope` 判定。 3. 数据集列表、创建、更新、删除、文档上传、检索测试统一按 `tenant_code`。 4. 公共知识库策略单独抽成标准规则。 | +| 前端改造 | 1. `area-dataset` 命名逐步迁到 `tenant-dataset`。 2. 页面按钮显示改为按能力/权限,不按角色名。 3. 数据集管理页统一使用租户选择器。 | +| 验收点 | 1. 自定义角色只要有权限即可管理知识库。 2. 仅有 `admin` 角色名但无权限时必须拒绝。 3. 公共知识库、本租户知识库、省级知识库展示边界正确。 | +| 风险 | 1. 这是角色硬编码最集中的模块。 2. 前后端若只改一侧,会出现按钮能点但接口 403,或接口能过但前端不显示。 | + +--- + +## 3.5 T5 评查点模块 + +当前状态:`进行中` + +涉及文件: + +- [evaluationPointServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py) +- [evaluationPointController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 为评查点表补 `tenant_code`。 2. 视旧库现状同步补 `tenant_name` 展示字段。 3. 历史 `ep.area` 回填标准租户编码。 4. 公共/省级类记录补固定租户编码,不再只存中文常量。 5. 已新增数据库草案:[schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql)。 | +| 后端改造 | 1. 首轮已完成:列表、详情、创建、更新已切为 `tenant_code` 优先,旧 `area` 仅在老表无字段时回退。 2. `T5-1` 已完成:列表读链路已拆分“当前用户租户上下文”和“显式筛选租户条件”,避免查询参数反向污染当前用户 scope。 3. `T5-1` 已完成:无效的 `visible_areas` SQL 绑定残留已清理。 4. `T5-2` 已完成第一轮:创建/更新写链路不再允许原始 `area` 文本直接作为归属主语义,统一从解析后的 `writable_scope` 推导 `area/tenant_code/tenant_name`。 5. `T5-2` 已完成第一轮:`PUBLIC/PROVINCIAL` 与 `公共/省级/default` 共享域写入已统一归一化到标准租户编码。 6. `T5-3` 已完成第一轮:列表/详情筛选默认优先按标准 `tenant_code` 命中共享域,旧 `公共/default/空值/省局/省级` 只作为无 `tenant_code` 老数据的受控 fallback。 7. `T5-4` 已完成第一轮:评查点模块主链路的 `is_global/can_manage` 不再直接依赖 `super_admin/provincial_admin/admin` 角色名,改为 `roles.data_scope + evaluation_point:*` 权限推断。 8. `T5-5` 已完成第一轮:查不到数据库用户上下文时,已移除 `payload.user_role` 角色名兜底;fallback 仅保留租户解析和权限服务判定,默认不再推断全局范围。 9. 全局用户写非公共评查点时,已要求必须显式提供标准 `tenant_code`;未指定租户/共享域时直接拒绝,避免静默落错域。 10. 模块内统一 policy 与最终 `area` 物理兼容层仍未收口。 | +| 前端改造 | 1. 筛选条件改为租户。 2. 创建/编辑表单改为租户选择,不再直接填地区文本。 3. 详情页展示租户中文名。 | +| 验收点 | 1. 已完成首轮验证目标:本租户用户只会看到并维护本租户评查点,公共/省级保持兼容共享。 2. 全局用户若创建非公共评查点,缺失 `tenant_code` 或完全未指定归属范围时会被明确拒绝。 3. 更新接口即便收到原始 `area` 文本,也不会再把它直接当成最终归属写入。 4. 列表和详情读取共享域时,已优先按 `PUBLIC/PROVINCIAL` 命中;老数据仅在缺标准字段时才回退旧名称集合。 5. 自定义新租户若数据库已补字段,可正常创建和筛选评查点。 | +| 风险 | 1. 当前模块物理表仍是 `area` 主模型,最容易误判“接口 tenant 化 = 数据已 tenant 化”。 2. 共享域中文常量仍未完全收口。 3. 若数据库未执行补字段脚本,仍只能停留在兼容态。 4. 收尾顺序、验收项与下线边界已另行整理到 [评查点模块收尾清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点模块收尾清单.md)。 | + +--- + +## 3.6 T6 合同模板模块 + +当前状态:`高风险收口进行中` + +涉及文件: + +- [contractTemplateServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py) +- [contractTemplateController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 为合同模板主表补 `tenant_code`。 2. 补 `tenant_name` 展示字段。 3. 历史 `region` 回填标准租户编码。 4. 如分类有租户隔离要求,评估分类表是否补租户字段。 | +| 后端改造 | 1. 删除结果中伪造的空 `tenant_code`。 2. 列表、搜索、详情、上传统一按 `tenant_code` 过滤。 3. 上传写入真实 `tenant_code/tenant_name`。 4. `region` 降级为兼容展示字段。 5. 去重规则从 `region + template_code` 迁到 `tenant_code + template_code`,旧数据保留 `region` fallback。 | +| 前端改造 | 1. 搜索筛选和上传表单改为租户选择器。 2. 页面展示 `tenant_name`,请求传 `tenant_code`。 | +| 验收点 | 1. 不同租户模板互相不可见。 2. 搜索结果、分类统计、详情页边界一致。 3. 历史模板仍可按旧中文地区展示。 4. 当前已完成第一批后端收口:服务端写入 `tenant_code/tenant_name`,列表/详情真实返回 `tenant_code`。 | +| 风险 | 1. 当前模块最容易出现“接口看似支持租户,实际完全没有真实租户字段”。 2. 如果分类和模板边界不同步,会出现分类统计异常。 3. 在数据库正式执行高风险迁移脚本前,线上旧表仍可能缺 `tenant_name` 列,需要先执行迁移或依赖运行时补列。 | + +--- + +## 3.7 T7 系统使用统计模块 + +当前状态:`进行中` + +涉及文件: + +- [usageStatsServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 为 `usage_login_events` 补 `tenant_code_snapshot / tenant_name_snapshot`。 2. 如其他统计事件表缺租户快照,同步补齐。 3. 为统计聚合补租户索引。 | +| 后端改造 | 1. 登录审计、上传审计、运行审计统一记录标准租户快照。 2. 统计聚合优先按 `tenant_code`,展示层再转 `tenant_name`。 3. 旧 `area_snapshot` 保留兼容,不再作为主统计维度。 | +| 前端改造 | 1. 统计页筛选维度改成租户。 2. 图表与导出字段展示租户中文名。 | +| 验收点 | 1. 登录统计、上传统计、运行统计按租户聚合结果正确。 2. 同一用户改租户后,历史事件仍保留原快照。 3. 导出与页面统计口径一致。 | +| 风险 | 1. 快照字段如果不补,后续历史统计无法稳定回溯。 2. 统计是衍生域,容易被主业务迁移遗漏。 | + +--- + +## 3.8 T8 首页入口 + 入口模块后台收口 + +当前状态:`已完成` + +涉及文件: + +- [homeServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py) +- [entryModuleAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py) +- `legal-platform-frontend/app/(audit)/entry-modules/*` + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 继续以 `leaudit_entry_module_tenants` 为主,不再扩散 `areas`。 2. 核对历史入口模块迁移结果是否完整。 | +| 后端改造 | 1. 读写链路统一以 tenants 关系表为主。 2. `areas/default/省局` fallback 已收敛为只读兼容。 3. “无 tenant 配置” 已有明确业务语义。 4. 创建/更新时若最终租户配置为空,后端直接拒绝。 5. 首页租户过滤已去掉会放宽范围的 `areas IS NULL` 类 fallback。 | +| 前端改造 | 1. 入口模块新建/编辑页面只认租户对象。 2. 列表页支持按租户过滤。 3. 首页消费的返回结构统一优先 `tenants`。 4. 首页兼容层统一识别 `route_path / routePath / target_path / targetPath / path`。 5. 创建/更新请求只以下发 `tenants` 为主,不再以 `areas` 作为主提交流程字段。 6. 管理端编辑页已不再消费 `areas`。 7. 管理端 API 类型已不再把 `areas` 作为主返回模型。 8. 交叉评查入口前端判断已改成“有 tenants 就绝不回退 areas”。 9. 首页前端标准化时优先由 `tenants` 反推兼容 `areas`。 | +| 验收点 | 1. 新建入口模块可配置任意新租户。 2. 首页可按租户正确展示模块。 3. 旧模块迁移后展示不回归。 4. 已验证 `000` 用户首页会话接口返回 `entryModules=5`。 5. 已验证 `/api/v3/entry-modules?tenant_code=MZ` 返回正常。 6. 已验证空 `tenants` 创建会返回明确 400。 7. 已验证入口模块详情接口返回正常,管理端可仅凭 `tenants` 回显。 8. 已验证管理端列表/详情接口不再返回 `areas`。 9. 已验证 `/api/home/entry-modules` 仍正常返回 5 个入口。 10. 已验证首页交叉评查可见性未回退。 | +| 风险 | 1. 旧 JSON 与新关系表双写期间容易不一致。 2. 首页和后台若消费不同字段,会出现“配了但不显示”。 | + +--- + +## 3.9 T9 租户主数据维护能力 + +当前状态:`进行中` + +涉及文件: + +- [tenantServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py) +- [tenantController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/tenantController.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 复核 `sys_tenants`、`sys_tenant_aliases`、`sys_tenant_feature_flags` 约束是否满足新增租户场景。 2. 如需入口模块/文档/RAG能力独立控制,补全 feature flag 种子。 | +| 后端改造 | 1. 已新增 `POST /api/v3/tenants`、`PUT /api/v3/tenants/{tenantCode}`、`PATCH /api/v3/tenants/{tenantCode}/status`。 2. 已支持同步维护 `sys_tenants / sys_tenant_aliases / sys_tenant_feature_flags`。 3. 已补租户专属权限 `rbac:tenants:*` 与 `/tenants` 菜单蓝图。 4. 已增加“禁用前引用检查”,当前至少拦截子租户、入口模块、启用用户引用场景。 | +| 前端改造 | 1. 已新增 `/tenants` 独立租户管理页。 2. 已支持维护租户名称、编码、类型、父级、别名、能力开关、feature keys、启停状态。 3. 仍未做更复杂的租户树和删除能力。 | +| 验收点 | 1. 无需手改 SQL 即可新增一个新租户。 2. 新租户创建后可被入口模块、用户管理、业务表单引用。 3. 禁用租户后下拉可按配置隐藏。 4. 系统设置菜单出现“租户管理”,并可真实保存。 | +| 风险 | 1. 只有读接口没有写接口,会导致每次新增租户都要人工入库。 2. 不做引用检查,容易禁用已被业务广泛使用的租户。 | + +--- + +## 3.10 T10 前端去角色化收口 + +当前状态:`未完成` + +涉及目录: + +- `legal-platform-frontend/app/(audit)/*` +- `legal-platform-frontend/components/*` +- `legal-platform-frontend/hooks/*` +- `legal-platform-frontend/lib/api/legacy/auth/check-route-permission.ts` + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 无直接数据库变更。 | +| 后端改造 | 1. `/auth/me` 或能力快照接口补齐当前用户有效权限、租户能力摘要。 2. 为前端去角色化提供稳定字段。 | +| 前端改造 | 1. 菜单、按钮、guard 从“认角色名”迁到“认权限/能力”。 2. RAG、入口模块、RBAC 管理页优先清理角色名分支。 3. `area-*` 旧命名逐步迁移为 `tenant-*`。 | +| 验收点 | 1. 自定义角色只要有权限,前端就展示正确入口。 2. 只有角色名没有权限时,前端不应误显示按钮。 3. 路由 guard 与接口鉴权结果一致。 | +| 风险 | 1. 去角色化只改前端不改后端,会出现展示和鉴权不一致。 2. 多页面零散硬编码,容易漏改。 | + +--- + +## 3.11 T11 规则域多租户方案 A 落地 + +当前状态:`进行中` + +专项文档: + +- [规则域多租户方案A实施计划.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/规则域多租户方案A实施计划.md) + +涉及文件: + +- [ruleServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py) +- [auditServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py) +- [ruleConfigServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py) +- [evaluationPointGroupServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py) +- [ruleGroupSupport.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py) +- [storage_adapter.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py) + +| 栏位 | 任务 | +| --- | --- | +| 数据库变更 | 1. 已新增规则域迁移脚本:[schema_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_rule_domain_tenant_phase1.sql)。 2. 已新增预检脚本:[precheck_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_rule_domain_tenant_phase1.sql)。 3. 已新增验证脚本:[verify_rule_domain_tenant_phase1.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/verify_rule_domain_tenant_phase1.sql)。 4. `ruleGroupSupport.ensure_rule_group_schema()` 已与迁移脚本对齐,补齐 `leaudit_rule_group_bindings.tenant_code / scope_type / tenant_name_snapshot` 与租户维度唯一索引兼容。 5. 当前仍需在新库实际执行 phase1 SQL。 | +| 后端改造 | 1. `auditServiceImpl` 已完成运行时 tenant-first 解析:`TENANT -> PROVINCIAL -> PUBLIC`。 2. `storage_adapter` 与 `LeauditAuditRun` 已完成运行结果快照兼容写入。 3. `ruleServiceImpl` 已完成读侧 tenant scope、写侧 `CreateVersion / Publish / Rollback` tenant scope、版本快照 `tenant_code_snapshot / scope_type_snapshot / source_version_id` 写入。 4. 当租户用户基于省级规则派生版本时,现已记录 `source_rule_set_id / source_version_id` lineage。 5. `ruleConfigServiceImpl` 已补 `effectiveTenantCode / effectiveScopeType / isInherited / sourceRuleSetId`,规则配置列表与详情都能回传来源态。 6. `evaluationPointGroupServiceImpl` 已完成规则组绑定写侧租户化:新增绑定按当前租户写入 `tenant_code/scope_type/tenant_name_snapshot`,租户用户不能直接写公共或其他租户绑定,更新/删除也已加同租户限制。 7. `CreateRuleDraft` 已改为直接回绑新建版本返回的 `ruleSetId`,避免多租户下按全局 `rule_type` 误绑。 | +| 前端改造 | 1. 后端来源态字段已就绪,可直接接前端“本租户规则 / 继承省级 / 继承公共”展示。 2. 评查组绑定页也已具备 `effectiveTenantCode / effectiveScopeType / isInherited / sourceRuleSetId` 数据基础。 3. 当前仍未完成规则配置页与评查组绑定页的最终 UI 联调展示。 | +| 验收点 | 1. 租户 A 发布规则版本,不影响租户 B。 2. 租户私有规则缺失时可继承 `PROVINCIAL`,再缺失时才继承 `PUBLIC`。 3. 规则配置页接口与评查组绑定接口都能明确返回规则来源态。 4. 租户用户不能直接修改继承态绑定或公共绑定。 5. `CreateRuleDraft` 自动补绑不会再绑到错误租户的规则集。 | +| 风险 | 1. 这是规则域主数据模型重构,不是单纯接口补条件。 2. `rule_type` 历史上被当成全局唯一键使用,迁移时最容易出隐藏耦合。 3. 若先改页面、不改运行链路,会造成“看起来分租户了,实际运行还串规则”的假象。 | + +--- + +## 4. 跨模块公共任务 + +这些任务不属于单一模块,但必须同步推进: + +| 公共任务 | 内容 | +| --- | --- | +| C1 字段标准化 | 全项目统一语义:`tenant_code` 为主归属,`tenant_name` 为展示值,`area/region` 为兼容字段。 | +| C2 历史值清洗 | 清理 `default/省级/省局/公共/空值` 等历史值,统一映射到标准租户编码。 | +| C3 统一执行器接入 | 将模块级手写范围判断逐步收敛到统一 `PermissionDecision + Policy + ScopeBuilder`。 | +| C4 回归矩阵落测 | 详情、下载、状态、导出、删除等派生接口必须和列表边界一致。 | +| C5 文档同步 | 每完成一个模块,回写到 [模块级真实落地清单与下一步动作.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md) 更新状态。 | + +--- + +## 5. 当前建议的下一步开发计划 + +当前不再建议按“功能页面改一点、业务模块改一点”并发推进,而是按收尾风险分 3 组执行。 + +### 5.1 必须先做 + +1. `P0` 评查点数据库收尾 + - 目标:执行 `evaluation_points` 的 `tenant_code / tenant_name` 补列、历史回填、预检 SQL 复核 + - 原因:`T5` 代码已首轮收口,但旧表不补字段就永远停留在兼容态 +2. `P1` 文档模块收尾 + - 目标:详情、附件、状态、删除、版本链统一按 `tenant_code` + - 原因:文档是主数据域,后续统计、公文、交叉评查都依赖它 +3. `P2` 内部公文模块收尾 + - 目标:运行链、结果链、版本链、路径链统一按 `tenant_code` + - 原因:这是最容易出现“参数租户化、底层仍按 region 跑”的模块 +4. `P3` 统计模块收尾 + - 目标:`tenant_code_snapshot / tenant_name_snapshot` 与聚合、导出口径统一 + - 原因:历史审计快照如果不先固化,后面会很难回溯 + +### 5.2 可以并行推进 + +1. `P4` RBAC policy 化 + - 目标:`can_manage / is_global`、组织树、角色分配改成统一权限/范围模型 +2. `P5` RAG 与前端去角色化 + - 目标:移除角色名驱动,统一改为“权限 + 租户能力 + 数据范围” +3. `P6` CrossReview 剩余链路复核 + - 目标:任务详情、关系列表、后续动作统一 policy + +### 5.3 最后清理 + +1. `P7` 全局兼容值清理与回归 + - 目标:`area / region / default / 省级 / 公共 / 省局` 只保留在兼容层、历史清洗层、展示层 + - 同步完成跨模块回归矩阵验收 + +### 5.4 当前执行原则 + +1. 一次只收一个主风险模块,不再大面积并发改造 +2. 先解决“落错租户、查错范围、串租户数据”,再做统一执行器与前端去角色化 +3. 每完成一个阶段,必须同步更新: + - [模块级真实落地清单与下一步动作.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md) + - [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md) + +--- + +## 6. 建议配套阅读 + +1. [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md) +2. [模块级真实落地清单与下一步动作.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md) +3. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +4. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +5. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) diff --git a/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md b/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md new file mode 100644 index 0000000..9d178b8 --- /dev/null +++ b/docs/权限与地区隔离/模块级真实落地清单与下一步动作.md @@ -0,0 +1,558 @@ +# 模块级真实落地清单与下一步动作 + +> 适用范围:`leaudit-platform` 当前权限、地区、租户、多租户入口模块改造的真实工程进度 +> 更新日期:2026-05-21 +> 文档定位:按模块判断“已落地 / 半落地 / 未开始 / 风险点 / 下一步动作”,便于逐模块把控,不再只看大 Phase。 + +--- + +## 1. 使用说明 + +本文档不再按“方案章节”描述,而是按真实模块拆分。 + +每个模块统一给出 5 个结论: + +1. 当前状态 +2. 已落地内容 +3. 未完成内容 +4. 主要风险 +5. 下一步动作 + +状态分级说明: + +- `已落地` +- `半落地` +- `已触达但未验收` +- `基本未开始` + +--- + +## 2. 总体判断 + +当前系统最真实的现状不是“权限改造未开始”,而是: + +1. 租户底座和兼容层已经开始落地 +2. 部分业务模块已接入租户参数或租户解析 +3. 但很多模块仍在用旧 `area/region` 作为最终边界字段 +4. 统一权限执行器还没有成为公共主链 +5. RAG 等模块仍存在角色硬编码残留 + +也就是说,当前最需要防的不是“没设计”,而是“半改状态继续扩散”。 + +--- + +## 3. 模块清单 + +## 3.1 租户主数据模块 + +涉及文件: + +- [tenantController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/tenantController.py) +- [tenantServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py) +- [tenantResolver.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py) +- [schema_tenant_foundation.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_tenant_foundation.sql) + +当前状态:`进行中` + +已落地内容: + +1. `sys_tenants / sys_tenant_aliases / sys_tenant_feature_flags` 已建表 +2. `TenantResolver` 已提供统一租户解析 +3. `TenantServiceImpl` 已支持主数据查询和 legacy fallback +4. `TenantController` 已提供: + - `GET /api/v3/tenants` + - `GET /api/v3/tenants/options` + - `GET /api/v3/tenants/{tenantCode}` + - `POST /api/v3/tenants` + - `PUT /api/v3/tenants/{tenantCode}` + - `PATCH /api/v3/tenants/{tenantCode}/status` +5. 前端已补齐租户 API 封装: + - `getTenants()` + - `getTenantByCode()` + - `createTenant()` + - `updateTenant()` + - `updateTenantStatus()` +6. 前端已新增独立 `/tenants` 租户管理页 +7. RBAC 已补 `/tenants` 菜单蓝图和 `rbac:tenants:*` 权限定义 + +未完成内容: + +1. 删除接口还没做 +2. 更复杂的租户树/批量维护能力还没做 +3. 下游业务引用检查仍可继续扩展到更多业务表 + +主要风险: + +1. 如果 RBAC 种子未被触发,老环境角色可能暂时还看不到新菜单 +2. 当前引用检查先覆盖了用户、入口模块、子租户,尚未扩展到所有业务表 + +下一步动作: + +1. 联调创建/更新/启停接口 +2. 扩展引用检查到更多业务表 +3. 进入 `T9` 收尾验收 + +--- + +## 3.2 登录 / 当前用户上下文 + +涉及文件: + +- [authServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py) +- [ssoUserCompat.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py) + +当前状态:`已落地` + +已落地内容: + +1. `sso_users` 动态列读取已兼容旧库缺失 `tenant_code/tenant_name` +2. 登录后返回已接入 `TenantResolver.ResolveUserContext()` +3. 当前用户接口已开始输出: + - `tenant_code` + - `tenant_name` + - `tenant_type` +4. OAuth 登录已明确禁止前端直接覆盖可信地区字段 + +未完成内容: + +1. 用户租户归属仍主要依赖 `sso_users.area + tenant_code` 混合状态 +2. 还没有后台用户租户维护闭环 +3. 还没有彻底把“用户归属租户”和“数据访问范围”解耦 + +主要风险: + +1. 用户返回有 `tenant_code`,并不代表所有业务查询都已经按它执行 +2. 仍可能出现“用户上下文是租户化的,业务 SQL 仍按旧地区字段比对” + +下一步动作: + +1. 明确用户主归属字段只认 `tenant_code` +2. 用户管理接口补齐租户设置/修改能力 +3. 审计所有下游是否仍直接只取 `payload.area` + +--- + +## 3.3 首页入口模块 + +涉及文件: + +- [homeServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py) + +当前状态:`已完成 T8 收口` + +已落地内容: + +1. 首页入口读取已经接入用户租户解析 +2. 已优先读取 `leaudit_entry_module_tenants` +3. 在关系表缺失时可回退旧 `areas` JSON +4. 已支持 `PUBLIC` 与用户租户联合过滤 +5. `super_admin` 已有绕过租户逻辑 +6. 已确认后端 `/api/home/entry-modules` 对 `000` 真实返回 5 个入口模块 +7. 已确认前端首页此前无入口的根因是兼容层漏识别 `targetPath/routePath` +8. 前端兼容层修复后,`/api/auth/session-data` 已真实返回 `entryModules=5` +9. 首页交叉评查入口判断已改为 `tenants` 优先;只要模块存在租户配置,前端不再回退按 `areas` 判定 +10. 首页后端租户过滤已收紧,去掉了 `em.areas IS NULL / jsonb_typeof <> 'array'` 这类会放宽边界的 fallback +11. 首页前端标准化时已优先使用 `tenants` 反推兼容 `areas`,首页主消费链路不再依赖旧 `areas` 原始值 + +未完成内容: + +1. 首页接口仍保留 `areas` 兼容输出 +2. 旧 JSON 与关系表双写仍属于过渡态 +3. 后续若要彻底去兼容,需要配合历史数据清理再收最后一刀 + +主要风险: + +1. 新模块如果只配关系表、不配旧 `areas`,下游兼容没收干净时仍可能表现异常 +2. 旧 `default` 仍可能造成边界模糊 + +下一步动作: + +1. `T8` 可视为完成,后续不再把首页入口当作阻塞项 +2. 对现有入口模块逐条核对关系表数据 +3. 进入 `T9` 租户主数据维护能力开发 + +--- + +## 3.4 入口模块后台管理 + +涉及文件: + +- [entryModuleAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py) +- [entryModuleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py) +- [schema_entry_module_tenants.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_entry_module_tenants.sql) + +当前状态:`已完成 T8 收口` + +已落地内容: + +1. 已引入 `leaudit_entry_module_tenants` +2. 列表/详情已支持关系表读取 +3. 创建/更新已支持 tenants 入参标准化 +4. 关系表缺失时可降级到旧 `areas` +5. 已实现旧 `areas` 与新 tenants 的兼容转换 +6. 已确认“后台配了入口但首页没显示”不是后台配置失败,而是首页前端兼容层问题 +7. 前端入口模块创建/更新请求已改成只以下发 `tenants` 为主,`areas` 不再作为主提交流程字段 +8. 入口模块列表页展示已改成只以 `tenants` 为主,`areas` 仅在编辑页做历史兼容回显 +9. 后端已新增“适用租户不能为空”校验,空 `tenants` 提交会被明确拒绝 +10. 入口模块管理端编辑页已不再读取 `areas`,管理端消费层已完全改为只消费 `tenants` +11. 入口模块管理端列表/详情接口已不再对外返回 `areas`,管理端返回结构已收口为 `tenants` 主模型 + +未完成内容: + +1. 旧 JSON 与关系表仍处于双写兼容期 +2. 后续若要彻底删掉 `areas` 存储,需要单独安排数据清理窗口 +3. 权限控制还不是“入口模块 + 租户能力 + permission”闭环 + +主要风险: + +1. 后端写了兼容层,但前端如果还传旧字段,会继续固化旧模型 +2. 关系表与旧 JSON 双写期间,容易出现数据不一致 + +--- + +## 3.5 T5 评查点模块 + +涉及文件: + +- [evaluationPointServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py) +- [evaluationPointController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py) + +当前状态:`首轮 tenant-first 收口已完成` + +已落地内容: + +1. 列表、详情、创建、更新已经改成 `tenant_code` 优先,老表没有 `tenant_code/tenant_name` 时才回退到 `area` +2. 创建、更新写入链路已支持同步写 `tenant_code/tenant_name` +3. 全局用户维护非公共评查点时,缺失标准 `tenant_code` 会被拒绝 +4. 控制器列表查询不再把当前用户租户误当成“显式筛选条件”硬塞回去 +5. `T5-1` 已完成:评查点列表读链路已拆开“当前用户租户上下文”和“显式筛选租户条件”,避免读范围判断漂移 +6. `T5-1` 已完成:清理了遗留的 `visible_areas` 无效 SQL 绑定残留 +7. `T5-2` 已完成第一轮:更新接口不再把原始 `area` 自由文本直接写成归属字段,统一改为从解析后的 `writable_scope` 推导 +8. `T5-2` 已完成第一轮:`PUBLIC/PROVINCIAL` 与 `公共/省级/default` 共享域写入已统一归一化到标准租户编码 +9. `T5-2` 已完成第一轮:全局用户若未显式指定租户或共享域,创建/更新会直接拒绝,避免静默落到错误范围 +10. `T5-3` 已完成第一轮:列表/详情的共享域读范围已优先按 `PUBLIC/PROVINCIAL` 命中,旧 `公共/default/空值/省局/省级` 仅作为老数据 fallback +11. `T5-4` 已完成第一轮:评查点模块主链路的全局/管理能力判定已改为 `data_scope + evaluation_point:*` 权限推断,不再直接写死角色名;仅保留查不到数据库上下文时的兼容兜底 +12. `T5-5` 已完成第一轮:查不到数据库用户上下文时,已移除 `payload.user_role` 角色名兜底;fallback 默认不再推断全局范围,只保留租户解析和权限服务判定 + +未完成内容: + +1. 物理表层面还没有彻底摆脱 `area` +2. `visible_areas -> visible_tenant_codes` 的最终命名与统一执行器接入仍未完成 +3. 统一数据范围执行器与最终 `area` 兼容层下线还没接进来 + +主要风险: + +1. 容易误以为“接口看起来有 `tenant_code`”就等于模块已完全租户化 +2. 如果数据库没有执行补字段脚本,仍只能停留在兼容模式 + +下一步动作: + +1. 先补数据库字段和历史回填 +2. 再把评查点模块内共享域中文常量继续下沉到更薄的兼容层 +3. 最后配合数据库历史回填收掉 `area` 物理兼容层 + +--- + +## 3.6 合同模板模块 + +涉及文件: + +- [contractTemplateServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py) +- [contractTemplateController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py) + +当前状态:`已完成首轮联调,未完成全量收口` + +已落地内容: + +1. controller 已接受 `region + tenant_code` +2. service 已引入 `TenantResolver` +3. 列表/搜索/详情查询开始考虑 `region/tenant_code` + +未完成内容: + +1. SQL 结果里仍直接写: + - `''::VARCHAR AS tenant_code` + - `t.region AS tenant_name` +2. 也就是模板模块当前本质上还没有真实租户字段 +3. 仍属于“参数长得像租户化,存储与返回仍是旧 region 模型” + +主要风险: + +1. 前端可能误以为该模块已经支持标准租户编码 +2. 实际上租户边界仍受 `region` 历史值影响 + +下一步动作: + +1. 合同模板表补 `tenant_code` +2. 返回结构不再伪造空 `tenant_code` +3. 分类、搜索、详情统一改为按租户编码过滤 + +--- + +## 3.7 RAG 知识库模块 + +涉及文件: + +- [ragDatasetServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py) +- [ragChatServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py) +- [ragChatController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py) +- 前端 `components/dify-dataset-manager/*` + +当前状态:`第一轮高风险收口已完成,未完成最终验收` + +已落地内容: + +1. controller 已传入 `TenantCode/TenantName` +2. service 已引入 `TenantResolver` +3. 已支持请求的租户/地区标准化 + +未完成内容: + +1. 仍然存在显式角色白名单: + - `provincial_admin` + - `admin` + - `super_admin` +2. 管理权限仍不是纯 permission 驱动 +3. 数据集主字段仍大量使用 `area` +4. 前端数据集管理目录仍明显保留 `area-dataset` 旧命名体系 + +主要风险: + +1. 这是当前角色去硬编码最明显的残留区 +2. 也是最容易出现“自定义角色有权限但不能用”的模块 + +下一步动作: + +1. 先移除 service 里的角色白名单 +2. 用 permission + tenant scope 替代 +3. 把 `area-dataset` 命名体系升级成 `tenant-dataset` + +--- + +## 3.8 文档模块 + +涉及文件: + +- [documentServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py) +- [documentController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/documentController.py) + +当前状态:`半落地` + +已落地内容: + +1. 上传接口已接受 `TenantCode/TenantName` +2. 已通过 `TenantResolver` 解析上传侧地区/租户 +3. 返回侧已开始回写解析后的租户信息 +4. 用户上下文开始兼容租户字段 + +未完成内容: + +1. 存储主字段仍是 `region` +2. 版本链、路径构建、重复判断等核心逻辑仍按 `region` +3. 还没有看到统一 `DocumentPolicy + PermissionDecision` 完整闭环证据 + +主要风险: + +1. 文档模块是主数据域,若仍按 `region` 跑,后续所有统计/详情/附件都有边界不一致风险 +2. 新租户一旦不等于历史中文地区名,旧逻辑会直接出错 + +下一步动作: + +1. 文档主表补 `tenant_code` +2. 列表/详情/状态/附件统一按租户边界过滤 +3. 把 `region` 退化为展示兼容字段 + +--- + +## 3.9 内部公文模块 + +涉及文件: + +- [govdocServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py) +- [govdocController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/govdocController.py) + +当前状态:`半落地` + +已落地内容: + +1. 上传/列表接口已接受 `tenantCode` +2. 已引入 `TenantResolver` +3. 上传前已解析租户请求 +4. 当前用户上下文已开始租户化 + +未完成内容: + +1. 最终写入和版本判断主字段仍是 `resolvedRegion` +2. 也就是公文模块本质仍在用旧地区值做主边界 +3. 未见统一权限执行器完全接入 + +主要风险: + +1. 与文档模块同类,属于“参数租户化、底层 region 化” +2. 真实多租户扩展时会先在这里出边界问题 + +下一步动作: + +1. 公文文档链路补 `tenant_code` +2. 版本链判断、上传归属、列表过滤统一切租户编码 +3. `region` 仅保留兼容展示 + +--- + +## 3.10 系统使用统计模块 + +涉及文件: + +- [usageStatsServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py) + +当前状态:`半落地` + +已落地内容: + +1. 登录事件记录已接入 `TenantResolver.ResolveUserContext()` +2. 登录审计写入前已做租户标准化 +3. 服务已引入 `SsoUserCompat + TenantResolver` + +未完成内容: + +1. 当前快照写入字段仍是 `area_snapshot` +2. 未见标准 `tenant_code_snapshot` +3. 统计查询是否完全按租户边界,还需要继续核验下半段 SQL + +主要风险: + +1. 审计留痕如果不记标准 `tenant_code`,后续历史统计会很难稳定迁移 +2. 统计口径可能继续受历史中文地区值影响 + +下一步动作: + +1. 审计事件表补 `tenant_code_snapshot` +2. 统计聚合统一按标准租户编码 +3. 地区维度改成“租户展示维度” + +--- + +## 3.11 RBAC 管理域 + +涉及文件: + +- [rbacAdminServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py) +- [rbacAdminController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py) + +当前状态:`已触达但未验收` + +已落地内容: + +1. 已引入 `SsoUserCompat + TenantResolver` +2. 用户列表和上下文读取已开始兼容 `tenant_code/tenant_name` +3. 部分列表/树结构已开始计算 tenant label +4. 已新增用户租户设置 DTO/VO、service 能力与 controller 接口 +5. 已新增 `PUT /api/v3/rbac/users/{UserId}/tenant` +6. 已完成前端“设置租户”弹窗与接口联调 +7. 已真实验证 `000` 用户可成功设置 `tenant_code=MZ` +8. 用户列表、角色用户列表已按 `tenant_code` 优先过滤,旧 `tenant_name/area` 仅作无 `tenant_code` 兼容回退 +9. 组织树已改为 `tenant_code` 优先分组,节点主键从展示名切到稳定租户编码 +10. 用户角色分配、用户角色查询已补“目标用户必须在当前管理员租户范围内”校验 + +未完成内容: + +1. `can_manage/is_global/is_super_admin` 仍由角色名派生,尚未切到统一 `permission + policy` +2. 组织树仍保留 `tenant_name/area` 兼容展示值,尚未完全接入租户主数据树 +3. 还没有形成“用户归属租户 + 管理范围 + 数据权限”的统一执行器模型 + +主要风险: + +1. RBAC 管理域如果长期继续用角色名派生管理能力,会拖住全项目统一权限执行器落地 +2. 当前属于“tenant-first 已收口,但 policy 未统一”,后续若新增接口仍可能绕回手写边界 + +下一步动作: + +1. 把 `can_manage/is_global` 从角色名派生迁到后端统一权限/能力快照 +2. 组织树进一步过渡到租户主数据树,而不是仅基于用户表动态拼树 +3. 角色授权与租户边界分开建模,并纳入统一 `RbacAdminPolicy` + +--- + +## 3.12 前端租户接入 + +涉及目录: + +- `legal-platform-frontend/lib/api/legacy/tenants/tenants.ts` +- `legal-platform-frontend/app/(audit)/entry-modules/*` +- `legal-platform-frontend/components/dify-dataset-manager/*` +- `legal-platform-frontend/hooks/use-area-dataset-config.ts` + +当前状态:`已触达但未收口` + +已落地内容: + +1. 前端已有 `/api/v3/tenants/options` 调用封装 +2. 入口模块前端已有新页面改造痕迹 +3. RAG 前端有较多对应改造痕迹 + +未完成内容: + +1. 仍广泛保留 `area-dataset` 命名 +2. 仍需要核查是否存在按角色名显示按钮/菜单的逻辑 +3. 还未证明页面全部优先认 `tenant_code` + +主要风险: + +1. 后端改了但前端仍传旧字段,会持续把旧模型写回系统 +2. 命名层长期保留 `area`,会误导后续开发继续写旧逻辑 + +下一步动作: + +1. 前端新表单统一只选租户,不再手输地区 +2. `area-*` 命名逐步迁到 `tenant-*` +3. 清理按钮/guard 中的角色硬编码 + +--- + +## 4. 当前优先级排序 + +如果按“最容易继续出错 / 越权 / 落错数据”排序,当前建议这样推进: + +1. `入口模块后台 + 租户主数据写能力` + - 原因:这是后续新增租户和新增入口能否真正闭环的当前缺口 +2. `文档模块` + - 原因:主数据域,后续所有统计和评查都依赖它 +3. `内部公文模块` + - 原因:与文档链路类似,仍是 region 主模型 +4. `合同模板模块` + - 原因:已接参数但底层未真正租户化,且属于配置/检索域 +5. `统计模块` + - 原因:需要尽快固化标准租户审计快照 +6. `RAG 模块` + - 原因:角色白名单残留明显,最容易出现自定义角色不可用 +7. `评查点模块` + - 原因:已经半租户化,但底层仍是 area 模型 + +--- + +## 5. 最关键的工程判断 + +现在不能再笼统说“项目已经租户化”。 + +更准确的说法是: + +1. 底座已租户化一部分 +2. 入口模块已开始关系化 +3. 用户上下文已开始输出租户信息 +4. 但多个核心业务模块仍以 `area/region` 作为最终执行边界 + +因此,后续改造主线必须从“传了 tenant 参数就算完成”升级为: + +1. 数据库存标准 `tenant_code` +2. 查询边界按 `tenant_code` +3. 返回结构带 `tenant_code + tenant_name` +4. 旧 `area/region/default/省级/省局` 只保留兼容层 + +--- + +## 6. 建议配套阅读 + +1. [权限与租户改造当前进度总览.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限与租户改造当前进度总览.md) +2. [权限改造实施任务拆解与排期.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限改造实施任务拆解与排期.md) +3. [角色去硬编码迁移清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/角色去硬编码迁移清单.md) +4. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) diff --git a/docs/权限与地区隔离/租户与角色权限关系真实案例说明.md b/docs/权限与地区隔离/租户与角色权限关系真实案例说明.md new file mode 100644 index 0000000..629a0e2 --- /dev/null +++ b/docs/权限与地区隔离/租户与角色权限关系真实案例说明.md @@ -0,0 +1,250 @@ +# 租户与角色权限关系真实案例说明 + +> 适用范围:`leaudit-platform` 当前 `角色权限 + 租户配置 + 数据范围` 模型 +> 更新日期:2026-05-20 +> 文档定位:不用抽象术语,直接用真实业务例子说明“租户”和“角色权限配置”到底是什么关系。 + +--- + +## 1. 先记一句最核心的话 + +这套系统里有三层控制: + +1. `角色权限` + - 管“这个人能不能做这件事” +2. `租户配置` + - 管“这件事能不能落到这个租户上” +3. `数据范围` + - 管“这个人最终能看到哪些租户的数据” + +它们是三层,不是一层。 + +--- + +## 2. 例子 1:入口模块 + +假设现在有两个租户: + +- `MZ` = 梅州 +- `PUBLIC` = 公共资源域 + +再假设有一个用户 `张三`: + +- 人属于 `MZ` +- 角色有: + - `entry_module:create` + - `entry_module:update` + +这时候只能说明一件事: + +- 张三“有权新建和编辑入口模块” + +但这还不能直接推出: + +- 张三可以把入口模块挂到任何租户上 + +还要再看租户配置。 + +如果租户配置是: + +- `MZ.feature_keys` 包含 `home.entry_module` +- `MZ.can_host_entry_module = true` +- `PUBLIC.feature_keys` 包含 `home.entry_module` +- `PUBLIC.can_host_entry_module = false` + +那么结果就是: + +1. 张三可以进入入口模块管理页 +2. 张三可以新建入口模块 +3. 张三可以把模块挂到 `MZ` +4. 张三不能把模块挂到 `PUBLIC` + +为什么? + +- 因为权限页只给了他“操作资格” +- 但 `PUBLIC` 没开放“可承载入口模块” + +所以这里的判断链路是: + +1. 先看角色权限:能不能新建 +2. 再看租户承载能力:这个租户能不能被选 +3. 最后才允许落库 + +--- + +## 3. 例子 2:文档上传 + +假设用户 `李四`: + +- 属于 `HZ` +- 角色里有 `documents:upload:create` + +这说明: + +- 李四“可以上传文档” + +但如果 `HZ` 这个租户配置是: + +- `feature_keys` 没有 `documents.upload` +- 或者 `can_host_documents = false` + +那么正确行为应该是: + +1. 李四即使有上传权限 +2. `HZ` 这个租户也不应该作为文档归属租户被使用 +3. 页面可能不显示上传入口,或者后端直接拒绝 + +也就是说: + +- `有权限` 不等于 `这个租户允许承载这类业务` + +--- + +## 4. 例子 3:知识库 + +假设用户 `王五`: + +- 属于 `MZ` +- 角色里有 `rag:dataset:manage` + +这说明: + +- 王五可以管理知识库 + +但是否能给 `SZ` 租户建知识库,还要看: + +- `SZ.feature_keys` 是否开启了 `rag.dataset` +- `SZ.can_host_rag` 是否为 `true` + +如果: + +- `SZ` 没开 `rag.dataset` + +那就算王五权限再高,也不应该把知识库业务落到 `SZ` + +这里要理解: + +- `rag:dataset:manage` 是“人有能力操作” +- `feature_keys / can_host_rag` 是“租户有没有资格承载这个业务” + +--- + +## 5. 例子 4:公共资源域 `PUBLIC` + +这个最容易混。 + +假设 `PUBLIC` 用来放: + +- 公共模板 +- 公共规则 +- 公共知识库底座 +- 公共入口模块 + +那 `PUBLIC` 的意义是: + +- 这是一个“共享资源归属地” +- 不是谁都能随便改的地方 + +比如用户 `赵六`: + +- 属于 `MZ` +- 角色只有 `document:read` + +那他可能可以看到 `PUBLIC` 下的一些共享资源, +但如果没有 `document:update` 或更高权限: + +- 他也不能改 `PUBLIC` 的资源 + +所以: + +- `PUBLIC` 决定资源归属偏共享 +- `角色权限` 决定你能不能动这份共享资源 + +--- + +## 6. 例子 5:为什么角色权限页和租户页看起来都在管“入口模块/文档/知识库” + +因为它们问的不是同一个问题。 + +角色权限页在问: + +- 这个用户能不能进入这个模块? +- 能不能新增、编辑、删除? + +租户页在问: + +- 这个租户开不开这个业务? +- 这个租户能不能承载这类数据? + +可以把它理解成: + +- `角色权限 = 驾照` +- `租户能力 = 哪些路允许走` +- `数据范围 = 你今天实际能开到哪些区域` + +只有驾照,没有开通道路,不行。 +只有道路开放,没有驾照,也不行。 + +--- + +## 7. 一个完整串联例子 + +假设你要“给梅州上线一个新的首页入口模块”。 + +要同时满足下面 6 件事: + +1. 当前操作人有权限 + - 例如有 `entry_module:create` +2. `MZ` 租户开放了入口模块业务 + - `feature_keys` 包含 `home.entry_module` +3. `MZ` 允许承载入口模块 + - `can_host_entry_module = true` +4. 当前人数据范围允许操作这个租户 + - 不是只能管自己部门,却跑去配别的租户 +5. 首页用户属于 `MZ` + - 登录后解析出 `tenant_code = MZ` +6. 首页读取时,模块租户关系里包含 `MZ` + +最后结果才是: + +- 后台能配置 +- 前台能看到 +- 数据不会挂错租户 + +--- + +## 8. 一句话收口 + +比如“入口模块”这个词,在系统里其实对应 3 个不同问题: + +1. `角色权限` + - 你能不能配入口模块 +2. `租户配置` + - 这个租户开不开入口模块 + - 这个租户能不能承载入口模块 +3. `数据范围` + - 你能不能看到或操作这个租户下的入口模块 + +这三件事少一件都不成立。 + +--- + +## 9. 最终理解公式 + +后续看所有业务模块,都可以套下面这条公式: + +`最终是否允许 = 角色权限通过` + +并且 + +`目标租户开放该业务` + +并且 + +`目标租户允许承载该类数据` + +并且 + +`当前用户数据范围允许访问该租户/该资源` + +只要按这个公式看,就不会再把“角色权限”和“租户配置”混成一件事。 diff --git a/docs/权限与地区隔离/租户主数据模型设计.md b/docs/权限与地区隔离/租户主数据模型设计.md new file mode 100644 index 0000000..fdb29a2 --- /dev/null +++ b/docs/权限与地区隔离/租户主数据模型设计.md @@ -0,0 +1,501 @@ +# 租户主数据模型设计 + +> 适用范围:当前系统把 `area / region / 入口地区 / 省级公共范围` 混合当成业务隔离边界使用的场景 +> 文档定位:定义“地区正式升级为租户主数据”后的核心表结构、字段规则、接口边界和兼容策略,作为后续入口模块、RAG、合同模板、文档、公文、统计、RBAC 改造的底层依据。 + +--- + +## 1. 结论先行 + +当前系统真正缺的不是“再补几个地区下拉”,而是: + +1. 缺少租户主数据表 +2. 缺少统一租户编码 +3. 缺少“公共租户 / 总部租户 / 普通租户”的类型语义 +4. 缺少租户启停、排序、展示名、别名、扩展字段 +5. 缺少业务表和租户主数据之间的稳定关联 + +因此建议正式引入: + +1. `sys_tenants` +2. `sys_tenant_aliases` +3. `sys_tenant_feature_flags` +4. 业务表中的 `tenant_code` + +同时保留现有 `area / region / tenant_name` 一段时间作为兼容字段,但不再把它们当主键语义使用。 + +--- + +## 2. 当前模型为什么不够 + +当前系统涉及租户边界的核心字段分散如下: + +1. `sso_users.area` +2. `sso_users.tenant_name` +3. `leaudit_documents.region` +4. `contract_templates.region` +5. `rag_dataset.area` +6. `rag_chat_app.area` +7. `leaudit_entry_modules.areas[].area` +8. 前端若干页面里的固定地区常量 + +这些字段的问题不是“名字不同”这么简单,而是: + +1. 没有统一编码 +2. 没有主数据约束 +3. 没有类型定义 +4. 没有别名映射 +5. 没有生命周期管理 +6. 没有租户能力开关 + +只要继续沿用字符串直写模式,新增一个租户就必须人工排查: + +1. 登录态信息 +2. 首页入口 +3. RAG 数据集 +4. 合同模板 +5. 文档上传 +6. 交叉评查入口 +7. 统计维度 +8. RBAC 管理树 + +这已经不是可维护架构。 + +--- + +## 3. 目标模型 + +## 3.1 核心原则 + +后续统一按下面规则建模: + +1. `tenant_code` 是唯一稳定编码,只用于程序匹配和外键引用 +2. `tenant_name` 是展示名称,可修改 +3. `area / region` 退化为兼容字段,不再承担主键语义 +4. “公共资源”不再用 `省级 / 省局 / default / 空串` 表示,而是用明确租户类型或独立作用域字段表达 +5. 所有租户候选值都必须来自主数据,不允许前后端各自发明字符串 + +## 3.2 推荐主表 `sys_tenants` + +建议表结构: + +```sql +CREATE TABLE IF NOT EXISTS sys_tenants ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL UNIQUE, + tenant_name VARCHAR(128) NOT NULL, + tenant_short_name VARCHAR(64) NULL, + tenant_type VARCHAR(32) NOT NULL, + parent_tenant_code VARCHAR(64) NULL, + display_order INT NOT NULL DEFAULT 0, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + can_host_entry_module BOOLEAN NOT NULL DEFAULT TRUE, + can_host_documents BOOLEAN NOT NULL DEFAULT TRUE, + can_host_rag BOOLEAN NOT NULL DEFAULT TRUE, + can_host_templates BOOLEAN NOT NULL DEFAULT TRUE, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL +); +``` + +## 3.3 字段语义 + +重点字段建议如下: + +1. `tenant_code` + 用于所有内部匹配,例如 `MZ`、`YF`、`JY`、`CZ`、`PROVINCIAL`、`PUBLIC` + +2. `tenant_name` + 展示名,例如 `梅州`、`云浮`、`揭阳`、`潮州`、`省局公共` + +3. `tenant_type` + 枚举建议: + - `LOCAL` + - `HEADQUARTER` + - `PUBLIC` + - `PILOT` + - `INTERNAL` + +4. `parent_tenant_code` + 用于表达层级,例如某试点租户挂在省级租户下 + +5. `is_public` + 表示该租户是否代表跨租户公共资源域,不再让业务侧自行解释 `省级 / default / 空串` + +6. `is_builtin` + 标识是否系统内置。当前固定地区和公共租户可以先作为内置租户落地 + +7. `can_host_entry_module / can_host_documents / can_host_rag / can_host_templates` + 表示这个租户是否允许承载某类业务配置,避免把“公共租户”误用于不该投放的模块 + +8. `ext` + 用于保留显示标签、颜色、旧编码映射、第三方系统标识等扩展信息 + +--- + +## 4. 别名表设计 + +## 4.1 为什么必须有别名表 + +当前系统已存在以下同义值混用: + +1. `省局` +2. `省级` +3. `default` +4. `''` +5. 未来可能出现的 `广东省局` + +如果不显式建别名表,只靠 if/else 兼容,后续每个模块都会各写一遍归一逻辑。 + +## 4.2 推荐表 `sys_tenant_aliases` + +```sql +CREATE TABLE IF NOT EXISTS sys_tenant_aliases ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + alias_value VARCHAR(128) NOT NULL, + alias_type VARCHAR(32) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL, + UNIQUE (tenant_code, alias_value) +); +``` + +## 4.3 `alias_type` 建议 + +建议最少支持: + +1. `LEGACY_AREA` +2. `LEGACY_REGION` +3. `DISPLAY` +4. `IMPORT` +5. `EXTERNAL_SYSTEM` + +这样可以区分: + +1. 历史库里的旧值 +2. 前端展示名 +3. 导入脚本映射值 +4. 第三方接口传入值 + +--- + +## 5. 租户能力开关表设计 + +## 5.1 为什么需要功能级能力开关 + +用户这次点出来的“入口模块无法给新租户分配”只是第一层问题。更深一层是: + +新增租户后,不一定所有模块都同时开放。 + +例如某新租户可能只开: + +1. 首页入口 +2. 文档上传 + +但暂时不开: + +1. 合同模板 +2. RAG 知识库管理 +3. 交叉评查 + +所以需要功能级能力模型,而不是只维护一个名字列表。 + +## 5.2 推荐表 `sys_tenant_feature_flags` + +```sql +CREATE TABLE IF NOT EXISTS sys_tenant_feature_flags ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + feature_key VARCHAR(64) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL, + UNIQUE (tenant_code, feature_key) +); +``` + +## 5.3 `feature_key` 建议 + +建议按当前系统模块先固化: + +1. `home.entry_module` +2. `documents.upload` +3. `documents.list` +4. `govdoc.audit` +5. `rag.dataset` +6. `rag.chat` +7. `contract.template` +8. `cross.review` +9. `usage.stats` + +--- + +## 6. 业务表接入规范 + +## 6.1 用户表 + +当前: + +1. `sso_users.area` +2. `sso_users.tenant_name` + +建议新增: + +1. `sso_users.tenant_code` + +规则: + +1. `tenant_code` 为主归属租户 +2. `area` 先保留,作为历史兼容展示字段 +3. `tenant_name` 保留,但展示应优先来自 `sys_tenants.tenant_name` + +## 6.2 文档表 + +当前: + +1. `leaudit_documents.region` + +建议新增: + +1. `leaudit_documents.tenant_code` + +兼容策略: + +1. 老代码读取 `region` 时,通过别名表映射为 `tenant_code` +2. 新代码写入时同时写 `tenant_code` 和兼容 `region` + +## 6.3 合同模板 + +当前: + +1. `contract_templates.region` + +建议新增: + +1. `contract_templates.tenant_code` +2. 可选新增 `scope_type` + +原因: + +当前 `省级` 既像租户,又像公共范围,语义混乱。建议拆成: + +1. `tenant_code` +2. `scope_type = TENANT / PUBLIC` + +## 6.4 RAG + +当前: + +1. `rag_dataset.area` +2. `rag_chat_app.area` +3. `is_public` +4. `is_default` + +建议新增: + +1. `tenant_code` +2. `scope_type` + +说明: + +`is_public` 可以保留,但应由 `scope_type` 和租户类型统一解释,避免 `省级 + is_public` 双重表达。 + +## 6.5 入口模块 + +当前: + +1. `leaudit_entry_modules.areas JSONB` + +建议: + +1. 保留 `leaudit_entry_modules` +2. 新增 `leaudit_entry_module_tenants` +3. 由关系表表达模块与租户的分配关系 + +详见文档: + +- [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) + +--- + +## 7. 租户类型与公共范围语义 + +## 7.1 当前问题 + +现在公共范围至少存在 4 种写法: + +1. `省局` +2. `省级` +3. `default` +4. `''` + +这 4 种值不能继续并存。 + +## 7.2 推荐统一语义 + +建议统一为两层: + +1. 租户归属层 +2. 数据作用域层 + +推荐枚举: + +1. `scope_type = TENANT` +2. `scope_type = PUBLIC` +3. `scope_type = CROSS_TENANT` + +说明: + +1. `tenant_code` 决定“归属” +2. `scope_type` 决定“谁可以看” + +例如: + +1. 某模板属于 `PROVINCIAL_PUBLIC`,`scope_type=PUBLIC` +2. 某知识库属于 `MZ`,`scope_type=TENANT` + +这样比单纯写 `region='省级'` 清晰得多。 + +--- + +## 8. 后端接口建议 + +## 8.1 新增租户主数据接口 + +建议最少提供: + +1. `GET /api/v3/tenants` + 返回启用中的租户列表 + +2. `GET /api/v3/tenants/{tenantCode}` + 返回租户详情 + +3. `POST /api/v3/tenants` + 创建租户 + +4. `PUT /api/v3/tenants/{tenantCode}` + 更新租户 + +5. `GET /api/v3/tenants/options` + 返回前端下拉所需精简结构 + +6. `GET /api/v3/tenants/features` + 查询租户能力矩阵 + +## 8.2 返回结构建议 + +建议前端统一使用: + +```json +{ + "tenant_code": "MZ", + "tenant_name": "梅州", + "tenant_type": "LOCAL", + "is_public": false, + "display_order": 10, + "is_enabled": true, + "features": [ + "home.entry_module", + "documents.upload", + "rag.chat" + ] +} +``` + +--- + +## 9. 前端接入规范 + +所有原来写死地区列表的页面,后续都不应该再维护常量数组,而应读取租户主数据接口。 + +重点包括: + +1. 入口模块新建页 +2. 入口模块列表页筛选 +3. 交叉评查入口可见性计算 +4. RAG 地区配置页 +5. 合同模板地区筛选 +6. 使用统计地区维度筛选 + +前端要遵守两条规则: + +1. 存储和提交用 `tenant_code` +2. 展示才用 `tenant_name` + +--- + +## 10. 不建议复用 `tenant_name` 当主键 + +虽然系统里已经有 `sso_users.tenant_name`,但不建议直接把它升级为租户主键,原因有 5 个: + +1. 它本质更像组织展示字段 +2. 历史数据可能存在空值和重复值 +3. 它不一定稳定 +4. 它可能包含业务展示修饰词 +5. 它不能稳定承载外键关系 + +因此: + +1. `tenant_name` 可保留 +2. 但必须引入新的 `tenant_code` + +--- + +## 11. 与权限系统的关系 + +租户主数据模型不是替代 RBAC,而是给 RBAC 提供更稳定的数据边界基座。 + +关系应为: + +1. RBAC 决定“你能做什么” +2. 租户主数据决定“你的租户边界是什么” +3. 数据范围执行器把两者合并成最终访问决策 + +也就是: + +1. `permission_key` +2. `data_scope` +3. `tenant_code` + +这三者必须同时成立。 + +--- + +## 12. 推荐实施顺序 + +建议顺序如下: + +1. 建 `sys_tenants`、`sys_tenant_aliases`、`sys_tenant_feature_flags` +2. 先把当前固定地区和公共范围落成主数据 +3. 给 `sso_users` 增加 `tenant_code` +4. 先改入口模块为租户接口驱动 +5. 再改 RAG、合同模板、文档上传 +6. 最后将统一数据范围执行器接到 `tenant_code` + +--- + +## 13. 本文档解决什么问题 + +本文档主要解决: + +1. “地区”到底该怎么升级成“租户” +2. 新租户为什么不能只改前端下拉 +3. 为什么必须引入 `tenant_code` +4. 为什么必须有别名表和能力开关 +5. 业务表后续应该如何统一接租户主数据 + +下一步建议继续阅读: + +1. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +2. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +3. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +4. [自定义租户功能连带影响深度补充.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md) diff --git a/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md b/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md new file mode 100644 index 0000000..0997fef --- /dev/null +++ b/docs/权限与地区隔离/统一执行器落地代码骨架与接入示例.md @@ -0,0 +1,1150 @@ +# 统一执行器落地代码骨架与接入示例 + +> 适用范围:`leaudit-platform` 后端权限改造实施阶段 +> 文档定位:把“统一数据范围执行器设计”进一步落到代码骨架、接口签名、目录建议和模块接入示例层面,供研发直接照着实施。 + +--- + +## 1. 文档目标 + +前面几份文档已经回答了: + +- 为什么要做统一执行器 +- 哪些接口会受影响 +- 应该按什么阶段实施 +- 字段和 SQL 应该如何规范化 + +这一份只解决一个问题: + +后端代码到底怎么改,才能在不推翻现有 service 结构的前提下,把统一执行器落进去。 + +核心要求: + +1. 尽量兼容现有 FastAPI + service 分层风格 +2. 优先通过“新增能力 + 渐进替换”推进 +3. 先不大改 controller 签名 +4. 先把 service 内部角色判断替换掉 + +--- + +## 2. 当前代码风格约束 + +从现有代码看,后端基本遵循: + +1. `Controller` + - 负责接 JWT payload + - 负责做基础 permission 判断 + - 调用 `ServiceImpl` +2. `Service` + - 负责业务逻辑 + - 直接写 SQL / 组装查询 +3. `PermissionServiceImpl` + - 当前仅提供布尔权限判断 + +因此统一执行器落地时,不建议第一步就做成特别重的 AOP 或 decorator 体系。 + +更适合的落地方式是: + +1. 先保留 controller 现状 +2. 在 service 内引入统一 `PermissionDecisionService` +3. 逐步把旧的 `_getCurrentUserContext`、`_build...Filters` 替换掉 + +--- + +## 3. 推荐目录结构 + +建议新增: + +```text +fastapi_modules/fastapi_leaudit/services/permission_scope/ + __init__.py + models.py + enums.py + exceptions.py + repositories/ + __init__.py + permissionGrantRepository.py + resourceScopeRepository.py + resolvers/ + __init__.py + dataScopeResolver.py + permissionDecisionService.py + queryScopeBuilder.py + policies/ + __init__.py + base.py + documentPolicy.py + govdocPolicy.py + usageStatsPolicy.py + ragPolicy.py + crossReviewPolicy.py + rbacAdminPolicy.py + contractTemplatePolicy.py + facade/ + __init__.py + scopeAwarePermissionFacade.py +``` + +说明: + +- `repositories` 负责查授权和资源归属 +- `resolvers` 负责算决策 +- `policies` 负责模块特例 +- `facade` 负责给业务 service 暴露统一入口 + +--- + +## 4. 建议新增核心对象 + +## 4.1 `models.py` + +建议定义: + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class ScopeContext: + user_id: int + permission_key: str + module: str + action: str + user_area: str | None + request_area: str | None = None + target_user_id: int | None = None + resource_id: int | None = None + route_path: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class PermissionGrant: + role_id: int + role_key: str + permission_key: str + grant_type: str + role_scope: str | None + permission_scope: str | None + priority: int + is_system_role: bool + condition_filter: dict[str, Any] | None = None + + +@dataclass(slots=True) +class PermissionDecision: + allowed: bool + deny_reason: str | None + effective_scope: str | None + scope_mode: str + allowed_areas: list[str] = field(default_factory=list) + allowed_user_ids: list[int] = field(default_factory=list) + allow_public: bool = False + matched_roles: list[str] = field(default_factory=list) + matched_permissions: list[str] = field(default_factory=list) + conditions: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class ScopeFieldMapping: + area_field: str | None = None + creator_field: str | None = None + owner_field: str | None = None + user_field: str | None = None + public_field: str | None = None + + +@dataclass(slots=True) +class ScopeClause: + sql: str + params: dict[str, Any] + scope_mode: str + description: str +``` + +## 4.2 `enums.py` + +建议定义: + +```python +class ScopeMode: + NONE = "NONE" + ALL = "ALL" + DEPT = "DEPT" + SELF = "SELF" + RELATION = "RELATION" + PUBLIC_MIXED = "PUBLIC_MIXED" + CUSTOM = "CUSTOM" +``` + +--- + +## 5. 推荐新增权限服务能力 + +## 5.1 不直接推翻 `IPermissionService` + +当前接口是: + +- `CheckPermission` +- `HasAnyPermission` +- `HasAllPermissions` + +不建议直接改掉这 3 个方法,否则牵涉面太广。 + +建议做法: + +1. 保留原接口 +2. 额外新增一个 scope 感知服务接口 + +## 5.2 推荐新增接口 + +建议新增文件: + +`fastapi_modules/fastapi_leaudit/services/permissionScopeService.py` + +```python +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.services.permission_scope.models import PermissionDecision, ScopeContext + + +class IPermissionScopeService(ABC): + @abstractmethod + async def Decide(self, Context: ScopeContext) -> PermissionDecision: + ... + + @abstractmethod + async def Require(self, Context: ScopeContext) -> PermissionDecision: + ... +``` + +说明: + +- `Decide()` 返回决策,不抛错 +- `Require()` 返回决策,若拒绝直接抛 `403` + +## 5.3 为什么不直接塞进 `IPermissionService` + +因为当前 `IPermissionService` 的语义非常清晰: + +- 它是“权限布尔检查器” + +如果直接把 scope 决策一起塞进去: + +- 现有 controller 依赖会变混乱 +- service 层也很难渐进迁移 + +更合理的做法是: + +- `PermissionServiceImpl` 继续做功能权限布尔校验 +- `PermissionScopeServiceImpl` 负责完整决策 + +--- + +## 6. Repository 骨架 + +## 6.1 `permissionGrantRepository.py` + +职责: + +- 加载用户所有 role / permission grant 明细 + +建议接口: + +```python +class PermissionGrantRepository: + async def ListUserPermissionGrants(self, UserId: int, PermissionKey: str) -> list[PermissionGrant]: + ... + + async def GetUserBaseIdentity(self, UserId: int) -> dict[str, Any]: + ... +``` + +推荐 SQL 思路: + +```sql +SELECT + r.id AS role_id, + r.role_key, + p.permission_key, + rp.grant_type, + r.data_scope AS role_scope, + rp.data_scope AS permission_scope, + COALESCE(r.priority, 0) AS priority, + COALESCE(r.is_system_role, FALSE) AS is_system_role, + rp.condition_filter +FROM user_role ur +JOIN roles r ON r.id = ur.role_id +JOIN role_permissions rp ON rp.role_id = r.id +JOIN permissions p ON p.id = rp.permission_id +WHERE ur.user_id = :user_id +``` + +然后在 Python 层执行: + +- 精确匹配 +- wildcard 匹配 +- `DENY/GRANT` 分类 + +## 6.2 `resourceScopeRepository.py` + +职责: + +- 为详情、下载、删除、导出类接口提供主资源回溯 + +建议接口: + +```python +class ResourceScopeRepository: + async def GetDocumentScopeSnapshot(self, DocumentId: int) -> dict[str, Any] | None: ... + async def GetGovdocRunScopeSnapshot(self, RunId: int) -> dict[str, Any] | None: ... + async def GetRagDatasetScopeSnapshot(self, DatasetId: int) -> dict[str, Any] | None: ... + async def GetCrossReviewProposalScopeSnapshot(self, ProposalId: int) -> dict[str, Any] | None: ... +``` + +这些方法不是做最终权限判断,而是提供: + +- 主资源地区 +- 主资源创建者 +- 关联任务/数据集/文档 ID + +供 policy 判断使用。 + +--- + +## 7. Resolver 骨架 + +## 7.1 `dataScopeResolver.py` + +职责: + +- 对 `PermissionGrant` 做范围合并 + +建议骨架: + +```python +class DataScopeResolver: + _SCOPE_RANK = { + "SELF": 1, + "DEPT": 2, + "ALL": 3, + } + + def resolve(self, grants: list[PermissionGrant]) -> tuple[bool, str | None, list[str]]: + exact_denies = [item for item in grants if item.grant_type == "DENY"] + if exact_denies: + return False, None, [] + + effective_scope = None + matched_roles: list[str] = [] + + for item in grants: + if item.grant_type != "GRANT": + continue + scope = item.permission_scope or item.role_scope or "SELF" + if effective_scope is None or self._SCOPE_RANK.get(scope, 0) > self._SCOPE_RANK.get(effective_scope, 0): + effective_scope = scope + matched_roles.append(item.role_key) + + return True, effective_scope, matched_roles +``` + +说明: + +- 第一版先解决 `ALL/DEPT/SELF` +- `PUBLIC_MIXED`、`RELATION` 不从库里直接出,而由 `ModulePolicy` 生成 + +## 7.2 `permissionDecisionService.py` + +职责: + +- 串联 grant repository、scope resolver、module policy + +建议骨架: + +```python +class PermissionDecisionService: + def __init__( + self, + grantRepository: PermissionGrantRepository, + scopeResolver: DataScopeResolver, + policyRegistry: ModulePolicyRegistry, + ) -> None: + self._grantRepository = grantRepository + self._scopeResolver = scopeResolver + self._policyRegistry = policyRegistry + + async def Decide(self, Context: ScopeContext) -> PermissionDecision: + grants = await self._grantRepository.ListUserPermissionGrants(Context.user_id, Context.permission_key) + allowed, effective_scope, matched_roles = self._scopeResolver.resolve(grants) + if not allowed: + return PermissionDecision( + allowed=False, + deny_reason="permission_denied", + effective_scope=None, + scope_mode="NONE", + ) + + base_decision = PermissionDecision( + allowed=True, + deny_reason=None, + effective_scope=effective_scope, + scope_mode=effective_scope or "SELF", + matched_roles=matched_roles, + matched_permissions=[Context.permission_key], + ) + + policy = self._policyRegistry.Get(Context.module) + return await policy.Decorate(Context, base_decision) +``` + +--- + +## 8. QueryScopeBuilder 骨架 + +## 8.1 核心职责 + +将 `PermissionDecision` 转成可直接拼接到 SQL 的子句。 + +## 8.2 建议骨架 + +```python +class QueryScopeBuilder: + def BuildByMapping( + self, + Decision: PermissionDecision, + Mapping: ScopeFieldMapping, + ScopeUserId: int, + ScopeArea: str | None, + RequestedArea: str | None = None, + RequestedUserId: int | None = None, + ) -> ScopeClause: + if not Decision.allowed: + return ScopeClause(sql="1 = 0", params={}, scope_mode="NONE", description="permission denied") + + if Decision.scope_mode == "ALL": + if RequestedArea and Mapping.area_field: + return ScopeClause( + sql=f"COALESCE({Mapping.area_field}, '') = :requested_area", + params={"requested_area": RequestedArea}, + scope_mode="ALL", + description="global scope with requested area", + ) + return ScopeClause(sql="1 = 1", params={}, scope_mode="ALL", description="global scope") + + if Decision.scope_mode == "DEPT": + if not ScopeArea: + return ScopeClause(sql="1 = 0", params={}, scope_mode="DEPT", description="missing user area") + if RequestedArea and RequestedArea != ScopeArea: + return ScopeClause(sql="1 = 0", params={}, scope_mode="DEPT", description="requested area out of scope") + return ScopeClause( + sql=f"COALESCE({Mapping.area_field}, '') = :scope_area", + params={"scope_area": ScopeArea}, + scope_mode="DEPT", + description="same area scope", + ) + + if Decision.scope_mode == "SELF": + user_field = Mapping.creator_field or Mapping.owner_field or Mapping.user_field + if not user_field: + return ScopeClause(sql="1 = 0", params={}, scope_mode="SELF", description="missing self mapping") + if RequestedUserId is not None and RequestedUserId != ScopeUserId: + return ScopeClause(sql="1 = 0", params={}, scope_mode="SELF", description="requested user out of scope") + return ScopeClause( + sql=f"{user_field} = :scope_user_id", + params={"scope_user_id": ScopeUserId}, + scope_mode="SELF", + description="self scope", + ) + + return ScopeClause(sql="1 = 0", params={}, scope_mode="CUSTOM", description="unsupported generic scope") +``` + +--- + +## 9. Policy 骨架 + +## 9.1 基础接口 + +建议文件:`policies/base.py` + +```python +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.services.permission_scope.models import PermissionDecision, ScopeClause, ScopeContext + + +class BaseModulePolicy(ABC): + @abstractmethod + async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: + ... + + @abstractmethod + async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause | None: + ... +``` + +## 9.2 `DocumentPolicy` + +建议职责: + +1. 保持 `ALL/DEPT/SELF` +2. 固定文档模块字段映射为: + - `d.region` + - `f.created_by` +3. 对详情、状态、附件、确认等接口复用同一资源规则 + +建议骨架: + +```python +class DocumentPolicy(BaseModulePolicy): + def __init__(self, Builder: QueryScopeBuilder) -> None: + self._builder = Builder + + async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: + return Decision + + async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: + return self._builder.BuildByMapping( + Decision=Decision, + Mapping=ScopeFieldMapping(area_field="d.region", creator_field="f.created_by"), + ScopeUserId=Context.user_id, + ScopeArea=Context.user_area, + RequestedArea=Context.request_area, + RequestedUserId=Context.target_user_id, + ) +``` + +## 9.3 `RagPolicy` + +建议职责: + +1. 读接口转为 `PUBLIC_MIXED` +2. 管理接口仍回到 `ALL/DEPT/SELF` + +建议骨架: + +```python +class RagPolicy(BaseModulePolicy): + def __init__(self, Builder: QueryScopeBuilder) -> None: + self._builder = Builder + + async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: + if Context.permission_key in {"rag:app:read", "rag:chat:use", "rag:dataset:read"}: + Decision.scope_mode = "PUBLIC_MIXED" + Decision.allow_public = True + Decision.allowed_areas = [area for area in [Context.user_area, "省级", ""] if area is not None] + return Decision + + async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: + if Decision.scope_mode == "PUBLIC_MIXED": + return ScopeClause( + sql="(COALESCE(dataset.area, '') IN :visible_areas OR dataset.is_public = TRUE)", + params={"visible_areas": Decision.allowed_areas}, + scope_mode="PUBLIC_MIXED", + description="rag public mixed scope", + ) + return self._builder.BuildByMapping( + Decision=Decision, + Mapping=ScopeFieldMapping(area_field="dataset.area", creator_field="dataset.created_by", public_field="dataset.is_public"), + ScopeUserId=Context.user_id, + ScopeArea=Context.user_area, + RequestedArea=Context.request_area, + ) +``` + +## 9.4 `CrossReviewPolicy` + +建议职责: + +- 把普通 `SELF/DEPT` 转成关系型 `RELATION` + +示意: + +```python +class CrossReviewPolicy(BaseModulePolicy): + async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: + if Decision.allowed: + Decision.scope_mode = "RELATION" + return Decision + + async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: + return ScopeClause( + sql=( + "EXISTS (" + "SELECT 1 FROM leaudit_cross_review_task_member tm " + "WHERE tm.task_id = task.id " + "AND tm.user_id = :scope_user_id " + "AND tm.deleted_at IS NULL" + ")" + ), + params={"scope_user_id": Context.user_id}, + scope_mode="RELATION", + description="cross review relation scope", + ) +``` + +--- + +## 10. Facade 骨架 + +建议文件:`facade/scopeAwarePermissionFacade.py` + +职责: + +给各个业务 service 一个最直接的调用入口。 + +```python +class ScopeAwarePermissionFacade: + def __init__( + self, + DecisionService: PermissionDecisionService, + PolicyRegistry: ModulePolicyRegistry, + ) -> None: + self._decisionService = DecisionService + self._policyRegistry = PolicyRegistry + + async def Require(self, Context: ScopeContext) -> tuple[PermissionDecision, ScopeClause | None]: + decision = await self._decisionService.Decide(Context) + if not decision.allowed: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有访问权限") + policy = self._policyRegistry.Get(Context.module) + clause = await policy.BuildClause(Context, decision) + return decision, clause +``` + +这样业务 service 的接入就会很轻: + +```python +decision, scope_clause = await self.ScopeFacade.Require(...) +``` + +--- + +## 11. Controller 接入策略 + +## 11.1 第一阶段不强制改 controller + +当前 controller 大多已经有: + +- `verify_access_token` +- `PermissionService.CheckPermission()` + +第一阶段不建议全部改掉。 + +建议策略: + +1. controller 继续做功能权限预筛 +2. service 内再做统一 scope 决策 + +原因: + +- 改动更可控 +- 避免一次性动太多 controller + +## 11.2 第二阶段再统一 controller + +等统一执行器稳定后,可以进一步做: + +- controller 不再自行 `CheckPermission` +- 改为 service 内一站式处理 + +但这不是第一批落地的必要条件。 + +--- + +## 12. `documentServiceImpl` 接入示例 + +## 12.1 当前问题 + +当前文档模块有: + +- `_getCurrentUserContext` +- `_buildDocumentScopeFilters` + +它们本质上就是统一执行器的前身,但还停留在角色硬编码层。 + +## 12.2 推荐替换方式 + +### 原有思路 + +```python +currentUser = await self._getCurrentUserContext(CurrentUserId) +filters = self._buildDocumentScopeFilters(...) +``` + +### 改造后思路 + +```python +context = ScopeContext( + user_id=CurrentUserId, + permission_key="documents:list:read", + module="documents", + action="read", + user_area=payload_area, + request_area=Region, + target_user_id=UserId, +) +decision, scope_clause = await self.ScopeFacade.Require(context) +``` + +然后在 SQL 中: + +```python +filters = [ + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + scope_clause.sql, +] +params = { + **scope_clause.params, +} +``` + +## 12.3 示例骨架 + +```python +async def ListDocuments(...): + identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) + context = ScopeContext( + user_id=CurrentUserId, + permission_key="documents:list:read", + module="documents", + action="read", + user_area=identity.get("area"), + request_area=Region, + target_user_id=UserId, + ) + _, scope_clause = await self.ScopeFacade.Require(context) + + where_clauses = [ + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + scope_clause.sql, + ] + params: dict[str, Any] = { + **scope_clause.params, + } + + if Keyword: + where_clauses.append("d.document_name ILIKE :keyword") + params["keyword"] = f"%{Keyword}%" + + sql = f""" + SELECT ... + FROM leaudit_documents d + JOIN leaudit_document_files f ON f.document_id = d.id + WHERE {' AND '.join(where_clauses)} + """ +``` + +## 12.4 详情接口示例 + +```python +async def GetDocument(self, CurrentUserId: int, Id: int) -> DocumentDetailVO: + identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) + context = ScopeContext( + user_id=CurrentUserId, + permission_key="documents:detail:read", + module="documents", + action="read", + user_area=identity.get("area"), + resource_id=Id, + ) + _, scope_clause = await self.ScopeFacade.Require(context) + + row = ( + await session.execute( + text( + f""" + SELECT ... + FROM leaudit_documents d + JOIN leaudit_document_files f ON f.document_id = d.id + WHERE d.id = :document_id + AND {scope_clause.sql} + """ + ), + {"document_id": Id, **scope_clause.params}, + ) + ).mappings().first() +``` + +重点: + +- 把 scope 直接写进详情查询 +- 不再“先查后判” + +--- + +## 13. `govdocServiceImpl` 接入示例 + +## 13.1 当前问题 + +公文模块里最大风险不是列表,而是: + +- `runId` +- `report/docx` +- `original` + +这些派生接口。 + +## 13.2 推荐接法 + +先统一一个内部方法: + +```python +async def _getScopedGovdocDocument( + self, + CurrentUserId: int, + PermissionKey: str, + DocumentId: int, +) -> dict[str, Any]: + ... +``` + +所有: + +- `GetDocumentDetail` +- `CreateRun` +- `DownloadOriginal` + +都先用它。 + +### 对 `runId` 类接口 + +新增一个仓储回溯: + +```python +snapshot = await self.ResourceScopeRepository.GetGovdocRunScopeSnapshot(runId) +document_id = snapshot["document_id"] +document = await self._getScopedGovdocDocument(CurrentUserId, "govdoc:run:read", document_id) +``` + +这样所有 run 结果、报告下载都回到 document scope。 + +--- + +## 14. `ragDatasetServiceImpl` 接入示例 + +## 14.1 当前问题 + +当前签名大量透传: + +- `UserArea` +- `UserRole` + +这就是旧架构的直接暴露。 + +## 14.2 第一阶段兼容策略 + +第一阶段不需要立刻改所有接口签名。 + +可以先保留: + +```python +async def GetAdminDatasets(self, CurrentUserId, UserArea, UserRole, Area, OnlyEnabled, Page, PageSize) +``` + +但内部不再使用 `UserRole`。 + +### 示例 + +```python +async def GetAdminDatasets(...): + context = ScopeContext( + user_id=CurrentUserId, + permission_key="rag:dataset:manage", + module="rag", + action="read", + user_area=UserArea, + request_area=Area, + ) + _, scope_clause = await self.ScopeFacade.Require(context) +``` + +然后把旧逻辑: + +```python +if UserRole not in ("provincial_admin", "admin", "super_admin"): + raise ... +``` + +全部删掉。 + +## 14.3 第二阶段签名优化 + +稳定后再把 service 接口签名收敛为: + +```python +async def GetAdminDatasets(self, CurrentUserId: int, Area: str | None, OnlyEnabled: bool | None, Page: int, PageSize: int) +``` + +即: + +- `UserArea` +- `UserRole` + +不再从 controller 显式透传。 + +而是在 service 内统一从 identity/repository 获取。 + +## 14.4 数据集子资源示例 + +```python +async def GetDatasetDocumentDetail(...): + identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) + context = ScopeContext( + user_id=CurrentUserId, + permission_key="rag:dataset:read", + module="rag", + action="read", + user_area=identity.get("area"), + resource_id=DatasetId, + ) + _, dataset_scope_clause = await self.ScopeFacade.Require(context) + + sql = f""" + SELECT ... + FROM rag_datasets dataset + JOIN rag_documents doc ON doc.dataset_id = dataset.id + WHERE dataset.id = :dataset_id + AND doc.id = :document_id + AND {dataset_scope_clause.sql} + """ +``` + +重点: + +- 文档详情跟随 dataset scope +- 不再自己判断角色 + +--- + +## 15. `rbacAdminServiceImpl` 接入示例 + +## 15.1 当前问题 + +当前 `_assertManagePermission` 和 `_assertPermission` 双轨并存。 + +还存在: + +```sql +bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')) AS can_manage +``` + +## 15.2 推荐做法 + +角色管理对象本身和用户管理对象分开处理。 + +### 角色元数据接口 + +例如: + +- `ListRoles` +- `CreateRole` +- `SaveRolePermissions` + +主要按功能权限控制,不做 area scope。 + +### 用户相关接口 + +例如: + +- `ListUsers` +- `AssignUserRoles` +- `GetUserRoles` + +要做用户 area scope。 + +## 15.3 `ListUsers` 示例 + +```python +async def ListUsers(...): + identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) + context = ScopeContext( + user_id=CurrentUserId, + permission_key="rbac:users:read", + module="rbac_admin", + action="read", + user_area=identity.get("area"), + request_area=Area, + ) + _, scope_clause = await self.ScopeFacade.Require(context) + + sql = f""" + SELECT ... + FROM sso_users u + WHERE u.deleted_at IS NULL + AND {scope_clause.sql} + """ +``` + +`RbacAdminPolicy` 内固定映射: + +- `area_field = u.area` + +## 15.4 `AssignUserRoles` 示例 + +建议先做用户合法性校验: + +```python +target_context = ScopeContext( + user_id=CurrentUserId, + permission_key="rbac:user_roles:write", + module="rbac_admin", + action="write", + user_area=identity.get("area"), + resource_id=UserId, +) +_, scope_clause = await self.ScopeFacade.Require(target_context) +``` + +然后先查: + +```sql +SELECT u.id +FROM sso_users u +WHERE u.id = :target_user_id + AND {scope_clause.sql} +``` + +只有通过后才执行角色绑定。 + +--- + +## 16. 接口签名迁移建议 + +## 16.1 第一阶段 + +原则: + +- 不强行修改所有抽象接口签名 +- 优先兼容现有 controller + +例如 RAG service 可以暂时保留: + +- `UserArea` +- `UserRole` + +但内部只真正使用: + +- `CurrentUserId` +- `UserArea` + +并逐步废弃 `UserRole` + +## 16.2 第二阶段 + +当统一执行器稳定后,建议收敛为: + +1. controller 只传 `CurrentUserId` +2. service 自己取基础身份 +3. `ScopeContext` 统一构造 + +这一步是“收口优化”,不是第一批落地阻塞项。 + +--- + +## 17. 日志与异常建议 + +## 17.1 统一异常 + +建议新增: + +```python +class PermissionScopeDeniedException(LeauditException): + ... +``` + +用于区分: + +- 功能权限拒绝 +- 数据范围拒绝 +- 资源不存在 + +## 17.2 日志建议 + +每次 scope 拒绝建议记录: + +```python +logger.warning( + "scope denied", + extra={ + "user_id": Context.user_id, + "permission_key": Context.permission_key, + "module": Context.module, + "resource_id": Context.resource_id, + "request_area": Context.request_area, + "scope_mode": decision.scope_mode, + "deny_reason": decision.deny_reason, + }, +) +``` + +这对后续排查误拒绝和越权都很重要。 + +--- + +## 18. 单元测试骨架建议 + +建议至少补以下测试: + +### `DataScopeResolver` + +- `GRANT SELF + GRANT DEPT -> DEPT` +- `GRANT ALL + DENY exact -> denied` +- `permission_scope 覆盖 role_scope` + +### `QueryScopeBuilder` + +- `ALL` +- `DEPT` +- `SELF` +- `SELF + requested_user_id != scope_user_id` + +### `RagPolicy` + +- `rag:dataset:read -> PUBLIC_MIXED` +- `rag:dataset:update -> 保持 ALL/DEPT/SELF` + +### `CrossReviewPolicy` + +- `allowed -> RELATION` + +--- + +## 19. 推荐实施顺序 + +代码落地建议按下面顺序推进: + +1. 建 `models / resolver / builder / policy base` +2. 建 `DocumentPolicy` +3. 用文档模块先跑通一条完整链路 +4. 建 `RagPolicy`,清理 RAG 角色白名单 +5. 建 `GovdocPolicy`,收口 run/report/download +6. 建 `UsageStatsPolicy` +7. 建 `RbacAdminPolicy` +8. 最后收敛 service 签名 + +这样做的好处是: + +- 先拿最典型模块验证设计 +- 再扩到复杂特例 +- 避免一开始就改满全仓库 + +--- + +## 20. 最终判断标准 + +这份代码骨架真正落地完成,不是看文件建没建,而是看下面几件事是否成立: + +1. 文档模块不再依赖 `_getCurrentUserContext + role_key IN (...)` +2. RAG 模块不再依赖 `UserRole` 白名单 +3. 公文下载/报告接口已通过主资源回溯做 scope 判断 +4. RBAC 用户管理域不再通过角色名派生 `can_manage` +5. 新增一个接口时,研发可以直接按 `ScopeContext -> Require -> ScopeClause` 方式接入 + +如果还做不到第 5 条,说明统一执行器还没有真正成为平台能力。 diff --git a/docs/权限与地区隔离/统一数据范围执行器设计.md b/docs/权限与地区隔离/统一数据范围执行器设计.md new file mode 100644 index 0000000..f48c7fb --- /dev/null +++ b/docs/权限与地区隔离/统一数据范围执行器设计.md @@ -0,0 +1,709 @@ +# 统一数据范围执行器设计 + +> 适用范围:`leaudit-platform` 当前 `RBAC + 单地区隔离 + 模块特例策略` 体系 +> 文档定位:把“data_scope 已建模但未统一执行”的现状,收敛为一套后端可落地的统一执行器设计。 + +--- + +## 1. 设计结论 + +当前系统真正缺的不是权限表,也不是权限点,而是一个统一的“数据范围执行层”。 + +现状已经具备: + +- `roles.data_scope` +- `role_permissions.data_scope` +- `role_permissions.grant_type = GRANT / DENY` +- 用户主地区 `sso_users.area` +- 多个模块已存在真实可运行的数据边界实现 + +当前系统真正缺失的是: + +1. 平台层没有把“功能权限 + 数据范围 + 模块特例策略”合并成一次统一决策 +2. `PermissionServiceImpl` 只返回布尔值,不返回 scope 决策 +3. 文档、公文、统计、合同模板、RBAC 管理域都在各自重复写 `is_global/can_manage/area/created_by` +4. RAG、交叉评查等特殊模块没有被纳入统一决策框架,只能继续靠模块内手工判断 + +因此,建议新增统一执行链: + +1. `PermissionDecisionService` +2. `DataScopeResolver` +3. `QueryScopeBuilder` +4. `ModulePolicyRegistry` +5. `ScopeAwarePermissionFacade` + +目标不是推翻当前业务 SQL,而是先把“决策入口”和“范围解释逻辑”统一,再逐模块替换已有散落实现。 + +--- + +## 2. 统一执行器要解决什么问题 + +## 2.1 功能准入和数据范围必须拆开 + +一个接口是否可调用,与一个用户调用后能看到多少数据,是两层不同问题: + +- 功能准入:是否拥有 `permission_key` +- 数据范围:即便拥有该权限,可见范围到底是 `ALL / DEPT / SELF / RELATION / PUBLIC_MIXED` + +当前系统多数地方只做了第一层,第二层分散在 service 内。 + +## 2.2 多角色用户必须有统一合并规则 + +当前库允许一个用户绑定多个角色: + +- 一个角色可能 `GRANT documents:list:read + SELF` +- 另一个角色可能 `GRANT documents:list:read + DEPT` +- 还有角色可能 `DENY documents:list:read` + +没有统一合并规则,就会出现: + +- 有人按最大范围放行 +- 有人按主角色放行 +- 有人忽略 `DENY` + +## 2.3 模块差异必须被显式建模 + +不是所有模块都适合硬套 `ALL/DEPT/SELF`: + +- 文档、公文、统计:典型数据范围模型 +- RAG:`地区 + 公开(public)` 混合模型 +- 交叉评查:任务成员关系模型 +- 规则/配置域:更偏功能权限域 + +所以统一执行器不能只有一个“拼 where region = ?”函数,而要允许模块策略扩展。 + +--- + +## 3. 总体架构 + +建议新增如下能力层。 + +## 3.1 核心对象 + +### 3.1.1 `ScopeContext` + +表示一次权限决策的输入上下文。 + +建议字段: + +```python +@dataclass +class ScopeContext: + user_id: int + permission_key: str + module: str + action: str + user_area: str | None + request_area: str | None = None + target_user_id: int | None = None + resource_id: int | None = None + extra_filters: dict[str, Any] | None = None +``` + +说明: + +- `user_area` 来自 `sso_users.area` +- `request_area` 是接口显式传入的筛选地区 +- `target_user_id` 适用于按用户过滤的查询 +- `resource_id` 适用于详情、删除、下载这类单资源接口 +- `extra_filters` 给统计、RAG、交叉评查等模块挂业务参数 + +### 3.1.2 `PermissionGrant` + +表示从数据库查出的单条授权记录。 + +```python +@dataclass +class PermissionGrant: + role_id: int + role_key: str + permission_key: str + grant_type: str + role_scope: str | None + permission_scope: str | None + condition_filter: dict[str, Any] | None + priority: int + is_system_role: bool +``` + +### 3.1.3 `PermissionDecision` + +表示平台最终产出的权限决策。 + +```python +@dataclass +class PermissionDecision: + allowed: bool + deny_reason: str | None + effective_scope: str | None + scope_mode: str + allowed_areas: list[str] + allowed_user_ids: list[int] + allow_public: bool + conditions: dict[str, Any] + matched_roles: list[str] + matched_permissions: list[str] +``` + +建议 `scope_mode` 取值: + +- `NONE`:不做数据范围控制 +- `ALL` +- `DEPT` +- `SELF` +- `RELATION` +- `PUBLIC_MIXED` +- `CUSTOM` + +### 3.1.4 `ScopeClause` + +表示最终用于拼接 SQL 的范围子句。 + +```python +@dataclass +class ScopeClause: + sql: str + params: dict[str, Any] + scope_mode: str + description: str +``` + +--- + +## 4. 执行链路 + +建议所有需要“权限 + 数据边界”的 service 统一走下面链路: + +```text +Controller + -> ScopeAwarePermissionFacade.require() + -> PermissionDecisionService.decide() + -> PermissionGrantRepository.load_user_grants() + -> DataScopeResolver.resolve() + -> ModulePolicyRegistry.get(module).decorate() + -> QueryScopeBuilder.build() + -> Service 执行业务 SQL / ORM +``` + +细化如下: + +1. 控制器或 service 构造 `ScopeContext` +2. `PermissionDecisionService` 先确认该用户是否拥有功能权限 +3. 若被 `DENY` 命中,直接拒绝 +4. 若 `GRANT` 命中,则计算有效范围 +5. 若模块存在特例策略,由 `ModulePolicy` 二次修饰决策 +6. 由 `QueryScopeBuilder` 将决策转换为统一 SQL 子句 +7. 业务 service 只负责把子句注入原查询,不再自行解释角色 + +--- + +## 5. 数据来源与优先级规则 + +## 5.1 数据来源 + +统一执行器的决策数据来自 4 层: + +1. `sso_users.area` +2. `roles.data_scope` +3. `role_permissions.data_scope` +4. `role_permissions.grant_type / condition_filter` + +## 5.2 功能准入优先级 + +功能准入保持现有语义,但要扩展为“带授权明细返回”: + +1. 精确 `DENY` +2. 通配符 `DENY` +3. 精确 `GRANT` +4. 通配符 `GRANT` +5. 无匹配则拒绝 + +这部分沿用 `PermissionServiceImpl` 现有规则,不改语义。 + +## 5.3 范围优先级 + +建议统一采用: + +1. `role_permissions.data_scope` +2. `roles.data_scope` +3. 模块默认值 + +即: + +- 权限级 scope 覆盖角色级 scope +- 角色级 scope 覆盖模块默认值 +- 若都没有,读接口默认 `SELF`,配置接口默认 `NONE` + +## 5.4 多角色合并规则 + +建议采用: + +1. 先按 `DENY` 决定是否整体拒绝 +2. 在所有有效 `GRANT` 中取“最大可见范围” +3. 若存在模块策略,则再由模块策略收敛边界 + +范围大小建议定义为: + +```text +ALL > DEPT > SELF +GROUP 不再扩展,仅兼容映射 +RELATION / PUBLIC_MIXED / CUSTOM 由模块策略生成 +``` + +注意: + +- `DENY` 是功能拒绝,不只是 scope 收缩 +- 不能把一个 `DENY documents:list:read` 理解成“降到 SELF” +- 一旦该权限被 `DENY` 命中,应直接 `allowed = False` + +## 5.5 `GROUP` 的处理 + +库里仍保留 `GROUP`,但当前系统没有成熟“组织组”模型。 + +建议: + +- 现阶段统一映射为 `DEPT` +- 文档和代码中明确标注“仅兼容,不新增使用” +- 后续如果真要启用组织组,再单独扩展 + +--- + +## 6. 范围语义规范 + +## 6.1 `ALL` + +含义: + +- 可见该模块全部数据 +- 可按任意地区或任意用户进一步筛选 + +适用: + +- 省级管理员 +- 超级管理员 +- 某些总部配置权限 + +## 6.2 `DEPT` + +本项目中 `DEPT` 语义应明确定义为: + +- 同主地区 `area` + +不是传统部门树。 + +含义: + +- 只能看与自身 `user_area` 相同地区的数据 +- 如接口显式传入 `request_area`,则必须等于 `user_area` + +## 6.3 `SELF` + +含义: + +- 仅可看与自己直接归属的数据 + +优先字段通常是: + +- `created_by` +- `owner_id` +- `user_id` + +必须按模块字段映射规范执行,不能每个模块再自行猜测。 + +## 6.4 `RELATION` + +适用于交叉评查等成员关系模型。 + +含义: + +- 可访问与当前用户存在显式关系绑定的数据 + +例如: + +- `task_member.user_id = current_user_id` +- `proposal.created_by = current_user_id` +- `document` 属于当前参与任务 + +## 6.5 `PUBLIC_MIXED` + +适用于 RAG。 + +含义: + +- 本地区资源可见 +- 公共资源可见 +- 省级公共资源可见 + +本质条件通常是: + +```sql +resource.area IN (:user_area, '省级', '') +OR resource.is_public = TRUE +``` + +--- + +## 7. 通用字段映射规范 + +统一执行器要避免“模块自己猜字段名”,必须先定义字段映射表。 + +## 7.1 推荐映射对象 + +```python +@dataclass +class ScopeFieldMapping: + area_field: str | None = None + creator_field: str | None = None + owner_field: str | None = None + user_field: str | None = None +``` + +## 7.2 模块字段规范 + +### 文档模块 + +- area 字段:`d.region` +- self 字段:`f.created_by` 或主文档归属用户 + +说明: + +- 当前文档真实归属更多落在文件记录 `created_by` +- 统一接入时要固定“列表/详情/附件/结果”的归属判断口径 + +### 公文模块 + +- area 字段:`d.region` +- self 字段:`f.created_by` + +### 使用统计模块 + +按口径不同分两类: + +- 用户口径 area:`u.area` +- 文档口径 area:`d.region` +- self 字段:`f.created_by` 或 `u.id` + +### 合同模板模块 + +- area 字段:`t.region` +- self 字段:`t.created_by` + +### RBAC 用户管理 + +- area 字段:`u.area` +- self 字段:通常不用 + +### RAG 数据集/应用 + +- area 字段:`dataset.area` / `app.area` +- self 字段:按创建者字段补充 +- public 字段:`is_public` + +### 交叉评查 + +- 不优先走 `area_field` +- 主入口走关系字段: + - `task_member.user_id` + - `proposal.created_by` + - `vote.user_id` + +--- + +## 8. 模块策略设计 + +统一执行器不能只靠 `ALL/DEPT/SELF`,必须允许模块策略。 + +## 8.1 `ModulePolicy` 接口 + +```python +class ModulePolicy(Protocol): + def decorate(self, ctx: ScopeContext, decision: PermissionDecision) -> PermissionDecision: ... + def build_clause(self, ctx: ScopeContext, decision: PermissionDecision) -> ScopeClause | None: ... +``` + +说明: + +- `decorate()` 负责把通用 scope 转成模块实际策略 +- `build_clause()` 负责拼接模块专属条件 + +## 8.2 DocumentPolicy + +适用模块: + +- 文档列表 +- 文档详情 +- 文档状态 +- 评查点聚合 +- 附件追加 +- 文档确认 + +建议策略: + +- `ALL`:不加地区限制,但允许 `request_area` +- `DEPT`:固定 `d.region = user_area` +- `SELF`:固定 `f.created_by = current_user_id` +- 单资源接口必须先验证资源是否在 scope 内,再做更新/删除 + +## 8.3 GovdocPolicy + +建议策略: + +- 与文档模块同一套 area/self 语义 +- `run/result/report/download` 等派生接口必须继承 document scope +- 不能只校验 `run_id` 是否存在,必须回溯到所属 `document_id` + +## 8.4 UsageStatsPolicy + +建议策略: + +- `overview/trends/by-areas`:支持 `ALL/DEPT` +- `by-users/by-departments/details`:支持 `ALL/DEPT/SELF` +- `SELF` 时只允许看到自己的登录、上传、评查明细 +- `areaScope=document` 与 `areaScope=user` 由模块策略切换字段映射 + +## 8.5 RagPolicy + +建议策略: + +- 读取类接口:`PUBLIC_MIXED` +- 管理类接口:`ALL/DEPT/SELF` + 资源属地校验 + +拆分为两类: + +1. `rag:app:read` / `rag:chat:use` / `rag:dataset:read` + - 允许 `本地区 + 省级 + 公共` +2. `rag:dataset:manage/create/update/delete` + - 不再按 `UserRole` 白名单 + - 统一按 `permission + scope` 决定 + +## 8.6 CrossReviewPolicy + +建议策略: + +- 主模型使用 `RELATION` +- `task:read` 不是 area 过滤,而是成员关系过滤 +- `document:complete` 不是角色白名单,而是“拥有权限 + 是任务参与者/负责方” +- `proposal:read/export` 必须通过文档所属任务关系回溯校验 + +结论: + +- 交叉评查接入统一执行器 +- 但不强制落到普通 `ALL/DEPT/SELF` +- 它是统一框架下的特例策略,不是例外代码 + +## 8.7 RbacAdminPolicy + +建议策略: + +- 角色、用户、组织树、路由、授权查询等接口优先按 `u.area` +- 全局管理员看全部 +- 地区管理员仅管理本地区用户 +- 角色对象本身通常不按地区分表,但“角色绑定到哪些用户”仍受用户 area 边界约束 + +--- + +## 9. QueryScopeBuilder 设计 + +## 9.1 目标 + +把 `PermissionDecision` 转成业务可注入的条件,而不是让业务自己重新解释。 + +## 9.2 建议接口 + +```python +class QueryScopeBuilder: + def build_by_mapping( + self, + decision: PermissionDecision, + mapping: ScopeFieldMapping, + current_user_id: int, + user_area: str | None, + request_area: str | None = None, + ) -> ScopeClause: ... +``` + +## 9.3 通用构造规则 + +### `ALL` + +```sql +1 = 1 +``` + +若带 `request_area`: + +```sql +COALESCE(area_field, '') = :request_area +``` + +### `DEPT` + +```sql +COALESCE(area_field, '') = :scope_area +``` + +并且: + +- 若 `request_area` 非空且不等于 `user_area`,直接拒绝 + +### `SELF` + +优先级: + +1. `creator_field` +2. `owner_field` +3. `user_field` + +生成: + +```sql +creator_field = :current_user_id +``` + +### `PUBLIC_MIXED` + +```sql +( + COALESCE(area_field, '') IN :visible_areas + OR public_field = TRUE +) +``` + +### `RELATION` + +由模块策略返回完整 SQL 子句,不走通用拼接。 + +--- + +## 10. 权限决策缓存建议 + +当前 `PermissionServiceImpl` 已有 60 秒权限集合缓存。 + +建议统一执行器增加两层缓存: + +1. 用户授权明细缓存 +2. 用户能力快照缓存 + +建议缓存内容: + +- grant / deny 结果 +- 每个 `permission_key` 对应的有效 scope +- 用户 `area` +- 用户角色列表 + +注意: + +- 缓存里只存决策原料,不缓存最终 SQL +- 角色变更、权限变更、用户地区变更后,应主动失效 + +--- + +## 11. 建议代码落点 + +建议新增目录: + +```text +fastapi_modules/fastapi_leaudit/services/permission_scope/ + decision_models.py + permissionDecisionService.py + dataScopeResolver.py + queryScopeBuilder.py + modulePolicies/ + base.py + documentPolicy.py + govdocPolicy.py + usageStatsPolicy.py + ragPolicy.py + crossReviewPolicy.py + rbacAdminPolicy.py +``` + +建议改造现有: + +- `services/impl/permissionServiceImpl.py` + - 保留布尔接口 + - 新增返回授权明细的方法 +- `services/permissionService.py` + - 增加 `GetPermissionDecision` +- `services/impl/documentServiceImpl.py` +- `services/impl/govdocServiceImpl.py` +- `services/impl/usageStatsServiceImpl.py` +- `services/impl/ragDatasetServiceImpl.py` +- `services/impl/ragChatServiceImpl.py` +- `services/impl/rbacAdminServiceImpl.py` +- `services/impl/contractTemplateServiceImpl.py` + +--- + +## 12. 推荐接入顺序 + +## 12.1 第一批 + +- RAG +- 文档 +- 公文 +- 使用统计 + +原因: + +- 这几块最明显存在范围逻辑分散或双轨冲突 +- 风险最高,收益也最大 + +## 12.2 第二批 + +- RBAC 管理域 +- 合同模板 +- 首页入口模块 + +## 12.3 第三批 + +- 交叉评查接入统一决策框架 +- 前端联动改为只认权限与服务端返回能力 + +--- + +## 13. 与现有代码的映射关系 + +当前散落逻辑,建议按下述替换: + +- `documentServiceImpl._getCurrentUserContext` + - 替换为 `PermissionDecisionService + ScopeContext` +- `documentServiceImpl._buildDocumentScopeFilters` + - 替换为 `QueryScopeBuilder + DocumentPolicy` +- `govdocServiceImpl` 内 area/created_by 过滤 + - 替换为 `GovdocPolicy` +- `usageStatsServiceImpl._build_user_scope_condition` + - 替换为 `UsageStatsPolicy` +- `contractTemplateServiceImpl` 内 `is_global/can_manage` + - 替换为 `ScopeDecision` +- `rbacAdminServiceImpl` 内 `bool_or(role_key IN (...))` + - 替换为统一管理域能力决策 +- `ragDatasetServiceImpl` / `ragChatServiceImpl` 内 `UserRole` 白名单 + - 替换为 `RagPolicy` + +--- + +## 14. 验收标准 + +统一执行器设计落地后,应满足以下标准: + +1. 新增一个 permission 时,可以同时配置 data_scope,并被平台自动执行 +2. 文档、公文、统计、RAG 不再自行硬编码角色解释范围 +3. `DENY`、多角色合并、地区限制有统一规则 +4. 详情、删除、下载、导出接口与列表接口共享同一 scope 决策 +5. 前端不再需要猜测“省级管理员/市级管理员能看到哪些数据” + +--- + +## 15. 最终建议 + +这次改造不应该继续扩写更多 `is_global/can_manage` 工具函数,而应直接建立统一执行器。 + +如果继续在各 service 内复制现有模式,后续问题会持续扩大: + +- 数据范围无法统一验收 +- 新角色接入成本持续上升 +- 前后端边界会长期不一致 +- RAG、交叉评查这类特例模块会越来越难治理 + +统一数据范围执行器是本轮权限架构改造的核心平台能力,优先级应为最高。 diff --git a/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md b/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md new file mode 100644 index 0000000..a0831a6 --- /dev/null +++ b/docs/权限与地区隔离/自定义租户功能连带影响深度补充.md @@ -0,0 +1,325 @@ +# 自定义租户功能连带影响深度补充 + +> 适用范围:新增自定义租户、扩展新地区、入口模块租户化、权限执行器接租户边界的全链路影响分析 +> 文档定位:补齐“入口模块之外,还有哪些地方会因为自定义租户而出问题”的深度清单,避免只修表面能力。 + +--- + +## 1. 结论先行 + +“新增一个租户”在当前系统里绝对不是只增加一条主数据。 + +如果只完成: + +1. 建一条租户记录 +2. 入口模块页面能选到它 + +但不继续处理其他连带点,系统仍会在多个模块出现隐性故障。 + +已确认至少有 8 条受影响链路: + +1. 首页入口可见性 +2. 交叉评查入口判定 +3. 文档上传默认归属 +4. 文档列表与评查数据隔离 +5. RAG 数据集与聊天应用归属 +6. 合同模板公共范围与地区筛选 +7. 使用统计地区口径 +8. RBAC 用户组织树与租户分组 + +此外还有两类经常被忽略的次生问题: + +1. SQL seed 和注释仍然写死旧地区集合 +2. OSS/对象路径里残留旧地区缩写 + +--- + +## 2. 首页入口链路影响 + +## 2.1 首页入口不是展示问题,而是权限边界入口 + +`homeServiceImpl.py` 当前会根据: + +1. `user_area` +2. `em.areas[].area` +3. `default` +4. `super_admin bypass` + +决定用户能看到什么入口。 + +这意味着: + +1. 新租户若没有被标准化到首页入口可见性链路,用户登录后首页就是残缺的 +2. 入口模块即使在后台配置成功,也可能对新租户不可见 + +## 2.2 首页最容易出现的 3 个问题 + +1. 用户已属于新租户,但首页没有任何业务入口 +2. 新租户误看到了旧公共入口 +3. 公共入口被错误地限定成某个租户专属入口 + +--- + +## 3. 交叉评查链路影响 + +## 3.1 前端自带固定地区推断 + +`cross-checking-access.ts` 当前做法: + +1. 写死固定地区别名数组 +2. 通过字符串包含关系猜测地区 +3. `provincial_admin` 自动追加 `省局` + +这套逻辑对自定义租户天然不兼容。 + +## 3.2 为什么这是高风险点 + +交叉评查不是普通菜单,而是: + +1. 首页入口 +2. 路由授权 +3. 业务流程入口 + +三者叠加的模块。 + +只要其中一层还依赖旧地区字符串,新租户就会出现: + +1. 首页看得见但点不进 +2. 有路由权限但首页没有入口 +3. 能进入页面但实际数据域不对 + +--- + +## 4. 文档上传与文档主表影响 + +## 4.1 上传接口默认 `region=default` + +`documentController.py` 当前上传接口默认: + +```python +region: str = Form("default") +``` + +这意味着: + +1. 前端没显式传租户时,新文档会直接落成 `default` +2. 新租户用户上传的文档,很可能不会自动归属到该租户 + +## 4.2 模型注释仍然固化旧地区集合 + +`leauditDocument.py` 注释仍写: + +1. `mz/yf/jy/cz/default` + +这说明当前代码心智模型依然认为系统只支持固定几个地区。 + +## 4.3 连带后果 + +如果不改: + +1. 新租户用户上传文档后,列表可能查不到 +2. 评查结果可能落在公共域 +3. 统计会把新租户数据混入默认域 + +--- + +## 5. RAG 链路影响 + +## 5.1 RAG 管理接口直接把 `area` 当租户键 + +`ragDatasetServiceImpl.py` 当前: + +1. 管理端筛选按 `d.area` +2. 创建知识库要求传 `area` +3. 权限范围由 `UserArea` 和 `UserRole` 控制 + +## 5.2 RAG 聊天应用按 `UserArea` 找默认应用 + +`ragChatServiceImpl.py` 的应用加载逻辑也依赖: + +1. `UserArea` +2. `UserRole` + +## 5.3 当前最容易出的问题 + +1. 新租户创建了知识库,但聊天应用找不到默认 app +2. 新租户能进 RAG 页面,但筛选和管理列表不正确 +3. `省级 / is_public / 空 area` 仍混用,导致公共知识库对新租户不可预期 + +## 5.4 前端 RAG 配置页也会被连带影响 + +`use-area-dataset-config.ts` 和 `area-dataset-config.tsx` 当前仍以 `area` 为中心: + +1. 获取可用地区 +2. 表单字段用 `area` +3. 标签把 `省级` 当特殊样式 + +所以即使后端引入 `tenant_code`,前端不一起改也会继续污染数据。 + +--- + +## 6. 合同模板链路影响 + +## 6.1 模板表仍把 `省级` 当公共模板域 + +当前 `contract_templates.region` 默认值和注释都围绕 `省级` 展开。 + +这对新租户有两个风险: + +1. 新租户专属模板无法稳定区分于公共模板 +2. 公共模板可能被省局租户语义吞掉 + +## 6.2 模板搜索与列表筛选都会受影响 + +如果新租户使用 `tenant_code`,但模板查询还按 `region` 中文值和旧别名判断,就会出现: + +1. 模板创建成功但列表查不到 +2. 公共模板展示范围不一致 + +--- + +## 7. 统计链路影响 + +## 7.1 登录统计快照仍然存 `area_snapshot` + +`usageStatsServiceImpl.py` 当前会在登录事件写入: + +1. `area_snapshot` + +如果新增租户后只改用户主数据,不改快照口径,统计层会出现: + +1. 历史值一套 +2. 新值一套 + +最后同一租户可能被拆成两列。 + +## 7.2 文档 / 评查 / 登录三类统计口径会继续分裂 + +统计服务本质上在汇总: + +1. 登录行为 +2. 文档上传 +3. 评查执行 + +如果三条链路用的租户字段不统一,最终的“按地区统计”会完全失真。 + +--- + +## 8. RBAC 与组织树链路影响 + +## 8.1 当前系统已存在 `tenant_name` 分组语义 + +`rbacAdminServiceImpl.py` 当前已经用: + +1. `tenant_name` +2. `dep_name` +3. `ou_name` + +构建组织树和筛选。 + +## 8.2 自定义租户会冲击两个维度 + +1. 用户主归属租户 +2. 组织树中的租户分组 + +如果只是新增 `tenant_code`,但 RBAC 管理页仍按旧 `tenant_name` 展示,会导致: + +1. 用户租户边界和管理树分组不一致 +2. 新租户看起来像“未分组租户” + +## 8.3 为什么这里不能只靠展示修补 + +因为 RBAC 管理域后面还会接数据范围执行器。 +如果组织树仍然不是标准租户主数据驱动,后面权限执行器就拿不到稳定边界。 + +--- + +## 9. SQL seed 与初始化脚本影响 + +## 9.1 当前 seed 里大量写死旧地区集合 + +已确认: + +1. `seed_home_entry_modules.sql` +2. `seed_govdoc_entry_module.sql` +3. 相关 schema 注释 + +都写了固定地区列表。 + +## 9.2 为什么这是隐藏高风险点 + +因为即使主代码改完,只要后续: + +1. 重建环境 +2. 补跑 seed +3. 初始化新库 + +系统又会被旧脚本拉回固定地区模型。 + +所以迁移方案必须覆盖脚本层。 + +--- + +## 10. OSS 路径与资源命名影响 + +`entryModuleAdminServiceImpl.py` 当前上传 Logo 路径: + +1. `documents/mz/static/img/...` + +这里虽然当前更像历史路径约定,不一定直接参与权限判定,但它暴露出两个问题: + +1. 资源路径命名仍带旧地区缩写 +2. 后续如果按租户做静态资源隔离,会被旧路径模型阻碍 + +建议: + +1. 不把这类路径直接当权限边界 +2. 后续新资源路径使用中性命名或 `tenant_code` + +--- + +## 11. 最容易漏掉的影响点 + +除了主要业务链路,还应补查以下项目: + +1. 前端下拉缓存是否写死地区列表 +2. 导出报表文件名是否拼接地区文本 +3. 定时任务或离线脚本是否按 `region='default'` 取数 +4. OpenAPI/DTO 描述是否仍写“地区” +5. 单元测试与集成测试是否仍断言 `省局/省级/default` +6. 文案、帮助文档、操作说明是否仍默认地区固定 + +--- + +## 12. 建议新增的配套文档 + +基于当前分析,还建议保留下面 4 份文档作为完整闭环: + +1. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +2. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +3. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) +4. [权限文档总导航与阅读顺序.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/权限文档总导航与阅读顺序.md) + +如果后续还要继续补文档,优先级建议是: + +1. 租户接口设计与返回结构规范 +2. 统一租户解析器与兼容层设计 +3. 入口模块/RAG/模板三大模块的详细 SQL 迁移脚本说明 + +--- + +## 13. 本文档解决什么问题 + +本文档主要解决: + +1. 自定义租户除了入口模块还会影响哪些地方 +2. 哪些链路只是 UI 问题,哪些链路本质是数据边界问题 +3. 为什么文档、RAG、模板、统计、RBAC 都必须一起纳入改造范围 +4. 哪些隐藏点最容易在改造后继续埋雷 + +建议阅读顺序: + +1. [地区租户化与自定义租户扩展改造方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区租户化与自定义租户扩展改造方案.md) +2. [租户主数据模型设计.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/租户主数据模型设计.md) +3. [地区到租户编码映射清洗清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/地区到租户编码映射清洗清单.md) +4. [入口模块租户配置表迁移方案.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/入口模块租户配置表迁移方案.md) diff --git a/docs/权限与地区隔离/规则域多租户方案A实施计划.md b/docs/权限与地区隔离/规则域多租户方案A实施计划.md new file mode 100644 index 0000000..b3ee578 --- /dev/null +++ b/docs/权限与地区隔离/规则域多租户方案A实施计划.md @@ -0,0 +1,531 @@ +# 规则域多租户方案 A 实施计划 + +> 适用范围:`leaudit-platform` 新平台规则配置、评查组、规则版本、评查运行结果链路 +> 方案结论:采用 `方案 A = 共享业务树 + 租户规则绑定 + 租户结果快照` +> 更新日期:2026-05-21 +> 文档定位:把“规则域如何接入多租户”从概念方案落成可执行的修改计划,作为后续开发、联调、数据库迁移、验收的统一基线。 + +--- + +## 1. 定案结论 + +规则域本轮正式采用 `方案 A`,不直接做“每租户独立整棵业务树”,也不在第一阶段引入“租户扩展节点”。 + +当前统一口径如下: + +1. `leaudit_evaluation_point_groups` 继续作为共享业务树 +2. 租户差异主要放在: + - `leaudit_rule_sets` + - `leaudit_rule_versions` + - `leaudit_rule_group_bindings` + - `leaudit_audit_runs` + - `leaudit_rule_results / leaudit_run_errors / leaudit_run_metrics` +3. 运行时规则生效顺序固定为: + - `TENANT` + - `PROVINCIAL` + - `PUBLIC` +4. `tenant_code` 是唯一真实租户边界 +5. `tenant_name` 只做展示与快照 +6. `area / region / default / 公共 / 省级` 只允许留在兼容层与历史清洗层 + +--- + +## 2. 当前问题复盘 + +规则域目前不是“功能不可用”,而是“资产归属模型仍是全局共享思路”,因此天然不满足真正多租户隔离。 + +当前高风险点如下: + +1. `leaudit_rule_sets` 仍缺 `tenant_code` +2. `leaudit_rule_versions` 仍缺租户快照 +3. `leaudit_rule_group_bindings` 仍缺租户边界 +4. `leaudit_audit_runs` 仍缺租户快照,导致结果链条没有稳定归属锚点 +5. `leaudit_rule_results / leaudit_run_errors / leaudit_run_metrics` 只能靠 `run_id` 间接推断租户 +6. `auditServiceImpl` 运行时按 `group_id` 取规则,但没有按租户选“当前生效规则” +7. `ruleServiceImpl` 仍以全局 `rule_type -> rule_set` 为主语义 + +这意味着: + +1. 不同租户可能共用同一个规则集与版本链 +2. 一个租户发布的新版本,可能直接影响另一个租户的运行结果 +3. 规则结果报表、失败诊断、运行统计无法稳定按租户回溯 + +--- + +## 3. 方案 A 的边界定义 + +## 3.1 保持共享的部分 + +以下内容第一阶段继续共享: + +1. 业务树:`leaudit_evaluation_point_groups` +2. 文档类型:`leaudit_document_types` +3. 入口模块与业务树的主挂载关系 + +共享的含义不是“不做租户控制”,而是: + +1. 树结构本身平台统一维护 +2. 哪些租户能看到哪些入口/业务节点,由可见性控制解决 +3. 某业务节点下到底生效哪套规则,由租户绑定解决 + +## 3.2 租户化的部分 + +以下内容必须 tenant-first: + +1. 规则集归属 +2. 规则版本归属 +3. 业务组到规则集的绑定关系 +4. 评查运行记录 +5. 评查结果、错误、指标等衍生记录 + +## 3.3 暂不纳入第一阶段的部分 + +本轮明确不做: + +1. 每租户复制整棵业务树 +2. 租户自定义树层级结构 +3. 树节点级继承编辑器 +4. 租户扩展节点能力 + +这些能力只预留扩展位,不进入本轮交付范围。 + +--- + +## 4. 目标架构 + +规则域的目标链路收口为: + +`文档(tenant_code) -> 共享业务树 group -> 按 tenant_code 解析生效 binding -> 命中 tenant/provincial/public 规则集 -> 锁定规则版本 -> 创建带租户快照的 audit_run -> 结果/错误/指标带租户快照落库` + +核心设计点: + +1. 业务树是“分类锚点” +2. 规则绑定是“租户差异入口” +3. `audit_run` 是“历史快照锚点” +4. 结果表是“查询与报表加速层”,不再只靠运行时 join 推断租户 + +--- + +## 5. 数据模型修改计划 + +## 5.1 `leaudit_rule_sets` + +### 新增字段 + +1. `tenant_code VARCHAR(64) NULL` +2. `scope_type VARCHAR(32) NOT NULL DEFAULT 'PROVINCIAL'` +3. `source_rule_set_id BIGINT NULL` +4. `tenant_name_snapshot VARCHAR(255) NULL` + +### 约束与索引 + +1. 索引:`idx_leaudit_rule_sets_tenant_code` +2. 索引:`idx_leaudit_rule_sets_scope_type` +3. 唯一索引建议调整为: + - `tenant_code + rule_type + deleted_at IS NULL` + - 或 `COALESCE(tenant_code, '') + rule_type + deleted_at IS NULL` + +### 语义 + +1. `TENANT`:租户私有规则集 +2. `PROVINCIAL`:省级统一治理规则集 +3. `PUBLIC`:真正公共资源域规则集 +4. `source_rule_set_id`:租户规则集如果来自省级继承/复制,记录来源 + +## 5.2 `leaudit_rule_versions` + +### 新增字段 + +1. `tenant_code_snapshot VARCHAR(64) NULL` +2. `scope_type_snapshot VARCHAR(32) NULL` +3. `source_version_id BIGINT NULL` + +### 原则 + +1. 版本归属以创建时快照固化 +2. 不允许完全依赖 join `rule_set` 才知道版本属于谁 + +## 5.3 `leaudit_rule_group_bindings` + +### 新增字段 + +1. `tenant_code VARCHAR(64) NULL` +2. `scope_type VARCHAR(32) NOT NULL DEFAULT 'PROVINCIAL'` +3. `tenant_name_snapshot VARCHAR(255) NULL` + +### 索引与约束 + +1. 索引:`idx_leaudit_rule_group_bindings_group_tenant` +2. 索引:`idx_leaudit_rule_group_bindings_scope_type` +3. 唯一索引调整为: + - `group_id + rule_set_id + COALESCE(tenant_code, '') + deleted_at IS NULL` + +### 语义 + +同一个 `group_id` 可以同时存在: + +1. 一个租户私有绑定 +2. 一个省级绑定 +3. 一个公共绑定 + +运行时按优先级解析,不再只看 `priority`。 + +## 5.4 `leaudit_rule_type_bindings` + +### 第一阶段处理原则 + +1. 不再作为新主链路真相来源 +2. 补 `tenant_code / scope_type` 仅用于兼容老接口与迁移脚本 +3. 新平台运行与配置以 `leaudit_rule_group_bindings` 为准 + +## 5.5 `leaudit_audit_runs` + +### 必补字段 + +1. `tenant_code VARCHAR(64) NULL` +2. `tenant_name_snapshot VARCHAR(255) NULL` +3. `scope_type_snapshot VARCHAR(32) NULL` +4. `group_id_snapshot BIGINT NULL` +5. `rule_binding_id_snapshot BIGINT NULL` + +### 原因 + +`audit_run` 是整个历史追溯的锚点。 +没有这层快照,后续规则换绑、租户改名、共享域清洗后,历史运行会失去稳定归属。 + +## 5.6 `leaudit_rule_results` + +### 建议新增字段 + +1. `tenant_code VARCHAR(64) NULL` +2. `tenant_name_snapshot VARCHAR(255) NULL` + +### 原则 + +虽然可以通过 `run_id` 反查,但结果表是高频列表、导出、统计来源,补快照能避免后续大量 join。 + +## 5.7 `leaudit_run_errors` + +### 建议新增字段 + +1. `tenant_code VARCHAR(64) NULL` +2. `tenant_name_snapshot VARCHAR(255) NULL` + +## 5.8 `leaudit_run_metrics` + +### 建议新增字段 + +1. `tenant_code VARCHAR(64) NULL` + +--- + +## 6. 历史数据回填策略 + +## 6.1 回填总原则 + +1. 现有规则资产默认回填为 `PROVINCIAL` +2. 不把现有全局规则直接回填成 `PUBLIC` +3. 历史 `region/default/公共/省级` 只作为映射依据,不作为新模型长期值 + +## 6.2 回填顺序 + +1. 先补字段与索引 +2. 再给 `rule_sets / rule_versions / group_bindings` 回填省级归属 +3. 再给 `audit_runs` 补租户快照 +4. 最后给 `rule_results / run_errors / run_metrics` 做快照回填 + +## 6.3 回填映射口径 + +1. 原来全局共享规则集:回填 `tenant_code='PROVINCIAL'` +2. 原来 `default / 省级 / 省局`:统一映射到 `PROVINCIAL` +3. 原来真正公共资源:才回填 `PUBLIC` + +--- + +## 7. 运行时解析修改计划 + +## 7.1 目标规则 + +运行时解析“某文档某分组到底用哪套规则”时,必须以文档 `tenant_code` 为第一主语义。 + +## 7.2 生效顺序 + +固定解析顺序: + +1. 精确租户:`tenant_code = document.tenant_code` +2. 省级继承:`tenant_code = 'PROVINCIAL'` +3. 公共继承:`tenant_code = 'PUBLIC'` + +即: + +`TENANT -> PROVINCIAL -> PUBLIC` + +## 7.3 修改点 + +文件: + +- [auditServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py) + +需要改造: + +1. `_resolve_rule_binding_from_group` +2. `_resolve_unique_group_binding_by_doc_type` +3. `Run` + +目标: + +1. 查询绑定时显式带入 `tenant_code` +2. 绑定结果返回: + - `binding_id` + - `binding tenant_code` + - `binding scope_type` + - `rule_set tenant_code` +3. 创建 `audit_run` 时直接写入租户快照与绑定快照 + +--- + +## 8. 后端改造任务拆分 + +## 8.1 `R1` 数据库迁移脚本 + +涉及: + +1. 新增一套规则域迁移 SQL +2. 新增预检 SQL +3. 新增回填验证 SQL + +建议文件: + +1. `scripts/创建sql/schema_rule_domain_tenant_phase1.sql` +2. `scripts/创建sql/precheck_rule_domain_tenant_phase1.sql` +3. `scripts/创建sql/verify_rule_domain_tenant_phase1.sql` + +交付目标: + +1. 字段补齐 +2. 索引补齐 +3. 历史数据回填 +4. 验证 SQL 可重复执行 + +## 8.2 `R2` 规则资产服务租户化 + +涉及文件: + +1. [ruleServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py) +2. [ruleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleController.py) + +改造范围: + +1. `ListSets` +2. `GetVersions` +3. `GetContent` +4. `CreateVersion` +5. `PublishRuleVersion` +6. `RollbackRuleVersion` + +目标: + +1. 规则集列表按当前租户 + 继承层展示 +2. 创建新版本时,不再按全局 `rule_type` 命中资产 +3. 发布/回滚只影响当前租户生效链 + +## 8.3 `R3` 业务组绑定租户化 + +涉及文件: + +1. [evaluationPointGroupServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py) +2. [ruleGroupSupport.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py) + +改造范围: + +1. `ListBindings` +2. `CreateBinding` +3. `UpdateBinding` +4. `DeleteBinding` +5. `GetRuleTemplate` +6. `CreateRuleDraft` + +目标: + +1. 绑定表支持租户归属 +2. 同组可见绑定区分: + - 本租户 + - 继承省级 + - 继承公共 +3. 后台页面能识别“当前生效绑定来自哪里” + +## 8.4 `R4` 运行链路租户化 + +涉及文件: + +1. [auditServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py) +2. [storage_adapter.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py) + +改造范围: + +1. 运行时选规则 +2. 创建 `audit_run` +3. 结果、错误、指标落库 + +目标: + +1. 运行链路 tenant-first +2. 所有衍生记录带租户快照 +3. 后续报表不再依赖复杂 join 推断 + +## 8.5 `R5` 规则配置页聚合租户化 + +涉及文件: + +1. [ruleConfigServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py) + +目标: + +1. `ListPackSummaries` 与 `GetPack` 按当前租户解析生效规则 +2. 返回新增标准字段: + - `effectiveTenantCode` + - `effectiveScopeType` + - `isInherited` + - `sourceRuleSetId` +3. 页面能区分: + - 本租户私有规则 + - 继承省级规则 + - 继承公共规则 + +## 8.6 `R6` 鉴权与权限补齐 + +涉及文件: + +1. [ruleController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleController.py) +2. [ruleConfigController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py) +3. [evaluationPointGroupController.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py) + +目标: + +1. 所有规则域接口强制登录态 +2. 功能权限校验与租户上下文透传统一化 +3. 杜绝“裸接口 + 全局资产”的组合风险 + +--- + +## 9. 前端改造计划 + +## 9.1 规则配置页 + +目标: + +1. 展示当前规则来源 +2. 展示当前租户是否继承上层规则 +3. 发布/回滚操作前明确告知影响范围 + +应补能力: + +1. “当前生效租户域”标签 +2. “继承来源”标签 +3. “复制省级为租户私有规则”入口 + +## 9.2 评查组管理页 + +目标: + +1. 共享业务树继续展示 +2. 绑定列表显式展示租户域 +3. 避免让用户误以为业务树已经按租户独立 + +## 9.3 历史兼容口径 + +前端主模型不再依赖: + +1. `region` +2. `default` +3. 中文共享常量 + +前端应统一消费: + +1. `tenant_code` +2. `tenant_name` +3. `scope_type` +4. `isInherited` + +--- + +## 10. 验收矩阵 + +## 10.1 规则资产 + +1. 租户 A 创建规则版本,不影响租户 B 当前规则 +2. 省级发布规则,未覆盖的租户自动继承 +3. 已有租户私有规则的组,不再被省级发布直接覆盖 + +## 10.2 运行链路 + +1. 梅州文档运行时优先命中 `MZ` +2. `MZ` 无绑定时回退 `PROVINCIAL` +3. `PROVINCIAL` 无绑定时回退 `PUBLIC` +4. `audit_run` 落库后可直接看到租户快照 + +## 10.3 结果与报表 + +1. `rule_results` 可直接按 `tenant_code` 过滤 +2. `run_errors` 可直接按 `tenant_code` 过滤 +3. `run_metrics` 可直接按 `tenant_code` 聚合 + +## 10.4 页面行为 + +1. 规则配置页能显示“本租户规则 / 继承省级 / 继承公共” +2. 评查组绑定页能正确显示当前生效规则来源 +3. 非当前租户用户不能修改本租户规则资产 + +--- + +## 11. 执行顺序 + +建议按下面顺序推进,不再交叉大爆改: + +1. `R1` 数据库迁移脚本 +2. `R4` 运行链路租户化 +3. `R2` 规则资产服务租户化 +4. `R3` 业务组绑定租户化 +5. `R5` 规则配置页聚合租户化 +6. `R6` 鉴权与权限补齐 +7. 前端联调与发布级验收 + +执行原则: + +1. 先补“数据真相字段” +2. 再改“运行时生效逻辑” +3. 最后改“后台配置与页面展示” + +--- + +## 12. 风险与边界 + +## 12.1 高风险 + +1. `rule_type` 当前可能被多处假定为全局唯一 +2. 历史运行结果回填需要保证与旧 `document.tenant_code` 一致 +3. 省级与公共域清洗口径必须先定死,不能一边跑一边改 + +## 12.2 中风险 + +1. 前端若仍只展示“当前绑定”,用户会误判租户隔离是否生效 +2. `rule_type_bindings` 老兼容链路如果不断回写,容易污染新模型 + +## 12.3 本轮不做 + +1. 不做租户扩展节点 +2. 不做独立业务树 +3. 不做历史规则资产自动差异合并 + +--- + +## 13. 最终交付定义 + +当以下 6 条同时满足时,规则域多租户方案 A 才算真正落地: + +1. 规则集、规则版本、组绑定都有稳定 `tenant_code` +2. 运行时规则解析已改为 `TENANT -> PROVINCIAL -> PUBLIC` +3. `audit_run` 已带完整租户快照 +4. `rule_results / run_errors / run_metrics` 已能直接按租户查询 +5. 规则配置页能明确展示当前规则来源与继承关系 +6. 发布级验收已证明不同租户互不串规则、互不串结果 diff --git a/docs/权限与地区隔离/角色去硬编码迁移清单.md b/docs/权限与地区隔离/角色去硬编码迁移清单.md new file mode 100644 index 0000000..58bc04a --- /dev/null +++ b/docs/权限与地区隔离/角色去硬编码迁移清单.md @@ -0,0 +1,254 @@ +# 角色去硬编码迁移清单 + +> 适用范围:当前项目中所有直接依赖 `super_admin/provincial_admin/admin/common/developer` 的权限判断 +> 文档定位:从“问题分析”进入“逐文件迁移清单”,指导研发按优先级拆改。 + +--- + +## 1. 迁移目标 + +本清单的目标不是把所有“角色”这个概念删掉,而是把“用角色名直接判断能力”的写法替换成下面三类正规来源: + +1. `permission_key` +2. `effective_scope` +3. `module policy / capability snapshot` + +保留角色本身作为“授权载体”,但不再让角色名直接主导业务逻辑。 + +--- + +## 2. 问题类型定义 + +为方便排期,先统一问题类型。 + +| 类型 | 含义 | +| --- | --- | +| `CTX` | 服务层上下文派生硬编码 | +| `ACT` | 服务层动作放行硬编码 | +| `UI` | 前端按钮/编辑态硬编码 | +| `GUARD` | 前端路由、guard、fallback、role mapping 硬编码 | +| `COMPAT` | 兼容层短期保留但必须收缩的旧逻辑 | + +--- + +## 3. 后端迁移清单 + +## 3.1 P0:必须最先处理 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py` | 多处 `UserRole in ('provincial_admin','admin','super_admin')` | `ACT` | 改为 `rag:* permission + RagPolicy + effective_scope` | `P0` | 高 | RAG 管理接口、数据集文档接口 | +| `fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py` | `user_role == 'provincial_admin'`、`_app_visible` 角色分支 | `ACT` | 改为 `PUBLIC_MIXED` 策略 | `P0` | 高 | 应用列表、默认应用、聊天入口 | +| `fastapi_modules/fastapi_leaudit/controllers/ragChatController.py` | 把 `UserRole` 透传给 service | `ACT` | 改为透传 `ScopeContext/CapabilitySnapshot` | `P0` | 中 | 所有 RAG 接口联动 | + +## 3.2 P1:统一上下文能力层 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py` | `_getCurrentUserContext()` 中按 `role_key IN (...)` 生成 `is_global/can_manage/is_super_admin` | `CTX` | 改为 `PermissionDecisionService` | `P1` | 高 | 文档列表、详情、附件、确认、删除 | +| `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` | 通过 `is_global/can_manage` 控制 `region/created_by` | `CTX` | 改为 `GovdocPolicy` | `P1` | 高 | 公文详情、run、报告、下载 | +| `fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py` | `_getCurrentUserContext` 中 `role_key IN (...)` 派生范围 | `CTX` | 改为 `UsageStatsPolicy` | `P1` | 中高 | 概览、趋势、明细 | +| `fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py` | `is_global/can_manage/is_area_admin` 依赖角色名 | `CTX` | 改为 `ContractTemplatePolicy` | `P1` | 中 | 模板列表、搜索、创建、删除 | +| `fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py` | `bool_or(r.role_key IN (...))` 派生管理能力 | `CTX` | 改为 `RbacAdminPolicy + rbac permission` | `P1` | 高 | 用户列表、组织树、角色分配 | +| `fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py` | `role_key = 'super_admin' => bypass_area` | `CTX` | 改为首页能力快照 | `P1` | 中 | 首页入口可见性 | + +## 3.3 P2:默认角色与兼容角色清理 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py` | 用户无角色时自动补 `common`;回退主角色 `common` | `COMPAT` | 保留默认角色机制,但从业务能力判断中剥离“common 特权假设” | `P2` | 中 | 登录、初始化用户 | +| `fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py` | `roleBucket = "admin" if any(role in {...}) else "common"` | `COMPAT` | 仅短期保留在菜单兼容层,逐步删除角色桶映射 | `P2` | 高 | 路由树、菜单 fallback | +| `fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py` | 静态蓝图 `_COMPAT_ROUTE_BLUEPRINTS` 以 `admin/common` 分桶 | `COMPAT` | 改为数据库路由兜底,不认角色桶 | `P2` | 中高 | 前端菜单 | + +--- + +## 4. 前端迁移清单 + +## 4.1 P0:直接影响权限感知 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `legal-platform-frontend/components/dify-dataset-manager/index.tsx` | `provincial_admin/super_admin/admin` 决定 `canEditDataset` | `UI` | 改为读 `rag` 能力或 permission_map | `P0` | 高 | RAG 编辑按钮、详情编辑态 | +| `legal-platform-frontend/hooks/use-area-dataset-config.ts` | `roleCanManageDataset = provincial_admin/super_admin/admin` | `UI` | 改为纯 permission 决策 | `P0` | 高 | 知识库配置页全部操作 | +| `legal-platform-frontend/components/dify-dataset-manager/area-dataset-config.tsx` | `isProvincialAdmin`、非省级自动填地区 | `UI` | 改为根据 `effective_scope` 与 `allowed_areas` 决定 | `P0` | 中高 | 地区选择器、创建编辑表单 | + +## 4.2 P1:菜单与路由层 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `legal-platform-frontend/lib/auth/user-routes.ts` | 静态 `FALLBACK_MENU_DATA.admin/common` | `GUARD` | 收敛为数据库路由兜底,不做角色桶映射 | `P1` | 高 | 主菜单 | +| `legal-platform-frontend/lib/api/legacy/auth/user-routes.ts` | `mapUserRoleToRoleKey()` 中 `provincial_admin -> admin`、`developer -> admin` | `GUARD` | 删除角色映射,直接使用后端返回路由与权限 | `P1` | 高 | 菜单缓存、路由授权 | +| `legal-platform-frontend/components/layout/Sidebar.tsx` | 按 `userRole` 拉路由、写 `localStorage.user_role` | `GUARD` | 改为按登录态和后端返回能力渲染 | `P1` | 中高 | 侧边栏菜单、权限 map | +| `legal-platform-frontend/lib/auth/cross-checking-access.ts` | `provincial_admin` 自动补 `省局` area 候选 | `GUARD` | 改为服务端返回 `allowed_areas` | `P1` | 中 | 交叉评查入口展示 | +| `legal-platform-frontend/lib/auth/guard.ts` | `developer/provincial_admin` 白名单进入设置页 | `GUARD` | 改为检查后台管理权限 | `P1` | 高 | 设置页访问 | + +## 4.3 P2:页面级残留 + +| 文件 | 当前写法 | 问题类型 | 替换目标 | 优先级 | 风险 | 影响范围 | +| --- | --- | --- | --- | --- | --- | --- | +| `legal-platform-frontend/app/(audit)/contract-template/list/ContractTemplateListClient.tsx` | `currentUser.role === "admin"` 才能上传 | `UI` | 改为 `contract_template:create:write` | `P2` | 中高 | 合同模板上传入口 | +| `legal-platform-frontend/app/(audit)/role-permissions/RolePermissionsClient.tsx` | `provincial_admin` 禁删、`coreRoles = ['provincial_admin','admin','common']`、`setIsCityAdmin(userRole==='admin')` | `UI/COMPAT` | 保留系统角色保护逻辑,但与角色名硬编码解耦 | `P2` | 中高 | 角色权限管理页 | +| `legal-platform-frontend/hooks/usePermission.tsx` | 默认 `userRole = common`,大量消费 `localStorage.user_role` | `COMPAT` | 保留兼容字段,但新增能力快照来源,逐步弱化角色中心地位 | `P2` | 中 | 多页面基础权限钩子 | +| `legal-platform-frontend/components/layout/Layout.tsx` | 默认 `userRole = developer`,回退读取本地角色 | `COMPAT` | 改为显式会话能力初始化 | `P2` | 中 | 全局布局 | + +--- + +## 5. 接口影响面清单 + +角色去硬编码不会只影响代码写法,还会影响接口行为。 + +## 5.1 后端接口影响 + +| 模块 | 受影响接口 | +| --- | --- | +| RAG | `/v3/rag/apps`、`/v3/rag/apps/default`、`/v3/rag/datasets/admin*`、`/v3/rag/datasets/{id}*` | +| 文档 | `/documents/list`、`/documents/{id}`、`/documents/status`、`/v3/review-points/*` | +| 公文 | `/govdoc/documents*`、`/govdoc/runs/*`、`/govdoc/documents/{id}/original` | +| 统计 | `/v3/usage-stats/*` | +| RBAC | `/v3/rbac/users*`、`/v3/rbac/roles/{id}/users`、`/v3/rbac/users/{id}/roles*` | +| 合同模板 | `/v3/contract-templates*` | +| 首页 | 首页入口模块加载逻辑 | + +## 5.2 前端受影响页面 + +| 页面/模块 | 受影响点 | +| --- | --- | +| 侧边栏菜单 | 路由来源、权限 map 来源 | +| 知识库管理页 | 可编辑、可创建、地区选择范围 | +| 合同模板页 | 上传按钮、删除按钮 | +| 设置页 | 进入 guard | +| 交叉评查入口 | 是否显示入口模块 | +| 角色权限管理页 | 系统角色保护、核心角色展示逻辑 | + +--- + +## 6. 推荐替换模式 + +## 6.1 后端替换模式 + +不推荐: + +```python +if user_role in ("provincial_admin", "admin", "super_admin"): + ... +``` + +推荐: + +```python +decision = await permissionDecisionService.decide( + ScopeContext( + user_id=current_user_id, + permission_key="rag:dataset:update", + module="rag", + resource_id=dataset_id, + ) +) +if not decision.allowed: + raise Forbidden() +``` + +## 6.2 前端替换模式 + +不推荐: + +```ts +const canUpload = currentUser.role === "admin"; +``` + +推荐: + +```ts +const canUpload = hasPermission("contract_template:create:write"); +``` + +或者: + +```ts +const canManageDataset = capability.rag?.manage === true; +``` + +--- + +## 7. 兼容策略 + +并不是所有角色相关代码都要同一天删除。 + +建议分 3 类处理: + +1. `立即删除` + - 服务层动作白名单 + - 前端页面按钮直接按角色判断 +2. `先替换后删除` + - `is_global/can_manage/bypass_area` +3. `短期保留但冻结` + - 路由 fallback + - 本地 `user_role` 兼容字段 + +“冻结”的意思是: + +- 不再新增依赖 +- 只允许向统一能力来源迁移 + +--- + +## 8. 风险说明 + +## 8.1 最大风险 + +如果只改后端,不改前端,会出现: + +- 后端已经可用,但前端按钮还不显示 +- 菜单还按旧角色展示 +- 用户误以为权限失效 + +如果只改前端,不改后端,会出现: + +- 前端展示放开 +- 后端仍按旧角色拒绝 +- 联调体验极差 + +## 8.2 第二风险 + +去硬编码后,如果没有统一能力快照,前端容易又发明一套新的本地推断规则。 + +所以必须同步提供: + +- `/auth/me` 扩展能力 +- 或专门的当前用户能力接口 + +--- + +## 9. 推荐执行顺序 + +建议按下面顺序推进: + +1. `ragDatasetServiceImpl.py` +2. `ragChatServiceImpl.py` +3. RAG 前端 3 个文件 +4. `documentServiceImpl.py` +5. `govdocServiceImpl.py` +6. `usageStatsServiceImpl.py` +7. `rbacAdminServiceImpl.py` +8. `contractTemplateServiceImpl.py` +9. `homeServiceImpl.py` +10. 菜单/guard/fallback 前端文件 + +原因: + +- 先打掉最明显的双轨冲突 +- 再统一高风险数据域 +- 最后收口前端兼容层 + +--- + +## 10. 完成判定 + +角色去硬编码完成,不是指仓库里再也没有 `admin` 字符串,而是指: + +1. 角色名不再直接决定业务动作是否允许 +2. 角色名不再直接决定数据边界 +3. 前端不再用角色名推断核心按钮与菜单 +4. 新增自定义角色时,不需要全仓库搜角色名改逻辑 + +只有达到这 4 条,才算真正完成迁移。 diff --git a/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md b/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md new file mode 100644 index 0000000..768b8e1 --- /dev/null +++ b/docs/权限与地区隔离/角色硬编码与接口影响专项补充分析.md @@ -0,0 +1,607 @@ +# 角色硬编码与接口影响专项补充分析 + +> 适用范围:`leaudit-platform` 当前角色权限与地区隔离体系 +> 文档定位:从主方案中拆出的专项补充稿,专门分析“硬编码角色如何改”和“哪些接口会被联动影响”。 + +--- + +## 1. 结论先行 + +当前项目里的角色硬编码不是零散问题,而是已经渗透到: + +- 后端服务层能力派生 +- 后端服务层动作放行 +- 前端 UI 可见性和可编辑性 +- 前端 guard、fallback、role mapping + +因此,这类改造不能只改某一个 service,也不能只改权限表配置。 + +如果只改一部分,会出现 3 类典型问题: + +1. 后端 permission 已放行,但服务层仍按角色名拒绝 +2. 后端边界已收敛,但前端仍按旧角色逻辑展示按钮或入口 +3. 菜单、页面、接口、详情、下载这些边界继续不一致 + +正确做法是: + +1. 先识别所有硬编码角色位置 +2. 再区分它们的职责类型 +3. 最后按“能力抽象 -> 服务层替换 -> 前端去角色化”的顺序渐进改造 + +--- + +## 2. 当前硬编码角色的 4 种形态 + +## 2.1 服务层上下文派生型 + +这类代码并不直接判断某个 permission,而是把角色名先转成派生能力: + +- `is_global` +- `can_manage` +- `is_super_admin` +- `is_area_admin` +- `bypass_area` + +典型位置: + +- `documentServiceImpl.py` +- `govdocServiceImpl.py` +- `usageStatsServiceImpl.py` +- `contractTemplateServiceImpl.py` +- `rbacAdminServiceImpl.py` +- `homeServiceImpl.py` + +典型模式: + +- `role_key IN ('super_admin', 'provincial_admin') => is_global` +- `role_key IN ('super_admin', 'provincial_admin', 'admin') => can_manage` +- `role_key = 'super_admin' => is_super_admin / bypass_area` + +本质问题: + +- 角色名被直接当成能力模型 +- 一旦出现新领域管理员,所有上下文派生逻辑都要重复改 + +## 2.2 服务层动作白名单型 + +这类代码直接按角色名决定某个业务动作是否允许。 + +典型位置: + +- `ragDatasetServiceImpl.py` +- `ragChatServiceImpl.py` + +典型模式: + +- `UserRole not in ("provincial_admin", "admin", "super_admin")` +- `user_role == "provincial_admin"` +- `UserRole == "admin"` + +本质问题: + +- 控制器层已经做 permission 校验 +- 服务层又加了角色白名单 +- 结果会出现“有权限但角色名不对仍被拒绝” + +这是当前最需要优先清理的硬编码类型。 + +## 2.3 前端 UI 能力硬编码型 + +这类代码不是后端鉴权,但会直接影响用户感知边界。 + +典型位置: + +- `components/dify-dataset-manager/index.tsx` +- `components/dify-dataset-manager/area-dataset-config.tsx` +- `hooks/use-area-dataset-config.ts` + +典型模式: + +- `provincial_admin` 可编辑全部 +- `super_admin` 可编辑全部 +- `admin` 仅可编辑本地区 + +本质问题: + +- 前端自己在解释“谁能管理” +- 即使后端以后完成去角色化,前端仍可能展示旧权限状态 + +## 2.4 前端兼容层硬编码型 + +这类代码主要存在于路由、guard 和迁移兼容层。 + +典型位置: + +- `legal-platform-frontend/lib/auth/user-routes.ts` +- `legal-platform-frontend/lib/api/legacy/auth/user-routes.ts` +- `legal-platform-frontend/lib/auth/guard.ts` +- `legal-platform-frontend/lib/auth/cross-checking-access.ts` + +典型模式: + +- `provincial_admin -> admin` +- `super_admin -> admin` +- `developer -> admin` +- `provincial_admin` 自动获得“省局 area”候选 + +本质问题: + +- 前端把真实角色体系压缩成少数桶位 +- 继续保留会让权限模型越来越难以统一 + +--- + +## 3. 为什么不能直接删掉所有角色硬编码 + +不是所有角色硬编码都应该同一天删除,因为它们承担职责不同。 + +## 3.1 可优先替换的 + +优先替换: + +- 服务层动作白名单 +- RAG 管理动作角色白名单 + +原因: + +- 这些逻辑已经和 permission 决策重复 +- 删除后收益最大,副作用相对可控 + +## 3.2 需要先抽象再替换的 + +需要先抽象: + +- `is_global` +- `can_manage` +- `is_super_admin` +- `is_area_admin` + +原因: + +- 它们已经被多个模块用于拼接 SQL 过滤条件 +- 直接删会导致大量数据边界逻辑断裂 + +正确做法: + +- 先统一沉淀为能力派生层 +- 再逐步把“角色名判断”替换为“能力决策” + +## 3.3 可以保留为兼容层但必须收缩边界的 + +允许短期保留: + +- route fallback +- role mapping +- 某些旧前端 guard + +但必须满足: + +1. 只存在于适配层 +2. 不再扩散到新业务代码 +3. 后续有明确移除计划 + +--- + +## 4. 推荐的替换目标 + +当前很多角色语义,其实应该改写成能力语义。 + +建议的替换关系如下: + +- `super_admin` 全局绕过 + 替换为:`is_super_admin` +- `provincial_admin => 全省可见` + 替换为:`effective_scope == ALL` +- `admin => 本地区管理` + 替换为:`effective_scope == DEPT + domain manage permission` +- `common => 自己的数据` + 替换为:`effective_scope == SELF` +- `provincial_admin/admin/super_admin` 共用白名单 + 替换为:显式 `create/update/delete/manage` permission + +这一步的本质,是把“角色名”换成“能力决策”。 + +--- + +## 5. 建议新增的统一能力层 + +建议新增统一能力派生对象,例如: + +- `is_super_admin` +- `has_global_scope` +- `has_area_scope` +- `can_manage_rbac` +- `can_manage_rag_dataset` +- `can_manage_contract_templates` +- `can_view_usage_stats` +- `can_bypass_home_area` + +这些能力不应直接写死在角色名上,而应由以下信息共同决策: + +- `roles` +- `permissions` +- `data_scope` +- 可选 `condition_filter` + +建议新增两层统一能力: + +1. `ScopeContextProvider` +2. `AdminCapabilityResolver` + +前者负责“当前用户的通用数据边界”,后者负责“某个业务域是否具备管理能力”。 + +--- + +## 6. 当前高风险冲突区 + +## 6.1 RAG 是最典型的双轨冲突区 + +当前链路是: + +1. 控制器层按 permission 校验 +2. 服务层又按 `UserRole` 白名单校验 +3. 最终管理能力同时受 permission 和角色名双重控制 + +风险: + +- 未来新增 `rag_manager` 这类角色时,即使给了 `rag:dataset:manage/create/update/delete`,服务层仍会拒绝 + +优先级: + +- 最高 + +## 6.2 RBAC 管理域存在“角色管理能力”和“权限管理能力”双重耦合 + +当前链路是: + +1. `_assertManagePermission` 依赖 `can_manage` +2. `_assertPermission` 再校验具体权限点 + +风险: + +- 如果未来某用户拥有 `rbac:*` 权限,但主角色不是 `admin/provincial_admin/super_admin`,仍可能被挡在第一层 + +优先级: + +- 高 + +## 6.3 文档 / 公文 / 统计存在共性上下文派生复制 + +这些模块并没有直接角色白名单放行,但都重复实现了: + +- `is_global` +- `can_manage` +- `is_super_admin` + +风险: + +- 改一个模块不改另一个,边界会漂移 + +优先级: + +- 高 + +## 6.4 前端知识库管理仍在自行解释角色 + +风险: + +- 后端已经 permission 化后,前端仍会显示不该显示的按钮 +- 或相反,后端已允许,前端仍不展示入口 + +优先级: + +- 高 + +--- + +## 7. 受影响接口分析 + +下面重点回答“其他接口会不会影响到”。 + +答案是:**会,而且影响面不小。** + +## 7.1 文档模块 + +受影响接口包括: + +- `POST /api/upload` +- `GET /api/documents/list` +- `GET /api/documents/status` +- `GET /api/documents/{DocumentId}` +- `GET /api/v3/review-points/{DocumentId}` +- `PATCH /api/v3/review-points/{ReviewPointResultId}/audit` +- `PATCH /api/v3/documents/{DocumentId}/confirm` +- `POST /api/documents/{DocumentId}/attachments` +- `PUT /api/documents/{DocumentId}` +- `DELETE /api/documents/{DocumentId}` + +影响原因: + +- 这些接口都依赖文档服务里的用户上下文派生和数据范围过滤 +- 一旦 `is_global/can_manage` 的计算逻辑变化,全部都会联动 + +## 7.2 公文模块 + +受影响接口包括: + +- `POST /api/govdoc/documents` +- `GET /api/govdoc/documents` +- `GET /api/govdoc/documents/{documentId}` +- `PATCH /api/govdoc/documents/{documentId}` +- `DELETE /api/govdoc/documents/{documentId}` +- `POST /api/govdoc/runs` +- `GET /api/govdoc/runs/{runId}` +- `GET /api/govdoc/runs/{runId}/result` +- `GET /api/govdoc/runs/{runId}/findings` +- `GET /api/govdoc/runs/{runId}/entities` +- `GET /api/govdoc/runs/{runId}/structure` +- `GET /api/govdoc/runs/{runId}/outline` +- `GET /api/govdoc/runs/{runId}/paragraphs` +- `GET /api/govdoc/runs/{runId}/report/html` +- `GET /api/govdoc/runs/{runId}/report/docx` +- `GET /api/govdoc/documents/{documentId}/original` + +影响原因: + +- 公文模块也使用了同构的上下文派生 +- 尤其结果、报告、下载类接口最容易出现“资源详情边界未同步”的问题 + +## 7.3 统计模块 + +受影响接口包括: + +- `GET /api/v3/usage-stats/overview` +- `GET /api/v3/usage-stats/trends` +- `GET /api/v3/usage-stats/by-users` +- `GET /api/v3/usage-stats/by-departments` +- `GET /api/v3/usage-stats/by-areas` +- `GET /api/v3/usage-stats/details` + +影响原因: + +- 当前统计接口对管理员可见性有明显上下文派生依赖 +- 去角色化后,这些接口需要统一切到“统计域 permission + scope”模型 + +## 7.4 RAG 模块 + +受影响接口包括: + +- `GET /api/v3/rag/apps` +- `GET /api/v3/rag/apps/default` +- `GET /api/v3/rag/datasets/my` +- `GET /api/v3/rag/datasets/admin` +- `POST /api/v3/rag/datasets/admin` +- `PUT /api/v3/rag/datasets/admin/{DatasetId}` +- `DELETE /api/v3/rag/datasets/admin/{DatasetId}` +- `GET /api/v3/rag/datasets/{DatasetId}` +- `PATCH /api/v3/rag/datasets/{DatasetId}` +- 各类 `/datasets/{DatasetId}/documents` +- 各类 `/datasets/{DatasetId}/segments` +- 各类检索测试接口 +- `POST /api/v3/rag/chat/messages` +- 会话、消息反馈、会话重命名、删除等接口 + +影响原因: + +- 控制器层和服务层当前存在双轨权限逻辑 +- 这是最典型的“permission 改了,接口仍会被角色名卡住”的模块 + +## 7.5 RBAC 管理模块 + +受影响接口包括: + +- `GET /api/v3/rbac/roles` +- `POST /api/v3/rbac/roles` +- `PUT /api/v3/rbac/roles/{RoleId}` +- `DELETE /api/v3/rbac/roles/{RoleId}` +- `GET /api/v3/rbac/users` +- `GET /api/admin/users/organizations/tree` +- `GET /api/v3/rbac/roles/{RoleId}/users` +- `POST /api/v3/rbac/users/{UserId}/roles` +- `DELETE /api/v3/rbac/users/{UserId}/roles/{RoleId}` +- `GET /api/v3/rbac/users/{UserId}/roles` +- `GET /api/v3/routes` +- `GET/PUT /api/rbac/roles/{RoleId}/routes` +- `GET/POST /api/v3/rbac/role-permissions` +- `POST /api/v3/rbac/roles/{RoleId}/access` +- `GET /api/v3/routes/{RouteId}/permissions` + +影响原因: + +- 当前同时依赖 `_assertManagePermission` 和 `_assertPermission` +- 第一层还是角色派生管理能力 + +## 7.6 首页入口模块 + +受影响接口: + +- `GET /api/home/entry-modules` + +影响原因: + +- 当前首页入口存在 `super_admin` 的 area bypass 语义 +- 这类入口可见性也属于权限边界的一部分 + +## 7.7 合同模板模块 + +受影响接口包括: + +- `GET /api/v3/contract-templates/categories` +- `GET /api/v3/contract-templates` +- `POST /api/v3/contract-templates` +- `GET /api/v3/contract-templates/search` +- `GET /api/v3/contract-templates/{TemplateId}` +- `DELETE /api/v3/contract-templates/{TemplateId}` + +影响原因: + +- 当前业务语义明确写着“地区管理员才能上传” +- 如果只去角色化、不补合同模板域显式权限,这类能力会失焦 + +## 7.8 中影响模块 + +中影响但必须纳入联调范围的还有: + +- 交叉评查模块 +- 评查点模块 +- 评查点分组模块 +- 规则配置模块 + +原因: + +- 它们虽然不一定都直接依赖角色名白名单 +- 但仍依赖 permission、route、入口和关系访问逻辑 +- 一旦整体权限能力模型调整,也必须回归验证 + +--- + +## 8. 前端联动影响 + +不能只看后端接口,前端也会同步受影响。 + +## 8.1 菜单与路由 + +受影响位置: + +- `Sidebar.tsx` +- `user-routes.ts` +- `check-route-permission.ts` +- fallback route mapping + +风险: + +- 菜单可见性与真实接口权限继续分叉 +- 某些角色被映射压扁后,真实权限无法完整反映 + +## 8.2 RAG 管理页面 + +受影响位置: + +- `components/dify-dataset-manager/*` +- `hooks/use-area-dataset-config.ts` + +风险: + +- 后端能力已收敛,前端仍按旧角色名展示编辑入口 + +## 8.3 页面 guard + +受影响位置: + +- `lib/auth/guard.ts` +- `lib/auth/cross-checking-access.ts` +- `lib/auth/session-user.ts` +- `lib/auth/jwt.ts` + +风险: + +- `user_role` 被继续当成完整权限模型使用 + +--- + +## 9. 推荐改造顺序 + +建议按下面顺序推进,避免同时炸开所有联动面。 + +## 9.1 第一阶段 + +先做平台能力层: + +- `PermissionDecisionService` +- `ScopeContextProvider` +- `AdminCapabilityResolver` + +目标: + +- 不先改业务接口行为,只先统一决策能力 + +## 9.2 第二阶段 + +优先改 RAG: + +- 清理服务层角色白名单 +- 改为 permission + scope/policy 决策 + +原因: + +- 这是最典型、收益也最大的双轨冲突区 + +## 9.3 第三阶段 + +接入共用上下文模块: + +- 文档 +- 公文 +- 统计 + +原因: + +- 它们共享大量上下文派生逻辑 +- 最适合沉淀统一 `QueryScopeBuilder` + +## 9.4 第四阶段 + +处理管理域: + +- RBAC 管理 +- 首页入口 +- 合同模板 + +目标: + +- 把“管理能力”从角色名迁移到领域 permission + +## 9.5 第五阶段 + +清理前端角色解释层: + +- role mapping +- fallback route +- guard +- UI 编辑能力判断 + +目标: + +- 前端不再自行解释“谁是管理员” + +--- + +## 10. 必做回归清单 + +建议单独维护一份“角色去硬编码回归清单”,至少覆盖: + +1. 用户拥有 permission,但主角色不是 `admin/provincial_admin` 时,接口是否仍能正确访问 +2. 用户获得某领域管理权限后,是否无需改代码即可生效 +3. 菜单、按钮、接口、详情、下载、导出边界是否一致 +4. 前端是否仍存在基于 `user_role` 的旧判断放大或缩小能力 +5. RAG 管理接口是否已完全摆脱角色白名单 +6. RBAC 管理接口是否已从“角色管理能力”切换到“权限管理能力” +7. 文档、公文、统计是否仍存在模块间边界不一致 + +--- + +## 11. 最终建议 + +这次专项分析的核心结论只有一句话: + +**角色硬编码改造,本质上不是“替换几个 if”,而是把整套权限系统从“角色名驱动”升级为“能力决策驱动”。** + +如果只做局部替换,问题会更隐蔽。 + +如果按下面顺序推进,风险最低: + +1. 先抽象能力层 +2. 先处理 RAG 双轨冲突 +3. 再统一文档/公文/统计上下文派生 +4. 再处理 RBAC 管理域和首页入口 +5. 最后清理前端角色解释和 fallback + +这样改完之后,项目才能真正从: + +- “角色名决定能力” + +走向: + +- “权限点 + 数据范围 + 模块 policy 决定能力” + +这才是后续权限平台可持续演进的正确方向。 diff --git a/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md b/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md new file mode 100644 index 0000000..bb7d4ca --- /dev/null +++ b/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md @@ -0,0 +1,222 @@ +# 评查点数据库执行说明与验证SQL + +> 适用范围:`evaluation_points` 旧表 tenant 化收尾 +> 更新日期:2026-05-21 +> 文档定位:给 DBA / 开发 / 审核人明确“评查点 tenant_code 补列脚本”执行前看什么、执行什么、执行后怎么验。 + +--- + +## 1. 本次要执行什么 + +`P0` 当前只处理评查点旧表 `evaluation_points` 的数据库收尾,不涉及业务逻辑大改。 + +本次数据库动作只有一条主线: + +1. 执行前预检 +2. 执行补列与历史回填脚本 +3. 执行后验收 + +当前对应文件: + +1. 预检 SQL + - [precheck_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql) +2. 正式脚本 + - [schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql) +3. 模块收尾说明 + - [评查点模块收尾清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点模块收尾清单.md) +4. 预检结果判读模板 + - [评查点预检结果判读模板.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点预检结果判读模板.md) + +补充说明: + +1. 预检脚本已经兼容“旧库尚未补 `tenant_code / tenant_name` 列”的场景 +2. 也就是说,正式落库前就可以先在旧环境直接执行预检,不会因为缺列而先报错 + +--- + +## 2. 为什么现在要先做数据库 + +当前评查点模块代码已经进入“tenant-first 兼容态”: + +1. 有 `tenant_code / tenant_name` 列时,service 会优先读写它们 +2. 没有列时,service 仍会回退到 `area` + +这说明: + +1. 代码首轮收口已经做了 +2. 但数据库不补字段,模块永远停留在兼容态 +3. 只有把旧表补成真实 `tenant_code` 模型,后续才能继续削掉 `area` fallback + +--- + +## 3. 执行前必须确认的 4 件事 + +### 3.1 表结构现状 + +先确认目标库里的 `evaluation_points` 目前到底有没有: + +1. `tenant_code` +2. `tenant_name` +3. `evaluation_point_groups_id` +4. `evaluation_point_groups_pid` + +预检脚本已经包含表结构查询。 + +### 3.2 `area` 值分布 + +必须先看真实分布,不要假设只有: + +1. `梅州` +2. `公共` +3. `省级` +4. `default` + +否则容易在历史脏值上翻车。 + +### 3.3 无法映射租户的残留 + +需要先找出: + +1. 别名表映射不到 +2. 租户名映射不到 +3. 也不属于 `PUBLIC / PROVINCIAL` 兼容集合 + +这些记录不能在执行前忽略,否则执行后仍会留下空 `tenant_code`。 + +### 3.4 `code` 唯一性现状 + +这点非常关键。 + +当前后端 [evaluationPointServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py) 里的 `_ensure_code_unique()` 仍是: + +1. 按全局 `LOWER(code)` 查重 +2. 不是按 `tenant_code + code` 查重 + +所以本轮数据库补列后: + +1. 不能立刻把“编码唯一性”切成租户内唯一 +2. 只能先做预检,确认是否存在未来会影响规则切换的跨租户重复编码 + +--- + +## 4. 建议执行顺序 + +### 第一步:跑预检 SQL + +执行: + +```sql +\i scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql +``` + +重点看 5 类输出: + +1. `area` 分布 +2. 无法映射租户的残留清单 +3. 共享域残留统计 +4. alias / tenant_name 冲突 +5. 编码重复情况 + +### 第二步:人工确认预检结果 + +至少确认: + +1. 是否存在无法映射租户的记录 +2. 是否存在 alias 映射冲突 +3. 是否存在 tenant_name 映射冲突 +4. 是否存在需要特殊处理的共享域值 + +如果这一步没确认,不建议直接执行正式脚本。 + +### 第三步:执行正式脚本 + +执行: + +```sql +\i scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql +``` + +本脚本会: + +1. 补 `tenant_code / tenant_name` +2. 建 3 个基础索引 +3. 确保 `PUBLIC / PROVINCIAL` 与关键 alias 存在 +4. 对历史 `area` 做首轮回填 + +### 第四步:执行后验收 + +执行完以后,至少重新确认: + +1. `tenant_code` 空值数量 +2. `tenant_name` 空值数量 +3. `PUBLIC / PROVINCIAL` 记录数 +4. 无法映射残留是否归零或是否仅剩人工待处理项 + +--- + +## 5. 预检 SQL 已覆盖的风险点 + +当前预检脚本已经覆盖: + +1. 当前表结构确认 +2. 总记录数与缺失字段数 +3. `area` 分布统计 +4. 无法映射租户的记录清单 +5. `公共 / 省级 / 省局 / default / 空值` 残留统计 +6. `sys_tenants.tenant_name` 冲突预检 +7. `sys_tenant_aliases.alias_value` 冲突预检 +8. `code` 全局重复预检 +9. “如果以后改成租户内唯一”时的跨租户重复预检 + +--- + +## 6. 执行后验收标准 + +### 6.1 数据层 + +至少满足: + +1. `evaluation_points.tenant_code` 已存在 +2. `evaluation_points.tenant_name` 已存在 +3. 大部分历史数据已具备可解析 `tenant_code` +4. `PUBLIC / PROVINCIAL` 共享域记录能稳定统计 + +### 6.2 服务层联动 + +执行完数据库后,下一步就应马上联调: + +1. 列表查询 +2. 详情查询 +3. 创建评查点 +4. 更新评查点 +5. 删除评查点 + +目标是确认: + +1. 新数据会真实落 `tenant_code / tenant_name` +2. 旧数据不会因为回填而突然查不到 +3. 共享域查询开始优先命中标准编码 + +### 6.3 规则约束提醒 + +当前不要误判为“补完字段就能立刻做按租户唯一编码”。 + +因为当前后端仍是全局校验 `code` 唯一。 + +所以本轮数据库执行后的正确状态是: + +1. 评查点归属边界先 tenant 化 +2. 编码唯一性策略保持原行为不变 +3. 后续若要改成“租户内唯一”,必须单独开一轮设计和回归 + +--- + +## 7. 本轮结论 + +`P0` 当前最合理的收口方式不是直接继续改 service,而是: + +1. 先把旧表补成真实 tenant 字段模型 +2. 再联调接口 +3. 再削减 service 中最厚的 `area` 兼容层 + +这是目前最小、最稳、最容易审计的推进顺序。 diff --git a/docs/权限与地区隔离/评查点模块收尾清单.md b/docs/权限与地区隔离/评查点模块收尾清单.md new file mode 100644 index 0000000..788066a --- /dev/null +++ b/docs/权限与地区隔离/评查点模块收尾清单.md @@ -0,0 +1,170 @@ +# 评查点模块收尾清单 + +> 适用范围:`evaluation_points` 旧表仍在线、评查点模块已完成 `T5-1 ~ T5-5` 首轮 tenant-first 收口后的最终收尾阶段 +> 更新日期:2026-05-21 +> 文档定位:明确评查点模块距离“真正摆脱 area 主边界”还差哪些数据库、后端、前端、验收与风险动作。 + +--- + +## 1. 当前真实状态 + +评查点模块当前已经完成的不是“全量终态”,而是第一轮高风险收口: + +1. 读链路已经 `tenant_code` 优先 +2. 写链路已经 `tenant_code` 优先 +3. 共享域 `PUBLIC / PROVINCIAL` 已经开始标准化 +4. 主链路角色判定已切到 `data_scope + permission` +5. 旧 `area` 仍然是物理表兼容字段,不是最终可删除状态 + +也就是说: + +1. 现在已经能明显降低“写错租户、查错范围”的风险 +2. 但还不能说评查点模块已经彻底完成租户化 + +--- + +## 2. 收尾目标 + +评查点模块最终收尾只看 4 个结果: + +1. `evaluation_points` 具备真实 `tenant_code` +2. 历史 `area` 已完成可审计回填 +3. service 查询与写入不再把 `area` 当主边界 +4. `area` 退化为展示/兼容字段,最后可评估下线 + +--- + +## 3. 数据库收尾项 + +### 3.1 必做 + +1. 给旧表 `evaluation_points` 补: + - `tenant_code VARCHAR(64) NULL` + - `tenant_name VARCHAR(128) NULL` +2. 建索引: + - `idx_evaluation_points_tenant_code` + - `idx_evaluation_points_group_tenant_code` + - 如编码唯一性要按租户收口,再评估 `uq_evaluation_points_tenant_code_code_active` +3. 先不删 `area` +4. 先不加 `NOT NULL` + +### 3.2 历史回填规则 + +统一按下面顺序: + +1. 已有合法 `tenant_code` 保留 +2. 否则优先按 `sys_tenant_aliases.alias_value -> tenant_code` +3. 否则按 `sys_tenants.tenant_name -> tenant_code` +4. 否则按兼容值兜底: + - `公共` / `default` / 空值 -> `PUBLIC` + - `省级` / `省局` -> `PROVINCIAL` +5. `tenant_name` 与 `tenant_code` 一起回填 + +### 3.3 回填前预检 + +上线前必须先跑: + +1. `area` 值分布统计 +2. `area` 无法映射租户编码的残留清单 +3. `code` 在跨租户下是否允许重复的预检 +4. `公共/省级/省局/default/空值` 数量统计 + +--- + +## 4. 后端收尾项 + +### 4.1 可以在数据库补字段后立即推进 + +1. `ListPoints` / `GetPoint` 查询彻底改成: + - 先按 `tenant_code` + - `tenant_name/area` 仅在老数据回填不完整时 fallback +2. `CreatePoint` / `UpdatePoint` 默认要求落 `tenant_code/tenant_name` +3. `_evaluation_point_tenant_code_expr()` 可从“兼容表达式”逐步收窄到真实列 +4. `_tenant_scope_match_sql()` 的旧中文兼容匹配可以继续缩薄 + +### 4.2 等历史回填完成后再推进 + +1. 去掉读链路里对 `公共/default/空值/省局/省级` 的多值 fallback +2. 去掉写链路里 `area` 作为最终归属镜像字段的必要性 +3. 评估把 `GetPoint` / `ListPoints` 的共享域判断统一下沉到模块 policy + +--- + +## 5. 前端收尾项 + +### 5.1 必查 + +1. 评查点列表页是否还传 `area` +2. 新建/编辑页是否还允许手填地区文本 +3. 详情页是否仍以 `area` 为主展示归属 +4. 查询缓存 key 是否仍含旧 `area` + +### 5.2 目标状态 + +1. 查询主参数只传 `tenant_code` +2. 展示层显示 `tenant_name` +3. `area` 最多只作为历史记录兼容显示 + +--- + +## 6. 验收清单 + +### 6.1 数据验收 + +1. `evaluation_points` 中 `tenant_code` 非空占比可统计 +2. `PUBLIC / PROVINCIAL` 记录数量可统计 +3. 未映射记录清单可导出 + +### 6.2 行为验收 + +1. 本租户管理员只能看到本租户 + 共享域评查点 +2. 本租户管理员不能把评查点改挂到其他租户 +3. 全局用户创建非共享评查点,必须显式指定 `tenant_code` +4. 共享域列表读取优先命中标准编码,不再先靠中文值 +5. 新建一个自定义租户后,可正常筛选该租户评查点 + +### 6.3 回归验收 + +1. 旧数据中 `公共/default/空值` 仍可见 +2. 旧数据中 `省级/省局` 仍可见 +3. 评查点分组联动不回归 +4. 编码唯一性行为不意外变化 + +--- + +## 7. 剩余风险 + +### 高风险 + +1. 如果数据库不先补 `tenant_code`,代码层再怎么 tenant-first,也只能停留在兼容态 +2. 如果历史回填不先跑,查询口径会长期停在“标准编码 + 中文兼容混合”阶段 + +### 中风险 + +1. 若前端仍持续提交 `area`,会延长兼容期 +2. 若共享域中文常量在别的模块也继续扩散,会影响后续统一执行器接入 + +### 低风险 + +1. `tenant_name` 展示名后续调整可能影响少量历史展示,不应影响真实边界 + +--- + +## 8. 推荐执行顺序 + +1. 先执行评查点 `tenant_code` 补字段与历史回填脚本 +2. 再做一次评查点接口联调 +3. 再收掉 service 内最厚的 `area` fallback +4. 最后把这条链接到统一数据范围执行器 + +--- + +## 9. 本轮直接产物 + +本轮已新增两份配套产物: + +1. 本文档:[评查点模块收尾清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点模块收尾清单.md) +2. 数据库草案:[schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql) +3. 执行说明与验证清单:[评查点数据库执行说明与验证SQL.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md) +4. 执行前预检 SQL:[precheck_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql) +5. 预检结果判读模板:[评查点预检结果判读模板.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点预检结果判读模板.md) diff --git a/docs/权限与地区隔离/评查点预检结果判读模板.md b/docs/权限与地区隔离/评查点预检结果判读模板.md new file mode 100644 index 0000000..15872ba --- /dev/null +++ b/docs/权限与地区隔离/评查点预检结果判读模板.md @@ -0,0 +1,253 @@ +# 评查点预检结果判读模板 + +> 适用范围:`precheck_evaluation_points_tenant_cleanup.sql` 执行结果判读 +> 更新日期:2026-05-21 +> 文档定位:把评查点预检 SQL 的输出结果直接翻译成“可执行 / 需人工确认 / 必须阻断”,避免预检跑完后不知道该怎么判断。 + +--- + +## 1. 使用方式 + +先执行: + +- [precheck_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql) + +再按本文逐项判读。 + +本文默认把每一类结果分成 3 个等级: + +1. `可直接执行` +2. `需人工确认后执行` +3. `必须阻断,先处理再执行` + +--- + +## 2. 表结构确认结果怎么判 + +预检项: + +1. `evaluation_points` 当前列清单 + +### 可直接执行 + +满足任一情况即可: + +1. 旧库尚未存在 `tenant_code / tenant_name` +2. 或者已存在其中一列 / 两列,但类型与脚本兼容 + +### 需人工确认后执行 + +1. 已存在 `tenant_code / tenant_name`,但长度与脚本不一致 +2. 已存在同名列,但注释、默认值、约束来源不明确 + +### 必须阻断 + +1. 存在同名列但类型明显不兼容 +2. `evaluation_point_groups_id / evaluation_point_groups_pid` 缺失 +3. 目标库实际不是当前应用正在使用的 `evaluation_points` 表 + +--- + +## 3. `area` 值分布怎么判 + +预检项: + +1. `area` 值分布统计 +2. 共享域残留统计 + +### 可直接执行 + +1. 分布值主要来自真实租户名 +2. 共享域值主要集中在: + - `公共` + - `default` + - 空值 + - `省级` + - `省局` + +### 需人工确认后执行 + +1. 出现少量疑似历史缩写、空格变体、大小写混用 +2. 出现 1 到 3 个可判断含义但未录入 alias 的地区别名 + +处理建议: + +1. 先补 `sys_tenant_aliases` +2. 再执行正式脚本 + +### 必须阻断 + +1. 大量 `area` 值不是租户名也不是共享域兼容值 +2. 大量值含义不清,无法判断归属 +3. 同一业务范围内明显混入多套地区命名体系 + +--- + +## 4. 无法映射租户的残留怎么判 + +预检项: + +1. `area` 无法映射租户编码的残留清单 + +### 可直接执行 + +1. 查询结果为空 + +### 需人工确认后执行 + +1. 只有极少量残留 +2. 且这些残留能通过补 alias 或人工指定租户解决 + +处理建议: + +1. 先补齐 `sys_tenant_aliases` +2. 或整理人工修正清单 +3. 再执行正式脚本 + +### 必须阻断 + +1. 残留记录很多 +2. 残留涉及核心高频业务数据 +3. 无法明确这些记录应归属哪个租户 + +--- + +## 5. alias / tenant_name 冲突怎么判 + +预检项: + +1. `tenant_name` 映射冲突预检 +2. `alias_value` 映射冲突预检 + +### 可直接执行 + +1. 两项结果都为空 + +### 需人工确认后执行 + +1. 只有历史遗留冲突 +2. 且冲突值本轮不会参与 `evaluation_points.area` 的回填 + +### 必须阻断 + +满足任一项即阻断: + +1. 同一个 `alias_value` 对应多个 `tenant_code` +2. 同一个 `tenant_name` 对应多个 `tenant_code` +3. 冲突值恰好已经出现在 `evaluation_points.area` 分布中 + +原因: + +1. 这会导致同一条评查点记录回填到不同租户存在不确定性 +2. 正式脚本虽然按 `id ASC` 选第一条,但这只能算技术兜底,不能当业务正确性依据 + +--- + +## 6. `code` 唯一性结果怎么判 + +预检项: + +1. 全局重复编码清单 +2. 未来按租户内唯一时的跨租户重复清单 + +### 可直接执行 + +1. 当前全局无重复编码 +2. 或者已有重复但已确认不在用、可后续清理 + +### 需人工确认后执行 + +1. 当前全局无重复 +2. 但“未来若按租户内唯一”模拟结果显示跨租户会有重复 + +这类情况本轮通常仍可执行,因为: + +1. 当前后端仍按全局唯一校验 `code` +2. 本轮并不修改编码唯一性策略 + +### 必须阻断 + +1. 当前就已经存在全局重复编码 +2. 且业务接口仍依赖 `code` 作为稳定标识 +3. 无法确认是否会影响现有创建、更新、查询逻辑 + +--- + +## 7. 共享域结果怎么判 + +预检项: + +1. `公共 / default / 空值 / 省级 / 省局` 残留统计 + +### 可直接执行 + +1. 这些值主要就是历史共享域语义 +2. 数量可解释 + +### 需人工确认后执行 + +1. 共享域值数量异常高 +2. 需要业务确认这些记录到底应保留共享,还是应回归具体租户 + +### 必须阻断 + +1. 共享域值被业务长期当作“具体地区”使用 +2. 一旦统一回填到 `PUBLIC / PROVINCIAL` 会直接改变业务语义 + +--- + +## 8. 最终执行判定规则 + +### 可以直接执行正式脚本 + +必须同时满足: + +1. 无法映射租户残留为空,或仅有极少量且已明确处理方式 +2. `tenant_name / alias` 无冲突 +3. 当前全局 `code` 唯一性没有实质性风险 +4. 共享域语义可确认 + +### 可以带人工清单执行 + +满足下面这种情况可接受: + +1. 预检结果存在少量人工待处理项 +2. 但这些项已形成明确修复清单 +3. 且不会影响大部分数据正确回填 + +此时建议: + +1. 先补 alias 或人工修正小批量数据 +2. 再执行正式脚本 +3. 执行后再复跑预检和验收 SQL + +### 必须先暂停 + +满足任一情况应暂停: + +1. 租户映射冲突未解 +2. 大量无法映射记录未解 +3. 当前全局重复编码风险未解 +4. 共享域值语义未达成一致 + +--- + +## 9. 推荐落地动作 + +预检结果出来后,建议按这个顺序处理: + +1. 先解决 alias / tenant_name 冲突 +2. 再补无法映射记录对应的 alias 或人工归属 +3. 再确认 `code` 唯一性是否只影响未来策略,不影响当前策略 +4. 最后执行正式补列与回填脚本 + +--- + +## 10. 本文和其他文档的关系 + +建议连着看: + +1. [评查点数据库执行说明与验证SQL.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点数据库执行说明与验证SQL.md) +2. [评查点模块收尾清单.md](/home/wren-dev/Porject/leaudit-platform/docs/权限与地区隔离/评查点模块收尾清单.md) +3. [schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql) +4. [precheck_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/precheck_evaluation_points_tenant_cleanup.sql) diff --git a/docs/权限与地区隔离/高风险数据库迁移清单与执行顺序.md b/docs/权限与地区隔离/高风险数据库迁移清单与执行顺序.md new file mode 100644 index 0000000..5db1978 --- /dev/null +++ b/docs/权限与地区隔离/高风险数据库迁移清单与执行顺序.md @@ -0,0 +1,285 @@ +# 高风险数据库迁移清单与执行顺序 + +> 适用范围:`leaudit-platform` 当前从 `area/region` 过渡到 `tenant_code` 的高风险数据库收口阶段 +> 更新日期:2026-05-21 +> 文档定位:明确高风险阶段数据库需要补哪些字段、为什么补、执行顺序是什么、哪些脚本需要先后落地。 + +--- + +## 1. 目标 + +本清单只解决一个核心问题: + +当前多个核心业务表仍没有把 `tenant_code` 作为真实归属主字段,导致代码层即使传了 `tenant_code`,最终也只是转成中文 `area/region` 去查。 + +高风险数据库阶段的目标是: + +1. 所有核心业务主表具备真实 `tenant_code` +2. 查询和写入具备切到 `tenant_code` 主链的基础 +3. 历史 `area/region/default/省级/公共` 有明确回填路径 + +--- + +## 2. 高风险表清单 + +## 2.1 必须补字段的主表 + +### `D1` leaudit_documents + +当前现状: + +1. 模型层只有 `region` +2. 文档、公文、交叉评查、版本链大量依赖该表 + +必须新增: + +1. `tenant_code VARCHAR(64) NULL` + +建议索引: + +1. `idx_leaudit_documents_tenant_code` +2. `idx_leaudit_documents_type_tenant_name_latest` + 说明:替代当前依赖 `region` 的版本匹配索引 + +### `D2` contract_templates + +当前现状: + +1. 主表只有 `region` +2. service 返回里仍存在 `'' AS tenant_code` + +必须新增: + +1. `tenant_code VARCHAR(64) NULL` + +建议索引: + +1. `idx_contract_templates_tenant_code_active` +2. `uq_contract_templates_tenant_code_code_active` + +### `D3` usage_login_events + +当前现状: + +1. 只有 `area_snapshot` +2. 登录统计无法稳定按租户回溯 + +必须新增: + +1. `tenant_code_snapshot VARCHAR(64) NULL` +2. `tenant_name_snapshot VARCHAR(128) NULL` + +建议索引: + +1. `idx_usage_login_events_tenant_code` + +### `D4` rag_dataset + +当前现状: + +1. 只有 `area` +2. 数据集可见性和编辑权限仍依赖旧地区模型 + +必须新增: + +1. `tenant_code VARCHAR(64) NULL` + +建议索引: + +1. `idx_rag_dataset_tenant_code` +2. 后续可替代 `idx_rag_dataset_area` + +### `D5` rag_chat_app + +当前现状: + +1. 只有 `area` +2. 聊天应用可见性仍跟旧地区字段耦合 + +必须新增: + +1. `tenant_code VARCHAR(64) NULL` + +建议索引: + +1. `idx_rag_chat_app_tenant_code` + +### `D8` evaluation_points + +当前现状: + +1. 真实运行链路仍使用旧表 `evaluation_points` +2. 评查点模块代码已切到 `tenant_code` 优先,但旧表仍可能只有 `area` +3. 若数据库不补列,模块只能长期停留在兼容态 + +必须新增: + +1. `tenant_code VARCHAR(64) NULL` +2. `tenant_name VARCHAR(128) NULL` + +建议索引: + +1. `idx_evaluation_points_tenant_code` +2. `idx_evaluation_points_group_tenant_code` +3. `idx_evaluation_points_group_pid_tenant_code` + +## 2.2 暂不直接补字段,但依赖主表收口的表 + +### `D6` govdoc_runs / govdoc_rule_results / govdoc_report_artifacts + +结论: + +1. 暂不优先单独加 `tenant_code` +2. 先通过 `govdoc_runs.document_id -> leaudit_documents.tenant_code` 间接收口 +3. 等主链稳定后,再评估是否补冗余快照列提升报表与筛选性能 + +### `D7` leaudit_audit_runs / 评查结果衍生表 + +结论: + +1. 暂不优先单独加 `tenant_code` +2. 先通过 `document_id -> leaudit_documents.tenant_code` 收口 +3. 若后续统计或明细页性能不足,再追加快照字段 + +--- + +## 3. 历史数据回填规则 + +## 3.1 统一回填原则 + +所有历史回填统一按下面顺序处理: + +1. 若已有 `tenant_code` 且合法,保留 +2. 否则根据原 `tenant_code` 对应编码直接回填 +3. 否则根据 `tenant_name` +4. 否则根据 `area` +5. 否则根据 `region` +6. 若值为 `default / 公共 / 省级 / 空值`,回填为规范编码 + +规范编码建议: + +1. `PUBLIC` +2. `PROVINCIAL` + +## 3.2 别名映射来源 + +优先使用: + +1. `sys_tenants` +2. `sys_tenant_aliases` + +不再允许长期使用硬编码映射表作为正式来源。 + +## 3.3 风险点 + +以下数据回填前必须预检: + +1. 同一个中文地区名是否映射到多个租户编码 +2. 是否存在脏数据:空格、大小写、历史缩写混用 +3. 是否存在已经改名但旧数据仍用旧租户名的记录 + +--- + +## 4. 执行顺序 + +## 4.1 第一步:补列,不切读写 + +执行内容: + +1. 给 `leaudit_documents` +2. `contract_templates` +3. `usage_login_events` +4. `rag_dataset` +5. `rag_chat_app` +6. `evaluation_points` + +先补 `tenant_code` 或快照字段及索引。 + +目标: + +1. 不影响当前存量逻辑 +2. 为后续渐进式代码改造做准备 + +## 4.2 第二步:做历史回填 + +执行内容: + +1. 用 `sys_tenant_aliases` 回填历史 `region/area` +2. 对 `PUBLIC/PROVINCIAL` 做统一归并 + +目标: + +1. 让存量数据先具备基础 `tenant_code` +2. 避免代码一切换就出现大量空值 + +## 4.3 第三步:后端改读写 + +执行内容: + +1. 文档模块先切 +2. 公文模块第二个切 +3. 合同模板第三个切 +4. 统计与 RAG 随后切 + +目标: + +1. 读写统一以 `tenant_code` 为主 +2. `region/area` 退化为兼容展示字段 + +## 4.4 第四步:补约束与清理旧索引 + +等代码和数据稳定后再做: + +1. 评估 `tenant_code` 是否可加 `NOT NULL` +2. 评估是否可将唯一索引从 `region` 改为 `tenant_code` +3. 逐步淘汰旧 `area/region` 主过滤索引 + +--- + +## 5. 本轮建议新增的 SQL 脚本 + +建议新增一个统一高风险迁移脚本: + +1. `scripts/创建sql/schema_tenant_code_high_risk_phase1.sql` +2. `scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql` + +建议内容: + +1. 给核心业务表补 `tenant_code` / `tenant_code_snapshot` +2. 建立基础索引 +3. 执行首轮历史回填 +4. 对评查点旧表单独补 `tenant_code/tenant_name` 与共享域标准编码回填 + +后续再拆第二阶段脚本: + +1. `scripts/创建sql/migrate_tenant_code_high_risk_phase2_cleanup.sql` + +用于: + +1. 强化约束 +2. 调整唯一索引 +3. 清理旧索引 + +--- + +## 6. 验收标准 + +数据库层本阶段完成后,应满足: + +1. 核心业务主表都能查到 `tenant_code` +2. 核心历史数据回填完成率可统计 +3. `PUBLIC / PROVINCIAL` 已规范化 +4. 后端可以在不依赖中文 `area/region` 的前提下完成后续主链改造 + +--- + +## 7. 本轮直接执行项 + +本轮直接开始: + +1. 新增 `schema_tenant_code_high_risk_phase1.sql` +2. 先补列与索引 +3. 加入首轮基于 `sys_tenant_aliases` 的历史回填 SQL +4. 评查点模块单独补充 [schema_evaluation_points_tenant_cleanup.sql](/home/wren-dev/Porject/leaudit-platform/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql),用于旧表 `evaluation_points` 的收尾 + +随后再进入 service 层改造。 diff --git a/docs/规则编辑/backups/rule-domain-before-reset-20260521-214554.sql b/docs/规则编辑/backups/rule-domain-before-reset-20260521-214554.sql new file mode 100644 index 0000000..437c5d3 --- /dev/null +++ b/docs/规则编辑/backups/rule-domain-before-reset-20260521-214554.sql @@ -0,0 +1,265 @@ +-- Rule domain backup before reset +-- generated_at: 2026-05-21T21:45:54 +-- rule_sets: 105 +-- rule_versions: 151 + +-- This file is an audit snapshot, not an automatic restore script. +-- Use the rows below to inspect pre-reset IDs, current_version_id, oss_url and sha. + +-- rule_set id=21 tenant_code=PUBLIC rule_type=contract.construction.general current_version_id=21 status=active deleted_at=None +-- rule_set id=22 tenant_code=PUBLIC rule_type=contract.entrust current_version_id=28 status=active deleted_at=None +-- rule_set id=23 tenant_code=PUBLIC rule_type=contract.evaluation.delegation current_version_id=3 status=active deleted_at=None +-- rule_set id=24 tenant_code=PUBLIC rule_type=contract.gift.charity current_version_id=4 status=active deleted_at=None +-- rule_set id=25 tenant_code=PUBLIC rule_type=contract.gift.general current_version_id=5 status=active deleted_at=None +-- rule_set id=26 tenant_code=PUBLIC rule_type=contract.lease current_version_id=6 status=active deleted_at=None +-- rule_set id=27 tenant_code=PUBLIC rule_type=contract.loan.general current_version_id=7 status=active deleted_at=None +-- rule_set id=28 tenant_code=PUBLIC rule_type=contract.purchase.general current_version_id=8 status=active deleted_at=None +-- rule_set id=29 tenant_code=PUBLIC rule_type=contract.sale current_version_id=9 status=active deleted_at=None +-- rule_set id=30 tenant_code=PUBLIC rule_type=contract.tech current_version_id=10 status=active deleted_at=None +-- rule_set id=31 tenant_code=PUBLIC rule_type=行政卷宗.行政处罚 current_version_id=11 status=active deleted_at=None +-- rule_set id=32 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.停业 current_version_id=12 status=active deleted_at=None +-- rule_set id=33 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.变更 current_version_id=13 status=active deleted_at=None +-- rule_set id=34 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.延续 current_version_id=14 status=active deleted_at=None +-- rule_set id=35 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.恢复营业 current_version_id=15 status=active deleted_at=None +-- rule_set id=36 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.收回 current_version_id=16 status=active deleted_at=None +-- rule_set id=37 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.新办 current_version_id=17 status=active deleted_at=None +-- rule_set id=38 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.歇业 current_version_id=18 status=active deleted_at=None +-- rule_set id=39 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.注销 current_version_id=19 status=active deleted_at=None +-- rule_set id=40 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.补办 current_version_id=20 status=active deleted_at=None +-- rule_set id=41 tenant_code=PUBLIC rule_type=govdoc.general current_version_id=31 status=active deleted_at=None +-- rule_set id=44 tenant_code=JY rule_type=contract.entrust current_version_id=32 status=active deleted_at=None +-- rule_set id=107 tenant_code=MZ rule_type=govdoc.general current_version_id=97 status=active deleted_at=None +-- rule_set id=108 tenant_code=MZ rule_type=contract.gift.charity current_version_id=101 status=active deleted_at=None +-- rule_set id=109 tenant_code=MZ rule_type=contract.gift.general current_version_id=102 status=active deleted_at=None +-- rule_set id=110 tenant_code=MZ rule_type=contract.lease current_version_id=103 status=active deleted_at=None +-- rule_set id=111 tenant_code=MZ rule_type=contract.loan.general current_version_id=104 status=active deleted_at=None +-- rule_set id=112 tenant_code=MZ rule_type=contract.purchase.general current_version_id=105 status=active deleted_at=None +-- rule_set id=113 tenant_code=MZ rule_type=contract.sale current_version_id=106 status=active deleted_at=None +-- rule_set id=114 tenant_code=MZ rule_type=行政卷宗.行政处罚 current_version_id=107 status=active deleted_at=None +-- rule_set id=115 tenant_code=MZ rule_type=行政卷宗.行政许可.停业 current_version_id=108 status=active deleted_at=None +-- rule_set id=116 tenant_code=MZ rule_type=行政卷宗.行政许可.变更 current_version_id=109 status=active deleted_at=None +-- rule_set id=117 tenant_code=MZ rule_type=行政卷宗.行政许可.延续 current_version_id=110 status=active deleted_at=None +-- rule_set id=118 tenant_code=MZ rule_type=行政卷宗.行政许可.恢复营业 current_version_id=111 status=active deleted_at=None +-- rule_set id=119 tenant_code=MZ rule_type=行政卷宗.行政许可.收回 current_version_id=112 status=active deleted_at=None +-- rule_set id=120 tenant_code=MZ rule_type=行政卷宗.行政许可.新办 current_version_id=113 status=active deleted_at=None +-- rule_set id=121 tenant_code=MZ rule_type=行政卷宗.行政许可.歇业 current_version_id=114 status=active deleted_at=None +-- rule_set id=122 tenant_code=MZ rule_type=行政卷宗.行政许可.注销 current_version_id=115 status=active deleted_at=None +-- rule_set id=123 tenant_code=MZ rule_type=行政卷宗.行政许可.补办 current_version_id=116 status=active deleted_at=None +-- rule_set id=124 tenant_code=MZ rule_type=contract.tech current_version_id=117 status=active deleted_at=None +-- rule_set id=125 tenant_code=MZ rule_type=contract.evaluation.delegation current_version_id=118 status=active deleted_at=None +-- rule_set id=126 tenant_code=MZ rule_type=contract.construction.general current_version_id=100 status=active deleted_at=None +-- rule_set id=127 tenant_code=MZ rule_type=contract.entrust current_version_id=121 status=active deleted_at=None +-- rule_set id=128 tenant_code=YF rule_type=govdoc.general current_version_id=128 status=active deleted_at=None +-- rule_set id=129 tenant_code=YF rule_type=contract.gift.charity current_version_id=129 status=active deleted_at=None +-- rule_set id=130 tenant_code=YF rule_type=contract.gift.general current_version_id=130 status=active deleted_at=None +-- rule_set id=131 tenant_code=YF rule_type=contract.lease current_version_id=131 status=active deleted_at=None +-- rule_set id=132 tenant_code=YF rule_type=contract.loan.general current_version_id=132 status=active deleted_at=None +-- rule_set id=133 tenant_code=YF rule_type=contract.purchase.general current_version_id=133 status=active deleted_at=None +-- rule_set id=134 tenant_code=YF rule_type=contract.sale current_version_id=134 status=active deleted_at=None +-- rule_set id=135 tenant_code=YF rule_type=行政卷宗.行政处罚 current_version_id=135 status=active deleted_at=None +-- rule_set id=136 tenant_code=YF rule_type=行政卷宗.行政许可.停业 current_version_id=136 status=active deleted_at=None +-- rule_set id=137 tenant_code=YF rule_type=行政卷宗.行政许可.变更 current_version_id=137 status=active deleted_at=None +-- rule_set id=138 tenant_code=YF rule_type=行政卷宗.行政许可.延续 current_version_id=138 status=active deleted_at=None +-- rule_set id=139 tenant_code=YF rule_type=行政卷宗.行政许可.恢复营业 current_version_id=139 status=active deleted_at=None +-- rule_set id=140 tenant_code=YF rule_type=行政卷宗.行政许可.收回 current_version_id=140 status=active deleted_at=None +-- rule_set id=141 tenant_code=YF rule_type=行政卷宗.行政许可.新办 current_version_id=141 status=active deleted_at=None +-- rule_set id=142 tenant_code=YF rule_type=行政卷宗.行政许可.歇业 current_version_id=142 status=active deleted_at=None +-- rule_set id=143 tenant_code=YF rule_type=行政卷宗.行政许可.注销 current_version_id=143 status=active deleted_at=None +-- rule_set id=144 tenant_code=YF rule_type=行政卷宗.行政许可.补办 current_version_id=144 status=active deleted_at=None +-- rule_set id=145 tenant_code=YF rule_type=contract.tech current_version_id=145 status=active deleted_at=None +-- rule_set id=146 tenant_code=YF rule_type=contract.evaluation.delegation current_version_id=146 status=active deleted_at=None +-- rule_set id=147 tenant_code=YF rule_type=contract.construction.general current_version_id=148 status=active deleted_at=None +-- rule_set id=148 tenant_code=YF rule_type=contract.entrust current_version_id=152 status=active deleted_at=None +-- rule_set id=149 tenant_code=JY rule_type=govdoc.general current_version_id=159 status=active deleted_at=None +-- rule_set id=150 tenant_code=JY rule_type=contract.gift.charity current_version_id=160 status=active deleted_at=None +-- rule_set id=151 tenant_code=JY rule_type=contract.gift.general current_version_id=161 status=active deleted_at=None +-- rule_set id=152 tenant_code=JY rule_type=contract.lease current_version_id=162 status=active deleted_at=None +-- rule_set id=153 tenant_code=JY rule_type=contract.loan.general current_version_id=163 status=active deleted_at=None +-- rule_set id=154 tenant_code=JY rule_type=contract.purchase.general current_version_id=164 status=active deleted_at=None +-- rule_set id=155 tenant_code=JY rule_type=contract.sale current_version_id=165 status=active deleted_at=None +-- rule_set id=156 tenant_code=JY rule_type=行政卷宗.行政处罚 current_version_id=166 status=active deleted_at=None +-- rule_set id=157 tenant_code=JY rule_type=行政卷宗.行政许可.停业 current_version_id=167 status=active deleted_at=None +-- rule_set id=158 tenant_code=JY rule_type=行政卷宗.行政许可.变更 current_version_id=168 status=active deleted_at=None +-- rule_set id=159 tenant_code=JY rule_type=行政卷宗.行政许可.延续 current_version_id=169 status=active deleted_at=None +-- rule_set id=160 tenant_code=JY rule_type=行政卷宗.行政许可.恢复营业 current_version_id=170 status=active deleted_at=None +-- rule_set id=161 tenant_code=JY rule_type=行政卷宗.行政许可.收回 current_version_id=171 status=active deleted_at=None +-- rule_set id=162 tenant_code=JY rule_type=行政卷宗.行政许可.新办 current_version_id=172 status=active deleted_at=None +-- rule_set id=163 tenant_code=JY rule_type=行政卷宗.行政许可.歇业 current_version_id=173 status=active deleted_at=None +-- rule_set id=164 tenant_code=JY rule_type=行政卷宗.行政许可.注销 current_version_id=174 status=active deleted_at=None +-- rule_set id=165 tenant_code=JY rule_type=行政卷宗.行政许可.补办 current_version_id=175 status=active deleted_at=None +-- rule_set id=166 tenant_code=JY rule_type=contract.tech current_version_id=176 status=active deleted_at=None +-- rule_set id=167 tenant_code=JY rule_type=contract.evaluation.delegation current_version_id=177 status=active deleted_at=None +-- rule_set id=168 tenant_code=JY rule_type=contract.construction.general current_version_id=178 status=active deleted_at=None +-- rule_set id=169 tenant_code=CZ rule_type=govdoc.general current_version_id=181 status=active deleted_at=None +-- rule_set id=170 tenant_code=CZ rule_type=contract.gift.charity current_version_id=182 status=active deleted_at=None +-- rule_set id=171 tenant_code=CZ rule_type=contract.gift.general current_version_id=183 status=active deleted_at=None +-- rule_set id=172 tenant_code=CZ rule_type=contract.lease current_version_id=184 status=active deleted_at=None +-- rule_set id=173 tenant_code=CZ rule_type=contract.loan.general current_version_id=185 status=active deleted_at=None +-- rule_set id=174 tenant_code=CZ rule_type=contract.purchase.general current_version_id=186 status=active deleted_at=None +-- rule_set id=175 tenant_code=CZ rule_type=contract.sale current_version_id=187 status=active deleted_at=None +-- rule_set id=176 tenant_code=CZ rule_type=行政卷宗.行政处罚 current_version_id=188 status=active deleted_at=None +-- rule_set id=177 tenant_code=CZ rule_type=行政卷宗.行政许可.停业 current_version_id=189 status=active deleted_at=None +-- rule_set id=178 tenant_code=CZ rule_type=行政卷宗.行政许可.变更 current_version_id=190 status=active deleted_at=None +-- rule_set id=179 tenant_code=CZ rule_type=行政卷宗.行政许可.延续 current_version_id=191 status=active deleted_at=None +-- rule_set id=180 tenant_code=CZ rule_type=行政卷宗.行政许可.恢复营业 current_version_id=192 status=active deleted_at=None +-- rule_set id=181 tenant_code=CZ rule_type=行政卷宗.行政许可.收回 current_version_id=193 status=active deleted_at=None +-- rule_set id=182 tenant_code=CZ rule_type=行政卷宗.行政许可.新办 current_version_id=194 status=active deleted_at=None +-- rule_set id=183 tenant_code=CZ rule_type=行政卷宗.行政许可.歇业 current_version_id=195 status=active deleted_at=None +-- rule_set id=184 tenant_code=CZ rule_type=行政卷宗.行政许可.注销 current_version_id=196 status=active deleted_at=None +-- rule_set id=185 tenant_code=CZ rule_type=行政卷宗.行政许可.补办 current_version_id=197 status=active deleted_at=None +-- rule_set id=186 tenant_code=CZ rule_type=contract.tech current_version_id=198 status=active deleted_at=None +-- rule_set id=187 tenant_code=CZ rule_type=contract.evaluation.delegation current_version_id=199 status=active deleted_at=None +-- rule_set id=188 tenant_code=CZ rule_type=contract.construction.general current_version_id=201 status=active deleted_at=None +-- rule_set id=189 tenant_code=CZ rule_type=contract.entrust current_version_id=205 status=active deleted_at=None + +-- rule_version id=1 rule_set_id=21 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=2 rule_set_id=22 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=3 rule_set_id=23 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=4 rule_set_id=24 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=5 rule_set_id=25 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=6 rule_set_id=26 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=7 rule_set_id=27 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=8 rule_set_id=28 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=9 rule_set_id=29 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=10 rule_set_id=30 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=11 rule_set_id=31 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=12 rule_set_id=32 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=13 rule_set_id=33 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=14 rule_set_id=34 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=15 rule_set_id=35 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=16 rule_set_id=36 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=17 rule_set_id=37 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=18 rule_set_id=38 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=19 rule_set_id=39 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=20 rule_set_id=40 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=21 rule_set_id=21 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=22 rule_set_id=22 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=23 rule_set_id=22 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=24 rule_set_id=22 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=25 rule_set_id=22 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=26 rule_set_id=22 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=27 rule_set_id=22 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=28 rule_set_id=22 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=29 rule_set_id=22 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=30 rule_set_id=21 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=31 rule_set_id=41 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=32 rule_set_id=44 version_no=v10 status=published oss_url=rules/contract.entrust/v10/rules.yaml sha=92310866a9ee3030e3b4198623ab4dc45ac5dfdd0b2cb08d334622544eb6e599 deleted_at=None +-- rule_version id=97 rule_set_id=107 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=98 rule_set_id=126 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=99 rule_set_id=126 version_no=v2 status=rollback oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=100 rule_set_id=126 version_no=v3 status=published oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=101 rule_set_id=108 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=102 rule_set_id=109 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=103 rule_set_id=110 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=104 rule_set_id=111 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=105 rule_set_id=112 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=106 rule_set_id=113 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=107 rule_set_id=114 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=108 rule_set_id=115 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=109 rule_set_id=116 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=110 rule_set_id=117 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=111 rule_set_id=118 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=112 rule_set_id=119 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=113 rule_set_id=120 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=114 rule_set_id=121 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=115 rule_set_id=122 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=116 rule_set_id=123 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=117 rule_set_id=124 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=118 rule_set_id=125 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=119 rule_set_id=127 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=120 rule_set_id=127 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=121 rule_set_id=127 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=122 rule_set_id=127 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=123 rule_set_id=127 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=124 rule_set_id=127 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=125 rule_set_id=127 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=126 rule_set_id=127 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=127 rule_set_id=127 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=128 rule_set_id=128 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=129 rule_set_id=129 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=130 rule_set_id=130 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=131 rule_set_id=131 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=132 rule_set_id=132 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=133 rule_set_id=133 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=134 rule_set_id=134 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=135 rule_set_id=135 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=136 rule_set_id=136 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=137 rule_set_id=137 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=138 rule_set_id=138 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=139 rule_set_id=139 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=140 rule_set_id=140 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=141 rule_set_id=141 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=142 rule_set_id=142 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=143 rule_set_id=143 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=144 rule_set_id=144 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=145 rule_set_id=145 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=146 rule_set_id=146 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=147 rule_set_id=147 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=148 rule_set_id=147 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=149 rule_set_id=147 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=150 rule_set_id=148 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=151 rule_set_id=148 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=152 rule_set_id=148 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=153 rule_set_id=148 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=154 rule_set_id=148 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=155 rule_set_id=148 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=156 rule_set_id=148 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=157 rule_set_id=148 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=158 rule_set_id=148 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=159 rule_set_id=149 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=160 rule_set_id=150 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=161 rule_set_id=151 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=162 rule_set_id=152 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=163 rule_set_id=153 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=164 rule_set_id=154 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=165 rule_set_id=155 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=166 rule_set_id=156 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=167 rule_set_id=157 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=168 rule_set_id=158 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=169 rule_set_id=159 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=170 rule_set_id=160 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=171 rule_set_id=161 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=172 rule_set_id=162 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=173 rule_set_id=163 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=174 rule_set_id=164 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=175 rule_set_id=165 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=176 rule_set_id=166 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=177 rule_set_id=167 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=178 rule_set_id=168 version_no=1.2 status=published oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=179 rule_set_id=168 version_no=v2 status=rollback oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=180 rule_set_id=168 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=181 rule_set_id=169 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=182 rule_set_id=170 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=183 rule_set_id=171 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=184 rule_set_id=172 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=185 rule_set_id=173 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=186 rule_set_id=174 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=187 rule_set_id=175 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=188 rule_set_id=176 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=189 rule_set_id=177 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=190 rule_set_id=178 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=191 rule_set_id=179 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=192 rule_set_id=180 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=193 rule_set_id=181 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=194 rule_set_id=182 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=195 rule_set_id=183 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=196 rule_set_id=184 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=197 rule_set_id=185 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=198 rule_set_id=186 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=199 rule_set_id=187 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=200 rule_set_id=188 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=201 rule_set_id=188 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=202 rule_set_id=188 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=203 rule_set_id=189 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=204 rule_set_id=189 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=205 rule_set_id=189 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=206 rule_set_id=189 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=207 rule_set_id=189 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=208 rule_set_id=189 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=209 rule_set_id=189 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=210 rule_set_id=189 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=211 rule_set_id=189 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=212 rule_set_id=168 version_no=v4 status=rollback oss_url=rules/contract.construction.general/v4/rules.yaml sha=5b058dbb0dd14a31132110f04e649d26cc2d9f544ec2f8ffa56c4ada1917cd13 deleted_at=None +-- rule_version id=213 rule_set_id=22 version_no=v10 status=draft oss_url=rules/contract.entrust/v10/rules.yaml sha=0481fb582bf30fb137d9552cecdae22fd6ee525954b893ccaec386b186caabcc deleted_at=None +-- rule_version id=214 rule_set_id=22 version_no=v11 status=draft oss_url=rules/contract.entrust/v11/rules.yaml sha=4fb270e13c9c3ab9d771807f9f333232a9cf78567163521606165b0889200c3e deleted_at=None +-- rule_version id=215 rule_set_id=127 version_no=pytest-vm-1779365463 status=rollback oss_url=rules/contract.entrust/pytest-vm-1779365463/rules.yaml sha=59763ad2915c90871dfa641264b74fe22f164b7590bd26a71f448dccba66d4e0 deleted_at=None diff --git a/docs/规则编辑/backups/rule-domain-before-reset-20260521-214608.sql b/docs/规则编辑/backups/rule-domain-before-reset-20260521-214608.sql new file mode 100644 index 0000000..4dc8a4f --- /dev/null +++ b/docs/规则编辑/backups/rule-domain-before-reset-20260521-214608.sql @@ -0,0 +1,265 @@ +-- Rule domain backup before reset +-- generated_at: 2026-05-21T21:46:08 +-- rule_sets: 105 +-- rule_versions: 151 + +-- This file is an audit snapshot, not an automatic restore script. +-- Use the rows below to inspect pre-reset IDs, current_version_id, oss_url and sha. + +-- rule_set id=21 tenant_code=PUBLIC rule_type=contract.construction.general current_version_id=21 status=active deleted_at=None +-- rule_set id=22 tenant_code=PUBLIC rule_type=contract.entrust current_version_id=28 status=active deleted_at=None +-- rule_set id=23 tenant_code=PUBLIC rule_type=contract.evaluation.delegation current_version_id=3 status=active deleted_at=None +-- rule_set id=24 tenant_code=PUBLIC rule_type=contract.gift.charity current_version_id=4 status=active deleted_at=None +-- rule_set id=25 tenant_code=PUBLIC rule_type=contract.gift.general current_version_id=5 status=active deleted_at=None +-- rule_set id=26 tenant_code=PUBLIC rule_type=contract.lease current_version_id=6 status=active deleted_at=None +-- rule_set id=27 tenant_code=PUBLIC rule_type=contract.loan.general current_version_id=7 status=active deleted_at=None +-- rule_set id=28 tenant_code=PUBLIC rule_type=contract.purchase.general current_version_id=8 status=active deleted_at=None +-- rule_set id=29 tenant_code=PUBLIC rule_type=contract.sale current_version_id=9 status=active deleted_at=None +-- rule_set id=30 tenant_code=PUBLIC rule_type=contract.tech current_version_id=10 status=active deleted_at=None +-- rule_set id=31 tenant_code=PUBLIC rule_type=行政卷宗.行政处罚 current_version_id=11 status=active deleted_at=None +-- rule_set id=32 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.停业 current_version_id=12 status=active deleted_at=None +-- rule_set id=33 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.变更 current_version_id=13 status=active deleted_at=None +-- rule_set id=34 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.延续 current_version_id=14 status=active deleted_at=None +-- rule_set id=35 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.恢复营业 current_version_id=15 status=active deleted_at=None +-- rule_set id=36 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.收回 current_version_id=16 status=active deleted_at=None +-- rule_set id=37 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.新办 current_version_id=17 status=active deleted_at=None +-- rule_set id=38 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.歇业 current_version_id=18 status=active deleted_at=None +-- rule_set id=39 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.注销 current_version_id=19 status=active deleted_at=None +-- rule_set id=40 tenant_code=PUBLIC rule_type=行政卷宗.行政许可.补办 current_version_id=20 status=active deleted_at=None +-- rule_set id=41 tenant_code=PUBLIC rule_type=govdoc.general current_version_id=31 status=active deleted_at=None +-- rule_set id=44 tenant_code=JY rule_type=contract.entrust current_version_id=32 status=active deleted_at=None +-- rule_set id=107 tenant_code=MZ rule_type=govdoc.general current_version_id=97 status=active deleted_at=None +-- rule_set id=108 tenant_code=MZ rule_type=contract.gift.charity current_version_id=101 status=active deleted_at=None +-- rule_set id=109 tenant_code=MZ rule_type=contract.gift.general current_version_id=102 status=active deleted_at=None +-- rule_set id=110 tenant_code=MZ rule_type=contract.lease current_version_id=103 status=active deleted_at=None +-- rule_set id=111 tenant_code=MZ rule_type=contract.loan.general current_version_id=104 status=active deleted_at=None +-- rule_set id=112 tenant_code=MZ rule_type=contract.purchase.general current_version_id=105 status=active deleted_at=None +-- rule_set id=113 tenant_code=MZ rule_type=contract.sale current_version_id=106 status=active deleted_at=None +-- rule_set id=114 tenant_code=MZ rule_type=行政卷宗.行政处罚 current_version_id=107 status=active deleted_at=None +-- rule_set id=115 tenant_code=MZ rule_type=行政卷宗.行政许可.停业 current_version_id=108 status=active deleted_at=None +-- rule_set id=116 tenant_code=MZ rule_type=行政卷宗.行政许可.变更 current_version_id=109 status=active deleted_at=None +-- rule_set id=117 tenant_code=MZ rule_type=行政卷宗.行政许可.延续 current_version_id=110 status=active deleted_at=None +-- rule_set id=118 tenant_code=MZ rule_type=行政卷宗.行政许可.恢复营业 current_version_id=111 status=active deleted_at=None +-- rule_set id=119 tenant_code=MZ rule_type=行政卷宗.行政许可.收回 current_version_id=112 status=active deleted_at=None +-- rule_set id=120 tenant_code=MZ rule_type=行政卷宗.行政许可.新办 current_version_id=113 status=active deleted_at=None +-- rule_set id=121 tenant_code=MZ rule_type=行政卷宗.行政许可.歇业 current_version_id=114 status=active deleted_at=None +-- rule_set id=122 tenant_code=MZ rule_type=行政卷宗.行政许可.注销 current_version_id=115 status=active deleted_at=None +-- rule_set id=123 tenant_code=MZ rule_type=行政卷宗.行政许可.补办 current_version_id=116 status=active deleted_at=None +-- rule_set id=124 tenant_code=MZ rule_type=contract.tech current_version_id=117 status=active deleted_at=None +-- rule_set id=125 tenant_code=MZ rule_type=contract.evaluation.delegation current_version_id=118 status=active deleted_at=None +-- rule_set id=126 tenant_code=MZ rule_type=contract.construction.general current_version_id=100 status=active deleted_at=None +-- rule_set id=127 tenant_code=MZ rule_type=contract.entrust current_version_id=121 status=active deleted_at=None +-- rule_set id=128 tenant_code=YF rule_type=govdoc.general current_version_id=128 status=active deleted_at=None +-- rule_set id=129 tenant_code=YF rule_type=contract.gift.charity current_version_id=129 status=active deleted_at=None +-- rule_set id=130 tenant_code=YF rule_type=contract.gift.general current_version_id=130 status=active deleted_at=None +-- rule_set id=131 tenant_code=YF rule_type=contract.lease current_version_id=131 status=active deleted_at=None +-- rule_set id=132 tenant_code=YF rule_type=contract.loan.general current_version_id=132 status=active deleted_at=None +-- rule_set id=133 tenant_code=YF rule_type=contract.purchase.general current_version_id=133 status=active deleted_at=None +-- rule_set id=134 tenant_code=YF rule_type=contract.sale current_version_id=134 status=active deleted_at=None +-- rule_set id=135 tenant_code=YF rule_type=行政卷宗.行政处罚 current_version_id=135 status=active deleted_at=None +-- rule_set id=136 tenant_code=YF rule_type=行政卷宗.行政许可.停业 current_version_id=136 status=active deleted_at=None +-- rule_set id=137 tenant_code=YF rule_type=行政卷宗.行政许可.变更 current_version_id=137 status=active deleted_at=None +-- rule_set id=138 tenant_code=YF rule_type=行政卷宗.行政许可.延续 current_version_id=138 status=active deleted_at=None +-- rule_set id=139 tenant_code=YF rule_type=行政卷宗.行政许可.恢复营业 current_version_id=139 status=active deleted_at=None +-- rule_set id=140 tenant_code=YF rule_type=行政卷宗.行政许可.收回 current_version_id=140 status=active deleted_at=None +-- rule_set id=141 tenant_code=YF rule_type=行政卷宗.行政许可.新办 current_version_id=141 status=active deleted_at=None +-- rule_set id=142 tenant_code=YF rule_type=行政卷宗.行政许可.歇业 current_version_id=142 status=active deleted_at=None +-- rule_set id=143 tenant_code=YF rule_type=行政卷宗.行政许可.注销 current_version_id=143 status=active deleted_at=None +-- rule_set id=144 tenant_code=YF rule_type=行政卷宗.行政许可.补办 current_version_id=144 status=active deleted_at=None +-- rule_set id=145 tenant_code=YF rule_type=contract.tech current_version_id=145 status=active deleted_at=None +-- rule_set id=146 tenant_code=YF rule_type=contract.evaluation.delegation current_version_id=146 status=active deleted_at=None +-- rule_set id=147 tenant_code=YF rule_type=contract.construction.general current_version_id=148 status=active deleted_at=None +-- rule_set id=148 tenant_code=YF rule_type=contract.entrust current_version_id=152 status=active deleted_at=None +-- rule_set id=149 tenant_code=JY rule_type=govdoc.general current_version_id=159 status=active deleted_at=None +-- rule_set id=150 tenant_code=JY rule_type=contract.gift.charity current_version_id=160 status=active deleted_at=None +-- rule_set id=151 tenant_code=JY rule_type=contract.gift.general current_version_id=161 status=active deleted_at=None +-- rule_set id=152 tenant_code=JY rule_type=contract.lease current_version_id=162 status=active deleted_at=None +-- rule_set id=153 tenant_code=JY rule_type=contract.loan.general current_version_id=163 status=active deleted_at=None +-- rule_set id=154 tenant_code=JY rule_type=contract.purchase.general current_version_id=164 status=active deleted_at=None +-- rule_set id=155 tenant_code=JY rule_type=contract.sale current_version_id=165 status=active deleted_at=None +-- rule_set id=156 tenant_code=JY rule_type=行政卷宗.行政处罚 current_version_id=166 status=active deleted_at=None +-- rule_set id=157 tenant_code=JY rule_type=行政卷宗.行政许可.停业 current_version_id=167 status=active deleted_at=None +-- rule_set id=158 tenant_code=JY rule_type=行政卷宗.行政许可.变更 current_version_id=168 status=active deleted_at=None +-- rule_set id=159 tenant_code=JY rule_type=行政卷宗.行政许可.延续 current_version_id=169 status=active deleted_at=None +-- rule_set id=160 tenant_code=JY rule_type=行政卷宗.行政许可.恢复营业 current_version_id=170 status=active deleted_at=None +-- rule_set id=161 tenant_code=JY rule_type=行政卷宗.行政许可.收回 current_version_id=171 status=active deleted_at=None +-- rule_set id=162 tenant_code=JY rule_type=行政卷宗.行政许可.新办 current_version_id=172 status=active deleted_at=None +-- rule_set id=163 tenant_code=JY rule_type=行政卷宗.行政许可.歇业 current_version_id=173 status=active deleted_at=None +-- rule_set id=164 tenant_code=JY rule_type=行政卷宗.行政许可.注销 current_version_id=174 status=active deleted_at=None +-- rule_set id=165 tenant_code=JY rule_type=行政卷宗.行政许可.补办 current_version_id=175 status=active deleted_at=None +-- rule_set id=166 tenant_code=JY rule_type=contract.tech current_version_id=176 status=active deleted_at=None +-- rule_set id=167 tenant_code=JY rule_type=contract.evaluation.delegation current_version_id=177 status=active deleted_at=None +-- rule_set id=168 tenant_code=JY rule_type=contract.construction.general current_version_id=178 status=active deleted_at=None +-- rule_set id=169 tenant_code=CZ rule_type=govdoc.general current_version_id=181 status=active deleted_at=None +-- rule_set id=170 tenant_code=CZ rule_type=contract.gift.charity current_version_id=182 status=active deleted_at=None +-- rule_set id=171 tenant_code=CZ rule_type=contract.gift.general current_version_id=183 status=active deleted_at=None +-- rule_set id=172 tenant_code=CZ rule_type=contract.lease current_version_id=184 status=active deleted_at=None +-- rule_set id=173 tenant_code=CZ rule_type=contract.loan.general current_version_id=185 status=active deleted_at=None +-- rule_set id=174 tenant_code=CZ rule_type=contract.purchase.general current_version_id=186 status=active deleted_at=None +-- rule_set id=175 tenant_code=CZ rule_type=contract.sale current_version_id=187 status=active deleted_at=None +-- rule_set id=176 tenant_code=CZ rule_type=行政卷宗.行政处罚 current_version_id=188 status=active deleted_at=None +-- rule_set id=177 tenant_code=CZ rule_type=行政卷宗.行政许可.停业 current_version_id=189 status=active deleted_at=None +-- rule_set id=178 tenant_code=CZ rule_type=行政卷宗.行政许可.变更 current_version_id=190 status=active deleted_at=None +-- rule_set id=179 tenant_code=CZ rule_type=行政卷宗.行政许可.延续 current_version_id=191 status=active deleted_at=None +-- rule_set id=180 tenant_code=CZ rule_type=行政卷宗.行政许可.恢复营业 current_version_id=192 status=active deleted_at=None +-- rule_set id=181 tenant_code=CZ rule_type=行政卷宗.行政许可.收回 current_version_id=193 status=active deleted_at=None +-- rule_set id=182 tenant_code=CZ rule_type=行政卷宗.行政许可.新办 current_version_id=194 status=active deleted_at=None +-- rule_set id=183 tenant_code=CZ rule_type=行政卷宗.行政许可.歇业 current_version_id=195 status=active deleted_at=None +-- rule_set id=184 tenant_code=CZ rule_type=行政卷宗.行政许可.注销 current_version_id=196 status=active deleted_at=None +-- rule_set id=185 tenant_code=CZ rule_type=行政卷宗.行政许可.补办 current_version_id=197 status=active deleted_at=None +-- rule_set id=186 tenant_code=CZ rule_type=contract.tech current_version_id=198 status=active deleted_at=None +-- rule_set id=187 tenant_code=CZ rule_type=contract.evaluation.delegation current_version_id=199 status=active deleted_at=None +-- rule_set id=188 tenant_code=CZ rule_type=contract.construction.general current_version_id=201 status=active deleted_at=None +-- rule_set id=189 tenant_code=CZ rule_type=contract.entrust current_version_id=205 status=active deleted_at=None + +-- rule_version id=1 rule_set_id=21 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=2 rule_set_id=22 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=3 rule_set_id=23 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=4 rule_set_id=24 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=5 rule_set_id=25 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=6 rule_set_id=26 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=7 rule_set_id=27 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=8 rule_set_id=28 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=9 rule_set_id=29 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=10 rule_set_id=30 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=11 rule_set_id=31 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=12 rule_set_id=32 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=13 rule_set_id=33 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=14 rule_set_id=34 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=15 rule_set_id=35 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=16 rule_set_id=36 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=17 rule_set_id=37 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=18 rule_set_id=38 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=19 rule_set_id=39 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=20 rule_set_id=40 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=21 rule_set_id=21 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=22 rule_set_id=22 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=23 rule_set_id=22 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=24 rule_set_id=22 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=25 rule_set_id=22 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=26 rule_set_id=22 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=27 rule_set_id=22 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=28 rule_set_id=22 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=29 rule_set_id=22 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=30 rule_set_id=21 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=31 rule_set_id=41 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=32 rule_set_id=44 version_no=v10 status=published oss_url=rules/contract.entrust/v10/rules.yaml sha=92310866a9ee3030e3b4198623ab4dc45ac5dfdd0b2cb08d334622544eb6e599 deleted_at=None +-- rule_version id=97 rule_set_id=107 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=98 rule_set_id=126 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=99 rule_set_id=126 version_no=v2 status=rollback oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=100 rule_set_id=126 version_no=v3 status=published oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=101 rule_set_id=108 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=102 rule_set_id=109 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=103 rule_set_id=110 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=104 rule_set_id=111 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=105 rule_set_id=112 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=106 rule_set_id=113 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=107 rule_set_id=114 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=108 rule_set_id=115 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=109 rule_set_id=116 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=110 rule_set_id=117 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=111 rule_set_id=118 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=112 rule_set_id=119 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=113 rule_set_id=120 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=114 rule_set_id=121 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=115 rule_set_id=122 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=116 rule_set_id=123 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=117 rule_set_id=124 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=118 rule_set_id=125 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=119 rule_set_id=127 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=120 rule_set_id=127 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=121 rule_set_id=127 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=122 rule_set_id=127 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=123 rule_set_id=127 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=124 rule_set_id=127 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=125 rule_set_id=127 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=126 rule_set_id=127 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=127 rule_set_id=127 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=128 rule_set_id=128 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=129 rule_set_id=129 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=130 rule_set_id=130 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=131 rule_set_id=131 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=132 rule_set_id=132 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=133 rule_set_id=133 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=134 rule_set_id=134 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=135 rule_set_id=135 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=136 rule_set_id=136 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=137 rule_set_id=137 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=138 rule_set_id=138 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=139 rule_set_id=139 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=140 rule_set_id=140 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=141 rule_set_id=141 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=142 rule_set_id=142 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=143 rule_set_id=143 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=144 rule_set_id=144 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=145 rule_set_id=145 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=146 rule_set_id=146 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=147 rule_set_id=147 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=148 rule_set_id=147 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=149 rule_set_id=147 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=150 rule_set_id=148 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=151 rule_set_id=148 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=152 rule_set_id=148 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=153 rule_set_id=148 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=154 rule_set_id=148 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=155 rule_set_id=148 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=156 rule_set_id=148 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=157 rule_set_id=148 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=158 rule_set_id=148 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=159 rule_set_id=149 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=160 rule_set_id=150 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=161 rule_set_id=151 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=162 rule_set_id=152 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=163 rule_set_id=153 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=164 rule_set_id=154 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=165 rule_set_id=155 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=166 rule_set_id=156 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=167 rule_set_id=157 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=168 rule_set_id=158 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=169 rule_set_id=159 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=170 rule_set_id=160 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=171 rule_set_id=161 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=172 rule_set_id=162 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=173 rule_set_id=163 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=174 rule_set_id=164 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=175 rule_set_id=165 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=176 rule_set_id=166 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=177 rule_set_id=167 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=178 rule_set_id=168 version_no=1.2 status=published oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=179 rule_set_id=168 version_no=v2 status=rollback oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=180 rule_set_id=168 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=181 rule_set_id=169 version_no=0.1 status=published oss_url=rules/govdoc.general/0.1/rules.yaml sha=cd8c93b16641aeda9bd403b0159d6443a5a68fb1f602a2adbcdd63a92e5d3687 deleted_at=None +-- rule_version id=182 rule_set_id=170 version_no=1.0 status=published oss_url=rules/contract.gift.charity/1.0/rules.yaml sha=5a7a89f1e88e34c6afa9405010b1aaef517f56704cd264227d652b794e4a0acd deleted_at=None +-- rule_version id=183 rule_set_id=171 version_no=1.0 status=published oss_url=rules/contract.gift.general/1.0/rules.yaml sha=efeca05a6c0a0a1e1d113c781d38f6cd761d18513eb0c2274e9255e50955940e deleted_at=None +-- rule_version id=184 rule_set_id=172 version_no=2.0 status=published oss_url=rules/contract.lease/2.0/rules.yaml sha=536265d6490c87bd7dfb66fdb0428a8164895bd368008073e74c788d2d3f1564 deleted_at=None +-- rule_version id=185 rule_set_id=173 version_no=1.0 status=published oss_url=rules/contract.loan.general/1.0/rules.yaml sha=134cc339b7edfdc061c98becefd9e5f904d25f6e19f5a27664c46610650e07d6 deleted_at=None +-- rule_version id=186 rule_set_id=174 version_no=1.0 status=published oss_url=rules/contract.purchase.general/1.0/rules.yaml sha=d7d9e3b19e83716f067021fc4084a01cc0b1cc0df7f4a851faf8a0a3d33040ca deleted_at=None +-- rule_version id=187 rule_set_id=175 version_no=2.1 status=published oss_url=rules/contract.sale/2.1/rules.yaml sha=e246bf9554003b078c6cccfde2ac9deecad1fbe083c536edf21735f613aeae7d deleted_at=None +-- rule_version id=188 rule_set_id=176 version_no=1.0 status=published oss_url=rules/行政卷宗.行政处罚/1.0/rules.yaml sha=e96c7925535ab0dee8e1cdfa0db5ab398c9951adcda8b680c713d66cd6fa5878 deleted_at=None +-- rule_version id=189 rule_set_id=177 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.停业/1.0/rules.yaml sha=b14ccd9f276943a6159355226f08934263bc95c6fa514d51a91a2d4ac1a02333 deleted_at=None +-- rule_version id=190 rule_set_id=178 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.变更/1.0/rules.yaml sha=44bf8354cbe556d03ea89e0568e0db955675fe7ade3cc1f712779195c26d67eb deleted_at=None +-- rule_version id=191 rule_set_id=179 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.延续/1.0/rules.yaml sha=2b1daa8b579f7ce8cb809dcda2035d46f82d75363357573a4cd3526ac20549fb deleted_at=None +-- rule_version id=192 rule_set_id=180 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.恢复营业/1.0/rules.yaml sha=f175e1fa7a4ec1b24991a8d628085bb1fbc40a60012a91d1c2364fd4638b8a4d deleted_at=None +-- rule_version id=193 rule_set_id=181 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.收回/1.0/rules.yaml sha=f0d1b780ef136356c51132d9d1da1591c89ad2a201a80d2dc533e76aebc34155 deleted_at=None +-- rule_version id=194 rule_set_id=182 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.新办/1.0/rules.yaml sha=89cdbcfd13549f3e5b8d7402d2a46bfcd05a8108b3193210ae176f3aeb3ab79e deleted_at=None +-- rule_version id=195 rule_set_id=183 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.歇业/1.0/rules.yaml sha=cb3f1ca8e5386176a2d9c334720dee0570626644a8fb5ebb281ebd92fc2b77a9 deleted_at=None +-- rule_version id=196 rule_set_id=184 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.注销/1.0/rules.yaml sha=3e07da60e91a20168ba39ca906b9d8b1b9d1fa1841c40d4a63563ef9660e7906 deleted_at=None +-- rule_version id=197 rule_set_id=185 version_no=1.0 status=published oss_url=rules/行政卷宗.行政许可.补办/1.0/rules.yaml sha=56af0893c1d458bbd42eb19b9e76d5394350429a580c9fecf79a66d2d78ca1ed deleted_at=None +-- rule_version id=198 rule_set_id=186 version_no=1.0 status=published oss_url=rules/contract.tech/1.0/rules.yaml sha=95aa3a0be32b44d5594d5ec101d6568480c36a71a1aeec31951680d8aa14e05f deleted_at=None +-- rule_version id=199 rule_set_id=187 version_no=0.1 status=published oss_url=rules/contract.evaluation.delegation/0.1/rules.yaml sha=7d59151c9a41903eca748c0f7bb283ddffad699a68c963af655a84e8fe621745 deleted_at=None +-- rule_version id=200 rule_set_id=188 version_no=1.2 status=rollback oss_url=rules/contract.construction.general/1.2/rules.yaml sha=56117d4aaf76837a9b913560dc21cbb7d5c2469883685a41b7e153b059ef4592 deleted_at=None +-- rule_version id=201 rule_set_id=188 version_no=v2 status=published oss_url=rules/contract.construction.general/v2/rules.yaml sha=ec77babbfe200bd63a6101d273e6318cc5eb492b86ed4829c239e6508ff91534 deleted_at=None +-- rule_version id=202 rule_set_id=188 version_no=v3 status=rollback oss_url=rules/contract.construction.general/v3/rules.yaml sha=a5c21fac8c4352fcf75c6b17a8ec5c6ff71b54b453a3338b3f5a68b6f496f355 deleted_at=None +-- rule_version id=203 rule_set_id=189 version_no=v5 status=rollback oss_url=rules/contract.entrust/v5/rules.yaml sha=fa0e241874262f7229387da9e352073dc3decfe3b8048e559c35cad0ff4b2b49 deleted_at=None +-- rule_version id=204 rule_set_id=189 version_no=2.0 status=rollback oss_url=rules/contract.entrust/2.0/rules.yaml sha=5b4653d245ef0c1897bc366baa4d18e0c5ae28badd1175c04c3841c0a4b06a17 deleted_at=None +-- rule_version id=205 rule_set_id=189 version_no=v8 status=published oss_url=rules/contract.entrust/v8/rules.yaml sha=cf1938696438a2ae6a8b584207f99a4f167512383e456dd0041a2d9287db38b0 deleted_at=None +-- rule_version id=206 rule_set_id=189 version_no=v9 status=rollback oss_url=rules/contract.entrust/v9/rules.yaml sha=81d41fe8d27d394e4372a0668a2acb8975003975929d1c5f56c379c41cc23d33 deleted_at=None +-- rule_version id=207 rule_set_id=189 version_no=v6 status=deprecated oss_url=rules/contract.entrust/v6/rules.yaml sha=86fe184ffcc2a0ef8639f91632f0859519e96fb96f2f7d29b0f6e2343d03ab07 deleted_at=None +-- rule_version id=208 rule_set_id=189 version_no=v7 status=deprecated oss_url=rules/contract.entrust/v7/rules.yaml sha=ba305b20965a5ed794539fe23a3c0534c9149380fc3a67d977ddfe7f0ecbf9f8 deleted_at=None +-- rule_version id=209 rule_set_id=189 version_no=v4 status=deprecated oss_url=rules/contract.entrust/v4/rules.yaml sha=d400d2801a42b94ad87b87bbad291b7f036acf0808a6dbc025336dedc181d21f deleted_at=None +-- rule_version id=210 rule_set_id=189 version_no=v3 status=rollback oss_url=rules/contract.entrust/v3/rules.yaml sha=9d03f9917d0d401efcbb120abdc48a7e03783cac83364692bce32c2eece8a40e deleted_at=None +-- rule_version id=211 rule_set_id=189 version_no=v2 status=rollback oss_url=rules/contract.entrust/v2/rules.yaml sha=5948e85e7d965b0d70710b5fef6acde5eb2a633993f3ada971447bd51c512395 deleted_at=None +-- rule_version id=212 rule_set_id=168 version_no=v4 status=rollback oss_url=rules/contract.construction.general/v4/rules.yaml sha=5b058dbb0dd14a31132110f04e649d26cc2d9f544ec2f8ffa56c4ada1917cd13 deleted_at=None +-- rule_version id=213 rule_set_id=22 version_no=v10 status=draft oss_url=rules/contract.entrust/v10/rules.yaml sha=0481fb582bf30fb137d9552cecdae22fd6ee525954b893ccaec386b186caabcc deleted_at=None +-- rule_version id=214 rule_set_id=22 version_no=v11 status=draft oss_url=rules/contract.entrust/v11/rules.yaml sha=4fb270e13c9c3ab9d771807f9f333232a9cf78567163521606165b0889200c3e deleted_at=None +-- rule_version id=215 rule_set_id=127 version_no=pytest-vm-1779365463 status=rollback oss_url=rules/contract.entrust/pytest-vm-1779365463/rules.yaml sha=59763ad2915c90871dfa641264b74fe22f164b7590bd26a71f448dccba66d4e0 deleted_at=None diff --git a/docs/规则编辑/leaudit-oss-yaml-files规则体检报告.md b/docs/规则编辑/leaudit-oss-yaml-files规则体检报告.md new file mode 100644 index 0000000..5782d84 --- /dev/null +++ b/docs/规则编辑/leaudit-oss-yaml-files规则体检报告.md @@ -0,0 +1,261 @@ +# leaudit-oss-yaml-files 规则体检报告 + +> 体检对象:`/home/wren-dev/Porject/leaudit-platform/leaudit-oss-yaml-files` +> 体检时间:2026-05-21 +> 体检方式:使用当前平台后端 `RuleValidator.ValidateYaml` 执行 YAML schema + DSL 语义校验,并补充扫描历史视觉写法、错误视觉字段引用、全文上下文字段引用。 + +## 1. 总体结论 + +当前目录共有 31 份 `rules.yaml`。 + +| 项 | 数量 | 说明 | +|----|------|------| +| 总规则文件 | 31 | 合同、公文、行政卷宗规则混合 | +| 可通过 schema + DSL 校验 | 21 | 可以作为后续发布候选,但仍可能存在历史兼容写法 | +| 校验失败 | 10 | 不能直接发布,发布接口会被后端拦截 | +| 存在历史视觉写法 | 13 | 部分能通过校验,但会影响规则编辑器正确回显/保存 | + +核心问题不是租户隔离,而是规则 YAML 自身存在三类格式风险: + +| 风险类型 | 影响 | +|----------|------| +| `field: ctx` | 当前 DSL 会把 `ctx` 当成抽取字段校验,因未声明 `ctx` 导致发布失败 | +| `check: visual + element` | 历史兼容写法,运行可部分兼容,但编辑器保存时容易变形成错误依赖 | +| `field: visual.xxx` | 把视觉要素当普通抽取字段,属于错误配置,应迁移为正式视觉 stage | + +## 2. 校验失败文件 + +以下 10 份当前不能直接发布。 + +| 文件 | 失败原因 | 直接影响 | +|------|----------|----------| +| `contract.construction.general/v3/rules.yaml` | `GC-OUR-001 stage 1 references unknown field: ctx` | 建设工程合同 v3 不能发布 | +| `contract.entrust/v9/rules.yaml` | `MM-ENT-035 stage 1 references unknown field: ctx` | 通用委托合同 v9 不能发布 | +| `contract.evaluation.delegation/0.1/rules.yaml` | `rules.1.messages/type Extra inputs are not permitted`,并包含 `field: ctx` | 委托评估合同 0.1 结构错误,不能发布 | +| `contract.gift.charity/1.0/rules.yaml` | `ZY-CHY-023 stage 1 references unknown field: ctx` | 慈善赠与合同不能发布 | +| `contract.gift.general/1.0/rules.yaml` | `ZY-GEN-016 stage 1 references unknown field: ctx` | 通用赠与合同不能发布 | +| `contract.lease/2.0/rules.yaml` | `ZL-LEASE-036 stage 1 references unknown field: ctx` | 租赁合同不能发布 | +| `contract.loan.general/1.0/rules.yaml` | `JK-OUR-001 stage 1 references unknown field: ctx` | 借款合同不能发布 | +| `contract.purchase.general/1.0/rules.yaml` | `MM-OUR-001 stage 1 references unknown field: ctx`,且有 `field: visual.xxx` | 买卖合同不能发布,且视觉补救动作配置错误 | +| `contract.sale/2.1/rules.yaml` | `MM-SALE-029 stage 1 references unknown field: ctx` | 销售合同不能发布 | +| `contract.tech/1.0/rules.yaml` | `JS-TECH-035 stage 1 references unknown field: ctx` | 技术合同不能发布 | + +## 3. 可通过但仍需迁移的文件 + +以下文件 schema + DSL 当前能过,但还存在历史视觉写法 `check: visual`。 + +| 文件 | 规则 | 当前问题 | +|------|------|----------| +| `contract.entrust/2.0/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v2/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v3/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v4/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v5/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v6/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v7/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.entrust/v8/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | + +这类文件不是立即阻断发布的问题,但不建议继续保留旧写法。原因是规则编辑页面现在应该以正式视觉 stage 为准,否则会出现你前面看到的 `visual.xxx`、视觉编码、YAML 片段不一致等问题。 + +## 4. 具体问题定位 + +### 4.1 `field: ctx` + +命中的规则如下: + +| 文件 | 规则 ID | 说明 | +|------|---------|------| +| `contract.construction.general/v3/rules.yaml` | `GC-OUR-001` | 我方缔约地位及不利条款审查 | +| `contract.entrust/v9/rules.yaml` | `MM-ENT-035` | 我方缔约地位及不利条款审查 | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-OUR-001` | 我方缔约地位及不利条款审查 | +| `contract.gift.charity/1.0/rules.yaml` | `ZY-CHY-023` | 我方缔约地位及不利条款审查 | +| `contract.gift.general/1.0/rules.yaml` | `ZY-GEN-016` | 我方缔约地位及不利条款审查 | +| `contract.lease/2.0/rules.yaml` | `ZL-LEASE-036` | 我方缔约地位及不利条款审查 | +| `contract.loan.general/1.0/rules.yaml` | `JK-OUR-001` | 我方缔约地位及不利条款审查 | +| `contract.purchase.general/1.0/rules.yaml` | `MM-OUR-001` | 我方缔约地位及不利条款审查 | +| `contract.sale/2.1/rules.yaml` | `MM-SALE-029` | 我方缔约地位及不利条款审查 | +| `contract.tech/1.0/rules.yaml` | `JS-TECH-035` | 我方缔约地位及不利条款审查 | + +当前错误写法: + +```yaml +stages: +- id: '1' + check: ai + field: ctx + prompt: |- + 合同全文:{{ctx}} +``` + +当前引擎的 AI prompt 插值只读取 `ctx.fields + ctx.derived`。`ctx` 不是内置字段名,所以 DSL 校验会失败,运行时也无法正确插入全文。 + +推荐修复方向有两种: + +| 方案 | 做法 | 是否推荐 | +|------|------|----------| +| A | 在规则文件 `extract` 中声明一个真实字段,例如 `合同全文`,prompt 使用 `{{合同全文}}` | 不推荐,全文不是普通抽取字段,容易拖慢抽取并污染字段语义 | +| B | 后端引擎提供正式全文上下文变量,例如 `full_text` 或 `document_text`,DSL validator 将其列为系统内置字段 | 推荐,符合“全文上下文不是抽取字段”的语义 | + +短期如果只想让 YAML 先通过发布,可以将 `field: ctx` 删除,并把 prompt 中 `{{ctx}}` 改成已声明字段拼接。但这会降低“我方不利条款审查”的覆盖面,不建议作为最终方案。 + +### 4.2 旧视觉写法 `check: visual` + +命中的规则如下: + +| 文件 | 规则 ID | 当前写法 | +|------|---------|----------| +| `contract.entrust/*/rules.yaml` | `MM-ENT-031` | `check: visual + element: 骑缝章` | +| `contract.lease/2.0/rules.yaml` | `ZL-LEASE-032` | `check: visual + element: 骑缝章` | +| `contract.tech/1.0/rules.yaml` | `JS-TECH-032` | `check: visual + element: 骑缝章` | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-SEAL-001` | `check: visual + element: seal + expect: type_in` | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-SEAL-002` | `check: visual + element: seal + expect: text_match` | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-CROSS-001` | `check: visual + element: cross_page_seal + expect: present` | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-CROSS-002` | `check: visual + element: cross_page_seal + expect: complete` | +| `contract.evaluation.delegation/0.1/rules.yaml` | `EVAL-SIGN-001` | `check: visual + element: signature + expect: present` | + +正确迁移方向: + +```yaml +visual_elements: + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed + +rules: +- group: 默认规则组 + rules: + - rule_id: JS-TECH-032 + name: 骑缝章检查 + risk: medium + score: 2 + type: deterministic + applies_in: + - executed + stages: + - id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 + logic: '1' +``` + +### 4.3 `field: visual.xxx` + +当前命中文件: + +| 文件 | 位置 | 问题 | +|------|------|------| +| `contract.purchase.general/1.0/rules.yaml` | remediation actions | `field: visual.甲方签章`、`field: visual.乙方签章` | + +这不是 stage 校验字段,所以当前失败优先暴露为 `ctx`,但它仍是错误语义。补救动作不应让用户“重查 visual.xxx 字段”,而应指向具体视觉要素或普通抽取字段。 + +推荐改法: + +```yaml +actions: +- type: recheck_visual + label: 核对甲方印章文字 + visual_type: seal + seal_id: 甲方签章 +- type: recheck_visual + label: 核对乙方印章文字 + visual_type: seal + seal_id: 乙方签章 +``` + +如果前端暂时没有 `recheck_visual` 动作渲染能力,则先改成普通说明类动作,避免继续写 `field: visual.xxx`。 + +## 5. `contract.evaluation.delegation/0.1` 额外结构错误 + +该文件除 `field: ctx` 和旧视觉写法外,还有 YAML 层级错误: + +```text +rules.1.messages Extra inputs are not permitted +rules.1.type Extra inputs are not permitted +``` + +这说明 `messages` 和 `type` 被放到了 `rules` 顶层列表的第二个元素上,而不是某个具体 rule 内。正确结构必须是: + +```yaml +rules: +- group: 签字合规 + rules: + - rule_id: EVAL-SIGN-001 + name: 甲乙双方签名存在 + risk: high + score: 10 + stages: + - id: '1' + type: signature.present + signature_id: 甲乙方代理人签名 + logic: '1' + messages: + pass: 合同存在甲乙双方的手写签名 + fail: 合同缺少甲方或乙方的手写签名 + type: deterministic +``` + +## 6. 入库/发布前建议顺序 + +建议不要一口气把 31 份全部入库。按以下顺序处理可控: + +1. 先修复 10 份校验失败文件,目标是 `RuleValidator.ValidateYaml` 全部通过。 +2. 再迁移 13 处旧视觉写法,目标是规则详情页回显、保存、发布后 YAML 不再出现 `check: visual`。 +3. 单独处理 `field: ctx` 的系统级方案,建议由引擎支持 `full_text/document_text` 内置上下文,而不是把全文伪装成抽取字段。 +4. 修复 `contract.evaluation.delegation/0.1` 的层级错误后再做视觉迁移。 +5. 对每个 `rule_type` 选择一个当前版本做租户物化,不要把历史所有版本一次性设为所有租户当前版本。 + +## 7. 可重复校验命令 + +在平台根目录执行: + +```bash +python - <<'PY' +from pathlib import Path +from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleValidator + +root = Path('leaudit-oss-yaml-files') +validator = RuleValidator() + +total = ok = 0 +for path in sorted(root.rglob('*.yaml')): + total += 1 + result = validator.ValidateYaml(path.read_text(encoding='utf-8')) + if result.valid: + ok += 1 + continue + print(f'\nFAIL {path}') + for error in result.errors or []: + print(error) + +print(f'\nTOTAL={total} OK={ok} FAIL={total - ok}') +PY +``` + +当前结果应为: + +```text +TOTAL=31 OK=21 FAIL=10 +``` + +## 8. 当前不建议立即做的事 + +| 不建议动作 | 原因 | +|------------|------| +| 直接把这 31 份全部导入生产库 | 有 10 份发布校验失败 | +| 用 `ctx` 作为普通 extract 字段补丁 | 会污染抽取字段语义,且运行时全文来源不稳定 | +| 继续让前端生成 `visual-时间戳` | 业务不可读,回滚/对比/迁移都困难 | +| 保留 `field: visual.xxx` | 视觉要素不是抽取字段,后续页面和执行器都会继续出错 | + +## 9. 后续开发任务 + +| 优先级 | 任务 | 验收点 | +|--------|------|--------| +| P0 | 引擎/校验器支持正式全文上下文变量 | `field: ctx` 不再需要,全文 AI 规则可发布可执行 | +| P0 | 修复 10 份失败 YAML | 31 份全部通过 `RuleValidator.ValidateYaml` | +| P1 | 旧视觉 stage 迁移为正式 stage | 不再出现 `check: visual`,规则详情页 YAML 片段正确 | +| P1 | 修复 `contract.evaluation.delegation/0.1` 层级 | schema 校验通过 | +| P1 | 修复 `contract.purchase.general` 的视觉补救动作 | 不再出现 `field: visual.xxx` | +| P2 | 增加批量规则体检脚本 | 本地、CI、入库前都能重复执行同一套检查 | + diff --git a/docs/规则编辑/规则配置正确设置规范.md b/docs/规则编辑/规则配置正确设置规范.md new file mode 100644 index 0000000..03b21a8 --- /dev/null +++ b/docs/规则编辑/规则配置正确设置规范.md @@ -0,0 +1,572 @@ +# 规则配置正确设置规范 + +> 适用范围:规则配置详情页、规则 YAML、规则版本保存/发布、历史规则迁移。 +> 核心原则:页面配置必须最终落成可被 DSL 校验和执行引擎直接消费的正式 YAML,不能只维护前端临时字段。 + +## 1. 总体对象关系 + +规则配置只有三类核心对象: + +| 对象 | 作用 | 由谁引用 | 正确落点 | +|------|------|----------|----------| +| 抽取字段 | 从文本中抽取业务值,例如 `合同金额`、`甲方`、`技术指标` | 规则 stage / AI prompt | `extract` | +| 视觉要素 | 从 OCR/版面视觉中识别签章、签名、骑缝章 | 视觉规则 stage | `visual_elements` | +| 评查规则 | 判断合同/案卷是否合规 | 执行引擎 | `rules[].rules[].stages` | + +禁止把视觉要素当普通抽取字段使用。也就是说,不能生成: + +```yaml +dependencies: +- visual.骑缝章 +``` + +也不能生成: + +```yaml +stages: +- id: '1' + check: required + field: visual.骑缝章 +``` + +正确做法是先定义视觉要素,再在视觉 stage 中引用。 + +## 2. 抽取字段正确设置 + +抽取字段用于文本抽取,配置在 `extract` 下。 + +```yaml +extract: +- group: 基础信息 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题或合同名称 + - name: 合同金额 + type: money + required_from: draft + desc: 合同总金额 + - name: 质量等级 + type: enum + required_from: draft + allowed: + - 合格 + - 优良 + desc: 工程质量等级 +``` + +字段设置规则: + +| 字段 | 正确口径 | +|------|----------| +| `name` | 业务字段名,必须稳定,AI prompt 和规则 stage 都会引用它 | +| `type` | 字段类型,常见为 `verbatim`、`string`、`money`、`date`、`integer`、`enum` | +| `required_from` | 只允许 `draft` 或 `executed`,不能写 `-` | +| `allowed` | 仅 `enum` 必填,用于枚举候选项 | +| `desc` | 抽取说明,要写清楚正向/负向边界 | + +抽取字段可以被确定性规则引用: + +```yaml +stages: +- id: '1' + check: required + field: 合同名称 +- id: '2' + check: required + field: 合同金额 +logic: 1 AND 2 +``` + +抽取字段也可以被 AI 规则引用: + +```yaml +stages: +- id: '1' + check: ai + prompt: '请检查合同金额是否明确。 + + 合同金额:{{合同金额}} + + 请以 JSON 格式回答:{"passed": true/false, "reason": "简要说明"} + ' +``` + +## 3. 视觉要素正确设置 + +视觉要素用于签章、签名、骑缝章,不属于文本抽取字段。 + +```yaml +visual_elements: + seals: + - id: 发包人公章 + name: 发包人公章或合同专用章 + required: true + required_from: executed + allowed_types: + - 公章 + - 合同专用章 + expected_text_match: + field: 发包人名称 + signatures: + - id: 法定代表人签名 + name: 法定代表人手写签名 + required: true + required_from: executed + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed +``` + +视觉要素字段含义: + +| 字段 | 正确口径 | +|------|----------| +| `id` | 视觉要素稳定编码,建议直接用业务名,例如 `骑缝章`、`发包人公章` | +| `name` | 页面展示名,可比 `id` 更详细 | +| `required` | 是否必需,布尔值 | +| `required_from` | 生效阶段,通常签章/签名/骑缝章为 `executed` | +| `allowed_types` | 可接受的章类型,例如 `公章`、`合同专用章` | +| `expected_text_match.field` | 印章文字需要匹配的抽取字段,例如 `发包人名称` | + +页面交互要求: + +| 页面字段 | 正确行为 | +|----------|----------| +| 视觉要素编码 | 可选;不填时用视觉要素名称作为 `id` | +| 视觉要素名称 | 必填;业务人员可理解的名称 | +| 要素类型 | 三选一:`签章`、`签名`、`骑缝章` | +| 签章类型 | 写入 `allowed_types`,不是 `signature_types` | +| 签署角色 | 当前兼容写入 `expected_text_match.field` 的第一个值 | + +禁止默认生成 `visual-时间戳` 作为业务配置。如果历史数据已经存在,可以保留兼容,但新增配置不应再制造这类不可读编码。 + +## 4. 视觉规则正确设置 + +视觉规则必须使用视觉 stage,不使用 `dependencies`。 + +签章存在: + +```yaml +stages: +- id: '1' + type: seal.present + seal_id: 发包人公章 +logic: '1' +``` + +签章文字匹配: + +```yaml +stages: +- id: '1' + type: seal.text_match + seal_id: 发包人公章 +logic: '1' +``` + +签章类型命中: + +```yaml +stages: +- id: '1' + type: seal.type_in + seal_id: 发包人公章 +logic: '1' +``` + +签名存在: + +```yaml +stages: +- id: '1' + type: signature.present + signature_id: 法定代表人签名 +logic: '1' +``` + +骑缝章完整: + +```yaml +stages: +- id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 +logic: '1' +``` + +## 5. 确定性规则正确设置 + +确定性规则用于非 AI 的固定判断,例如必填、格式、金额一致、数量检查。 + +```yaml +- rule_id: GC-000 + name: 基础信息完整性 + risk: high + score: 10 + type: deterministic + stages: + - id: '1' + check: required + fields: + - 发包人名称 + - 承包人名称 + - 工程名称 + - 合同金额 + logic: and + logic: '1' + messages: + pass: 基础信息完整 + fail: 缺少发包人/承包人/工程名称/合同金额 +``` + +设置规则: + +| 项 | 正确口径 | +|----|----------| +| `rule_id` | 稳定规则编码,不随版本变化 | +| `name` | 业务名称 | +| `risk` | `high`、`medium`、`low` | +| `score` | 分值,必须有值 | +| `stages` | 至少一条 | +| `logic` | 多 stage 时必须表达 stage 组合关系 | +| `messages` | 建议保留,用于结果展示 | + +## 6. AI 规则正确设置 + +AI 规则必须配置 `check: ai` 和 prompt。Prompt 中引用的 `{{字段名}}` 必须来自 `extract` 或可解析的子文档字段。 + +```yaml +- rule_id: JS-TECH-008 + name: 技术标准与质量条款 + risk: medium + score: 3 + type: ai_rule + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中技术标准与质量条款的完整性和明确性。 + + 技术规范:{{技术标准规范}} + 质量要求:{{质量要求}} + + 请以 JSON 格式回答:{"passed": true/false, "reason": "简要说明"} + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' +``` + +AI 规则设置要求: + +| 项 | 正确口径 | +|----|----------| +| `prompt` | 必须包含评查目标、输入字段、判断标准、输出格式 | +| `{{字段名}}` | 必须能在抽取字段中找到 | +| `schema` | 建议配置,保证输出可解析 | +| `pass_when` | 建议配置,避免返回结构无法判定 | + +## 7. 规则组合正确设置 + +规则组合不直接检查字段,而是组合已有规则结果。 + +```yaml +- rule_id: GC-GROUP-QUALITY + name: 质量条款综合评查 + type: rule_group + risk: high + score: 20 + rules: + - GC-QUALITY-001 + - GC-QUALITY-002 + logic: GC-QUALITY-001 AND GC-QUALITY-002 +``` + +规则组合要求: + +| 项 | 正确口径 | +|----|----------| +| `type` | 必须为 `rule_group` | +| `rules` | 子规则 ID 列表,必须存在 | +| `logic` | 组合表达式,引用子规则 ID | +| `stages` | 不应配置 | + +## 8. 子文档/案卷字段正确设置 + +案卷类规则可能有子文档配置。 + +```yaml +sub_documents: +- id: 处罚决定书 + name: 行政处罚决定书 + required: true + extract: + - group: 当事人信息 + fields: + - name: 当事人名称 + type: verbatim + desc: 被处罚对象名称 +``` + +引用方式: + +```yaml +stages: +- id: '1' + check: required + field: 处罚决定书.当事人名称 +``` + +子文档设置要求: + +| 项 | 正确口径 | +|----|----------| +| `id` | 稳定文书编码 | +| `name` | 文书展示名 | +| `required` | 是否必需 | +| `extract.fields` | 子文档内部字段 | + +## 9. 版本保存与发布正确流程 + +规则编辑必须区分“保存草稿”和“发布生效”。 + +1. 编辑页面内容。 +2. 前端序列化为正式 YAML。 +3. 后端执行 DSL schema 校验。 +4. 点击“保存规则配置”只生成草稿版本。 +5. 点击“发布版本”后才成为当前租户生效版本。 +6. 其他租户不应看到该租户未发布或已发布的私有版本。 + +版本状态口径: + +| 状态 | 含义 | 是否生效 | 是否可回滚 | +|------|------|----------|------------| +| `draft` | 草稿 | 否 | 否 | +| `validated` | 校验通过但未发布 | 否 | 否 | +| `published` | 当前生效版本 | 是 | 否,当前版本不能回滚到自己 | +| `rollback` | 历史回滚版本 | 作为历史候选 | 是 | +| `deprecated` | 废弃版本 | 否 | 通常否 | + +## 10. 租户下规则配置正确口径 + +当前采用方案 A:业务树共享,规则版本按租户隔离。 + +| 层级 | 是否共享 | 说明 | +|------|----------|------| +| 业务树/规则包分类 | 共享 | 例如合同、案卷、技术合同分类 | +| 规则 YAML 版本 | 租户隔离 | 梅州、揭阳可以有不同发布版本 | +| 公共规则模板 | 共享来源 | 新租户可从公共模板复制初始版本 | +| 角色权限 | 控制谁能看/编辑/发布 | 不决定规则数据归属 | + +正确行为: + +- 揭阳编辑并发布 `JS-TECH-032` 的 V10,梅州仍应看到梅州自己的当前版本。 +- 新建租户时,应从公共模板或指定来源物化一份租户规则版本。 +- 规则编辑接口必须带当前用户租户上下文,不能只按 `rule_type` 查全局版本。 + +## 11. 历史旧写法迁移规则 + +### 11.1 `check: visual + element` + +旧写法: + +```yaml +stages: +- id: '1' + check: visual + element: 骑缝章 +``` + +如果 `element` 是 `骑缝章` 或 `cross_page_seal`,迁移为: + +```yaml +visual_elements: + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed + +stages: +- id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 +``` + +如果 `element` 是 `seal` 或具体公章名,迁移为: + +```yaml +visual_elements: + seals: + - id: 发包人公章 + name: 发包人公章 + required: true + required_from: executed + +stages: +- id: '1' + type: seal.present + seal_id: 发包人公章 +``` + +如果 `element` 是 `signature` 或具体签名名,迁移为: + +```yaml +visual_elements: + signatures: + - id: 法定代表人签名 + name: 法定代表人签名 + required: true + required_from: executed + +stages: +- id: '1' + type: signature.present + signature_id: 法定代表人签名 +``` + +### 11.2 `dependencies: visual.xxx` + +旧写法: + +```yaml +dependencies: +- visual.骑缝章 +``` + +迁移原则: + +- 不再输出 `dependencies`。 +- 根据视觉要素类型生成正式视觉 stage。 +- 如果找不到对应 `visual_elements`,页面应提示先补视觉要素,不能静默保存。 + +### 11.3 `field: visual.xxx` + +旧写法: + +```yaml +stages: +- id: '1' + check: required + field: visual.骑缝章 +``` + +迁移原则: + +- 删除这个错误 stage。 +- 改成 `cross_page_seal.complete + seal_id`、`seal.present + seal_id` 或 `signature.present + signature_id`。 + +## 12. 页面正确交互设计 + +规则详情页应按当前规则实际引用展示: + +| 区块 | 展示内容 | 新增行为 | +|------|----------|----------| +| 抽取字段 | 当前规则引用的文本字段 | 新增后自动挂到当前规则普通字段依赖 | +| 子文档 | 当前规则引用的子文档或子文档字段 | 新增后按子文档字段引用 | +| 视觉要素 | 当前规则引用的签章/签名/骑缝章 | 新增后生成视觉 stage | +| 当前 YAML 片段 | 正式序列化后的当前规则 YAML | 不显示前端临时 `dependencies` | +| 版本管理 | 草稿、发布、回滚 | 回滚必须选择非当前、非草稿版本 | + +新增视觉要素的页面逻辑: + +1. 用户填写“视觉要素名称”,例如 `骑缝章`。 +2. “视觉要素编码”可不填。 +3. 不填编码时,系统用名称作为 `id`。 +4. 类型选择 `骑缝章` 后,保存当前规则时生成 `cross_page_seal.complete`。 +5. YAML 片段显示正式 stage。 + +## 13. `JS-TECH-032` 正确配置 + +当前旧配置: + +```yaml +- rule_id: JS-TECH-032 + name: 骑缝章检查 + risk: medium + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: 骑缝章 + logic: '1' +``` + +正确配置: + +```yaml +visual_elements: + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed + +rules: +- group: 默认规则组 + rules: + - rule_id: JS-TECH-032 + name: 骑缝章检查 + risk: medium + score: 2 + type: deterministic + applies_in: + - executed + stages: + - id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 + logic: '1' + messages: + pass: 已加盖骑缝章 + fail: 未检测到骑缝章 +``` + +页面上应表现为: + +- 视觉要素区显示 `骑缝章 / 骑缝章 / executed 必需`。 +- 检查方法显示 `cross_page_seal.complete` 或中文 `骑缝章完整性检查`。 +- YAML 片段显示 `type: cross_page_seal.complete` 和 `seal_id: 骑缝章`。 +- 不再显示 `dependencies: visual.骑缝章`。 + +## 14. 保存前验收清单 + +每次保存规则配置前必须满足: + +| 检查项 | 验收标准 | +|--------|----------| +| YAML schema | `RulesFile.model_validate` 通过 | +| DSL validator | `validate(rules_file)` 通过 | +| 字段引用 | 所有 `field / fields / prompt {{}}` 都能找到抽取字段 | +| 视觉引用 | 所有 `seal_id / signature_id` 都能找到视觉要素 | +| 规则组合 | 子规则 ID 存在 | +| 租户边界 | 保存和发布只影响当前租户 | +| 预览一致 | 页面 YAML 片段与实际保存 YAML 一致 | + +## 15. 禁止项 + +禁止以下配置继续进入新版本: + +- `visual-时间戳` 作为新增视觉要素默认编码。 +- `dependencies: visual.xxx`。 +- `check: required + field: visual.xxx`。 +- `required_from: -`。 +- `enum` 字段没有 `allowed`。 +- AI prompt 引用不存在的字段。 +- 规则发布不带租户上下文。 +- 当前版本可以回滚到自己。 + diff --git a/fastapi_admin/celery_app.py b/fastapi_admin/celery_app.py index 615448b..7ebfc57 100644 --- a/fastapi_admin/celery_app.py +++ b/fastapi_admin/celery_app.py @@ -7,6 +7,9 @@ from celery.schedules import crontab from kombu import Queue from fastapi_admin.config import ( + LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL, + LEAUDIT_PAGE_QUALITY_QUEUE_URGENT, + LEAUDIT_RULE_TENANT_MATERIALIZE_CRON_MINUTES, LEAUDIT_TASK_SOFT_TIME_LIMIT, LEAUDIT_TASK_TIME_LIMIT, LEAUDIT_STUCK_SCAN_CRON_MINUTES, @@ -36,10 +39,13 @@ celery_app.conf.update( imports=( "fastapi_modules.fastapi_leaudit.leaudit_bridge.tasks", "fastapi_modules.fastapi_leaudit.govdoc_bridge.tasks", + "fastapi_modules.fastapi_leaudit.page_quality.tasks", ), task_queues=( Queue(LEAUDIT_WORKER_QUEUE_URGENT), Queue(LEAUDIT_WORKER_QUEUE_NORMAL), + Queue(LEAUDIT_PAGE_QUALITY_QUEUE_URGENT), + Queue(LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL), ), task_track_started=True, task_acks_late=True, @@ -52,7 +58,12 @@ celery_app.conf.update( "task": "leaudit.scan_stuck_documents", "schedule": crontab(minute=f"*/{max(1, int(LEAUDIT_STUCK_SCAN_CRON_MINUTES))}"), "options": {"queue": LEAUDIT_WORKER_QUEUE_NORMAL}, - } + }, + "leaudit-materialize-rule-tenants": { + "task": "leaudit.materialize_rule_tenants", + "schedule": crontab(minute=f"*/{max(1, int(LEAUDIT_RULE_TENANT_MATERIALIZE_CRON_MINUTES))}"), + "options": {"queue": LEAUDIT_WORKER_QUEUE_NORMAL}, + }, }, ) @@ -60,6 +71,7 @@ celery_app.autodiscover_tasks( [ "fastapi_modules.fastapi_leaudit.leaudit_bridge", "fastapi_modules.fastapi_leaudit.govdoc_bridge", + "fastapi_modules.fastapi_leaudit.page_quality", ], force=True, ) @@ -67,3 +79,4 @@ celery_app.autodiscover_tasks( # 显式导入任务模块,避免 worker 在某些启动方式下漏注册 bridge tasks。 from fastapi_modules.fastapi_leaudit.leaudit_bridge import tasks as _leaudit_bridge_tasks # noqa: F401,E402 from fastapi_modules.fastapi_leaudit.govdoc_bridge import tasks as _govdoc_bridge_tasks # noqa: F401,E402 +from fastapi_modules.fastapi_leaudit.page_quality import tasks as _page_quality_tasks # noqa: F401,E402 diff --git a/fastapi_admin/config/__init__.pyi b/fastapi_admin/config/__init__.pyi index 9194245..2e518e8 100644 --- a/fastapi_admin/config/__init__.pyi +++ b/fastapi_admin/config/__init__.pyi @@ -81,8 +81,12 @@ LEAUDIT_WORKER_CONCURRENCY: int LEAUDIT_RUN_LOCK_SECONDS: int LEAUDIT_STUCK_SCAN_CRON_MINUTES: int LEAUDIT_STUCK_TIMEOUT_MINUTES: int +LEAUDIT_RULE_TENANT_MATERIALIZE_CRON_MINUTES: int LEAUDIT_TASK_SOFT_TIME_LIMIT: int LEAUDIT_TASK_TIME_LIMIT: int +LEAUDIT_PAGE_QUALITY_ENABLED: bool +LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL: str +LEAUDIT_PAGE_QUALITY_QUEUE_URGENT: str # 常量 ROOT_PATH: object diff --git a/fastapi_admin/config/_settings.py b/fastapi_admin/config/_settings.py index 874d3ee..06dcb47 100644 --- a/fastapi_admin/config/_settings.py +++ b/fastapi_admin/config/_settings.py @@ -122,8 +122,12 @@ class LeauditSettings(_Base): LEAUDIT_RUN_LOCK_SECONDS: int = 1800 LEAUDIT_STUCK_SCAN_CRON_MINUTES: int = 5 LEAUDIT_STUCK_TIMEOUT_MINUTES: int = 20 + LEAUDIT_RULE_TENANT_MATERIALIZE_CRON_MINUTES: int = 30 LEAUDIT_TASK_SOFT_TIME_LIMIT: int = 3300 LEAUDIT_TASK_TIME_LIMIT: int = 3600 + LEAUDIT_PAGE_QUALITY_ENABLED: bool = False + LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL: str = "leaudit.page_quality.normal" + LEAUDIT_PAGE_QUALITY_QUEUE_URGENT: str = "leaudit.page_quality.urgent" # 实例化所有 Settings diff --git a/fastapi_common/fastapi_common_security/jwtService.py b/fastapi_common/fastapi_common_security/jwtService.py index aaa438f..2ed48b2 100644 --- a/fastapi_common/fastapi_common_security/jwtService.py +++ b/fastapi_common/fastapi_common_security/jwtService.py @@ -41,6 +41,9 @@ class JwtService: roles: list[str] | None = None, permissions: list[str] | None = None, area: str | None = None, + tenantCode: str | None = None, + tenantName: str | None = None, + tenantType: str | None = None, userRole: str | None = None, deviceId: str | None = None, deviceName: str | None = None, @@ -71,6 +74,9 @@ class JwtService: "ou_id": ouId, "ou_name": ouName, "area": area, + "tenant_code": tenantCode, + "tenant_name": tenantName, + "tenant_type": tenantType, "user_role": userRole, "iat": now, "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), diff --git a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py index 47f6c42..bf6b062 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py @@ -40,7 +40,8 @@ class ContractTemplateController(BaseController): keyword: str | None = Query(None, description="关键词"), category_id: int | None = Query(None, description="分类ID"), category_name: str | None = Query(None, description="分类名称"), - region: str | None = Query(None, description="地区"), + region: str | None = Query(None, description="兼容保留字段:租户展示值/旧地区"), + tenant_code: str | None = Query(None, description="租户编码"), file_format: str | None = Query(None, description="文件格式"), is_featured: bool | None = Query(None, description="是否推荐"), page: int = Query(1, ge=1, description="页码"), @@ -56,6 +57,7 @@ class ContractTemplateController(BaseController): category_id=category_id, category_name=category_name, region=region, + tenant_code=tenant_code, file_format=file_format, is_featured=is_featured, page=page, @@ -72,6 +74,7 @@ class ContractTemplateController(BaseController): template_code: str = Form(...), category_id: int = Form(...), region: str | None = Form(default=None), + tenant_code: str | None = Form(default=None), description: str | None = Form(default=None), is_featured: bool = Form(default=False), file: UploadFile = File(...), @@ -79,12 +82,13 @@ class ContractTemplateController(BaseController): payload: dict = Depends(verify_access_token), ): if not await self._check_permission(int(payload["user_id"]), ["contract_template:create:write"]): - return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许地区管理员上传合同模板", "data": None}) + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许租户管理员上传合同模板", "data": None}) body = ContractTemplateCreateDTO( title=title, template_code=template_code, category_id=category_id, region=region, + tenant_code=tenant_code, description=description, is_featured=is_featured, ) @@ -96,7 +100,8 @@ class ContractTemplateController(BaseController): q: str = Query(..., min_length=1, description="搜索关键词"), category_id: int | None = Query(None, description="分类ID"), category_name: str | None = Query(None, description="分类名称"), - region: str | None = Query(None, description="地区"), + region: str | None = Query(None, description="兼容保留字段:租户展示值/旧地区"), + tenant_code: str | None = Query(None, description="租户编码"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(12, ge=1, le=200, description="分页大小"), sort_by: str = Query("updated_at", description="排序字段"), @@ -110,6 +115,7 @@ class ContractTemplateController(BaseController): category_id=category_id, category_name=category_name, region=region, + tenant_code=tenant_code, page=page, page_size=page_size, sort_by=sort_by, diff --git a/fastapi_modules/fastapi_leaudit/controllers/documentController.py b/fastapi_modules/fastapi_leaudit/controllers/documentController.py index b79de14..82088fb 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/documentController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/documentController.py @@ -51,6 +51,13 @@ class ReviewPointAuditDTO(BaseModel): class DocumentController(BaseController): """文档控制器。""" + @staticmethod + def _tenant_context(payload: dict[str, Any]) -> dict[str, str | None]: + return { + "TenantCode": payload.get("tenant_code"), + "TenantName": payload.get("tenant_name"), + } + def __init__(self): super().__init__(prefix="", tags=["文档"]) self.DocumentService: IDocumentService = DocumentServiceImpl() @@ -62,7 +69,8 @@ class DocumentController(BaseController): typeId: int | None = Form(None, description="文档类型ID"), typeCode: str | None = Form(None, description="文档类型编码"), groupId: int | None = Form(None, description="二级分组ID"), - region: str = Form("default", description="所属地区"), + region: str | None = Form(None, description="所属租户/地区"), + tenant_code: str | None = Form(None, description="租户编码"), fileRole: str = Form("primary", description="文件角色"), createdBy: int | None = Form(None, description="上传用户ID"), autoRun: bool = Form(False, description="是否上传后自动触发评查"), @@ -81,6 +89,7 @@ class DocumentController(BaseController): attachment.content_type, ) ) + tenant_context = self._tenant_context(payload) Data = await self.DocumentService.Upload( FileName=file.filename or "upload.bin", FileContent=Content, @@ -91,6 +100,8 @@ class DocumentController(BaseController): Region=region, FileRole=fileRole, CreatedBy=int(payload["user_id"]), + TenantCode=tenant_code or tenant_context.get("TenantCode"), + TenantName=tenant_context.get("TenantName"), Attachments=attachmentPayloads, AutoRun=autoRun, Speed=speed, @@ -133,7 +144,8 @@ class DocumentController(BaseController): typeCode: str | None = None, type_ids: str | None = Query(None, description="逗号分隔的文档类型ID列表"), entry_module_id: int | None = Query(None, description="按入口模块ID过滤文档"), - region: str | None = None, + region: str | None = Query(None, description="兼容保留字段:租户展示值/旧地区"), + tenant_code: str | None = Query(None, description="租户编码"), processingStatus: str | None = None, resultStatus: str | None = None, auditStatus: int | None = Query(None, description="按人工审核状态过滤"), @@ -156,6 +168,7 @@ class DocumentController(BaseController): TypeIds=typeIdList, EntryModuleId=entry_module_id, Region=region, + TenantCode=tenant_code, ProcessingStatus=processingStatus, ResultStatus=resultStatus, AuditStatus=auditStatus, diff --git a/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py b/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py index c12ff2f..4737e62 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py @@ -23,7 +23,8 @@ class EntryModuleController(BaseController): @self.router.get("") async def GetEntryModules( name: str | None = Query(None, description="模块名称模糊搜索"), - area: str | None = Query(None, description="地区筛选"), + area: str | None = Query(None, description="历史地区筛选(兼容参数,建议改用 tenant_code)"), + tenant_code: str | None = Query(None, description="租户编码筛选"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(10, ge=1, le=200, description="每页数量"), payload: dict = Depends(verify_access_token), @@ -31,7 +32,13 @@ class EntryModuleController(BaseController): """查询入口模块列表。""" if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:list:read"): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有入口模块列表权限", "data": None}) - data = await self.EntryModuleService.ListModules(Name=name, Area=area, Page=page, PageSize=page_size) + data = await self.EntryModuleService.ListModules( + Name=name, + Area=area, + TenantCode=tenant_code, + Page=page, + PageSize=page_size, + ) return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) @self.router.get("/{ModuleId}") diff --git a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py index 3e5fc68..41d0d61 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py @@ -26,6 +26,15 @@ class EvaluationPointController(BaseController): "delete": "evaluation_point:delete:delete", } + @staticmethod + def _tenant_context(payload: dict) -> dict[str, str | None]: + return { + "UserArea": payload.get("area"), + "UserRole": payload.get("user_role"), + "TenantCode": payload.get("tenant_code"), + "TenantName": payload.get("tenant_name"), + } + def __init__(self): super().__init__(prefix="/v3/evaluation-points", tags=["评查点"]) self.PointService: IEvaluationPointService = EvaluationPointServiceImpl() @@ -40,14 +49,22 @@ class EvaluationPointController(BaseController): evaluation_point_groups_pid: int | None = Query(None, description="一级分组ID"), evaluation_point_groups_id: int | None = Query(None, description="二级分组ID"), document_attribute_type: str | None = Query(None, description="文档属性类型"), - area: str | None = Query(None, description="地区"), + area: str | None = Query(None, description="地区/兼容租户展示值"), + tenant_code: str | None = Query(None, description="租户编码"), + tenant_name: str | None = Query(None, description="租户名称(兼容筛选)"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=500, description="分页大小"), payload: dict = Depends(verify_access_token), ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.PointService.ListPoints( + int(payload["user_id"]), + tenant_context["UserArea"], + tenant_context["UserRole"], + tenant_context["TenantCode"], + tenant_context["TenantName"], name, code, risk, @@ -56,6 +73,8 @@ class EvaluationPointController(BaseController): evaluation_point_groups_id, document_attribute_type, area, + tenant_code, + tenant_name, page, page_size, ) @@ -72,28 +91,61 @@ class EvaluationPointController(BaseController): async def GetEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["detail"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None}) - data = await self.PointService.GetPoint(PointId) + tenant_context = self._tenant_context(payload) + data = await self.PointService.GetPoint( + int(payload["user_id"]), + tenant_context["UserArea"], + tenant_context["UserRole"], + tenant_context["TenantCode"], + tenant_context["TenantName"], + PointId, + ) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.post("") async def CreateEvaluationPoint(body: EvaluationPointCreateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["create"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建评查点权限", "data": None}) - data = await self.PointService.CreatePoint(body) + tenant_context = self._tenant_context(payload) + data = await self.PointService.CreatePoint( + int(payload["user_id"]), + tenant_context["UserArea"], + tenant_context["UserRole"], + tenant_context["TenantCode"], + tenant_context["TenantName"], + body, + ) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.put("/{PointId}") async def UpdateEvaluationPoint(PointId: int, body: EvaluationPointUpdateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["update"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新评查点权限", "data": None}) - data = await self.PointService.UpdatePoint(PointId, body) + tenant_context = self._tenant_context(payload) + data = await self.PointService.UpdatePoint( + int(payload["user_id"]), + tenant_context["UserArea"], + tenant_context["UserRole"], + tenant_context["TenantCode"], + tenant_context["TenantName"], + PointId, + body, + ) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.delete("/{PointId}") async def DeleteEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除评查点权限", "data": None}) - data = await self.PointService.DeletePoint(PointId) + tenant_context = self._tenant_context(payload) + data = await self.PointService.DeletePoint( + int(payload["user_id"]), + tenant_context["UserArea"], + tenant_context["UserRole"], + tenant_context["TenantCode"], + tenant_context["TenantName"], + PointId, + ) return JSONResponse(status_code=200, content=data.model_dump()) async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: diff --git a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py index 2521d41..c28eae0 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py @@ -41,7 +41,7 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) - data = await self.GroupService.ListGroups(name, code, is_enabled, pid, page, page_size) + data = await self.GroupService.ListGroups(name, code, is_enabled, pid, page, page_size, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.get("/all") @@ -52,7 +52,7 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) - data = await self.GroupService.ListAllGroups(include_disabled, with_rule_count) + data = await self.GroupService.ListAllGroups(include_disabled, with_rule_count, int(payload["user_id"])) return JSONResponse(status_code=200, content=[item.model_dump() for item in data]) @self.router.get("/by-document-types") @@ -65,14 +65,19 @@ class EvaluationPointGroupController(BaseController): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) document_type_id_list = [int(item.strip()) for item in document_type_ids.split(",") if item.strip().isdigit()] - data = await self.GroupService.ListGroupsByDocumentTypes(document_type_id_list, include_disabled, with_rule_count) + data = await self.GroupService.ListGroupsByDocumentTypes( + document_type_id_list, + include_disabled, + with_rule_count, + int(payload["user_id"]), + ) return JSONResponse(status_code=200, content=[item.model_dump() for item in data]) @self.router.post("") async def CreateEvaluationPointGroup(body: EvaluationPointGroupCreateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:create:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建评查点分组权限", "data": None}) - data = await self.GroupService.CreateGroup(body) + data = await self.GroupService.CreateGroup(body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.patch("/batch/status") @@ -82,7 +87,7 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有批量更新评查点分组权限", "data": None}) - data = await self.GroupService.BatchUpdateStatus(body) + data = await self.GroupService.BatchUpdateStatus(body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.delete("/batch") @@ -92,7 +97,7 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:delete:delete"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有批量删除评查点分组权限", "data": None}) - data = await self.GroupService.BatchDelete(body) + data = await self.GroupService.BatchDelete(body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.get("/{GroupId}") @@ -103,21 +108,21 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) - data = await self.GroupService.GetGroup(GroupId, with_rule_count) + data = await self.GroupService.GetGroup(GroupId, with_rule_count, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.put("/{GroupId}") async def UpdateEvaluationPointGroup(GroupId: int, body: EvaluationPointGroupUpdateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新评查点分组权限", "data": None}) - data = await self.GroupService.UpdateGroup(GroupId, body) + data = await self.GroupService.UpdateGroup(GroupId, body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.delete("/{GroupId}") async def DeleteEvaluationPointGroup(GroupId: int, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:delete:delete"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除评查点分组权限", "data": None}) - data = await self.GroupService.DeleteGroup(GroupId) + data = await self.GroupService.DeleteGroup(GroupId, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.get("/{GroupId}/children") @@ -130,42 +135,42 @@ class EvaluationPointGroupController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) - data = await self.GroupService.GetChildren(GroupId, is_enabled, page, page_size) + data = await self.GroupService.GetChildren(GroupId, is_enabled, page, page_size, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.put("/{GroupId}/rebind") async def RebindEvaluationPointGroup(GroupId: int, body: EvaluationPointGroupRebindDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有换绑评查点分组权限", "data": None}) - data = await self.GroupService.RebindGroup(GroupId, body) + data = await self.GroupService.RebindGroup(GroupId, body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.post("/{GroupId}/bindings") async def CreateEvaluationPointGroupBinding(GroupId: int, body: EvaluationPointGroupBindingCreateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有绑定规则集权限", "data": None}) - data = await self.GroupService.CreateBinding(GroupId, body) + data = await self.GroupService.CreateBinding(GroupId, body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.put("/bindings/{BindingId}") async def UpdateEvaluationPointGroupBinding(BindingId: int, body: EvaluationPointGroupBindingUpdateDTO, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新规则集绑定权限", "data": None}) - data = await self.GroupService.UpdateBinding(BindingId, body) + data = await self.GroupService.UpdateBinding(BindingId, body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.delete("/bindings/{BindingId}") async def DeleteEvaluationPointGroupBinding(BindingId: int, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除规则集绑定权限", "data": None}) - await self.GroupService.DeleteBinding(BindingId) + await self.GroupService.DeleteBinding(BindingId, int(payload["user_id"])) return JSONResponse(status_code=200, content={"success": True}) @self.router.get("/{GroupId}/rule-template") async def GetEvaluationPointGroupRuleTemplate(GroupId: int, payload: dict = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看规则模板权限", "data": None}) - data = await self.GroupService.GetRuleTemplate(GroupId) + data = await self.GroupService.GetRuleTemplate(GroupId, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) @self.router.post("/{GroupId}/rule-drafts") @@ -177,7 +182,7 @@ class EvaluationPointGroupController(BaseController): if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write", "rules:create:write"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有保存规则草稿权限", "data": None}) effective_body = body.model_copy(update={"editor_user_id": body.editor_user_id or int(payload["user_id"])}) - data = await self.GroupService.CreateRuleDraft(GroupId, effective_body) + data = await self.GroupService.CreateRuleDraft(GroupId, effective_body, int(payload["user_id"])) return JSONResponse(status_code=200, content=data.model_dump()) async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: diff --git a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py index 75f645c..15663c8 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py @@ -30,7 +30,8 @@ class GovdocController(BaseController): async def UploadDocument( file: UploadFile = File(...), typeId: int | None = Form(default=None), - region: str = Form(default="default"), + region: str | None = Form(default=None, description="兼容保留字段:租户展示值/旧地区"), + tenant_code: str | None = Form(default=None, description="租户编码"), autoRun: bool = Form(default=True), speed: str = Form(default="normal"), ruleVersionId: int | None = Form(default=None), @@ -44,6 +45,7 @@ class GovdocController(BaseController): file=file, typeId=typeId, region=region, + tenantCode=tenant_code, autoRun=autoRun, speed=speed, ruleVersionId=ruleVersionId, @@ -57,7 +59,8 @@ class GovdocController(BaseController): pageSize: int = Query(default=20, ge=1, le=100), keyword: str | None = Query(default=None), fileExt: str | None = Query(default=None), - region: str | None = Query(default=None), + region: str | None = Query(default=None, description="兼容保留字段:租户展示值/旧地区"), + tenant_code: str | None = Query(default=None, description="租户编码"), status: str | None = Query(default=None), resultStatus: str | None = Query(default=None), createdBy: int | None = Query(default=None), @@ -75,6 +78,7 @@ class GovdocController(BaseController): keyword=keyword, fileExt=fileExt, region=region, + tenantCode=tenant_code, status=status, resultStatus=resultStatus, createdBy=createdBy, @@ -151,7 +155,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """查询 run 状态、阶段、耗时、错误摘要。""" - result = await self.GovdocService.GetRunStatus(runId=runId) + result = await self.GovdocService.GetRunStatus(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) # ── 结果与报告 ──────────────────────────────────── @@ -162,7 +166,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取审查结果摘要:summary + checked rules + findings 统计 + entities 摘要。""" - result = await self.GovdocService.GetRunResult(runId=runId) + result = await self.GovdocService.GetRunResult(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/findings") @@ -171,7 +175,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取段落级 findings 明细列表。""" - result = await self.GovdocService.GetRunFindings(runId=runId) + result = await self.GovdocService.GetRunFindings(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/entities") @@ -180,7 +184,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取识别出的标题、文号、署名等实体。""" - result = await self.GovdocService.GetRunEntities(runId=runId) + result = await self.GovdocService.GetRunEntities(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/structure") @@ -189,7 +193,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取文档结构统计(结构面板数据)。""" - result = await self.GovdocService.GetRunStructure(runId=runId) + result = await self.GovdocService.GetRunStructure(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/outline") @@ -198,7 +202,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取文档大纲树(大纲面板数据)。""" - result = await self.GovdocService.GetRunOutline(runId=runId) + result = await self.GovdocService.GetRunOutline(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/paragraphs") @@ -207,7 +211,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取前端文档联动视图所需的段落 HTML。""" - result = await self.GovdocService.GetRunParagraphs(runId=runId) + result = await self.GovdocService.GetRunParagraphs(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/report/html") @@ -216,7 +220,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取 HTML 报告内容或下载地址。""" - result = await self.GovdocService.GetReportHtml(runId=runId) + result = await self.GovdocService.GetReportHtml(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/runs/{runId}/report/docx") @@ -225,7 +229,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取批注 DOCX 下载地址。""" - result = await self.GovdocService.GetReportDocx(runId=runId) + result = await self.GovdocService.GetReportDocx(runId=runId, userId=int(payload["user_id"])) return Result.success(data=result) @self.router.get("/documents/{documentId}/original") @@ -234,7 +238,7 @@ class GovdocController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), ): """获取原始上传文档下载地址。""" - result = await self.GovdocService.DownloadOriginal(documentId=documentId) + result = await self.GovdocService.DownloadOriginal(documentId=documentId, userId=int(payload["user_id"])) return Result.success(data=result) # ── 规则 ────────────────────────────────────────── diff --git a/fastapi_modules/fastapi_leaudit/controllers/pageQualityController.py b/fastapi_modules/fastapi_leaudit/controllers/pageQualityController.py new file mode 100644 index 0000000..a1e07c9 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/pageQualityController.py @@ -0,0 +1,62 @@ +"""页级图片质量控制器。""" + +from typing import Any + +from fastapi import Depends, Form + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_common.fastapi_common_web.domain.responses import Result +from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import ( + PageQualityDetailVO, + PageQualityRecheckVO, + PageQualitySummaryVO, +) +from fastapi_modules.fastapi_leaudit.services.impl.pageQualityServiceImpl import PageQualityServiceImpl +from fastapi_modules.fastapi_leaudit.services.pageQualityService import IPageQualityService + + +class PageQualityController(BaseController): + """页级图片质量控制器。""" + + def __init__(self): + super().__init__(prefix="", tags=["页级图片质量"]) + self.PageQualityService: IPageQualityService = PageQualityServiceImpl() + + @self.router.get("/documents/{DocumentId}/page-quality/summary", response_model=Result[PageQualitySummaryVO]) + async def GetDocumentPageQualitySummary( + DocumentId: int, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """获取文档页级模糊检测摘要。""" + data = await self.PageQualityService.GetDocumentSummary( + CurrentUserId=int(payload["user_id"]), + DocumentId=DocumentId, + ) + return Result.success(data=data) + + @self.router.get("/documents/{DocumentId}/page-quality", response_model=Result[PageQualityDetailVO]) + async def GetDocumentPageQualityDetail( + DocumentId: int, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """获取文档页级模糊检测详情。""" + data = await self.PageQualityService.GetDocumentDetail( + CurrentUserId=int(payload["user_id"]), + DocumentId=DocumentId, + ) + return Result.success(data=data) + + @self.router.post("/documents/{DocumentId}/page-quality/recheck", response_model=Result[PageQualityRecheckVO]) + async def RecheckDocumentPageQuality( + DocumentId: int, + speed: str = Form("normal", description="执行速度档位:urgent/normal"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + """手工重跑页级模糊检测。""" + data = await self.PageQualityService.RecheckDocument( + CurrentUserId=int(payload["user_id"]), + DocumentId=DocumentId, + Speed=speed, + ) + return Result.success(data=data, message="页级模糊检测任务已投递") diff --git a/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py b/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py index 7ebd5a1..6881d54 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/ragChatController.py @@ -64,6 +64,15 @@ class RagChatController(BaseController): "dataset_delete": "rag:dataset:delete", } + @staticmethod + def _tenant_context(payload: dict[str, Any]) -> dict[str, str | None]: + return { + "UserArea": payload.get("area"), + "UserRole": payload.get("user_role"), + "TenantCode": payload.get("tenant_code"), + "TenantName": payload.get("tenant_name"), + } + def __init__(self): super().__init__(prefix="/v3/rag", tags=["RAG 聊天"]) self.RagChatService: IRagChatService = RagChatServiceImpl() @@ -74,10 +83,10 @@ class RagChatController(BaseController): async def GetApps(payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["app_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天应用权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagChatService.GetApps( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, ) return Result.success(data=data) @@ -85,10 +94,10 @@ class RagChatController(BaseController): async def GetDefaultApp(payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["app_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看默认聊天应用权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagChatService.GetDefaultApp( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, ) return Result.success(data=data) @@ -96,16 +105,17 @@ class RagChatController(BaseController): async def GetMyDatasets(payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.GetMyDatasets( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, ) return Result.success(data=data) @self.router.get("/datasets/admin", response_model=Result[RagDatasetPageVO]) async def GetAdminDatasets( area: str | None = Query(None), + tenant_code: str | None = Query(None, description="租户编码,支持逗号分隔"), onlyEnabled: bool | None = Query(None), page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200), @@ -113,11 +123,12 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_manage"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有管理知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.GetAdminDatasets( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, Area=area, + TenantFilterCode=tenant_code, OnlyEnabled=onlyEnabled, Page=page, PageSize=pageSize, @@ -128,10 +139,10 @@ class RagChatController(BaseController): async def CreateAdminDataset(Body: dict[str, Any], payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_create"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.CreateAdminDataset( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, Body=Body, ) return Result.success(data=data) @@ -140,10 +151,10 @@ class RagChatController(BaseController): async def UpdateAdminDataset(DatasetId: int, Body: dict[str, Any], payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_update"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.UpdateAdminDataset( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, Body=Body, ) @@ -153,10 +164,10 @@ class RagChatController(BaseController): async def DeleteAdminDataset(DatasetId: int, payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.DeleteAdminDataset( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, ) return Result.success(data=data) @@ -165,10 +176,10 @@ class RagChatController(BaseController): async def GetDatasetDetail(DatasetId: int, payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.GetDatasetDetail( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, ) return Result.success(data=data) @@ -177,10 +188,10 @@ class RagChatController(BaseController): async def UpdateDataset(DatasetId: int, Body: RagDatasetUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_update"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有修改知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.UpdateDataset( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, Body=Body, ) @@ -196,10 +207,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库文档权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.GetDatasetDocuments( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, Page=page, Limit=limit, @@ -215,10 +226,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库文档权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagDatasetService.GetDatasetDocumentDetail( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, ) @@ -235,10 +246,10 @@ class RagChatController(BaseController): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有上传知识库文档权限", "data": None}) process_config = json.loads(data) if data else None file_bytes = await file.read() + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.UploadDatasetDocument( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, FileName=file.filename or "document", ContentType=file.content_type, @@ -259,10 +270,10 @@ class RagChatController(BaseController): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有重处理知识库文档权限", "data": None}) process_config = json.loads(data) if data else None file_bytes = await file.read() + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.UpdateDatasetDocumentByFile( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, FileName=file.filename or "document", @@ -280,10 +291,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库处理进度权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.GetDatasetDocumentIndexingStatus( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, ) @@ -302,10 +313,10 @@ class RagChatController(BaseController): if Action not in {"enable", "disable"}: return JSONResponse(status_code=400, content={"code": 400, "msg": "当前仅支持启用和禁用", "data": None}) document_ids = Body.get("document_ids") or [] + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.BatchUpdateDatasetDocumentStatus( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentIds=[int(item) for item in document_ids], Enabled=enabled, @@ -323,10 +334,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库分段权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.GetDatasetDocumentSegments( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, Page=page, @@ -343,10 +354,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除知识库文档权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.DeleteDatasetDocument( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, ) @@ -360,10 +371,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除知识库文档权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.BatchDeleteDatasetDocuments( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentIds=Body.document_ids, ) @@ -377,10 +388,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有检索知识库权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.RetrieveDataset( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, Query=str(Body.get("query") or ""), RetrievalModel=Body.get("retrieval_model") if isinstance(Body.get("retrieval_model"), dict) else None, @@ -396,10 +407,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库分段权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.GetDatasetDocumentSegmentDetail( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, SegmentId=SegmentId, @@ -416,10 +427,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_update"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有修改知识库分段权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.UpdateDatasetDocumentSegment( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, SegmentId=SegmentId, @@ -436,10 +447,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除知识库分段权限", "data": None}) + tenant_context = self._tenant_context(payload) result = await self.RagDatasetService.DeleteDatasetDocumentSegment( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, DatasetId=DatasetId, DocumentId=DocumentId, SegmentId=SegmentId, @@ -453,10 +464,10 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["chat_use"], self._PERMISSIONS["app_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天配置权限", "data": None}) + tenant_context = self._tenant_context(payload) data = await self.RagChatService.GetAppParameters( CurrentUserId=int(payload["user_id"]), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, AppId=appId, ) return Result.success(data=data) @@ -465,11 +476,11 @@ class RagChatController(BaseController): async def SendMessage(Body: RagChatSendMessageDTO, payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["chat_use"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有使用 RAG 对话权限", "data": None}) + tenant_context = self._tenant_context(payload) stream = self.RagChatService.SendMessage( CurrentUserId=int(payload["user_id"]), UserName=payload.get("username") or str(payload.get("user_id")), - UserArea=payload.get("area"), - UserRole=payload.get("user_role"), + **tenant_context, Query=Body.query, ConversationId=Body.conversationId, AppId=Body.appId, @@ -488,7 +499,13 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["chat_use"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有停止 RAG 对话权限", "data": None}) - data = await self.RagChatService.StopMessage(int(payload["user_id"]), MessageId, Body) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.StopMessage( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + MessageId=MessageId, + Body=Body, + ) return Result.success(data=data) @self.router.get("/chat/conversations", response_model=Result[RagConversationPageVO]) @@ -500,7 +517,14 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天会话权限", "data": None}) - data = await self.RagChatService.GetConversations(int(payload["user_id"]), appId, page, pageSize) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.GetConversations( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + AppId=appId, + Page=page, + PageSize=pageSize, + ) return Result.success(data=data) @self.router.get("/chat/conversations/{ConversationId}/messages", response_model=Result[RagMessagePageVO]) @@ -512,7 +536,14 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_read"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天消息权限", "data": None}) - data = await self.RagChatService.GetConversationMessages(int(payload["user_id"]), ConversationId, page, pageSize) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.GetConversationMessages( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + ConversationId=ConversationId, + Page=page, + PageSize=pageSize, + ) return Result.success(data=data) @self.router.patch("/chat/conversations/{ConversationId}", response_model=Result[RagConversationRenameVO]) @@ -523,14 +554,25 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_update"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有修改聊天会话权限", "data": None}) - data = await self.RagChatService.RenameConversation(int(payload["user_id"]), ConversationId, Body) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.RenameConversation( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + ConversationId=ConversationId, + Body=Body, + ) return Result.success(data=data) @self.router.delete("/chat/conversations/{ConversationId}", response_model=Result[RagOperationResultVO]) async def DeleteConversation(ConversationId: str, payload: dict[str, Any] = Depends(verify_access_token)): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_delete"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除聊天会话权限", "data": None}) - data = await self.RagChatService.DeleteConversation(int(payload["user_id"]), ConversationId) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.DeleteConversation( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + ConversationId=ConversationId, + ) return Result.success(data=data) @self.router.post("/chat/messages/{MessageId}/feedback", response_model=Result[RagOperationResultVO]) @@ -541,7 +583,13 @@ class RagChatController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["message_feedback"]]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有反馈聊天消息权限", "data": None}) - data = await self.RagChatService.UpdateFeedback(int(payload["user_id"]), MessageId, Body) + tenant_context = self._tenant_context(payload) + data = await self.RagChatService.UpdateFeedback( + CurrentUserId=int(payload["user_id"]), + **tenant_context, + MessageId=MessageId, + Body=Body, + ) return Result.success(data=data) async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: diff --git a/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py index 7669ebf..2fe6598 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from fastapi_common.fastapi_common_security.security import verify_access_token from fastapi_common.fastapi_common_web.controller import BaseController -from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import RoleAccessSaveDTO, RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, UserRolesAssignDTO +from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import RoleAccessSaveDTO, RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, UserRolesAssignDTO, UserTenantUpdateDTO from fastapi_modules.fastapi_leaudit.services.impl.rbacAdminServiceImpl import RbacAdminServiceImpl from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService @@ -56,11 +56,12 @@ class RbacAdminController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), - area: str | None = Query(None), + area: str | None = Query(None, description="兼容租户展示值/旧地区"), + tenant_code: str | None = Query(None, description="租户编码"), nick_name: str | None = Query(None), ): """查询用户列表。""" - data = await self.RbacAdminService.ListUsers(int(payload["user_id"]), page, page_size, area, nick_name) + data = await self.RbacAdminService.ListUsers(int(payload["user_id"]), page, page_size, area, tenant_code, nick_name) return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) @self.router.get("/admin/users/organizations/tree") @@ -79,11 +80,12 @@ class RbacAdminController(BaseController): payload: dict[str, Any] = Depends(verify_access_token), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), - area: str | None = Query(None), + area: str | None = Query(None, description="兼容租户展示值/旧地区"), + tenant_code: str | None = Query(None, description="租户编码"), username: str | None = Query(None), ): """查询指定角色下的用户列表。""" - data = await self.RbacAdminService.ListRoleUsers(int(payload["user_id"]), RoleId, page, page_size, area, username) + data = await self.RbacAdminService.ListRoleUsers(int(payload["user_id"]), RoleId, page, page_size, area, tenant_code, username) return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) @self.router.post("/v3/rbac/users/{UserId}/roles") @@ -92,6 +94,12 @@ class RbacAdminController(BaseController): data = await self.RbacAdminService.AssignUserRoles(int(payload["user_id"]), UserId, Body.role_ids) return JSONResponse(status_code=200, content={"code": 200, "message": "角色分配成功", "data": data.model_dump()}) + @self.router.put("/v3/rbac/users/{UserId}/tenant") + async def UpdateUserTenant(UserId: int, Body: UserTenantUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """更新用户租户。""" + data = await self.RbacAdminService.UpdateUserTenant(int(payload["user_id"]), UserId, Body) + return JSONResponse(status_code=200, content={"code": 200, "message": "用户租户更新成功", "data": data.model_dump()}) + @self.router.delete("/v3/rbac/users/{UserId}/roles/{RoleId}") async def RevokeUserRole(UserId: int, RoleId: int, payload: dict[str, Any] = Depends(verify_access_token)): """移除用户角色。""" diff --git a/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py b/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py index 6e8a2c9..5004148 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py @@ -30,7 +30,12 @@ class RuleConfigController(BaseController): """列出规则配置页 pack。""" if not await self._check_permission(int(payload["user_id"])): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则配置查看权限", "data": None}) - data = await (self.RuleConfigService.ListPackSummaries() if summaryOnly else self.RuleConfigService.ListPacks()) + current_user_id = int(payload["user_id"]) + data = await ( + self.RuleConfigService.ListPackSummaries(CurrentUserId=current_user_id) + if summaryOnly + else self.RuleConfigService.ListPacks(CurrentUserId=current_user_id) + ) return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": [item.model_dump() for item in data]}) @self.router.get("/{PackId}") @@ -38,7 +43,7 @@ class RuleConfigController(BaseController): """获取单个规则配置 pack。""" if not await self._check_permission(int(payload["user_id"])): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则配置查看权限", "data": None}) - data = await self.RuleConfigService.GetPack(PackId) + data = await self.RuleConfigService.GetPack(PackId, CurrentUserId=int(payload["user_id"])) return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) async def _check_permission(self, user_id: int) -> bool: diff --git a/fastapi_modules/fastapi_leaudit/controllers/ruleController.py b/fastapi_modules/fastapi_leaudit/controllers/ruleController.py index 22dec92..71b5f81 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/ruleController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/ruleController.py @@ -1,5 +1,11 @@ """规则管理控制器。""" +from typing import Any + +from fastapi import Depends +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token from fastapi_common.fastapi_common_web.controller import BaseController from fastapi_common.fastapi_common_web.domain.responses import Result @@ -18,7 +24,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import ( RuleVersionVO, ) from fastapi_modules.fastapi_leaudit.services import IRuleService +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService class RuleController(BaseController): @@ -27,28 +35,50 @@ class RuleController(BaseController): def __init__(self): super().__init__(prefix="/rule-sets", tags=["规则管理"]) self.RuleService: IRuleService = GetRuleServiceSingleton() + self.PermissionService: IPermissionService = PermissionServiceImpl() + self._PERMISSIONS = { + "list": "rules:list:read", + "version_list": "rules:version_list:read", + "content": "rules:content:read", + "validate": "rules:validate:execute", + "create": "rules:version_create:write", + "publish": "rules:publish:write", + "rollback": "rules:rollback:write", + "binding_read": "rules:binding_list:read", + "binding_create": "rules:binding_create:write", + "binding_update": "rules:binding_update:write", + "binding_delete": "rules:binding_delete:delete", + } @self.router.get("", response_model=Result[list[RuleSetVO]]) - async def ListRuleSets(): + async def ListRuleSets(payload: dict[str, Any] = Depends(verify_access_token)): """列出规则集。""" - Data = await self.RuleService.ListSets() + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则集查看权限", "data": None}) + Data = await self.RuleService.ListSets(CurrentUserId=int(payload["user_id"])) return Result.success(data=Data) @self.router.get("/{RuleType}/versions", response_model=Result[list[RuleVersionVO]]) - async def GetVersions(RuleType: str): + async def GetVersions(RuleType: str, payload: dict[str, Any] = Depends(verify_access_token)): """列出规则集的所有版本。""" - Data = await self.RuleService.GetVersions(RuleType=RuleType) + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["version_list"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则版本查看权限", "data": None}) + Data = await self.RuleService.GetVersions(RuleType=RuleType, CurrentUserId=int(payload["user_id"])) return Result.success(data=Data) @self.router.get("/versions/{VersionId}/content", response_model=Result[RuleContentVO]) - async def GetVersionContent(VersionId: int): + async def GetVersionContent(VersionId: int, payload: dict[str, Any] = Depends(verify_access_token)): """获取规则版本正文。""" - Data = await self.RuleService.GetContent(VersionId=VersionId) + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["content"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则正文查看权限", "data": None}) + Data = await self.RuleService.GetContent(VersionId=VersionId, CurrentUserId=int(payload["user_id"])) return Result.success(data=Data) @self.router.post("/{RuleType}/validate", response_model=Result[RuleValidationVO]) - async def ValidateRuleYaml(RuleType: str, body: RuleValidateDTO): + async def ValidateRuleYaml(RuleType: str, body: RuleValidateDTO, payload: dict[str, Any] = Depends(verify_access_token)): """校验规则 YAML。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["validate"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则校验权限", "data": None}) Data = await self.RuleService.Validate( RuleType=RuleType, YamlText=body.yamlText, @@ -56,47 +86,68 @@ class RuleController(BaseController): return Result.success(data=Data) @self.router.post("/{RuleType}/versions", response_model=Result[RuleVersionVO]) - async def CreateRuleVersion(RuleType: str, body: RuleVersionCreateDTO): + async def CreateRuleVersion(RuleType: str, body: RuleVersionCreateDTO, payload: dict[str, Any] = Depends(verify_access_token)): """创建规则版本。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["create"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建规则版本权限", "data": None}) Data = await self.RuleService.CreateVersion( RuleType=RuleType, YamlText=body.yamlText, ChangeNote=body.changeNote, - EditorUserId=body.editorUserId, + EditorUserId=int(payload["user_id"]), + CurrentUserId=int(payload["user_id"]), ) return Result.success(data=Data) @self.router.post("/{RuleType}/publish", response_model=Result[RuleVersionVO]) - async def PublishRuleVersion(RuleType: str, body: RulePublishDTO): + async def PublishRuleVersion(RuleType: str, body: RulePublishDTO, payload: dict[str, Any] = Depends(verify_access_token)): """发布规则版本。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["publish"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有发布规则权限", "data": None}) Data = await self.RuleService.Publish( RuleType=RuleType, VersionId=body.versionId, - OperatorUserId=body.operatorUserId, + OperatorUserId=int(payload["user_id"]), + CurrentUserId=int(payload["user_id"]), ) return Result.success(data=Data) @self.router.post("/{RuleType}/rollback", response_model=Result[RuleVersionVO]) - async def RollbackRuleVersion(RuleType: str, body: RulePublishDTO): + async def RollbackRuleVersion(RuleType: str, body: RulePublishDTO, payload: dict[str, Any] = Depends(verify_access_token)): """回滚到指定规则版本。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["rollback"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有回滚规则权限", "data": None}) Data = await self.RuleService.Rollback( RuleType=RuleType, VersionId=body.versionId, - OperatorUserId=body.operatorUserId, + OperatorUserId=int(payload["user_id"]), + CurrentUserId=int(payload["user_id"]), ) return Result.success(data=Data) # ── 规则类型绑定 ────────────────────────────────────────── @self.router.get("/bindings", response_model=Result[list[RuleBindingVO]]) - async def ListBindings(ruleType: str | None = None, region: str | None = None): - """列出规则类型绑定。可按规则类型/地区过滤。""" - Data = await self.RuleService.ListBindings(RuleType=ruleType, Region=region) + async def ListBindings( + ruleType: str | None = None, + region: str | None = None, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """列出规则类型绑定。当前主要按规则类型过滤,region 仅兼容保留。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["binding_read"], self._PERMISSIONS["list"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则绑定查看权限", "data": None}) + Data = await self.RuleService.ListBindings( + RuleType=ruleType, + Region=region, + CurrentUserId=int(payload["user_id"]), + ) return Result.success(data=Data) @self.router.post("/{RuleType}/bindings", response_model=Result[RuleBindingVO]) - async def CreateBinding(RuleType: str, body: RuleBindingCreateDTO): + async def CreateBinding(RuleType: str, body: RuleBindingCreateDTO, payload: dict[str, Any] = Depends(verify_access_token)): """创建规则类型绑定。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["binding_create"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建规则绑定权限", "data": None}) Data = await self.RuleService.CreateBinding( DocTypeId=body.docTypeId, RuleSetId=body.ruleSetId, @@ -105,23 +156,32 @@ class RuleController(BaseController): Priority=body.priority, DocTypeCode=body.docTypeCode, Note=body.note, + CurrentUserId=int(payload["user_id"]), ) return Result.success(data=Data) @self.router.put("/bindings/{BindingId}", response_model=Result[RuleBindingVO]) - async def UpdateBinding(BindingId: int, body: RuleBindingUpdateDTO): + async def UpdateBinding(BindingId: int, body: RuleBindingUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): """更新规则类型绑定。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["binding_update"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新规则绑定权限", "data": None}) Data = await self.RuleService.UpdateBinding( BindingId=BindingId, IsActive=body.isActive, Priority=body.priority, BindingMode=body.bindingMode, Note=body.note, + CurrentUserId=int(payload["user_id"]), ) return Result.success(data=Data) @self.router.delete("/bindings/{BindingId}", response_model=Result[None]) - async def DeleteBinding(BindingId: int): + async def DeleteBinding(BindingId: int, payload: dict[str, Any] = Depends(verify_access_token)): """删除规则类型绑定。""" - await self.RuleService.DeleteBinding(BindingId=BindingId) + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["binding_delete"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除规则绑定权限", "data": None}) + await self.RuleService.DeleteBinding(BindingId=BindingId, CurrentUserId=int(payload["user_id"])) return Result.success() + + async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: + return await self.PermissionService.HasAnyPermission(UserId=user_id, PermissionKeys=permission_keys) diff --git a/fastapi_modules/fastapi_leaudit/controllers/tenantController.py b/fastapi_modules/fastapi_leaudit/controllers/tenantController.py new file mode 100644 index 0000000..044c4db --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/tenantController.py @@ -0,0 +1,80 @@ +"""租户主数据控制器。""" + +from __future__ import annotations + +from typing import Any + +from fastapi import Depends, Query +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController + +from fastapi_modules.fastapi_leaudit.domian.Dto.tenantDto import ( + TenantCreateDTO, + TenantStatusUpdateDTO, + TenantUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.tenantServiceImpl import TenantServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService +from fastapi_modules.fastapi_leaudit.services.tenantService import ITenantService + + +class TenantController(BaseController): + """租户主数据控制器。""" + + def __init__(self): + super().__init__(prefix="/v3/tenants", tags=["租户主数据"]) + self.TenantService: ITenantService = TenantServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("") + async def GetTenants( + include_disabled: bool = Query(False, description="是否包含禁用租户"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "rbac:tenants:read"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看租户主数据权限", "data": None}) + data = await self.TenantService.ListTenants(IncludeDisabled=include_disabled) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": {"items": data, "total": len(data)}}) + + @self.router.get("/options") + async def GetTenantOptions( + feature_key: str | None = Query(None, description="按功能键过滤租户"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + data = await self.TenantService.ListTenantOptions(FeatureKey=feature_key) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": {"items": data, "total": len(data)}}) + + @self.router.get("/{TenantCode}") + async def GetTenant(TenantCode: str, payload: dict[str, Any] = Depends(verify_access_token)): + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "rbac:tenants:read"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看租户详情权限", "data": None}) + data = await self.TenantService.GetTenant(TenantCode) + if not data: + return JSONResponse(status_code=404, content={"code": 404, "msg": "租户不存在", "data": None}) + features = await self.TenantService.GetTenantFeatures(TenantCode) + aliases = await self.TenantService.GetTenantAliases(TenantCode) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": {**data, "feature_keys": features, "alias_values": aliases}}) + + @self.router.post("") + async def CreateTenant(Body: TenantCreateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "rbac:tenants:create"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建租户权限", "data": None}) + data = await self.TenantService.CreateTenant(int(payload["user_id"]), Body) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data}) + + @self.router.put("/{TenantCode}") + async def UpdateTenant(TenantCode: str, Body: TenantUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "rbac:tenants:update"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新租户权限", "data": None}) + data = await self.TenantService.UpdateTenant(int(payload["user_id"]), TenantCode, Body) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data}) + + @self.router.patch("/{TenantCode}/status") + async def UpdateTenantStatus(TenantCode: str, Body: TenantStatusUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "rbac:tenants:status"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新租户状态权限", "data": None}) + data = await self.TenantService.UpdateTenantStatus(int(payload["user_id"]), TenantCode, Body) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data}) diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py index 795ac4c..0b77230 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py @@ -7,7 +7,8 @@ class ContractTemplateListQueryDTO(BaseModel): keyword: str | None = Field(None, description="关键词") category_id: int | None = Field(None, description="分类ID") category_name: str | None = Field(None, description="分类名称") - region: str | None = Field(None, description="地区") + region: str | None = Field(None, description="兼容保留字段:租户展示值/旧地区") + tenant_code: str | None = Field(None, description="租户编码") file_format: str | None = Field(None, description="文件格式") is_featured: bool | None = Field(None, description="是否推荐") page: int = Field(1, ge=1, description="页码") @@ -22,7 +23,8 @@ class ContractTemplateSearchQueryDTO(BaseModel): q: str = Field(..., min_length=1, description="搜索关键词") category_id: int | None = Field(None, description="分类ID") category_name: str | None = Field(None, description="分类名称") - region: str | None = Field(None, description="地区") + region: str | None = Field(None, description="兼容保留字段:租户展示值/旧地区") + tenant_code: str | None = Field(None, description="租户编码") page: int = Field(1, ge=1, description="页码") page_size: int = Field(12, ge=1, le=200, description="分页大小") sort_by: str = Field("updated_at", description="排序字段") @@ -35,6 +37,7 @@ class ContractTemplateCreateDTO(BaseModel): title: str = Field(..., min_length=1, max_length=200, description="模板标题") template_code: str = Field(..., min_length=1, max_length=50, description="模板编码") category_id: int = Field(..., description="分类ID") - region: str | None = Field(None, description="所属地区") + region: str | None = Field(None, description="兼容保留字段:所属租户展示值/旧地区") + tenant_code: str | None = Field(None, description="所属租户编码") description: str | None = Field(None, description="模板简介") is_featured: bool = Field(False, description="是否推荐") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py index 289fbe3..1452dba 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py @@ -4,13 +4,22 @@ from pydantic import BaseModel, Field class EntryModuleAreaDTO(BaseModel): - """入口模块地区配置。""" + """入口模块历史地区配置,仅用于兼容旧请求。""" area: str = Field(..., description="地区名称") enabled: bool = Field(True, description="是否启用") sort_order: int = Field(0, description="排序号") +class EntryModuleTenantDTO(BaseModel): + """入口模块租户配置。""" + + tenant_code: str = Field(..., description="租户编码") + tenant_name: str | None = Field(None, description="租户名称") + enabled: bool = Field(True, description="是否启用") + sort_order: int = Field(0, description="排序号") + + class EntryModuleCreateDTO(BaseModel): """创建入口模块请求。""" @@ -18,7 +27,8 @@ class EntryModuleCreateDTO(BaseModel): description: str | None = Field(None, description="模块描述") path: str | None = Field(None, description="前端路由路径") route_path: str | None = Field(None, description="前端跳转路径") - areas: list[EntryModuleAreaDTO] | None = Field(None, description="地区配置") + areas: list[EntryModuleAreaDTO] | None = Field(None, description="历史地区配置(兼容字段,建议改用 tenants)") + tenants: list[EntryModuleTenantDTO] | None = Field(None, description="租户配置") class EntryModuleUpdateDTO(BaseModel): @@ -28,4 +38,5 @@ class EntryModuleUpdateDTO(BaseModel): description: str | None = Field(None, description="模块描述") path: str | None = Field(None, description="前端路由路径") route_path: str | None = Field(None, description="前端跳转路径") - areas: list[EntryModuleAreaDTO] | None = Field(None, description="地区配置") + areas: list[EntryModuleAreaDTO] | None = Field(None, description="历史地区配置(兼容字段,建议改用 tenants)") + tenants: list[EntryModuleTenantDTO] | None = Field(None, description="租户配置") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py index 3377f3d..16fb230 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py @@ -23,6 +23,8 @@ class EvaluationPointBaseDTO(BaseModel): action_config: str | None = Field(None, description="动作配置") score: float | int | None = Field(None, description="分值") area: str | None = Field(None, description="地区") + tenant_code: str | None = Field(None, description="租户编码") + tenant_name: str | None = Field(None, description="租户名称") class EvaluationPointCreateDTO(EvaluationPointBaseDTO): diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py index 6737a4b..5259690 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py @@ -60,3 +60,9 @@ class UserRolesAssignDTO(BaseModel): """用户角色分配请求。""" role_ids: list[int] = Field(default_factory=list, description="角色ID列表") + + +class UserTenantUpdateDTO(BaseModel): + """用户租户更新请求。""" + + tenant_code: str = Field(..., description="租户编码") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py index 638792d..08fd123 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py @@ -9,7 +9,7 @@ class RuleBindingCreateDTO(BaseModel): docTypeId: int = Field(..., description="文档类型ID → leaudit_document_types.id") docTypeCode: str | None = Field(None, description="文档类型编码(冗余快速匹配)") ruleSetId: int = Field(..., description="规则集ID → leaudit_rule_sets.id") - region: str = Field("default", description="适用地区") + region: str = Field("公共", description="兼容保留字段:旧地区/租户展示值") bindingMode: str = Field("explicit", description="绑定模式: explicit / wildcard / fallback") priority: int = Field(0, description="优先级(数值越大优先级越高)") note: str | None = Field(None, description="备注说明") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/tenantDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/tenantDto.py new file mode 100644 index 0000000..91b85fd --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/tenantDto.py @@ -0,0 +1,51 @@ +"""租户主数据 DTO。""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class TenantCreateDTO(BaseModel): + """创建租户请求。""" + + tenant_code: str = Field(..., description="租户编码") + tenant_name: str = Field(..., description="租户名称") + tenant_short_name: str | None = Field(None, description="租户简称") + tenant_type: str = Field("CUSTOM", description="租户类型") + parent_tenant_code: str | None = Field(None, description="父级租户编码") + display_order: int = Field(0, description="显示顺序") + is_enabled: bool = Field(True, description="是否启用") + is_public: bool = Field(False, description="是否公共租户") + can_host_entry_module: bool = Field(True, description="是否可分配入口模块") + can_host_documents: bool = Field(True, description="是否可承载文档") + can_host_rag: bool = Field(True, description="是否可承载知识库") + can_host_templates: bool = Field(True, description="是否可承载模板") + feature_keys: list[str] = Field(default_factory=list, description="功能开关键列表") + alias_values: list[str] = Field(default_factory=list, description="兼容别名列表") + ext: dict[str, Any] | None = Field(None, description="扩展字段") + + +class TenantUpdateDTO(BaseModel): + """更新租户请求。""" + + tenant_name: str | None = Field(None, description="租户名称") + tenant_short_name: str | None = Field(None, description="租户简称") + tenant_type: str | None = Field(None, description="租户类型") + parent_tenant_code: str | None = Field(None, description="父级租户编码") + display_order: int | None = Field(None, description="显示顺序") + is_public: bool | None = Field(None, description="是否公共租户") + can_host_entry_module: bool | None = Field(None, description="是否可分配入口模块") + can_host_documents: bool | None = Field(None, description="是否可承载文档") + can_host_rag: bool | None = Field(None, description="是否可承载知识库") + can_host_templates: bool | None = Field(None, description="是否可承载模板") + feature_keys: list[str] | None = Field(None, description="功能开关键列表") + alias_values: list[str] | None = Field(None, description="兼容别名列表") + ext: dict[str, Any] | None = Field(None, description="扩展字段") + + +class TenantStatusUpdateDTO(BaseModel): + """租户启停请求。""" + + is_enabled: bool = Field(..., description="是否启用") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py index 6984e28..5f7c8ca 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py @@ -23,7 +23,9 @@ class ContractTemplateListItemVO(BaseModel): category_name: str | None = Field(None, description="分类名称") category_icon: str | None = Field(None, description="分类图标") description: str | None = Field(None, description="模板简介") - region: str = Field(..., description="所属地区") + region: str = Field(..., description="兼容保留字段:所属租户展示值/旧地区") + tenant_code: str | None = Field(None, description="租户编码") + tenant_name: str | None = Field(None, description="租户名称") file_path: str | None = Field(None, description="原始模板文件路径") pdf_file_path: str | None = Field(None, description="PDF 预览文件路径") file_format: str = Field(..., description="文件格式") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py index 75541e1..63c5374 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py @@ -7,6 +7,13 @@ from datetime import datetime from pydantic import BaseModel, Field +class CrossReviewTaskTenantVO(BaseModel): + """任务评查租户展示项。""" + + tenantCode: str = Field("", description="租户编码") + tenantName: str = Field("", description="租户名称") + + class CrossReviewTaskItemVO(BaseModel): """任务列表项。""" @@ -20,7 +27,8 @@ class CrossReviewTaskItemVO(BaseModel): totalDocuments: int = Field(0, description="文档总数") completedDocuments: int = Field(0, description="已完成文档数") createdAt: datetime | None = Field(None, description="创建时间") - evaluationRegion: list[str] = Field(default_factory=list, description="评查地区") + evaluationTenants: list[CrossReviewTaskTenantVO] = Field(default_factory=list, description="评查租户列表") + evaluationRegion: list[str] = Field(default_factory=list, description="评查租户/地区(兼容展示值)") class CrossReviewTaskPageVO(BaseModel): diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py index a2007df..0ff0da6 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from fastapi_modules.fastapi_leaudit.domian.vo.auditVo import AuditRunVO +from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import PageQualitySummaryVO class DocumentUploadVO(BaseModel): @@ -20,11 +21,16 @@ class DocumentUploadVO(BaseModel): typeCode: str = Field(..., description="文档类型编码") groupId: int | None = Field(None, description="命中的二级分组ID") region: str = Field(..., description="所属地区") + tenantCode: str | None = Field(None, description="所属租户编码") + tenantName: str | None = Field(None, description="所属租户名称") fileName: str = Field(..., description="文件名") ossUrl: str = Field(..., description="OSS 对象路径") speed: str = Field(..., description="执行速度档位:urgent/normal") processingStatus: str = Field(..., description="文档处理状态") autoRunTriggered: bool = Field(..., description="是否已自动触发评查") + pageQualityRunId: int | None = Field(None, description="页级模糊检测运行ID") + pageQualityRunStatus: str | None = Field(None, description="页级模糊检测运行状态") + pageQualitySummaryStatus: str | None = Field(None, description="页级模糊检测摘要状态") run: AuditRunVO | None = Field(None, description="自动触发后的运行信息") @@ -47,6 +53,11 @@ class DocumentStatusItemVO(BaseModel): runStatus: str | None = Field(None, description="当前运行状态") phase: str | None = Field(None, description="当前运行阶段") resultStatus: str | None = Field(None, description="当前结果状态") + pageQualityRunId: int | None = Field(None, description="页级模糊检测运行ID") + pageQualityRunStatus: str | None = Field(None, description="页级模糊检测运行状态") + pageQualitySummaryStatus: str | None = Field(None, description="页级模糊检测摘要状态") + pageQualityReviewPageCount: int = Field(0, description="疑似模糊页数") + pageQualityRejectPageCount: int = Field(0, description="建议重拍页数") updatedAt: str | None = Field(None, description="更新时间") @@ -99,6 +110,8 @@ class DocumentListItemVO(BaseModel): groupId: int | None = Field(None, description="命中的二级分组ID") groupName: str | None = Field(None, description="二级分组名称") region: str = Field(..., description="区域") + tenantCode: str | None = Field(None, description="所属租户编码") + tenantName: str | None = Field(None, description="所属租户名称") normalizedName: str | None = Field(None, description="归一化名称") fileId: int | None = Field(None, description="文件ID") fileName: str | None = Field(None, description="文件名") @@ -119,6 +132,11 @@ class DocumentListItemVO(BaseModel): documentNumber: str | None = Field(None, description="业务文号/案号") auditStatus: int | None = Field(None, description="人工维护审核状态") isTestDocument: bool = Field(False, description="是否测试文档") + pageQualityRunId: int | None = Field(None, description="页级模糊检测运行ID") + pageQualityRunStatus: str | None = Field(None, description="页级模糊检测运行状态") + pageQualitySummaryStatus: str | None = Field(None, description="页级模糊检测摘要状态") + pageQualityIssueCount: int = Field(0, description="页级问题页数") + pageQualityWarningText: str | None = Field(None, description="页级模糊预警文案") updatedAt: str | None = Field(None, description="更新时间") hasHistory: bool = Field(False, description="是否存在历史版本") totalVersions: int = Field(1, description="总版本数") @@ -130,6 +148,7 @@ class DocumentDetailVO(DocumentListItemVO): remark: str | None = Field(None, description="备注") pageCount: int | None = Field(None, description="页数,暂无精确值时可为空") + pageQualitySummary: PageQualitySummaryVO | None = Field(None, description="页级模糊检测摘要") attachments: list[DocumentAttachmentVO] = Field(default_factory=list, description="附件列表") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py index a448b3c..d025356 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py @@ -3,10 +3,11 @@ from pydantic import BaseModel, Field -class EntryModuleAreaVO(BaseModel): - """入口模块地区配置。""" +class EntryModuleTenantVO(BaseModel): + """入口模块租户配置。""" - area: str = Field(..., description="地区名称") + tenant_code: str = Field(..., description="租户编码") + tenant_name: str | None = Field(None, description="租户名称") enabled: bool = Field(True, description="是否启用") sort_order: int = Field(0, description="排序号") @@ -21,7 +22,7 @@ class EntryModuleVO(BaseModel): route_path: str | None = Field(None, description="前端跳转路径") sort_order: int = Field(0, description="排序") is_enabled: bool = Field(True, description="是否启用") - areas: list[EntryModuleAreaVO] = Field(default_factory=list, description="地区配置") + tenants: list[EntryModuleTenantVO] = Field(default_factory=list, description="租户配置") created_at: str | None = Field(None, description="创建时间") updated_at: str | None = Field(None, description="更新时间") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py index 39c9ceb..f372f53 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py @@ -13,12 +13,19 @@ class RuleGroupBindingVO(BaseModel): priority: int = Field(0, description="优先级") is_active: bool = Field(True, description="是否启用") note: str | None = Field(None, description="备注") + tenant_code: str | None = Field(None, description="绑定所属租户编码") + scope_type: str | None = Field(None, description="绑定所属作用域") + tenant_name_snapshot: str | None = Field(None, description="绑定所属租户名称快照") rule_type: str | None = Field(None, description="规则类型编码") rule_name: str | None = Field(None, description="规则集名称") current_version_id: int | None = Field(None, description="当前版本ID") fallback_version_id: int | None = Field(None, description="回退版本ID") has_usable_version: bool = Field(False, description="是否存在可用版本") usable_rule_count: int = Field(0, description="可用规则数") + effectiveTenantCode: str | None = Field(None, description="当前绑定实际生效租户编码") + effectiveScopeType: str | None = Field(None, description="当前绑定实际生效作用域") + isInherited: bool = Field(False, description="当前绑定是否为继承态") + sourceRuleSetId: int | None = Field(None, description="来源规则集ID") class EvaluationPointGroupVO(BaseModel): diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py index 7e18f86..1233374 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py @@ -27,6 +27,8 @@ class EvaluationPointVO(BaseModel): action_config: str = Field("", description="动作配置") score: float = Field(0, description="分值") area: str = Field("", description="地区") + tenantCode: str = Field("", description="租户编码") + tenantName: str = Field("", description="租户名称") created_at: str | None = Field(None, description="创建时间") updated_at: str | None = Field(None, description="更新时间") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py index 4ab0bb6..cdc6069 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py @@ -11,6 +11,15 @@ class HomeEntryAreaVO(BaseModel): sortOrder: int = Field(0, description="地区内排序") +class HomeEntryTenantVO(BaseModel): + """入口模块租户配置。""" + + tenantCode: str = Field(..., description="租户编码") + tenantName: str | None = Field(None, description="租户名称") + enabled: bool = Field(..., description="是否启用") + sortOrder: int = Field(0, description="租户内排序") + + class HomeEntryDocumentTypeVO(BaseModel): """入口模块下的文档类型。""" @@ -31,4 +40,5 @@ class HomeEntryModuleVO(BaseModel): sortOrder: int = Field(0, description="排序序号") requiresDocumentTypes: bool = Field(True, description="是否要求至少绑定一个文档类型") areas: list[HomeEntryAreaVO] = Field(default_factory=list, description="地区配置") + tenants: list[HomeEntryTenantVO] = Field(default_factory=list, description="租户配置") documentTypes: list[HomeEntryDocumentTypeVO] = Field(default_factory=list, description="关联文档类型列表") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/pageQualityVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/pageQualityVo.py new file mode 100644 index 0000000..dfd50fc --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/pageQualityVo.py @@ -0,0 +1,41 @@ +"""页级图片质量 VO。""" + +from pydantic import BaseModel, Field + + +class PageQualityPageResultVO(BaseModel): + """单页模糊检测结果。""" + + pageNum: int = Field(..., description="页码") + qualityStatus: str = Field(..., description="pass/review/reject") + qualityScore: float | None = Field(None, description="分值") + reasonText: str | None = Field(None, description="原因说明") + + +class PageQualitySummaryVO(BaseModel): + """文档页级模糊检测摘要。""" + + runId: int | None = Field(None, description="最新运行ID") + runStatus: str | None = Field(None, description="运行状态") + summaryStatus: str | None = Field(None, description="摘要状态") + totalPages: int = Field(0, description="总页数") + reviewPageCount: int = Field(0, description="疑似模糊页数") + rejectPageCount: int = Field(0, description="建议重拍页数") + warningText: str | None = Field(None, description="汇总提示文案") + pages: list[int] = Field(default_factory=list, description="问题页码列表") + finishedAt: str | None = Field(None, description="完成时间") + + +class PageQualityDetailVO(BaseModel): + """文档页级模糊检测详情。""" + + summary: PageQualitySummaryVO = Field(..., description="摘要") + results: list[PageQualityPageResultVO] = Field(default_factory=list, description="页结果") + + +class PageQualityRecheckVO(BaseModel): + """手工重检响应。""" + + runId: int = Field(..., description="运行ID") + documentId: int = Field(..., description="文档ID") + status: str = Field(..., description="queued/running") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py index 47ec176..2bc1dc3 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py @@ -5,6 +5,8 @@ class RagChatAppVO(BaseModel): appId: str = Field(..., description="应用ID") appName: str = Field(..., description="应用名称") description: str = Field("", description="应用描述") + tenantCode: str = Field("", description="租户编码") + tenantName: str = Field("", description="租户名称") isDefault: bool = Field(False, description="是否默认应用") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ragDatasetVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ragDatasetVo.py index 02bbb97..8eed26f 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/ragDatasetVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ragDatasetVo.py @@ -6,6 +6,8 @@ class RagDatasetItemVO(BaseModel): name: str = Field(...) description: str = Field("") area: str = Field("") + tenantCode: str = Field("") + tenantName: str = Field("") isPublic: bool = Field(False) isDefault: bool = Field(False) documentCount: int = Field(0) @@ -29,6 +31,8 @@ class RagDatasetDetailVO(BaseModel): name: str = Field(...) description: str = Field("") area: str = Field("") + tenantCode: str = Field("") + tenantName: str = Field("") isPublic: bool = Field(False) isDefault: bool = Field(False) status: int = Field(1) diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py index 030816e..57c210a 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py @@ -46,6 +46,7 @@ class UserVO(BaseModel): phone_number: str | None = Field(None, description="手机号") email: str | None = Field(None, description="邮箱") area: str | None = Field(None, description="地区") + tenant_code: str | None = Field(None, description="租户编码") ou_name: str | None = Field(None, description="组织名称") ou_id: str | None = Field(None, description="组织ID") status: int = Field(0, description="状态") @@ -81,6 +82,7 @@ class OrganizationTreeUserVO(BaseModel): username: str = Field(..., description="用户名") nick_name: str = Field(..., description="姓名") area: str | None = Field(None, description="地区") + tenant_code: str | None = Field(None, description="租户编码") ou_id: str = Field("", description="组织ID") ou_name: str = Field("", description="组织名称") is_leader: bool = Field(False, description="是否负责人") @@ -195,6 +197,16 @@ class UserRolesVO(BaseModel): roles: list[RoleVO] = Field(default_factory=list, description="角色列表") +class UserTenantUpdateVO(BaseModel): + """用户租户更新响应。""" + + user_id: int = Field(..., description="用户ID") + username: str = Field(..., description="用户名") + area: str | None = Field(None, description="兼容地区展示值") + tenant_code: str | None = Field(None, description="租户编码") + tenant_name: str | None = Field(None, description="租户名称") + + class RoutePermissionsVO(BaseModel): """路由权限响应。""" diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py index 52aec26..67bd01f 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py @@ -11,6 +11,10 @@ class RuleConfigPackVO(BaseModel): rootGroupId: int | None = Field(None, description="一级分组ID") bindingId: int | None = Field(None, description="当前命中的规则集绑定ID") ruleSetId: int | None = Field(None, description="命中的规则集ID") + effectiveTenantCode: str | None = Field(None, description="当前命中的生效租户编码") + effectiveScopeType: str | None = Field(None, description="当前命中的生效作用域类型") + isInherited: bool = Field(False, description="当前规则是否来自继承作用域") + sourceRuleSetId: int | None = Field(None, description="来源规则集ID") ruleType: str | None = Field(None, description="规则类型编码") ruleName: str | None = Field(None, description="规则集名称") currentVersionId: int | None = Field(None, description="规则集当前版本ID") @@ -25,6 +29,7 @@ class RuleConfigPackVO(BaseModel): subtype: str = Field("", description="二级业务子类型名称") yamlText: str = Field("", description="当前规则 YAML 正文") sourceStatus: str = Field(..., description="ready/empty/missing") + rules: list["RuleConfigPackRuleSummaryVO"] = Field(default_factory=list, description="规则摘要列表") class RuleConfigPackRuleSummaryVO(BaseModel): @@ -57,6 +62,10 @@ class RuleConfigPackListVO(BaseModel): rootGroupId: int | None = Field(None, description="一级分组ID") bindingId: int | None = Field(None, description="当前命中的规则集绑定ID") ruleSetId: int | None = Field(None, description="命中的规则集ID") + effectiveTenantCode: str | None = Field(None, description="当前命中的生效租户编码") + effectiveScopeType: str | None = Field(None, description="当前命中的生效作用域类型") + isInherited: bool = Field(False, description="当前规则是否来自继承作用域") + sourceRuleSetId: int | None = Field(None, description="来源规则集ID") ruleType: str | None = Field(None, description="规则类型编码") ruleName: str | None = Field(None, description="规则集名称") currentVersionId: int | None = Field(None, description="规则集当前版本ID") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py index 6b97a5a..3badbe4 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py @@ -9,6 +9,10 @@ class RuleSetVO(BaseModel): id: int = Field(..., description="规则集ID") ruleType: str = Field(..., description="业务规则类型编码") ruleName: str = Field(..., description="规则集名称") + effectiveTenantCode: str | None = Field(None, description="当前生效租户编码") + effectiveScopeType: str | None = Field(None, description="当前生效作用域") + isInherited: bool = Field(False, description="当前规则集是否为继承态") + sourceRuleSetId: int | None = Field(None, description="来源规则集ID") domainType: str | None = Field(None, description="域类型") currentVersionId: int | None = Field(None, description="当前激活版本ID") fallbackVersionId: int | None = Field(None, description="最近一个可回退使用的已发布版本ID") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/usageStatsVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/usageStatsVo.py index f8f0e97..7dd7e82 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/usageStatsVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/usageStatsVo.py @@ -34,6 +34,8 @@ class UsageStatsUserItemVO(BaseModel): nickName: str = Field("") departmentName: str | None = Field(None) area: str | None = Field(None) + tenantCode: str | None = Field(None) + tenantName: str | None = Field(None) loginCount: int = Field(0) uploadDocumentCount: int = Field(0) uploadAttachmentCount: int = Field(0) @@ -71,6 +73,8 @@ class UsageStatsDepartmentPageVO(BaseModel): class UsageStatsAreaItemVO(BaseModel): area: str = Field("") + tenantCode: str | None = Field(None) + tenantName: str | None = Field(None) loginUserCount: int = Field(0) loginCount: int = Field(0) uploadDocumentCount: int = Field(0) @@ -96,6 +100,8 @@ class UsageStatsDetailItemVO(BaseModel): nickName: str = Field("") departmentName: str | None = Field(None) area: str | None = Field(None) + tenantCode: str | None = Field(None) + tenantName: str | None = Field(None) documentId: int | None = Field(None) documentName: str | None = Field(None) documentTypeId: int | None = Field(None) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py index c8ecdd6..b937e7c 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py @@ -263,7 +263,7 @@ class GovdocRunner: await session.execute( text( """ - SELECT COALESCE(region, 'default') AS region + SELECT COALESCE(NULLIF(region, ''), '公共') AS region FROM leaudit_documents WHERE id = :document_id LIMIT 1 @@ -272,7 +272,7 @@ class GovdocRunner: {"document_id": DocumentId}, ) ).mappings().first() - return str(row["region"] or "default") if row else "default" + return str(row["region"] or "公共") if row else "公共" def _BuildArtifactRow( self, diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py index 34e855d..6c32f34 100644 --- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py +++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py @@ -27,6 +27,64 @@ log = logging.getLogger(__name__) class StorageAdapter: """Write leaudit pipeline results to leaudit_platform database.""" + def __init__(self) -> None: + self._column_exists_cache: dict[str, bool] = {} + + async def _column_exists(self, session, table_name: str, column_name: str) -> bool: + cache_key = f"{table_name}.{column_name}" + cached = self._column_exists_cache.get(cache_key) + if cached is not None: + return cached + + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + AND column_name = :column_name + ) + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + ).scalar_one() + ) + self._column_exists_cache[cache_key] = exists + return exists + + async def _load_run_tenant_snapshot(self, session, run_id: int | None) -> dict[str, Any]: + if run_id is None: + return {} + + select_fields = ["id"] + if await self._column_exists(session, "leaudit_audit_runs", "tenant_code"): + select_fields.append("tenant_code") + if await self._column_exists(session, "leaudit_audit_runs", "tenant_name_snapshot"): + select_fields.append("tenant_name_snapshot") + + if len(select_fields) == 1: + return {} + + row = ( + await session.execute( + text( + f""" + SELECT {", ".join(select_fields)} + FROM leaudit_audit_runs + WHERE id = :run_id + LIMIT 1 + """ + ), + {"run_id": run_id}, + ) + ).mappings().first() + return dict(row) if row else {} + async def _ensure_run_id(self, document_id: int, run_id: int | None) -> int | None: """Return explicit ``run_id`` when given, otherwise fall back to latest run. @@ -169,6 +227,9 @@ class StorageAdapter: resolved_run_id = await self._ensure_run_id(document_id, run_id) inferred_positions = _build_inferred_field_positions(bundle, ocr_result) async with GetAsyncSession() as session: + run_snapshot = await self._load_run_tenant_snapshot(session, resolved_run_id) + rule_results_has_tenant_code = await self._column_exists(session, "leaudit_rule_results", "tenant_code") + rule_results_has_tenant_name = await self._column_exists(session, "leaudit_rule_results", "tenant_name_snapshot") # Delete existing results for this document+run await session.execute( text("DELETE FROM leaudit_rule_results WHERE document_id = :did AND run_id = :rid"), @@ -193,6 +254,10 @@ class StorageAdapter: ) if rule_version_id is not None: row["rule_version_id"] = rule_version_id + if rule_results_has_tenant_code: + row["tenant_code"] = str(run_snapshot.get("tenant_code") or "") or None + if rule_results_has_tenant_name: + row["tenant_name_snapshot"] = str(run_snapshot.get("tenant_name_snapshot") or "") or None json_columns = {"stages", "extracted_fields", "field_positions", "remediation", "rule_meta"} serialized_row = { key: (json.dumps(value, ensure_ascii=False) if key in json_columns and value is not None else value) @@ -250,65 +315,43 @@ class StorageAdapter: metric = dict(timing or {}) async with GetAsyncSession() as session: + run_snapshot = await self._load_run_tenant_snapshot(session, resolved_run_id) + has_tenant_code = await self._column_exists(session, "leaudit_run_metrics", "tenant_code") await session.execute( text("DELETE FROM leaudit_run_metrics WHERE run_id = :rid"), {"rid": resolved_run_id}, ) + payload = { + "run_id": resolved_run_id, + "ocr_seconds": metric.get("ocr"), + "normalize_seconds": metric.get("normalize"), + "extract_seconds": metric.get("extraction", metric.get("extract")), + "evaluate_seconds": metric.get("evaluation", metric.get("evaluate")), + "rescue_seconds": metric.get("rescue"), + "total_seconds": metric.get("total"), + "page_count": page_count, + "sub_document_count": sub_document_count, + "field_count": field_count, + "rule_count": rule_count, + "llm_call_count": None, + "vlm_call_count": None, + "rescue_rule_count": rescue_rule_count, + "artifact_count": artifact_count, + } + if has_tenant_code: + payload["tenant_code"] = str(run_snapshot.get("tenant_code") or "") or None + columns = list(payload.keys()) await session.execute( text( - """ + f""" INSERT INTO leaudit_run_metrics ( - run_id, - ocr_seconds, - normalize_seconds, - extract_seconds, - evaluate_seconds, - rescue_seconds, - total_seconds, - page_count, - sub_document_count, - field_count, - rule_count, - llm_call_count, - vlm_call_count, - rescue_rule_count, - artifact_count + {", ".join(columns)} ) VALUES ( - :run_id, - :ocr_seconds, - :normalize_seconds, - :extract_seconds, - :evaluate_seconds, - :rescue_seconds, - :total_seconds, - :page_count, - :sub_document_count, - :field_count, - :rule_count, - :llm_call_count, - :vlm_call_count, - :rescue_rule_count, - :artifact_count + {", ".join(f":{column}" for column in columns)} ) """ ), - { - "run_id": resolved_run_id, - "ocr_seconds": metric.get("ocr"), - "normalize_seconds": metric.get("normalize"), - "extract_seconds": metric.get("extraction", metric.get("extract")), - "evaluate_seconds": metric.get("evaluation", metric.get("evaluate")), - "rescue_seconds": metric.get("rescue"), - "total_seconds": metric.get("total"), - "page_count": page_count, - "sub_document_count": sub_document_count, - "field_count": field_count, - "rule_count": rule_count, - "llm_call_count": None, - "vlm_call_count": None, - "rescue_rule_count": rescue_rule_count, - "artifact_count": artifact_count, - }, + payload, ) await session.commit() @@ -329,40 +372,58 @@ class StorageAdapter: resolved_run_id = await self._ensure_run_id(document_id, run_id) async with GetAsyncSession() as session: + run_snapshot = await self._load_run_tenant_snapshot(session, resolved_run_id) + has_tenant_code = await self._column_exists(session, "leaudit_run_errors", "tenant_code") + has_tenant_name = await self._column_exists(session, "leaudit_run_errors", "tenant_name_snapshot") for message in messages: + columns = [ + "run_id", + "document_id", + "stage", + "level", + "error_code", + "message", + "detail_json", + ] + values = [ + ":run_id", + ":document_id", + ":stage", + ":level", + ":error_code", + ":message", + ":detail_json", + ] + payload = { + "run_id": resolved_run_id, + "document_id": document_id, + "stage": stage, + "level": level, + "error_code": error_code, + "message": message, + "detail_json": json.dumps(detail_json, ensure_ascii=False) + if detail_json is not None + else None, + } + if has_tenant_code: + columns.append("tenant_code") + values.append(":tenant_code") + payload["tenant_code"] = str(run_snapshot.get("tenant_code") or "") or None + if has_tenant_name: + columns.append("tenant_name_snapshot") + values.append(":tenant_name_snapshot") + payload["tenant_name_snapshot"] = str(run_snapshot.get("tenant_name_snapshot") or "") or None await session.execute( text( - """ + f""" INSERT INTO leaudit_run_errors ( - run_id, - document_id, - stage, - level, - error_code, - message, - detail_json + {", ".join(columns)} ) VALUES ( - :run_id, - :document_id, - :stage, - :level, - :error_code, - :message, - :detail_json + {", ".join(values)} ) """ ), - { - "run_id": resolved_run_id, - "document_id": document_id, - "stage": stage, - "level": level, - "error_code": error_code, - "message": message, - "detail_json": json.dumps(detail_json, ensure_ascii=False) - if detail_json is not None - else None, - }, + payload, ) await session.commit() diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py index 22f90e4..5ced3fc 100644 --- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py +++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py @@ -42,6 +42,9 @@ from fastapi_modules.fastapi_leaudit.models import ( LeauditDocument, LeauditDocumentFile, ) +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantMaterializer import ( + GetRuleTenantMaterializerSingleton, +) log = logger @@ -318,6 +321,24 @@ def leaudit_scan_stuck_documents_task(self) -> dict[str, Any]: loop.close() +@celery_app.task( + bind=True, + name="leaudit.materialize_rule_tenants", +) +def leaudit_materialize_rule_tenants_task(self) -> dict[str, Any]: + """周期补齐真实租户的规则私有副本。""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + stats = loop.run_until_complete( + GetRuleTenantMaterializerSingleton().MaterializeAllEnabledTenants() + ) + log.info("规则租户物化完成: %s", stats) + return {"status": "success", **stats} + finally: + loop.close() + + # type_id → rules directory mapping (only fixed-mapping types) # 行政许可 (type_id=2) has 9 sub-types, NOT mapped here — # must come from document metadata (rules_file_path) or content classification. diff --git a/fastapi_modules/fastapi_leaudit/models/leauditAuditRun.py b/fastapi_modules/fastapi_leaudit/models/leauditAuditRun.py index cedcfa5..f68824a 100644 --- a/fastapi_modules/fastapi_leaudit/models/leauditAuditRun.py +++ b/fastapi_modules/fastapi_leaudit/models/leauditAuditRun.py @@ -34,6 +34,11 @@ class LeauditAuditRun(BaseModel): ruleSourceOssUrl: Mapped[str | None] = mapped_column("rule_source_oss_url", String(2048), comment="规则 YAML OSS 地址") ruleSourceSha256: Mapped[str | None] = mapped_column("rule_source_sha256", String(64), comment="规则文件 SHA256") ruleLocalCachePath: Mapped[str | None] = mapped_column("rule_local_cache_path", String(1024), comment="本地缓存路径") + tenantCode: Mapped[str | None] = mapped_column("tenant_code", String(64), comment="所属租户编码快照") + tenantNameSnapshot: Mapped[str | None] = mapped_column("tenant_name_snapshot", String(255), comment="所属租户名称快照") + scopeTypeSnapshot: Mapped[str | None] = mapped_column("scope_type_snapshot", String(32), comment="规则作用域快照") + groupIdSnapshot: Mapped[int | None] = mapped_column("group_id_snapshot", BigInteger, comment="运行命中的业务组快照") + ruleBindingIdSnapshot: Mapped[int | None] = mapped_column("rule_binding_id_snapshot", BigInteger, comment="命中的规则绑定快照") # 模型快照 engineVersion: Mapped[str | None] = mapped_column("engine_version", String(64)) diff --git a/fastapi_modules/fastapi_leaudit/models/leauditDocument.py b/fastapi_modules/fastapi_leaudit/models/leauditDocument.py index dc75102..a74b1dd 100644 --- a/fastapi_modules/fastapi_leaudit/models/leauditDocument.py +++ b/fastapi_modules/fastapi_leaudit/models/leauditDocument.py @@ -25,6 +25,7 @@ class LeauditDocument(BaseModel): processingStatus: Mapped[str | None] = mapped_column("processing_status", String(64), default="waiting", comment="waiting/processing/completed/failed") currentRunId: Mapped[int | None] = mapped_column("current_run_id", BigInteger, comment="最新有效 run id") region: Mapped[str] = mapped_column(String(32), default="default", comment="所属地区: mz/yf/jy/cz/default") + tenantCode: Mapped[str | None] = mapped_column("tenant_code", String(64), comment="所属租户编码") versionGroupKey: Mapped[str | None] = mapped_column("version_group_key", String(64), comment="文档版本归档组键") versionNo: Mapped[int] = mapped_column("version_no", Integer, default=1, comment="同一文档系列中的版本号") previousVersionId: Mapped[int | None] = mapped_column("previous_version_id", BigInteger, comment="上一版本文档ID") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditRagChatApp.py b/fastapi_modules/fastapi_leaudit/models/leauditRagChatApp.py index 6bd0417..6195489 100644 --- a/fastapi_modules/fastapi_leaudit/models/leauditRagChatApp.py +++ b/fastapi_modules/fastapi_leaudit/models/leauditRagChatApp.py @@ -13,6 +13,7 @@ class LeauditRagChatApp(BaseModel): name: Mapped[str] = mapped_column(String(255)) description: Mapped[str] = mapped_column(Text, default="") area: Mapped[str] = mapped_column(String(50), default="") + tenantCode: Mapped[str | None] = mapped_column("tenant_code", String(64)) datasetId: Mapped[int | None] = mapped_column("dataset_id", BigInteger) systemPrompt: Mapped[str] = mapped_column("system_prompt", Text, default="") llmModel: Mapped[str] = mapped_column("llm_model", String(100), default="") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditRagDataset.py b/fastapi_modules/fastapi_leaudit/models/leauditRagDataset.py index ac1acfd..8dd4f5e 100644 --- a/fastapi_modules/fastapi_leaudit/models/leauditRagDataset.py +++ b/fastapi_modules/fastapi_leaudit/models/leauditRagDataset.py @@ -14,6 +14,7 @@ class LeauditRagDataset(BaseModel): name: Mapped[str] = mapped_column(String(255), comment="知识库名称") description: Mapped[str] = mapped_column(Text, default="", comment="知识库描述") area: Mapped[str] = mapped_column(String(50), default="", comment="地区") + tenantCode: Mapped[str | None] = mapped_column("tenant_code", String(64), comment="所属租户编码") isPublic: Mapped[bool] = mapped_column("is_public", Boolean, default=False) isDefault: Mapped[bool] = mapped_column("is_default", Boolean, default=False) collectionName: Mapped[str] = mapped_column("collection_name", String(100), unique=True) diff --git a/fastapi_modules/fastapi_leaudit/page_quality/runner.py b/fastapi_modules/fastapi_leaudit/page_quality/runner.py new file mode 100644 index 0000000..a2c48e6 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/page_quality/runner.py @@ -0,0 +1,16 @@ +"""页级图片质量执行器。""" + +from __future__ import annotations + +from typing import Any + +from fastapi_modules.fastapi_leaudit.services.impl.pageQualityServiceImpl import PageQualityServiceImpl + + +class PageQualityRunner: + """页级图片质量执行器。""" + + async def Execute(self, RunId: int) -> dict[str, Any]: + """执行一次页级模糊检测。""" + service = PageQualityServiceImpl() + return await service.ExecuteRun(RunId=RunId) diff --git a/fastapi_modules/fastapi_leaudit/page_quality/tasks.py b/fastapi_modules/fastapi_leaudit/page_quality/tasks.py new file mode 100644 index 0000000..2fc2fc2 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/page_quality/tasks.py @@ -0,0 +1,48 @@ +"""页级图片质量任务入口。""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi_common.fastapi_common_logger import logger + +from fastapi_admin.celery_app import celery_app +from fastapi_admin.config import ( + LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL, + LEAUDIT_PAGE_QUALITY_QUEUE_URGENT, +) +from fastapi_modules.fastapi_leaudit.page_quality.runner import PageQualityRunner + +log = logger + + +def resolve_page_quality_queue(speed: str = "normal") -> str: + """根据优先级返回页级模糊检测队列。""" + if (speed or "").strip().lower() in {"urgent", "high", "fast", "紧急"}: + return LEAUDIT_PAGE_QUALITY_QUEUE_URGENT + return LEAUDIT_PAGE_QUALITY_QUEUE_NORMAL + + +def dispatch_page_quality_task(run_id: int, *, speed: str = "normal") -> str: + """投递页级模糊检测任务。""" + queue = resolve_page_quality_queue(speed) + task = page_quality_process_document_task.apply_async(kwargs={"run_id": run_id}, queue=queue) + log.info("page_quality run_id=%s dispatched: queue=%s, task_id=%s", run_id, queue, task.id) + return str(task.id or "") + + +@celery_app.task( + bind=True, + name="leaudit.page_quality.process_document", + acks_late=True, +) +def page_quality_process_document_task(self, run_id: int) -> dict[str, Any]: + """页级模糊检测 Celery 入口。""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + runner = PageQualityRunner() + return loop.run_until_complete(runner.Execute(RunId=run_id)) + finally: + loop.close() diff --git a/fastapi_modules/fastapi_leaudit/services/__init__.py b/fastapi_modules/fastapi_leaudit/services/__init__.py index 2aaa39a..efedcef 100644 --- a/fastapi_modules/fastapi_leaudit/services/__init__.py +++ b/fastapi_modules/fastapi_leaudit/services/__init__.py @@ -9,6 +9,7 @@ from fastapi_modules.fastapi_leaudit.services.authService import IAuthService from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService from fastapi_modules.fastapi_leaudit.services.ossService import IOssService from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService +from fastapi_modules.fastapi_leaudit.services.pageQualityService import IPageQualityService from fastapi_modules.fastapi_leaudit.services.promptTemplateService import IPromptTemplateService from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService from fastapi_modules.fastapi_leaudit.services.rbacService import IRbacService @@ -16,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConf from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService +from fastapi_modules.fastapi_leaudit.services.tenantService import ITenantService from fastapi_modules.fastapi_leaudit.services.govdocService import IGovdocService from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService @@ -29,6 +31,7 @@ __all__ = [ "IHomeService", "IOssService", "IPermissionService", + "IPageQualityService", "IPromptTemplateService", "IRbacAdminService", "IRbacService", @@ -36,6 +39,7 @@ __all__ = [ "IRuleService", "IRagDatasetService", "IRagChatService", + "ITenantService", "IUsageStatsService", "IGovdocService", ] diff --git a/fastapi_modules/fastapi_leaudit/services/documentService.py b/fastapi_modules/fastapi_leaudit/services/documentService.py index 962a09b..6605508 100644 --- a/fastapi_modules/fastapi_leaudit/services/documentService.py +++ b/fastapi_modules/fastapi_leaudit/services/documentService.py @@ -35,9 +35,11 @@ class IDocumentService(ABC): TypeId: int | None = None, TypeCode: str | None = None, GroupId: int | None = None, - Region: str = "default", + Region: str | None = None, FileRole: str = "primary", CreatedBy: int | None = None, + TenantCode: str | None = None, + TenantName: str | None = None, Attachments: list[tuple[str, bytes, str | None]] | None = None, AutoRun: bool = False, Speed: str = "normal", @@ -58,6 +60,7 @@ class IDocumentService(ABC): TypeIds: list[int] | None = None, EntryModuleId: int | None = None, Region: str | None = None, + TenantCode: str | None = None, ProcessingStatus: str | None = None, ResultStatus: str | None = None, AuditStatus: int | None = None, diff --git a/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py b/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py index fb3d8e9..a875438 100644 --- a/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py +++ b/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py @@ -10,7 +10,14 @@ class IEntryModuleAdminService(ABC): """入口模块管理服务接口。""" @abstractmethod - async def ListModules(self, Name: str | None, Area: str | None, Page: int, PageSize: int) -> EntryModuleListVO: + async def ListModules( + self, + Name: str | None, + Area: str | None, + TenantCode: str | None, + Page: int, + PageSize: int, + ) -> EntryModuleListVO: """分页查询入口模块。""" ... diff --git a/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py b/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py index c6839a7..db7f978 100755 --- a/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py +++ b/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py @@ -35,11 +35,12 @@ class IEvaluationPointGroupService(ABC): Pid: int | None, Page: int, PageSize: int, + CurrentUserId: int, ) -> EvaluationPointGroupListVO: ... @abstractmethod - async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool) -> list[EvaluationPointGroupVO]: + async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool, CurrentUserId: int) -> list[EvaluationPointGroupVO]: ... @abstractmethod @@ -48,57 +49,58 @@ class IEvaluationPointGroupService(ABC): DocumentTypeIds: list[int], IncludeDisabled: bool, WithRuleCount: bool, + CurrentUserId: int, ) -> list[EvaluationPointGroupVO]: ... @abstractmethod - async def GetGroup(self, GroupId: int, WithRuleCount: bool) -> EvaluationPointGroupVO: + async def GetGroup(self, GroupId: int, WithRuleCount: bool, CurrentUserId: int) -> EvaluationPointGroupVO: ... @abstractmethod - async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int) -> EvaluationPointGroupListVO: + async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int, CurrentUserId: int) -> EvaluationPointGroupListVO: ... @abstractmethod - async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO) -> EvaluationPointGroupVO: + async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO, CurrentUserId: int) -> EvaluationPointGroupVO: ... @abstractmethod - async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO) -> EvaluationPointGroupVO: + async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO, CurrentUserId: int) -> EvaluationPointGroupVO: ... @abstractmethod - async def DeleteGroup(self, GroupId: int) -> EvaluationPointGroupDeleteVO: + async def DeleteGroup(self, GroupId: int, CurrentUserId: int) -> EvaluationPointGroupDeleteVO: ... @abstractmethod - async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO) -> EvaluationPointGroupRebindVO: + async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO, CurrentUserId: int) -> EvaluationPointGroupRebindVO: ... @abstractmethod - async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO) -> EvaluationPointGroupBatchStatusVO: + async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO, CurrentUserId: int) -> EvaluationPointGroupBatchStatusVO: ... @abstractmethod - async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO) -> EvaluationPointGroupBatchDeleteVO: + async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO, CurrentUserId: int) -> EvaluationPointGroupBatchDeleteVO: ... @abstractmethod - async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO) -> RuleGroupBindingVO: + async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO, CurrentUserId: int) -> RuleGroupBindingVO: ... @abstractmethod - async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO) -> RuleGroupBindingVO: + async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO, CurrentUserId: int) -> RuleGroupBindingVO: ... @abstractmethod - async def DeleteBinding(self, BindingId: int) -> None: + async def DeleteBinding(self, BindingId: int, CurrentUserId: int) -> None: ... @abstractmethod - async def GetRuleTemplate(self, GroupId: int) -> EvaluationPointGroupRuleTemplateVO: + async def GetRuleTemplate(self, GroupId: int, CurrentUserId: int) -> EvaluationPointGroupRuleTemplateVO: ... @abstractmethod - async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO) -> EvaluationPointGroupRuleDraftVO: + async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO, CurrentUserId: int) -> EvaluationPointGroupRuleDraftVO: ... diff --git a/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py b/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py index a9e56ef..265ff73 100644 --- a/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py +++ b/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py @@ -18,6 +18,11 @@ class IEvaluationPointService(ABC): @abstractmethod async def ListPoints( self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + CurrentTenantCode: str | None, + CurrentTenantName: str | None, Name: str | None, Code: str | None, Risk: str | None, @@ -25,26 +30,61 @@ class IEvaluationPointService(ABC): GroupPid: int | None, GroupId: int | None, DocumentAttributeType: str | None, - Area: str | None, + FilterArea: str | None, + FilterTenantCode: str | None, + FilterTenantName: str | None, Page: int, PageSize: int, ) -> EvaluationPointListVO: ... @abstractmethod - async def GetPoint(self, PointId: int) -> EvaluationPointVO: + async def GetPoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + ) -> EvaluationPointVO: ... @abstractmethod - async def CreatePoint(self, Body: EvaluationPointCreateDTO) -> EvaluationPointVO: + async def CreatePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + Body: EvaluationPointCreateDTO, + ) -> EvaluationPointVO: ... @abstractmethod - async def UpdatePoint(self, PointId: int, Body: EvaluationPointUpdateDTO) -> EvaluationPointVO: + async def UpdatePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + Body: EvaluationPointUpdateDTO, + ) -> EvaluationPointVO: ... @abstractmethod - async def DeletePoint(self, PointId: int) -> EvaluationPointDeleteVO: + async def DeletePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + ) -> EvaluationPointDeleteVO: ... @abstractmethod diff --git a/fastapi_modules/fastapi_leaudit/services/govdocService.py b/fastapi_modules/fastapi_leaudit/services/govdocService.py index 6084de1..dbccd66 100644 --- a/fastapi_modules/fastapi_leaudit/services/govdocService.py +++ b/fastapi_modules/fastapi_leaudit/services/govdocService.py @@ -16,7 +16,8 @@ class IGovdocService(ABC): self, file: UploadFile, typeId: int | None = None, - region: str = "default", + region: str | None = None, + tenantCode: str | None = None, autoRun: bool = False, speed: str = "normal", ruleVersionId: int | None = None, @@ -33,6 +34,7 @@ class IGovdocService(ABC): keyword: str | None = None, fileExt: str | None = None, region: str | None = None, + tenantCode: str | None = None, status: str | None = None, resultStatus: str | None = None, createdBy: int | None = None, @@ -73,54 +75,54 @@ class IGovdocService(ABC): ... @abstractmethod - async def GetRunStatus(self, runId: int) -> dict[str, Any]: + async def GetRunStatus(self, runId: int, userId: int | None = None) -> dict[str, Any]: """查询 run 状态、阶段、耗时、错误摘要。""" ... # ── 结果与报告 ──────────────────────────────────────── @abstractmethod - async def GetRunResult(self, runId: int) -> dict[str, Any]: + async def GetRunResult(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取审查结果摘要:summary + checked rules + findings 统计 + entities 摘要。""" ... @abstractmethod - async def GetRunFindings(self, runId: int) -> dict[str, Any]: + async def GetRunFindings(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取段落级 findings 明细列表。""" ... @abstractmethod - async def GetRunEntities(self, runId: int) -> dict[str, Any]: + async def GetRunEntities(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取识别出的标题、文号、署名等实体。""" ... @abstractmethod - async def GetRunParagraphs(self, runId: int) -> dict[str, Any]: + async def GetRunParagraphs(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取前端文档联动视图所需的段落 HTML。""" ... @abstractmethod - async def GetRunStructure(self, runId: int) -> dict[str, Any]: + async def GetRunStructure(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取文档结构统计(结构面板数据)。""" ... @abstractmethod - async def GetRunOutline(self, runId: int) -> dict[str, Any]: + async def GetRunOutline(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取文档大纲树(大纲面板数据)。""" ... @abstractmethod - async def GetReportHtml(self, runId: int) -> dict[str, Any]: + async def GetReportHtml(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取 HTML 报告内容或下载地址。""" ... @abstractmethod - async def GetReportDocx(self, runId: int) -> dict[str, Any]: + async def GetReportDocx(self, runId: int, userId: int | None = None) -> dict[str, Any]: """获取批注 DOCX 下载地址。""" ... @abstractmethod - async def DownloadOriginal(self, documentId: int) -> dict[str, Any]: + async def DownloadOriginal(self, documentId: int, userId: int | None = None) -> dict[str, Any]: """获取原始上传文档下载地址。""" ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py index b3573c7..697a001 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py @@ -5,6 +5,7 @@ """ from datetime import datetime +from typing import Any from fastapi_common.fastapi_common_logger import logger from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession @@ -41,17 +42,105 @@ def _normalize_speed(speed: str | None) -> str: return "normal" +def _candidate_binding_tenant_codes(tenant_code: str | None) -> list[str]: + """Return binding resolution order for one document tenant. + + PUBLIC is the platform template source; PROVINCIAL remains only as legacy fallback. + """ + normalized = str(tenant_code or "").strip().upper() + candidates: list[str] = [] + if normalized and normalized not in {"PROVINCIAL", "PUBLIC"}: + candidates.append(normalized) + candidates.append("PUBLIC") + if normalized != "PUBLIC": + candidates.append("PROVINCIAL") + return list(dict.fromkeys(candidates)) + + +def _pick_effective_binding(bindings: list[dict], tenant_code: str | None) -> dict | None: + """Pick the effective binding by tenant inheritance order. + + Legacy rows without ``tenant_code`` are treated as the loosest fallback. + """ + if not bindings: + return None + + binding_map: dict[str, dict] = {} + empty_binding: dict | None = None + for binding in bindings: + normalized = str(binding.get("tenant_code") or "").strip().upper() + if not normalized: + if empty_binding is None: + empty_binding = binding + continue + binding_map.setdefault(normalized, binding) + + for candidate in _candidate_binding_tenant_codes(tenant_code): + matched = binding_map.get(candidate) + if matched is not None: + return matched + return empty_binding + + class AuditServiceImpl(IAuditService): """评查服务实现。""" - async def _resolve_rule_binding_from_group(self, session, group_id: int | None) -> dict | None: + def __init__(self) -> None: + self._column_exists_cache: dict[str, bool] = {} + + async def _column_exists(self, session, table_name: str, column_name: str) -> bool: + cache_key = f"{table_name}.{column_name}" + cached = self._column_exists_cache.get(cache_key) + if cached is not None: + return cached + + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + AND column_name = :column_name + ) + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + ).scalar_one() + ) + self._column_exists_cache[cache_key] = exists + return exists + + async def _resolve_rule_binding_from_group( + self, + session, + group_id: int | None, + tenant_code: str | None = None, + ) -> dict | None: """按二级分组解析正式规则绑定。""" if not group_id: return None + binding_tenant_expr = ( + "COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL')" + if await self._column_exists(session, "leaudit_rule_group_bindings", "tenant_code") + else "'PROVINCIAL'" + ) + binding_scope_expr = ( + "COALESCE(NULLIF(BTRIM(rgb.scope_type), ''), 'PROVINCIAL')" + if await self._column_exists(session, "leaudit_rule_group_bindings", "scope_type") + else "'PROVINCIAL'" + ) result = await session.execute( text( - """ + f""" SELECT + rgb.id AS binding_id, + {binding_tenant_expr} AS tenant_code, + {binding_scope_expr} AS scope_type, rs.id AS rule_set_id, COALESCE(rs.current_version_id, fallback_rv.id) AS rule_version_id, COALESCE(current_rv.oss_url, fallback_rv.oss_url) AS rule_source_oss_url, @@ -76,14 +165,18 @@ class AuditServiceImpl(IAuditService): AND rgb.is_active = TRUE AND rgb.deleted_at IS NULL ORDER BY rgb.priority DESC, rgb.id ASC - LIMIT 1 """ ), {"group_id": int(group_id)}, ) - return result.mappings().first() + return _pick_effective_binding(list(result.mappings().all()), tenant_code) - async def _resolve_unique_group_binding_by_doc_type(self, session, doc_type_id: int | None) -> dict | None: + async def _resolve_unique_group_binding_by_doc_type( + self, + session, + doc_type_id: int | None, + tenant_code: str | None = None, + ) -> dict | None: """当文档尚未落 group_id 时,按文档类型唯一子组兜底解析正式绑定。""" if not doc_type_id: return None @@ -103,7 +196,44 @@ class AuditServiceImpl(IAuditService): ) ).mappings().first() resolved_group_id = int(group_row["group_id"]) if group_row and group_row.get("group_id") is not None else None - return await self._resolve_rule_binding_from_group(session, resolved_group_id) + return await self._resolve_rule_binding_from_group(session, resolved_group_id, tenant_code) + + async def _persist_run_tenant_snapshot( + self, + session, + run_id: int, + *, + tenant_code: str | None, + scope_type: str | None, + group_id: int | None, + rule_binding_id: int | None, + ) -> None: + updates: list[str] = [] + params: dict[str, Any] = {"run_id": run_id} + optional_values = { + "tenant_code": tenant_code, + "scope_type_snapshot": scope_type, + "group_id_snapshot": group_id, + "rule_binding_id_snapshot": rule_binding_id, + } + for column_name, value in optional_values.items(): + if await self._column_exists(session, "leaudit_audit_runs", column_name): + updates.append(f"{column_name} = :{column_name}") + params[column_name] = value + + if not updates: + return + + await session.execute( + text( + f""" + UPDATE leaudit_audit_runs + SET {", ".join(updates)} + WHERE id = :run_id + """ + ), + params, + ) async def Run( self, @@ -187,9 +317,19 @@ class AuditServiceImpl(IAuditService): ) latestRunNo = runNoResult.scalar_one_or_none() or 0 - binding = await self._resolve_rule_binding_from_group(session, getattr(document, "groupId", None)) + document_tenant_code = str(getattr(document, "tenantCode", None) or "").strip() or None + + binding = await self._resolve_rule_binding_from_group( + session, + getattr(document, "groupId", None), + document_tenant_code, + ) if binding is None: - binding = await self._resolve_unique_group_binding_by_doc_type(session, getattr(document, "typeId", None)) + binding = await self._resolve_unique_group_binding_by_doc_type( + session, + getattr(document, "typeId", None), + document_tenant_code, + ) if binding and getattr(document, "groupId", None) is None: logger.info("文档未显式记录 group_id,已按文档类型唯一子组解析正式规则绑定") if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]: @@ -217,6 +357,14 @@ class AuditServiceImpl(IAuditService): ) session.add(run) await session.flush() + await self._persist_run_tenant_snapshot( + session, + run.Id, + tenant_code=document_tenant_code, + scope_type=str(binding.get("scope_type") or "").strip() or None, + group_id=getattr(document, "groupId", None), + rule_binding_id=int(binding["binding_id"]) if binding.get("binding_id") is not None else None, + ) document.currentRunId = run.Id document.processingStatus = "queued" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py index 7b1b168..1b55370 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py @@ -13,16 +13,90 @@ from fastapi_common.fastapi_common_web.exception.LeauditException import Leaudit from fastapi_modules.fastapi_leaudit.domian.vo.auth.loginTokenVo import LoginTokenVO from fastapi_modules.fastapi_leaudit.services.authService import IAuthService +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class AuthServiceImpl(IAuthService): """认证服务实现。""" + def __init__(self) -> None: + self.TenantResolver = TenantResolver() + self._sso_user_columns_cache: set[str] | None = None + @staticmethod def _naive_utcnow() -> datetime: """返回适配 timestamp without time zone 的 UTC 时间。""" return datetime.utcnow() + async def _get_sso_user_columns(self, session) -> set[str]: + """读取 `sso_users` 实际列,兼容部分环境尚未完成租户字段迁移。""" + if self._sso_user_columns_cache is not None: + return self._sso_user_columns_cache + + from sqlalchemy import text + + rows = await session.execute( + text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'sso_users' + """ + ) + ) + self._sso_user_columns_cache = {str(row[0]) for row in rows.fetchall()} + return self._sso_user_columns_cache + + @staticmethod + def _optional_sso_user_column(columns: set[str], column: str, pg_type: str = "varchar") -> str: + """列存在则直接查询,不存在时回退为同名 NULL 别名。""" + if column in columns: + return column + return f"NULL::{pg_type} AS {column}" + + async def _build_sso_user_select_fields( + self, + session, + *, + include_password: bool = False, + include_status: bool = False, + include_deleted_at: bool = False, + include_try_fields: bool = False, + ) -> str: + """构造兼容旧库结构的 `sso_users` SELECT 字段列表。""" + columns = await self._get_sso_user_columns(session) + fields = [ + "id", + "sub", + "username", + "nick_name", + "phone_number", + "email", + "ou_id", + "ou_name", + "is_leader", + ] + if include_password: + fields.append(self._optional_sso_user_column(columns, "password")) + if include_status: + fields.append(self._optional_sso_user_column(columns, "status", "integer")) + if include_deleted_at: + fields.append(self._optional_sso_user_column(columns, "deleted_at", "timestamp")) + if include_try_fields: + fields.append(self._optional_sso_user_column(columns, "try_count", "integer")) + fields.append(self._optional_sso_user_column(columns, "try_login_time", "timestamp")) + fields.extend( + [ + self._optional_sso_user_column(columns, "area"), + self._optional_sso_user_column(columns, "tenant_code"), + self._optional_sso_user_column(columns, "tenant_name"), + self._optional_sso_user_column(columns, "dep_name"), + self._optional_sso_user_column(columns, "dep_short_name"), + ] + ) + return ", ".join(fields) + async def PasswordLogin(self, Sub: str, Password: str) -> LoginTokenVO: """账密登录。 @@ -33,11 +107,16 @@ class AuthServiceImpl(IAuthService): async with GetAsyncSession() as session: from sqlalchemy import text + select_fields = await self._build_sso_user_select_fields( + session, + include_password=True, + include_status=True, + include_deleted_at=True, + include_try_fields=True, + ) result = await session.execute( text( - "SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, password, status, deleted_at, " - "try_count, try_login_time, area, tenant_name, dep_name, dep_short_name " + f"SELECT {select_fields} " "FROM sso_users " "WHERE deleted_at IS NULL AND (sub = :identifier OR username = :identifier) " "ORDER BY CASE WHEN sub = :identifier THEN 0 ELSE 1 END, id ASC " @@ -91,11 +170,14 @@ class AuthServiceImpl(IAuthService): async with GetAsyncSession() as session: from sqlalchemy import text + select_fields = await self._build_sso_user_select_fields( + session, + include_status=True, + include_deleted_at=True, + ) result = await session.execute( text( - "SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, status, deleted_at, area, " - "tenant_name, dep_name, dep_short_name " + f"SELECT {select_fields} " "FROM sso_users WHERE sub = :sub" ), {"sub": Sub}, @@ -149,10 +231,10 @@ class AuthServiceImpl(IAuthService): user_id = created.scalar_one() await self._ensure_default_role(session, user_id) + select_fields = await self._build_sso_user_select_fields(session) result = await session.execute( text( - "SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, area, tenant_name, dep_name, dep_short_name " + f"SELECT {select_fields} " "FROM sso_users WHERE sub = :sub" ), {"sub": Sub}, @@ -171,11 +253,14 @@ class AuthServiceImpl(IAuthService): async with GetAsyncSession() as session: from sqlalchemy import text + select_fields = await self._build_sso_user_select_fields( + session, + include_status=True, + include_deleted_at=True, + ) result = await session.execute( text( - "SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, status, deleted_at, area, " - "tenant_name, dep_name, dep_short_name " + f"SELECT {select_fields} " "FROM sso_users WHERE id = :uid" ), {"uid": UserId}, @@ -190,12 +275,30 @@ class AuthServiceImpl(IAuthService): await self._ensure_default_role(session, user["id"]) identity = await self._loadUserIdentity(session, user["id"]) - return self._buildUserInfo(user, identity) + user_info = self._buildUserInfo(user, identity) + tenant_resolution = await self.TenantResolver.ResolveUserContext( + Area=user.get("area"), + TenantCode=user.get("tenant_code"), + TenantName=user.get("tenant_name"), + Source="current_user", + ) + user_info["tenant_code"] = tenant_resolution.tenant_code + user_info["tenant_name"] = tenant_resolution.tenant_name or user.get("tenant_name") + user_info["tenant_type"] = tenant_resolution.tenant_type + return user_info async def _buildLoginResponse(self, user: dict[str, Any], session) -> LoginTokenVO: """组装登录响应:查询角色/权限 → 签发 JWT。""" identity = await self._loadUserIdentity(session, user["id"]) user_info = self._buildUserInfo(user, identity) + tenant_resolution = await self.TenantResolver.ResolveUserContext( + Area=user.get("area"), + TenantCode=user.get("tenant_code"), + TenantName=user.get("tenant_name"), + ) + user_info["tenant_code"] = tenant_resolution.tenant_code + user_info["tenant_type"] = tenant_resolution.tenant_type + user_info["tenant_name"] = tenant_resolution.tenant_name or user.get("tenant_name") tokens = JwtService.generate( userId=user["id"], @@ -206,6 +309,9 @@ class AuthServiceImpl(IAuthService): roles=identity["roles"], permissions=identity["permissions"], area=user.get("area"), + tenantCode=tenant_resolution.tenant_code, + tenantName=tenant_resolution.tenant_name or user.get("tenant_name"), + tenantType=tenant_resolution.tenant_type, userRole=identity["primary_role"], ) @@ -305,10 +411,12 @@ class AuthServiceImpl(IAuthService): "ou_name": user.get("ou_name"), "is_leader": user.get("is_leader"), "area": user.get("area"), + "tenant_code": user.get("tenant_code"), "user_role": identity["primary_role"], "roles": identity["roles"], "permissions": identity["permissions"], "tenant_name": user.get("tenant_name"), + "tenant_type": None, "dep_name": user.get("dep_name"), "dep_short_name": user.get("dep_short_name"), } diff --git a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py index f26dca7..5384acd 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py @@ -26,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateSearchResultVO, ) from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver from fastapi_modules.fastapi_leaudit.services.ossService import IOssService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl @@ -40,8 +42,9 @@ _ALLOWED_SORT_FIELDS = { class ContractTemplateServiceImpl(IContractTemplateService): """合同模板服务实现。""" - def __init__(self, OssService: IOssService | None = None) -> None: + def __init__(self, OssService: IOssService | None = None, TenantResolverService: TenantResolver | None = None) -> None: self.OssService = OssService or OssServiceImpl() + self.TenantResolver = TenantResolverService or TenantResolver() async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]: count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count" @@ -83,6 +86,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_id=Query.category_id, category_name=Query.category_name, region=Query.region, + tenant_code=Query.tenant_code, file_format=Query.file_format, is_featured=Query.is_featured, currentUser=currentUser, @@ -111,6 +115,14 @@ class ContractTemplateServiceImpl(IContractTemplateService): c.icon AS category_icon, c.description AS category_description, t.region, + COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code, + CASE + WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name + WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region + WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共' + WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级' + ELSE NULL + END AS tenant_name, t.description, t.file_path, t.pdf_file_path, @@ -148,13 +160,14 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_id=Query.category_id, category_name=Query.category_name, region=Query.region, + tenant_code=Query.tenant_code, page=Query.page, page_size=Query.page_size, sort_by=Query.sort_by, sort_order=Query.sort_order, ) page_result = await self.ListTemplates(list_query, CurrentUserId) - category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId) + category_stats = await self._load_search_category_stats(Query.q, Query.region, Query.tenant_code, CurrentUserId) return ContractTemplateSearchResultVO( total=page_result.total, @@ -182,6 +195,14 @@ class ContractTemplateServiceImpl(IContractTemplateService): c.icon AS category_icon, c.description AS category_description, t.region, + COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code, + CASE + WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name + WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region + WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共' + WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级' + ELSE NULL + END AS tenant_name, t.description, t.file_path, t.pdf_file_path, @@ -252,7 +273,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): async with GetAsyncSession() as session: await self._ensureContractTemplateSchema(session) currentUser = await self._getCurrentUserContext(CurrentUserId, session) - resolvedRegion = self._resolve_upload_region(currentUser, Body.region) + resolvedTenantCode, resolvedTenantName, resolvedRegion = self._resolve_upload_scope(currentUser, Body.region, Body.tenant_code) categoryRow = ( await session.execute( text( @@ -276,17 +297,23 @@ class ContractTemplateServiceImpl(IContractTemplateService): """ SELECT id FROM contract_templates - WHERE region = :region + WHERE ( + tenant_code = :tenant_code + OR ( + (tenant_code IS NULL OR BTRIM(tenant_code) = '') + AND region = :region + ) + ) AND template_code = :template_code AND deleted_at IS NULL LIMIT 1 """ ), - {"region": resolvedRegion, "template_code": normalizedCode}, + {"tenant_code": resolvedTenantCode, "region": resolvedRegion, "template_code": normalizedCode}, ) ).mappings().first() if duplicateRow: - raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前地区已存在模板编码 {normalizedCode}") + raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前租户已存在模板编码 {normalizedCode}") categoryName = str(categoryRow["name"] or "未分类") objectKey = OssPathUtils.BuildContractTemplateKey( @@ -325,6 +352,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): template_code, title, category_id, + tenant_code, + tenant_name, region, description, file_path, @@ -343,6 +372,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): :template_code, :title, :category_id, + :tenant_code, + :tenant_name, :region, :description, :file_path, @@ -365,6 +396,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): "template_code": normalizedCode, "title": normalizedTitle, "category_id": Body.category_id, + "tenant_code": resolvedTenantCode, + "tenant_name": resolvedTenantName, "region": resolvedRegion, "description": (Body.description or "").strip() or None, "file_path": filePath, @@ -433,6 +466,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): self, keyword: str, requestedRegion: str | None, + requestedTenantCode: str | None, CurrentUserId: int, ) -> list[ContractTemplateSearchCategoryVO]: clean_keyword = (keyword or "").strip() @@ -443,7 +477,12 @@ class ContractTemplateServiceImpl(IContractTemplateService): await self._ensureContractTemplateSchema(session) currentUser = await self._getCurrentUserContext(CurrentUserId, session) params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"} - scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=requestedRegion) + scope_filters = self._build_template_scope_filters( + currentUser, + params, + requestedRegion=requestedRegion, + requestedTenantCode=requestedTenantCode, + ) filters = [ "c.deleted_at IS NULL", "t.deleted_at IS NULL", @@ -487,6 +526,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_id: int | None, category_name: str | None, region: str | None, + tenant_code: str | None, file_format: str | None, is_featured: bool | None, currentUser: dict[str, Any], @@ -495,7 +535,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): params: dict[str, Any] = {} needs_category_name_filter = False - filters.extend(self._build_template_scope_filters(currentUser, params, region)) + filters.extend(self._build_template_scope_filters(currentUser, params, region, tenant_code)) if category_id is not None: filters.append("t.category_id = :category_id") @@ -543,6 +583,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): def _bind_expanding(self, *sql_objects_and_params: Any): sql_objects = list(sql_objects_and_params[:-1]) params = sql_objects_and_params[-1] + if "visible_tenant_codes" in params: + sql_objects = [sql.bindparams(bindparam("visible_tenant_codes", expanding=True)) for sql in sql_objects] if "visible_regions" in params: sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects] return tuple(sql_objects) @@ -559,6 +601,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): ) def _to_list_item_vo(self, row: Any) -> ContractTemplateListItemVO: + tenant_name = row.get("tenant_name") or row.get("region") or "省级" return ContractTemplateListItemVO( id=int(row["id"]), template_code=str(row.get("template_code") or ""), @@ -567,7 +610,9 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_name=row.get("category_name"), category_icon=row.get("category_icon"), description=row.get("description"), - region=str(row.get("region") or "省级"), + region=str(row.get("region") or tenant_name), + tenant_code=row.get("tenant_code"), + tenant_name=tenant_name, file_path=row.get("file_path"), pdf_file_path=row.get("pdf_file_path"), file_format=str(row.get("file_format") or ""), @@ -600,53 +645,145 @@ class ContractTemplateServiceImpl(IContractTemplateService): currentUser: dict[str, Any], params: dict[str, Any], requestedRegion: str | None, + requestedTenantCode: str | None = None, writable: bool = False, ) -> list[str]: - requested = (requestedRegion or "").strip() - area = str(currentUser["area"] or "").strip() + requested_tenant_code, requested_region = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser) + current_tenant_code = str(currentUser.get("tenant_code") or "").strip() + current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip() if currentUser["is_global"]: - if requested: - params["requested_region"] = requested + if requested_tenant_code: + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + if requested_region: + if requested_region in {"省级", "公共"}: + return self._tenant_filter_sql( + params, + tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", + region=requested_region, + prefix="requested", + ) + params["requested_region"] = requested_region return ["t.region = :requested_region"] return ["1=1"] if writable: - if not area: + if not current_tenant_code and not current_region: return ["1=0"] - if requested and requested != area: + if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code: return ["1=0"] - params["scope_region"] = area - return ["t.region = :scope_region"] + if requested_tenant_code and not current_tenant_code: + return ["1=0"] + if requested_region and requested_region != current_region: + return ["1=0"] + return self._tenant_filter_sql( + params, + tenant_code=current_tenant_code or requested_tenant_code, + region=current_region or requested_region, + prefix="scope", + ) if currentUser["can_manage"]: - if not area: + if not current_tenant_code and not current_region: return ["1=0"] - if requested: - if requested == "省级": - params["requested_region"] = requested - return ["t.region = :requested_region"] - if requested != area: + if requested_tenant_code: + if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}: + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + if current_tenant_code and requested_tenant_code != current_tenant_code: return ["1=0"] - params["requested_region"] = requested - return ["t.region = :requested_region"] - params["visible_regions"] = ["省级", area] - return ["t.region IN :visible_regions"] + if not current_tenant_code and requested_region != current_region: + return ["1=0"] + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + if requested_region: + if requested_region in {"省级", "公共"}: + return self._tenant_filter_sql( + params, + tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", + region=requested_region, + prefix="requested", + ) + if requested_region != current_region: + return ["1=0"] + return self._tenant_filter_sql( + params, + tenant_code=current_tenant_code or None, + region=current_region or requested_region, + prefix="requested", + ) + return self._visible_tenant_filters( + params, + tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code], + legacy_regions=["省级", "公共", current_region], + ) - if requested: - if requested == "省级": - params["requested_region"] = requested - return ["t.region = :requested_region"] - if area and requested == area: - params["requested_region"] = requested - return ["t.region = :requested_region"] + if requested_tenant_code: + if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}: + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + if current_tenant_code and requested_tenant_code == current_tenant_code: + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + if not current_tenant_code and current_region and requested_region == current_region: + return self._tenant_filter_sql( + params, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) return ["1=0"] - if area: - params["visible_regions"] = ["省级", area] - return ["t.region IN :visible_regions"] - params["requested_region"] = "省级" - return ["t.region = :requested_region"] + if requested_region: + if requested_region in {"省级", "公共"}: + return self._tenant_filter_sql( + params, + tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", + region=requested_region, + prefix="requested", + ) + if current_region and requested_region == current_region: + return self._tenant_filter_sql( + params, + tenant_code=current_tenant_code or None, + region=current_region or requested_region, + prefix="requested", + ) + return ["1=0"] + + if current_tenant_code or current_region: + return self._visible_tenant_filters( + params, + tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code], + legacy_regions=["省级", "公共", current_region], + ) + return self._tenant_filter_sql( + params, + tenant_code="PROVINCIAL", + region="省级", + prefix="requested", + ) async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]: own_session = False @@ -655,13 +792,28 @@ class ContractTemplateServiceImpl(IContractTemplateService): session_cm = GetAsyncSession() session = await session_cm.__aenter__() try: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) row = ( await session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage FROM sso_users u @@ -676,9 +828,18 @@ class ContractTemplateServiceImpl(IContractTemplateService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="contract_template_user_context", + ) return { "id": int(row["id"]), "area": str(row["area"] or ""), + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_area_admin": bool(row["can_manage"]) and not bool(row["is_global"]), @@ -687,14 +848,104 @@ class ContractTemplateServiceImpl(IContractTemplateService): if own_session: await session_cm.__aexit__(None, None, None) - def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str: - _ = requestedRegion - area = str(currentUser["area"] or "").strip() + def _resolve_upload_scope( + self, + currentUser: dict[str, Any], + requestedRegion: str | None, + requestedTenantCode: str | None = None, + ) -> tuple[str | None, str | None, str]: + requested_tenant_code, requested_region = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser) + current_tenant_code = str(currentUser.get("tenant_code") or "").strip() or None + current_tenant_name = str(currentUser.get("tenant_name") or "").strip() or None + current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip() if not currentUser.get("is_area_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持地区管理员上传合同模板") - if not area: - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前地区管理员账号未配置所属地区,无法上传合同模板") - return area + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持租户管理员上传合同模板") + if not current_region: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户管理员账号未配置所属租户,无法上传合同模板") + if requested_tenant_code and not current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前账号未配置标准租户编码,不能显式指定模板租户") + if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户") + if requested_region and requested_region != current_region: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户") + return current_tenant_code, current_tenant_name or current_region, current_region + + def _normalize_scope_value( + self, + requestedRegion: str | None, + requestedTenantCode: str | None, + currentUser: dict[str, Any], + ) -> tuple[str | None, str]: + tenant_code = str(requestedTenantCode or "").strip() + if tenant_code: + if tenant_code == str(currentUser.get("tenant_code") or "").strip(): + return ( + str(currentUser.get("tenant_code") or "").strip() or None, + str(currentUser.get("tenant_scope_value") or currentUser.get("tenant_name") or currentUser.get("area") or "").strip(), + ) + if tenant_code == "PUBLIC": + return "PUBLIC", "公共" + if tenant_code == "PROVINCIAL": + return "PROVINCIAL", "省级" + return tenant_code, str(requestedRegion or "").strip() + return None, str(requestedRegion or "").strip() + + def _tenant_filter_sql( + self, + params: dict[str, Any], + *, + tenant_code: str | None, + region: str | None, + prefix: str, + ) -> list[str]: + normalized_region = str(region or "").strip() + normalized_tenant_code = str(tenant_code or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + if normalized_region: + params[f"{prefix}_region"] = normalized_region + return [ + "(" + f"t.tenant_code = :{prefix}_tenant_code " + "OR (" + "(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') " + f"AND t.region = :{prefix}_region" + ")" + ")" + ] + return [f"t.tenant_code = :{prefix}_tenant_code"] + if normalized_region: + params[f"{prefix}_region"] = normalized_region + return [f"t.region = :{prefix}_region"] + return ["1=0"] + + def _visible_tenant_filters( + self, + params: dict[str, Any], + *, + tenant_codes: list[str | None], + legacy_regions: list[str | None], + ) -> list[str]: + normalized_tenant_codes = [code.strip() for code in tenant_codes if code and str(code).strip()] + normalized_regions = [region.strip() for region in legacy_regions if region and str(region).strip()] + if normalized_tenant_codes: + params["visible_tenant_codes"] = normalized_tenant_codes + if normalized_regions: + params["visible_regions"] = normalized_regions + return [ + "(" + "t.tenant_code IN :visible_tenant_codes " + "OR (" + "(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') " + "AND t.region IN :visible_regions" + ")" + ")" + ] + return ["t.tenant_code IN :visible_tenant_codes"] + if normalized_regions: + params["visible_regions"] = normalized_regions + return ["t.region IN :visible_regions"] + return ["1=0"] async def _ensureContractTemplateSchema(self, session) -> None: statements = [ @@ -716,6 +967,14 @@ class ContractTemplateServiceImpl(IContractTemplateService): """, """ ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(128) + """, + """ + ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500) """, """ @@ -758,6 +1017,17 @@ class ContractTemplateServiceImpl(IContractTemplateService): """ ) ) + await session.execute( + text( + """ + UPDATE contract_templates + SET tenant_name = region + WHERE (tenant_name IS NULL OR BTRIM(tenant_name) = '') + AND region IS NOT NULL + AND BTRIM(region) <> '' + """ + ) + ) await session.execute( text( """ diff --git a/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py index 3f346ff..f4c41c0 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py @@ -45,12 +45,15 @@ from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import ( CrossReviewTaskItemVO, CrossReviewTaskPageVO, CrossReviewTaskProgressVO, + CrossReviewTaskTenantVO, ) from fastapi_modules.fastapi_leaudit.services.crossReviewService import ICrossReviewService from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService from fastapi_modules.fastapi_leaudit.services.auditService import IAuditService from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class CrossReviewServiceImpl(ICrossReviewService): @@ -189,6 +192,7 @@ class CrossReviewServiceImpl(ICrossReviewService): def __init__(self): self.DocumentService: IDocumentService = DocumentServiceImpl() self.AuditService: IAuditService = AuditServiceImpl() + self.TenantResolver = TenantResolver() async def CreateTask(self, CurrentUserId: int, Body: CrossReviewTaskCreateDTO) -> CrossReviewTaskCreateVO: """创建交叉评查任务。""" @@ -198,6 +202,7 @@ class CrossReviewServiceImpl(ICrossReviewService): memberUserIds = self._unique_int_list(Body.memberUserIds + [CurrentUserId]) principalUserIds = self._unique_int_list(Body.principalUserIds) documentIds = self._unique_int_list(Body.documentIds) + await self._assert_task_scope_inputs(session, CurrentUserId, memberUserIds, documentIds) await self._reset_transaction_for_write(session) async with session.begin(): @@ -332,6 +337,17 @@ class CrossReviewServiceImpl(ICrossReviewService): or 0 ) + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_code", + ) + tenant_name_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_name", + ) rows = ( await session.execute( text( @@ -348,13 +364,34 @@ class CrossReviewServiceImpl(ICrossReviewService): task_regions AS ( SELECT tm.task_id, - ARRAY_AGG(DISTINCT u.area ORDER BY u.area) FILTER ( - WHERE u.area IS NOT NULL AND u.area != '' + ARRAY_AGG( + DISTINCT COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) + ORDER BY COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) + ) FILTER ( + WHERE COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) IS NOT NULL ) AS evaluation_regions FROM leaudit_cross_review_task_members tm JOIN sso_users u ON u.id = tm.user_id WHERE tm.delete_time IS NULL GROUP BY tm.task_id + ), + task_tenants AS ( + SELECT + tm.task_id, + jsonb_agg( + DISTINCT jsonb_build_object( + 'tenantCode', + COALESCE(NULLIF({tenant_code_expr}, ''), ''), + 'tenantName', + COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, ''), '') + ) + ) FILTER ( + WHERE COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) IS NOT NULL + ) AS evaluation_tenants + FROM leaudit_cross_review_task_members tm + JOIN sso_users u ON u.id = tm.user_id + WHERE tm.delete_time IS NULL + GROUP BY tm.task_id ) SELECT t.id AS task_id, @@ -366,18 +403,21 @@ class CrossReviewServiceImpl(ICrossReviewService): t.create_time, COALESCE(ds.total_documents, 0) AS total_documents, COALESCE(ds.completed_documents, 0) AS completed_documents, + COALESCE(tt.evaluation_tenants, '[]'::jsonb) AS evaluation_tenants, COALESCE(tr.evaluation_regions, ARRAY[]::varchar[]) AS evaluation_regions FROM leaudit_cross_review_tasks t JOIN leaudit_cross_review_task_members tm ON tm.task_id = t.id LEFT JOIN doc_stats ds ON ds.task_id = t.id + LEFT JOIN task_tenants tt + ON tt.task_id = t.id LEFT JOIN task_regions tr ON tr.task_id = t.id WHERE {whereSql} GROUP BY t.id, t.task_name, t.task_type, t.doc_type_id, t.doc_type_code, - t.status, t.create_time, ds.total_documents, ds.completed_documents, + t.status, t.create_time, ds.total_documents, ds.completed_documents, tt.evaluation_tenants, tr.evaluation_regions ORDER BY t.create_time DESC, t.id DESC LIMIT :limit OFFSET :offset @@ -392,6 +432,7 @@ class CrossReviewServiceImpl(ICrossReviewService): totalDocuments = int(row["total_documents"] or 0) completedDocuments = int(row["completed_documents"] or 0) progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2) + evaluationTenants = self._parse_task_tenants(row.get("evaluation_tenants")) rawRegions = row.get("evaluation_regions") if rawRegions is None: evaluationRegion: list[str] = [] @@ -399,6 +440,8 @@ class CrossReviewServiceImpl(ICrossReviewService): evaluationRegion = [str(r) for r in rawRegions] else: evaluationRegion = [str(rawRegions)] + if not evaluationRegion: + evaluationRegion = [tenant.tenantName for tenant in evaluationTenants if tenant.tenantName] items.append( CrossReviewTaskItemVO( taskId=int(row["task_id"]), @@ -411,6 +454,7 @@ class CrossReviewServiceImpl(ICrossReviewService): totalDocuments=totalDocuments, completedDocuments=completedDocuments, createdAt=row.get("create_time"), + evaluationTenants=evaluationTenants, evaluationRegion=evaluationRegion, ) ) @@ -1346,6 +1390,7 @@ class CrossReviewServiceImpl(ICrossReviewService): resolvedTypeId = int(TypeId) if TypeId is not None else self._to_int(taskMeta.get("doc_type_id")) resolvedGroupId = int(GroupId) if GroupId is not None else None + taskScope = await self._load_task_scope_context(session, TaskId) uploadResult = await self.DocumentService.Upload( FileName=FileName, @@ -1355,6 +1400,9 @@ class CrossReviewServiceImpl(ICrossReviewService): TypeCode=None if resolvedTypeId is not None else taskMeta.get("doc_type_code"), GroupId=resolvedGroupId, CreatedBy=CurrentUserId, + Region=taskScope["tenant_scope_value"], + TenantCode=taskScope["tenant_code"], + TenantName=taskScope["tenant_name"], AutoRun=True, ReviewScope="cross_review", ) @@ -1988,6 +2036,236 @@ class CrossReviewServiceImpl(ICrossReviewService): if not exists: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户不是交叉评查任务成员") + async def _assert_task_scope_inputs( + self, + session, + current_user_id: int, + member_user_ids: list[int], + document_ids: list[int], + ) -> None: + """创建任务前校验成员和文档均在单一有效租户范围内。""" + current_context = await self.DocumentService._getCurrentUserContext(current_user_id) + + current_tenant_code = str(current_context.get("tenant_code") or "").strip() + current_tenant_name = str(current_context.get("tenant_name") or current_context.get("tenant_scope_value") or current_context.get("area") or "").strip() + is_global = bool(current_context.get("is_global")) + if not is_global and not current_tenant_code and not current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户未绑定有效租户,不能创建交叉评查任务") + + resolved_scopes: list[dict[str, str]] = [] + + if member_user_ids: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + user_rows = ( + await session.execute( + text( + f""" + SELECT u.id, COALESCE(u.area, '') AS area, {tenant_code_select}, {tenant_name_select} + FROM sso_users u + WHERE id IN :user_ids + AND deleted_at IS NULL + """ + ).bindparams(bindparam("user_ids", expanding=True)), + {"user_ids": member_user_ids}, + ) + ).mappings().all() + user_map = {int(row["id"]): row for row in user_rows} + missing_user_ids = [user_id for user_id in member_user_ids if user_id not in user_map] + if missing_user_ids: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"成员用户不存在: {missing_user_ids[0]}") + + for user_id in member_user_ids: + row = user_map[user_id] + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row.get("area") or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="cross_review_member_scope", + ) + member_tenant_code = str(tenant.tenant_code or row.get("tenant_code") or "").strip() + member_tenant_name = str(tenant.tenant_name or tenant.normalized_value or row.get("area") or "").strip() + if not member_tenant_code and not member_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"成员用户未绑定有效租户: {user_id}") + resolved_scopes.append({"tenant_code": member_tenant_code, "tenant_name": member_tenant_name}) + if current_tenant_code: + if member_tenant_code: + if member_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}") + elif member_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}") + elif not is_global and member_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}") + + if document_ids: + document_columns = await self._load_table_columns(session, "leaudit_documents") + document_tenant_code_select = "COALESCE(tenant_code, '') AS tenant_code" if "tenant_code" in document_columns else "'' AS tenant_code" + document_rows = ( + await session.execute( + text( + f""" + SELECT id, COALESCE(region, '') AS region, {document_tenant_code_select} + FROM leaudit_documents + WHERE id IN :document_ids + AND deleted_at IS NULL + """ + ).bindparams(bindparam("document_ids", expanding=True)), + {"document_ids": document_ids}, + ) + ).mappings().all() + document_map = {int(row["id"]): row for row in document_rows} + missing_document_ids = [document_id for document_id in document_ids if document_id not in document_map] + if missing_document_ids: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"任务文档不存在: {missing_document_ids[0]}") + + for document_id in document_ids: + row = document_map[document_id] + tenant = await self.TenantResolver.Resolve( + RawValue=str(row.get("region") or ""), + Source="cross_review_document_scope", + PreferredTenantCode=str(row.get("tenant_code") or "") or None, + ) + document_tenant_code = str(tenant.tenant_code or row.get("tenant_code") or "").strip() + document_tenant_name = str(tenant.tenant_name or tenant.normalized_value or row.get("region") or "").strip() + if not document_tenant_code and not document_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"任务文档未绑定有效租户: {document_id}") + resolved_scopes.append({"tenant_code": document_tenant_code, "tenant_name": document_tenant_name}) + if current_tenant_code: + if document_tenant_code: + if document_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}") + elif document_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}") + elif not is_global and document_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}") + + self._assert_single_task_scope(resolved_scopes) + + async def _load_task_scope_context(self, session, task_id: int) -> dict[str, str | None]: + """读取任务所属租户上下文,补传文档时沿用任务真实边界。""" + document_columns = await self._load_table_columns(session, "leaudit_documents") + document_tenant_code_select = "COALESCE(d.tenant_code, '') AS tenant_code" if "tenant_code" in document_columns else "'' AS tenant_code" + document_rows = ( + await session.execute( + text( + f""" + SELECT + td.document_id, + COALESCE(d.region, '') AS region, + {document_tenant_code_select} + FROM leaudit_cross_review_task_documents td + JOIN leaudit_documents d + ON d.id = td.document_id + WHERE td.task_id = :task_id + AND td.delete_time IS NULL + AND d.deleted_at IS NULL + ORDER BY td.id ASC + """ + ), + {"task_id": task_id}, + ) + ).mappings().all() + if document_rows: + document_scopes: list[dict[str, str]] = [] + for row in document_rows: + tenant = await self.TenantResolver.Resolve( + RawValue=str(row.get("region") or ""), + Source="cross_review_task_document_scope", + PreferredTenantCode=str(row.get("tenant_code") or "") or None, + ) + document_scopes.append( + { + "tenant_code": str(tenant.tenant_code or row.get("tenant_code") or "").strip(), + "tenant_name": str(tenant.tenant_name or tenant.normalized_value or row.get("region") or "").strip(), + } + ) + return self._assert_single_task_scope(document_scopes, error_message="交叉评查任务存在多租户文档,不能继续补传文档") + + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + row = ( + await session.execute( + text( + f""" + SELECT + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select} + FROM leaudit_cross_review_tasks t + JOIN sso_users u + ON u.id = t.assigner_id + WHERE t.id = :task_id + AND t.delete_time IS NULL + LIMIT 1 + """ + ), + {"task_id": task_id}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查任务不存在") + + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row.get("area") or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="cross_review_task_scope", + ) + return { + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or str(row.get("area") or "") or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row.get("area") or ""), + } + + def _assert_single_task_scope( + self, + scopes: list[dict[str, str]], + *, + error_message: str = "交叉评查任务不允许混挂多个租户的成员或文档", + ) -> dict[str, str | None]: + normalized: list[dict[str, str]] = [] + identities: set[str] = set() + for scope in scopes: + tenant_code = str(scope.get("tenant_code") or "").strip() + tenant_name = str(scope.get("tenant_name") or "").strip() + if not tenant_code and not tenant_name: + continue + identity = f"code:{tenant_code}" if tenant_code else f"name:{tenant_name}" + identities.add(identity) + normalized.append({"tenant_code": tenant_code, "tenant_name": tenant_name}) + if len(identities) > 1: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, error_message) + if not normalized: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "未能识别交叉评查任务租户边界") + primary = normalized[0] + return { + "tenant_code": primary["tenant_code"] or None, + "tenant_name": primary["tenant_name"] or None, + "tenant_scope_value": primary["tenant_name"] or primary["tenant_code"] or "", + } + async def _reset_transaction_for_write(self, session) -> None: """显式写事务前清理查询阶段开启的隐式事务。""" if session.in_transaction(): @@ -2019,6 +2297,28 @@ class CrossReviewServiceImpl(ICrossReviewService): return [str(v) for v in value] return [str(value)] + def _parse_task_tenants(self, value) -> list[CrossReviewTaskTenantVO]: + """安全解析任务租户 JSON 聚合结果。""" + if value is None: + return [] + if not isinstance(value, list): + return [] + result: list[CrossReviewTaskTenantVO] = [] + seen: set[tuple[str, str]] = set() + for item in value: + if not isinstance(item, dict): + continue + tenant_code = str(item.get("tenantCode") or item.get("tenant_code") or "").strip() + tenant_name = str(item.get("tenantName") or item.get("tenant_name") or "").strip() + if not tenant_code and not tenant_name: + continue + identity = (tenant_code, tenant_name) + if identity in seen: + continue + seen.add(identity) + result.append(CrossReviewTaskTenantVO(tenantCode=tenant_code, tenantName=tenant_name or tenant_code)) + return result + def _build_score_summary(self, finalScore: float, fullScore: float) -> str: if fullScore <= 0: return "0/0" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index 9297879..f5a0ecb 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -53,12 +53,16 @@ from fastapi_modules.fastapi_leaudit.domian.vo.reviewPointVo import ( ReviewPointStatsVO, ReviewPointsAggregateVO, ) +from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import PageQualitySummaryVO from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile from fastapi_modules.fastapi_leaudit.leaudit_bridge.fileSourceResolver import FileSourceResolver from fastapi_modules.fastapi_leaudit.services import IAuditService, IDocumentService, IOssService from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.pageQualityServiceImpl import PageQualityServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import sync_group_bindings_from_doc_type +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class DocumentServiceImpl(IDocumentService): @@ -71,6 +75,10 @@ class DocumentServiceImpl(IDocumentService): ) -> None: self.OssService = OssService or OssServiceImpl() self.AuditService = AuditService or AuditServiceImpl() + self.PageQualityService = PageQualityServiceImpl() + self.TenantResolver = TenantResolver() + + _PUBLIC_REGION = "公共" async def Upload( self, @@ -80,9 +88,11 @@ class DocumentServiceImpl(IDocumentService): TypeId: int | None = None, TypeCode: str | None = None, GroupId: int | None = None, - Region: str = "default", + Region: str | None = None, FileRole: str = "primary", CreatedBy: int | None = None, + TenantCode: str | None = None, + TenantName: str | None = None, Attachments: list[tuple[str, bytes, str | None]] | None = None, AutoRun: bool = False, Speed: str = "normal", @@ -96,7 +106,25 @@ class DocumentServiceImpl(IDocumentService): if not TypeId and not TypeCode: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "typeId 与 typeCode 至少传一个") - normalizedRegion = (Region or "default").strip() or "default" + normalizedRegion = await self._resolve_document_region( + Region=Region, + TenantCode=TenantCode, + TenantName=TenantName, + ) + currentUserContext: dict[str, Any] | None = None + if CreatedBy is not None: + currentUserContext = await self._getCurrentUserContext(CreatedBy) + normalizedRegion = self._resolve_upload_region( + currentUserContext, + requestedRegion=normalizedRegion, + requestedTenantCode=TenantCode, + ) + resolvedTenant = await self.TenantResolver.Resolve( + RawValue=normalizedRegion, + Source="document_upload_response", + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) normalizedFileRole = (FileRole or "primary").strip() or "primary" fileExt = Path(FileName).suffix.lstrip(".").lower() or None mimeType = ContentType or mimetypes.guess_type(FileName)[0] or "application/octet-stream" @@ -168,6 +196,7 @@ class DocumentServiceImpl(IDocumentService): Session, type_id=resolvedTypeId, root_group_id=resolvedRootGroupId, + tenant_code=resolvedTenant.tenant_code, region=normalizedRegion, normalized_name=normalizedName, file_ext=fileExt, @@ -190,6 +219,7 @@ class DocumentServiceImpl(IDocumentService): bizDocumentId=internalDocumentNo, typeId=resolvedTypeId, groupId=resolvedGroupId, + tenantCode=resolvedTenant.tenant_code, region=normalizedRegion, processingStatus="waiting", versionGroupKey=versionGroupKey, @@ -260,6 +290,7 @@ class DocumentServiceImpl(IDocumentService): ossUrl = documentFile.ossUrl or "" run = None + pageQualityRun = None processingStatus = document.processingStatus or "waiting" if AutoRun: run = await self.AuditService.Run( @@ -270,6 +301,17 @@ class DocumentServiceImpl(IDocumentService): ) processingStatus = "running" if run.status in {"pending", "running"} else run.status + if normalizedFileRole == "primary": + try: + pageQualityRun = await self.PageQualityService.DispatchForDocument( + DocumentId=document.Id, + TriggerUserId=CreatedBy, + Force=duplicateUpload, + Speed=normalizedSpeed, + ) + except Exception as exc: + logger.warning("dispatch page quality failed for document_id=%s: %s", document.Id, exc) + return DocumentUploadVO( documentId=document.Id, internalDocumentNo=document.bizDocumentId, @@ -283,11 +325,16 @@ class DocumentServiceImpl(IDocumentService): typeCode=resolvedTypeCode, groupId=resolvedGroupId, region=normalizedRegion, + tenantCode=resolvedTenant.tenant_code, + tenantName=resolvedTenant.tenant_name or normalizedRegion, fileName=documentFile.fileName, ossUrl=ossUrl, speed=normalizedSpeed, processingStatus=processingStatus, autoRunTriggered=AutoRun, + pageQualityRunId=pageQualityRun.runId if pageQualityRun else None, + pageQualityRunStatus=pageQualityRun.status if pageQualityRun else None, + pageQualitySummaryStatus=None, run=run, ) @@ -302,6 +349,7 @@ class DocumentServiceImpl(IDocumentService): TypeIds: list[int] | None = None, EntryModuleId: int | None = None, Region: str | None = None, + TenantCode: str | None = None, ProcessingStatus: str | None = None, ResultStatus: str | None = None, AuditStatus: int | None = None, @@ -319,6 +367,11 @@ class DocumentServiceImpl(IDocumentService): documentColumns = await self._loadDocumentColumns(Session) currentUser = await self._getCurrentUserContext(CurrentUserId) + requestedTenant = await self.TenantResolver.Resolve( + RawValue=(Region or "").strip() or None, + Source="document_list_scope", + PreferredTenantCode=str(TenantCode or "").strip() or None, + ) filters = [ "d.is_latest_version = true", "d.deleted_at IS NULL", @@ -331,7 +384,8 @@ class DocumentServiceImpl(IDocumentService): CurrentUserId=CurrentUserId, CurrentUser=currentUser, Params=params, - RequestedRegion=Region, + RequestedRegion=requestedTenant.tenant_name or requestedTenant.normalized_value or Region, + RequestedTenantCode=requestedTenant.tenant_code or TenantCode, RequestedUserId=UserId, DocumentAlias="d", FileAlias="f", @@ -414,6 +468,14 @@ class DocumentServiceImpl(IDocumentService): "d.is_test_document AS is_test_document" if "is_test_document" in documentColumns else "FALSE AS is_test_document", "dt.name AS type_name", f"{resolvedGroupNameExpr} AS group_name", + "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code", + """ + CASE + WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' + WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' + ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '') + END AS tenant_name + """.strip(), ] count_sql = text( @@ -553,6 +615,10 @@ class DocumentServiceImpl(IDocumentService): rows = (await Session.execute(list_sql, params)).mappings().all() history_by_group: dict[str, list[DocumentHistoryVersionVO]] = {} + page_quality_map = await self._loadLatestPageQualitySummaryMap( + Session, + [int(row["document_id"]) for row in rows], + ) group_keys = [str(row["version_group_key"]) for row in rows if row["version_group_key"]] if group_keys: history_rows = ( @@ -581,6 +647,7 @@ class DocumentServiceImpl(IDocumentService): documents: list[DocumentListItemVO] = [] for row in rows: + quality = page_quality_map.get(int(row["document_id"]), {}) group_key = str(row["version_group_key"] or "") documents.append( DocumentListItemVO( @@ -596,6 +663,8 @@ class DocumentServiceImpl(IDocumentService): groupId=int(row["group_id"]) if row["group_id"] is not None else None, groupName=row["group_name"], region=row["region"], + tenantCode=row["tenant_code"], + tenantName=row["tenant_name"], normalizedName=row["normalized_name"], fileId=int(row["file_id"]) if row["file_id"] is not None else None, fileName=row["file_name"], @@ -614,6 +683,11 @@ class DocumentServiceImpl(IDocumentService): documentNumber=row["document_number"], auditStatus=int(row["audit_status"]) if row["audit_status"] is not None else None, isTestDocument=bool(row["is_test_document"]), + pageQualityRunId=quality.get("pageQualityRunId"), + pageQualityRunStatus=quality.get("pageQualityRunStatus"), + pageQualitySummaryStatus=quality.get("pageQualitySummaryStatus"), + pageQualityIssueCount=int(quality.get("pageQualityIssueCount") or 0), + pageQualityWarningText=quality.get("pageQualityWarningText"), updatedAt=row["updated_at"].isoformat() if row["updated_at"] else None, hasHistory=bool(row["has_history"]), totalVersions=int(row["total_versions"] or 1), @@ -872,6 +946,10 @@ class DocumentServiceImpl(IDocumentService): params, ) ).mappings().all() + page_quality_map = await self._loadLatestPageQualitySummaryMap( + Session, + [int(row["document_id"]) for row in rows], + ) return [ DocumentStatusItemVO( @@ -880,6 +958,15 @@ class DocumentServiceImpl(IDocumentService): runStatus=row["run_status"], phase=row["phase"], resultStatus=row["result_status"], + pageQualityRunId=page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRunId"), + pageQualityRunStatus=page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRunStatus"), + pageQualitySummaryStatus=page_quality_map.get(int(row["document_id"]), {}).get("pageQualitySummaryStatus"), + pageQualityReviewPageCount=int( + page_quality_map.get(int(row["document_id"]), {}).get("pageQualityReviewPageCount") or 0 + ), + pageQualityRejectPageCount=int( + page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRejectPageCount") or 0 + ), updatedAt=row["updated_at"].isoformat() if row["updated_at"] else None, ) for row in rows @@ -930,7 +1017,7 @@ class DocumentServiceImpl(IDocumentService): versionLabel = f"v{int(detail.versionNo or 1)}" objectKey = OssPathUtils.BuildBusinessDocKey( - Region=detail.region or "default", + Region=detail.region or "公共", TypeCode=detail.typeCode or "contract", DocumentId=DocumentId, Version=versionLabel, @@ -1639,6 +1726,7 @@ class DocumentServiceImpl(IDocumentService): d.biz_document_id, d.type_id, d.group_id, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, d.region, d.processing_status, d.version_group_key, @@ -1680,7 +1768,8 @@ class DocumentServiceImpl(IDocumentService): "bizDocumentId": time.time_ns(), "typeId": int(sourceRow["type_id"]) if sourceRow["type_id"] is not None else None, "groupId": int(sourceRow["group_id"]) if sourceRow["group_id"] is not None else None, - "region": str(sourceRow["region"] or "default").strip() or "default", + "tenantCode": sourceRow["tenant_code"], + "region": str(sourceRow["region"] or "公共").strip() or "公共", "processingStatus": "waiting", "versionGroupKey": str(sourceRow["version_group_key"] or uuid.uuid4().hex), "versionNo": int(sourceRow["version_no"] or 1) + 1, @@ -1724,7 +1813,7 @@ class DocumentServiceImpl(IDocumentService): IncludeAttachments=False, ) await Session.flush() - return newDocument, resolvedTypeCode, str(sourceRow["region"] or "default").strip() or "default" + return newDocument, resolvedTypeCode, str(sourceRow["region"] or "公共").strip() or "公共" def _normalizeAttachmentMergeMode(self, MergeMode: str | None) -> str: """标准化附件合并模式。""" @@ -1887,7 +1976,7 @@ class DocumentServiceImpl(IDocumentService): targetDocumentId = int(documentMeta["document_id"]) targetVersionNo = int(documentMeta["version_no"] or 1) - normalizedRegion = str(documentMeta["region"] or "default").strip() or "default" + normalizedRegion = str(documentMeta["region"] or "公共").strip() or "公共" if normalizedMergeMode == "new": newDocument, resolvedTypeCode, normalizedRegion = await self._createNextVersionFromExistingDocument( @@ -1939,10 +2028,20 @@ class DocumentServiceImpl(IDocumentService): await Session.commit() - refreshed = await self._getDocumentDetail(Session, targetDocumentId, CurrentUserId, currentUser, documentColumns) - if not refreshed: - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问") - return refreshed + try: + await self.PageQualityService.DispatchForDocument( + DocumentId=targetDocumentId, + TriggerUserId=CurrentUserId, + Force=True, + Speed="normal", + ) + except Exception as exc: + logger.warning("dispatch page quality after append attachments failed for document_id=%s: %s", targetDocumentId, exc) + + refreshed = await self.GetDocument(CurrentUserId=CurrentUserId, Id=targetDocumentId) + if not refreshed: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问") + return refreshed async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]: @@ -2004,7 +2103,7 @@ class DocumentServiceImpl(IDocumentService): }, ) ).scalar_one() - await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, "default") + await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, self._PUBLIC_REGION) await sync_group_bindings_from_doc_type(Session, int(row), Body.ruleSetIds) await Session.commit() @@ -2047,7 +2146,7 @@ class DocumentServiceImpl(IDocumentService): await Session.execute(text(f"UPDATE leaudit_document_types SET {', '.join(sets)} WHERE id = :id"), params) if "ruleSetIds" in providedFields and Body.ruleSetIds is not None: - await self._syncRuleBindings(Session, Id, Body.ruleSetIds, "default") + await self._syncRuleBindings(Session, Id, Body.ruleSetIds, self._PUBLIC_REGION) await sync_group_bindings_from_doc_type(Session, Id, Body.ruleSetIds) await Session.commit() @@ -2598,6 +2697,14 @@ class DocumentServiceImpl(IDocumentService): async def _ensureDocumentGroupColumn(self, Session) -> None: """渐进式补齐文档二级分组字段,避免旧环境缺列。""" + await Session.execute( + text( + """ + ALTER TABLE leaudit_documents + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) + """ + ) + ) await Session.execute( text( """ @@ -2610,6 +2717,9 @@ class DocumentServiceImpl(IDocumentService): await Session.execute( text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_group_id ON leaudit_documents(group_id)") ) + await Session.execute( + text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code ON leaudit_documents(tenant_code)") + ) async def _resolveDocumentGroupId(self, Session, TypeId: int, GroupId: int | None) -> int | None: """校验上传时选择的二级分组是否属于当前文档类型。""" @@ -2684,6 +2794,93 @@ class DocumentServiceImpl(IDocumentService): return int(row["root_group_id"]) return None + async def _loadLatestPageQualitySummaryMap( + self, + Session, + DocumentIds: list[int], + ) -> dict[int, dict[str, Any]]: + """批量读取文档最新页级模糊摘要。""" + normalized_ids = [int(document_id) for document_id in DocumentIds if int(document_id) > 0] + if not normalized_ids: + return {} + if not await self._tableExists(Session, "leaudit_page_quality_runs"): + return {} + + rows = ( + await Session.execute( + text( + """ + SELECT DISTINCT ON (document_id) + document_id, + id AS run_id, + status AS run_status, + summary_status, + total_pages, + review_page_count, + reject_page_count + FROM leaudit_page_quality_runs + WHERE document_id = ANY(:document_ids) + AND deleted_at IS NULL + ORDER BY document_id, id DESC + """ + ), + {"document_ids": normalized_ids}, + ) + ).mappings().all() + + result: dict[int, dict[str, Any]] = {} + for row in rows: + document_id = int(row["document_id"]) + review_count = int(row["review_page_count"] or 0) + reject_count = int(row["reject_page_count"] or 0) + issue_count = review_count + reject_count + summary_status = str(row["summary_status"] or "") or None + warning_text = None + if issue_count > 0 and summary_status: + pages = await self._loadPageQualityIssuePages(Session, int(row["run_id"])) + warning_text = self._buildPageQualityWarningText(pages, summary_status) + result[document_id] = { + "pageQualityRunId": int(row["run_id"]), + "pageQualityRunStatus": str(row["run_status"] or "") or None, + "pageQualitySummaryStatus": summary_status, + "pageQualityTotalPages": int(row["total_pages"] or 0), + "pageQualityReviewPageCount": review_count, + "pageQualityRejectPageCount": reject_count, + "pageQualityIssueCount": issue_count, + "pageQualityWarningText": warning_text, + } + return result + + async def _loadPageQualityIssuePages(self, Session, RunId: int) -> list[int]: + """读取单次运行的异常页码列表。""" + if not await self._tableExists(Session, "leaudit_page_quality_results"): + return [] + rows = ( + await Session.execute( + text( + """ + SELECT DISTINCT page_num + FROM leaudit_page_quality_results + WHERE run_id = :run_id + AND quality_status IN ('review', 'reject') + ORDER BY page_num ASC + """ + ), + {"run_id": RunId}, + ) + ).mappings().all() + return [int(row["page_num"]) for row in rows if row["page_num"] is not None] + + def _buildPageQualityWarningText(self, Pages: list[int], SummaryStatus: str | None) -> str | None: + """组装页级模糊预警文案。""" + if not Pages or not SummaryStatus: + return None + pages_text = "、".join(f"第{page}页" for page in Pages[:10]) + suffix = "建议重拍" if SummaryStatus == "reject" else "疑似模糊" + if len(Pages) > 10: + pages_text = f"{pages_text}等" + return f"{pages_text}{suffix}" + async def _getDocumentDetail( self, Session, @@ -2725,6 +2922,14 @@ class DocumentServiceImpl(IDocumentService): "d.remark AS remark" if "remark" in DocumentColumns else "NULL::text AS remark", "d.is_test_document AS is_test_document" if "is_test_document" in DocumentColumns else "FALSE AS is_test_document", "d.audit_status AS audit_status" if "audit_status" in DocumentColumns else "NULL::integer AS audit_status", + "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code", + """ + CASE + WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' + WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' + ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '') + END AS tenant_name + """.strip(), ] detailRow = ( @@ -2890,6 +3095,23 @@ class DocumentServiceImpl(IDocumentService): ) for row in attachmentRows ] + page_quality_map = await self._loadLatestPageQualitySummaryMap(Session, [int(detailRow["document_id"])]) + quality = page_quality_map.get(int(detailRow["document_id"]), {}) + page_quality_pages = [] + if quality.get("pageQualityRunId") is not None: + page_quality_pages = await self._loadPageQualityIssuePages(Session, int(quality["pageQualityRunId"])) + page_quality_summary = None + if quality: + page_quality_summary = PageQualitySummaryVO( + runId=quality.get("pageQualityRunId"), + runStatus=quality.get("pageQualityRunStatus"), + summaryStatus=quality.get("pageQualitySummaryStatus"), + totalPages=int(quality.get("pageQualityTotalPages") or 0), + reviewPageCount=int(quality.get("pageQualityReviewPageCount") or 0), + rejectPageCount=int(quality.get("pageQualityRejectPageCount") or 0), + warningText=quality.get("pageQualityWarningText"), + pages=page_quality_pages, + ) return DocumentDetailVO( documentId=int(detailRow["document_id"]), @@ -2904,6 +3126,8 @@ class DocumentServiceImpl(IDocumentService): groupId=int(detailRow["group_id"]) if detailRow["group_id"] is not None else None, groupName=detailRow["group_name"], region=str(detailRow["region"] or ""), + tenantCode=detailRow["tenant_code"], + tenantName=detailRow["tenant_name"], normalizedName=detailRow["normalized_name"], fileId=int(detailRow["file_id"]) if detailRow["file_id"] is not None else None, fileName=detailRow["file_name"], @@ -2927,7 +3151,13 @@ class DocumentServiceImpl(IDocumentService): remark=detailRow["remark"], isTestDocument=bool(detailRow["is_test_document"]), auditStatus=int(detailRow["audit_status"]) if detailRow["audit_status"] is not None else None, + pageQualityRunId=quality.get("pageQualityRunId"), + pageQualityRunStatus=quality.get("pageQualityRunStatus"), + pageQualitySummaryStatus=quality.get("pageQualitySummaryStatus"), + pageQualityIssueCount=int(quality.get("pageQualityIssueCount") or 0), + pageQualityWarningText=quality.get("pageQualityWarningText"), pageCount=None, + pageQualitySummary=page_quality_summary, attachments=attachments, ) @@ -2971,31 +3201,52 @@ class DocumentServiceImpl(IDocumentService): DocumentAlias: str, FileAlias: str, RequestedRegion: str | None = None, + RequestedTenantCode: str | None = None, RequestedUserId: int | None = None, ) -> list[str]: - """根据当前用户角色与地区构建文档数据范围过滤。""" + """根据当前用户角色与租户/地区构建文档数据范围过滤。""" filters: list[str] = [] - requestedRegion = (RequestedRegion or "").strip() - area = str(CurrentUser["area"] or "").strip() + requested_tenant_code, requested_region = self._normalize_scope_value(RequestedRegion, RequestedTenantCode, CurrentUser) + current_tenant_code = str(CurrentUser.get("tenant_code") or "").strip() + current_region = str(CurrentUser.get("tenant_scope_value") or CurrentUser.get("area") or "").strip() if CurrentUser["is_global"]: - if requestedRegion: + if requested_tenant_code: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenant_code=requested_tenant_code, + region=requested_region, + prefix="requested", + ) + ) + elif requested_region: + Params["requested_region"] = requested_region filters.append(f"{DocumentAlias}.region = :requested_region") - Params["requested_region"] = requestedRegion if RequestedUserId is not None: filters.append(f"{FileAlias}.created_by = :requested_user_id") Params["requested_user_id"] = RequestedUserId return filters if CurrentUser["can_manage"]: - if not area: - filters.append("1 = 0") - return filters - if requestedRegion and requestedRegion != area: - filters.append("1 = 0") - return filters - filters.append(f"{DocumentAlias}.region = :scope_region") - Params["scope_region"] = area + if not current_tenant_code and not current_region: + return ["1 = 0"] + effective_tenant_code = current_tenant_code or requested_tenant_code + effective_region = current_region or requested_region + if current_tenant_code and requested_tenant_code and requested_tenant_code != current_tenant_code: + return ["1 = 0"] + if requested_region and current_region and requested_region != current_region: + return ["1 = 0"] + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenant_code=effective_tenant_code or None, + region=effective_region or None, + prefix="scope", + ) + ) if RequestedUserId is not None: filters.append(f"{FileAlias}.created_by = :requested_user_id") Params["requested_user_id"] = RequestedUserId @@ -3003,9 +3254,37 @@ class DocumentServiceImpl(IDocumentService): filters.append(f"{FileAlias}.created_by = :scope_user_id") Params["scope_user_id"] = CurrentUserId - if requestedRegion: - filters.append(f"{DocumentAlias}.region = :requested_region") - Params["requested_region"] = requestedRegion + if requested_tenant_code: + if current_tenant_code and requested_tenant_code != current_tenant_code: + filters.append("1 = 0") + elif not current_tenant_code and current_region and requested_region != current_region: + filters.append("1 = 0") + else: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenant_code=(current_tenant_code or requested_tenant_code) or None, + region=(current_region or requested_region) or None, + prefix="requested", + ) + ) + elif requested_region: + if current_region and requested_region != current_region: + filters.append("1 = 0") + elif current_tenant_code: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenant_code=current_tenant_code or None, + region=current_region or requested_region, + prefix="requested", + ) + ) + else: + Params["requested_region"] = requested_region + filters.append(f"{DocumentAlias}.region = :requested_region") if RequestedUserId is not None and RequestedUserId != CurrentUserId: filters.append("1 = 0") return filters @@ -3013,13 +3292,21 @@ class DocumentServiceImpl(IDocumentService): async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]: """加载当前用户上下文,用于文档数据隔离。""" async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) row = ( await Session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, + {tenant_code_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin @@ -3035,13 +3322,112 @@ class DocumentServiceImpl(IDocumentService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + tenant_resolution = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row["tenant_code"] or ""), + TenantName=None, + Source="document_user", + ) return { - "area": str(row["area"] or ""), + "area": tenant_resolution.tenant_name or tenant_resolution.normalized_value or str(row["area"] or ""), + "tenant_code": tenant_resolution.tenant_code or "", + "tenant_name": tenant_resolution.tenant_name or "", + "tenant_scope_value": tenant_resolution.tenant_name or tenant_resolution.normalized_value or str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_super_admin": bool(row["is_super_admin"]), } + async def _resolve_document_region( + self, + *, + Region: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> str: + resolution = await self.TenantResolver.Resolve( + RawValue=Region, + Source="document_region", + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) + normalized = resolution.tenant_name or resolution.normalized_value or "" + return normalized.strip() or "公共" + + @staticmethod + def _normalize_region_value(value: str | None) -> str: + if value is None: + return "" + trimmed = str(value).strip() + return trimmed + + def _normalize_scope_value( + self, + requestedRegion: str | None, + requestedTenantCode: str | None, + currentUser: dict[str, Any], + ) -> tuple[str | None, str]: + tenant_code = str(requestedTenantCode or "").strip() + if tenant_code: + if tenant_code == str(currentUser.get("tenant_code") or "").strip(): + return ( + str(currentUser.get("tenant_code") or "").strip() or None, + str(currentUser.get("tenant_scope_value") or currentUser.get("tenant_name") or currentUser.get("area") or "").strip(), + ) + if tenant_code == "PUBLIC": + return "PUBLIC", self._PUBLIC_REGION + if tenant_code == "PROVINCIAL": + return "PROVINCIAL", "省级" + return tenant_code or None, str(requestedRegion or "").strip() + return None, self._normalize_region_value(requestedRegion) + + def _resolve_upload_region( + self, + currentUser: dict[str, Any], + requestedRegion: str | None, + requestedTenantCode: str | None = None, + ) -> str: + requestedTenantCodeNormalized, normalizedRequestedRegion = self._normalize_scope_value( + requestedRegion, + requestedTenantCode, + currentUser, + ) + currentTenantCode = str(currentUser.get("tenant_code") or "").strip() + currentRegion = str(currentUser.get("tenant_scope_value") or currentUser.get("area") or "").strip() + + if currentUser["is_global"]: + return normalizedRequestedRegion or currentRegion or self._PUBLIC_REGION + + if requestedTenantCodeNormalized: + if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + if not currentTenantCode and currentRegion and normalizedRequestedRegion != currentRegion: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + + elif normalizedRequestedRegion and currentRegion and normalizedRequestedRegion != currentRegion: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + + return currentRegion or normalizedRequestedRegion or self._PUBLIC_REGION + + def _document_tenant_filter_sql( + self, + params: dict[str, Any], + *, + DocumentAlias: str, + tenant_code: str | None, + region: str | None, + prefix: str, + ) -> list[str]: + normalized_region = str(region or "").strip() + normalized_tenant_code = str(tenant_code or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + return [f"{DocumentAlias}.tenant_code = :{prefix}_tenant_code"] + if normalized_region: + params[f"{prefix}_region"] = normalized_region + return [f"{DocumentAlias}.region = :{prefix}_region"] + return ["1 = 0"] + async def _loadReviewRunRow(self, Session, Detail: DocumentDetailVO) -> dict[str, Any] | None: """定位当前文档可展示的最新评查运行。""" params: dict[str, object] = {"document_id": Detail.documentId} @@ -3171,6 +3557,8 @@ class DocumentServiceImpl(IDocumentService): "groupId": Detail.groupId, "groupName": Detail.groupName, "region": Detail.region, + "tenantCode": Detail.tenantCode, + "tenantName": Detail.tenantName, "auditStatus": Detail.auditStatus or 0, "audit_status": Detail.auditStatus or 0, "uploadTime": _format_iso_datetime(createdAt) or Detail.updatedAt or "", @@ -3772,8 +4160,9 @@ class DocumentServiceImpl(IDocumentService): return str(Row["skip_reason"]) return str(Row.get("rule_name") or Row.get("rule_id") or "") - async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str = "default") -> None: + async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str | None = None) -> None: """全量替换规则绑定。""" + normalizedRegion = self._normalize_binding_region(Region) await Session.execute( text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"), {"id": DocTypeId}, @@ -3786,15 +4175,22 @@ class DocumentServiceImpl(IDocumentService): VALUES (:doc_type_id, :rule_set_id, 'explicit', :priority, :region, true, NOW(), NOW()) """ ), - {"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": Region}, + {"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": normalizedRegion}, ) + def _normalize_binding_region(self, region: str | None) -> str: + normalized = str(region or "").strip() + if normalized in {"", "default", "省级"}: + return self._PUBLIC_REGION + return normalized + async def _find_latest_version_candidate( session, *, type_id: int, root_group_id: int | None, + tenant_code: str | None, region: str, normalized_name: str, file_ext: str | None = None, @@ -3809,6 +4205,7 @@ async def _find_latest_version_candidate( if root_group_id is not None: params: dict[str, object] = { "root_group_id": root_group_id, + "tenant_code": str(tenant_code or "").strip(), "region": region, "normalized_name": normalized_name, **ext_params, @@ -3842,7 +4239,14 @@ async def _find_latest_version_candidate( GROUP BY document_type_id ) dg ON dg.document_type_id = d.type_id - WHERE d.region = :region + WHERE ( + d.tenant_code = :tenant_code + OR ( + (:tenant_code = '') + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') + AND d.region = :region + ) + ) AND d.normalized_name = :normalized_name AND d.is_latest_version = true AND d.deleted_at IS NULL{ext_clause} @@ -3866,6 +4270,7 @@ async def _find_latest_version_candidate( params = { "type_id": type_id, + "tenant_code": str(tenant_code or "").strip(), "region": region, "normalized_name": normalized_name, **ext_params, @@ -3886,7 +4291,14 @@ async def _find_latest_version_candidate( AND f.is_active = true AND f.file_role = 'primary' WHERE d.type_id = :type_id - AND d.region = :region + AND ( + d.tenant_code = :tenant_code + OR ( + (:tenant_code = '') + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') + AND d.region = :region + ) + ) AND d.normalized_name = :normalized_name{ext_clause} AND d.is_latest_version = true AND d.deleted_at IS NULL diff --git a/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py index 8e3eb9c..ea926f9 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py @@ -2,25 +2,33 @@ from __future__ import annotations +import json from datetime import datetime from pathlib import Path +from typing import Any -from sqlalchemy import text +from sqlalchemy import bindparam, text from fastapi_admin.config import OSS_BASE_URL, OSS_BUCKET from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException -from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import EntryModuleCreateDTO, EntryModuleUpdateDTO +from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import ( + EntryModuleAreaDTO, + EntryModuleCreateDTO, + EntryModuleTenantDTO, + EntryModuleUpdateDTO, +) from fastapi_modules.fastapi_leaudit.domian.vo.entryModuleAdminVo import ( - EntryModuleAreaVO, EntryModuleImageUploadVO, EntryModuleListVO, + EntryModuleTenantVO, EntryModuleVO, ) from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver class EntryModuleAdminServiceImpl(IEntryModuleAdminService): @@ -28,43 +36,128 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): def __init__(self) -> None: self.OssService = OssServiceImpl() + self.TenantResolver = TenantResolver() + self._tenant_table_exists_cache: bool | None = None + self._entry_module_tenant_table_exists_cache: bool | None = None - async def ListModules(self, Name: str | None, Area: str | None, Page: int, PageSize: int) -> EntryModuleListVO: + async def ListModules( + self, + Name: str | None, + Area: str | None, + TenantCode: str | None, + Page: int, + PageSize: int, + ) -> EntryModuleListVO: """分页查询入口模块。""" offset = max(Page - 1, 0) * PageSize - filters = ["deleted_at IS NULL"] + filters = ["em.deleted_at IS NULL"] params: dict[str, object] = {"limit": PageSize, "offset": offset} if Name: - filters.append("name ILIKE :name") + filters.append("em.name ILIKE :name") params["name"] = f"%{Name.strip()}%" - if Area: - filters.append( - "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(areas, '[]'::jsonb)) AS area_item WHERE area_item->>'area' = :area)" - ) - params["area"] = Area.strip() + resolved_filter_tenant_code = await self._resolveFilterTenantCode(Area=Area, TenantCode=TenantCode) + has_tenant_mapping_table = await self._entry_module_tenant_table_exists() + if resolved_filter_tenant_code: + legacy_area = (Area or "").strip() + if not legacy_area: + tenant_name_map = await self._loadTenantNameMap([resolved_filter_tenant_code]) + legacy_area = tenant_name_map.get(resolved_filter_tenant_code, "") + if has_tenant_mapping_table: + filters.append( + """ + ( + EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = em.id + AND emt.deleted_at IS NULL + AND emt.tenant_code = :tenant_code + ) + OR ( + NOT EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt0 + WHERE emt0.entry_module_id = em.id + AND emt0.deleted_at IS NULL + ) + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' = :legacy_area + ) + ) + ) + """ + ) + params["tenant_code"] = resolved_filter_tenant_code + else: + filters.append( + """ + EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' = :legacy_area + ) + """ + ) + params["legacy_area"] = legacy_area - whereClause = " AND ".join(filters) + where_clause = " AND ".join(filters) + tenant_select_sql = ( + """ + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'tenant_code', emt.tenant_code, + 'tenant_name', emt.tenant_name, + 'enabled', emt.is_enabled, + 'sort_order', emt.sort_order + ) + ORDER BY emt.sort_order ASC, emt.id ASC + ) + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = em.id + AND emt.deleted_at IS NULL + ), + '[]'::jsonb + ) AS tenants + """ + if has_tenant_mapping_table + else "'[]'::jsonb AS tenants" + ) - async with GetAsyncSession() as Session: + async with GetAsyncSession() as session: total = int( ( - await Session.execute( - text(f"SELECT COUNT(*) FROM leaudit_entry_modules WHERE {whereClause}"), + await session.execute( + text(f"SELECT COUNT(*) FROM leaudit_entry_modules em WHERE {where_clause}"), params, ) ).scalar_one() ) rows = ( - await Session.execute( + await session.execute( text( f""" - SELECT id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at - FROM leaudit_entry_modules - WHERE {whereClause} - ORDER BY sort_order ASC, id ASC + SELECT + em.id, + em.name, + em.description, + em.path, + em.icon_path, + em.areas, + em.sort_order, + em.is_enabled, + em.created_at, + em.updated_at, + {tenant_select_sql} + FROM leaudit_entry_modules em + WHERE {where_clause} + ORDER BY em.sort_order ASC, em.id ASC LIMIT :limit OFFSET :offset """ ), @@ -72,25 +165,28 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): ) ).mappings().all() - return EntryModuleListVO( - total=total, - page=Page, - page_size=PageSize, - items=[self._toModuleVo(row) for row in rows], - ) + items = [await self._toModuleVo(row) for row in rows] + return EntryModuleListVO(total=total, page=Page, page_size=PageSize, items=items) async def GetModule(self, ModuleId: int) -> EntryModuleVO: """获取入口模块详情。""" row = await self._getModuleRow(ModuleId) - return self._toModuleVo(row) + return await self._toModuleVo(row) async def CreateModule(self, Body: EntryModuleCreateDTO) -> EntryModuleVO: """创建入口模块。""" route_path = (Body.route_path if Body.route_path is not None else Body.path or "").strip() or None - async with GetAsyncSession() as Session: + normalized_tenants = await self._normalizeTenants( + Tenants=Body.tenants, + Areas=Body.areas, + ) + self._ensureTenantAssignments(normalized_tenants) + legacy_areas_json = self._legacyAreasJson(normalized_tenants) + + async with GetAsyncSession() as session: try: row = ( - await Session.execute( + await session.execute( text( """ INSERT INTO leaudit_entry_modules ( @@ -98,7 +194,7 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): ) VALUES ( :name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL ) - RETURNING id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + RETURNING id """ ), { @@ -106,24 +202,34 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): "description": (Body.description or "").strip() or None, "route_path": route_path, "icon_path": None, - "areas": self._areasJson(Body.areas), - "sort_order": await self._nextSortOrder(Session), + "areas": legacy_areas_json, + "sort_order": await self._nextSortOrder(session), }, ) ).mappings().one() - await Session.commit() + module_id = int(row["id"]) + await self._syncModuleTenants(session, module_id, normalized_tenants) + await session.commit() except Exception as exc: - await Session.rollback() + await session.rollback() raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"创建入口模块失败: {exc}") from exc - return self._toModuleVo(row) + + return await self.GetModule(module_id) async def UpdateModule(self, ModuleId: int, Body: EntryModuleUpdateDTO) -> EntryModuleVO: """更新入口模块。""" current = await self._getModuleRow(ModuleId) incoming_route_path = Body.route_path if Body.route_path is not None else Body.path - async with GetAsyncSession() as Session: + + if Body.tenants is not None or Body.areas is not None: + normalized_tenants = await self._normalizeTenants(Tenants=Body.tenants, Areas=Body.areas) + else: + normalized_tenants = await self._extractTenantsFromRow(current) + self._ensureTenantAssignments(normalized_tenants) + + async with GetAsyncSession() as session: row = ( - await Session.execute( + await session.execute( text( """ UPDATE leaudit_entry_modules @@ -135,28 +241,31 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): updated_at = NOW() WHERE id = :module_id AND deleted_at IS NULL - RETURNING id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + RETURNING id """ ), { "module_id": ModuleId, "name": Body.name.strip() if Body.name is not None else current["name"], - "description": (Body.description.strip() if Body.description is not None else current["description"]), - "route_path": (incoming_route_path.strip() if incoming_route_path is not None else current["path"]), - "areas": self._areasJson(Body.areas) if Body.areas is not None else self._areasJson(current["areas"]), + "description": Body.description.strip() if Body.description is not None else current.get("description"), + "route_path": incoming_route_path.strip() if incoming_route_path is not None else current.get("path"), + "areas": self._legacyAreasJson(normalized_tenants), }, ) ).mappings().first() if not row: - await Session.rollback() + await session.rollback() raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "入口模块不存在") - await Session.commit() - return self._toModuleVo(row) + + await self._syncModuleTenants(session, ModuleId, normalized_tenants) + await session.commit() + + return await self.GetModule(ModuleId) async def DeleteModule(self, ModuleId: int) -> None: """删除入口模块。""" - async with GetAsyncSession() as Session: - await Session.execute( + async with GetAsyncSession() as session: + await session.execute( text( """ UPDATE leaudit_entry_modules @@ -166,17 +275,28 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): ), {"module_id": ModuleId}, ) - await Session.commit() + if await self._entry_module_tenant_table_exists(): + await session.execute( + text( + """ + UPDATE leaudit_entry_module_tenants + SET deleted_at = NOW(), updated_at = NOW() + WHERE entry_module_id = :module_id AND deleted_at IS NULL + """ + ), + {"module_id": ModuleId}, + ) + await session.commit() async def UploadModuleImage(self, ModuleId: int, FileName: str, ContentType: str, Content: bytes) -> EntryModuleImageUploadVO: """上传入口模块图标。""" module = await self._getModuleRow(ModuleId) suffix = Path(FileName).suffix.lower() or ".png" - objectKey = f"documents/mz/static/img/entry_module_{ModuleId}{suffix}" - await self.OssService.UploadBytes(ObjectKey=objectKey, Content=Content, ContentType=ContentType or "application/octet-stream") + object_key = f"documents/mz/static/img/entry_module_{ModuleId}{suffix}" + await self.OssService.UploadBytes(ObjectKey=object_key, Content=Content, ContentType=ContentType or "application/octet-stream") - async with GetAsyncSession() as Session: - await Session.execute( + async with GetAsyncSession() as session: + await session.execute( text( """ UPDATE leaudit_entry_modules @@ -184,27 +304,63 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): WHERE id = :module_id AND deleted_at IS NULL """ ), - {"module_id": ModuleId, "icon_path": objectKey}, + {"module_id": ModuleId, "icon_path": object_key}, ) - await Session.commit() + await session.commit() return EntryModuleImageUploadVO( module_id=ModuleId, - path=objectKey, - url=f"{OSS_BASE_URL.rstrip('/')}/{OSS_BUCKET}/{objectKey}", + path=object_key, + url=f"{OSS_BASE_URL.rstrip('/')}/{OSS_BUCKET}/{object_key}", message=f"入口模块 {module['name']} 图标上传成功", ) async def _getModuleRow(self, ModuleId: int): """查询入口模块原始记录。""" - async with GetAsyncSession() as Session: + has_tenant_mapping_table = await self._entry_module_tenant_table_exists() + tenant_select_sql = ( + """ + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'tenant_code', emt.tenant_code, + 'tenant_name', emt.tenant_name, + 'enabled', emt.is_enabled, + 'sort_order', emt.sort_order + ) + ORDER BY emt.sort_order ASC, emt.id ASC + ) + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = em.id + AND emt.deleted_at IS NULL + ), + '[]'::jsonb + ) AS tenants + """ + if has_tenant_mapping_table + else "'[]'::jsonb AS tenants" + ) + async with GetAsyncSession() as session: row = ( - await Session.execute( + await session.execute( text( - """ - SELECT id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at - FROM leaudit_entry_modules - WHERE id = :module_id AND deleted_at IS NULL + f""" + SELECT + em.id, + em.name, + em.description, + em.path, + em.icon_path, + em.areas, + em.sort_order, + em.is_enabled, + em.created_at, + em.updated_at, + {tenant_select_sql} + FROM leaudit_entry_modules em + WHERE em.id = :module_id + AND em.deleted_at IS NULL """ ), {"module_id": ModuleId}, @@ -216,24 +372,14 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): async def _nextSortOrder(self, Session) -> int: """获取下一个排序号。""" - maxSort = ( + max_sort = ( await Session.execute(text("SELECT COALESCE(MAX(sort_order), 0) FROM leaudit_entry_modules WHERE deleted_at IS NULL")) ).scalar_one() - return int(maxSort or 0) + 10 + return int(max_sort or 0) + 10 - def _toModuleVo(self, Row) -> EntryModuleVO: + async def _toModuleVo(self, Row) -> EntryModuleVO: """把数据库记录转换为 VO。""" - rawAreas = Row.get("areas") or [] - areas = [ - EntryModuleAreaVO( - area=str(item.get("area") or ""), - enabled=bool(item.get("enabled", False)), - sort_order=int(item.get("sort_order", 0)), - ) - for item in rawAreas - if isinstance(item, dict) and item.get("area") - ] - areas.sort(key=lambda item: (item.sort_order, item.area)) + tenants = await self._extractTenantsFromRow(Row) return EntryModuleVO( id=int(Row["id"]), name=str(Row["name"] or ""), @@ -242,38 +388,325 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService): route_path=Row.get("path"), sort_order=int(Row.get("sort_order") or 0), is_enabled=bool(Row.get("is_enabled", True)), - areas=areas, + tenants=tenants, created_at=self._toIso(Row.get("created_at")), updated_at=self._toIso(Row.get("updated_at")), ) - def _areasJson(self, Areas) -> str: - """序列化地区配置。""" - import json + async def _extractTenantsFromRow(self, Row) -> list[EntryModuleTenantVO]: + raw_tenants = Row.get("tenants") or [] + normalized: list[EntryModuleTenantVO] = [] + if isinstance(raw_tenants, list) and raw_tenants: + for item in raw_tenants: + if not isinstance(item, dict) or not item.get("tenant_code"): + continue + normalized.append( + EntryModuleTenantVO( + tenant_code=str(item["tenant_code"]), + tenant_name=item.get("tenant_name"), + enabled=bool(item.get("enabled", True)), + sort_order=int(item.get("sort_order", 0)), + ) + ) + normalized.sort(key=lambda item: (item.sort_order, item.tenant_code)) + if normalized: + return normalized - if not Areas: - return "[]" + raw_areas = Row.get("areas") or [] + if not isinstance(raw_areas, list): + return [] - normalized: list[dict[str, object]] = [] - for index, item in enumerate(Areas, start=1): - if hasattr(item, "model_dump"): - payload = item.model_dump() - elif isinstance(item, dict): - payload = item - else: + fallback_tenants: list[EntryModuleTenantVO] = [] + for index, item in enumerate(raw_areas, start=1): + if not isinstance(item, dict) or not item.get("area"): continue - if not payload.get("area"): - continue - normalized.append( - { - "area": str(payload["area"]), - "enabled": bool(payload.get("enabled", True)), - "sort_order": int(payload.get("sort_order", index)), - } + resolution = await self._resolveLegacyTenantValue( + RawValue=str(item.get("area") or ""), + Source="entry_module_legacy_area", + ) + fallback_tenants.append( + EntryModuleTenantVO( + tenant_code=resolution.tenant_code, + tenant_name=resolution.tenant_name, + enabled=bool(item.get("enabled", True)), + sort_order=int(item.get("sort_order", index)), + ) ) - return json.dumps(normalized, ensure_ascii=False) - def _toIso(self, Value) -> str | None: + unique: dict[str, EntryModuleTenantVO] = {} + for item in fallback_tenants: + unique[item.tenant_code] = item + return sorted(unique.values(), key=lambda item: (item.sort_order, item.tenant_code)) + + async def _normalizeTenants( + self, + *, + Tenants: list[EntryModuleTenantDTO] | None, + Areas: list[EntryModuleAreaDTO] | None, + ) -> list[dict[str, Any]]: + if Tenants is not None: + return await self._normalizeTenantDtos(Tenants) + if Areas is not None: + # 仅兼容旧请求体,优先要求前端使用 tenants。 + return await self._normalizeAreas(Areas) + return [] + + async def _normalizeTenantDtos(self, Tenants: list[EntryModuleTenantDTO]) -> list[dict[str, Any]]: + if not Tenants: + return [] + + tenant_codes = [str(item.tenant_code).strip() for item in Tenants if str(item.tenant_code).strip()] + tenant_name_map = await self._loadTenantNameMap(tenant_codes) + + normalized: dict[str, dict[str, Any]] = {} + for index, item in enumerate(Tenants, start=1): + tenant_code = str(item.tenant_code).strip() + if not tenant_code: + continue + if tenant_code not in tenant_name_map: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"未知租户编码: {tenant_code}") + normalized[tenant_code] = { + "tenant_code": tenant_code, + "tenant_name": str(item.tenant_name or tenant_name_map[tenant_code] or "").strip() or tenant_name_map[tenant_code], + "enabled": bool(item.enabled), + "sort_order": int(item.sort_order or index), + } + return sorted(normalized.values(), key=lambda item: (int(item["sort_order"]), str(item["tenant_code"]))) + + async def _normalizeAreas(self, Areas: list[EntryModuleAreaDTO]) -> list[dict[str, Any]]: + if not Areas: + return [] + + normalized: dict[str, dict[str, Any]] = {} + for index, item in enumerate(Areas, start=1): + area_name = str(item.area or "").strip() + if not area_name: + continue + resolution = await self._resolveLegacyTenantValue( + RawValue=area_name, + Source="entry_module_area_payload", + ) + normalized[resolution.tenant_code] = { + "tenant_code": resolution.tenant_code, + "tenant_name": resolution.tenant_name or area_name, + "enabled": bool(item.enabled), + "sort_order": int(item.sort_order or index), + } + return sorted(normalized.values(), key=lambda item: (int(item["sort_order"]), str(item["tenant_code"]))) + + async def _loadTenantNameMap(self, TenantCodes: list[str]) -> dict[str, str]: + tenant_codes = [code for code in {str(item).strip() for item in TenantCodes} if code] + if not tenant_codes: + return {} + if not await self._tenant_table_exists(): + return { + code: ("公共" if code == "PUBLIC" else "省局" if code == "PROVINCIAL" else code) + for code in tenant_codes + } + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + """ + SELECT tenant_code, tenant_name + FROM sys_tenants + WHERE tenant_code IN :tenant_codes + AND deleted_at IS NULL + AND is_enabled = TRUE + """ + ).bindparams(bindparam("tenant_codes", expanding=True)), + {"tenant_codes": tenant_codes}, + ) + ).mappings().all() + return {str(row["tenant_code"]): str(row["tenant_name"] or "") for row in rows} + + async def _tenant_table_exists(self) -> bool: + """兼容旧环境未落 sys_tenants 时的入口模块读写。""" + if self._tenant_table_exists_cache is not None: + return self._tenant_table_exists_cache + async with GetAsyncSession() as session: + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'sys_tenants' + ) + """ + ) + ) + ).scalar_one() + ) + self._tenant_table_exists_cache = exists + return exists + + async def _entry_module_tenant_table_exists(self) -> bool: + """兼容旧环境未落入口模块租户映射表时的读写。""" + if self._entry_module_tenant_table_exists_cache is not None: + return self._entry_module_tenant_table_exists_cache + async with GetAsyncSession() as session: + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'leaudit_entry_module_tenants' + ) + """ + ) + ) + ).scalar_one() + ) + self._entry_module_tenant_table_exists_cache = exists + return exists + + async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution: + resolution = await self.TenantResolver.Resolve( + RawValue=RawValue, + Source=Source, + ) + if resolution.tenant_code: + return resolution + + normalized = str(RawValue or "").strip() + if normalized in {"", "default", "省级", "公共"}: + public_resolution = await self.TenantResolver.Resolve( + RawValue="", + Source=Source, + ) + if public_resolution.tenant_code: + return public_resolution + return TenantResolution( + tenant_code="PUBLIC", + tenant_name="公共", + tenant_type="PUBLIC", + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=True, + ) + if normalized == "省局": + return TenantResolution( + tenant_code="PROVINCIAL", + tenant_name="省局", + tenant_type="PROVINCIAL", + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=False, + ) + return TenantResolution( + tenant_code=normalized or None, + tenant_name=normalized or None, + tenant_type="LEGACY_AREA" if normalized else None, + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=False, + ) + + async def _syncModuleTenants(self, Session, ModuleId: int, Tenants: list[dict[str, Any]]) -> None: + if not await self._entry_module_tenant_table_exists(): + return + tenant_codes = [str(item["tenant_code"]) for item in Tenants if item.get("tenant_code")] + + if tenant_codes: + await Session.execute( + text( + """ + UPDATE leaudit_entry_module_tenants + SET deleted_at = NOW(), updated_at = NOW() + WHERE entry_module_id = :module_id + AND deleted_at IS NULL + AND tenant_code NOT IN :tenant_codes + """ + ).bindparams(bindparam("tenant_codes", expanding=True)), + {"module_id": ModuleId, "tenant_codes": tenant_codes}, + ) + else: + await Session.execute( + text( + """ + UPDATE leaudit_entry_module_tenants + SET deleted_at = NOW(), updated_at = NOW() + WHERE entry_module_id = :module_id + AND deleted_at IS NULL + """ + ), + {"module_id": ModuleId}, + ) + + for item in Tenants: + await Session.execute( + text( + """ + INSERT INTO leaudit_entry_module_tenants ( + entry_module_id, tenant_code, tenant_name, is_enabled, sort_order, created_at, updated_at, deleted_at + ) VALUES ( + :module_id, :tenant_code, :tenant_name, :is_enabled, :sort_order, NOW(), NOW(), NULL + ) + ON CONFLICT (entry_module_id, tenant_code) + DO UPDATE SET + tenant_name = EXCLUDED.tenant_name, + is_enabled = EXCLUDED.is_enabled, + sort_order = EXCLUDED.sort_order, + updated_at = NOW(), + deleted_at = NULL + """ + ), + { + "module_id": ModuleId, + "tenant_code": item["tenant_code"], + "tenant_name": item.get("tenant_name"), + "is_enabled": bool(item.get("enabled", True)), + "sort_order": int(item.get("sort_order", 0)), + }, + ) + + async def _resolveFilterTenantCode(self, *, Area: str | None, TenantCode: str | None) -> str | None: + if TenantCode and TenantCode.strip(): + return TenantCode.strip() + if Area and Area.strip(): + resolution = await self._resolveLegacyTenantValue( + RawValue=Area.strip(), + Source="entry_module_filter_area", + ) + return resolution.tenant_code + return None + + @staticmethod + def _legacyAreasJson(Tenants: list[dict[str, Any]]) -> str: + # 继续回写 areas 仅用于兼容旧读链路,主配置来源是 leaudit_entry_module_tenants。 + areas = [ + { + "area": str(item.get("tenant_name") or item.get("tenant_code") or ""), + "enabled": bool(item.get("enabled", True)), + "sort_order": int(item.get("sort_order", 0)), + } + for item in Tenants + if item.get("tenant_code") + ] + return json.dumps(areas, ensure_ascii=False) + + @staticmethod + def _ensureTenantAssignments(Tenants: list[dict[str, Any]]) -> None: + if Tenants: + return + raise LeauditException( + StatusCodeEnum.HTTP_400_BAD_REQUEST, + "入口模块至少需要配置一个适用租户", + ) + + @staticmethod + def _toIso(Value) -> str | None: """时间转 ISO 字符串。""" if Value is None: return None diff --git a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py index a42b46d..b896bfe 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py @@ -40,6 +40,9 @@ from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import ( sync_doc_type_bindings_from_group, ) from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantScope import normalize_scoped_tenant_code +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): @@ -47,6 +50,8 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): def __init__(self) -> None: self.RuleService = GetRuleServiceSingleton() + self.TenantResolver = TenantResolver() + self._entry_module_tenant_table_exists_cache: bool | None = None async def ListGroups( self, @@ -56,12 +61,29 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): Pid: int | None, Page: int, PageSize: int, + CurrentUserId: int, ) -> EvaluationPointGroupListVO: async with GetAsyncSession() as session: await self._ensure_ready(session) + current_user = await self._get_current_user_context(session, CurrentUserId) offset = max(Page - 1, 0) * PageSize filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"] params: dict[str, Any] = {"limit": PageSize, "offset": offset} + from_clause = """ + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) + """ + filters.append( + await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="group_scope", + ) + ) if Name: filters.append("g.name ILIKE :name") @@ -80,7 +102,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): total = int( ( await session.execute( - text(f"SELECT COUNT(*) FROM leaudit_evaluation_point_groups g WHERE {where_clause}"), + text(f"SELECT COUNT(*) {from_clause} WHERE {where_clause}"), params, ) ).scalar_one() @@ -104,10 +126,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): g.created_at, g.updated_at, COALESCE(bg.binding_count, 0) AS rule_count - FROM leaudit_evaluation_point_groups g - LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id - LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL - LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) + {from_clause} LEFT JOIN ( SELECT group_id, COUNT(*)::int AS binding_count FROM leaudit_rule_group_bindings @@ -122,7 +141,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): params, ) ).mappings().all() - binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows]) + binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows], current_user=current_user) return EvaluationPointGroupListVO( data=[self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=True) for row in rows], total=total, @@ -130,10 +149,21 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): page_size=PageSize, ) - async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool) -> list[EvaluationPointGroupVO]: + async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool, CurrentUserId: int) -> list[EvaluationPointGroupVO]: async with GetAsyncSession() as session: await self._ensure_ready(session) + current_user = await self._get_current_user_context(session, CurrentUserId) filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"] + params: dict[str, Any] = {} + filters.append( + await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="group_scope", + ) + ) if not IncludeDisabled: filters.append("g.is_enabled = TRUE") rows = ( @@ -168,10 +198,11 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): WHERE {' AND '.join(filters)} ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC """ - ) + ), + params, ) ).mappings().all() - binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows]) + binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows], current_user=current_user) groups = [self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=WithRuleCount) for row in rows] by_parent: dict[int, list[EvaluationPointGroupVO]] = {} @@ -193,11 +224,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): DocumentTypeIds: list[int], IncludeDisabled: bool, WithRuleCount: bool, + CurrentUserId: int, ) -> list[EvaluationPointGroupVO]: normalized_ids = sorted({int(item) for item in DocumentTypeIds if item}) if not normalized_ids: return [] - roots = await self.ListAllGroups(IncludeDisabled=IncludeDisabled, WithRuleCount=WithRuleCount) + roots = await self.ListAllGroups( + IncludeDisabled=IncludeDisabled, + WithRuleCount=WithRuleCount, + CurrentUserId=CurrentUserId, + ) result: list[EvaluationPointGroupVO] = [] for root in roots: if root.document_type_id in normalized_ids: @@ -208,29 +244,41 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): result.append(root) return result - async def GetGroup(self, GroupId: int, WithRuleCount: bool) -> EvaluationPointGroupVO: + async def GetGroup(self, GroupId: int, WithRuleCount: bool, CurrentUserId: int) -> EvaluationPointGroupVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - row = await self._get_group_row(session, GroupId) - binding_map = await self._load_binding_map(session, [GroupId]) + current_user = await self._get_current_user_context(session, CurrentUserId) + row = await self._get_group_row(session, GroupId, current_user) + binding_map = await self._load_binding_map(session, [GroupId], current_user=current_user) return self._to_group_vo(row, binding_map.get(GroupId, []), include_rule_count=WithRuleCount) - async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int) -> EvaluationPointGroupListVO: - await self.GetGroup(GroupId, WithRuleCount=False) - return await self.ListGroups(Name=None, Code=None, IsEnabled=IsEnabled, Pid=GroupId, Page=Page, PageSize=PageSize) + async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int, CurrentUserId: int) -> EvaluationPointGroupListVO: + await self.GetGroup(GroupId, WithRuleCount=False, CurrentUserId=CurrentUserId) + return await self.ListGroups( + Name=None, + Code=None, + IsEnabled=IsEnabled, + Pid=GroupId, + Page=Page, + PageSize=PageSize, + CurrentUserId=CurrentUserId, + ) - async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO) -> EvaluationPointGroupVO: + async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO, CurrentUserId: int) -> EvaluationPointGroupVO: payload = self._normalize_create_payload(Body) async with GetAsyncSession() as session: await self._ensure_ready(session) + current_user = await self._get_current_user_context(session, CurrentUserId) await self._ensure_code_unique(session, payload["code"], None) - parent = await self._ensure_parent_valid(session, payload["pid"]) + parent = await self._ensure_parent_valid(session, payload["pid"], current_user) payload["entry_module_id"] = await self._ensure_entry_module_valid( - session, payload["pid"], payload["entry_module_id"], parent + session, payload["pid"], payload["entry_module_id"], parent, current_user ) payload["document_type_id"] = await self._ensure_document_type_valid( - session, payload["pid"], payload["document_type_id"], payload["entry_module_id"], None, parent + session, payload["pid"], payload["document_type_id"], payload["entry_module_id"], None, parent, current_user ) + if not current_user["is_global"] and payload["pid"] == 0 and payload["entry_module_id"] is None and payload["document_type_id"] is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前租户创建一级分组时必须绑定入口模块或文档类型") row = ( await session.execute( text( @@ -248,12 +296,13 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): ).mappings().one() await session.commit() group_id = int(row["id"]) - return await self.GetGroup(group_id, WithRuleCount=True) + return await self.GetGroup(group_id, WithRuleCount=True, CurrentUserId=CurrentUserId) - async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO) -> EvaluationPointGroupVO: + async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO, CurrentUserId: int) -> EvaluationPointGroupVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - current = await self._get_group_row(session, GroupId) + current_user = await self._get_current_user_context(session, CurrentUserId) + current = await self._get_group_row(session, GroupId, current_user) provided_fields = set(getattr(Body, "model_fields_set", set())) next_pid = self._normalize_pid(Body.pid) if Body.pid is not None else self._normalize_pid(current["pid"]) name = (Body.name.strip() if Body.name is not None else str(current.get("name") or "")).strip() @@ -270,11 +319,13 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码不能为空") await self._ensure_code_unique(session, code, GroupId) - parent = await self._ensure_parent_valid(session, next_pid) - entry_module_id = await self._ensure_entry_module_valid(session, next_pid, entry_module_id, parent) + parent = await self._ensure_parent_valid(session, next_pid, current_user) + entry_module_id = await self._ensure_entry_module_valid(session, next_pid, entry_module_id, parent, current_user) document_type_id = await self._ensure_document_type_valid( - session, next_pid, document_type_id, entry_module_id, GroupId, parent + session, next_pid, document_type_id, entry_module_id, GroupId, parent, current_user ) + if not current_user["is_global"] and next_pid == 0 and entry_module_id is None and document_type_id is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前租户的一级分组必须绑定入口模块或文档类型") await session.execute( text( """ @@ -305,12 +356,13 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): ) await sync_doc_type_bindings_from_group(session, GroupId) await session.commit() - return await self.GetGroup(GroupId, WithRuleCount=True) + return await self.GetGroup(GroupId, WithRuleCount=True, CurrentUserId=CurrentUserId) - async def DeleteGroup(self, GroupId: int) -> EvaluationPointGroupDeleteVO: + async def DeleteGroup(self, GroupId: int, CurrentUserId: int) -> EvaluationPointGroupDeleteVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - current = await self._get_group_row(session, GroupId) + current_user = await self._get_current_user_context(session, CurrentUserId) + current = await self._get_group_row(session, GroupId, current_user) is_root = self._normalize_pid(current["pid"]) == 0 if is_root: child_count = int( @@ -364,11 +416,12 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): deleted_points=binding_count, ) - async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO) -> EvaluationPointGroupRebindVO: + async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO, CurrentUserId: int) -> EvaluationPointGroupRebindVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - current = await self._get_group_row(session, GroupId) - target = await self._get_group_row(session, Body.new_parent_id) + current_user = await self._get_current_user_context(session, CurrentUserId) + current = await self._get_group_row(session, GroupId, current_user) + target = await self._get_group_row(session, Body.new_parent_id, current_user) if self._normalize_pid(current["pid"]) != 0 or self._normalize_pid(target["pid"]) != 0: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "仅支持一级分组之间迁移二级分组") if GroupId == Body.new_parent_id: @@ -388,12 +441,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): moved = int(result.rowcount or 0) return EvaluationPointGroupRebindVO(success=True, message="二级分组迁移成功", rebind_count=moved, doc_types_updated=moved) - async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO) -> EvaluationPointGroupBatchStatusVO: + async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO, CurrentUserId: int) -> EvaluationPointGroupBatchStatusVO: ids = sorted({int(item) for item in Body.ids if item}) if not ids: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组") async with GetAsyncSession() as session: await self._ensure_ready(session) + current_user = await self._get_current_user_context(session, CurrentUserId) + accessible_rows = await self._list_accessible_group_rows(session, ids, current_user) + if len(accessible_rows) != len(ids): + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "部分分组不存在或当前租户不可访问") result = await session.execute( text( """ @@ -409,22 +466,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): updated_count = int(result.rowcount or 0) return EvaluationPointGroupBatchStatusVO(success=True, updated_count=updated_count, message=f"成功更新 {updated_count} 个分组状态") - async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO) -> EvaluationPointGroupBatchDeleteVO: + async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO, CurrentUserId: int) -> EvaluationPointGroupBatchDeleteVO: ids = sorted({int(item) for item in Body.ids if item}) if not ids: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组") async with GetAsyncSession() as session: await self._ensure_ready(session) - rows = ( - await session.execute( - text( - "SELECT id, pid, document_type_id FROM leaudit_evaluation_point_groups WHERE id IN :ids AND deleted_at IS NULL" - ).bindparams(bindparam("ids", expanding=True)), - {"ids": ids}, - ) - ).mappings().all() + current_user = await self._get_current_user_context(session, CurrentUserId) + rows = await self._list_accessible_group_rows(session, ids, current_user) if len(rows) != len(ids): - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "部分分组不存在") + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "部分分组不存在或当前租户不可访问") if any(self._normalize_pid(row["pid"]) == 0 for row in rows): raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "批量删除仅支持二级分组") @@ -452,19 +503,33 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): deleted_groups = int(result.rowcount or 0) return EvaluationPointGroupBatchDeleteVO(success=True, deleted_groups=deleted_groups, deleted_points=deleted_bindings, message=f"成功删除 {deleted_groups} 个分组") - async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO) -> RuleGroupBindingVO: + async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO, CurrentUserId: int) -> RuleGroupBindingVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - group = await self._get_group_row(session, GroupId) + current_user = await self._get_current_user_context(session, CurrentUserId) + group = await self._get_group_row(session, GroupId, current_user) if self._normalize_pid(group["pid"]) == 0: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "一级分组不能直接绑定规则集,请先选择二级分组") - await self._ensure_rule_set_valid(session, Body.rule_set_id) + rule_set_meta = await self._ensure_rule_set_valid(session, Body.rule_set_id, current_user) + scope_payload = self._build_binding_scope_payload(current_user=current_user, rule_set_meta=rule_set_meta) existing = ( await session.execute( text( - "SELECT id FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND rule_set_id = :rule_set_id AND deleted_at IS NULL LIMIT 1" + """ + SELECT id + FROM leaudit_rule_group_bindings + WHERE group_id = :group_id + AND rule_set_id = :rule_set_id + AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') = :tenant_code + AND deleted_at IS NULL + LIMIT 1 + """ ), - {"group_id": GroupId, "rule_set_id": Body.rule_set_id}, + { + "group_id": GroupId, + "rule_set_id": Body.rule_set_id, + "tenant_code": scope_payload["tenant_code"], + }, ) ).mappings().first() if existing: @@ -474,9 +539,9 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): text( """ INSERT INTO leaudit_rule_group_bindings ( - group_id, rule_set_id, priority, is_active, note, created_at, updated_at + group_id, rule_set_id, tenant_code, scope_type, tenant_name_snapshot, priority, is_active, note, created_at, updated_at ) VALUES ( - :group_id, :rule_set_id, :priority, :is_active, :note, NOW(), NOW() + :group_id, :rule_set_id, :tenant_code, :scope_type, :tenant_name_snapshot, :priority, :is_active, :note, NOW(), NOW() ) RETURNING id """ @@ -484,6 +549,9 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): { "group_id": GroupId, "rule_set_id": Body.rule_set_id, + "tenant_code": scope_payload["tenant_code"], + "scope_type": scope_payload["scope_type"], + "tenant_name_snapshot": scope_payload["tenant_name_snapshot"], "priority": Body.priority, "is_active": Body.is_active, "note": Body.note.strip() if Body.note else None, @@ -493,14 +561,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): await sync_doc_type_bindings_from_group(session, GroupId) await session.commit() binding_id = int(row["id"]) - binding_row = await self._get_binding_row(session, binding_id) - binding_vo = await self._build_binding_vo(binding_row) + binding_row = await self._get_binding_row(session, binding_id, current_user) + binding_vo = await self._build_binding_vo(binding_row, current_user=current_user) return binding_vo - async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO) -> RuleGroupBindingVO: + async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO, CurrentUserId: int) -> RuleGroupBindingVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - current = await self._get_binding_row(session, BindingId) + current_user = await self._get_current_user_context(session, CurrentUserId) + current = await self._get_binding_row(session, BindingId, current_user) + self._assert_binding_mutable_by_user(current, current_user) await session.execute( text( """ @@ -521,14 +591,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): ) await sync_doc_type_bindings_from_group(session, int(current["group_id"])) await session.commit() - binding_row = await self._get_binding_row(session, BindingId) - binding_vo = await self._build_binding_vo(binding_row) + binding_row = await self._get_binding_row(session, BindingId, current_user) + binding_vo = await self._build_binding_vo(binding_row, current_user=current_user) return binding_vo - async def DeleteBinding(self, BindingId: int) -> None: + async def DeleteBinding(self, BindingId: int, CurrentUserId: int) -> None: async with GetAsyncSession() as session: await self._ensure_ready(session) - current = await self._get_binding_row(session, BindingId) + current_user = await self._get_current_user_context(session, CurrentUserId) + current = await self._get_binding_row(session, BindingId, current_user) + self._assert_binding_mutable_by_user(current, current_user) await session.execute( text( "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE id = :binding_id" @@ -538,10 +610,11 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): await sync_doc_type_bindings_from_group(session, int(current["group_id"])) await session.commit() - async def GetRuleTemplate(self, GroupId: int) -> EvaluationPointGroupRuleTemplateVO: + async def GetRuleTemplate(self, GroupId: int, CurrentUserId: int) -> EvaluationPointGroupRuleTemplateVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - context = await self._load_rule_context(session, GroupId) + current_user = await self._get_current_user_context(session, CurrentUserId) + context = await self._load_rule_context(session, GroupId, current_user) template = self._build_rule_yaml_template(context) existing_binding_id = await self._get_group_binding_id_for_rule_type(session, GroupId, context["rule_type"]) return EvaluationPointGroupRuleTemplateVO( @@ -567,10 +640,11 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): existingBindingId=existing_binding_id, ) - async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO) -> EvaluationPointGroupRuleDraftVO: + async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO, CurrentUserId: int) -> EvaluationPointGroupRuleDraftVO: async with GetAsyncSession() as session: await self._ensure_ready(session) - context = await self._load_rule_context(session, GroupId) + current_user = await self._get_current_user_context(session, CurrentUserId) + context = await self._load_rule_context(session, GroupId, current_user) yaml_text = Body.yaml_text.strip() if not yaml_text: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "YAML 内容不能为空") @@ -580,18 +654,25 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): YamlText=yaml_text, ChangeNote=Body.change_note.strip() if Body.change_note else None, EditorUserId=Body.editor_user_id, + CurrentUserId=CurrentUserId, ) async with GetAsyncSession() as session: await self._ensure_ready(session) - rule_set_id = await self._get_rule_set_id_by_type(session, context["rule_type"]) + rule_set_id = int(created_version.ruleSetId) if rule_set_id is None: raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "规则版本已创建,但未找到对应规则集") - binding_id, auto_bound = await self._ensure_group_binding_for_rule_set(session, GroupId, rule_set_id) + current_user = await self._get_current_user_context(session, CurrentUserId) + binding_id, auto_bound = await self._ensure_group_binding_for_rule_set( + session, + GroupId, + rule_set_id, + current_user=current_user, + ) await sync_doc_type_bindings_from_group(session, GroupId) await session.commit() - binding_row = await self._get_binding_row(session, binding_id) - binding_vo = await self._build_binding_vo(binding_row) + binding_row = await self._get_binding_row(session, binding_id, current_user) + binding_vo = await self._build_binding_vo(binding_row, current_user=current_user) return EvaluationPointGroupRuleDraftVO( packId=GroupId, @@ -610,11 +691,21 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): await ensure_rule_group_schema(session) await bootstrap_rule_groups(session) - async def _get_group_row(self, session, group_id: int): + async def _get_group_row(self, session, group_id: int, current_user: dict[str, Any] | None = None): + params: dict[str, Any] = {"group_id": group_id} + access_filter = "1=1" + if current_user is not None: + access_filter = await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="group_detail_scope", + ) row = ( await session.execute( text( - """ + f""" SELECT g.id, g.pid, @@ -643,37 +734,67 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): FROM leaudit_rule_group_bindings WHERE deleted_at IS NULL GROUP BY group_id - ) bg ON bg.group_id = g.id - WHERE g.id = :group_id AND g.deleted_at IS NULL + ) bg ON bg.group_id = g.id + WHERE g.id = :group_id AND g.deleted_at IS NULL AND {access_filter} LIMIT 1 """ ), - {"group_id": group_id}, + params, ) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则分组不存在") return row - async def _get_binding_row(self, session, binding_id: int): + async def _get_binding_row(self, session, binding_id: int, current_user: dict[str, Any] | None = None): + params: dict[str, Any] = {"binding_id": binding_id} + access_filter = "1=1" + if current_user is not None: + access_filter = await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="binding_scope", + ) row = ( await session.execute( text( """ - SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at - FROM leaudit_rule_group_bindings - WHERE id = :binding_id AND deleted_at IS NULL + SELECT + rgb.id, + rgb.group_id, + rgb.rule_set_id, + rgb.rule_type_binding_id, + COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL') AS tenant_code, + COALESCE(NULLIF(BTRIM(rgb.scope_type), ''), 'PROVINCIAL') AS scope_type, + rgb.tenant_name_snapshot, + rgb.priority, + rgb.is_active, + rgb.note, + rgb.created_at, + rgb.updated_at + FROM leaudit_rule_group_bindings rgb + JOIN leaudit_evaluation_point_groups child ON child.id = rgb.group_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = child.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id + WHERE rgb.id = :binding_id AND rgb.deleted_at IS NULL AND {access_filter} LIMIT 1 """ ), - {"binding_id": binding_id}, + params, ) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则组绑定不存在") return row - async def _load_binding_map(self, session, group_ids: list[int]) -> dict[int, list[RuleGroupBindingVO]]: + async def _load_binding_map( + self, + session, + group_ids: list[int], + current_user: dict[str, Any] | None = None, + ) -> dict[int, list[RuleGroupBindingVO]]: group_ids = [int(item) for item in group_ids if item] if not group_ids: return {} @@ -681,7 +802,19 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): await session.execute( text( """ - SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at + SELECT + id, + group_id, + rule_set_id, + rule_type_binding_id, + COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') AS tenant_code, + COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') AS scope_type, + tenant_name_snapshot, + priority, + is_active, + note, + created_at, + updated_at FROM leaudit_rule_group_bindings WHERE group_id IN :group_ids AND deleted_at IS NULL ORDER BY priority DESC, id ASC @@ -692,11 +825,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): ).mappings().all() result: dict[int, list[RuleGroupBindingVO]] = {} for row in rows: - result.setdefault(int(row["group_id"]), []).append(await self._build_binding_vo(row)) + result.setdefault(int(row["group_id"]), []).append(await self._build_binding_vo(row, current_user=current_user)) return result - async def _build_binding_vo(self, row) -> RuleGroupBindingVO: - rule_set_meta = await self._get_rule_set_meta(int(row["rule_set_id"])) + async def _build_binding_vo(self, row, current_user: dict[str, Any] | None = None) -> RuleGroupBindingVO: + rule_set_meta = await self._get_rule_set_meta(int(row["rule_set_id"]), current_user=current_user) + scope_state = self._build_binding_scope_state( + binding_row=row, + current_user=current_user, + rule_set_meta=rule_set_meta, + ) return RuleGroupBindingVO( id=int(row["id"]), group_id=int(row["group_id"]), @@ -705,17 +843,26 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): priority=int(row.get("priority") or 0), is_active=bool(row.get("is_active", True)), note=row.get("note"), + tenant_code=scope_state["effective_tenant_code"], + scope_type=scope_state["effective_scope_type"], + tenant_name_snapshot=row.get("tenant_name_snapshot"), rule_type=rule_set_meta.get("rule_type"), rule_name=rule_set_meta.get("rule_name"), current_version_id=rule_set_meta.get("current_version_id"), fallback_version_id=rule_set_meta.get("fallback_version_id"), has_usable_version=bool(rule_set_meta.get("has_usable_version", False)), usable_rule_count=int(rule_set_meta.get("usable_rule_count") or 0), + effectiveTenantCode=scope_state["effective_tenant_code"], + effectiveScopeType=scope_state["effective_scope_type"], + isInherited=bool(scope_state["is_inherited"]), + sourceRuleSetId=self._to_int(rule_set_meta.get("source_rule_set_id")), ) - async def _get_rule_set_meta(self, rule_set_id: int) -> dict[str, Any]: + async def _get_rule_set_meta(self, rule_set_id: int, current_user: dict[str, Any] | None = None) -> dict[str, Any]: if not getattr(self, "_rule_set_meta_cache", None): - rule_sets = await self.RuleService.ListSets() + rule_sets = await self.RuleService.ListSets( + CurrentUserId=int(current_user["id"]) if current_user and current_user.get("id") is not None else None + ) self._rule_set_meta_cache = { int(item.id): { "rule_type": item.ruleType, @@ -724,15 +871,18 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): "fallback_version_id": item.fallbackVersionId, "has_usable_version": item.hasUsableVersion, "usable_rule_count": item.usableRuleCount, + "source_rule_set_id": getattr(item, "sourceRuleSetId", None), + "effective_tenant_code": getattr(item, "effectiveTenantCode", None), + "effective_scope_type": getattr(item, "effectiveScopeType", None), } for item in rule_sets } return self._rule_set_meta_cache.get(rule_set_id, {}) - async def _ensure_parent_valid(self, session, pid: int): + async def _ensure_parent_valid(self, session, pid: int, current_user: dict[str, Any]): if pid == 0: return None - parent = await self._get_group_row(session, pid) + parent = await self._get_group_row(session, pid, current_user) if self._normalize_pid(parent["pid"]) != 0: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上级分组必须是一级分组") return parent @@ -743,6 +893,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): pid: int, entry_module_id: int | None, parent: Any | None, + current_user: dict[str, Any], ) -> int | None: if pid != 0: if parent is not None and parent.get("entry_module_id") is not None: @@ -752,14 +903,31 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): if entry_module_id in (None, 0, "0", ""): return None + params: dict[str, Any] = {"entry_module_id": int(entry_module_id)} + access_filter = await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="em.id", + current_user=current_user, + params=params, + param_prefix="entry_module_scope", + ) exists = ( await session.execute( - text("SELECT id FROM leaudit_entry_modules WHERE id = :entry_module_id AND deleted_at IS NULL LIMIT 1"), - {"entry_module_id": int(entry_module_id)}, + text( + f""" + SELECT em.id + FROM leaudit_entry_modules em + WHERE em.id = :entry_module_id + AND em.deleted_at IS NULL + AND {access_filter} + LIMIT 1 + """ + ), + params, ) ).scalar_one_or_none() if exists is None: - raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联入口模块不存在") + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联入口模块不存在或当前租户不可访问") return int(entry_module_id) async def _ensure_document_type_valid( @@ -770,6 +938,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): entry_module_id: int | None, group_id: int | None, parent: Any | None, + current_user: dict[str, Any], ) -> int | None: if pid == 0: if document_type_id is None and entry_module_id is None: @@ -788,16 +957,27 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): if document_type_id is None: return None - exists = ( + row = ( await session.execute( text( - "SELECT id FROM leaudit_document_types WHERE id = :doc_type_id AND deleted_at IS NULL LIMIT 1" + """ + SELECT id, entry_module_id + FROM leaudit_document_types + WHERE id = :doc_type_id AND deleted_at IS NULL + LIMIT 1 + """ ), {"doc_type_id": int(document_type_id)}, ) - ).scalar_one_or_none() - if exists is None: + ).mappings().first() + if row is None: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联文档类型不存在") + doc_type_entry_module_id = int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None + effective_entry_module_id = int(entry_module_id) if entry_module_id is not None else doc_type_entry_module_id + if doc_type_entry_module_id is not None and effective_entry_module_id is not None and doc_type_entry_module_id != effective_entry_module_id: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "文档类型所属入口模块与分组入口模块不一致") + if doc_type_entry_module_id is not None: + await self._assert_entry_module_access(session, doc_type_entry_module_id, current_user) if pid == 0: duplicated_root = ( await session.execute( @@ -829,18 +1009,37 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): if duplicated is not None: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码已存在") - async def _ensure_rule_set_valid(self, session, rule_set_id: int) -> None: - exists = ( + async def _ensure_rule_set_valid(self, session, rule_set_id: int, current_user: dict[str, Any]) -> dict[str, Any]: + row = ( await session.execute( - text("SELECT id FROM leaudit_rule_sets WHERE id = :rule_set_id AND deleted_at IS NULL LIMIT 1"), + text( + """ + SELECT + id, + COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') AS tenant_code, + COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') AS scope_type, + source_rule_set_id + FROM leaudit_rule_sets + WHERE id = :rule_set_id AND deleted_at IS NULL + LIMIT 1 + """ + ), {"rule_set_id": rule_set_id}, ) - ).scalar_one_or_none() - if exists is None: + ).mappings().first() + if row is None: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "规则集不存在") + if not current_user.get("is_global"): + rule_set_tenant = normalize_scoped_tenant_code(str(row.get("tenant_code") or "")) + user_tenant = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="") + if not user_tenant: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户上下文缺失,不能绑定规则集") + if rule_set_tenant not in {user_tenant, "PROVINCIAL"}: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不能直接绑定公共或其他租户规则资产") + return dict(row) - async def _load_rule_context(self, session, group_id: int) -> dict[str, Any]: - row = await self._get_group_row(session, group_id) + async def _load_rule_context(self, session, group_id: int, current_user: dict[str, Any]) -> dict[str, Any]: + row = await self._get_group_row(session, group_id, current_user) if self._normalize_pid(row["pid"]) == 0: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请先选择二级分组,再创建规则 YAML") @@ -913,6 +1112,168 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): next_seq = int((row or {}).get("max_seq") or 0) + 1 return f"v{next_seq}" + async def _get_current_user_context(self, session, current_user_id: int) -> dict[str, Any]: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + row = ( + await session.execute( + text( + f""" + SELECT + u.id, + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + AND u.deleted_at IS NULL + AND u.status = 0 + GROUP BY u.id, u.area + """ + ), + {"user_id": current_user_id}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用") + + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="evaluation_group_user_context", + ) + return { + "id": int(row["id"]), + "area": str(row["area"] or ""), + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "is_global": bool(row["is_global"]), + } + + async def _entry_module_tenant_table_exists(self, session) -> bool: + if self._entry_module_tenant_table_exists_cache is not None: + return self._entry_module_tenant_table_exists_cache + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'leaudit_entry_module_tenants' + ) + """ + ) + ) + ).scalar_one() + ) + self._entry_module_tenant_table_exists_cache = exists + return exists + + async def _entry_module_tenant_scope_sql( + self, + *, + session, + entry_module_expr: str, + current_user: dict[str, Any], + params: dict[str, Any], + param_prefix: str, + ) -> str: + if current_user.get("is_global"): + return "1=1" + if not await self._entry_module_tenant_table_exists(session): + return "1=0" + params[f"{param_prefix}_tenant_code"] = str(current_user.get("tenant_code") or "").strip() + return f""" + EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = {entry_module_expr} + AND emt.deleted_at IS NULL + AND emt.is_enabled = TRUE + AND ( + emt.tenant_code = :{param_prefix}_tenant_code + OR emt.tenant_code = 'PUBLIC' + ) + ) + """ + + async def _assert_entry_module_access(self, session, entry_module_id: int | None, current_user: dict[str, Any]) -> None: + if current_user.get("is_global"): + return + if entry_module_id is None: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前资源未绑定入口模块,当前租户不可访问") + params: dict[str, Any] = {"entry_module_id": int(entry_module_id)} + access_filter = await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="em.id", + current_user=current_user, + params=params, + param_prefix="assert_entry_module_scope", + ) + exists = ( + await session.execute( + text( + f""" + SELECT em.id + FROM leaudit_entry_modules em + WHERE em.id = :entry_module_id + AND em.deleted_at IS NULL + AND {access_filter} + LIMIT 1 + """ + ), + params, + ) + ).scalar_one_or_none() + if exists is None: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不可访问该入口模块") + + async def _list_accessible_group_rows(self, session, group_ids: list[int], current_user: dict[str, Any]) -> list[Any]: + params: dict[str, Any] = {"ids": group_ids} + access_filter = await self._entry_module_tenant_scope_sql( + session=session, + entry_module_expr="COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="batch_group_scope", + ) + rows = ( + await session.execute( + text( + f""" + SELECT g.id, g.pid, g.document_type_id + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + WHERE g.id IN :ids + AND g.deleted_at IS NULL + AND {access_filter} + """ + ).bindparams(bindparam("ids", expanding=True)), + params, + ) + ).mappings().all() + return rows + def _build_rule_yaml_template(self, context: dict[str, Any]) -> str: keywords = [ item @@ -979,7 +1340,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): ).mappings().first() return int(row["id"]) if row else None - async def _ensure_group_binding_for_rule_set(self, session, group_id: int, rule_set_id: int) -> tuple[int, bool]: + async def _ensure_group_binding_for_rule_set( + self, + session, + group_id: int, + rule_set_id: int, + *, + current_user: dict[str, Any], + ) -> tuple[int, bool]: + rule_set_meta = await self._ensure_rule_set_valid(session, rule_set_id, current_user) + scope_payload = self._build_binding_scope_payload(current_user=current_user, rule_set_meta=rule_set_meta) existing = ( await session.execute( text( @@ -988,11 +1358,16 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND rule_set_id = :rule_set_id + AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') = :tenant_code AND deleted_at IS NULL LIMIT 1 """ ), - {"group_id": group_id, "rule_set_id": rule_set_id}, + { + "group_id": group_id, + "rule_set_id": rule_set_id, + "tenant_code": scope_payload["tenant_code"], + }, ) ).mappings().first() if existing: @@ -1003,9 +1378,9 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): text( """ INSERT INTO leaudit_rule_group_bindings ( - group_id, rule_set_id, priority, is_active, note, created_at, updated_at + group_id, rule_set_id, tenant_code, scope_type, tenant_name_snapshot, priority, is_active, note, created_at, updated_at ) VALUES ( - :group_id, :rule_set_id, 100, TRUE, :note, NOW(), NOW() + :group_id, :rule_set_id, :tenant_code, :scope_type, :tenant_name_snapshot, 100, TRUE, :note, NOW(), NOW() ) RETURNING id """ @@ -1013,12 +1388,77 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): { "group_id": group_id, "rule_set_id": rule_set_id, + "tenant_code": scope_payload["tenant_code"], + "scope_type": scope_payload["scope_type"], + "tenant_name_snapshot": scope_payload["tenant_name_snapshot"], "note": "由二级分组新建规则 YAML 时自动补绑", }, ) ).mappings().one() return int(row["id"]), True + def _build_binding_scope_payload( + self, + *, + current_user: dict[str, Any], + rule_set_meta: dict[str, Any], + ) -> dict[str, Any]: + if not current_user.get("is_global"): + tenant_code = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="") + if not tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户上下文缺失,不能修改规则绑定") + rule_set_tenant = normalize_scoped_tenant_code( + str(rule_set_meta.get("effective_tenant_code") or rule_set_meta.get("tenant_code") or "") + ) + if rule_set_tenant == "PUBLIC": + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不能直接绑定公共规则资产") + return { + "tenant_code": tenant_code, + "scope_type": "TENANT", + "tenant_name_snapshot": str(current_user.get("tenant_name") or "").strip() or None, + } + + effective_tenant_code = normalize_scoped_tenant_code( + str(rule_set_meta.get("effective_tenant_code") or rule_set_meta.get("tenant_code") or "") + ) + effective_scope_type = str(rule_set_meta.get("effective_scope_type") or "").strip().upper() + if not effective_scope_type: + effective_scope_type = "PUBLIC" if effective_tenant_code == "PUBLIC" else "PROVINCIAL" + return { + "tenant_code": effective_tenant_code, + "scope_type": effective_scope_type, + "tenant_name_snapshot": None, + } + + def _build_binding_scope_state( + self, + *, + binding_row: dict[str, Any] | Any, + current_user: dict[str, Any] | None, + rule_set_meta: dict[str, Any], + ) -> dict[str, Any]: + effective_tenant_code = normalize_scoped_tenant_code(str(binding_row.get("tenant_code") or "")) + effective_scope_type = str(binding_row.get("scope_type") or "").strip().upper() + if not effective_scope_type: + effective_scope_type = "PUBLIC" if effective_tenant_code == "PUBLIC" else "PROVINCIAL" if effective_tenant_code == "PROVINCIAL" else "TENANT" + user_tenant = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="") if current_user else "" + is_tenant_user = bool(current_user) and not bool(current_user.get("is_global")) and bool(user_tenant) + is_inherited = is_tenant_user and effective_tenant_code != user_tenant + return { + "effective_tenant_code": effective_tenant_code, + "effective_scope_type": effective_scope_type, + "is_inherited": is_inherited, + "source_rule_set_id": self._to_int(rule_set_meta.get("source_rule_set_id")), + } + + def _assert_binding_mutable_by_user(self, binding_row: dict[str, Any] | Any, current_user: dict[str, Any]) -> None: + if current_user.get("is_global"): + return + binding_tenant = normalize_scoped_tenant_code(str(binding_row.get("tenant_code") or "")) + user_tenant = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="") + if not user_tenant or binding_tenant != user_tenant: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不能修改继承或其他租户的规则绑定") + def _normalize_create_payload(self, body: EvaluationPointGroupCreateDTO) -> dict[str, Any]: name = body.name.strip() code = body.code.strip() @@ -1068,3 +1508,8 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): if isinstance(value, datetime): return value.isoformat() return str(value) + + def _to_int(self, value: Any) -> int | None: + if value is None: + return None + return int(value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py index 0531a44..1f5a909 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py @@ -7,10 +7,11 @@ from decimal import Decimal from typing import Any from urllib.parse import quote_plus -from sqlalchemy import text +from sqlalchemy import bindparam, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from fastapi_admin.config import DB_HOST, DB_PASSWORD, DB_PORT, DB_USER +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointDto import ( @@ -25,6 +26,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointVo import ( EvaluationPointVO, ) from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver _LEGACY_DB_NAME = os.getenv("LEGACY_RULE_DB_NAME", "docauditai") _LEGACY_DB_URL = ( @@ -38,8 +42,24 @@ _LegacySession = async_sessionmaker(_LEGACY_ENGINE, expire_on_commit=False) class EvaluationPointServiceImpl(IEvaluationPointService): """评查点服务实现。""" + _WRITE_PERMISSION_KEYS: tuple[str, ...] = ( + "evaluation_point:create:write", + "evaluation_point:update:write", + "evaluation_point:delete:delete", + ) + + def __init__(self) -> None: + self.PermissionService = PermissionServiceImpl() + self.TenantResolver = TenantResolver() + self._legacy_columns_cache: dict[str, set[str]] = {} + async def ListPoints( self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + CurrentTenantCode: str | None, + CurrentTenantName: str | None, Name: str | None, Code: str | None, Risk: str | None, @@ -47,11 +67,26 @@ class EvaluationPointServiceImpl(IEvaluationPointService): GroupPid: int | None, GroupId: int | None, DocumentAttributeType: str | None, - Area: str | None, + FilterArea: str | None, + FilterTenantCode: str | None, + FilterTenantName: str | None, Page: int, PageSize: int, ) -> EvaluationPointListVO: offset = max(Page - 1, 0) * PageSize + current_user = await self._get_current_user_context( + CurrentUserId=CurrentUserId, + UserArea=UserArea, + UserRole=UserRole, + TenantCode=CurrentTenantCode, + TenantName=CurrentTenantName, + ) + requested_scope = await self._resolve_requested_scope( + Area=FilterArea, + TenantCode=FilterTenantCode, + TenantName=FilterTenantName, + Source="evaluation_point_list", + ) where_clause, params = self._build_list_filters( Name=Name, Code=Code, @@ -60,178 +95,204 @@ class EvaluationPointServiceImpl(IEvaluationPointService): GroupPid=GroupPid, GroupId=GroupId, DocumentAttributeType=DocumentAttributeType, - Area=Area, + RequestedScope=requested_scope, + CurrentUser=current_user, + EvaluationPointColumns=await self._load_legacy_table_columns("evaluation_points"), ) params.update({"limit": PageSize, "offset": offset}) async with _LegacySession() as session: + evaluation_point_columns = await self._load_legacy_table_columns("evaluation_points", Session=session) + tenant_code_select = self._evaluation_point_tenant_code_select(evaluation_point_columns, alias="ep") + tenant_name_select = self._evaluation_point_tenant_name_select(evaluation_point_columns, alias="ep") + count_sql = text( + f""" + SELECT COUNT(*) + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE {where_clause} + """ + ) + list_sql = text( + f""" + SELECT + ep.id, + ep.code, + ep.name, + ep.evaluation_point_groups_id, + ep.evaluation_point_groups_pid, + ep.risk, + ep.description, + ep.is_enabled, + ep.document_attribute_type, + ep.references_laws, + ep.extraction_config, + ep.evaluation_config, + ep.pass_message, + ep.fail_message, + ep.suggestion_message, + ep.suggestion_message_type, + ep.post_action, + ep.action_config, + ep.score, + ep.area, + {tenant_code_select}, + {tenant_name_select}, + ep.created_at, + ep.updated_at, + child_group.name AS group_name, + parent_group.name AS rule_type + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE {where_clause} + ORDER BY COALESCE(ep.sort, 0) ASC, ep.updated_at DESC, ep.id DESC + LIMIT :limit OFFSET :offset + """ + ) total = int( ( await session.execute( - text( - f""" - SELECT COUNT(*) - FROM evaluation_points ep - LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id - LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) - WHERE {where_clause} - """ - ), + count_sql, params, ) ).scalar_one() ) rows = ( await session.execute( - text( - f""" - SELECT - ep.id, - ep.code, - ep.name, - ep.evaluation_point_groups_id, - ep.evaluation_point_groups_pid, - ep.risk, - ep.description, - ep.is_enabled, - ep.document_attribute_type, - ep.references_laws, - ep.extraction_config, - ep.evaluation_config, - ep.pass_message, - ep.fail_message, - ep.suggestion_message, - ep.suggestion_message_type, - ep.post_action, - ep.action_config, - ep.score, - ep.area, - ep.created_at, - ep.updated_at, - child_group.name AS group_name, - parent_group.name AS rule_type - FROM evaluation_points ep - LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id - LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) - WHERE {where_clause} - ORDER BY COALESCE(ep.sort, 0) ASC, ep.updated_at DESC, ep.id DESC - LIMIT :limit OFFSET :offset - """ - ), + list_sql, params, ) ).mappings().all() return EvaluationPointListVO( - data=[self._to_point_vo(row) for row in rows], + data=[await self._to_point_vo(row) for row in rows], total=total, page=Page, page_size=PageSize, ) - async def GetPoint(self, PointId: int) -> EvaluationPointVO: - async with _LegacySession() as session: - row = ( - await session.execute( - text( - """ - SELECT - ep.id, - ep.code, - ep.name, - ep.evaluation_point_groups_id, - ep.evaluation_point_groups_pid, - ep.risk, - ep.description, - ep.is_enabled, - ep.document_attribute_type, - ep.references_laws, - ep.extraction_config, - ep.evaluation_config, - ep.pass_message, - ep.fail_message, - ep.suggestion_message, - ep.suggestion_message_type, - ep.post_action, - ep.action_config, - ep.score, - ep.area, - ep.created_at, - ep.updated_at, - child_group.name AS group_name, - parent_group.name AS rule_type - FROM evaluation_points ep - LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id - LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) - WHERE ep.id = :point_id - """ - ), - {"point_id": PointId}, - ) - ).mappings().first() - + async def GetPoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + ) -> EvaluationPointVO: + current_user = await self._get_current_user_context( + CurrentUserId=CurrentUserId, + UserArea=UserArea, + UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, + ) + row = await self._get_point_row(PointId=PointId, CurrentUser=current_user) if not row: raise LeauditException(StatusCodeEnum.NOT_FOUND, "评查点不存在") - return self._to_point_vo(row) + return await self._to_point_vo(row) - async def CreatePoint(self, Body: EvaluationPointCreateDTO) -> EvaluationPointVO: + async def CreatePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + Body: EvaluationPointCreateDTO, + ) -> EvaluationPointVO: + current_user = await self._get_current_user_context( + CurrentUserId=CurrentUserId, + UserArea=UserArea, + UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, + ) await self._validate_group_relation(Body.evaluation_point_groups_pid, Body.evaluation_point_groups_id) await self._ensure_code_unique(str(Body.code).strip()) now = datetime.utcnow() insert_params = self._build_write_params(Body, now) + requested_scope = await self._resolve_requested_scope( + Area=Body.area, + TenantCode=Body.tenant_code, + TenantName=Body.tenant_name, + Source="evaluation_point_create", + ) + writable_scope = self._resolve_writable_scope( + current_user, + RequestedScope=requested_scope, + ) + insert_params["area"] = writable_scope["area"] insert_params["created_at"] = now insert_params["updated_at"] = now async with _LegacySession() as session: + evaluation_point_columns = await self._load_legacy_table_columns("evaluation_points", Session=session) + insert_fields = [ + "code", + "name", + "evaluation_point_groups_id", + "evaluation_point_groups_pid", + "risk", + "description", + "is_enabled", + "document_attribute_type", + "references_laws", + "extraction_config", + "evaluation_config", + "pass_message", + "fail_message", + "suggestion_message", + "suggestion_message_type", + "post_action", + "action_config", + "score", + "area", + "created_at", + "updated_at", + ] + insert_values = [ + ":code", + ":name", + ":evaluation_point_groups_id", + ":evaluation_point_groups_pid", + ":risk", + ":description", + ":is_enabled", + ":document_attribute_type", + "CAST(:references_laws AS jsonb)", + "CAST(:extraction_config AS jsonb)", + "CAST(:evaluation_config AS jsonb)", + ":pass_message", + ":fail_message", + ":suggestion_message", + ":suggestion_message_type", + ":post_action", + ":action_config", + ":score", + ":area", + ":created_at", + ":updated_at", + ] + if "tenant_code" in evaluation_point_columns: + insert_params["tenant_code"] = writable_scope["tenant_code"] + insert_fields.append("tenant_code") + insert_values.append(":tenant_code") + if "tenant_name" in evaluation_point_columns: + insert_params["tenant_name"] = writable_scope["tenant_name"] + insert_fields.append("tenant_name") + insert_values.append(":tenant_name") async with session.begin(): new_id = await session.scalar( text( - """ + f""" INSERT INTO evaluation_points ( - code, - name, - evaluation_point_groups_id, - evaluation_point_groups_pid, - risk, - description, - is_enabled, - document_attribute_type, - references_laws, - extraction_config, - evaluation_config, - pass_message, - fail_message, - suggestion_message, - suggestion_message_type, - post_action, - action_config, - score, - area, - created_at, - updated_at + {", ".join(insert_fields)} ) VALUES ( - :code, - :name, - :evaluation_point_groups_id, - :evaluation_point_groups_pid, - :risk, - :description, - :is_enabled, - :document_attribute_type, - CAST(:references_laws AS jsonb), - CAST(:extraction_config AS jsonb), - CAST(:evaluation_config AS jsonb), - :pass_message, - :fail_message, - :suggestion_message, - :suggestion_message_type, - :post_action, - :action_config, - :score, - :area, - :created_at, - :updated_at + {", ".join(insert_values)} ) RETURNING id """ @@ -240,19 +301,35 @@ class EvaluationPointServiceImpl(IEvaluationPointService): ) await session.commit() - return await self.GetPoint(int(new_id)) + return await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, int(new_id)) - async def UpdatePoint(self, PointId: int, Body: EvaluationPointUpdateDTO) -> EvaluationPointVO: - await self.GetPoint(PointId) + async def UpdatePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + Body: EvaluationPointUpdateDTO, + ) -> EvaluationPointVO: + current_user = await self._get_current_user_context( + CurrentUserId=CurrentUserId, + UserArea=UserArea, + UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, + ) + await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, PointId) payload = Body.model_dump(exclude_unset=True) if not payload: - return await self.GetPoint(PointId) + return await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, PointId) group_pid = payload.get("evaluation_point_groups_pid") group_id = payload.get("evaluation_point_groups_id") if group_pid is not None or group_id is not None: - current = await self.GetPoint(PointId) + current = await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, PointId) await self._validate_group_relation( group_pid if group_pid is not None else current.evaluation_point_groups_pid, group_id if group_id is not None else current.evaluation_point_groups_id, @@ -279,7 +356,6 @@ class EvaluationPointServiceImpl(IEvaluationPointService): "post_action", "action_config", "score", - "area", ] json_fields = ["references_laws", "extraction_config", "evaluation_config"] @@ -289,6 +365,21 @@ class EvaluationPointServiceImpl(IEvaluationPointService): params[field] = self._normalize_scalar_field(field, payload[field]) updates.append(f"{field} = :{field}") + if any(key in payload for key in ("area", "tenant_code", "tenant_name")): + requested_scope = await self._resolve_requested_scope( + Area=payload.get("area"), + TenantCode=payload.get("tenant_code"), + TenantName=payload.get("tenant_name"), + Source="evaluation_point_update", + ) + writable_scope = self._resolve_writable_scope( + current_user, + RequestedScope=requested_scope, + ) + params["area"] = writable_scope["area"] + if "area = :area" not in updates: + updates.append("area = :area") + for field in json_fields: if field not in payload: continue @@ -298,6 +389,13 @@ class EvaluationPointServiceImpl(IEvaluationPointService): updates.append("updated_at = :updated_at") async with _LegacySession() as session: + evaluation_point_columns = await self._load_legacy_table_columns("evaluation_points", Session=session) + if "tenant_code" in evaluation_point_columns and "tenant_code" not in params and "writable_scope" in locals(): + params["tenant_code"] = writable_scope["tenant_code"] + updates.append("tenant_code = :tenant_code") + if "tenant_name" in evaluation_point_columns and "tenant_name" not in params and "writable_scope" in locals(): + params["tenant_name"] = writable_scope["tenant_name"] + updates.append("tenant_name = :tenant_name") async with session.begin(): await session.execute( text(f"UPDATE evaluation_points SET {', '.join(updates)} WHERE id = :point_id"), @@ -305,10 +403,26 @@ class EvaluationPointServiceImpl(IEvaluationPointService): ) await session.commit() - return await self.GetPoint(PointId) + return await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, PointId) - async def DeletePoint(self, PointId: int) -> EvaluationPointDeleteVO: - await self.GetPoint(PointId) + async def DeletePoint( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + PointId: int, + ) -> EvaluationPointDeleteVO: + current_user = await self._get_current_user_context( + CurrentUserId=CurrentUserId, + UserArea=UserArea, + UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, + ) + self._assert_can_write(current_user) + await self.GetPoint(CurrentUserId, UserArea, UserRole, TenantCode, TenantName, PointId) async with _LegacySession() as session: async with session.begin(): await session.execute(text("DELETE FROM evaluation_points WHERE id = :point_id"), {"point_id": PointId}) @@ -345,7 +459,9 @@ class EvaluationPointServiceImpl(IEvaluationPointService): GroupPid: int | None, GroupId: int | None, DocumentAttributeType: str | None, - Area: str | None, + RequestedScope: dict[str, str], + CurrentUser: dict[str, Any], + EvaluationPointColumns: set[str], ) -> tuple[str, dict[str, Any]]: filters = ["1=1"] params: dict[str, Any] = {} @@ -386,9 +502,14 @@ class EvaluationPointServiceImpl(IEvaluationPointService): else: filters.append("ep.document_attribute_type = :document_attribute_type") params["document_attribute_type"] = DocumentAttributeType - if Area: - filters.append("ep.area = :area") - params["area"] = Area + filters.extend( + self._build_scope_filters( + CurrentUser=CurrentUser, + Params=params, + RequestedScope=RequestedScope, + EvaluationPointColumns=EvaluationPointColumns, + ) + ) return " AND ".join(filters), params @@ -454,13 +575,18 @@ class EvaluationPointServiceImpl(IEvaluationPointService): "post_action": Body.post_action or "none", "action_config": Body.action_config or "", "score": float(Body.score or 0), - "area": (Body.area or "").strip() or None, + "area": None, "created_at": Now, "updated_at": Now, } - def _to_point_vo(self, row: dict[str, Any]) -> EvaluationPointVO: + async def _to_point_vo(self, row: dict[str, Any]) -> EvaluationPointVO: group_id = row.get("evaluation_point_groups_id") + tenant = await self._resolve_record_tenant( + Area=row.get("area"), + TenantCode=row.get("tenant_code"), + TenantName=row.get("tenant_name"), + ) return EvaluationPointVO( id=int(row["id"]), code=str(row.get("code") or ""), @@ -485,6 +611,8 @@ class EvaluationPointServiceImpl(IEvaluationPointService): action_config=str(row.get("action_config") or ""), score=self._normalize_score(row.get("score")), area=str(row.get("area") or ""), + tenantCode=tenant.tenant_code or "", + tenantName=tenant.tenant_name or str(row.get("tenant_name") or row.get("area") or ""), created_at=self._format_datetime(row.get("created_at")), updated_at=self._format_datetime(row.get("updated_at")), ) @@ -533,13 +661,474 @@ class EvaluationPointServiceImpl(IEvaluationPointService): return str(value or "").strip() if field == "document_attribute_type": return self._normalize_document_attribute_type(value) - if field == "area": - area = str(value or "").strip() - return area or None if field == "score": return self._normalize_score(value) return value + async def _resolve_area_value( + self, + *, + Area: str | None, + TenantCode: str | None, + TenantName: str | None, + Source: str, + ) -> str | None: + resolution = await self.TenantResolver.Resolve( + RawValue=Area, + Source=Source, + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) + normalized = resolution.tenant_name or resolution.normalized_value or str(Area or "").strip() + normalized = normalized.strip() + return normalized or None + + async def _resolve_requested_scope( + self, + *, + Area: str | None, + TenantCode: str | None, + TenantName: str | None, + Source: str, + ) -> dict[str, str]: + resolution = await self.TenantResolver.Resolve( + RawValue=Area, + Source=Source, + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) + requested_scope = { + "tenant_code": str(resolution.tenant_code or TenantCode or "").strip(), + "tenant_name": str(resolution.tenant_name or TenantName or "").strip(), + "normalized_value": str(resolution.normalized_value or Area or "").strip(), + } + shared_scope = self._normalize_shared_writable_scope(requested_scope) + if shared_scope: + return { + "tenant_code": shared_scope["tenant_code"], + "tenant_name": shared_scope["tenant_name"], + "normalized_value": requested_scope["normalized_value"], + } + return requested_scope + + async def _resolve_record_tenant( + self, + Area: str | None, + TenantCode: str | None = None, + TenantName: str | None = None, + ): + return await self.TenantResolver.Resolve( + RawValue=Area, + Source="evaluation_point_record", + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) + + async def _get_current_user_context( + self, + *, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> dict[str, Any]: + async with GetAsyncSession() as session: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + row = ( + await session.execute( + text( + f""" + SELECT + u.id, + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, + COALESCE(bool_or(COALESCE(r.data_scope, '') = 'ALL'), FALSE) AS is_global + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + GROUP BY u.id, u.area + """ + ), + {"user_id": CurrentUserId}, + ) + ).mappings().first() + + if row: + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="evaluation_point_user_context", + ) + can_manage = await self.PermissionService.HasAnyPermission(CurrentUserId, list(self._WRITE_PERMISSION_KEYS)) + return { + "id": int(row["id"]), + "area": str(row["area"] or ""), + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "is_global": bool(row["is_global"]), + "can_manage": can_manage, + } + + tenant = await self.TenantResolver.ResolveUserContext( + Area=UserArea, + TenantCode=TenantCode, + TenantName=TenantName, + Source="evaluation_point_user_context_fallback", + ) + can_manage = await self.PermissionService.HasAnyPermission(CurrentUserId, list(self._WRITE_PERMISSION_KEYS)) + return { + "id": int(CurrentUserId), + "area": str(UserArea or ""), + "tenant_code": tenant.tenant_code or (str(TenantCode or "").strip() or None), + "tenant_name": tenant.tenant_name or (str(TenantName or "").strip() or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(UserArea or ""), + "is_global": False, + "can_manage": can_manage, + } + + def _build_scope_filters( + self, + *, + CurrentUser: dict[str, Any], + Params: dict[str, Any], + RequestedScope: dict[str, str], + EvaluationPointColumns: set[str], + ) -> list[str]: + current_tenant_code = str(CurrentUser.get("tenant_code") or "").strip() + current_tenant_name = str(CurrentUser.get("tenant_name") or CurrentUser.get("tenant_scope_value") or CurrentUser.get("area") or "").strip() + requested_tenant_code = str(RequestedScope.get("tenant_code") or "").strip() + requested_tenant_name = str( + RequestedScope.get("tenant_name") or RequestedScope.get("normalized_value") or "" + ).strip() + shared_scope_codes = {"PUBLIC", "PROVINCIAL"} + shared_scope_names = {"公共", "省级"} + + if CurrentUser.get("is_global"): + if requested_tenant_code or requested_tenant_name: + return [ + self._tenant_scope_match_sql( + alias="ep", + columns=EvaluationPointColumns, + params=Params, + prefix="requested_scope", + tenant_code=requested_tenant_code or None, + tenant_name=requested_tenant_name or requested_tenant_code, + ) + ] + return ["1=1"] + + if not current_tenant_code and not current_tenant_name: + return ["1=0"] + + if requested_tenant_code or requested_tenant_name: + allowed = False + effective_tenant_code = requested_tenant_code + effective_tenant_name = requested_tenant_name or requested_tenant_code + if requested_tenant_code in shared_scope_codes or effective_tenant_name in shared_scope_names: + allowed = True + elif current_tenant_code and requested_tenant_code == current_tenant_code: + allowed = True + effective_tenant_code = current_tenant_code + effective_tenant_name = effective_tenant_name or current_tenant_name + elif requested_tenant_name and requested_tenant_name == current_tenant_name: + allowed = True + effective_tenant_code = effective_tenant_code or current_tenant_code + effective_tenant_name = current_tenant_name + if not allowed: + return ["1=0"] + return [ + self._tenant_scope_match_sql( + alias="ep", + columns=EvaluationPointColumns, + params=Params, + prefix="requested_scope", + tenant_code=effective_tenant_code or None, + tenant_name=effective_tenant_name, + ) + ] + + visible_scopes: list[tuple[str | None, str]] = [ + ("PROVINCIAL", "省级"), + ("PUBLIC", "公共"), + ] + if current_tenant_code or current_tenant_name: + visible_scopes.append((current_tenant_code or None, current_tenant_name)) + scope_clauses = [ + self._tenant_scope_match_sql( + alias="ep", + columns=EvaluationPointColumns, + params=Params, + prefix=f"visible_scope_{index}", + tenant_code=tenant_code, + tenant_name=tenant_name, + ) + for index, (tenant_code, tenant_name) in enumerate(visible_scopes) + if tenant_name + ] + return [f"({' OR '.join(scope_clauses)})"] if scope_clauses else ["1=0"] + + def _assert_can_write(self, current_user: dict[str, Any]) -> None: + if current_user.get("can_manage"): + return + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有跨租户评查点管理权限") + + def _normalize_shared_writable_scope(self, requested_scope: dict[str, str]) -> dict[str, str] | None: + requested_tenant_code = str(requested_scope.get("tenant_code") or "").strip() + requested_tenant_name = str(requested_scope.get("tenant_name") or "").strip() + requested_normalized_value = str(requested_scope.get("normalized_value") or "").strip() + + if requested_tenant_code == "PROVINCIAL": + return {"area": "省级", "tenant_code": "PROVINCIAL", "tenant_name": requested_tenant_name or "省级"} + if requested_tenant_code == "PUBLIC": + return {"area": "公共", "tenant_code": "PUBLIC", "tenant_name": requested_tenant_name or "公共"} + if requested_tenant_name == "省级" or requested_normalized_value == "省级": + return {"area": "省级", "tenant_code": "PROVINCIAL", "tenant_name": "省级"} + if requested_tenant_name == "公共" or requested_normalized_value in {"公共", "default"}: + return {"area": "公共", "tenant_code": "PUBLIC", "tenant_name": "公共"} + return None + + def _resolve_writable_scope( + self, + current_user: dict[str, Any], + *, + RequestedScope: dict[str, str], + ) -> dict[str, str]: + self._assert_can_write(current_user) + current_tenant_code = str(current_user.get("tenant_code") or "").strip() + current_tenant_name = str( + current_user.get("tenant_name") or current_user.get("tenant_scope_value") or current_user.get("area") or "" + ).strip() + current_area = str(current_user.get("tenant_scope_value") or current_user.get("area") or current_tenant_name).strip() + requested_tenant_code = str(RequestedScope.get("tenant_code") or "").strip() + requested_tenant_name = str(RequestedScope.get("tenant_name") or "").strip() + requested_normalized_value = str(RequestedScope.get("normalized_value") or "").strip() + shared_scope = self._normalize_shared_writable_scope(RequestedScope) + + if current_user.get("is_global"): + if not requested_tenant_code and not requested_tenant_name and not requested_normalized_value: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请显式指定评查点所属租户或共享域") + if shared_scope: + return shared_scope + effective_tenant_code = requested_tenant_code + effective_tenant_name = requested_tenant_name or requested_normalized_value or effective_tenant_code + if not effective_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请使用标准 tenant_code 维护非公共评查点") + return { + "area": effective_tenant_name, + "tenant_code": effective_tenant_code, + "tenant_name": effective_tenant_name, + } + if not current_tenant_code and not current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户管理员未配置所属租户,无法写入评查点") + current_scope = self._normalize_shared_writable_scope( + { + "tenant_code": current_tenant_code, + "tenant_name": current_tenant_name, + "normalized_value": current_area, + } + ) or { + "area": current_tenant_name or current_area or current_tenant_code, + "tenant_code": current_tenant_code, + "tenant_name": current_tenant_name or current_area or current_tenant_code, + } + if not requested_tenant_code and not requested_tenant_name and not requested_normalized_value: + return current_scope + if shared_scope and shared_scope["tenant_code"] != current_scope["tenant_code"]: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许维护本人所属租户的评查点") + if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许维护本人所属租户的评查点") + if requested_tenant_name and not requested_tenant_code and requested_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许维护本人所属租户的评查点") + if ( + requested_normalized_value + and not requested_tenant_code + and not requested_tenant_name + and requested_normalized_value != current_area + and requested_normalized_value != current_tenant_name + ): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许维护本人所属租户的评查点") + return current_scope + + async def _get_point_row(self, *, PointId: int, CurrentUser: dict[str, Any]) -> Any | None: + evaluation_point_columns = await self._load_legacy_table_columns("evaluation_points") + where_clause, params = self._build_list_filters( + Name=None, + Code=None, + Risk=None, + IsEnabled=None, + GroupPid=None, + GroupId=None, + DocumentAttributeType=None, + RequestedScope={"tenant_code": "", "tenant_name": "", "normalized_value": ""}, + CurrentUser=CurrentUser, + EvaluationPointColumns=evaluation_point_columns, + ) + params["point_id"] = PointId + async with _LegacySession() as session: + tenant_code_select = self._evaluation_point_tenant_code_select(evaluation_point_columns, alias="ep") + tenant_name_select = self._evaluation_point_tenant_name_select(evaluation_point_columns, alias="ep") + point_sql = text( + f""" + SELECT + ep.id, + ep.code, + ep.name, + ep.evaluation_point_groups_id, + ep.evaluation_point_groups_pid, + ep.risk, + ep.description, + ep.is_enabled, + ep.document_attribute_type, + ep.references_laws, + ep.extraction_config, + ep.evaluation_config, + ep.pass_message, + ep.fail_message, + ep.suggestion_message, + ep.suggestion_message_type, + ep.post_action, + ep.action_config, + ep.score, + ep.area, + {tenant_code_select}, + {tenant_name_select}, + ep.created_at, + ep.updated_at, + child_group.name AS group_name, + parent_group.name AS rule_type + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE ep.id = :point_id + AND {where_clause} + """ + ) + row = ( + await session.execute( + point_sql, + params, + ) + ).mappings().first() + return row + + async def _load_legacy_table_columns(self, table_name: str, Session=None) -> set[str]: + cached = self._legacy_columns_cache.get(table_name) + if cached is not None: + return cached + + if Session is not None: + rows = ( + await Session.execute( + text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + """ + ), + {"table_name": table_name}, + ) + ).mappings().all() + columns = {str(row["column_name"]) for row in rows if row.get("column_name")} + self._legacy_columns_cache[table_name] = columns + return columns + + async with _LegacySession() as session: + return await self._load_legacy_table_columns(table_name, Session=session) + + @staticmethod + def _evaluation_point_tenant_code_expr(columns: set[str], *, alias: str) -> str: + if "tenant_code" in columns: + return f"COALESCE(NULLIF(BTRIM({alias}.tenant_code), ''), '')" + return "''" + + @staticmethod + def _evaluation_point_tenant_name_expr(columns: set[str], *, alias: str) -> str: + if "tenant_name" in columns: + return f"COALESCE(NULLIF(BTRIM({alias}.tenant_name), ''), NULLIF(BTRIM({alias}.area), ''), '')" + return f"COALESCE(NULLIF(BTRIM({alias}.area), ''), '')" + + def _evaluation_point_tenant_code_select(self, columns: set[str], *, alias: str) -> str: + return f"{self._evaluation_point_tenant_code_expr(columns, alias=alias)} AS tenant_code" + + def _evaluation_point_tenant_name_select(self, columns: set[str], *, alias: str) -> str: + return f"{self._evaluation_point_tenant_name_expr(columns, alias=alias)} AS tenant_name" + + def _tenant_scope_match_sql( + self, + *, + alias: str, + columns: set[str], + params: dict[str, Any], + prefix: str, + tenant_code: str | None, + tenant_name: str, + ) -> str: + tenant_code_expr = self._evaluation_point_tenant_code_expr(columns, alias=alias) + tenant_name_expr = self._evaluation_point_tenant_name_expr(columns, alias=alias) + normalized_tenant_code = str(tenant_code or "").strip() + normalized_tenant_name = str(tenant_name or "").strip() + fallback_names = self._shared_scope_legacy_names(normalized_tenant_code, normalized_tenant_name) + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + if fallback_names: + fallback_clauses: list[str] = [] + for index, fallback_name in enumerate(fallback_names): + key = f"{prefix}_tenant_name_{index}" + params[key] = fallback_name + fallback_clauses.append(f"{tenant_name_expr} = :{key}") + return ( + "(" + f"{tenant_code_expr} = :{prefix}_tenant_code " + "OR (" + f"{tenant_code_expr} = '' " + f"AND ({' OR '.join(fallback_clauses)})" + ")" + ")" + ) + params[f"{prefix}_tenant_name"] = normalized_tenant_name + return ( + "(" + f"{tenant_code_expr} = :{prefix}_tenant_code " + "OR (" + f"{tenant_code_expr} = '' " + f"AND {tenant_name_expr} = :{prefix}_tenant_name" + ")" + ")" + ) + params[f"{prefix}_tenant_name"] = normalized_tenant_name + return f"{tenant_name_expr} = :{prefix}_tenant_name" + + @staticmethod + def _shared_scope_legacy_names(tenant_code: str, tenant_name: str) -> list[str]: + if tenant_code == "PUBLIC": + return ["公共", "default", ""] + if tenant_code == "PROVINCIAL": + return ["省级", "省局"] + if tenant_name == "公共": + return ["公共", "default", ""] + if tenant_name == "省级": + return ["省级", "省局"] + return [] + def _default_json(self, field: str) -> dict[str, Any]: if field == "references_laws": return {"name": "", "content": "", "articles": []} diff --git a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py index 2a3af9e..069dde4 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py @@ -31,12 +31,16 @@ from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.html_renderer import from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile from fastapi_modules.fastapi_leaudit.services import IGovdocService, IOssService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver @dataclass(frozen=True) class _GovdocDocumentRow: documentId: int region: str + tenantCode: str | None + tenantName: str | None processingStatus: str currentRunId: int | None versionGroupKey: str | None @@ -72,9 +76,10 @@ class _GovdocDocumentRow: class GovdocServiceImpl(IGovdocService): """公文处理与格式审查服务实现。""" - def __init__(self, OssService: IOssService | None = None) -> None: + def __init__(self, OssService: IOssService | None = None, TenantResolverService: TenantResolver | None = None) -> None: self.OssService = OssService or OssServiceImpl() self.Storage = StorageAdapter() + self.TenantResolver = TenantResolverService or TenantResolver() def _parse_date_filter(self, value: str | None, field_name: str) -> date | None: if value is None: @@ -96,7 +101,8 @@ class GovdocServiceImpl(IGovdocService): self, file: UploadFile, typeId: int | None = None, - region: str = "default", + region: str | None = None, + tenantCode: str | None = None, autoRun: bool = True, speed: str = "normal", ruleVersionId: int | None = None, @@ -111,7 +117,12 @@ class GovdocServiceImpl(IGovdocService): if not content: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上传文件内容不能为空") - normalizedRegion = (region or "default").strip() or "default" + requested_tenant = await self.TenantResolver.Resolve( + RawValue=(region or "").strip() or None, + Source="govdoc_upload_request", + PreferredTenantCode=str(tenantCode or "").strip() or None, + ) + normalizedRegion = str(requested_tenant.tenant_name or requested_tenant.normalized_value or region or "").strip() fileName = file.filename fileExt = Path(fileName).suffix.lstrip(".").lower() or None if fileExt != "docx": @@ -128,10 +139,11 @@ class GovdocServiceImpl(IGovdocService): await self._ensureGovdocSchema(session) await self._backfill_missing_version_groups(session) currentUser = await self._getCurrentUserContext(createdBy) - resolvedRegion = self._resolve_upload_region(currentUser, normalizedRegion) + resolvedRegion = self._resolve_upload_region(currentUser, normalizedRegion, tenantCode) latestCandidate = await self._find_latest_version_candidate( session, region=resolvedRegion, + tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"), normalizedName=normalizedName, fileExt=fileExt, ) @@ -139,6 +151,7 @@ class GovdocServiceImpl(IGovdocService): latestCandidate = await self._backfill_legacy_version_chain( session, region=resolvedRegion, + tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"), normalizedName=normalizedName, fileExt=fileExt, ) @@ -163,6 +176,7 @@ class GovdocServiceImpl(IGovdocService): bizDocumentId=time.time_ns(), typeId=typeId, groupId=None, + tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"), region=resolvedRegion, processingStatus="waiting", currentRunId=None, @@ -242,6 +256,8 @@ class GovdocServiceImpl(IGovdocService): "fileId": documentFile.Id, "fileName": documentFile.fileName, "region": resolvedRegion, + "tenantCode": requested_tenant.tenant_code or currentUser.get("tenant_code"), + "tenantName": requested_tenant.tenant_name or resolvedRegion, "engineType": "govdoc", "processingStatus": "processing" if runPayload else (document.processingStatus or "waiting"), "autoRunTriggered": shouldAutoRun, @@ -256,6 +272,7 @@ class GovdocServiceImpl(IGovdocService): keyword: str | None = None, fileExt: str | None = None, region: str | None = None, + tenantCode: str | None = None, status: str | None = None, resultStatus: str | None = None, createdBy: int | None = None, @@ -267,6 +284,11 @@ class GovdocServiceImpl(IGovdocService): raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") currentUser = await self._getCurrentUserContext(userId) + requested_tenant = await self.TenantResolver.Resolve( + RawValue=(region or "").strip() or None, + Source="govdoc_list_scope", + PreferredTenantCode=str(tenantCode or "").strip() or None, + ) page = max(1, int(page)) pageSize = max(1, min(int(pageSize), 100)) offset = (page - 1) * pageSize @@ -292,7 +314,8 @@ class GovdocServiceImpl(IGovdocService): Params=params, DocumentAlias="d", FileAlias="f", - RequestedRegion=region, + RequestedRegion=requested_tenant.tenant_name or requested_tenant.normalized_value or region, + RequestedTenantCode=requested_tenant.tenant_code or tenantCode, RequestedUserId=createdBy, ) ) @@ -327,7 +350,9 @@ class GovdocServiceImpl(IGovdocService): WITH effective_docs AS ( SELECT d.id AS document_id, - COALESCE(d.region, 'default') AS region, + COALESCE(NULLIF(d.region, ''), '公共') AS region, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._tenant_name_sql('d')} AS tenant_name, COALESCE(d.processing_status, 'waiting') AS processing_status, d.current_run_id, COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key, @@ -419,28 +444,28 @@ class GovdocServiceImpl(IGovdocService): SELECT d2.id AS document_id, COUNT(*) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ) AS total_versions, ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_version_no, LAG(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_previous_version_id, FIRST_VALUE(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_root_version_id, CASE WHEN ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at DESC, d2.id DESC ) = 1 THEN true ELSE false END AS derived_is_latest_version, - md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key + md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key FROM leaudit_documents d2 JOIN leaudit_document_files f2 ON f2.document_id = d2.id @@ -565,7 +590,9 @@ class GovdocServiceImpl(IGovdocService): f""" SELECT d.id AS document_id, - COALESCE(d.region, 'default') AS region, + COALESCE(NULLIF(d.region, ''), '公共') AS region, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._tenant_name_sql('d')} AS tenant_name, COALESCE(d.processing_status, 'waiting') AS processing_status, d.current_run_id, COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key, @@ -657,28 +684,28 @@ class GovdocServiceImpl(IGovdocService): SELECT d2.id AS document_id, COUNT(*) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ) AS total_versions, ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_version_no, LAG(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_previous_version_id, FIRST_VALUE(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_root_version_id, CASE WHEN ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at DESC, d2.id DESC ) = 1 THEN true ELSE false END AS derived_is_latest_version, - md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key + md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key FROM leaudit_documents d2 JOIN leaudit_document_files f2 ON f2.document_id = d2.id @@ -767,6 +794,8 @@ class GovdocServiceImpl(IGovdocService): "mimeType": mapped.mimeType, "fileSize": mapped.fileSize, "region": mapped.region, + "tenantCode": mapped.tenantCode, + "tenantName": mapped.tenantName or mapped.region, "processingStatus": mapped.processingStatus, "versionGroupKey": mapped.versionGroupKey, "versionNo": mapped.versionNo, @@ -853,7 +882,7 @@ class GovdocServiceImpl(IGovdocService): currentUser = await self._getCurrentUserContext(triggerUserId) documentMeta = await self._get_document_for_run(documentId, triggerUserId, currentUser) if documentMeta.currentRunId and not force: - currentRun = await self.GetRunStatus(documentMeta.currentRunId) + currentRun = await self.GetRunStatus(documentMeta.currentRunId, userId=triggerUserId) if currentRun["status"] in {"pending", "processing"}: return { "runId": documentMeta.currentRunId, @@ -897,77 +926,17 @@ class GovdocServiceImpl(IGovdocService): "taskId": str(getattr(task, "id", "") or ""), } - async def GetRunStatus(self, runId: int) -> dict[str, Any]: - async with GetAsyncSession() as session: - await self._ensureGovdocSchema(session) - row = ( - await session.execute( - text( - """ - SELECT - id, - document_id, - status, - phase, - result_status, - total_score, - passed_count, - failed_count, - skipped_count, - error_message, - task_id, - created_at, - updated_at, - started_at, - finished_at - FROM govdoc_runs - WHERE id = :run_id - AND deleted_at IS NULL - LIMIT 1 - """ - ), - {"run_id": runId}, - ) - ).mappings().first() - if not row: - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在") + async def GetRunStatus(self, runId: int, userId: int | None = None) -> dict[str, Any]: + row = await self._get_scoped_run_row(runId, userId=userId) return self._build_run_summary(row) # ── 结果与报告 ──────────────────────────────────────── - async def GetRunResult(self, runId: int) -> dict[str, Any]: + async def GetRunResult(self, runId: int, userId: int | None = None) -> dict[str, Any]: """从 govdoc_runs + govdoc_rule_results 读取审查结果,含 structure/outline。""" + runRow = await self._get_scoped_run_row(runId, userId=userId) async with GetAsyncSession() as session: await self._ensureGovdocSchema(session) - runRow = ( - await session.execute( - text( - """ - SELECT - id, - document_id, - status, - phase, - total_score, - passed_count, - failed_count, - skipped_count, - result_status, - result_summary_json, - started_at, - finished_at - FROM govdoc_runs - WHERE id = :run_id - AND deleted_at IS NULL - LIMIT 1 - """ - ), - {"run_id": runId}, - ) - ).mappings().first() - if not runRow: - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在") - documentRow = ( await session.execute( text( @@ -1096,16 +1065,16 @@ class GovdocServiceImpl(IGovdocService): "entities": aux.get("entities", {}), } - async def GetRunFindings(self, runId: int) -> dict[str, Any]: - result = await self.GetRunResult(runId) + async def GetRunFindings(self, runId: int, userId: int | None = None) -> dict[str, Any]: + result = await self.GetRunResult(runId, userId=userId) return {"runId": runId, "findings": result["findings"]} - async def GetRunEntities(self, runId: int) -> dict[str, Any]: - result = await self.GetRunResult(runId) + async def GetRunEntities(self, runId: int, userId: int | None = None) -> dict[str, Any]: + result = await self.GetRunResult(runId, userId=userId) return {"runId": runId, "entities": result.get("entities", {})} - async def GetRunParagraphs(self, runId: int) -> str: - runStatus = await self.GetRunStatus(runId) + async def GetRunParagraphs(self, runId: int, userId: int | None = None) -> str: + runStatus = await self.GetRunStatus(runId, userId=userId) if runStatus["status"] != "completed": raise LeauditException( StatusCodeEnum.HTTP_409_CONFLICT, @@ -1117,7 +1086,10 @@ class GovdocServiceImpl(IGovdocService): content = await self.OssService.DownloadBytes(str(paragraphArtifact["oss_url"])) return content.decode("utf-8") - documentMeta = await self._get_document_for_read(int(runStatus["documentId"])) + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + currentUser = await self._getCurrentUserContext(userId) + documentMeta = await self._get_document_for_run(int(runStatus["documentId"]), userId, currentUser) fileRow = await self._get_active_original_file(documentMeta.documentId) ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url") @@ -1138,7 +1110,7 @@ class GovdocServiceImpl(IGovdocService): ) try: doc = parse_docx(tempPath) - findingsResult = await self.GetRunFindings(runId) + findingsResult = await self.GetRunFindings(runId, userId=userId) findingMap: dict[int, list[str]] = {} for finding in findingsResult["findings"]: pi = int(finding.get("location", {}).get("paragraph_index") or 0) @@ -1150,20 +1122,21 @@ class GovdocServiceImpl(IGovdocService): except Exception: pass - async def GetRunStructure(self, runId: int) -> dict[str, Any]: - result = await self.GetRunResult(runId) + async def GetRunStructure(self, runId: int, userId: int | None = None) -> dict[str, Any]: + result = await self.GetRunResult(runId, userId=userId) return {"runId": runId, "structure": result.get("structure", [])} - async def GetRunOutline(self, runId: int) -> dict[str, Any]: - result = await self.GetRunResult(runId) + async def GetRunOutline(self, runId: int, userId: int | None = None) -> dict[str, Any]: + result = await self.GetRunResult(runId, userId=userId) return {"runId": runId, "outline": result.get("outline", [])} - async def GetReportHtml(self, runId: int) -> dict[str, Any]: - result = await self.GetRunResult(runId) + async def GetReportHtml(self, runId: int, userId: int | None = None) -> dict[str, Any]: + result = await self.GetRunResult(runId, userId=userId) html = render_html(self._build_audit_result_from_run_result(result)) return {"runId": runId, "html": html} - async def GetReportDocx(self, runId: int) -> dict[str, Any]: + async def GetReportDocx(self, runId: int, userId: int | None = None) -> dict[str, Any]: + await self._get_scoped_run_row(runId, userId=userId) artifact = await self._get_report_artifact(runId, "annotated_docx") if not artifact: return {"runId": runId, "docxUrl": ""} @@ -1172,7 +1145,11 @@ class GovdocServiceImpl(IGovdocService): "docxUrl": await self.OssService.PresignGetUrl(str(artifact["oss_url"])), } - async def DownloadOriginal(self, documentId: int) -> dict[str, Any]: + async def DownloadOriginal(self, documentId: int, userId: int | None = None) -> dict[str, Any]: + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + currentUser = await self._getCurrentUserContext(userId) + await self._get_document_for_run(documentId, userId, currentUser) fileRow = await self._get_active_original_file(documentId) ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url") if not ossUrl: @@ -1253,6 +1230,10 @@ class GovdocServiceImpl(IGovdocService): async def _ensureGovdocSchema(self, session) -> None: statements = [ + """ + ALTER TABLE leaudit_documents + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) + """, """ ALTER TABLE leaudit_documents ADD COLUMN IF NOT EXISTS engine_type VARCHAR(32) NOT NULL DEFAULT 'leaudit' @@ -1329,6 +1310,10 @@ class GovdocServiceImpl(IGovdocService): ) """, """ + CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code + ON public.leaudit_documents(tenant_code) WHERE deleted_at IS NULL + """, + """ CREATE INDEX IF NOT EXISTS idx_leaudit_documents_engine_type ON public.leaudit_documents(engine_type) WHERE deleted_at IS NULL """, @@ -1376,13 +1361,28 @@ class GovdocServiceImpl(IGovdocService): async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]: async with GetAsyncSession() as session: await self._backfill_missing_version_groups(session) + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) row = ( await session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin @@ -1398,8 +1398,17 @@ class GovdocServiceImpl(IGovdocService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="govdoc_user_context", + ) return { - "area": str(row["area"] or ""), + "area": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_super_admin": bool(row["is_super_admin"]), @@ -1413,14 +1422,26 @@ class GovdocServiceImpl(IGovdocService): DocumentAlias: str, FileAlias: str, RequestedRegion: str | None = None, + RequestedTenantCode: str | None = None, RequestedUserId: int | None = None, ) -> list[str]: filters: list[str] = [] - requestedRegion = (RequestedRegion or "").strip() - area = str(CurrentUser["area"] or "").strip() + requestedTenantCodeNormalized, requestedRegion = self._normalize_scope_value(RequestedRegion, RequestedTenantCode, CurrentUser) + currentTenantCode = str(CurrentUser.get("tenant_code") or "").strip() + area = str(CurrentUser.get("tenant_scope_value") or CurrentUser["area"] or "").strip() if CurrentUser["is_global"]: - if requestedRegion: + if requestedTenantCodeNormalized: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenantCode=requestedTenantCodeNormalized, + region=requestedRegion, + prefix="requested", + ) + ) + elif requestedRegion: filters.append(f"{DocumentAlias}.region = :requested_region") Params["requested_region"] = requestedRegion if RequestedUserId is not None: @@ -1429,14 +1450,23 @@ class GovdocServiceImpl(IGovdocService): return filters if CurrentUser["can_manage"]: - if not area: - filters.append("1 = 0") - return filters - if requestedRegion and requestedRegion != area: - filters.append("1 = 0") - return filters - filters.append(f"{DocumentAlias}.region = :scope_region") - Params["scope_region"] = area + if not currentTenantCode and not area: + return ["1 = 0"] + effectiveTenantCode = currentTenantCode or requestedTenantCodeNormalized + effectiveRegion = area or requestedRegion + if currentTenantCode and requestedTenantCodeNormalized and requestedTenantCodeNormalized != currentTenantCode: + return ["1 = 0"] + if requestedRegion and area and requestedRegion != area: + return ["1 = 0"] + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenantCode=effectiveTenantCode or None, + region=effectiveRegion or None, + prefix="scope", + ) + ) if RequestedUserId is not None: filters.append(f"{FileAlias}.created_by = :requested_user_id") Params["requested_user_id"] = RequestedUserId @@ -1444,29 +1474,142 @@ class GovdocServiceImpl(IGovdocService): filters.append(f"{FileAlias}.created_by = :scope_user_id") Params["scope_user_id"] = CurrentUserId - if requestedRegion: - filters.append(f"{DocumentAlias}.region = :requested_region") - Params["requested_region"] = requestedRegion + if requestedTenantCodeNormalized: + if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode: + filters.append("1 = 0") + elif not currentTenantCode and area and requestedRegion != area: + filters.append("1 = 0") + else: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenantCode=(currentTenantCode or requestedTenantCodeNormalized) or None, + region=(area or requestedRegion) or None, + prefix="requested", + ) + ) + elif requestedRegion: + if area and requestedRegion != area: + filters.append("1 = 0") + elif currentTenantCode: + filters.extend( + self._document_tenant_filter_sql( + Params, + DocumentAlias=DocumentAlias, + tenantCode=currentTenantCode or None, + region=area or requestedRegion, + prefix="requested", + ) + ) + else: + filters.append(f"{DocumentAlias}.region = :requested_region") + Params["requested_region"] = requestedRegion if RequestedUserId is not None and RequestedUserId != CurrentUserId: filters.append("1 = 0") return filters - def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str) -> str: - area = str(currentUser["area"] or "").strip() + def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None, requestedTenantCode: str | None = None) -> str: + requestedTenantCodeNormalized, requestedRegion = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser) + currentTenantCode = str(currentUser.get("tenant_code") or "").strip() + area = str(currentUser.get("tenant_scope_value") or currentUser["area"] or "").strip() if currentUser["is_global"]: - return requestedRegion or area or "default" - if currentUser["can_manage"]: - if area and requestedRegion and requestedRegion != area: - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本地区") - return area or requestedRegion or "default" + return requestedRegion or area or "公共" + + if requestedTenantCodeNormalized: + if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + if not currentTenantCode and area and requestedRegion and requestedRegion != area: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + return area or requestedRegion or "公共" + if area and requestedRegion and requestedRegion != area: - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人地区") - return area or requestedRegion or "default" + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户") + return area or requestedRegion or "公共" + + def _normalize_scope_value( + self, + requestedRegion: str | None, + requestedTenantCode: str | None, + currentUser: dict[str, Any], + ) -> tuple[str | None, str]: + tenant_code = str(requestedTenantCode or "").strip() + if tenant_code: + if tenant_code == str(currentUser.get("tenant_code") or "").strip(): + return ( + str(currentUser.get("tenant_code") or "").strip() or None, + str(currentUser.get("tenant_scope_value") or currentUser.get("tenant_name") or currentUser.get("area") or "").strip(), + ) + if tenant_code == "PUBLIC": + return "PUBLIC", "公共" + if tenant_code == "PROVINCIAL": + return "PROVINCIAL", "省级" + return tenant_code or None, str(requestedRegion or "").strip() + return None, str(requestedRegion or "").strip() + + def _document_tenant_filter_sql( + self, + params: dict[str, Any], + *, + DocumentAlias: str, + tenantCode: str | None, + region: str | None, + prefix: str, + ) -> list[str]: + normalizedRegion = str(region or "").strip() + normalizedTenantCode = str(tenantCode or "").strip() + if normalizedTenantCode: + params[f"{prefix}_tenant_code"] = normalizedTenantCode + return [f"{DocumentAlias}.tenant_code = :{prefix}_tenant_code"] + if normalizedRegion: + params[f"{prefix}_region"] = normalizedRegion + return [f"{DocumentAlias}.region = :{prefix}_region"] + return ["1 = 0"] def _normalize_document_name(self, fileName: str) -> str: suffix = Path(fileName).suffix return fileName[: -len(suffix)] if suffix else fileName + def _tenant_name_sql(self, alias: str) -> str: + return ( + "CASE " + f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') = 'PUBLIC' THEN '公共' " + f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') = 'PROVINCIAL' THEN '省级' " + f"ELSE COALESCE(NULLIF(BTRIM({alias}.region), ''), '公共') " + "END" + ) + + def _version_partition_key_sql(self, doc_alias: str, file_alias: str) -> str: + version_scope_expr = self._version_scope_key_sql(doc_alias) + return ( + "CONCAT_WS('|', " + f"{version_scope_expr}, " + f"COALESCE({doc_alias}.normalized_name, ''), " + f"COALESCE({file_alias}.file_ext, '')" + ")" + ) + + def _version_scope_key_sql(self, alias: str) -> str: + return ( + "CASE " + f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') IS NOT NULL " + f"THEN CONCAT('TENANT:', NULLIF(BTRIM({alias}.tenant_code), '')) " + f"ELSE CONCAT('REGION:', COALESCE(NULLIF(BTRIM({alias}.region), ''), '公共')) " + "END" + ) + + def _tenant_version_match_sql(self, doc_alias: str, tenant_param: str, region_param: str) -> str: + return ( + "(" + f"NULLIF(BTRIM({doc_alias}.tenant_code), '') = NULLIF(BTRIM(:{tenant_param}), '') " + "OR (" + f"(:{tenant_param} = '') " + f"AND ({doc_alias}.tenant_code IS NULL OR BTRIM({doc_alias}.tenant_code) = '') " + f"AND {doc_alias}.region = :{region_param}" + ")" + ")" + ) + async def _get_document_for_run( self, documentId: int, @@ -1500,7 +1643,9 @@ class GovdocServiceImpl(IGovdocService): f""" SELECT d.id AS document_id, - COALESCE(d.region, 'default') AS region, + COALESCE(NULLIF(d.region, ''), '公共') AS region, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._tenant_name_sql('d')} AS tenant_name, COALESCE(d.processing_status, 'waiting') AS processing_status, d.current_run_id, COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key, @@ -1568,28 +1713,28 @@ class GovdocServiceImpl(IGovdocService): SELECT d2.id AS document_id, COUNT(*) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ) AS total_versions, ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_version_no, LAG(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_previous_version_id, FIRST_VALUE(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_root_version_id, CASE WHEN ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at DESC, d2.id DESC ) = 1 THEN true ELSE false END AS derived_is_latest_version, - md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key + md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key FROM leaudit_documents d2 JOIN leaudit_document_files f2 ON f2.document_id = d2.id @@ -1620,7 +1765,9 @@ class GovdocServiceImpl(IGovdocService): """ SELECT d.id AS document_id, - COALESCE(d.region, 'default') AS region, + COALESCE(NULLIF(d.region, ''), '公共') AS region, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._tenant_name_sql('d')} AS tenant_name, COALESCE(d.processing_status, 'waiting') AS processing_status, d.current_run_id, COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key, @@ -1688,28 +1835,28 @@ class GovdocServiceImpl(IGovdocService): SELECT d2.id AS document_id, COUNT(*) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ) AS total_versions, ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_version_no, LAG(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_previous_version_id, FIRST_VALUE(d2.id) OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at ASC, d2.id ASC ) AS derived_root_version_id, CASE WHEN ROW_NUMBER() OVER ( - PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '') + PARTITION BY {self._version_partition_key_sql('d2', 'f2')} ORDER BY d2.created_at DESC, d2.id DESC ) = 1 THEN true ELSE false END AS derived_is_latest_version, - md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key + md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key FROM leaudit_documents d2 JOIN leaudit_document_files f2 ON f2.document_id = d2.id @@ -1734,6 +1881,74 @@ class GovdocServiceImpl(IGovdocService): raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在") return self._map_document_row(row) + async def _get_scoped_run_row(self, runId: int, *, userId: int | None) -> dict[str, Any]: + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + + currentUser = await self._getCurrentUserContext(userId) + params: dict[str, Any] = {"run_id": runId} + filters = [ + "gr.id = :run_id", + "gr.deleted_at IS NULL", + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + "f.is_active = true", + "f.file_role = 'original'", + "COALESCE(d.engine_type, 'leaudit') = 'govdoc'", + ] + filters.extend( + self._buildDocumentScopeFilters( + CurrentUserId=userId, + CurrentUser=currentUser, + Params=params, + DocumentAlias="d", + FileAlias="f", + ) + ) + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + row = ( + await session.execute( + text( + f""" + SELECT + gr.id, + gr.document_id, + gr.status, + gr.phase, + gr.result_status, + gr.total_score, + gr.passed_count, + gr.failed_count, + gr.skipped_count, + gr.error_message, + gr.task_id, + gr.result_summary_json, + gr.created_at, + gr.updated_at, + gr.started_at, + gr.finished_at + FROM govdoc_runs gr + JOIN leaudit_documents d + ON d.id = gr.document_id + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + WHERE {whereClause} + LIMIT 1 + """ + ), + params, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在或无权访问") + return dict(row) + async def _get_active_original_file(self, documentId: int): async with GetAsyncSession() as session: row = ( @@ -1866,7 +2081,9 @@ class GovdocServiceImpl(IGovdocService): return _GovdocDocumentRow( documentId=int(row["document_id"]), - region=str(row["region"] or "default"), + region=str(row["region"] or "公共"), + tenantCode=str(row["tenant_code"]) if row.get("tenant_code") else None, + tenantName=str(row["tenant_name"]) if row.get("tenant_name") else None, processingStatus=str(row["processing_status"] or "waiting"), currentRunId=int(row["current_run_id"]) if row.get("current_run_id") is not None else None, versionGroupKey=str(row["version_group_key"]) if row.get("version_group_key") else None, @@ -1921,6 +2138,8 @@ class GovdocServiceImpl(IGovdocService): "mimeType": mapped.mimeType, "fileSize": mapped.fileSize, "region": mapped.region, + "tenantCode": mapped.tenantCode, + "tenantName": mapped.tenantName or mapped.region, "processingStatus": mapped.processingStatus, "currentRunId": mapped.currentRunId, "latestRunId": mapped.currentRunId, @@ -1955,12 +2174,20 @@ class GovdocServiceImpl(IGovdocService): session, *, region: str, + tenantCode: str | None = None, normalizedName: str, fileExt: str | None, ) -> dict[str, Any] | None: + resolved_tenant = await self.TenantResolver.Resolve( + RawValue=region, + Source="govdoc_version_lookup", + PreferredTenantCode=str(tenantCode or "").strip() or None, + ) + normalized_region = str(resolved_tenant.tenant_name or resolved_tenant.normalized_value or region or "公共").strip() or "公共" extClause = "" params: dict[str, Any] = { - "region": region, + "tenant_code": str(resolved_tenant.tenant_code or "").strip(), + "region": normalized_region, "normalized_name": normalizedName, } if fileExt: @@ -1985,7 +2212,7 @@ class GovdocServiceImpl(IGovdocService): WHERE d.deleted_at IS NULL AND d.review_scope = 'govdoc' AND COALESCE(d.engine_type, 'leaudit') = 'govdoc' - AND d.region = :region + AND {self._tenant_version_match_sql('d', 'tenant_code', 'region')} AND COALESCE(d.normalized_name, '') = :normalized_name AND COALESCE(d.is_latest_version, true) = true{extClause} ORDER BY d.version_no DESC, d.id DESC @@ -2002,12 +2229,20 @@ class GovdocServiceImpl(IGovdocService): session, *, region: str, + tenantCode: str | None = None, normalizedName: str, fileExt: str | None, ) -> dict[str, Any] | None: + resolved_tenant = await self.TenantResolver.Resolve( + RawValue=region, + Source="govdoc_version_backfill", + PreferredTenantCode=str(tenantCode or "").strip() or None, + ) + normalized_region = str(resolved_tenant.tenant_name or resolved_tenant.normalized_value or region or "公共").strip() or "公共" extClause = "" params: dict[str, Any] = { - "region": region, + "tenant_code": str(resolved_tenant.tenant_code or "").strip(), + "region": normalized_region, "normalized_name": normalizedName, } if fileExt: @@ -2028,7 +2263,7 @@ class GovdocServiceImpl(IGovdocService): WHERE d.deleted_at IS NULL AND d.review_scope = 'govdoc' AND COALESCE(d.engine_type, 'leaudit') = 'govdoc' - AND d.region = :region + AND {self._tenant_version_match_sql('d', 'tenant_code', 'region')} AND COALESCE(d.normalized_name, '') = :normalized_name{extClause} ORDER BY d.created_at ASC, d.id ASC """ @@ -2085,7 +2320,9 @@ class GovdocServiceImpl(IGovdocService): text( """ SELECT - d.region, + COALESCE(NULLIF(BTRIM(d.region), ''), '公共') AS region, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + CASE WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '公共') END AS tenant_name, COALESCE(d.normalized_name, '') AS normalized_name, COALESCE(f.file_ext, '') AS file_ext, ARRAY_AGG(d.id ORDER BY d.created_at ASC, d.id ASC) AS document_ids, @@ -2099,7 +2336,7 @@ class GovdocServiceImpl(IGovdocService): WHERE d.deleted_at IS NULL AND d.review_scope = 'govdoc' AND COALESCE(d.engine_type, 'leaudit') = 'govdoc' - GROUP BY d.region, COALESCE(d.normalized_name, ''), COALESCE(f.file_ext, '') + GROUP BY COALESCE(NULLIF(BTRIM(d.region), ''), '公共'), COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL), CASE WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '公共') END, COALESCE(d.normalized_name, ''), COALESCE(f.file_ext, '') HAVING BOOL_OR(COALESCE(d.version_group_key, '') = '') """ ) @@ -2110,7 +2347,9 @@ class GovdocServiceImpl(IGovdocService): return for group in groups: - region = str(group["region"] or "default") + region = str(group["region"] or "公共") + tenantCode = str(group.get("tenant_code") or "").strip() or None + tenantName = str(group.get("tenant_name") or region).strip() or region normalizedName = str(group["normalized_name"] or "") fileExt = str(group["file_ext"] or "") documentIds = [int(value) for value in (group["document_ids"] or [])] @@ -2118,6 +2357,8 @@ class GovdocServiceImpl(IGovdocService): continue versionGroupKey = str(group["existing_version_group_key"] or "").strip() or self._derive_version_group_key( + tenantCode=tenantCode, + tenantName=tenantName, region=region, normalizedName=normalizedName, fileExt=fileExt or None, @@ -2153,8 +2394,21 @@ class GovdocServiceImpl(IGovdocService): await session.commit() - def _derive_version_group_key(self, *, region: str, normalizedName: str, fileExt: str | None) -> str: - raw = f"{region}|{normalizedName}|{fileExt or ''}" + def _derive_version_group_key( + self, + *, + tenantCode: str | None, + tenantName: str | None, + region: str, + normalizedName: str, + fileExt: str | None, + ) -> str: + versionScopeKey = ( + f"TENANT:{tenantCode.strip()}" + if str(tenantCode or "").strip() + else f"REGION:{(tenantName or region or '公共').strip() or '公共'}" + ) + raw = f"{versionScopeKey}|{normalizedName}|{fileExt or ''}" return hashlib.md5(raw.encode("utf-8")).hexdigest() async def _resolve_ruleset_metadata(self, rulesPath: str | None) -> dict[str, str]: diff --git a/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py index f0c48b3..630c9e0 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py @@ -8,9 +8,16 @@ from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException -from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryAreaVO, HomeEntryDocumentTypeVO, HomeEntryModuleVO +from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import ( + HomeEntryAreaVO, + HomeEntryDocumentTypeVO, + HomeEntryModuleVO, + HomeEntryTenantVO, +) from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver class HomeServiceImpl(IHomeService): @@ -26,18 +33,37 @@ class HomeServiceImpl(IHomeService): def __init__(self) -> None: self.RbacService = RbacServiceImpl() + self.TenantResolver = TenantResolver() + self._entry_module_tenant_table_exists_cache: bool | None = None async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]: """获取当前用户可见的首页入口模块。""" - allowedPaths = await self._loadAllowedPaths(UserId=UserId) - async with GetAsyncSession() as Session: - userResult = await Session.execute( + allowed_paths = await self._loadAllowedPaths(UserId=UserId) + has_tenant_mapping_table = await self._entry_module_tenant_table_exists() + + async with GetAsyncSession() as session: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + user_result = await session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, - COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS bypass_area + {tenant_code_select}, + {tenant_name_select}, + COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS bypass_tenant FROM sso_users u LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id @@ -49,18 +75,113 @@ class HomeServiceImpl(IHomeService): ), {"user_id": UserId}, ) - userRow = userResult.mappings().first() - if not userRow: + user_row = user_result.mappings().first() + if not user_row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用") - result = await Session.execute( - text( - """ - WITH user_roles AS ( - SELECT ur.role_id - FROM user_role ur - WHERE ur.user_id = :user_id + tenant_resolution = await self.TenantResolver.ResolveUserContext( + Area=str(user_row["area"] or ""), + TenantCode=str(user_row["tenant_code"] or ""), + TenantName=str(user_row["tenant_name"] or ""), + Source="home_entry_user", + ) + effective_tenant_code = tenant_resolution.tenant_code or "" + effective_tenant_name = ( + str(tenant_resolution.tenant_name or tenant_resolution.normalized_value or user_row["area"] or "").strip() + ) + legacy_area_candidates = [ + candidate + for candidate in { + str(user_row["area"] or "").strip(), + effective_tenant_name, + } + if candidate + ] + tenant_select_sql = ( + """ + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'tenant_code', emt.tenant_code, + 'tenant_name', emt.tenant_name, + 'enabled', emt.is_enabled, + 'sort_order', emt.sort_order + ) + ORDER BY emt.sort_order ASC, emt.id ASC + ) + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = em.id + AND emt.deleted_at IS NULL + ), + '[]'::jsonb + ) AS tenants + """ + if has_tenant_mapping_table + else "'[]'::jsonb AS tenants" + ) + tenant_scope_filter_sql = ( + """ + ( + :bypass_tenant = TRUE + OR EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = em.id + AND emt.deleted_at IS NULL + AND emt.is_enabled = TRUE + AND ( + emt.tenant_code = :user_tenant_code + OR emt.tenant_code = 'PUBLIC' + ) ) + OR ( + NOT EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt0 + WHERE emt0.entry_module_id = em.id + AND emt0.deleted_at IS NULL + ) + AND ( + EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' = ANY(:legacy_area_candidates) + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' IN ('default', '公共') + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + ) + ) + ) + """ + if has_tenant_mapping_table + else """ + ( + :bypass_tenant = TRUE + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' = ANY(:legacy_area_candidates) + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item + WHERE area_item->>'area' IN ('default', '公共') + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + ) + """ + ) + + result = await session.execute( + text( + f""" SELECT em.id, em.name, @@ -69,6 +190,7 @@ class HomeServiceImpl(IHomeService): em.icon_path, em.areas, em.sort_order, + {tenant_select_sql}, COALESCE( json_agg( json_build_object( @@ -89,24 +211,7 @@ class HomeServiceImpl(IHomeService): ON dt.entry_module_id = em.id WHERE em.deleted_at IS NULL AND em.is_enabled = TRUE - AND ( - :bypass_area = TRUE - OR COALESCE(:user_area, '') = '' - OR em.areas IS NULL - OR jsonb_typeof(em.areas) <> 'array' - OR EXISTS ( - SELECT 1 - FROM jsonb_array_elements(em.areas) AS area_item - WHERE area_item->>'area' = :user_area - AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE - ) - OR EXISTS ( - SELECT 1 - FROM jsonb_array_elements(em.areas) AS area_item - WHERE area_item->>'area' = 'default' - AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE - ) - ) + AND {tenant_scope_filter_sql} GROUP BY em.id, em.name, @@ -119,82 +224,183 @@ class HomeServiceImpl(IHomeService): """ ), { - "user_id": UserId, - "user_area": str(userRow["area"] or ""), - "bypass_area": bool(userRow["bypass_area"]), + "user_tenant_code": effective_tenant_code, + "legacy_area_candidates": legacy_area_candidates, + "bypass_tenant": bool(user_row["bypass_tenant"]), }, ) modules: list[HomeEntryModuleVO] = [] for row in result.mappings().all(): - areas: list[HomeEntryAreaVO] = [] - rawAreas = row["areas"] - if isinstance(rawAreas, list): - for areaItem in rawAreas: - if isinstance(areaItem, dict) and areaItem.get("area"): - areas.append( - HomeEntryAreaVO( - area=str(areaItem["area"]), - enabled=bool(areaItem.get("enabled", False)), - sortOrder=int(areaItem.get("sort_order", 0)), + tenants: list[HomeEntryTenantVO] = [] + raw_tenants = row["tenants"] + if isinstance(raw_tenants, list): + for item in raw_tenants: + if isinstance(item, dict) and item.get("tenant_code"): + tenants.append( + HomeEntryTenantVO( + tenantCode=str(item["tenant_code"]), + tenantName=item.get("tenant_name"), + enabled=bool(item.get("enabled", False)), + sortOrder=int(item.get("sort_order", 0)), ) ) + if not tenants: + raw_areas = row["areas"] + if isinstance(raw_areas, list): + for index, area_item in enumerate(raw_areas, start=1): + if not isinstance(area_item, dict) or not area_item.get("area"): + continue + resolution = await self._resolveLegacyTenantValue( + RawValue=str(area_item.get("area") or ""), + Source="home_entry_legacy_area", + ) + tenants.append( + HomeEntryTenantVO( + tenantCode=resolution.tenant_code, + tenantName=resolution.tenant_name, + enabled=bool(area_item.get("enabled", False)), + sortOrder=int(area_item.get("sort_order", index)), + ) + ) + tenants.sort(key=lambda item: (item.sortOrder, item.tenantCode)) - documentTypes: list[HomeEntryDocumentTypeVO] = [] - rawDocumentTypes = row["document_types"] - if isinstance(rawDocumentTypes, list): - for documentType in rawDocumentTypes: - if isinstance(documentType, dict) and documentType.get("id") is not None: - documentTypes.append( + areas: list[HomeEntryAreaVO] = [ + HomeEntryAreaVO( + area=item.tenantName or item.tenantCode, + enabled=item.enabled, + sortOrder=item.sortOrder, + ) + for item in tenants + ] + + document_types: list[HomeEntryDocumentTypeVO] = [] + raw_document_types = row["document_types"] + if isinstance(raw_document_types, list): + for document_type in raw_document_types: + if isinstance(document_type, dict) and document_type.get("id") is not None: + document_types.append( HomeEntryDocumentTypeVO( - id=int(documentType["id"]), - name=str(documentType["name"]), - code=documentType.get("code"), + id=int(document_type["id"]), + name=str(document_type["name"]), + code=document_type.get("code"), ) ) - targetPath = self._normalizeTargetPath( + target_path = self._normalizeTargetPath( RawPath=str(row["path"] or ""), - HasDocumentTypes=len(documentTypes) > 0, + HasDocumentTypes=len(document_types) > 0, ) - if not targetPath: + if not target_path: continue - if not self._isAllowedTargetPath(targetPath, allowedPaths): + if not self._isAllowedTargetPath(target_path, allowed_paths): continue - requiresDocumentTypes = targetPath not in {"/chat-with-llm/chat", "/cross-checking"} + requires_document_types = target_path not in {"/chat-with-llm/chat", "/cross-checking"} modules.append( HomeEntryModuleVO( id=int(row["id"]), name=str(row["name"]), description=row["description"], - targetPath=targetPath, - routePath=targetPath, + targetPath=target_path, + routePath=target_path, iconPath=row["icon_path"], sortOrder=int(row["sort_order"] or 0), - requiresDocumentTypes=requiresDocumentTypes, + requiresDocumentTypes=requires_document_types, areas=areas, - documentTypes=documentTypes, + tenants=tenants, + documentTypes=document_types, ) ) return modules + async def _entry_module_tenant_table_exists(self) -> bool: + if self._entry_module_tenant_table_exists_cache is not None: + return self._entry_module_tenant_table_exists_cache + async with GetAsyncSession() as session: + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'leaudit_entry_module_tenants' + ) + """ + ) + ) + ).scalar_one() + ) + self._entry_module_tenant_table_exists_cache = exists + return exists + + async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution: + resolution = await self.TenantResolver.Resolve( + RawValue=RawValue, + Source=Source, + ) + if resolution.tenant_code: + return resolution + + normalized = str(RawValue or "").strip() + if normalized in {"", "default", "省级", "公共"}: + public_resolution = await self.TenantResolver.Resolve( + RawValue="", + Source=Source, + ) + if public_resolution.tenant_code: + return public_resolution + return TenantResolution( + tenant_code="PUBLIC", + tenant_name="公共", + tenant_type="PUBLIC", + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=True, + ) + if normalized == "省局": + provincial_resolution = await self.TenantResolver.Resolve(RawValue="省局", Source=Source) + if provincial_resolution.tenant_code: + return provincial_resolution + return TenantResolution( + tenant_code="PROVINCIAL", + tenant_name="省局", + tenant_type="PROVINCIAL", + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=False, + ) + return TenantResolution( + tenant_code=normalized or None, + tenant_name=normalized or None, + tenant_type="LEGACY_AREA" if normalized else None, + raw_value=RawValue, + normalized_value=normalized, + source=Source, + is_public=False, + ) + async def _loadAllowedPaths(self, UserId: int) -> set[str]: """加载当前用户在首页可点击的目标路径集合。""" - routesVo = await self.RbacService.GetCurrentUserRoutes(UserId=UserId) - allowedPaths: set[str] = set() + routes_vo = await self.RbacService.GetCurrentUserRoutes(UserId=UserId) + allowed_paths: set[str] = set() def collect(items) -> None: for item in items: - allowedPaths.add(str(item.route_path or "")) + allowed_paths.add(str(item.route_path or "")) if item.children: collect(item.children) - collect(routesVo.routes) - return {path for path in allowedPaths if path} + collect(routes_vo.routes) + return {path for path in allowed_paths if path} def _isAllowedTargetPath(self, TargetPath: str, AllowedPaths: set[str]) -> bool: """判断首页目标路径是否被当前用户路由树覆盖。""" @@ -212,8 +418,8 @@ class HomeServiceImpl(IHomeService): return "/files/upload" if any( - RawPath == enabledPath or RawPath.startswith(f"{enabledPath}/") - for enabledPath in self._MINIMAL_ENABLED_TARGETS + RawPath == enabled_path or RawPath.startswith(f"{enabled_path}/") + for enabled_path in self._MINIMAL_ENABLED_TARGETS ): return RawPath diff --git a/fastapi_modules/fastapi_leaudit/services/impl/pageQualityServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/pageQualityServiceImpl.py new file mode 100644 index 0000000..5d16679 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/pageQualityServiceImpl.py @@ -0,0 +1,555 @@ +"""页级图片质量服务实现。""" + +from __future__ import annotations + +from typing import Any +from pathlib import Path +import tempfile +import logging + +import fitz +from leaudit.converters import doc2pdf +from sqlalchemy import text + +from fastapi_admin.config import LEAUDIT_PAGE_QUALITY_ENABLED +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException +from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import ( + PageQualityDetailVO, + PageQualityPageResultVO, + PageQualityRecheckVO, + PageQualitySummaryVO, +) +from fastapi_modules.fastapi_leaudit.services.pageQualityService import IPageQualityService +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl + +logger = logging.getLogger(__name__) + + +class PageQualityServiceImpl(IPageQualityService): + """页级图片质量服务实现。""" + + def __init__(self) -> None: + self.OssService = OssServiceImpl() + self.DocumentService = None + + async def DispatchForDocument( + self, + DocumentId: int, + TriggerUserId: int | None = None, + Force: bool = False, + Speed: str = "normal", + ) -> PageQualityRecheckVO | None: + """按文档触发页级模糊检测任务。""" + await self._ensureTables() + if not bool(LEAUDIT_PAGE_QUALITY_ENABLED): + return None + + async with GetAsyncSession() as session: + if not Force: + active = ( + await session.execute( + text( + """ + SELECT id, status + FROM leaudit_page_quality_runs + WHERE document_id = :document_id + AND status IN ('queued', 'running') + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + if active: + return PageQualityRecheckVO( + runId=int(active["id"]), + documentId=DocumentId, + status=str(active["status"] or "queued"), + ) + + file_row = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_document_files + WHERE document_id = :document_id + AND is_active = true + AND file_role = 'primary' + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + document_file_id = int(file_row["id"]) if file_row and file_row.get("id") is not None else None + + run_row = ( + await session.execute( + text( + """ + INSERT INTO leaudit_page_quality_runs ( + document_id, + document_file_id, + status, + created_by, + created_at, + updated_at + ) VALUES ( + :document_id, + :document_file_id, + 'queued', + :created_by, + NOW(), + NOW() + ) + RETURNING id + """ + ), + { + "document_id": DocumentId, + "document_file_id": document_file_id, + "created_by": TriggerUserId, + }, + ) + ).mappings().first() + await session.commit() + + run_id = int(run_row["id"]) + from fastapi_modules.fastapi_leaudit.page_quality.tasks import dispatch_page_quality_task + + task_id = dispatch_page_quality_task(run_id, speed=Speed) + async with GetAsyncSession() as session: + await session.execute( + text( + """ + UPDATE leaudit_page_quality_runs + SET task_id = :task_id, + updated_at = NOW() + WHERE id = :run_id + """ + ), + {"run_id": run_id, "task_id": task_id}, + ) + await session.commit() + + return PageQualityRecheckVO(runId=run_id, documentId=DocumentId, status="queued") + + async def GetDocumentSummary( + self, + CurrentUserId: int, + DocumentId: int, + ) -> PageQualitySummaryVO: + """获取文档页级模糊检测摘要。""" + await self._document_service().GetDocument(CurrentUserId=CurrentUserId, Id=DocumentId) + await self._ensureTables() + return await self._load_summary(DocumentId) + + async def GetDocumentDetail( + self, + CurrentUserId: int, + DocumentId: int, + ) -> PageQualityDetailVO: + """获取文档页级模糊检测详情。""" + await self._document_service().GetDocument(CurrentUserId=CurrentUserId, Id=DocumentId) + await self._ensureTables() + summary = await self._load_summary(DocumentId) + results: list[PageQualityPageResultVO] = [] + if summary.runId is not None: + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + """ + SELECT page_num, quality_status, quality_score, reason_text + FROM leaudit_page_quality_results + WHERE run_id = :run_id + AND quality_status IN ('review', 'reject') + ORDER BY page_num ASC, id ASC + """ + ), + {"run_id": summary.runId}, + ) + ).mappings().all() + results = [ + PageQualityPageResultVO( + pageNum=int(row["page_num"]), + qualityStatus=str(row["quality_status"] or "review"), + qualityScore=float(row["quality_score"]) if row["quality_score"] is not None else None, + reasonText=str(row["reason_text"] or "") or None, + ) + for row in rows + ] + return PageQualityDetailVO(summary=summary, results=results) + + async def RecheckDocument( + self, + CurrentUserId: int, + DocumentId: int, + Speed: str = "normal", + ) -> PageQualityRecheckVO: + """手工重跑页级模糊检测。""" + await self._document_service().GetDocument(CurrentUserId=CurrentUserId, Id=DocumentId) + payload = await self.DispatchForDocument( + DocumentId=DocumentId, + TriggerUserId=CurrentUserId, + Force=True, + Speed=Speed, + ) + if payload is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "页级模糊检测未启用") + return payload + + async def ExecuteRun(self, RunId: int) -> dict[str, Any]: + """执行一次页级模糊检测。""" + await self._ensureTables() + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT r.id, + r.document_id, + r.document_file_id, + f.file_name, + f.file_ext, + f.oss_url, + f.local_path + FROM leaudit_page_quality_runs r + LEFT JOIN leaudit_document_files f + ON f.id = r.document_file_id + WHERE r.id = :run_id + AND r.deleted_at IS NULL + LIMIT 1 + """ + ), + {"run_id": RunId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "页级模糊检测运行不存在") + await session.execute( + text( + """ + UPDATE leaudit_page_quality_runs + SET status = 'running', + started_at = COALESCE(started_at, NOW()), + updated_at = NOW() + WHERE id = :run_id + """ + ), + {"run_id": RunId}, + ) + await session.execute( + text("DELETE FROM leaudit_page_quality_results WHERE run_id = :run_id"), + {"run_id": RunId}, + ) + await session.commit() + + document_id = int(row["document_id"]) + file_name = str(row["file_name"] or "") + suffix = f".{str(row['file_ext']).lstrip('.')}" if row.get("file_ext") else Path(file_name).suffix + temp_path: str | None = None + converted_path: str | None = None + try: + local_path = str(row["local_path"] or "").strip() + if local_path and Path(local_path).is_file(): + source_path = Path(local_path) + else: + oss_url = str(row["oss_url"] or "").strip() + if not oss_url: + await self._mark_skipped(RunId, "no_source") + return {"runId": RunId, "documentId": document_id, "status": "skipped", "reason": "no_source"} + temp_path = await self.OssService.DownloadToTempFile( + oss_url, + Suffix=suffix or "", + Prefix=f"page-quality-{document_id}-", + ) + source_path = Path(temp_path) + + page_images = self._build_page_images(source_path) + if not page_images: + await self._mark_skipped(RunId, "no_pages") + return {"runId": RunId, "documentId": document_id, "status": "skipped", "reason": "no_pages"} + + total_pages = len(page_images) + review_pages = 0 + reject_pages = 0 + async with GetAsyncSession() as session: + for page_num, page_image in page_images: + status, score, reason = self._classify_page_image(page_image) + if status == "review": + review_pages += 1 + elif status == "reject": + reject_pages += 1 + await session.execute( + text( + """ + INSERT INTO leaudit_page_quality_results ( + run_id, + document_id, + page_num, + quality_status, + quality_score, + reason_text, + created_at, + updated_at + ) VALUES ( + :run_id, + :document_id, + :page_num, + :quality_status, + :quality_score, + :reason_text, + NOW(), + NOW() + ) + """ + ), + { + "run_id": RunId, + "document_id": document_id, + "page_num": page_num, + "quality_status": status, + "quality_score": score, + "reason_text": reason, + }, + ) + summary_status = "reject" if reject_pages > 0 else ("review" if review_pages > 0 else "pass") + await session.execute( + text( + """ + UPDATE leaudit_page_quality_runs + SET status = 'completed', + summary_status = :summary_status, + total_pages = :total_pages, + review_page_count = :review_page_count, + reject_page_count = :reject_page_count, + finished_at = NOW(), + updated_at = NOW() + WHERE id = :run_id + """ + ), + { + "run_id": RunId, + "summary_status": summary_status, + "total_pages": total_pages, + "review_page_count": review_pages, + "reject_page_count": reject_pages, + }, + ) + await session.commit() + + return { + "runId": RunId, + "documentId": document_id, + "status": "completed", + "summaryStatus": summary_status, + "totalPages": total_pages, + "reviewPageCount": review_pages, + "rejectPageCount": reject_pages, + } + except Exception as exc: + async with GetAsyncSession() as session: + await session.execute( + text( + """ + UPDATE leaudit_page_quality_runs + SET status = 'failed', + error_message = :error_message, + finished_at = NOW(), + updated_at = NOW() + WHERE id = :run_id + """ + ), + {"run_id": RunId, "error_message": str(exc)[:2000]}, + ) + await session.commit() + raise + finally: + if temp_path: + Path(temp_path).unlink(missing_ok=True) + + async def _load_summary(self, DocumentId: int) -> PageQualitySummaryVO: + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT + id, + status, + summary_status, + total_pages, + review_page_count, + reject_page_count, + finished_at + FROM leaudit_page_quality_runs + WHERE document_id = :document_id + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + if not row: + return PageQualitySummaryVO() + page_rows = ( + await session.execute( + text( + """ + SELECT DISTINCT page_num + FROM leaudit_page_quality_results + WHERE run_id = :run_id + AND quality_status IN ('review', 'reject') + ORDER BY page_num ASC + """ + ), + {"run_id": int(row["id"])}, + ) + ).mappings().all() + pages = [int(item["page_num"]) for item in page_rows] + summary_status = str(row["summary_status"] or "") or None + return PageQualitySummaryVO( + runId=int(row["id"]), + runStatus=str(row["status"] or "") or None, + summaryStatus=summary_status, + totalPages=int(row["total_pages"] or 0), + reviewPageCount=int(row["review_page_count"] or 0), + rejectPageCount=int(row["reject_page_count"] or 0), + warningText=self._build_warning_text(pages, summary_status), + pages=pages, + finishedAt=row["finished_at"].isoformat() if row["finished_at"] else None, + ) + + def _build_warning_text(self, pages: list[int], summary_status: str | None) -> str | None: + if not pages or not summary_status: + return None + pages_text = "、".join(f"第 {page} 页" for page in pages[:10]) + suffix = "建议重拍" if summary_status == "reject" else "疑似模糊" + if len(pages) > 10: + pages_text = f"{pages_text} 等" + return f"{pages_text}{suffix}" + + def _build_page_images(self, source_path: Path) -> list[tuple[int, bytes]]: + suffix = source_path.suffix.lower() + if suffix in {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}: + return [(1, source_path.read_bytes())] + if suffix == ".pdf": + return self._render_pdf_pages(source_path) + if suffix in {".doc", ".docx", ".wps"}: + temp_pdf = tempfile.NamedTemporaryFile(prefix="page-quality-docx-", suffix=".pdf", delete=False) + temp_pdf.close() + doc2pdf.convert(str(source_path), temp_pdf.name, soffice="auto", pdfa=False, force=True, verify=False) + try: + return self._render_pdf_pages(Path(temp_pdf.name)) + finally: + Path(temp_pdf.name).unlink(missing_ok=True) + return [] + + def _render_pdf_pages(self, path: Path) -> list[tuple[int, bytes]]: + doc = fitz.open(path) + try: + pages: list[tuple[int, bytes]] = [] + for index in range(len(doc)): + page = doc[index] + pix = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False) + pages.append((index + 1, pix.tobytes("png"))) + return pages + finally: + doc.close() + + def _classify_page_image(self, image_bytes: bytes) -> tuple[str, float, str | None]: + size = len(image_bytes) + if size < 25_000: + return "reject", 0.2, "页面图像内容过少或清晰度较低,建议重拍" + if size < 60_000: + return "review", 0.45, "页面疑似存在模糊,建议人工确认" + return "pass", 0.9, None + + def _document_service(self): + if self.DocumentService is None: + from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl + + self.DocumentService = DocumentServiceImpl() + return self.DocumentService + + async def _mark_skipped(self, RunId: int, SkipReason: str) -> None: + async with GetAsyncSession() as session: + await session.execute( + text( + """ + UPDATE leaudit_page_quality_runs + SET status = 'skipped', + skip_reason = :skip_reason, + finished_at = NOW(), + updated_at = NOW() + WHERE id = :run_id + """ + ), + {"run_id": RunId, "skip_reason": SkipReason}, + ) + await session.commit() + + async def _ensureTables(self) -> None: + async with GetAsyncSession() as session: + await session.execute( + text( + """ + CREATE TABLE IF NOT EXISTS public.leaudit_page_quality_runs ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL, + document_file_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + summary_status VARCHAR(32) NULL, + total_pages INTEGER NOT NULL DEFAULT 0, + review_page_count INTEGER NOT NULL DEFAULT 0, + reject_page_count INTEGER NOT NULL DEFAULT 0, + skip_reason VARCHAR(64) NULL, + task_id VARCHAR(128) NULL, + error_message TEXT NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_by BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL + ) + """ + ) + ) + await session.execute( + text( + """ + CREATE TABLE IF NOT EXISTS public.leaudit_page_quality_results ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + page_num INTEGER NOT NULL, + quality_status VARCHAR(32) NOT NULL, + quality_score NUMERIC(10, 4) NULL, + reason_text TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + ) + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_leaudit_page_quality_runs_document_id ON public.leaudit_page_quality_runs(document_id)" + ) + ) + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_leaudit_page_quality_results_run_id ON public.leaudit_page_quality_results(run_id)" + ) + ) + await session.commit() diff --git a/fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py index f25333d..7b016b9 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py @@ -17,8 +17,26 @@ from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissi class PermissionServiceImpl(IPermissionService): """权限检查服务实现。""" + _GLOBAL_PERMISSION_CACHE: dict[int, tuple[float, tuple[set[str], set[str]]]] = {} + def __init__(self) -> None: - self._permission_cache: dict[int, tuple[float, tuple[set[str], set[str]]]] = {} + self._permission_cache = self.__class__._GLOBAL_PERMISSION_CACHE + + @classmethod + def InvalidateUser(cls, UserId: int) -> None: + """清理指定用户权限缓存。""" + cls._GLOBAL_PERMISSION_CACHE.pop(UserId, None) + + @classmethod + def InvalidateUsers(cls, UserIds: list[int]) -> None: + """批量清理用户权限缓存。""" + for userId in UserIds: + cls.InvalidateUser(int(userId)) + + @classmethod + def InvalidateAll(cls) -> None: + """清理全部权限缓存。角色权限批量变更后使用。""" + cls._GLOBAL_PERMISSION_CACHE.clear() async def CheckPermission(self, UserId: int, PermissionKey: str) -> bool: """检查用户是否拥有指定权限。 diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py index bcb8641..2c3fb9c 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py @@ -39,6 +39,7 @@ from fastapi_modules.fastapi_leaudit.rag_engine.chroma_client import get_chroma from fastapi_modules.fastapi_leaudit.rag_engine.generator import generate_stream from fastapi_modules.fastapi_leaudit.rag_engine.question_chains import generate_followups from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver DEFAULT_CONVERSATION_NAME = "新对话" @@ -54,15 +55,40 @@ class RagChatServiceImpl(IRagChatService): _task_locks: dict[str, asyncio.Lock] = {} _title_tasks: dict[str, asyncio.Task] = {} - async def GetApps(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppListVO: - apps = await self._load_apps(UserArea, UserRole, only_default=False) + def __init__(self) -> None: + self.TenantResolver = TenantResolver() + + _APP_TENANT_NAME_SQL = ( + "CASE " + "WHEN NULLIF(BTRIM(a.tenant_code), '') = 'PUBLIC' THEN '公共' " + "WHEN NULLIF(BTRIM(a.tenant_code), '') = 'PROVINCIAL' THEN '省级' " + "ELSE COALESCE(NULLIF(BTRIM(a.area), ''), '未分配地区') " + "END" + ) + + async def GetApps( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagChatAppListVO: + apps = await self._load_apps(UserArea, UserRole, TenantCode, TenantName, only_default=False) return RagChatAppListVO(data=apps, total=len(apps)) - async def GetDefaultApp(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppVO | None: - apps = await self._load_apps(UserArea, UserRole, only_default=True) + async def GetDefaultApp( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagChatAppVO | None: + apps = await self._load_apps(UserArea, UserRole, TenantCode, TenantName, only_default=True) if apps: return apps[0] - all_apps = await self._load_apps(UserArea, UserRole, only_default=False) + all_apps = await self._load_apps(UserArea, UserRole, TenantCode, TenantName, only_default=False) return all_apps[0] if all_apps else None async def SendMessage( @@ -74,15 +100,25 @@ class RagChatServiceImpl(IRagChatService): Query: str, ConversationId: str | None, AppId: int | None, + TenantCode: str | None = None, + TenantName: str | None = None, ) -> AsyncGenerator[bytes, None]: if not Query.strip(): raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "问题不能为空") - app = await self._resolve_app(AppId, UserArea, UserRole) + app = await self._resolve_app(AppId, UserArea, UserRole, TenantCode, TenantName) if not app: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "未配置可用聊天应用") - conversationId = await self._ensure_conversation(CurrentUserId, ConversationId, app["id"]) + conversationId = await self._ensure_conversation( + user_id=CurrentUserId, + conversation_id=ConversationId, + app_id=app["id"], + user_area=UserArea, + user_role=UserRole, + tenant_code=TenantCode, + tenant_name=TenantName, + ) messageId = str(uuid.uuid4()) taskId = str(uuid.uuid4()) is_new_conversation = not ConversationId or ConversationId == "-1" @@ -161,7 +197,18 @@ class RagChatServiceImpl(IRagChatService): await asyncio.sleep(0.05) - async def GetConversations(self, CurrentUserId: int, AppId: int | None, Page: int, PageSize: int) -> RagConversationPageVO: + async def GetConversations( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + AppId: int | None, + Page: int, + PageSize: int, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagConversationPageVO: + tenant_context = await self._resolve_tenant_context(UserArea, TenantCode, TenantName) async with GetAsyncSession() as session: rows = ( await session.execute( @@ -186,8 +233,20 @@ class RagChatServiceImpl(IRagChatService): }, ) ).mappings().all() - has_more = len(rows) > PageSize - items = rows[:PageSize] + filtered_rows: list[dict] = [] + for row in rows: + record = dict(row) + if await self._conversation_accessible( + conversation_id=str(record["conversation_id"]), + expected_user_id=CurrentUserId, + tenant_context=tenant_context, + user_role=UserRole, + app_id=AppId, + session=session, + ): + filtered_rows.append(record) + has_more = len(filtered_rows) > PageSize + items = filtered_rows[:PageSize] return RagConversationPageVO( data=[ RagConversationItemVO( @@ -205,8 +264,25 @@ class RagChatServiceImpl(IRagChatService): limit=PageSize, ) - async def GetConversationMessages(self, CurrentUserId: int, ConversationId: str, Page: int, PageSize: int) -> RagMessagePageVO: - await self._ensure_conversation_owner(CurrentUserId, ConversationId) + async def GetConversationMessages( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + Page: int, + PageSize: int, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagMessagePageVO: + await self._ensure_conversation_owner( + user_id=CurrentUserId, + conversation_id=ConversationId, + user_area=UserArea, + user_role=UserRole, + tenant_code=TenantCode, + tenant_name=TenantName, + ) async with GetAsyncSession() as session: rows = ( await session.execute( @@ -333,8 +409,24 @@ class RagChatServiceImpl(IRagChatService): chunks.append(answer) return "".join(chunks) - async def RenameConversation(self, CurrentUserId: int, ConversationId: str, Body: RagConversationRenameDTO) -> RagConversationRenameVO: - await self._ensure_conversation_owner(CurrentUserId, ConversationId) + async def RenameConversation( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + Body: RagConversationRenameDTO, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagConversationRenameVO: + await self._ensure_conversation_owner( + user_id=CurrentUserId, + conversation_id=ConversationId, + user_area=UserArea, + user_role=UserRole, + tenant_code=TenantCode, + tenant_name=TenantName, + ) final_name = Body.name.strip() if not final_name: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "会话名称不能为空") @@ -359,8 +451,23 @@ class RagChatServiceImpl(IRagChatService): ) return RagConversationRenameVO(result="success", name=final_name) - async def DeleteConversation(self, CurrentUserId: int, ConversationId: str) -> RagOperationResultVO: - await self._ensure_conversation_owner(CurrentUserId, ConversationId) + async def DeleteConversation( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: + await self._ensure_conversation_owner( + user_id=CurrentUserId, + conversation_id=ConversationId, + user_area=UserArea, + user_role=UserRole, + tenant_code=TenantCode, + tenant_name=TenantName, + ) async with GetAsyncSession() as session: async with session.begin(): await session.execute( @@ -371,13 +478,23 @@ class RagChatServiceImpl(IRagChatService): ) return RagOperationResultVO(result="success") - async def UpdateFeedback(self, CurrentUserId: int, MessageId: str, Body: RagMessageFeedbackDTO) -> RagOperationResultVO: + async def UpdateFeedback( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + MessageId: str, + Body: RagMessageFeedbackDTO, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: + tenant_context = await self._resolve_tenant_context(UserArea, TenantCode, TenantName) async with GetAsyncSession() as session: - owner = ( + row = ( await session.execute( text( """ - SELECT c.user_id + SELECT c.user_id, c.conversation_id FROM rag_message m JOIN rag_conversation c ON c.conversation_id = m.conversation_id WHERE m.message_id = :message_id AND c.deleted_at IS NULL @@ -386,10 +503,16 @@ class RagChatServiceImpl(IRagChatService): ), {"message_id": MessageId}, ) - ).scalar_one_or_none() - if owner is None: + ).mappings().first() + if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "消息不存在") - if int(owner) != CurrentUserId: + if not await self._conversation_accessible( + conversation_id=str(row["conversation_id"]), + expected_user_id=CurrentUserId, + tenant_context=tenant_context, + user_role=UserRole, + session=session, + ): raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权修改该消息反馈") await session.execute( text("UPDATE rag_message SET feedback = :feedback WHERE message_id = :message_id"), @@ -397,13 +520,23 @@ class RagChatServiceImpl(IRagChatService): ) return RagOperationResultVO(result="success") - async def StopMessage(self, CurrentUserId: int, MessageId: str, Body: RagStopMessageDTO | None = None) -> RagOperationResultVO: + async def StopMessage( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + MessageId: str, + Body: RagStopMessageDTO | None = None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: + tenant_context = await self._resolve_tenant_context(UserArea, TenantCode, TenantName) async with GetAsyncSession() as session: row = ( await session.execute( text( """ - SELECT m.metadata, c.user_id + SELECT m.metadata, c.user_id, c.conversation_id FROM rag_message m JOIN rag_conversation c ON c.conversation_id = m.conversation_id WHERE m.message_id = :message_id @@ -416,7 +549,12 @@ class RagChatServiceImpl(IRagChatService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "消息不存在") - if int(row["user_id"]) != CurrentUserId: + if not await self._conversation_accessible( + conversation_id=str(row["conversation_id"]), + expected_user_id=CurrentUserId, + tenant_context=tenant_context, + user_role=UserRole, + ): raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权停止该消息") metadata = row.get("metadata") or {} @@ -432,8 +570,10 @@ class RagChatServiceImpl(IRagChatService): UserArea: str | None, UserRole: str | None, AppId: int | None, + TenantCode: str | None = None, + TenantName: str | None = None, ) -> RagAppParametersVO: - app = await self._resolve_app(AppId, UserArea, UserRole) + app = await self._resolve_app(AppId, UserArea, UserRole, TenantCode, TenantName) if not app: return RagAppParametersVO() try: @@ -449,21 +589,27 @@ class RagChatServiceImpl(IRagChatService): fileUpload={"image": {"enabled": False}}, ) - async def _load_apps(self, user_area: str | None, user_role: str | None, only_default: bool) -> list[RagChatAppVO]: + async def _load_apps( + self, + user_area: str | None, + user_role: str | None, + tenant_code: str | None, + tenant_name: str | None, + only_default: bool, + ) -> list[RagChatAppVO]: async with GetAsyncSession() as session: + await self._ensure_rag_chat_schema(session) sql = ( - """ - SELECT a.id, a.name, a.description, a.is_default + f""" + SELECT a.id, a.name, a.description, a.is_default, a.area, + COALESCE(NULLIF(BTRIM(a.tenant_code), ''), NULL) AS tenant_code, + {self._APP_TENANT_NAME_SQL} AS tenant_name, + COALESCE(d.is_public, FALSE) AS dataset_public FROM rag_chat_app a LEFT JOIN rag_dataset d ON d.id = a.dataset_id AND d.deleted_at IS NULL WHERE a.deleted_at IS NULL AND a.status = 1 AND (:only_default = FALSE OR a.is_default = TRUE) - AND ( - :is_provincial = TRUE - OR a.area IN (:user_area, '省级', '') - OR COALESCE(d.is_public, FALSE) = TRUE - ) ORDER BY a.sort_order ASC, a.created_at DESC """ ) @@ -472,31 +618,47 @@ class RagChatServiceImpl(IRagChatService): text(sql), { "only_default": only_default, - "is_provincial": user_role == "provincial_admin", - "user_area": user_area or "", }, ) ).mappings().all() - return [ - RagChatAppVO( - appId=str(row["id"]), - appName=row["name"], - description=row.get("description") or "", - isDefault=bool(row.get("is_default")), + tenant_context = await self._resolve_tenant_context(user_area, tenant_code, tenant_name) + data: list[RagChatAppVO] = [] + for row in rows: + record = dict(row) + if not await self._app_visible(record, tenant_context=tenant_context, user_role=user_role): + continue + data.append( + RagChatAppVO( + appId=str(record["id"]), + appName=record["name"], + description=record.get("description") or "", + tenantCode=str(record.get("tenant_code") or ""), + tenantName=str(record.get("tenant_name") or record.get("area") or ""), + isDefault=bool(record.get("is_default")), + ) ) - for row in rows - ] + return data - async def _resolve_app(self, app_id: int | None, user_area: str | None, user_role: str | None) -> dict | None: + async def _resolve_app( + self, + app_id: int | None, + user_area: str | None, + user_role: str | None, + tenant_code: str | None, + tenant_name: str | None, + ) -> dict | None: + tenant_context = await self._resolve_tenant_context(user_area, tenant_code, tenant_name) async with GetAsyncSession() as session: + await self._ensure_rag_chat_schema(session) params = { "app_id": app_id, - "user_area": user_area or "", - "is_provincial": user_role == "provincial_admin", } base_sql = ( - """ - SELECT a.id, a.name, a.description, a.area, a.dataset_id, a.system_prompt, + f""" + SELECT a.id, a.name, a.description, a.area, + COALESCE(NULLIF(BTRIM(a.tenant_code), ''), NULL) AS tenant_code, + {self._APP_TENANT_NAME_SQL} AS tenant_name, + a.dataset_id, a.system_prompt, a.llm_model, a.temperature, a.max_tokens, a.opening_statement, a.suggested_questions, a.is_default, COALESCE(d.is_public, FALSE) AS dataset_public, COALESCE(d.name, '') AS dataset_name @@ -512,7 +674,7 @@ class RagChatServiceImpl(IRagChatService): params, ) ).mappings().first() - if row and self._app_visible(row, user_area, user_role): + if row and await self._app_visible(dict(row), tenant_context=tenant_context, user_role=user_role): return dict(row) row = ( await session.execute( @@ -520,7 +682,7 @@ class RagChatServiceImpl(IRagChatService): params, ) ).mappings().first() - if row and self._app_visible(row, user_area, user_role): + if row and await self._app_visible(dict(row), tenant_context=tenant_context, user_role=user_role): return dict(row) row = ( await session.execute( @@ -528,15 +690,83 @@ class RagChatServiceImpl(IRagChatService): params, ) ).mappings().first() - return dict(row) if row and self._app_visible(row, user_area, user_role) else None + return dict(row) if row and await self._app_visible(dict(row), tenant_context=tenant_context, user_role=user_role) else None - def _app_visible(self, row: dict, user_area: str | None, user_role: str | None) -> bool: - if user_role == "provincial_admin": + async def _app_visible(self, row: dict, tenant_context: dict, user_role: str | None) -> bool: + if self._role_is_global(user_role): return True - area = row.get("area") or "" - return area in ("", "省级", user_area or "") or bool(row.get("dataset_public")) + if bool(row.get("dataset_public")): + return True + if str(row.get("tenant_code") or "").strip().upper() == "PUBLIC": + return True + return self._row_matches_tenant_scope( + row_tenant_code=row.get("tenant_code"), + row_area=row.get("area"), + tenant_context=tenant_context, + ) - async def _ensure_conversation(self, user_id: int, conversation_id: str | None, app_id: int | None) -> str: + async def _resolve_tenant_context( + self, + user_area: str | None, + tenant_code: str | None, + tenant_name: str | None, + ) -> dict[str, str | None]: + resolved = await self.TenantResolver.ResolveUserContext( + Area=user_area, + TenantCode=tenant_code, + TenantName=tenant_name, + Source="rag_chat_user", + ) + return { + "tenant_code": resolved.tenant_code, + "tenant_name": resolved.tenant_name, + "tenant_type": resolved.tenant_type, + "area": user_area, + } + + async def _resolve_record_tenant(self, raw_value: str | None): + return await self.TenantResolver.Resolve( + RawValue=raw_value, + Source="rag_chat_record", + ) + + async def _ensure_rag_chat_schema(self, session) -> None: + await session.execute(text("ALTER TABLE rag_chat_app ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) NULL")) + await session.execute(text("CREATE INDEX IF NOT EXISTS idx_rag_chat_app_tenant_code ON rag_chat_app(tenant_code) WHERE deleted_at IS NULL")) + + @staticmethod + def _tenant_context_is_global(tenant_context: dict[str, str | None]) -> bool: + tenant_code = str(tenant_context.get("tenant_code") or "").strip().upper() + return tenant_code in {"PUBLIC", "PROVINCIAL"} + + @staticmethod + def _role_is_global(user_role: str | None) -> bool: + normalized = str(user_role or "").strip() + return normalized in {"super_admin", "provincial_admin"} + + def _row_matches_tenant_scope( + self, + *, + row_tenant_code: str | None, + row_area: str | None, + tenant_context: dict[str, str | None], + ) -> bool: + user_tenant_code = str(tenant_context.get("tenant_code") or "").strip() + if user_tenant_code: + return str(row_tenant_code or "").strip() == user_tenant_code + return str(row_area or "").strip() == str(tenant_context.get("area") or "").strip() + + async def _ensure_conversation( + self, + user_id: int, + conversation_id: str | None, + app_id: int | None, + user_area: str | None, + user_role: str | None, + tenant_code: str | None, + tenant_name: str | None, + ) -> str: + tenant_context = await self._resolve_tenant_context(user_area, tenant_code, tenant_name) if conversation_id and conversation_id != "-1": async with GetAsyncSession() as session: row = ( @@ -554,7 +784,14 @@ class RagChatServiceImpl(IRagChatService): ) ).mappings().first() if row: - if int(row["user_id"]) != user_id: + if not await self._conversation_accessible( + conversation_id=str(row["conversation_id"]), + expected_user_id=user_id, + tenant_context=tenant_context, + user_role=user_role, + app_id=app_id, + session=session, + ): raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权使用该会话") return str(row["conversation_id"]) conversation_id = str(uuid.uuid4()) @@ -576,21 +813,99 @@ class RagChatServiceImpl(IRagChatService): ) return conversation_id - async def _ensure_conversation_owner(self, user_id: int, conversation_id: str) -> None: - async with GetAsyncSession() as session: - owner = ( - await session.execute( - text( - "SELECT user_id FROM rag_conversation WHERE conversation_id = :conversation_id AND deleted_at IS NULL LIMIT 1" - ), - {"conversation_id": conversation_id}, - ) - ).scalar_one_or_none() - if owner is None: - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "会话不存在") - if int(owner) != user_id: + async def _ensure_conversation_owner( + self, + *, + user_id: int, + conversation_id: str, + user_area: str | None, + user_role: str | None, + tenant_code: str | None, + tenant_name: str | None, + ) -> None: + tenant_context = await self._resolve_tenant_context(user_area, tenant_code, tenant_name) + if not await self._conversation_accessible( + conversation_id=conversation_id, + expected_user_id=user_id, + tenant_context=tenant_context, + user_role=user_role, + ): raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权访问该会话") + async def _conversation_accessible( + self, + *, + conversation_id: str, + expected_user_id: int, + tenant_context: dict[str, str | None], + user_role: str | None, + app_id: int | None = None, + session=None, + ) -> bool: + if session is not None: + return await self._conversation_accessible_with_session( + session=session, + conversation_id=conversation_id, + expected_user_id=expected_user_id, + tenant_context=tenant_context, + user_role=user_role, + app_id=app_id, + ) + async with GetAsyncSession() as owned_session: + return await self._conversation_accessible_with_session( + session=owned_session, + conversation_id=conversation_id, + expected_user_id=expected_user_id, + tenant_context=tenant_context, + user_role=user_role, + app_id=app_id, + ) + + async def _conversation_accessible_with_session( + self, + *, + session, + conversation_id: str, + expected_user_id: int, + tenant_context: dict[str, str | None], + user_role: str | None, + app_id: int | None = None, + ) -> bool: + row = ( + await session.execute( + text( + """ + SELECT + c.conversation_id, + c.user_id, + c.app_id, + a.area, + COALESCE(NULLIF(BTRIM(a.tenant_code), ''), NULL) AS tenant_code, + COALESCE(d.is_public, FALSE) AS dataset_public + FROM rag_conversation c + LEFT JOIN rag_chat_app a ON a.id = c.app_id AND a.deleted_at IS NULL + LEFT JOIN rag_dataset d ON d.id = a.dataset_id AND d.deleted_at IS NULL + WHERE c.conversation_id = :conversation_id + AND c.deleted_at IS NULL + LIMIT 1 + """ + ), + {"conversation_id": conversation_id}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "会话不存在") + if int(row["user_id"]) != expected_user_id: + return False + if app_id is not None and row.get("app_id") is not None and int(row["app_id"]) != int(app_id): + return False + app_row = { + "tenant_code": row.get("tenant_code"), + "area": row.get("area"), + "dataset_public": row.get("dataset_public"), + } + return await self._app_visible(app_row, tenant_context=tenant_context, user_role=user_role) + async def _retrieve_context(self, dataset_id: int | None, query: str) -> tuple[list[dict], str]: if not dataset_id: return [], "" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py index 62b7615..243ae42 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py @@ -38,6 +38,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import RagOperationResu from fastapi_modules.fastapi_leaudit.rag_engine.chroma_client import get_chroma from fastapi_modules.fastapi_leaudit.rag_engine.config import RAG_CONFIG, build_openai_embeddings_url from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class RagDatasetServiceImpl(IRagDatasetService): @@ -57,43 +58,75 @@ class RagDatasetServiceImpl(IRagDatasetService): ) a ON a.dataset_id = d.id """ + def __init__(self) -> None: + self.TenantResolver = TenantResolver() + + _DATASET_TENANT_NAME_SQL = ( + "CASE " + "WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' " + "WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' " + "ELSE COALESCE(NULLIF(BTRIM(d.area), ''), '未分配地区') " + "END" + ) + async def GetAdminDatasets( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, Area: str | None, + TenantFilterCode: str | None, OnlyEnabled: bool | None, Page: int, PageSize: int, ) -> RagDatasetPageVO: - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有管理知识库权限") - managed_area = self._resolve_managed_area(UserRole=UserRole, UserArea=UserArea) + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) + async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) + managed_area = await self._resolve_managed_area(UserRole=UserRole, UserArea=UserArea, TenantContext=tenant_context) + managed_tenant_code = self._resolve_managed_tenant_code(UserRole=UserRole, TenantContext=tenant_context) filters = ["d.deleted_at IS NULL"] params: dict = { "offset": max(Page - 1, 0) * PageSize, "limit": PageSize, } - areas = [item.strip() for item in str(Area or "").split(",") if item.strip()] - if managed_area: - if areas and any(item != managed_area for item in areas): + requested_tenants = await self._normalize_requested_tenants(Area, TenantFilterCode) + if managed_tenant_code: + if requested_tenants and any(str(item.get("tenant_code") or "").strip() != managed_tenant_code for item in requested_tenants): raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户只能查看本地区知识库配置") - filters.append("d.area = :managed_area") - params["managed_area"] = managed_area - elif len(areas) == 1: - filters.append("d.area = :area") - params["area"] = areas[0] - elif len(areas) > 1: - filters.append("d.area = ANY(:areas)") - params["areas"] = areas + filters.extend(self._dataset_tenant_filter_sql(managed_tenant_code, managed_area, prefix="managed", alias="d", params=params)) + elif len(requested_tenants) == 1: + filters.extend( + self._dataset_tenant_filter_sql( + requested_tenants[0].get("tenant_code"), + requested_tenants[0].get("normalized_area"), + prefix="requested", + alias="d", + params=params, + ) + ) + elif len(requested_tenants) > 1: + tenant_conditions: list[str] = [] + for index, item in enumerate(requested_tenants): + condition_parts = self._dataset_tenant_filter_sql( + item.get("tenant_code"), + item.get("normalized_area"), + prefix=f"requested_{index}", + alias="d", + params=params, + ) + tenant_conditions.append("(" + " AND ".join(condition_parts) + ")") + filters.append("(" + " OR ".join(tenant_conditions) + ")") if OnlyEnabled is not None: filters.append("d.status = :status") params["status"] = 1 if OnlyEnabled else 0 where_sql = " AND ".join(filters) async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) total = ( await session.execute( text(f"SELECT COUNT(1) FROM rag_dataset d WHERE {where_sql}"), @@ -104,7 +137,10 @@ class RagDatasetServiceImpl(IRagDatasetService): await session.execute( text( f""" - SELECT d.id, d.name, d.description, d.area, d.is_public, d.is_default, d.document_count, d.total_chunks, d.status, + SELECT d.id, d.name, d.description, d.area, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._DATASET_TENANT_NAME_SQL} AS tenant_name, + d.is_public, d.is_default, d.document_count, d.total_chunks, d.status, d.sort_order, d.created_at, d.updated_at, a.id AS app_id, COALESCE(a.name, '') AS app_name, COALESCE(a.is_default, FALSE) AS app_is_default FROM rag_dataset d @@ -117,31 +153,39 @@ class RagDatasetServiceImpl(IRagDatasetService): params, ) ).mappings().all() - return RagDatasetPageVO( - data=[self._to_item_vo(dict(row)) for row in rows], - total=int(total or 0), - ) + return RagDatasetPageVO(data=[await self._to_item_vo(dict(row)) for row in rows], total=int(total or 0)) async def CreateAdminDataset( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, Body: dict, ) -> RagDatasetDetailVO: - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有创建知识库权限") - - area = str(Body.get("area") or "").strip() + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) + area, resolved_tenant_code, tenant_resolution = await self._resolve_dataset_area_input( + RawArea=Body.get("area"), + TenantCode=Body.get("tenant_code"), + TenantName=Body.get("tenant_name"), + ) name = str(Body.get("dataset_name") or Body.get("name") or "").strip() description = str(Body.get("dataset_description") or Body.get("description") or "").strip() if not area or not name: - raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "地区和知识库名称不能为空") - self._assert_manage_area_scope(UserRole=UserRole, UserArea=UserArea, DatasetArea=area) + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户/地区和知识库名称不能为空") + await self._assert_manage_area_scope( + UserRole=UserRole, + UserArea=UserArea, + TenantContext=tenant_context, + DatasetArea=area, + DatasetTenantCode=resolved_tenant_code, + ) collection_name = self._slugify_collection_name(area, name) retrieval_model = {} async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) base = ( await session.execute( text( @@ -158,17 +202,17 @@ class RagDatasetServiceImpl(IRagDatasetService): if base: retrieval_model = base.get("retrieval_model") or {} if bool(Body.get("is_default")): - await self._clear_default_flags(session) + await self._clear_default_flags(session, tenant_code=resolved_tenant_code) row = ( await session.execute( text( """ INSERT INTO rag_dataset ( - name, description, area, is_public, is_default, collection_name, + name, description, area, tenant_code, is_public, is_default, collection_name, embedding_model, embedding_dim, chunk_max_size, chunk_min_size, retrieval_model, sort_order, status, created_by, updated_by ) VALUES ( - :name, :description, :area, :is_public, :is_default, :collection_name, + :name, :description, :area, :tenant_code, :is_public, :is_default, :collection_name, :embedding_model, :embedding_dim, :chunk_max_size, :chunk_min_size, CAST(:retrieval_model AS jsonb), :sort_order, :status, :created_by, :updated_by ) @@ -179,6 +223,7 @@ class RagDatasetServiceImpl(IRagDatasetService): "name": name, "description": description, "area": area, + "tenant_code": resolved_tenant_code, "is_public": bool(Body.get("is_public")), "is_default": bool(Body.get("is_default")), "collection_name": collection_name, @@ -200,34 +245,53 @@ class RagDatasetServiceImpl(IRagDatasetService): dataset_id=dataset_id, dataset_name=name, dataset_area=area, + dataset_tenant_code=resolved_tenant_code, current_user_id=CurrentUserId, is_default=bool(Body.get("is_default")), ) refreshed = await self._get_dataset_row(dataset_id) - return self._to_detail_vo(refreshed) if refreshed else None + return await self._to_detail_vo(refreshed) if refreshed else None async def UpdateAdminDataset( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Body: dict, ) -> RagDatasetDetailVO | None: - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有更新知识库权限") + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) existing = await self._get_dataset_row(DatasetId) if not existing: return None - self._assert_manage_area_scope(UserRole=UserRole, UserArea=UserArea, DatasetArea=str(existing.get("area") or "")) + await self._assert_manage_area_scope( + UserRole=UserRole, + UserArea=UserArea, + TenantContext=tenant_context, + DatasetArea=str(existing.get("area") or ""), + DatasetTenantCode=str(existing.get("tenant_code") or "") or None, + ) - area = str(Body.get("area") or existing.get("area") or "").strip() - self._assert_manage_area_scope(UserRole=UserRole, UserArea=UserArea, DatasetArea=area) + area, resolved_tenant_code, _ = await self._resolve_dataset_area_input( + RawArea=Body.get("area") or existing.get("area"), + TenantCode=Body.get("tenant_code") or existing.get("tenant_code"), + TenantName=Body.get("tenant_name"), + ) + await self._assert_manage_area_scope( + UserRole=UserRole, + UserArea=UserArea, + TenantContext=tenant_context, + DatasetArea=area, + DatasetTenantCode=resolved_tenant_code, + ) async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) target_is_default = bool(Body.get("is_default", existing.get("is_default"))) if target_is_default: - await self._clear_default_flags(session) + await self._clear_default_flags(session, tenant_code=resolved_tenant_code) elif existing.get("is_default") and Body.get("is_default") is False: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "默认知识库不能直接取消,请先将其他知识库设为默认") await session.execute( @@ -237,6 +301,7 @@ class RagDatasetServiceImpl(IRagDatasetService): SET name = :name, description = :description, area = :area, + tenant_code = :tenant_code, is_public = :is_public, is_default = :is_default, sort_order = :sort_order, @@ -251,6 +316,7 @@ class RagDatasetServiceImpl(IRagDatasetService): "name": str(Body.get("dataset_name") or Body.get("name") or existing.get("name") or "").strip(), "description": str(Body.get("dataset_description") or Body.get("description") or existing.get("description") or "").strip(), "area": area, + "tenant_code": resolved_tenant_code, "is_public": bool(Body.get("is_public", existing.get("is_public"))), "is_default": target_is_default, "sort_order": int(Body.get("sort_order") if Body.get("sort_order") is not None else (existing.get("sort_order") or 0)), @@ -263,28 +329,37 @@ class RagDatasetServiceImpl(IRagDatasetService): dataset_id=DatasetId, dataset_name=str(Body.get("dataset_name") or Body.get("name") or existing.get("name") or "").strip(), dataset_area=area, + dataset_tenant_code=resolved_tenant_code, current_user_id=CurrentUserId, is_default=target_is_default, ) refreshed = await self._get_dataset_row(DatasetId) - return self._to_detail_vo(refreshed) if refreshed else None + return await self._to_detail_vo(refreshed) if refreshed else None async def DeleteAdminDataset( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, ) -> RagOperationResultVO: - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有删除知识库权限") + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) existing = await self._get_dataset_row(DatasetId) if not existing: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") - self._assert_manage_area_scope(UserRole=UserRole, UserArea=UserArea, DatasetArea=str(existing.get("area") or "")) + await self._assert_manage_area_scope( + UserRole=UserRole, + UserArea=UserArea, + TenantContext=tenant_context, + DatasetArea=str(existing.get("area") or ""), + DatasetTenantCode=str(existing.get("tenant_code") or "") or None, + ) if bool(existing.get("is_default")): raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "默认知识库不允许删除,请先切换默认知识库") async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) await session.execute( text("UPDATE rag_dataset SET deleted_at = NOW(), updated_by = :updated_by, updated_at = NOW() WHERE id = :dataset_id"), {"dataset_id": DatasetId, "updated_by": CurrentUserId}, @@ -295,64 +370,71 @@ class RagDatasetServiceImpl(IRagDatasetService): ) return RagOperationResultVO(result="success") - async def GetMyDatasets(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagDatasetPageVO: + async def GetMyDatasets( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> RagDatasetPageVO: + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) rows = ( await session.execute( text( f""" - SELECT d.id, d.name, d.description, d.area, d.is_public, d.is_default, d.document_count, d.total_chunks, d.status, + SELECT d.id, d.name, d.description, d.area, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._DATASET_TENANT_NAME_SQL} AS tenant_name, + d.is_public, d.is_default, d.document_count, d.total_chunks, d.status, d.sort_order, d.created_at, d.updated_at, a.id AS app_id, COALESCE(a.name, '') AS app_name, COALESCE(a.is_default, FALSE) AS app_is_default FROM rag_dataset d {self._APP_LINK_SQL} WHERE d.deleted_at IS NULL AND d.status = 1 - AND ( - :is_provincial = TRUE - OR d.area IN (:user_area, '省级', '') - OR d.is_public = TRUE - ) + AND d.status = 1 ORDER BY d.sort_order ASC, d.created_at DESC """ ), - { - "is_provincial": UserRole == "provincial_admin", - "user_area": UserArea or "", - }, + {}, ) ).mappings().all() return RagDatasetPageVO( - data=[ - RagDatasetItemVO( - **self._to_item_vo(dict(row)).model_dump() - ) - for row in rows - ], - total=len(rows), + data=[item for item in [await self._build_visible_item_vo(dict(row), tenant_context, UserRole) for row in rows] if item], + total=len([item for item in [await self._build_visible_item_vo(dict(row), tenant_context, UserRole) for row in rows] if item]), ) - async def GetDatasetDetail(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, DatasetId: int) -> RagDatasetDetailVO | None: - row = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + async def GetDatasetDetail( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + DatasetId: int, + ) -> RagDatasetDetailVO | None: + row = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not row: return None - return self._to_detail_vo(row) + return await self._to_detail_vo(row) async def UpdateDataset( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Body: RagDatasetUpdateDTO, ) -> RagDatasetDetailVO | None: - row = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + row = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not row: return None - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有修改知识库配置权限") - update_fields: list[str] = [] params: dict = {"dataset_id": DatasetId, "updated_by": CurrentUserId} @@ -382,20 +464,22 @@ class RagDatasetServiceImpl(IRagDatasetService): params, ) - refreshed = await self._get_visible_dataset(UserArea, UserRole, DatasetId) - return self._to_detail_vo(refreshed) if refreshed else None + refreshed = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) + return await self._to_detail_vo(refreshed) if refreshed else None async def GetDatasetDocuments( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Page: int, Limit: int, Keyword: str | None, ) -> RagDatasetDocumentPageVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") @@ -474,10 +558,12 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> RagDatasetDocumentItemVO | None: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: return None @@ -519,13 +605,24 @@ class RagDatasetServiceImpl(IRagDatasetService): updatedAt=int(row["updated_at"].timestamp()) if row.get("updated_at") else 0, ) - async def _get_visible_dataset(self, user_area: str | None, user_role: str | None, dataset_id: int) -> dict | None: + async def _get_visible_dataset( + self, + user_area: str | None, + user_role: str | None, + tenant_code: str | None, + tenant_name: str | None, + dataset_id: int, + ) -> dict | None: async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) row = ( await session.execute( text( f""" - SELECT d.id, d.name, d.description, d.area, d.is_public, d.is_default, d.status, + SELECT d.id, d.name, d.description, d.area, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._DATASET_TENANT_NAME_SQL} AS tenant_name, + d.is_public, d.is_default, d.status, d.document_count, d.total_chunks, d.chunk_max_size, d.chunk_min_size, d.sort_order, d.retrieval_model, d.collection_name, d.embedding_model, d.created_at, d.updated_at, a.id AS app_id, COALESCE(a.name, '') AS app_name, COALESCE(a.is_default, FALSE) AS app_is_default @@ -542,19 +639,18 @@ class RagDatasetServiceImpl(IRagDatasetService): ).mappings().first() if not row: return None - if user_role == "provincial_admin": - return dict(row) - area = row.get("area") or "" - if area in ("", "省级", user_area or "") or bool(row.get("is_public")): + if await self._dataset_visible(dict(row), UserArea=user_area, UserRole=user_role, TenantCode=tenant_code, TenantName=tenant_name): return dict(row) return None - def _to_detail_vo(self, row: dict) -> RagDatasetDetailVO: + async def _to_detail_vo(self, row: dict) -> RagDatasetDetailVO: return RagDatasetDetailVO( id=row["id"], name=row["name"], description=row.get("description") or "", area=row.get("area") or "", + tenantCode=str(row.get("tenant_code") or ""), + tenantName=str(row.get("tenant_name") or row.get("area") or ""), isPublic=bool(row.get("is_public")), isDefault=bool(row.get("is_default")), status=row.get("status") or 1, @@ -571,12 +667,14 @@ class RagDatasetServiceImpl(IRagDatasetService): appIsDefault=bool(row.get("app_is_default")), ) - def _to_item_vo(self, row: dict) -> RagDatasetItemVO: + async def _to_item_vo(self, row: dict) -> RagDatasetItemVO: return RagDatasetItemVO( id=row["id"], name=row.get("name") or "", description=row.get("description") or "", area=row.get("area") or "", + tenantCode=str(row.get("tenant_code") or ""), + tenantName=str(row.get("tenant_name") or row.get("area") or ""), isPublic=bool(row.get("is_public")), isDefault=bool(row.get("is_default")), documentCount=row.get("document_count") or 0, @@ -592,11 +690,15 @@ class RagDatasetServiceImpl(IRagDatasetService): async def _get_dataset_row(self, dataset_id: int) -> dict | None: async with GetAsyncSession() as session: + await self._ensure_rag_tenant_schema(session) row = ( await session.execute( text( f""" - SELECT d.id, d.name, d.description, d.area, d.is_public, d.is_default, d.status, + SELECT d.id, d.name, d.description, d.area, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code, + {self._DATASET_TENANT_NAME_SQL} AS tenant_name, + d.is_public, d.is_default, d.status, d.document_count, d.total_chunks, d.chunk_max_size, d.chunk_min_size, d.sort_order, d.retrieval_model, d.collection_name, d.embedding_model, d.created_at, d.updated_at, a.id AS app_id, COALESCE(a.name, '') AS app_name, COALESCE(a.is_default, FALSE) AS app_is_default @@ -611,9 +713,187 @@ class RagDatasetServiceImpl(IRagDatasetService): ).mappings().first() return dict(row) if row else None - async def _clear_default_flags(self, session) -> None: - await session.execute(text("UPDATE rag_dataset SET is_default = FALSE WHERE deleted_at IS NULL")) - await session.execute(text("UPDATE rag_chat_app SET is_default = FALSE WHERE deleted_at IS NULL")) + async def _resolve_tenant_context( + self, + *, + UserArea: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> dict[str, str | None]: + resolved = await self.TenantResolver.ResolveUserContext( + Area=UserArea, + TenantCode=TenantCode, + TenantName=TenantName, + Source="rag_dataset_user", + ) + return { + "tenant_code": resolved.tenant_code, + "tenant_name": resolved.tenant_name, + "tenant_type": resolved.tenant_type, + "area": resolved.normalized_value or UserArea, + } + + async def _resolve_record_tenant(self, raw_value: str | None): + return await self.TenantResolver.Resolve( + RawValue=raw_value, + Source="rag_dataset_record", + ) + + async def _dataset_visible( + self, + row: dict, + *, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> bool: + if self._role_is_global(UserRole): + return True + if bool(row.get("is_public")): + return True + tenant_context = await self._resolve_tenant_context(UserArea=UserArea, TenantCode=TenantCode, TenantName=TenantName) + return self._row_matches_tenant_scope( + row_tenant_code=row.get("tenant_code"), + row_area=row.get("area"), + tenant_context=tenant_context, + ) + + async def _build_visible_item_vo( + self, + row: dict, + tenant_context: dict[str, str | None], + user_role: str | None, + ) -> RagDatasetItemVO | None: + if not await self._dataset_visible( + row, + UserArea=tenant_context.get("area"), + UserRole=user_role, + TenantCode=tenant_context.get("tenant_code"), + TenantName=tenant_context.get("tenant_name"), + ): + return None + return await self._to_item_vo(row) + + async def _normalize_requested_tenants( + self, + area_filter: str | None, + tenant_code_filter: str | None = None, + ) -> list[dict[str, str]]: + area_items = [item.strip() for item in str(area_filter or "").split(",") if item.strip()] + code_items = [item.strip() for item in str(tenant_code_filter or "").split(",") if item.strip()] + normalized: list[dict[str, str]] = [] + seen_keys: set[str] = set() + + for item in area_items: + resolution = await self.TenantResolver.Resolve( + RawValue=item, + Source="rag_dataset_filter", + ) + normalized_area = resolution.tenant_name or resolution.normalized_value or item + tenant_code = resolution.tenant_code or "" + dedupe_key = tenant_code or normalized_area + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + normalized.append( + { + "tenant_code": tenant_code, + "tenant_name": resolution.tenant_name or normalized_area, + "normalized_area": normalized_area, + } + ) + + for tenant_code in code_items: + resolution = await self.TenantResolver.Resolve( + RawValue=None, + Source="rag_dataset_filter_code", + PreferredTenantCode=tenant_code, + ) + normalized_area = ( + resolution.tenant_name + or self._fallback_area_from_tenant_code(tenant_code) + or resolution.normalized_value + or tenant_code + ) + resolved_code = resolution.tenant_code or tenant_code + dedupe_key = resolved_code or normalized_area + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + normalized.append( + { + "tenant_code": resolved_code, + "tenant_name": resolution.tenant_name or normalized_area, + "normalized_area": normalized_area, + } + ) + return normalized + + async def _resolve_dataset_area_input( + self, + *, + RawArea: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> tuple[str, str | None, object]: + resolution = await self.TenantResolver.Resolve( + RawValue=RawArea, + Source="rag_dataset_input", + PreferredTenantCode=str(TenantCode or "").strip() or None, + FallbackTenantName=TenantName, + ) + normalized_area = resolution.tenant_name or resolution.normalized_value or str(RawArea or "").strip() + return normalized_area, resolution.tenant_code, resolution + + async def _clear_default_flags(self, session, tenant_code: str | None = None) -> None: + await self._ensure_rag_tenant_schema(session) + normalized_tenant_code = str(tenant_code or "").strip() + if normalized_tenant_code: + await session.execute( + text( + """ + UPDATE rag_dataset + SET is_default = FALSE + WHERE deleted_at IS NULL + AND tenant_code = :tenant_code + """ + ), + {"tenant_code": normalized_tenant_code}, + ) + await session.execute( + text( + """ + UPDATE rag_chat_app + SET is_default = FALSE + WHERE deleted_at IS NULL + AND tenant_code = :tenant_code + """ + ), + {"tenant_code": normalized_tenant_code}, + ) + return + + await session.execute( + text( + """ + UPDATE rag_dataset + SET is_default = FALSE + WHERE deleted_at IS NULL + AND (tenant_code IS NULL OR BTRIM(tenant_code) = '') + """ + ) + ) + await session.execute( + text( + """ + UPDATE rag_chat_app + SET is_default = FALSE + WHERE deleted_at IS NULL + AND (tenant_code IS NULL OR BTRIM(tenant_code) = '') + """ + ) + ) async def _ensure_linked_app( self, @@ -621,9 +901,11 @@ class RagDatasetServiceImpl(IRagDatasetService): dataset_id: int, dataset_name: str, dataset_area: str, + dataset_tenant_code: str | None, current_user_id: int, is_default: bool, ) -> None: + await self._ensure_rag_tenant_schema(session) app_row = ( await session.execute( text( @@ -647,6 +929,9 @@ class RagDatasetServiceImpl(IRagDatasetService): """ UPDATE rag_chat_app SET name = :name, + description = :description, + area = :area, + tenant_code = :tenant_code, is_default = :is_default, status = 1, updated_by = :updated_by, @@ -657,6 +942,9 @@ class RagDatasetServiceImpl(IRagDatasetService): { "app_id": int(app_row["id"]), "name": app_name, + "description": f"{dataset_area or '默认地区'}知识库问答助手", + "area": dataset_area or "", + "tenant_code": dataset_tenant_code, "is_default": is_default, "updated_by": current_user_id, }, @@ -667,10 +955,10 @@ class RagDatasetServiceImpl(IRagDatasetService): text( """ INSERT INTO rag_chat_app ( - name, description, area, dataset_id, suggested_questions, + name, description, area, tenant_code, dataset_id, suggested_questions, opening_statement, sort_order, status, is_default, created_by, updated_by ) VALUES ( - :name, :description, :area, :dataset_id, CAST(:suggested_questions AS jsonb), + :name, :description, :area, :tenant_code, :dataset_id, CAST(:suggested_questions AS jsonb), :opening_statement, 0, 1, :is_default, :created_by, :updated_by ) """ @@ -679,6 +967,7 @@ class RagDatasetServiceImpl(IRagDatasetService): "name": app_name, "description": f"{dataset_area or '默认地区'}知识库问答助手", "area": dataset_area or "", + "tenant_code": dataset_tenant_code, "dataset_id": dataset_id, "suggested_questions": json.dumps([], ensure_ascii=False), "opening_statement": f"您好,我是{app_name}。", @@ -705,36 +994,110 @@ class RagDatasetServiceImpl(IRagDatasetService): return f"legal_kb_{normalized}"[:96] return f"legal_kb_{uuid.uuid4().hex[:12]}" - def _resolve_managed_area(self, UserRole: str | None, UserArea: str | None) -> str | None: - if UserRole == "admin": - area = str(UserArea or "").strip() - if not area: - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前市级管理员未配置地区,无法管理知识库") - return area + @staticmethod + def _fallback_area_from_tenant_code(tenant_code: str | None) -> str | None: + normalized = str(tenant_code or "").strip().upper() + if normalized == "PUBLIC": + return "公共" + if normalized == "PROVINCIAL": + return "省级" return None - def _assert_manage_area_scope(self, UserRole: str | None, UserArea: str | None, DatasetArea: str) -> None: - if UserRole in ("provincial_admin", "super_admin"): - return - if UserRole != "admin": - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有管理知识库权限") + def _resolve_managed_tenant_code(self, UserRole: str | None, TenantContext: dict[str, str | None]) -> str | None: + if self._role_is_global(UserRole): + return None + tenant_code = str(TenantContext.get("tenant_code") or "").strip() + return tenant_code or None - managed_area = self._resolve_managed_area(UserRole=UserRole, UserArea=UserArea) + async def _resolve_managed_area(self, UserRole: str | None, UserArea: str | None, TenantContext: dict[str, str | None]) -> str | None: + if self._role_is_global(UserRole): + return None + area = str(TenantContext.get("area") or UserArea or "").strip() + if not area: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户未配置租户/地区,无法管理知识库") + return area + + async def _assert_manage_area_scope( + self, + UserRole: str | None, + UserArea: str | None, + TenantContext: dict[str, str | None], + DatasetArea: str, + DatasetTenantCode: str | None = None, + ) -> None: + if self._role_is_global(UserRole): + return + managed_tenant_code = self._resolve_managed_tenant_code(UserRole=UserRole, TenantContext=TenantContext) + if managed_tenant_code: + if str(DatasetTenantCode or "").strip() != managed_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户只能管理本租户知识库") + return + + managed_area = await self._resolve_managed_area(UserRole=UserRole, UserArea=UserArea, TenantContext=TenantContext) if DatasetArea != managed_area: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户只能管理本地区知识库") + async def _ensure_rag_tenant_schema(self, session) -> None: + await session.execute(text("ALTER TABLE rag_dataset ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) NULL")) + await session.execute(text("ALTER TABLE rag_chat_app ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) NULL")) + await session.execute(text("CREATE INDEX IF NOT EXISTS idx_rag_dataset_tenant_code ON rag_dataset(tenant_code) WHERE deleted_at IS NULL")) + await session.execute(text("CREATE INDEX IF NOT EXISTS idx_rag_chat_app_tenant_code ON rag_chat_app(tenant_code) WHERE deleted_at IS NULL")) + + def _dataset_tenant_filter_sql( + self, + tenant_code: str | None, + area: str | None, + *, + prefix: str, + alias: str, + params: dict, + ) -> list[str]: + normalized_tenant_code = str(tenant_code or "").strip() + normalized_area = str(area or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + return [f"{alias}.tenant_code = :{prefix}_tenant_code"] + if normalized_area: + params[f"{prefix}_area"] = normalized_area + return [f"COALESCE({alias}.area, '') = :{prefix}_area"] + return ["1 = 0"] + + @staticmethod + def _tenant_context_is_global(tenant_context: dict[str, str | None]) -> bool: + tenant_code = str(tenant_context.get("tenant_code") or "").strip().upper() + return tenant_code in {"PUBLIC", "PROVINCIAL"} + + @staticmethod + def _role_is_global(user_role: str | None) -> bool: + normalized = str(user_role or "").strip() + return normalized in {"super_admin", "provincial_admin"} + + def _row_matches_tenant_scope( + self, + *, + row_tenant_code: str | None, + row_area: str | None, + tenant_context: dict[str, str | None], + ) -> bool: + user_tenant_code = str(tenant_context.get("tenant_code") or "").strip() + if user_tenant_code: + return str(row_tenant_code or "").strip() == user_tenant_code + return str(row_area or "").strip() == str(tenant_context.get("area") or "").strip() + async def UploadDatasetDocument( self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, FileName: str, ContentType: str | None, Content: bytes, ProcessConfig: dict | None, ) -> RagDatasetUploadDocumentVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") @@ -803,13 +1166,15 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, Page: int, Limit: int, Keyword: str | None, ) -> RagDatasetSegmentPageVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") @@ -867,11 +1232,13 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, ) -> RagDatasetSegmentItemVO | None: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: return None @@ -903,22 +1270,23 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, Body: dict, ) -> RagDatasetSegmentItemVO | None: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: return None - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有修改知识库分段权限") - current = await self.GetDatasetDocumentSegmentDetail( CurrentUserId=CurrentUserId, UserArea=UserArea, UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, DatasetId=DatasetId, DocumentId=DocumentId, SegmentId=SegmentId, @@ -959,21 +1327,22 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, ) -> RagDatasetBatchDeleteResultVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有删除知识库分段权限") - current = await self.GetDatasetDocumentSegmentDetail( CurrentUserId=CurrentUserId, UserArea=UserArea, UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, DatasetId=DatasetId, DocumentId=DocumentId, SegmentId=SegmentId, @@ -1015,6 +1384,8 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> RagOperationResultVO: @@ -1022,6 +1393,8 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId=CurrentUserId, UserArea=UserArea, UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, DatasetId=DatasetId, DocumentIds=[DocumentId], ) @@ -1035,16 +1408,15 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentIds: list[int], ) -> RagOperationResultVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有删除知识库文档权限") - normalized_ids = [int(item) for item in DocumentIds if item] if not normalized_ids: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "缺少待删除文档") @@ -1150,11 +1522,13 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Query: str, RetrievalModel: dict | None, ) -> RagDatasetRetrieveResponseVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") @@ -1227,6 +1601,8 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> dict: @@ -1234,6 +1610,8 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId=CurrentUserId, UserArea=UserArea, UserRole=UserRole, + TenantCode=TenantCode, + TenantName=TenantName, DatasetId=DatasetId, DocumentId=DocumentId, ) @@ -1266,6 +1644,8 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, FileName: str, @@ -1273,13 +1653,10 @@ class RagDatasetServiceImpl(IRagDatasetService): Content: bytes, ProcessConfig: dict | None, ) -> RagDatasetUploadDocumentVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有重处理知识库文档权限") - async with GetAsyncSession() as session: current = ( await session.execute( @@ -1366,17 +1743,16 @@ class RagDatasetServiceImpl(IRagDatasetService): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentIds: list[int], Enabled: bool, ) -> RagOperationResultVO: - dataset = await self._get_visible_dataset(UserArea, UserRole, DatasetId) + dataset = await self._get_visible_dataset(UserArea, UserRole, TenantCode, TenantName, DatasetId) if not dataset: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "知识库不存在") - if UserRole not in ("provincial_admin", "admin", "super_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有修改知识库文档状态权限") - ids = [int(doc_id) for doc_id in DocumentIds] if not ids: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "未传入文档ID") diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index b84f262..06461c4 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -19,6 +19,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import ( RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, + UserTenantUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( OrganizationNodeVO, @@ -37,8 +38,12 @@ from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( UserListVO, UserRoleVO, UserRolesVO, + UserTenantUpdateVO, UserVO, ) +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService @@ -46,9 +51,20 @@ class RbacAdminServiceImpl(IRbacAdminService): """RBAC 管理服务实现。""" _logger = logging.getLogger("APP") + _ADMIN_SEED_CACHE_TTL_SECONDS = 300 + _admin_seed_route_map_cache: dict[str, int] | None = None + _admin_seed_route_map_cached_at = 0.0 _UNGROUPED_TENANT_LABEL = "未分组租户" _UNGROUPED_DEPARTMENT_LABEL = "未分组部门" _UNGROUPED_ORGANIZATION_LABEL = "未分组组织" + _CORE_ROLE_AUTO_ROUTE_PATHS: dict[str, tuple[str, ...]] = { + "super_admin": ("/settings", "/tenants"), + "provincial_admin": ("/settings", "/tenants"), + "admin": ("/settings", "/tenants"), + } + + def __init__(self, TenantResolverService: TenantResolver | None = None) -> None: + self.TenantResolver = TenantResolverService or TenantResolver() _MANAGEABLE_ROUTE_BLUEPRINTS: list[dict[str, Any]] = [ { @@ -178,6 +194,17 @@ class RbacAdminServiceImpl(IRbacAdminService): "is_cache": True, "meta": {"group": "cross-review"}, }, + { + "route_path": "/rules", + "route_name": "rule-management", + "component": "rules", + "route_title": "规则管理", + "icon": "ri-book-3-line", + "sort_order": 70, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + }, { "route_path": "/settings", "route_name": "system-settings", @@ -225,13 +252,25 @@ class RbacAdminServiceImpl(IRbacAdminService): "is_cache": True, "meta": {"group": "settings"}, }, + { + "route_path": "/tenants", + "route_name": "tenants", + "component": "tenants", + "route_title": "租户管理", + "icon": "ri-building-line", + "sort_order": 4, + "parent_path": "/settings", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + }, { "route_path": "/usage-stats", "route_name": "usage-stats", "component": "usage-stats", "route_title": "系统使用统计", "icon": "ri-bar-chart-box-line", - "sort_order": 4, + "sort_order": 5, "parent_path": "/settings", "is_hidden": False, "is_cache": True, @@ -251,6 +290,10 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "doc_type:create:write", "display_name": "创建文档类型", "module": "doc_type", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/document-types", "route_path": "/document-types"}, {"permission_key": "doc_type:update:write", "display_name": "更新文档类型", "module": "doc_type", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, {"permission_key": "doc_type:delete:delete", "display_name": "删除文档类型", "module": "doc_type", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, + {"permission_key": "rbac:tenants:read", "display_name": "查看租户列表", "module": "rbac", "resource": "tenants", "action": "read", "api_method": "GET", "api_path": "/api/v3/tenants", "route_path": "/tenants"}, + {"permission_key": "rbac:tenants:create", "display_name": "创建租户", "module": "rbac", "resource": "tenants", "action": "create", "api_method": "POST", "api_path": "/api/v3/tenants", "route_path": "/tenants"}, + {"permission_key": "rbac:tenants:update", "display_name": "更新租户", "module": "rbac", "resource": "tenants", "action": "update", "api_method": "PUT", "api_path": "/api/v3/tenants/{tenant_code}", "route_path": "/tenants"}, + {"permission_key": "rbac:tenants:status", "display_name": "启停租户", "module": "rbac", "resource": "tenants", "action": "status", "api_method": "PATCH", "api_path": "/api/v3/tenants/{tenant_code}/status", "route_path": "/tenants"}, {"permission_key": "usage_stats:overview:read", "display_name": "查看统计总览", "module": "usage_stats", "resource": "overview", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/overview", "route_path": "/usage-stats"}, {"permission_key": "usage_stats:trends:read", "display_name": "查看统计趋势", "module": "usage_stats", "resource": "trends", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/trends", "route_path": "/usage-stats"}, {"permission_key": "usage_stats:users:read", "display_name": "查看用户统计", "module": "usage_stats", "resource": "users", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-users", "route_path": "/usage-stats"}, @@ -263,14 +306,23 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "contract_template:create:write", "display_name": "上传合同模板", "module": "contract_template", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"}, {"permission_key": "contract_template:update:write", "display_name": "更新合同模板", "module": "contract_template", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"}, {"permission_key": "contract_template:delete:delete", "display_name": "删除合同模板", "module": "contract_template", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"}, - {"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, - {"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, - {"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"}, - {"permission_key": "evaluation_group:batch:write", "display_name": "批量维护评查点分组", "module": "evaluation_group", "resource": "batch", "action": "write", "api_method": "PATCH", "api_path": "/api/v3/evaluation-point-groups/batch/status", "route_path": "/rule-groups"}, - {"permission_key": "evaluation_group:delete:delete", "display_name": "删除评查点分组", "module": "evaluation_group", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"}, + {"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rules"}, + {"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rules"}, + {"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rules"}, + {"permission_key": "evaluation_group:batch:write", "display_name": "批量维护评查点分组", "module": "evaluation_group", "resource": "batch", "action": "write", "api_method": "PATCH", "api_path": "/api/v3/evaluation-point-groups/batch/status", "route_path": "/rules"}, + {"permission_key": "evaluation_group:delete:delete", "display_name": "删除评查点分组", "module": "evaluation_group", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rules"}, {"permission_key": "rules:list:read", "display_name": "规则配置列表", "module": "rules", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/rule-config-packs", "route_path": "/rules"}, + {"permission_key": "rules:version_list:read", "display_name": "规则版本列表", "module": "rules", "resource": "version_list", "action": "read", "api_method": "GET", "api_path": "/api/rule-sets/{rule_type}/versions", "route_path": "/rules"}, {"permission_key": "rules:content:read", "display_name": "规则 YAML 内容", "module": "rules", "resource": "content", "action": "read", "api_method": "GET", "api_path": "/api/v3/rule-config-packs/{id}", "route_path": "/rules"}, {"permission_key": "rules:create:write", "display_name": "创建规则草稿", "module": "rules", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups/{id}/rule-drafts", "route_path": "/rules"}, + {"permission_key": "rules:validate:execute", "display_name": "规则 YAML 校验", "module": "rules", "resource": "validate", "action": "execute", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/validate", "route_path": "/rules"}, + {"permission_key": "rules:version_create:write", "display_name": "创建规则版本", "module": "rules", "resource": "version_create", "action": "write", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/versions", "route_path": "/rules"}, + {"permission_key": "rules:publish:write", "display_name": "发布规则版本", "module": "rules", "resource": "publish", "action": "write", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/publish", "route_path": "/rules"}, + {"permission_key": "rules:rollback:write", "display_name": "回滚规则版本", "module": "rules", "resource": "rollback", "action": "write", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/rollback", "route_path": "/rules"}, + {"permission_key": "rules:binding_list:read", "display_name": "规则绑定列表", "module": "rules", "resource": "binding_list", "action": "read", "api_method": "GET", "api_path": "/api/rule-sets/bindings", "route_path": "/rules"}, + {"permission_key": "rules:binding_create:write", "display_name": "创建规则绑定", "module": "rules", "resource": "binding_create", "action": "write", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/bindings", "route_path": "/rules"}, + {"permission_key": "rules:binding_update:write", "display_name": "更新规则绑定", "module": "rules", "resource": "binding_update", "action": "write", "api_method": "PUT", "api_path": "/api/rule-sets/bindings/{binding_id}", "route_path": "/rules"}, + {"permission_key": "rules:binding_delete:delete", "display_name": "删除规则绑定", "module": "rules", "resource": "binding_delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/rule-sets/bindings/{binding_id}", "route_path": "/rules"}, {"permission_key": "evaluation_point:list:read", "display_name": "评查点列表", "module": "evaluation_point", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"}, {"permission_key": "evaluation_point:detail:read", "display_name": "评查点详情", "module": "evaluation_point", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, {"permission_key": "evaluation_point:create:write", "display_name": "创建评查点", "module": "evaluation_point", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"}, @@ -291,6 +343,7 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "rbac:roles:delete", "display_name": "删除角色", "module": "rbac", "resource": "roles", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/rbac/roles/{role_id}", "route_path": "/role-permissions"}, {"permission_key": "rbac:users:read", "display_name": "用户列表", "module": "rbac", "resource": "users", "action": "read", "api_method": "GET", "api_path": "/api/v3/rbac/users", "route_path": "/role-permissions"}, {"permission_key": "rbac:user_roles:write", "display_name": "分配用户角色", "module": "rbac", "resource": "user_roles", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/users/{user_id}/roles", "route_path": "/role-permissions"}, + {"permission_key": "rbac:user_tenant:update", "display_name": "更新用户租户", "module": "rbac", "resource": "user_tenant", "action": "update", "api_method": "PUT", "api_path": "/api/v3/rbac/users/{user_id}/tenant", "route_path": "/role-permissions"}, {"permission_key": "rbac:role_routes:write", "display_name": "配置角色菜单", "module": "rbac", "resource": "role_routes", "action": "write", "api_method": "PUT", "api_path": "/api/rbac/roles/{role_id}/routes", "route_path": "/role-permissions"}, {"permission_key": "rbac:role_permissions:write", "display_name": "配置角色权限", "module": "rbac", "resource": "role_permissions", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/role-permissions", "route_path": "/role-permissions"}, {"permission_key": "rag:app:read", "display_name": "查看 RAG 应用", "module": "rag", "resource": "app", "action": "read", "api_method": "GET", "api_path": "/api/v3/rag/apps", "route_path": "/chat-with-llm"}, @@ -306,10 +359,15 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "rag:dataset:delete", "display_name": "删除知识库与文档", "module": "rag", "resource": "dataset", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/rag/datasets/admin/{DatasetId}", "route_path": "/chat-with-llm"}, ] + _CORE_ROLE_AUTO_GRANTS: dict[str, tuple[str, ...]] = { + "super_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"), + "provincial_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"), + "admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"), + } + async def ListRoles(self, CurrentUserId: int, Page: int, PageSize: int, RoleKey: str | None, RoleName: str | None, IncludeSystem: bool) -> RoleListVO: """查询角色列表。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:read") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:read") offset = max(Page - 1, 0) * PageSize filters = ["1=1"] params: dict[str, object] = {"limit": PageSize, "offset": offset} @@ -343,8 +401,7 @@ class RbacAdminServiceImpl(IRbacAdminService): async def CreateRole(self, CurrentUserId: int, Body: RoleCreateDTO) -> RoleVO: """创建角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:create") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:create") async with GetAsyncSession() as Session: row = ( await Session.execute( @@ -369,8 +426,7 @@ class RbacAdminServiceImpl(IRbacAdminService): async def UpdateRole(self, CurrentUserId: int, RoleId: int, Body: RoleUpdateDTO) -> RoleVO: """更新角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:update") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:update") async with GetAsyncSession() as Session: current = await self._getRoleRow(Session, RoleId) row = ( @@ -403,8 +459,7 @@ class RbacAdminServiceImpl(IRbacAdminService): async def DeleteRole(self, CurrentUserId: int, RoleId: int, Force: bool) -> None: """删除角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:delete") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:delete") async with GetAsyncSession() as Session: role = await self._getRoleRow(Session, RoleId) if role["is_system_role"]: @@ -419,34 +474,55 @@ class RbacAdminServiceImpl(IRbacAdminService): await Session.execute(text("DELETE FROM roles WHERE id = :role_id"), {"role_id": RoleId}) await Session.commit() - async def ListUsers(self, CurrentUserId: int, Page: int, PageSize: int, Area: str | None, NickName: str | None) -> UserListVO: + async def ListUsers( + self, + CurrentUserId: int, + Page: int, + PageSize: int, + Area: str | None, + TenantCode: str | None, + NickName: str | None, + ) -> UserListVO: """查询用户列表。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:users:read") - currentUser = await self._getCurrentUserContext(CurrentUserId) + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:users:read") offset = max(Page - 1, 0) * PageSize filters = ["u.deleted_at IS NULL", "u.status = 0"] params: dict[str, object] = {"limit": PageSize, "offset": offset} - if NickName: - filters.append("u.nick_name ILIKE :nick_name") - params["nick_name"] = f"%{NickName.strip()}%" - if Area: - filters.append("u.area = :query_area") - params["query_area"] = Area.strip() - elif not currentUser["is_global"]: - filters.append("COALESCE(u.area, '') = :user_area") - params["user_area"] = currentUser["area"] - whereClause = " AND ".join(filters) - async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + requested_scope = await self._resolve_requested_scope(Session, RawValue=TenantCode or Area) + filters, params = self._apply_user_scope_filters( + Filters=filters, + Params=params, + CurrentUser=currentUser, + RequestedScope=requested_scope, + SsoUserColumns=sso_user_columns, + alias="u", + ) + if NickName: + filters.append("u.nick_name ILIKE :nick_name") + params["nick_name"] = f"%{NickName.strip()}%" + whereClause = " AND ".join(filters) + tenant_code_select = SsoUserCompat.optional_column_as( + sso_user_columns, + alias="u", + column="tenant_code", + output_alias="tenant_code", + ) + tenant_name_select = SsoUserCompat.optional_column_as( + sso_user_columns, + alias="u", + column="tenant_name", + output_alias="tenant_name", + ) total = int((await Session.execute(text(f"SELECT COUNT(*) FROM sso_users u WHERE {whereClause}"), params)).scalar_one()) rows = ( await Session.execute( text( f""" SELECT - u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, u.ou_name, u.ou_id, u.status, - u.is_leader, u.tenant_name, u.dep_name, u.dep_short_name, + u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, {tenant_code_select}, u.ou_name, u.ou_id, u.status, + u.is_leader, {tenant_name_select}, u.dep_name, u.dep_short_name, COALESCE( json_agg( DISTINCT jsonb_build_object('role_id', r.id, 'role_key', r.role_key, 'role_name', r.role_name) @@ -470,9 +546,7 @@ class RbacAdminServiceImpl(IRbacAdminService): async def GetOrganizationTree(self, CurrentUserId: int, IncludeUsers: bool, RootUuid: str | None) -> OrganizationTreeVO: """查询组织树。""" started_at = time.perf_counter() - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:users:read") - currentUser = await self._getCurrentUserContext(CurrentUserId) + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:users:read") root_uuid = str(RootUuid or "").strip() or None def _normalize_group_label(value: str, empty_label: str) -> str: @@ -482,70 +556,122 @@ class RbacAdminServiceImpl(IRbacAdminService): text_value = str(value or "").strip() return text_value or empty_label - def _make_org_user(row: Any) -> OrganizationTreeUserVO: - tenant_name = str(row.get("tenant_name") or "").strip() - dep_name = str(row.get("dep_name") or "").strip() - dep_short_name = str(row.get("dep_short_name") or "").strip() - ou_name = str(row.get("ou_name") or "").strip() - return OrganizationTreeUserVO( - id=int(row["id"]), - username=str(row.get("username") or ""), - nick_name=str(row.get("nick_name") or ""), - area=row.get("area"), - ou_id=str(row.get("ou_id") or ""), - ou_name=ou_name, - is_leader=bool(row.get("is_leader", False)), - status=int(row.get("status") or 0), - tenant_name=row.get("tenant_name"), - dep_name=row.get("dep_name"), - dep_short_name=row.get("dep_short_name"), - email=row.get("email"), - phone_number=row.get("phone_number"), - organization_path=OrganizationPathVO( - tenant_name=tenant_name, - dep_name=dep_name, - dep_short_name=dep_short_name, - ou_name=ou_name, - ), - ) - - user_filters = ["deleted_at IS NULL", "status = 0"] - params: dict[str, object] = {} - if not currentUser["is_global"]: - user_filters.append("COALESCE(area, '') = :user_area") - params["user_area"] = currentUser["area"] - - if root_uuid: - if root_uuid.startswith("tenant__"): - tenant_label = _normalize_group_label(root_uuid.removeprefix("tenant__"), self._UNGROUPED_TENANT_LABEL) - user_filters.append("COALESCE(tenant_name, '') = :tenant_name") - params["tenant_name"] = tenant_label - elif root_uuid.startswith("dep__"): - tenant_label, _, dep_label = root_uuid.removeprefix("dep__").partition("__") - tenant_label = _normalize_group_label(tenant_label, self._UNGROUPED_TENANT_LABEL) - dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL) - user_filters.append("COALESCE(tenant_name, '') = :tenant_name") - user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name") - params["tenant_name"] = tenant_label - params["dep_name"] = dep_label - elif root_uuid.startswith("org__"): - tenant_label, _, remainder = root_uuid.removeprefix("org__").partition("__") - dep_label, _, org_label = remainder.partition("__") - tenant_label = _normalize_group_label(tenant_label, self._UNGROUPED_TENANT_LABEL) - dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL) - org_label = _normalize_group_label(org_label, self._UNGROUPED_ORGANIZATION_LABEL) - user_filters.append("COALESCE(tenant_name, '') = :tenant_name") - user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name") - user_filters.append("COALESCE(ou_name, '') = :ou_name") - params["tenant_name"] = tenant_label - params["dep_name"] = dep_label - params["ou_name"] = org_label - else: - user_filters.append("COALESCE(ou_id, '') = :root_ou_id") - params["root_ou_id"] = root_uuid - where_clause = " AND ".join(user_filters) - async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_expr = SsoUserCompat.raw_optional_column(sso_user_columns, alias="sso_users", column="tenant_code") + tenant_code_expr_u = SsoUserCompat.raw_optional_column(sso_user_columns, alias="u", column="tenant_code") + tenant_name_expr = f"COALESCE({SsoUserCompat.raw_optional_column(sso_user_columns, alias='sso_users', column='tenant_name')}, area, '')" + tenant_name_expr_u = f"COALESCE({SsoUserCompat.raw_optional_column(sso_user_columns, alias='u', column='tenant_name')}, u.area, '')" + + def _tenant_scope_sql(*, code_param: str, name_param: str) -> str: + return ( + f"(COALESCE({tenant_code_expr}, '') = :{code_param} " + f"OR (COALESCE({tenant_code_expr}, '') = '' AND {tenant_name_expr} = :{name_param}))" + ) + + def _make_org_user(row: Any) -> OrganizationTreeUserVO: + tenant_name = str(row.get("tenant_name") or row.get("area") or "").strip() + dep_name = str(row.get("dep_name") or "").strip() + dep_short_name = str(row.get("dep_short_name") or "").strip() + ou_name = str(row.get("ou_name") or "").strip() + return OrganizationTreeUserVO( + id=int(row["id"]), + username=str(row.get("username") or ""), + nick_name=str(row.get("nick_name") or ""), + area=row.get("area"), + tenant_code=row.get("tenant_code"), + ou_id=str(row.get("ou_id") or ""), + ou_name=ou_name, + is_leader=bool(row.get("is_leader", False)), + status=int(row.get("status") or 0), + tenant_name=row.get("tenant_name") or row.get("area"), + dep_name=row.get("dep_name"), + dep_short_name=row.get("dep_short_name"), + email=row.get("email"), + phone_number=row.get("phone_number"), + organization_path=OrganizationPathVO( + tenant_name=tenant_name, + dep_name=dep_name, + dep_short_name=dep_short_name, + ou_name=ou_name, + ), + ) + user_filters = ["deleted_at IS NULL", "status = 0"] + params: dict[str, object] = {} + if not currentUser["is_global"]: + current_tenant_code = str(currentUser.get("tenant_code") or "").strip() + current_tenant_name = str(currentUser.get("tenant_scope_value") or "").strip() + if current_tenant_code: + user_filters.append(_tenant_scope_sql(code_param="user_tenant_code", name_param="user_tenant_name")) + params["user_tenant_code"] = current_tenant_code + params["user_tenant_name"] = current_tenant_name + else: + user_filters.append(f"{tenant_name_expr} = :user_tenant_name") + params["user_tenant_name"] = current_tenant_name + + if root_uuid: + if root_uuid.startswith("tenant__"): + tenant_key = _normalize_group_label(root_uuid.removeprefix("tenant__"), self._UNGROUPED_TENANT_LABEL) + if tenant_key: + params["tenant_node_code"] = tenant_key + resolution = await self.TenantResolver.Resolve( + RawValue=tenant_key, + Source="rbac_org_tree_tenant_root", + PreferredTenantCode=tenant_key, + ) + params["tenant_node_name"] = str(resolution.tenant_name or tenant_key).strip() + user_filters.append(_tenant_scope_sql(code_param="tenant_node_code", name_param="tenant_node_name")) + else: + user_filters.append(f"COALESCE({tenant_code_expr}, '') = ''") + user_filters.append(f"{tenant_name_expr} = :tenant_node_name") + params["tenant_node_name"] = "" + elif root_uuid.startswith("dep__"): + tenant_key, _, dep_label = root_uuid.removeprefix("dep__").partition("__") + tenant_key = _normalize_group_label(tenant_key, self._UNGROUPED_TENANT_LABEL) + dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL) + if tenant_key: + params["tenant_node_code"] = tenant_key + resolution = await self.TenantResolver.Resolve( + RawValue=tenant_key, + Source="rbac_org_tree_department_root", + PreferredTenantCode=tenant_key, + ) + params["tenant_node_name"] = str(resolution.tenant_name or tenant_key).strip() + user_filters.append(_tenant_scope_sql(code_param="tenant_node_code", name_param="tenant_node_name")) + else: + user_filters.append(f"COALESCE({tenant_code_expr}, '') = ''") + params["tenant_node_name"] = "" + user_filters.append(f"{tenant_name_expr} = :tenant_node_name") + user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name") + params["dep_name"] = dep_label + elif root_uuid.startswith("org__"): + tenant_key, _, remainder = root_uuid.removeprefix("org__").partition("__") + dep_label, _, org_label = remainder.partition("__") + tenant_key = _normalize_group_label(tenant_key, self._UNGROUPED_TENANT_LABEL) + dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL) + org_label = _normalize_group_label(org_label, self._UNGROUPED_ORGANIZATION_LABEL) + if tenant_key: + params["tenant_node_code"] = tenant_key + resolution = await self.TenantResolver.Resolve( + RawValue=tenant_key, + Source="rbac_org_tree_org_root", + PreferredTenantCode=tenant_key, + ) + params["tenant_node_name"] = str(resolution.tenant_name or tenant_key).strip() + user_filters.append(_tenant_scope_sql(code_param="tenant_node_code", name_param="tenant_node_name")) + else: + user_filters.append(f"COALESCE({tenant_code_expr}, '') = ''") + params["tenant_node_name"] = "" + user_filters.append(f"{tenant_name_expr} = :tenant_node_name") + user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name") + user_filters.append("COALESCE(ou_name, '') = :ou_name") + params["dep_name"] = dep_label + params["ou_name"] = org_label + else: + user_filters.append("COALESCE(ou_id, '') = :root_ou_id") + params["root_ou_id"] = root_uuid + where_clause = " AND ".join(user_filters) + total_users = int( ( await Session.execute( @@ -570,10 +696,12 @@ class RbacAdminServiceImpl(IRbacAdminService): await Session.execute( text( f""" - SELECT DISTINCT COALESCE(tenant_name, '') AS tenant_name + SELECT DISTINCT + COALESCE({tenant_code_expr}, '') AS tenant_code, + {tenant_name_expr} AS tenant_name FROM sso_users WHERE {where_clause} - ORDER BY COALESCE(tenant_name, '') ASC + ORDER BY COALESCE({tenant_code_expr}, '') ASC, {tenant_name_expr} ASC """ ), params, @@ -582,7 +710,7 @@ class RbacAdminServiceImpl(IRbacAdminService): query_rows_count = len(tenant_rows) organizations = [ OrganizationNodeVO( - ou_id=f"tenant__{_display_group_label(row.get('tenant_name'), self._UNGROUPED_TENANT_LABEL)}", + ou_id=f"tenant__{_display_group_label(row.get('tenant_code'), self._UNGROUPED_TENANT_LABEL)}", ou_name=_display_group_label(row.get("tenant_name"), self._UNGROUPED_TENANT_LABEL), parent_ou_id=None, level=1, @@ -591,7 +719,8 @@ class RbacAdminServiceImpl(IRbacAdminService): ] total_organizations = len(organizations) elif root_uuid.startswith("tenant__"): - tenant_label = _display_group_label(params.get("tenant_name"), self._UNGROUPED_TENANT_LABEL) + tenant_key = _normalize_group_label(root_uuid.removeprefix("tenant__"), self._UNGROUPED_TENANT_LABEL) + tenant_label = _display_group_label(params.get("tenant_node_name"), self._UNGROUPED_TENANT_LABEL) dep_rows = ( await Session.execute( text( @@ -609,7 +738,7 @@ class RbacAdminServiceImpl(IRbacAdminService): query_rows_count = len(dep_rows) child_nodes = [ OrganizationNodeVO( - ou_id=f"dep__{tenant_label}__{_display_group_label(row.get('department_name'), self._UNGROUPED_DEPARTMENT_LABEL)}", + ou_id=f"dep__{tenant_key}__{_display_group_label(row.get('department_name'), self._UNGROUPED_DEPARTMENT_LABEL)}", ou_name=_display_group_label(row.get("department_name"), self._UNGROUPED_DEPARTMENT_LABEL), parent_ou_id=root_uuid, level=2, @@ -627,7 +756,7 @@ class RbacAdminServiceImpl(IRbacAdminService): ] total_organizations = 1 + len(child_nodes) elif root_uuid.startswith("dep__"): - tenant_label, _, dep_label = root_uuid.removeprefix("dep__").partition("__") + tenant_key, _, dep_label = root_uuid.removeprefix("dep__").partition("__") org_rows = ( await Session.execute( text( @@ -647,7 +776,7 @@ class RbacAdminServiceImpl(IRbacAdminService): child_nodes = [ OrganizationNodeVO( ou_id=str(row.get("ou_id") or "").strip() - or f"org__{tenant_label}__{dep_label}__{_display_group_label(row.get('ou_name'), self._UNGROUPED_ORGANIZATION_LABEL)}", + or f"org__{tenant_key}__{dep_label}__{_display_group_label(row.get('ou_name'), self._UNGROUPED_ORGANIZATION_LABEL)}", ou_name=_display_group_label(row.get("ou_name"), self._UNGROUPED_ORGANIZATION_LABEL), parent_ou_id=root_uuid, level=3, @@ -658,7 +787,7 @@ class RbacAdminServiceImpl(IRbacAdminService): OrganizationNodeVO( ou_id=root_uuid, ou_name=_display_group_label(_normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL), self._UNGROUPED_DEPARTMENT_LABEL), - parent_ou_id=f"tenant__{tenant_label}", + parent_ou_id=f"tenant__{tenant_key}", level=2, children=child_nodes, ) @@ -674,11 +803,12 @@ class RbacAdminServiceImpl(IRbacAdminService): username, nick_name, area, + {tenant_code_expr_u} AS tenant_code, ou_id, ou_name, is_leader, status, - tenant_name, + {tenant_name_expr_u} AS tenant_name, dep_name, dep_short_name, email, @@ -696,27 +826,27 @@ class RbacAdminServiceImpl(IRbacAdminService): total_users = len(user_rows) if IncludeUsers else total_users if root_uuid.startswith("org__"): - tenant_label, _, remainder = root_uuid.removeprefix("org__").partition("__") + tenant_key, _, remainder = root_uuid.removeprefix("org__").partition("__") dep_label, _, org_label = remainder.partition("__") organizations = [ OrganizationNodeVO( ou_id=root_uuid, ou_name=_display_group_label(_normalize_group_label(org_label, self._UNGROUPED_ORGANIZATION_LABEL), self._UNGROUPED_ORGANIZATION_LABEL), - parent_ou_id=f"dep__{tenant_label}__{dep_label}", + parent_ou_id=f"dep__{tenant_key}__{dep_label}", level=3, users=users, ) ] else: first_row = user_rows[0] if user_rows else {} - tenant_label = _display_group_label(first_row.get("tenant_name"), self._UNGROUPED_TENANT_LABEL) + tenant_key = _display_group_label(first_row.get("tenant_code"), self._UNGROUPED_TENANT_LABEL) dep_label = _display_group_label(first_row.get("dep_name") or first_row.get("dep_short_name"), self._UNGROUPED_DEPARTMENT_LABEL) org_label = _display_group_label(first_row.get("ou_name"), self._UNGROUPED_ORGANIZATION_LABEL) organizations = [ OrganizationNodeVO( ou_id=root_uuid, ou_name=org_label, - parent_ou_id=f"dep__{tenant_label}__{dep_label}", + parent_ou_id=f"dep__{tenant_key}__{dep_label}", level=3, users=users, ) @@ -741,26 +871,36 @@ class RbacAdminServiceImpl(IRbacAdminService): ) return result - async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO: + async def ListRoleUsers( + self, + CurrentUserId: int, + RoleId: int, + Page: int, + PageSize: int, + Area: str | None, + TenantCode: str | None, + UserName: str | None, + ) -> UserListVO: """查询指定角色下的用户列表。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:users:read") - currentUser = await self._getCurrentUserContext(CurrentUserId) + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:users:read") offset = max(Page - 1, 0) * PageSize filters = ["u.deleted_at IS NULL", "u.status = 0", "ur.role_id = :role_id"] params: dict[str, object] = {"role_id": RoleId, "limit": PageSize, "offset": offset} - if UserName: - filters.append("(u.username ILIKE :user_name OR u.nick_name ILIKE :user_name)") - params["user_name"] = f"%{UserName.strip()}%" - if Area: - filters.append("u.area = :query_area") - params["query_area"] = Area.strip() - elif not currentUser["is_global"]: - filters.append("COALESCE(u.area, '') = :user_area") - params["user_area"] = currentUser["area"] - whereClause = " AND ".join(filters) - async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + requested_scope = await self._resolve_requested_scope(Session, RawValue=TenantCode or Area) + filters, params = self._apply_user_scope_filters( + Filters=filters, + Params=params, + CurrentUser=currentUser, + RequestedScope=requested_scope, + SsoUserColumns=sso_user_columns, + alias="u", + ) + if UserName: + filters.append("(u.username ILIKE :user_name OR u.nick_name ILIKE :user_name)") + params["user_name"] = f"%{UserName.strip()}%" + whereClause = " AND ".join(filters) total = int( ( await Session.execute( @@ -776,13 +916,25 @@ class RbacAdminServiceImpl(IRbacAdminService): ) ).scalar_one() ) + tenant_code_select = SsoUserCompat.optional_column_as( + sso_user_columns, + alias="u", + column="tenant_code", + output_alias="tenant_code", + ) + tenant_name_select = SsoUserCompat.optional_column_as( + sso_user_columns, + alias="u", + column="tenant_name", + output_alias="tenant_name", + ) rows = ( await Session.execute( text( f""" SELECT - u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, u.ou_name, u.ou_id, u.status, - u.is_leader, u.tenant_name, u.dep_name, u.dep_short_name, + u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, {tenant_code_select}, u.ou_name, u.ou_id, u.status, + u.is_leader, {tenant_name_select}, u.dep_name, u.dep_short_name, COALESCE( json_agg( DISTINCT jsonb_build_object('role_id', r.id, 'role_key', r.role_key, 'role_name', r.role_name) @@ -806,8 +958,8 @@ class RbacAdminServiceImpl(IRbacAdminService): async def AssignUserRoles(self, CurrentUserId: int, UserId: int, RoleIds: list[int]) -> UserRolesVO: """为用户分配角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:user_roles:write") + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:user_roles:write") + await self._assertTargetUserWithinScope(CurrentUser=currentUser, UserId=UserId, Operation="分配角色") async with GetAsyncSession() as Session: await Session.execute(text("DELETE FROM user_role WHERE user_id = :user_id"), {"user_id": UserId}) for roleId in sorted(set(RoleIds)): @@ -818,20 +970,119 @@ class RbacAdminServiceImpl(IRbacAdminService): {"user_id": UserId, "role_id": roleId}, ) await Session.commit() + PermissionServiceImpl.InvalidateUser(UserId) return await self.GetUserRoles(CurrentUserId, UserId) + async def UpdateUserTenant(self, CurrentUserId: int, UserId: int, Body: UserTenantUpdateDTO) -> UserTenantUpdateVO: + """更新用户租户。""" + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:user_tenant:update") + + normalized_tenant_code = str(Body.tenant_code or "").strip() + if not normalized_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户编码不能为空") + + tenant_resolution = await self.TenantResolver.Resolve( + RawValue=None, + Source="rbac_update_user_tenant", + PreferredTenantCode=normalized_tenant_code, + ) + if not tenant_resolution.tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在或未启用") + if not currentUser["is_global"] and tenant_resolution.tenant_code != currentUser.get("tenant_code"): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能修改其他租户用户") + + async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + row = ( + await Session.execute( + text( + f""" + SELECT + u.id, + u.username, + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select} + FROM sso_users u + WHERE u.id = :user_id + AND u.deleted_at IS NULL + LIMIT 1 + """ + ), + {"user_id": UserId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "用户不存在") + + target_user_tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row.get("area") or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="rbac_update_user_existing_context", + ) + if not currentUser["is_global"]: + existing_target_tenant_code = target_user_tenant.tenant_code or str(row.get("tenant_code") or "") or None + if existing_target_tenant_code and existing_target_tenant_code != currentUser.get("tenant_code"): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能修改其他租户用户") + + update_payload = { + "user_id": UserId, + "tenant_code": tenant_resolution.tenant_code, + "tenant_name": tenant_resolution.tenant_name or "", + "area": tenant_resolution.tenant_name or "", + } + await Session.execute( + text( + """ + UPDATE sso_users + SET + tenant_code = :tenant_code, + tenant_name = :tenant_name, + area = :area, + updated_at = NOW() + WHERE id = :user_id + AND deleted_at IS NULL + """ + ), + update_payload, + ) + await Session.commit() + PermissionServiceImpl.InvalidateUser(UserId) + + return UserTenantUpdateVO( + user_id=UserId, + username=str(row.get("username") or ""), + area=tenant_resolution.tenant_name or "", + tenant_code=tenant_resolution.tenant_code, + tenant_name=tenant_resolution.tenant_name, + ) + async def RevokeUserRole(self, CurrentUserId: int, UserId: int, RoleId: int) -> None: """移除用户角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:user_roles:write") + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:user_roles:write") + await self._assertTargetUserWithinScope(CurrentUser=currentUser, UserId=UserId, Operation="移除角色") async with GetAsyncSession() as Session: await Session.execute(text("DELETE FROM user_role WHERE user_id = :user_id AND role_id = :role_id"), {"user_id": UserId, "role_id": RoleId}) await Session.commit() + PermissionServiceImpl.InvalidateUser(UserId) async def GetUserRoles(self, CurrentUserId: int, UserId: int) -> UserRolesVO: """查询用户角色。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:users:read") + currentUser = await self._assertManageAndPermission(CurrentUserId, "rbac:users:read") + await self._assertTargetUserWithinScope(CurrentUser=currentUser, UserId=UserId, Operation="查询角色") async with GetAsyncSession() as Session: userRow = ( await Session.execute(text("SELECT id, username FROM sso_users WHERE id = :user_id"), {"user_id": UserId}) @@ -858,7 +1109,7 @@ class RbacAdminServiceImpl(IRbacAdminService): """查询全部可管理路由。""" await self._assertManagePermission(CurrentUserId) async with GetAsyncSession() as Session: - routeMap = await self._ensureAdminSeeds(Session) + routeMap = await self._getAdminRouteMap(Session, EnsureSeeds=False) rows = ( await Session.execute( text( @@ -877,10 +1128,9 @@ class RbacAdminServiceImpl(IRbacAdminService): async def GetRoleRoutes(self, CurrentUserId: int, RoleId: int) -> RoleRoutesVO: """查询角色路由授权。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:read") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:read") async with GetAsyncSession() as Session: - routeMap = await self._ensureAdminSeeds(Session) + routeMap = await self._getAdminRouteMap(Session, EnsureSeeds=False) rows = ( await Session.execute( text( @@ -904,21 +1154,20 @@ class RbacAdminServiceImpl(IRbacAdminService): async def UpdateRoleRoutes(self, CurrentUserId: int, RoleId: int, Body: RoleRoutesUpdateDTO) -> RoleRouteUpdateResultVO: """更新角色路由授权。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:role_routes:write") + await self._assertManageAndPermission(CurrentUserId, "rbac:role_routes:write") routeIds = sorted(set(Body.route_ids)) async with GetAsyncSession() as Session: - await self._ensureAdminSeeds(Session) + await self._getAdminRouteMap(Session, EnsureSeeds=True) result = await self._updateRoleRoutesInSession(Session, RoleId, routeIds, Body.permission) await Session.commit() + PermissionServiceImpl.InvalidateAll() return result async def GetRolePermissions(self, CurrentUserId: int, RoleId: int) -> RolePermissionsVO: """查询角色权限授权。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:roles:read") + await self._assertManageAndPermission(CurrentUserId, "rbac:roles:read") async with GetAsyncSession() as Session: - await self._ensureAdminSeeds(Session) + await self._getAdminRouteMap(Session, EnsureSeeds=False) rows = ( await Session.execute( text( @@ -937,19 +1186,17 @@ class RbacAdminServiceImpl(IRbacAdminService): async def SaveRolePermissions(self, CurrentUserId: int, Body: RolePermissionsBatchDTO) -> RolePermissionsVO: """保存角色权限授权。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:role_permissions:write") + await self._assertManageAndPermission(CurrentUserId, "rbac:role_permissions:write") async with GetAsyncSession() as Session: - await self._ensureAdminSeeds(Session) + await self._getAdminRouteMap(Session, EnsureSeeds=True) await self._saveRolePermissionsInSession(Session, Body.role_id, Body.permissions, Body.replace, Body.replace_scope_permission_ids) await Session.commit() + PermissionServiceImpl.InvalidateAll() return await self.GetRolePermissions(CurrentUserId, Body.role_id) async def SaveRoleAccess(self, CurrentUserId: int, RoleId: int, Body: RoleAccessSaveDTO) -> RoleAccessSaveVO: """原子保存角色菜单与接口权限。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:role_routes:write") - await self._assertPermission(CurrentUserId, "rbac:role_permissions:write") + await self._assertManageAndPermissions(CurrentUserId, ["rbac:role_routes:write", "rbac:role_permissions:write"]) routeIds = sorted(set(int(routeId) for routeId in Body.route_ids)) permissionIds = sorted(set(int(permissionId) for permissionId in Body.permission_ids)) permissionConfigs = [ @@ -957,19 +1204,19 @@ class RbacAdminServiceImpl(IRbacAdminService): for permissionId in permissionIds ] async with GetAsyncSession() as Session: - await self._ensureAdminSeeds(Session) + await self._getAdminRouteMap(Session, EnsureSeeds=True) routeResult = await self._updateRoleRoutesInSession(Session, RoleId, routeIds, Body.route_permission) await self._saveRolePermissionsInSession(Session, RoleId, permissionConfigs, True, Body.replace_scope_permission_ids) permissionResult = await self._getRolePermissionsInSession(Session, RoleId) await Session.commit() + PermissionServiceImpl.InvalidateAll() return RoleAccessSaveVO(role_id=RoleId, route_result=routeResult, permission_result=permissionResult) async def GetRoutePermissions(self, CurrentUserId: int, RouteId: int) -> RoutePermissionsVO: """查询路由关联权限定义。""" - await self._assertManagePermission(CurrentUserId) - await self._assertPermission(CurrentUserId, "rbac:permissions:read") + await self._assertManageAndPermission(CurrentUserId, "rbac:permissions:read") async with GetAsyncSession() as Session: - await self._ensureAdminSeeds(Session) + await self._getAdminRouteMap(Session, EnsureSeeds=False) routeRow = ( await Session.execute(text("SELECT id, route_path, route_title FROM sys_routes WHERE id = :route_id"), {"route_id": RouteId}) ).mappings().first() @@ -1081,6 +1328,51 @@ class RbacAdminServiceImpl(IRbacAdminService): ).mappings().all() return RolePermissionsVO(role_id=RoleId, permissions=[self._toRolePermissionVo(row) for row in rows]) + def _get_cached_admin_seed_route_map(self) -> dict[str, int] | None: + """返回近期已确认的 RBAC 管理路由映射,避免高频读接口反复 upsert。""" + if not self._admin_seed_route_map_cache: + return None + if time.monotonic() - self._admin_seed_route_map_cached_at > self._ADMIN_SEED_CACHE_TTL_SECONDS: + return None + expectedPaths = {str(item["route_path"]) for item in self._MANAGEABLE_ROUTE_BLUEPRINTS} + if set(self._admin_seed_route_map_cache.keys()) != expectedPaths: + self.__class__._admin_seed_route_map_cache = None + self.__class__._admin_seed_route_map_cached_at = 0.0 + return None + return dict(self._admin_seed_route_map_cache) + + def _remember_admin_seed_route_map(self, RouteMap: dict[str, int]) -> None: + self.__class__._admin_seed_route_map_cache = dict(RouteMap) + self.__class__._admin_seed_route_map_cached_at = time.monotonic() + + async def _getAdminRouteMap(self, Session, *, EnsureSeeds: bool) -> dict[str, int]: + """读取可管理路由映射;写接口或缓存失效时才执行 seed。""" + cached = self._get_cached_admin_seed_route_map() + if cached: + return cached + + if EnsureSeeds: + return await self._ensureAdminSeeds(Session) + + paths = [str(item["route_path"]) for item in self._MANAGEABLE_ROUTE_BLUEPRINTS] + rows = ( + await Session.execute( + text( + """ + SELECT route_path, id + FROM sys_routes + WHERE deleted_at IS NULL + AND route_path = ANY(:paths) + """ + ).bindparams(paths=paths) + ) + ).mappings().all() + routeMap = {str(row["route_path"]): int(row["id"]) for row in rows} + if len(routeMap) == len(paths): + return await self._ensureAdminSeeds(Session) + + return await self._ensureAdminSeeds(Session) + async def _assertManagePermission(self, CurrentUserId: int) -> None: """校验当前用户是否具备管理能力。""" context = await self._getCurrentUserContext(CurrentUserId) @@ -1089,6 +1381,65 @@ class RbacAdminServiceImpl(IRbacAdminService): async def _assertPermission(self, CurrentUserId: int, PermissionKey: str) -> None: """校验当前用户是否具备特定细粒度权限。super_admin 自动放行。""" + await self._assertPermissions(CurrentUserId, [PermissionKey]) + + async def _assertPermissions(self, CurrentUserId: int, PermissionKeys: list[str]) -> dict[str, Any]: + """一次性校验管理能力和多个细粒度权限,返回当前用户上下文。""" + context = await self._getCurrentUserContext(CurrentUserId) + if not context["can_manage"]: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有系统设置管理权限") + + permissionKeys = sorted({str(key).strip() for key in PermissionKeys if str(key).strip()}) + if context["is_super_admin"] or not permissionKeys: + return context + + async with GetAsyncSession() as Session: + grantedRows = ( + await Session.execute( + text( + """ + SELECT DISTINCT p.permission_key + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + JOIN user_role ur ON ur.role_id = rp.role_id + WHERE ur.user_id = :user_id + AND p.permission_key = ANY(:permission_keys) + AND rp.grant_type = 'GRANT' + """ + ).bindparams(permission_keys=permissionKeys), + {"user_id": CurrentUserId}, + ) + ).mappings().all() + granted = {str(row["permission_key"] or "") for row in grantedRows} + missing = [key for key in permissionKeys if key not in granted] + if not missing: + return context + + displayRows = ( + await Session.execute( + text( + """ + SELECT permission_key, display_name + FROM permissions + WHERE permission_key = ANY(:permission_keys) + """ + ).bindparams(permission_keys=missing) + ) + ).mappings().all() + displayByKey = {str(row["permission_key"] or ""): str(row["display_name"] or "") for row in displayRows} + displayName = displayByKey.get(missing[0]) or missing[0] + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"缺少「{displayName}」权限") + + async def _assertManageAndPermission(self, CurrentUserId: int, PermissionKey: str) -> dict[str, Any]: + """兼容单权限场景的合并校验。""" + return await self._assertPermissions(CurrentUserId, [PermissionKey]) + + async def _assertManageAndPermissions(self, CurrentUserId: int, PermissionKeys: list[str]) -> dict[str, Any]: + """兼容多权限场景的合并校验。""" + return await self._assertPermissions(CurrentUserId, PermissionKeys) + + async def _assertPermissionLegacy(self, CurrentUserId: int, PermissionKey: str) -> None: + """保留旧逻辑参考;不再由热路径调用。""" context = await self._getCurrentUserContext(CurrentUserId) if context["is_super_admin"]: return @@ -1123,13 +1474,28 @@ class RbacAdminServiceImpl(IRbacAdminService): async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]: """加载当前用户上下文。""" async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) row = ( await Session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin @@ -1145,7 +1511,139 @@ class RbacAdminServiceImpl(IRbacAdminService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") - return {"area": str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_super_admin": bool(row["is_super_admin"])} + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row["tenant_code"] or "") or None, + TenantName=str(row["tenant_name"] or "") or None, + Source="rbac_admin_user_context", + ) + return { + "area": str(row["area"] or ""), + "tenant_code": tenant.tenant_code or str(row["tenant_code"] or "") or None, + "tenant_name": tenant.tenant_name or str(row["tenant_name"] or "") or str(row["area"] or "") or None, + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "is_global": bool(row["is_global"]), + "can_manage": bool(row["can_manage"]), + "is_super_admin": bool(row["is_super_admin"]), + } + + async def _resolve_requested_scope(self, Session, RawValue: str | None) -> dict[str, str]: + """把显式 area 参数统一解析为租户编码 + 展示值。""" + normalized = str(RawValue or "").strip() + if not normalized: + return {"tenant_code": "", "tenant_name": "", "normalized_value": ""} + resolution = await self.TenantResolver.Resolve( + RawValue=normalized, + Source="rbac_admin_query_scope", + PreferredTenantCode=normalized, + ) + return { + "tenant_code": str(resolution.tenant_code or "").strip(), + "tenant_name": str(resolution.tenant_name or "").strip(), + "normalized_value": str(resolution.normalized_value or normalized).strip(), + } + + def _apply_user_scope_filters( + self, + *, + Filters: list[str], + Params: dict[str, object], + CurrentUser: dict[str, Any], + RequestedScope: dict[str, str], + SsoUserColumns: set[str], + alias: str, + ) -> tuple[list[str], dict[str, object]]: + tenant_code_expr = SsoUserCompat.raw_optional_column(SsoUserColumns, alias=alias, column="tenant_code") + tenant_name_expr = f"COALESCE({SsoUserCompat.raw_optional_column(SsoUserColumns, alias=alias, column='tenant_name')}, {alias}.area, '')" + + requested_tenant_code = str(RequestedScope.get("tenant_code") or "").strip() + requested_tenant_name = str(RequestedScope.get("tenant_name") or RequestedScope.get("normalized_value") or "").strip() + current_tenant_code = str(CurrentUser.get("tenant_code") or "").strip() + current_tenant_name = str(CurrentUser.get("tenant_scope_value") or "").strip() + + if requested_tenant_code or requested_tenant_name: + if not CurrentUser["is_global"]: + if requested_tenant_code: + if not current_tenant_code or requested_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能查询其他租户用户") + elif requested_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能查询其他租户用户") + if requested_tenant_code: + Filters.append(f"COALESCE({tenant_code_expr}, '') = :query_tenant_code") + Params["query_tenant_code"] = requested_tenant_code + else: + Filters.append(f"{tenant_name_expr} = :query_tenant_name") + Params["query_tenant_name"] = requested_tenant_name + elif not CurrentUser["is_global"]: + if current_tenant_code: + Filters.append(f"COALESCE({tenant_code_expr}, '') = :user_tenant_code") + Params["user_tenant_code"] = current_tenant_code + else: + Filters.append(f"{tenant_name_expr} = :user_tenant_name") + Params["user_tenant_name"] = current_tenant_name + return Filters, Params + + async def _assertTargetUserWithinScope(self, *, CurrentUser: dict[str, Any], UserId: int, Operation: str) -> None: + """校验目标用户是否处于当前管理员可操作租户范围。""" + if CurrentUser["is_global"]: + return + + async with GetAsyncSession() as Session: + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + row = ( + await Session.execute( + text( + f""" + SELECT + u.id, + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select} + FROM sso_users u + WHERE u.id = :user_id + AND u.deleted_at IS NULL + LIMIT 1 + """ + ), + {"user_id": UserId}, + ) + ).mappings().first() + + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "用户不存在") + + target_tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row.get("area") or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="rbac_target_user_scope_check", + ) + target_tenant_code = str(target_tenant.tenant_code or row.get("tenant_code") or "").strip() + target_tenant_name = str(target_tenant.tenant_name or row.get("tenant_name") or row.get("area") or "").strip() + current_tenant_code = str(CurrentUser.get("tenant_code") or "").strip() + current_tenant_name = str(CurrentUser.get("tenant_scope_value") or "").strip() + + if current_tenant_code: + if target_tenant_code and target_tenant_code != current_tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能{Operation}其他租户用户") + if not target_tenant_code and target_tenant_name and target_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能{Operation}其他租户用户") + return + + if target_tenant_name != current_tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能{Operation}其他租户用户") async def _ensureAdminSeeds(self, Session) -> dict[str, int]: """确保系统设置所需路由和权限定义已存在。""" @@ -1211,7 +1709,66 @@ class RbacAdminServiceImpl(IRbacAdminService): "api_method": blueprint["api_method"], }, ) + + for role_key, permission_keys in self._CORE_ROLE_AUTO_GRANTS.items(): + if not permission_keys: + continue + await Session.execute( + text( + """ + INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) + SELECT + r.id, + p.id, + 'GRANT', + 'ALL', + NOW(), + NOW() + FROM roles r + JOIN permissions p + ON p.permission_key = ANY(:permission_keys) + WHERE r.role_key = :role_key + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ) + """ + ).bindparams(permission_keys=list(permission_keys)), + {"role_key": role_key}, + ) + + for role_key, route_paths in self._CORE_ROLE_AUTO_ROUTE_PATHS.items(): + if not route_paths: + continue + await Session.execute( + text( + """ + INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) + SELECT + r.id, + sr.id, + 'RW', + 1, + NOW(), + NOW() + FROM roles r + JOIN sys_routes sr ON sr.route_path = ANY(:route_paths) + WHERE r.role_key = :role_key + AND sr.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM role_route rr + WHERE rr.role_id = r.id + AND rr.route_id = sr.id + ) + """ + ).bindparams(route_paths=list(route_paths)), + {"role_key": role_key}, + ) await Session.commit() + self._remember_admin_seed_route_map(routeIdsByPath) return routeIdsByPath async def _loadPermissionsByRoute(self, Session, RouteIds: list[int]) -> dict[int, list[RoutePermissionVO]]: @@ -1299,6 +1856,7 @@ class RbacAdminServiceImpl(IRbacAdminService): phone_number=Row.get("phone_number"), email=Row.get("email"), area=Row.get("area"), + tenant_code=Row.get("tenant_code"), ou_name=Row.get("ou_name"), ou_id=Row.get("ou_id"), status=int(Row.get("status") or 0), diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py index 99374b3..c2b082c 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py @@ -26,12 +26,12 @@ class RbacServiceImpl(IRbacService): "/files", "/documents", "/rules", - "/rule-groups", "/rules-files", "/settings", "/entry-modules", "/role-permissions", "/document-types", + "/tenants", "/usage-stats", ) @@ -121,20 +121,6 @@ class RbacServiceImpl(IRbacService): "is_cache": True, "meta": {"group": "rules"}, "children": [ - { - "id": 1007, - "route_path": "/rule-groups", - "route_name": "rule-groups", - "component": "rule-groups", - "parent_id": 1006, - "route_title": "评查点分组", - "icon": "ri-folder-open-line", - "sort_order": 1, - "is_hidden": False, - "is_cache": True, - "meta": {"group": "rules"}, - "children": None, - }, { "id": 1008, "route_path": "/rules/list", @@ -265,13 +251,27 @@ class RbacServiceImpl(IRbacService): }, { "id": 1017, + "route_path": "/tenants", + "route_name": "tenants", + "component": "tenants", + "parent_id": 1013, + "route_title": "租户管理", + "icon": "ri-building-line", + "sort_order": 4, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, + { + "id": 1018, "route_path": "/usage-stats", "route_name": "usage-stats", "component": "usage-stats", "parent_id": 1013, "route_title": "系统使用统计", "icon": "ri-bar-chart-box-line", - "sort_order": 4, + "sort_order": 5, "is_hidden": False, "is_cache": True, "meta": {"group": "settings"}, @@ -280,7 +280,7 @@ class RbacServiceImpl(IRbacService): ], }, { - "id": 1018, + "id": 1019, "route_path": "/cross-checking", "route_name": "cross-checking", "component": "cross-checking", @@ -394,20 +394,6 @@ class RbacServiceImpl(IRbacService): "is_cache": True, "meta": {"group": "rules"}, "children": [ - { - "id": 2006, - "route_path": "/rule-groups", - "route_name": "rule-groups", - "component": "rule-groups", - "parent_id": 2005, - "route_title": "评查点分组", - "icon": "ri-folder-open-line", - "sort_order": 1, - "is_hidden": False, - "is_cache": True, - "meta": {"group": "rules"}, - "children": None, - }, { "id": 2007, "route_path": "/rules/list", @@ -579,9 +565,9 @@ class RbacServiceImpl(IRbacService): "/entry-modules": ["entry_module:"], "/role-permissions": ["rbac:"], "/document-types": ["doc_type:"], + "/tenants": ["rbac:tenants:"], "/usage-stats": ["usage_stats:"], - "/rules": ["rules:", "evaluation_point:"], - "/rule-groups": ["evaluation_group:", "rules:"], + "/rules": ["rules:", "evaluation_point:", "evaluation_group:"], "/rules/list": ["rules:", "evaluation_point:"], "/rules-files": ["rules:"], } diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py index 92d2e7b..028a7dd 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py @@ -22,6 +22,7 @@ from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleVal from fastapi_modules.fastapi_leaudit.services import IOssService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantScope import normalize_scoped_tenant_code, pick_effective_scoped_row from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService @@ -29,7 +30,7 @@ class RuleConfigServiceImpl(IRuleConfigService): """规则配置页聚合服务实现。""" _GLOBAL_YAML_SUMMARY_CACHE: dict[int, tuple[float, dict[str, Any]]] = {} - _GLOBAL_PACK_SUMMARY_CACHE: tuple[float, list[RuleConfigPackListVO]] | None = None + _GLOBAL_PACK_SUMMARY_CACHE: dict[str, tuple[float, list[RuleConfigPackListVO]]] = {} _GLOBAL_WARM_LOCK: asyncio.Lock | None = None def __init__(self, OssService: IOssService | None = None) -> None: @@ -40,8 +41,11 @@ class RuleConfigServiceImpl(IRuleConfigService): self._pack_summary_cache = self.__class__._GLOBAL_PACK_SUMMARY_CACHE @classmethod - def _set_pack_summary_cache(cls, value: tuple[float, list[RuleConfigPackListVO]] | None) -> None: - cls._GLOBAL_PACK_SUMMARY_CACHE = value + def _set_pack_summary_cache(cls, cache_key: str, value: tuple[float, list[RuleConfigPackListVO]] | None) -> None: + if value is None: + cls._GLOBAL_PACK_SUMMARY_CACHE.pop(cache_key, None) + return + cls._GLOBAL_PACK_SUMMARY_CACHE[cache_key] = value @classmethod def _get_warm_lock(cls) -> asyncio.Lock: @@ -51,29 +55,30 @@ class RuleConfigServiceImpl(IRuleConfigService): def InvalidateSummaryCaches(self, version_ids: list[int] | None = None) -> None: """清理规则摘要缓存;version_ids 为空时清空全部。""" - self.__class__._set_pack_summary_cache(None) + self.__class__._GLOBAL_PACK_SUMMARY_CACHE.clear() if version_ids is None: self._yaml_summary_cache.clear() return for version_id in {int(item) for item in version_ids if item is not None}: self._yaml_summary_cache.pop(version_id, None) - async def WarmPackSummaries(self, force: bool = False) -> list[RuleConfigPackListVO]: + async def WarmPackSummaries(self, force: bool = False, CurrentUserId: int | None = None) -> list[RuleConfigPackListVO]: """预热规则列表摘要缓存。""" async with self.__class__._get_warm_lock(): if force: self.InvalidateSummaryCaches() - return await self.ListPackSummaries() + return await self.ListPackSummaries(CurrentUserId=CurrentUserId) - async def ListPacks(self) -> list[RuleConfigPackVO]: + async def ListPacks(self, CurrentUserId: int | None = None) -> list[RuleConfigPackVO]: """列出规则配置页所需的全部 pack。""" rows = await self._load_pack_rows() - rule_set_map = await self._load_rule_set_meta_map() - return [await self._build_pack_vo(row, rule_set_map) for row in rows] + rule_set_map = await self._load_rule_set_meta_map(CurrentUserId=CurrentUserId) + return [await self._build_pack_vo(row, rule_set_map, CurrentUserId=CurrentUserId) for row in rows] - async def ListPackSummaries(self) -> list[RuleConfigPackListVO]: + async def ListPackSummaries(self, CurrentUserId: int | None = None) -> list[RuleConfigPackListVO]: """列出规则列表页所需的轻量 pack。""" - cached = self.__class__._GLOBAL_PACK_SUMMARY_CACHE + cache_key = self._summary_cache_key(CurrentUserId) + cached = self.__class__._GLOBAL_PACK_SUMMARY_CACHE.get(cache_key) now = time.monotonic() if cached and now - cached[0] <= 60: return cached[1] @@ -82,10 +87,11 @@ class RuleConfigServiceImpl(IRuleConfigService): if not rows: return [] + current_user = await self._load_current_user(CurrentUserId) group_ids = [int(row["group_id"]) for row in rows] - binding_map = await self._load_effective_binding_map(group_ids) + binding_map = await self._load_effective_binding_map(group_ids, CurrentUserId=CurrentUserId) rule_set_ids = sorted({int(item["rule_set_id"]) for item in binding_map.values() if item.get("rule_set_id") is not None}) - rule_set_map = await self._load_rule_set_meta_map_by_ids(rule_set_ids) + rule_set_map = await self._load_rule_set_meta_map_by_ids(rule_set_ids, CurrentUserId=CurrentUserId) latest_version_map = await self._load_latest_version_map(rule_set_ids) base_items: list[dict[str, Any]] = [] @@ -93,53 +99,16 @@ class RuleConfigServiceImpl(IRuleConfigService): for row in rows: group_id = int(row["group_id"]) binding = binding_map.get(group_id) - document_type = str(row["document_type_name"] or "").strip() - main_type = str(row["main_type"] or "").strip() - subtype = str(row["subtype"] or "").strip() or "通用" - module_type = str(row["entry_module_name"] or "").strip() or (f"{document_type}评查" if document_type else "规则配置") - - binding_id: int | None = None - rule_set_id: int | None = None - rule_type: str | None = None - rule_name: str | None = None - current_version_id: int | None = None - fallback_version_id: int | None = None - resolved_version_id: int | None = None - has_usable_version = False - usable_rule_count = 0 - - if binding: - binding_id = int(binding["id"]) - rule_set_id = int(binding["rule_set_id"]) - rule_set_meta = rule_set_map.get(rule_set_id, {}) - rule_type = str(rule_set_meta.get("rule_type") or "") or None - rule_name = str(rule_set_meta.get("rule_name") or "") or None - current_version_id = self._to_int(rule_set_meta.get("current_version_id")) - fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id")) - has_usable_version = bool(rule_set_meta.get("has_usable_version")) - resolved_version_id = current_version_id or fallback_version_id or latest_version_map.get(rule_set_id) - if resolved_version_id is not None: - resolved_version_ids.add(resolved_version_id) - - base_items.append({ - "packId": group_id, - "groupId": group_id, - "rootGroupId": self._to_int(row.get("root_group_id")), - "bindingId": binding_id, - "ruleSetId": rule_set_id, - "ruleType": rule_type, - "ruleName": rule_name, - "currentVersionId": current_version_id, - "fallbackVersionId": fallback_version_id, - "resolvedVersionId": resolved_version_id, - "hasUsableVersion": has_usable_version, - "usableRuleCount": usable_rule_count, - "documentTypeId": self._to_int(row.get("document_type_id")), - "documentType": document_type, - "moduleType": module_type, - "mainType": main_type or document_type, - "subtype": subtype, - }) + item = self._build_pack_summary_item( + row=row, + binding=binding, + rule_set_map=rule_set_map, + latest_version_map=latest_version_map, + current_user=current_user, + ) + if item.get("resolvedVersionId") is not None: + resolved_version_ids.add(int(item["resolvedVersionId"])) + base_items.append(item) version_oss_map = await self._load_version_oss_map(sorted(resolved_version_ids)) yaml_summary_map = await self._load_yaml_summaries(version_oss_map) @@ -160,11 +129,11 @@ class RuleConfigServiceImpl(IRuleConfigService): packs.append(RuleConfigPackListVO(**item)) cache_value = (time.monotonic(), packs) - self.__class__._set_pack_summary_cache(cache_value) + self.__class__._set_pack_summary_cache(cache_key, cache_value) self._pack_summary_cache = cache_value return packs - async def GetPack(self, PackId: int) -> RuleConfigPackVO: + async def GetPack(self, PackId: int, CurrentUserId: int | None = None) -> RuleConfigPackVO: """获取单个规则配置 pack。""" async with GetAsyncSession() as session: row = ( @@ -200,13 +169,20 @@ class RuleConfigServiceImpl(IRuleConfigService): if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则配置 pack 不存在") - rule_set_map = await self._load_rule_set_meta_map() - return await self._build_pack_vo(row, rule_set_map) + rule_set_map = await self._load_rule_set_meta_map(CurrentUserId=CurrentUserId) + return await self._build_pack_vo(row, rule_set_map, CurrentUserId=CurrentUserId) - async def _build_pack_vo(self, row, rule_set_map: dict[int, dict[str, object]]) -> RuleConfigPackVO: + async def _build_pack_vo( + self, + row, + rule_set_map: dict[int, dict[str, object]], + *, + CurrentUserId: int | None = None, + ) -> RuleConfigPackVO: """构建单个 pack 聚合对象。""" group_id = int(row["group_id"]) - binding = await self._load_effective_binding(group_id) + binding = await self._load_effective_binding(group_id, CurrentUserId=CurrentUserId) + current_user = await self._load_current_user(CurrentUserId) document_type = str(row["document_type_name"] or "").strip() main_type = str(row["main_type"] or "").strip() @@ -224,23 +200,33 @@ class RuleConfigServiceImpl(IRuleConfigService): has_usable_version = False usable_rule_count = 0 binding_id: int | None = None + rules: list[RuleConfigPackRuleSummaryVO] = [] + source_fields = self._resolve_source_fields(binding=binding, current_user=current_user) if binding: binding_id = int(binding["id"]) - rule_set_id = int(binding["rule_set_id"]) - rule_set_meta = rule_set_map.get(rule_set_id, {}) - rule_type = str(rule_set_meta.get("rule_type") or "") or None - rule_name = str(rule_set_meta.get("rule_name") or "") or None - current_version_id = self._to_int(rule_set_meta.get("current_version_id")) - fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id")) - resolved_version_id = current_version_id or fallback_version_id - has_usable_version = bool(rule_set_meta.get("has_usable_version")) - usable_rule_count = int(rule_set_meta.get("usable_rule_count") or 0) - if resolved_version_id is None: - resolved_version_id = await self._load_latest_version_id(rule_set_id) - if resolved_version_id is not None: - yaml_text = await self._load_yaml_text_by_version_id(resolved_version_id) - source_status = "ready" if yaml_text.strip() else "missing" + bound_rule_set_id = int(binding["rule_set_id"]) + rule_set_meta = rule_set_map.get(bound_rule_set_id) + if rule_set_meta: + rule_set_id = bound_rule_set_id + rule_type = str(rule_set_meta.get("rule_type") or "") or None + rule_name = str(rule_set_meta.get("rule_name") or "") or None + current_version_id = self._to_int(rule_set_meta.get("current_version_id")) + fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id")) + resolved_version_id = current_version_id or fallback_version_id + has_usable_version = bool(rule_set_meta.get("has_usable_version")) + usable_rule_count = int(rule_set_meta.get("usable_rule_count") or 0) + if resolved_version_id is None: + resolved_version_id = await self._load_latest_version_id(rule_set_id) + if resolved_version_id is not None: + yaml_text = await self._load_yaml_text_by_version_id(resolved_version_id) + source_status = "ready" if yaml_text.strip() else "missing" + version_oss_map = await self._load_version_oss_map([resolved_version_id]) + summary = (await self._load_yaml_summaries(version_oss_map)).get(resolved_version_id) + if summary and summary.get("loaded"): + summary_rules = list(summary.get("rules") or []) + rules = [RuleConfigPackRuleSummaryVO(**rule) for rule in summary_rules] + usable_rule_count = len(rules) return RuleConfigPackVO( packId=group_id, @@ -248,6 +234,10 @@ class RuleConfigServiceImpl(IRuleConfigService): rootGroupId=self._to_int(row.get("root_group_id")), bindingId=binding_id, ruleSetId=rule_set_id, + effectiveTenantCode=source_fields["effectiveTenantCode"], + effectiveScopeType=source_fields["effectiveScopeType"], + isInherited=source_fields["isInherited"], + sourceRuleSetId=self._to_int(rule_set_map.get(rule_set_id, {}).get("source_rule_set_id")) if rule_set_id is not None else None, ruleType=rule_type, ruleName=rule_name, currentVersionId=current_version_id, @@ -262,6 +252,7 @@ class RuleConfigServiceImpl(IRuleConfigService): subtype=subtype, yamlText=yaml_text, sourceStatus=source_status, + rules=rules, ) async def _load_pack_rows(self): @@ -294,57 +285,61 @@ class RuleConfigServiceImpl(IRuleConfigService): ) ).mappings().all() - async def _load_effective_binding(self, group_id: int): + async def _load_effective_binding(self, group_id: int, CurrentUserId: int | None = None): """读取当前二级分组实际生效的规则集绑定。""" - async with GetAsyncSession() as session: - row = ( - await session.execute( - text( - """ - SELECT id, rule_set_id - FROM leaudit_rule_group_bindings - WHERE group_id = :group_id - AND deleted_at IS NULL - AND is_active = TRUE - ORDER BY priority DESC, id ASC - LIMIT 1 - """ - ), - {"group_id": group_id}, - ) - ).mappings().first() - return row + return (await self._load_effective_binding_map([group_id], CurrentUserId=CurrentUserId)).get(group_id) - async def _load_effective_binding_map(self, group_ids: list[int]) -> dict[int, dict[str, Any]]: + async def _load_effective_binding_map( + self, + group_ids: list[int], + CurrentUserId: int | None = None, + ) -> dict[int, dict[str, Any]]: if not group_ids: return {} async with GetAsyncSession() as session: + current_user = await self.RuleService._get_current_user_context(session, CurrentUserId) + binding_tenant_expr = ( + "COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL')" + if await self.RuleService._column_exists(session, "leaudit_rule_group_bindings", "tenant_code") + else "'PROVINCIAL'" + ) rows = ( await session.execute( text( - """ - SELECT id, group_id, rule_set_id - FROM ( - SELECT - id, - group_id, - rule_set_id, - ROW_NUMBER() OVER (PARTITION BY group_id ORDER BY priority DESC, id ASC) AS rn - FROM leaudit_rule_group_bindings - WHERE deleted_at IS NULL - AND is_active = TRUE - AND group_id = ANY(:group_ids) - ) t - WHERE rn = 1 + f""" + SELECT + id, + group_id, + rule_set_id, + priority, + {binding_tenant_expr} AS tenant_code + FROM leaudit_rule_group_bindings + WHERE deleted_at IS NULL + AND is_active = TRUE + AND group_id = ANY(:group_ids) + ORDER BY group_id ASC, priority DESC, id ASC """ ), {"group_ids": group_ids}, ) ).mappings().all() - return {int(row["group_id"]): dict(row) for row in rows} + grouped: dict[int, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[int(row["group_id"])].append(dict(row)) + binding_map: dict[int, dict[str, Any]] = {} + tenant_code = str(current_user.get("tenant_code") or "") or None if current_user else None + for group_id, group_rows in grouped.items(): + effective = pick_effective_scoped_row(group_rows, tenant_code) + if effective is not None: + binding_map[group_id] = dict(effective) + return binding_map - async def _load_rule_set_meta_map(self) -> dict[int, dict[str, object]]: - items = await self.RuleService.ListSets() + async def _load_current_user(self, CurrentUserId: int | None = None) -> dict[str, object] | None: + async with GetAsyncSession() as session: + return await self.RuleService._get_current_user_context(session, CurrentUserId) + + async def _load_rule_set_meta_map(self, CurrentUserId: int | None = None) -> dict[int, dict[str, object]]: + items = await self.RuleService.ListSets(CurrentUserId=CurrentUserId) return { item.id: { "rule_type": item.ruleType, @@ -353,60 +348,130 @@ class RuleConfigServiceImpl(IRuleConfigService): "fallback_version_id": item.fallbackVersionId, "has_usable_version": item.hasUsableVersion, "usable_rule_count": item.usableRuleCount, + "source_rule_set_id": getattr(item, "sourceRuleSetId", None), } for item in items } - async def _load_rule_set_meta_map_by_ids(self, rule_set_ids: list[int]) -> dict[int, dict[str, object]]: + async def _load_rule_set_meta_map_by_ids( + self, + rule_set_ids: list[int], + CurrentUserId: int | None = None, + ) -> dict[int, dict[str, object]]: if not rule_set_ids: return {} - async with GetAsyncSession() as session: - rows = ( - await session.execute( - text( - """ - SELECT - rs.id, - rs.rule_type, - rs.rule_name, - rs.current_version_id, - current_rv.id AS usable_current_version_id, - fallback_rv.id AS fallback_version_id, - CASE - WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE - ELSE FALSE - END AS has_usable_version - FROM leaudit_rule_sets rs - LEFT JOIN leaudit_rule_versions current_rv - ON current_rv.id = rs.current_version_id - AND current_rv.status = 'published' - LEFT JOIN LATERAL ( - SELECT rv.id - FROM leaudit_rule_versions rv - WHERE rv.rule_set_id = rs.id - AND rv.status = 'published' - AND (rs.current_version_id IS NULL OR rv.id <> rs.current_version_id) - ORDER BY rv.version_seq DESC, rv.id DESC - LIMIT 1 - ) fallback_rv ON TRUE - WHERE rs.deleted_at IS NULL - AND rs.id = ANY(:rule_set_ids) - """ - ), - {"rule_set_ids": rule_set_ids}, - ) - ).mappings().all() + items = await self.RuleService.ListSets(CurrentUserId=CurrentUserId) return { - int(row["id"]): { - "rule_type": row["rule_type"], - "rule_name": row["rule_name"], - "current_version_id": row["current_version_id"], - "fallback_version_id": row["fallback_version_id"], - "has_usable_version": row["has_usable_version"], + item.id: { + "rule_type": item.ruleType, + "rule_name": item.ruleName, + "current_version_id": item.currentVersionId, + "fallback_version_id": item.fallbackVersionId, + "has_usable_version": item.hasUsableVersion, + "usable_rule_count": item.usableRuleCount, + "source_rule_set_id": getattr(item, "sourceRuleSetId", None), } - for row in rows + for item in items + if item.id in set(rule_set_ids) } + def _build_pack_summary_item( + self, + *, + row, + binding: dict[str, Any] | None, + rule_set_map: dict[int, dict[str, object]], + latest_version_map: dict[int, int], + current_user: dict[str, object] | None, + ) -> dict[str, Any]: + document_type = str(row["document_type_name"] or "").strip() + main_type = str(row["main_type"] or "").strip() + subtype = str(row["subtype"] or "").strip() or "通用" + module_type = str(row["entry_module_name"] or "").strip() or (f"{document_type}评查" if document_type else "规则配置") + + binding_id: int | None = None + rule_set_id: int | None = None + rule_type: str | None = None + rule_name: str | None = None + current_version_id: int | None = None + fallback_version_id: int | None = None + resolved_version_id: int | None = None + has_usable_version = False + usable_rule_count = 0 + source_rule_set_id: int | None = None + source_fields = self._resolve_source_fields(binding=binding, current_user=current_user) + + if binding: + binding_id = int(binding["id"]) + bound_rule_set_id = int(binding["rule_set_id"]) + rule_set_meta = rule_set_map.get(bound_rule_set_id) + if rule_set_meta: + rule_set_id = bound_rule_set_id + rule_type = str(rule_set_meta.get("rule_type") or "") or None + rule_name = str(rule_set_meta.get("rule_name") or "") or None + current_version_id = self._to_int(rule_set_meta.get("current_version_id")) + fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id")) + has_usable_version = bool(rule_set_meta.get("has_usable_version")) + source_rule_set_id = self._to_int(rule_set_meta.get("source_rule_set_id")) + resolved_version_id = current_version_id or fallback_version_id or latest_version_map.get(rule_set_id) + + return { + "packId": int(row["group_id"]), + "groupId": int(row["group_id"]), + "rootGroupId": self._to_int(row.get("root_group_id")), + "bindingId": binding_id, + "ruleSetId": rule_set_id, + "effectiveTenantCode": source_fields["effectiveTenantCode"], + "effectiveScopeType": source_fields["effectiveScopeType"], + "isInherited": source_fields["isInherited"], + "sourceRuleSetId": source_rule_set_id, + "ruleType": rule_type, + "ruleName": rule_name, + "currentVersionId": current_version_id, + "fallbackVersionId": fallback_version_id, + "resolvedVersionId": resolved_version_id, + "hasUsableVersion": has_usable_version, + "usableRuleCount": usable_rule_count, + "documentTypeId": self._to_int(row.get("document_type_id")), + "documentType": document_type, + "moduleType": module_type, + "mainType": main_type or document_type, + "subtype": subtype, + } + + def _resolve_source_fields( + self, + *, + binding: dict[str, Any] | None, + current_user: dict[str, object] | None, + ) -> dict[str, Any]: + if not binding: + return { + "effectiveTenantCode": None, + "effectiveScopeType": None, + "isInherited": False, + } + + effective_tenant_code = normalize_scoped_tenant_code(str(binding.get("tenant_code") or "")) + effective_scope_type = self._scope_type_from_tenant_code(effective_tenant_code) + is_tenant_user = bool(current_user) and not bool(current_user.get("is_global")) and bool(str(current_user.get("tenant_code") or "").strip()) + is_inherited = is_tenant_user and effective_scope_type in {"PROVINCIAL", "PUBLIC"} + return { + "effectiveTenantCode": effective_tenant_code, + "effectiveScopeType": effective_scope_type, + "isInherited": is_inherited, + } + + def _scope_type_from_tenant_code(self, tenant_code: str | None) -> str | None: + normalized = normalize_scoped_tenant_code(tenant_code) if tenant_code is not None else None + if not normalized: + return None + if normalized == "PUBLIC": + return "PUBLIC" + if normalized == "PROVINCIAL": + return "PROVINCIAL" + return "TENANT" + async def _load_version_oss_map(self, version_ids: list[int]) -> dict[int, str]: if not version_ids: return {} @@ -644,6 +709,10 @@ class RuleConfigServiceImpl(IRuleConfigService): return None return int(value) + @staticmethod + def _summary_cache_key(CurrentUserId: int | None) -> str: + return f"user:{CurrentUserId or 0}" + _RULE_CONFIG_SERVICE_SINGLETON: RuleConfigServiceImpl | None = None diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py index 99108a1..3b83b86 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py @@ -32,6 +32,9 @@ async def ensure_rule_group_schema(session) -> None: group_id BIGINT NOT NULL REFERENCES leaudit_evaluation_point_groups(id), rule_set_id BIGINT NOT NULL REFERENCES leaudit_rule_sets(id), rule_type_binding_id BIGINT NULL REFERENCES leaudit_rule_type_bindings(id), + tenant_code VARCHAR(64) NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'PROVINCIAL', + tenant_name_snapshot VARCHAR(255) NULL, priority INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, note TEXT NULL, @@ -45,9 +48,12 @@ async def ensure_rule_group_schema(session) -> None: "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_entry_module ON leaudit_evaluation_point_groups(entry_module_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_id ON leaudit_rule_group_bindings(group_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_rule_set_id ON leaudit_rule_group_bindings(rule_set_id)", + "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_tenant ON leaudit_rule_group_bindings(group_id, tenant_code) WHERE deleted_at IS NULL", + "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_scope_type ON leaudit_rule_group_bindings(scope_type) WHERE deleted_at IS NULL", "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_ep_groups_code_active ON leaudit_evaluation_point_groups (LOWER(code)) WHERE deleted_at IS NULL", "DROP INDEX IF EXISTS uq_leaudit_ep_groups_doc_type_active", - "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_rule_group_bindings_active ON leaudit_rule_group_bindings (group_id, rule_set_id) WHERE deleted_at IS NULL", + "DROP INDEX IF EXISTS uq_leaudit_rule_group_bindings_active", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_rule_group_bindings_group_scope_rule_set_active ON leaudit_rule_group_bindings (group_id, COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL'), rule_set_id) WHERE deleted_at IS NULL", ] for statement in statements: await session.execute(text(statement)) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py index 57057ea..fb36252 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py @@ -22,6 +22,9 @@ from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleVal from fastapi_modules.fastapi_leaudit.services import IOssService, IRuleService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import sync_doc_type_bindings_from_group +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantScope import normalize_scoped_tenant_code, pick_effective_scoped_row +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver class RuleServiceImpl(IRuleService): @@ -37,8 +40,11 @@ class RuleServiceImpl(IRuleService): ) -> None: self.OssService = OssService or OssServiceImpl() self.Validator = Validator or RuleValidator() + self.TenantResolver = TenantResolver() self._list_sets_cache = self.__class__._GLOBAL_LIST_SETS_CACHE self._rule_count_cache = self.__class__._GLOBAL_RULE_COUNT_CACHE + self._entry_module_tenant_table_exists_cache: bool | None = None + self._column_exists_cache: dict[str, bool] = {} @classmethod def _set_list_sets_cache(cls, value: tuple[float, list[RuleSetVO]] | None) -> None: @@ -89,49 +95,53 @@ class RuleServiceImpl(IRuleService): ).mappings().first() return int(row["total"] or 0) if row else 0 - async def ListSets(self) -> list[RuleSetVO]: + async def ListSets(self, CurrentUserId: int | None = None) -> list[RuleSetVO]: """列出所有规则集。""" now = time.monotonic() cached = self.__class__._GLOBAL_LIST_SETS_CACHE - if cached and now - cached[0] <= 30: + if CurrentUserId is None and cached and now - cached[0] <= 30: return cached[1] async with GetAsyncSession() as Session: - Result = await Session.execute( - text( - """ - SELECT - rs.id, - rs.rule_type, - rs.rule_name, - rs.domain_type, - rs.current_version_id, - current_rv.id AS usable_current_version_id, - fallback_rv.id AS fallback_version_id, - CASE - WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE - ELSE FALSE - END AS has_usable_version, - rs.status - FROM leaudit_rule_sets rs - LEFT JOIN leaudit_rule_versions current_rv - ON current_rv.id = rs.current_version_id - AND current_rv.status = 'published' - LEFT JOIN LATERAL ( - SELECT rv.id - FROM leaudit_rule_versions rv - WHERE rv.rule_set_id = rs.id - AND rv.status = 'published' - AND (rs.current_version_id IS NULL OR rv.id <> rs.current_version_id) - ORDER BY rv.version_seq DESC, rv.id DESC - LIMIT 1 - ) fallback_rv ON TRUE - WHERE rs.deleted_at IS NULL - ORDER BY rs.id DESC - """ + current_user = await self._get_current_user_context(Session, CurrentUserId) + if await self._column_exists(Session, "leaudit_rule_sets", "tenant_code"): + rows = await self._load_scoped_rule_set_rows(Session, current_user=current_user) + else: + Result = await Session.execute( + text( + """ + SELECT + rs.id, + rs.rule_type, + rs.rule_name, + rs.domain_type, + rs.current_version_id, + current_rv.id AS usable_current_version_id, + fallback_rv.id AS fallback_version_id, + CASE + WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE + ELSE FALSE + END AS has_usable_version, + rs.status + FROM leaudit_rule_sets rs + LEFT JOIN leaudit_rule_versions current_rv + ON current_rv.id = rs.current_version_id + AND current_rv.status = 'published' + LEFT JOIN LATERAL ( + SELECT rv.id + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = rs.id + AND rv.status = 'published' + AND (rs.current_version_id IS NULL OR rv.id <> rs.current_version_id) + ORDER BY rv.version_seq DESC, rv.id DESC + LIMIT 1 + ) fallback_rv ON TRUE + WHERE rs.deleted_at IS NULL + ORDER BY rs.id DESC + """ + ) ) - ) - rows = Result.mappings().all() + rows = Result.mappings().all() usable_counts: dict[int, int] = {} for row in rows: @@ -144,6 +154,18 @@ class RuleServiceImpl(IRuleService): id=int(Row["id"]), ruleType=Row["rule_type"], ruleName=Row["rule_name"], + effectiveTenantCode=str(Row.get("tenant_code") or "").strip() or None, + effectiveScopeType="PUBLIC" + if str(Row.get("tenant_code") or "").strip().upper() == "PUBLIC" + else "PROVINCIAL" + if str(Row.get("tenant_code") or "").strip().upper() in {"", "PROVINCIAL"} + else "TENANT", + isInherited=bool(current_user) + and not bool(current_user.get("is_global")) + and bool(str(current_user.get("tenant_code") or "").strip()) + and str(Row.get("tenant_code") or "").strip().upper() + not in {"", str(current_user.get("tenant_code") or "").strip().upper()}, + sourceRuleSetId=int(Row["source_rule_set_id"]) if Row.get("source_rule_set_id") is not None else None, domainType=Row["domain_type"], currentVersionId=Row["current_version_id"], fallbackVersionId=Row["fallback_version_id"], @@ -156,8 +178,9 @@ class RuleServiceImpl(IRuleService): for Row in rows ] cache_value = (time.monotonic(), items) - self.__class__._set_list_sets_cache(cache_value) - self._list_sets_cache = cache_value + if CurrentUserId is None: + self.__class__._set_list_sets_cache(cache_value) + self._list_sets_cache = cache_value return items async def _GetRuleCountByVersionId(self, VersionId: int) -> int: @@ -193,34 +216,59 @@ class RuleServiceImpl(IRuleService): except Exception: return 0 - async def GetVersions(self, RuleType: str) -> list[RuleVersionVO]: + async def GetVersions(self, RuleType: str, CurrentUserId: int | None = None) -> list[RuleVersionVO]: """获取规则集的所有版本。""" async with GetAsyncSession() as Session: - Result = await Session.execute( - text( - """ - SELECT - rv.id, - rv.rule_set_id, - rv.version_no, - rv.status, - rv.oss_url, - rv.change_note, - rv.published_at - FROM leaudit_rule_versions rv - JOIN leaudit_rule_sets rs ON rs.id = rv.rule_set_id - WHERE rs.rule_type = :rule_type - AND rs.deleted_at IS NULL - ORDER BY rv.version_seq DESC, rv.id DESC - """ - ), - {"rule_type": RuleType}, - ) + current_user = await self._get_current_user_context(Session, CurrentUserId) + if await self._column_exists(Session, "leaudit_rule_sets", "tenant_code"): + rule_set_row = await self._get_effective_rule_set_row_by_type(Session, RuleType, current_user=current_user) + if not rule_set_row: + return [] + Result = await Session.execute( + text( + """ + SELECT + rv.id, + rv.rule_set_id, + rv.version_no, + rv.status, + rv.oss_url, + rv.change_note, + rv.published_at + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = :rule_set_id + ORDER BY rv.version_seq DESC, rv.id DESC + """ + ), + {"rule_set_id": int(rule_set_row["id"])}, + ) + else: + Result = await Session.execute( + text( + """ + SELECT + rv.id, + rv.rule_set_id, + rv.version_no, + rv.status, + rv.oss_url, + rv.change_note, + rv.published_at + FROM leaudit_rule_versions rv + JOIN leaudit_rule_sets rs ON rs.id = rv.rule_set_id + WHERE rs.rule_type = :rule_type + AND rs.deleted_at IS NULL + ORDER BY rv.version_seq DESC, rv.id DESC + """ + ), + {"rule_type": RuleType}, + ) return [self._BuildRuleVersionVo(Row) for Row in Result.mappings().all()] - async def GetContent(self, VersionId: int) -> RuleContentVO: + async def GetContent(self, VersionId: int, CurrentUserId: int | None = None) -> RuleContentVO: """获取指定版本的规则正文。""" async with GetAsyncSession() as Session: + current_user = await self._get_current_user_context(Session, CurrentUserId) Result = await Session.execute( text( """ @@ -241,6 +289,14 @@ class RuleServiceImpl(IRuleService): Row = Result.mappings().first() if not Row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则版本不存在") + if await self._column_exists(Session, "leaudit_rule_sets", "tenant_code"): + effective_rule_set = await self._get_effective_rule_set_row_by_type( + Session, + str(Row["rule_type"] or ""), + current_user=current_user, + ) + if effective_rule_set is None or int(effective_rule_set["id"]) != int(Row["rule_set_id"]): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不可访问该规则版本") YamlText = (await self.OssService.DownloadBytes(Row["oss_url"])).decode("utf-8") return RuleContentVO( @@ -277,12 +333,59 @@ class RuleServiceImpl(IRuleService): errors=Validation.errors or [], ) + def _pick_writable_rule_set_row( + self, + rows: list[dict[str, object]], + *, + current_user: dict[str, object] | None, + ) -> dict[str, object] | None: + if not rows: + return None + if current_user is None: + return dict(rows[0]) + current_user_tenant = str(current_user.get("tenant_code") or "").strip().upper() + if current_user.get("is_global") and not current_user_tenant: + effective = pick_effective_scoped_row(rows, "PROVINCIAL") + return dict(effective) if effective is not None else dict(rows[0]) + effective = pick_effective_scoped_row(rows, current_user_tenant or None) + if effective is None: + return None + normalized = str(effective.get("tenant_code") or "").strip().upper() or "PROVINCIAL" + if normalized == "PUBLIC": + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不能直接修改公共规则资产") + return dict(effective) + + def _assert_version_belongs_to_writable_rule_set( + self, + version_row: dict[str, object] | None, + writable_rule_set: dict[str, object] | None, + ) -> None: + if version_row is None or writable_rule_set is None: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则版本不存在或当前租户无可写规则集") + if int(version_row["rule_set_id"]) != int(writable_rule_set["id"]): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不能发布或回滚其他租户的规则版本") + + def _assert_rollback_target( + self, + version_row: dict[str, object] | None, + rule_set_row: dict[str, object] | None, + ) -> None: + if version_row is None or rule_set_row is None: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则版本不存在或当前租户无可写规则集") + current_version_id = rule_set_row.get("current_version_id") + if current_version_id is not None and int(version_row["id"]) == int(current_version_id): + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前版本已经生效,无需回滚") + status = str(version_row.get("status") or "").strip().lower() + if status in {"draft", "validated"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "草稿版本不能作为回滚目标,请先发布或选择历史版本") + async def CreateVersion( self, RuleType: str, YamlText: str, ChangeNote: str | None = None, EditorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """创建规则版本。""" Validation = await self.Validate(RuleType, YamlText) @@ -295,40 +398,124 @@ class RuleServiceImpl(IRuleService): DomainType = self._ResolveDomainType(RuleType) async with GetAsyncSession() as Session: - RuleSet = await self._GetRuleSetByType(Session, RuleType) + current_user = await self._get_current_user_context(Session, CurrentUserId) + use_tenant_scope = await self._column_exists(Session, "leaudit_rule_sets", "tenant_code") + self._assert_rule_tenant_schema_ready_for_write(use_tenant_scope=use_tenant_scope, current_user=current_user) + writable_tenant_code: str | None = None + writable_scope_type = "PROVINCIAL" + source_rule_set_id: int | None = None + source_rule_set_binding_ids: list[int] = [] + + if use_tenant_scope: + candidate_rows = await self._load_rule_set_rows_by_type(Session, RuleType) + writable_rule_set = self._pick_writable_rule_set_row(candidate_rows, current_user=current_user) + RuleSet = writable_rule_set + if current_user and not current_user.get("is_global"): + writable_tenant_code = str(current_user.get("tenant_code") or "").strip().upper() or None + writable_scope_type = "TENANT" + elif RuleSet is not None: + writable_tenant_code = str(RuleSet.get("tenant_code") or "").strip().upper() or "PROVINCIAL" + writable_scope_type = "PROVINCIAL" if writable_tenant_code != "PUBLIC" else "PUBLIC" + if RuleSet is not None: + normalized_rule_set_tenant = str(RuleSet.get("tenant_code") or "").strip().upper() or "PROVINCIAL" + if ( + writable_scope_type == "TENANT" + and writable_tenant_code + and normalized_rule_set_tenant != writable_tenant_code + ): + source_rule_set_id = int(RuleSet["id"]) + source_rule_set_binding_ids = await self._load_source_group_binding_ids(Session, source_rule_set_id) + RuleSet = None + else: + RuleSet = await self._GetRuleSetByType(Session, RuleType) if not RuleSet: - CreateResult = await Session.execute( - text( - """ - INSERT INTO leaudit_rule_sets ( - rule_type, - rule_name, - domain_type, - description, - status, - is_builtin, - owner_user_id - ) VALUES ( - :rule_type, - :rule_name, - :domain_type, - :description, - 'draft', - false, - :owner_user_id - ) - RETURNING id, rule_type, rule_name, domain_type, current_version_id, status + if use_tenant_scope: + CreateResult = await Session.execute( + text( + """ + INSERT INTO leaudit_rule_sets ( + rule_type, + rule_name, + domain_type, + description, + region, + status, + is_builtin, + owner_user_id, + tenant_code, + scope_type, + source_rule_set_id, + tenant_name_snapshot + ) VALUES ( + :rule_type, + :rule_name, + :domain_type, + :description, + :region, + 'draft', + false, + :owner_user_id, + :tenant_code, + :scope_type, + :source_rule_set_id, + :tenant_name_snapshot + ) + RETURNING id, rule_type, rule_name, domain_type, current_version_id, status, tenant_code, source_rule_set_id """ ), - { - "rule_type": RuleType, - "rule_name": RulesFile.metadata.name, - "domain_type": DomainType, - "description": RulesFile.metadata.description, - "owner_user_id": EditorUserId, - }, - ) + { + "rule_type": RuleType, + "rule_name": RulesFile.metadata.name, + "domain_type": DomainType, + "description": RulesFile.metadata.description, + "region": self._legacy_region_for_scope(writable_tenant_code, writable_scope_type), + "owner_user_id": EditorUserId, + "tenant_code": writable_tenant_code or "PROVINCIAL", + "scope_type": writable_scope_type, + "source_rule_set_id": source_rule_set_id, + "tenant_name_snapshot": str(current_user.get("tenant_name") or "") if current_user else None, + }, + ) + else: + CreateResult = await Session.execute( + text( + """ + INSERT INTO leaudit_rule_sets ( + rule_type, + rule_name, + domain_type, + description, + status, + is_builtin, + owner_user_id + ) VALUES ( + :rule_type, + :rule_name, + :domain_type, + :description, + 'draft', + false, + :owner_user_id + ) + RETURNING id, rule_type, rule_name, domain_type, current_version_id, status + """ + ), + { + "rule_type": RuleType, + "rule_name": RulesFile.metadata.name, + "domain_type": DomainType, + "description": RulesFile.metadata.description, + "owner_user_id": EditorUserId, + }, + ) RuleSet = CreateResult.mappings().first() + if use_tenant_scope and writable_scope_type == "TENANT" and source_rule_set_binding_ids: + await self._clone_source_group_bindings_for_tenant( + Session, + source_binding_ids=source_rule_set_binding_ids, + tenant_rule_set_id=int(RuleSet["id"]), + current_user=current_user, + ) else: await Session.execute( text( @@ -362,6 +549,9 @@ class RuleServiceImpl(IRuleService): ) NextSeq = int(SeqResult.mappings().first()["max_seq"]) + 1 VersionNo = RulesFile.metadata.version or f"v{NextSeq}" + source_version_id: int | None = None + if source_rule_set_id is not None: + source_version_id = await self._load_latest_version_id_by_rule_set(Session, source_rule_set_id) ExistingVersion = await Session.execute( text( """ @@ -438,14 +628,41 @@ class RuleServiceImpl(IRuleService): "editor_user_id": EditorUserId, }, ) + version_row = VersionResult.mappings().first() + if use_tenant_scope and await self._column_exists(Session, "leaudit_rule_versions", "tenant_code_snapshot"): + snapshot = self._build_version_scope_snapshot( + writable_tenant_code=writable_tenant_code, + writable_scope_type=writable_scope_type, + rule_set_row=dict(RuleSet), + source_version_id=source_version_id, + ) + await Session.execute( + text( + """ + UPDATE leaudit_rule_versions + SET + tenant_code_snapshot = :tenant_code_snapshot, + scope_type_snapshot = :scope_type_snapshot, + source_version_id = :source_version_id + WHERE id = :version_id + """ + ), + { + "tenant_code_snapshot": snapshot["tenant_code_snapshot"], + "scope_type_snapshot": snapshot["scope_type_snapshot"], + "source_version_id": snapshot["source_version_id"], + "version_id": int(version_row["id"]), + }, + ) await Session.commit() - return self._BuildRuleVersionVo(VersionResult.mappings().first()) + return self._BuildRuleVersionVo(version_row) async def Publish( self, RuleType: str, VersionId: int, OperatorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """发布指定版本。""" return await self._SwitchVersion( @@ -453,6 +670,7 @@ class RuleServiceImpl(IRuleService): VersionId=VersionId, TargetStatus="published", OperatorUserId=OperatorUserId, + CurrentUserId=CurrentUserId, ) async def Rollback( @@ -460,6 +678,7 @@ class RuleServiceImpl(IRuleService): RuleType: str, VersionId: int, OperatorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """回滚到指定历史版本。""" return await self._SwitchVersion( @@ -467,14 +686,30 @@ class RuleServiceImpl(IRuleService): VersionId=VersionId, TargetStatus="published", OperatorUserId=OperatorUserId, + CurrentUserId=CurrentUserId, Rollback=True, ) - async def ListBindings(self, RuleType: str | None = None, Region: str | None = None) -> list[RuleBindingVO]: + async def ListBindings( + self, + RuleType: str | None = None, + Region: str | None = None, + CurrentUserId: int | None = None, + ) -> list[RuleBindingVO]: """列出规则类型绑定,只读取新分组绑定。""" async with GetAsyncSession() as Session: + current_user = await self._get_current_user_context(Session, CurrentUserId) params: dict[str, object] = {} filters = ["rs.deleted_at IS NULL", "dt.deleted_at IS NULL", "child.deleted_at IS NULL", "rgb.deleted_at IS NULL"] + filters.append( + await self._entry_module_tenant_scope_sql( + session=Session, + entry_module_expr="COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=current_user, + params=params, + param_prefix="rule_binding_scope", + ) + ) if RuleType: filters.append("rs.rule_type = :rule_type") params["rule_type"] = RuleType @@ -496,6 +731,7 @@ class RuleServiceImpl(IRuleService): rs.rule_name FROM leaudit_rule_group_bindings rgb JOIN leaudit_evaluation_point_groups child ON child.id = rgb.group_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = child.pid AND parent.deleted_at IS NULL JOIN leaudit_document_types dt ON dt.id = child.document_type_id JOIN leaudit_rule_sets rs ON rs.id = rgb.rule_set_id WHERE {where_clause} @@ -538,9 +774,11 @@ class RuleServiceImpl(IRuleService): Priority: int = 0, DocTypeCode: str | None = None, Note: str | None = None, + CurrentUserId: int | None = None, ) -> RuleBindingVO: """创建规则类型绑定;若文档类型唯一对应一个二级分组,则优先写入新分组绑定。""" async with GetAsyncSession() as Session: + current_user = await self._get_current_user_context(Session, CurrentUserId) RuleSet = await Session.execute( text("SELECT id, rule_type, rule_name FROM leaudit_rule_sets WHERE id = :rid AND deleted_at IS NULL LIMIT 1"), {"rid": RuleSetId}, @@ -549,7 +787,8 @@ class RuleServiceImpl(IRuleService): if not RsRow: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则集不存在") - GroupId = await self._resolve_unique_child_group_id(Session, DocTypeId) + await self._assert_document_type_access(Session, DocTypeId, current_user) + GroupId = await self._resolve_unique_accessible_child_group_id(Session, DocTypeId, current_user) if GroupId is not None: ExistingGroupBinding = await Session.execute( text( @@ -613,7 +852,7 @@ class RuleServiceImpl(IRuleService): note=Row["note"], ) - child_group_count = await self._count_child_groups(Session, DocTypeId) + child_group_count = await self._count_accessible_child_groups(Session, DocTypeId, current_user) if child_group_count == 0: raise LeauditException( StatusCodeEnum.HTTP_409_CONFLICT, @@ -631,35 +870,12 @@ class RuleServiceImpl(IRuleService): Priority: int | None = None, BindingMode: str | None = None, Note: str | None = None, + CurrentUserId: int | None = None, ) -> RuleBindingVO: """更新规则类型绑定;若绑定ID来自新分组绑定,则优先更新新表。""" async with GetAsyncSession() as Session: - GroupBinding = await Session.execute( - text( - """ - SELECT - rgb.id, - rgb.group_id, - child.document_type_id AS doc_type_id, - dt.code AS doc_type_code, - rgb.rule_set_id, - rgb.priority, - rgb.is_active, - rgb.note, - rs.rule_type, - rs.rule_name - FROM leaudit_rule_group_bindings rgb - JOIN leaudit_evaluation_point_groups child ON child.id = rgb.group_id - LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id - JOIN leaudit_rule_sets rs ON rs.id = rgb.rule_set_id - WHERE rgb.id = :bid - AND rgb.deleted_at IS NULL - LIMIT 1 - """ - ), - {"bid": BindingId}, - ) - GroupRow = GroupBinding.mappings().first() + current_user = await self._get_current_user_context(Session, CurrentUserId) + GroupRow = await self._get_binding_row(Session, BindingId, current_user) if GroupRow: await Session.execute( text( @@ -681,32 +897,7 @@ class RuleServiceImpl(IRuleService): ) await sync_doc_type_bindings_from_group(Session, int(GroupRow["group_id"])) await Session.commit() - refreshed = ( - await Session.execute( - text( - """ - SELECT - rgb.id, - rgb.group_id, - child.document_type_id AS doc_type_id, - dt.code AS doc_type_code, - rgb.rule_set_id, - rgb.priority, - rgb.is_active, - rgb.note, - rs.rule_type, - rs.rule_name - FROM leaudit_rule_group_bindings rgb - JOIN leaudit_evaluation_point_groups child ON child.id = rgb.group_id - LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id - JOIN leaudit_rule_sets rs ON rs.id = rgb.rule_set_id - WHERE rgb.id = :bid - LIMIT 1 - """ - ), - {"bid": BindingId}, - ) - ).mappings().first() + refreshed = await self._get_binding_row(Session, BindingId, current_user) return RuleBindingVO( id=int(refreshed["id"]), docTypeId=int(refreshed["doc_type_id"]), @@ -725,22 +916,11 @@ class RuleServiceImpl(IRuleService): "绑定记录不存在或已迁移到新分组绑定,请刷新列表后重试", ) - async def DeleteBinding(self, BindingId: int) -> None: + async def DeleteBinding(self, BindingId: int, CurrentUserId: int | None = None) -> None: """删除规则类型绑定;若绑定ID来自新分组绑定,则优先删除新表。""" async with GetAsyncSession() as Session: - GroupBinding = await Session.execute( - text( - """ - SELECT id, group_id - FROM leaudit_rule_group_bindings - WHERE id = :bid - AND deleted_at IS NULL - LIMIT 1 - """ - ), - {"bid": BindingId}, - ) - GroupRow = GroupBinding.mappings().first() + current_user = await self._get_current_user_context(Session, CurrentUserId) + GroupRow = await self._get_binding_row(Session, BindingId, current_user) if GroupRow: await Session.execute( text("UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE id = :bid"), @@ -762,17 +942,32 @@ class RuleServiceImpl(IRuleService): VersionId: int, TargetStatus: str, OperatorUserId: int | None = None, + CurrentUserId: int | None = None, Rollback: bool = False, ) -> RuleVersionVO: """切换当前生效版本。""" async with GetAsyncSession() as Session: - RuleSet = await self._GetRuleSetByType(Session, RuleType) + current_user = await self._get_current_user_context(Session, CurrentUserId) + use_tenant_scope = await self._column_exists(Session, "leaudit_rule_sets", "tenant_code") + self._assert_rule_tenant_schema_ready_for_write(use_tenant_scope=use_tenant_scope, current_user=current_user) + if use_tenant_scope: + candidate_rows = await self._load_rule_set_rows_by_type(Session, RuleType) + RuleSet = self._pick_writable_rule_set_row(candidate_rows, current_user=current_user) + else: + RuleSet = await self._GetRuleSetByType(Session, RuleType) if not RuleSet: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则集不存在") VersionRow = await self._GetVersion(Session, VersionId) - if not VersionRow or int(VersionRow["rule_set_id"]) != int(RuleSet["id"]): - raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则版本不存在或不属于当前规则集") + self._assert_version_belongs_to_writable_rule_set( + dict(VersionRow) if VersionRow else None, + dict(RuleSet) if RuleSet else None, + ) + if Rollback: + self._assert_rollback_target( + dict(VersionRow) if VersionRow else None, + dict(RuleSet) if RuleSet else None, + ) await Session.execute( text( @@ -851,6 +1046,32 @@ class RuleServiceImpl(IRuleService): ) return Result.mappings().first() + async def _load_rule_set_rows_by_type(self, Session, RuleType: str) -> list[dict[str, object]]: + if not await self._column_exists(Session, "leaudit_rule_sets", "tenant_code"): + row = await self._GetRuleSetByType(Session, RuleType) + return [dict(row)] if row else [] + Result = await Session.execute( + text( + """ + SELECT + id, + rule_type, + rule_name, + domain_type, + current_version_id, + status, + COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') AS tenant_code, + source_rule_set_id + FROM leaudit_rule_sets + WHERE rule_type = :rule_type + AND deleted_at IS NULL + ORDER BY id DESC + """ + ), + {"rule_type": RuleType}, + ) + return [dict(row) for row in Result.mappings().all()] + async def _GetVersion(self, Session, VersionId: int): """按版本 ID 查询规则版本。""" Result = await Session.execute( @@ -873,6 +1094,554 @@ class RuleServiceImpl(IRuleService): ) return Result.mappings().first() + async def _load_latest_version_id_by_rule_set(self, Session, RuleSetId: int) -> int | None: + row = ( + await Session.execute( + text( + """ + SELECT id + FROM leaudit_rule_versions + WHERE rule_set_id = :rule_set_id + ORDER BY version_seq DESC, id DESC + LIMIT 1 + """ + ), + {"rule_set_id": RuleSetId}, + ) + ).mappings().first() + return int(row["id"]) if row and row.get("id") is not None else None + + def _build_version_scope_snapshot( + self, + *, + writable_tenant_code: str | None, + writable_scope_type: str, + rule_set_row: dict[str, object], + source_version_id: int | None, + ) -> dict[str, object | None]: + return { + "tenant_code_snapshot": writable_tenant_code or str(rule_set_row.get("tenant_code") or "") or "PROVINCIAL", + "scope_type_snapshot": writable_scope_type, + "source_version_id": source_version_id, + } + + def _legacy_region_for_scope(self, tenant_code: str | None, scope_type: str | None) -> str: + """兼容旧唯一约束 rule_type + region;租户私有规则集不能再共用 default。""" + normalized_tenant = normalize_scoped_tenant_code(str(tenant_code or ""), default="") + normalized_scope = str(scope_type or "").strip().upper() + if normalized_scope == "TENANT" and normalized_tenant: + return normalized_tenant + if normalized_tenant == "PUBLIC" or normalized_scope == "PUBLIC": + return "PUBLIC" + return "default" + + def _assert_rule_tenant_schema_ready_for_write( + self, + *, + use_tenant_scope: bool, + current_user: dict[str, object] | None, + ) -> None: + """租户用户不能在缺少规则域租户列时写共享规则集。""" + if use_tenant_scope: + return + if current_user is None or current_user.get("is_global"): + return + if str(current_user.get("tenant_code") or "").strip(): + raise LeauditException( + StatusCodeEnum.HTTP_409_CONFLICT, + "规则域租户隔离表结构未就绪,已拒绝写入共享规则集;请先执行 schema_rule_domain_tenant_phase1.sql", + ) + + def _build_tenant_binding_clone_payload( + self, + *, + current_user: dict[str, object], + source_binding: dict[str, object], + tenant_rule_set_id: int, + ) -> dict[str, object | None]: + tenant_code = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="") + if not tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户上下文缺失,不能派生规则绑定") + return { + "group_id": int(source_binding["group_id"]), + "rule_set_id": int(tenant_rule_set_id), + "tenant_code": tenant_code, + "scope_type": "TENANT", + "tenant_name_snapshot": str(current_user.get("tenant_name") or "").strip() or None, + "priority": int(source_binding.get("priority") or 0), + "note": "由租户规则集派生自动补绑", + } + + async def _load_source_group_binding_ids(self, Session, source_rule_set_id: int) -> list[int]: + if not await self._column_exists(Session, "leaudit_rule_group_bindings", "tenant_code"): + return [] + rows = ( + await Session.execute( + text( + """ + SELECT id + FROM leaudit_rule_group_bindings + WHERE rule_set_id = :rule_set_id + AND deleted_at IS NULL + AND is_active = TRUE + ORDER BY priority DESC, id ASC + """ + ), + {"rule_set_id": source_rule_set_id}, + ) + ).mappings().all() + return [int(row["id"]) for row in rows] + + async def _clone_source_group_bindings_for_tenant( + self, + Session, + *, + source_binding_ids: list[int], + tenant_rule_set_id: int, + current_user: dict[str, object] | None, + ) -> None: + if not source_binding_ids or not current_user or current_user.get("is_global"): + return + rows = ( + await Session.execute( + text( + """ + SELECT id, group_id, priority, note + FROM leaudit_rule_group_bindings + WHERE id = ANY(:binding_ids) + AND deleted_at IS NULL + AND is_active = TRUE + ORDER BY priority DESC, id ASC + """ + ), + {"binding_ids": source_binding_ids}, + ) + ).mappings().all() + for row in rows: + payload = self._build_tenant_binding_clone_payload( + current_user=current_user, + source_binding=dict(row), + tenant_rule_set_id=tenant_rule_set_id, + ) + await Session.execute( + text( + """ + INSERT INTO leaudit_rule_group_bindings ( + group_id, + rule_set_id, + tenant_code, + scope_type, + tenant_name_snapshot, + priority, + is_active, + note, + created_at, + updated_at + ) + SELECT + :group_id, + :rule_set_id, + CAST(:tenant_code AS varchar), + :scope_type, + :tenant_name_snapshot, + :priority, + TRUE, + :note, + NOW(), + NOW() + WHERE NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_group_bindings + WHERE group_id = :group_id + AND tenant_code = CAST(:tenant_code AS varchar) + AND deleted_at IS NULL + ) + """ + ), + payload, + ) + + async def _column_exists(self, Session, table_name: str, column_name: str) -> bool: + cache_key = f"{table_name}.{column_name}" + cached = self._column_exists_cache.get(cache_key) + if cached is not None: + return cached + exists = bool( + ( + await Session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + AND column_name = :column_name + ) + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + ).scalar_one() + ) + if exists: + self._column_exists_cache[cache_key] = exists + return exists + + async def _load_scoped_rule_set_rows( + self, + Session, + *, + current_user: dict[str, object] | None, + ) -> list[dict[str, object]]: + Result = await Session.execute( + text( + """ + SELECT + rs.id, + rs.rule_type, + rs.rule_name, + rs.domain_type, + rs.current_version_id, + current_rv.id AS usable_current_version_id, + fallback_rv.id AS fallback_version_id, + CASE + WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE + ELSE FALSE + END AS has_usable_version, + rs.status, + COALESCE(NULLIF(BTRIM(rs.tenant_code), ''), 'PROVINCIAL') AS tenant_code, + rs.source_rule_set_id + FROM leaudit_rule_sets rs + LEFT JOIN leaudit_rule_versions current_rv + ON current_rv.id = rs.current_version_id + AND current_rv.status = 'published' + LEFT JOIN LATERAL ( + SELECT rv.id + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = rs.id + AND rv.status = 'published' + AND (rs.current_version_id IS NULL OR rv.id <> rs.current_version_id) + ORDER BY rv.version_seq DESC, rv.id DESC + LIMIT 1 + ) fallback_rv ON TRUE + WHERE rs.deleted_at IS NULL + ORDER BY rs.rule_type ASC, rs.id DESC + """ + ) + ) + rows = [dict(row) for row in Result.mappings().all()] + current_user_tenant = str((current_user or {}).get("tenant_code") or "").strip().upper() + if current_user is None or (current_user.get("is_global") and not current_user_tenant): + return rows + + grouped: dict[str, list[dict[str, object]]] = {} + for row in rows: + grouped.setdefault(str(row.get("rule_type") or ""), []).append(row) + + effective_rows: list[dict[str, object]] = [] + for group_rows in grouped.values(): + effective = pick_effective_scoped_row(group_rows, current_user_tenant or None) + if effective is not None: + effective_rows.append(dict(effective)) + effective_rows.sort(key=lambda item: int(item["id"]), reverse=True) + return effective_rows + + async def _get_effective_rule_set_row_by_type( + self, + Session, + RuleType: str, + *, + current_user: dict[str, object] | None, + ) -> dict[str, object] | None: + if not await self._column_exists(Session, "leaudit_rule_sets", "tenant_code"): + return await self._GetRuleSetByType(Session, RuleType) + + Result = await Session.execute( + text( + """ + SELECT + rs.id, + rs.rule_type, + rs.rule_name, + rs.domain_type, + rs.current_version_id, + rs.status, + COALESCE(NULLIF(BTRIM(rs.tenant_code), ''), 'PROVINCIAL') AS tenant_code, + rs.source_rule_set_id + FROM leaudit_rule_sets rs + WHERE rs.rule_type = :rule_type + AND rs.deleted_at IS NULL + ORDER BY rs.id DESC + """ + ), + {"rule_type": RuleType}, + ) + rows = [dict(row) for row in Result.mappings().all()] + if not rows: + return None + current_user_tenant = str((current_user or {}).get("tenant_code") or "").strip().upper() + if current_user is None or (current_user.get("is_global") and not current_user_tenant): + return rows[0] + effective = pick_effective_scoped_row(rows, current_user_tenant or None) + return dict(effective) if effective is not None else None + + async def _get_current_user_context(self, Session, CurrentUserId: int | None) -> dict[str, object] | None: + if CurrentUserId is None: + return None + sso_user_columns = await SsoUserCompat.get_columns(Session) + tenant_code_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_code", + ) + tenant_name_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_name", + ) + row = ( + await Session.execute( + text( + f""" + SELECT + u.id, + COALESCE(u.area, '') AS area, + COALESCE(MAX({tenant_code_expr}), '') AS tenant_code, + COALESCE(MAX({tenant_name_expr}), '') AS tenant_name, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + AND u.deleted_at IS NULL + AND u.status = 0 + GROUP BY u.id, u.area + """ + ), + {"user_id": CurrentUserId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用") + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row.get("tenant_code") or "") or None, + TenantName=str(row.get("tenant_name") or "") or None, + Source="rule_binding_user_context", + ) + return { + "id": int(row["id"]), + "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), + "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), + "is_global": bool(row["is_global"]), + } + + async def _entry_module_tenant_table_exists(self, session) -> bool: + if self._entry_module_tenant_table_exists_cache is not None: + return self._entry_module_tenant_table_exists_cache + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'leaudit_entry_module_tenants' + ) + """ + ) + ) + ).scalar_one() + ) + self._entry_module_tenant_table_exists_cache = exists + return exists + + async def _entry_module_tenant_scope_sql( + self, + *, + session, + entry_module_expr: str, + current_user: dict[str, object] | None, + params: dict[str, object], + param_prefix: str, + ) -> str: + if current_user is None or current_user.get("is_global"): + return "1=1" + if not await self._entry_module_tenant_table_exists(session): + return "1=0" + params[f"{param_prefix}_tenant_code"] = str(current_user.get("tenant_code") or "").strip() + return f""" + EXISTS ( + SELECT 1 + FROM leaudit_entry_module_tenants emt + WHERE emt.entry_module_id = {entry_module_expr} + AND emt.deleted_at IS NULL + AND emt.is_enabled = TRUE + AND ( + emt.tenant_code = :{param_prefix}_tenant_code + OR emt.tenant_code = 'PUBLIC' + ) + ) + """ + + async def _assert_entry_module_access(self, Session, EntryModuleId: int | None, CurrentUser: dict[str, object] | None) -> None: + if CurrentUser is None or CurrentUser.get("is_global"): + return + if EntryModuleId is None: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前资源未绑定入口模块,当前租户不可访问") + params: dict[str, object] = {"entry_module_id": int(EntryModuleId)} + access_filter = await self._entry_module_tenant_scope_sql( + session=Session, + entry_module_expr="em.id", + current_user=CurrentUser, + params=params, + param_prefix="assert_rule_binding_scope", + ) + exists = ( + await Session.execute( + text( + f""" + SELECT em.id + FROM leaudit_entry_modules em + WHERE em.id = :entry_module_id + AND em.deleted_at IS NULL + AND {access_filter} + LIMIT 1 + """ + ), + params, + ) + ).scalar_one_or_none() + if exists is None: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户不可访问该入口模块") + + async def _assert_document_type_access(self, Session, DocTypeId: int, CurrentUser: dict[str, object] | None) -> None: + row = ( + await Session.execute( + text( + """ + SELECT id, entry_module_id + FROM leaudit_document_types + WHERE id = :doc_type_id + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"doc_type_id": DocTypeId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在") + await self._assert_entry_module_access( + Session, + int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None, + CurrentUser, + ) + + async def _resolve_unique_accessible_child_group_id(self, Session, DocTypeId: int, CurrentUser: dict[str, object] | None) -> int | None: + params: dict[str, object] = {"doc_type_id": DocTypeId} + access_filter = await self._entry_module_tenant_scope_sql( + session=Session, + entry_module_expr="COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=CurrentUser, + params=params, + param_prefix="unique_child_scope", + ) + row = ( + await Session.execute( + text( + f""" + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(child.id) END AS group_id + FROM leaudit_evaluation_point_groups child + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = child.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id + WHERE child.document_type_id = :doc_type_id + AND child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND {access_filter} + """ + ), + params, + ) + ).mappings().first() + return int(row["group_id"]) if row and row.get("group_id") is not None else None + + async def _count_accessible_child_groups(self, Session, DocTypeId: int, CurrentUser: dict[str, object] | None) -> int: + params: dict[str, object] = {"doc_type_id": DocTypeId} + access_filter = await self._entry_module_tenant_scope_sql( + session=Session, + entry_module_expr="COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=CurrentUser, + params=params, + param_prefix="count_child_scope", + ) + row = ( + await Session.execute( + text( + f""" + SELECT COUNT(*) AS total + FROM leaudit_evaluation_point_groups child + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = child.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id + WHERE child.document_type_id = :doc_type_id + AND child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND {access_filter} + """ + ), + params, + ) + ).mappings().first() + return int(row["total"] or 0) if row else 0 + + async def _get_binding_row(self, Session, BindingId: int, CurrentUser: dict[str, object] | None): + params: dict[str, object] = {"bid": BindingId} + access_filter = await self._entry_module_tenant_scope_sql( + session=Session, + entry_module_expr="COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id)", + current_user=CurrentUser, + params=params, + param_prefix="binding_detail_scope", + ) + row = ( + await Session.execute( + text( + f""" + SELECT + rgb.id, + rgb.group_id, + child.document_type_id AS doc_type_id, + dt.code AS doc_type_code, + rgb.rule_set_id, + rgb.priority, + rgb.is_active, + rgb.note, + rs.rule_type, + rs.rule_name + FROM leaudit_rule_group_bindings rgb + JOIN leaudit_evaluation_point_groups child ON child.id = rgb.group_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = child.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt ON dt.id = child.document_type_id + JOIN leaudit_rule_sets rs ON rs.id = rgb.rule_set_id + WHERE rgb.id = :bid + AND rgb.deleted_at IS NULL + AND {access_filter} + LIMIT 1 + """ + ), + params, + ) + ).mappings().first() + if not row: + raise LeauditException( + StatusCodeEnum.HTTP_404_NOT_FOUND, + "绑定记录不存在或当前租户不可访问,请刷新列表后重试", + ) + return row + def _BuildRuleVersionVo(self, Row) -> RuleVersionVO: """构造规则版本响应。""" return RuleVersionVO( diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantMaterializer.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantMaterializer.py new file mode 100644 index 0000000..960ed21 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantMaterializer.py @@ -0,0 +1,307 @@ +"""规则租户物化服务。""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession + + +class RuleTenantMaterializer: + """将平台模板规则物化为租户私有规则资产。""" + + _PLATFORM_TEMPLATE_TENANTS = {"PUBLIC", "PROVINCIAL"} + + def _template_source_order(self) -> list[str]: + return ["PUBLIC", "PROVINCIAL"] + + def _filter_materializable_tenants(self, tenants: list[dict[str, Any]]) -> list[str]: + result: list[str] = [] + for tenant in tenants: + tenant_code = str(tenant.get("tenant_code") or "").strip().upper() + if not tenant_code or tenant_code in self._PLATFORM_TEMPLATE_TENANTS: + continue + if not bool(tenant.get("is_enabled", True)): + continue + result.append(tenant_code) + return result + + def _legacy_region_for_tenant(self, tenant_code: str) -> str: + return str(tenant_code or "").strip().upper() + + async def MaterializeAllEnabledTenants(self) -> dict[str, int]: + async with GetAsyncSession() as session: + await self._ensure_rule_tenant_schema(session) + await self._promote_legacy_provincial_templates_to_public(session) + tenants = await self._load_enabled_tenants(session) + tenant_codes = self._filter_materializable_tenants(tenants) + stats = await self._materialize_tenants(session, tenant_codes) + await session.commit() + return stats + + async def MaterializeTenant(self, TenantCode: str) -> dict[str, int]: + tenant_code = str(TenantCode or "").strip().upper() + if not tenant_code or tenant_code in self._PLATFORM_TEMPLATE_TENANTS: + return {"tenants": 0, "rule_sets": 0, "versions": 0, "bindings": 0} + async with GetAsyncSession() as session: + await self._ensure_rule_tenant_schema(session) + await self._promote_legacy_provincial_templates_to_public(session) + stats = await self._materialize_tenants(session, [tenant_code]) + await session.commit() + return stats + + async def _ensure_rule_tenant_schema(self, session) -> None: + required = { + "leaudit_rule_sets": {"tenant_code", "scope_type", "source_rule_set_id", "tenant_name_snapshot"}, + "leaudit_rule_versions": {"tenant_code_snapshot", "scope_type_snapshot", "source_version_id"}, + "leaudit_rule_group_bindings": {"tenant_code", "scope_type", "tenant_name_snapshot"}, + } + rows = ( + await session.execute( + text( + """ + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ANY(:table_names) + """ + ), + {"table_names": list(required)}, + ) + ).mappings().all() + existing: dict[str, set[str]] = {} + for row in rows: + existing.setdefault(str(row["table_name"]), set()).add(str(row["column_name"])) + missing = [ + f"{table}.{column}" + for table, columns in required.items() + for column in sorted(columns - existing.get(table, set())) + ] + if missing: + raise RuntimeError("规则域租户字段未就绪: " + ", ".join(missing)) + + async def _load_enabled_tenants(self, session) -> list[dict[str, Any]]: + rows = ( + await session.execute( + text( + """ + SELECT tenant_code, is_enabled + FROM sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY display_order ASC, id ASC + """ + ) + ) + ).mappings().all() + return [dict(row) for row in rows] + + async def _promote_legacy_provincial_templates_to_public(self, session) -> None: + """把历史 PROVINCIAL 模板归并成 PUBLIC 模板源。""" + await session.execute( + text( + """ + UPDATE leaudit_rule_sets + SET tenant_code = 'PUBLIC', + scope_type = 'PUBLIC', + region = 'PUBLIC', + updated_at = NOW() + WHERE deleted_at IS NULL + AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') = 'PROVINCIAL' + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_sets existing + WHERE existing.rule_type = leaudit_rule_sets.rule_type + AND existing.tenant_code = 'PUBLIC' + AND existing.deleted_at IS NULL + ) + """ + ) + ) + await session.execute( + text( + """ + UPDATE leaudit_rule_versions rv + SET tenant_code_snapshot = 'PUBLIC', + scope_type_snapshot = 'PUBLIC', + updated_at = NOW() + FROM leaudit_rule_sets rs + WHERE rv.rule_set_id = rs.id + AND rs.tenant_code = 'PUBLIC' + AND (rv.tenant_code_snapshot IS NULL OR rv.tenant_code_snapshot = 'PROVINCIAL') + """ + ) + ) + await session.execute( + text( + """ + UPDATE leaudit_rule_group_bindings rgb + SET tenant_code = 'PUBLIC', + scope_type = 'PUBLIC', + updated_at = NOW() + FROM leaudit_rule_sets rs + WHERE rgb.rule_set_id = rs.id + AND rs.tenant_code = 'PUBLIC' + AND rgb.deleted_at IS NULL + AND COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL') = 'PROVINCIAL' + """ + ) + ) + + async def _materialize_tenants(self, session, tenant_codes: list[str]) -> dict[str, int]: + stats = {"tenants": len(tenant_codes), "rule_sets": 0, "versions": 0, "bindings": 0} + for tenant_code in tenant_codes: + stats["rule_sets"] += await self._materialize_rule_sets(session, tenant_code) + stats["versions"] += await self._materialize_versions(session, tenant_code) + stats["bindings"] += await self._materialize_bindings(session, tenant_code) + return stats + + async def _materialize_rule_sets(self, session, tenant_code: str) -> int: + result = await session.execute( + text( + """ + INSERT INTO leaudit_rule_sets ( + rule_type, rule_name, domain_type, description, entry_module, + current_version_id, status, is_builtin, owner_user_id, + tenant_code, scope_type, source_rule_set_id, tenant_name_snapshot, + created_at, updated_at, deleted_at, region + ) + SELECT + src.rule_type, src.rule_name, src.domain_type, src.description, src.entry_module, + NULL, 'draft', FALSE, src.owner_user_id, + CAST(:tenant_code AS varchar), 'TENANT', src.id, t.tenant_name, + NOW(), NOW(), NULL, CAST(:tenant_code AS varchar) + FROM leaudit_rule_sets src + JOIN sys_tenants t ON t.tenant_code = CAST(:tenant_code AS varchar) + WHERE src.deleted_at IS NULL + AND src.tenant_code = 'PUBLIC' + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_sets existing + WHERE existing.rule_type = src.rule_type + AND existing.tenant_code = CAST(:tenant_code AS varchar) + AND existing.deleted_at IS NULL + ) + """ + ), + {"tenant_code": tenant_code}, + ) + return int(result.rowcount or 0) + + async def _materialize_versions(self, session, tenant_code: str) -> int: + result = await session.execute( + text( + """ + INSERT INTO leaudit_rule_versions ( + rule_set_id, version_no, version_seq, status, source_type, dsl_format, + oss_url, file_sha256, file_size, local_cache_path, + metadata_type_id, metadata_name, metadata_version, change_note, + editor_user_id, publisher_user_id, published_at, + created_at, updated_at, deleted_at, + tenant_code_snapshot, scope_type_snapshot, source_version_id + ) + SELECT + tenant_rs.id, src_v.version_no, src_v.version_seq, src_v.status, + src_v.source_type, src_v.dsl_format, src_v.oss_url, src_v.file_sha256, + src_v.file_size, src_v.local_cache_path, src_v.metadata_type_id, + src_v.metadata_name, src_v.metadata_version, src_v.change_note, + src_v.editor_user_id, src_v.publisher_user_id, src_v.published_at, + NOW(), NOW(), NULL, + CAST(:tenant_code AS varchar), 'TENANT', src_v.id + FROM leaudit_rule_sets tenant_rs + JOIN leaudit_rule_sets src_rs ON src_rs.id = tenant_rs.source_rule_set_id + JOIN leaudit_rule_versions src_v ON src_v.rule_set_id = src_rs.id + WHERE tenant_rs.deleted_at IS NULL + AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) + AND src_rs.tenant_code = 'PUBLIC' + AND src_v.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_versions tenant_existing + WHERE tenant_existing.rule_set_id = tenant_rs.id + AND tenant_existing.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_versions existing + WHERE existing.rule_set_id = tenant_rs.id + AND existing.deleted_at IS NULL + AND ( + existing.source_version_id = src_v.id + OR existing.version_no = src_v.version_no + OR existing.version_seq = src_v.version_seq + ) + ) + """ + ), + {"tenant_code": tenant_code}, + ) + await session.execute( + text( + """ + UPDATE leaudit_rule_sets tenant_rs + SET current_version_id = tenant_v.id, + status = 'active', + updated_at = NOW() + FROM leaudit_rule_sets src_rs + JOIN leaudit_rule_versions src_current ON src_current.id = src_rs.current_version_id + JOIN leaudit_rule_versions tenant_v ON tenant_v.source_version_id = src_current.id + WHERE tenant_rs.source_rule_set_id = src_rs.id + AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) + AND tenant_v.rule_set_id = tenant_rs.id + AND tenant_rs.current_version_id IS NULL + """ + ), + {"tenant_code": tenant_code}, + ) + return int(result.rowcount or 0) + + async def _materialize_bindings(self, session, tenant_code: str) -> int: + result = await session.execute( + text( + """ + INSERT INTO leaudit_rule_group_bindings ( + group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, + tenant_code, scope_type, tenant_name_snapshot, + created_at, updated_at, deleted_at + ) + SELECT + src_b.group_id, tenant_rs.id, src_b.rule_type_binding_id, src_b.priority, + src_b.is_active, '由公共规则模板自动物化', + CAST(:tenant_code AS varchar), 'TENANT', t.tenant_name, + NOW(), NOW(), NULL + FROM leaudit_rule_group_bindings src_b + JOIN leaudit_rule_sets src_rs ON src_rs.id = src_b.rule_set_id + JOIN leaudit_rule_sets tenant_rs + ON tenant_rs.source_rule_set_id = src_rs.id + AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) + AND tenant_rs.deleted_at IS NULL + JOIN sys_tenants t ON t.tenant_code = CAST(:tenant_code AS varchar) + WHERE src_b.deleted_at IS NULL + AND src_b.is_active = TRUE + AND src_rs.tenant_code = 'PUBLIC' + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_group_bindings existing + WHERE existing.group_id = src_b.group_id + AND existing.tenant_code = CAST(:tenant_code AS varchar) + AND existing.rule_set_id = tenant_rs.id + AND existing.deleted_at IS NULL + ) + """ + ), + {"tenant_code": tenant_code}, + ) + return int(result.rowcount or 0) + + +_RULE_TENANT_MATERIALIZER_SINGLETON: RuleTenantMaterializer | None = None + + +def GetRuleTenantMaterializerSingleton() -> RuleTenantMaterializer: + global _RULE_TENANT_MATERIALIZER_SINGLETON + if _RULE_TENANT_MATERIALIZER_SINGLETON is None: + _RULE_TENANT_MATERIALIZER_SINGLETON = RuleTenantMaterializer() + return _RULE_TENANT_MATERIALIZER_SINGLETON diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantScope.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantScope.py new file mode 100644 index 0000000..b96721e --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleTenantScope.py @@ -0,0 +1,54 @@ +"""规则域租户作用域解析工具。""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + + +def normalize_scoped_tenant_code(value: str | None, default: str = "PROVINCIAL") -> str: + """标准化规则域作用域租户编码。""" + normalized = str(value or "").strip().upper() + return normalized or default + + +def candidate_scope_tenant_codes(tenant_code: str | None) -> list[str]: + """返回规则域作用域命中顺序: TENANT -> PUBLIC -> PROVINCIAL。 + + PUBLIC 是新的平台级模板源;PROVINCIAL 仅作为历史兼容兜底。 + """ + normalized = normalize_scoped_tenant_code(tenant_code) + candidates: list[str] = [] + if normalized not in {"PROVINCIAL", "PUBLIC"}: + candidates.append(normalized) + candidates.append("PUBLIC") + if normalized != "PUBLIC": + candidates.append("PROVINCIAL") + return list(dict.fromkeys(candidates)) + + +def pick_effective_scoped_row( + rows: Iterable[Mapping[str, Any]], + tenant_code: str | None, + *, + tenant_code_key: str = "tenant_code", +) -> Mapping[str, Any] | None: + """按租户继承顺序挑选一条实际生效记录。""" + row_by_tenant: dict[str, Mapping[str, Any]] = {} + legacy_provincial_row: Mapping[str, Any] | None = None + + for row in rows: + normalized = normalize_scoped_tenant_code(str(row.get(tenant_code_key) or ""), default="") + if not normalized: + if legacy_provincial_row is None: + legacy_provincial_row = row + continue + row_by_tenant.setdefault(normalized, row) + + for candidate in candidate_scope_tenant_codes(tenant_code): + matched = row_by_tenant.get(candidate) + if matched is not None: + return matched + if candidate == "PROVINCIAL" and legacy_provincial_row is not None: + return legacy_provincial_row + return legacy_provincial_row diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py b/fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py new file mode 100644 index 0000000..5f64450 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/ssoUserCompat.py @@ -0,0 +1,68 @@ +"""`sso_users` 表结构兼容工具。""" + +from __future__ import annotations + +from sqlalchemy import text + + +class SsoUserCompat: + """兼容旧环境 `sso_users` 尚未完成租户字段迁移的场景。""" + + _columns_cache: set[str] | None = None + + @classmethod + async def get_columns(cls, session) -> set[str]: + if cls._columns_cache is not None: + return cls._columns_cache + + rows = await session.execute( + text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'sso_users' + """ + ) + ) + cls._columns_cache = {str(row[0]) for row in rows.fetchall()} + return cls._columns_cache + + @staticmethod + def raw_optional_column( + columns: set[str], + *, + alias: str, + column: str, + pg_type: str = "varchar", + ) -> str: + if column in columns: + return f"{alias}.{column}" + return f"NULL::{pg_type}" + + @classmethod + def optional_column_as( + cls, + columns: set[str], + *, + alias: str, + column: str, + pg_type: str = "varchar", + output_alias: str | None = None, + ) -> str: + expression = cls.raw_optional_column(columns, alias=alias, column=column, pg_type=pg_type) + return f"{expression} AS {output_alias or column}" + + @classmethod + def optional_coalesce_as( + cls, + columns: set[str], + *, + alias: str, + column: str, + fallback_sql: str, + pg_type: str = "varchar", + output_alias: str | None = None, + ) -> str: + expression = cls.raw_optional_column(columns, alias=alias, column=column, pg_type=pg_type) + return f"COALESCE({expression}, {fallback_sql}) AS {output_alias or column}" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py b/fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py new file mode 100644 index 0000000..e4c81e9 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/tenantResolver.py @@ -0,0 +1,204 @@ +"""统一租户解析器与兼容层。""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession + + +@dataclass(slots=True) +class TenantResolution: + """租户解析结果。""" + + tenant_code: str | None + tenant_name: str | None + tenant_type: str | None + raw_value: str | None + normalized_value: str | None + source: str + is_public: bool = False + + +class TenantResolver: + """统一租户解析器。 + + 当前阶段目标: + 1. 兼容 area / region / tenant_name 等历史字段 + 2. 尽可能解析成稳定 tenant_code + 3. 为旧业务继续保留 normalized 中文值,避免立即打崩 area 依赖代码 + """ + + _PUBLIC_SENTINELS = {"default", "公共"} + _PROVINCIAL_SENTINELS = {"省级", "省局"} + + def __init__(self) -> None: + self._table_exists_cache: dict[str, bool] = {} + + async def Resolve( + self, + RawValue: str | None, + Source: str = "generic", + FallbackTenantName: str | None = None, + PreferredTenantCode: str | None = None, + ) -> TenantResolution: + normalized = self._normalize(RawValue) + if PreferredTenantCode and PreferredTenantCode.strip(): + tenant = await self._loadTenantByCode(PreferredTenantCode.strip()) + if tenant: + return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) + + if normalized in self._PUBLIC_SENTINELS: + tenant = await self._loadTenantByCode("PUBLIC") + if tenant: + return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source, force_public=True) + + if normalized in self._PROVINCIAL_SENTINELS: + tenant = await self._loadTenantByCode("PROVINCIAL") + if tenant: + return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) + + if normalized: + tenant = await self._loadTenantByAlias(normalized) + if tenant: + return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) + + fallback_name = self._normalize(FallbackTenantName) + if fallback_name: + tenant = await self._loadTenantByAlias(fallback_name) + if tenant: + return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized or fallback_name, source=Source) + + return TenantResolution( + tenant_code=None, + tenant_name=fallback_name or normalized, + tenant_type=None, + raw_value=RawValue, + normalized_value=normalized or fallback_name, + source=Source, + is_public=False, + ) + + async def ResolveUserContext( + self, + *, + Area: str | None, + TenantCode: str | None, + TenantName: str | None, + Source: str = "user_context", + ) -> TenantResolution: + """按用户上下文解析租户。""" + return await self.Resolve( + RawValue=Area, + Source=Source, + FallbackTenantName=TenantName, + PreferredTenantCode=TenantCode, + ) + + @staticmethod + def _normalize(value: str | None) -> str | None: + if value is None: + return None + trimmed = str(value).strip() + return trimmed if trimmed else "" + + async def _loadTenantByCode(self, tenant_code: str) -> dict | None: + if not await self._table_exists("sys_tenants"): + return None + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT tenant_code, tenant_name, tenant_type, COALESCE(is_public, FALSE) AS is_public + FROM sys_tenants + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + AND is_enabled = TRUE + LIMIT 1 + """ + ), + {"tenant_code": tenant_code}, + ) + ).mappings().first() + return dict(row) if row else None + + async def _loadTenantByAlias(self, alias_value: str) -> dict | None: + if not await self._table_exists("sys_tenants") or not await self._table_exists("sys_tenant_aliases"): + return None + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT t.tenant_code, t.tenant_name, t.tenant_type, COALESCE(t.is_public, FALSE) AS is_public + FROM sys_tenant_aliases a + JOIN sys_tenants t ON t.tenant_code = a.tenant_code + WHERE a.alias_value = :alias_value + AND a.deleted_at IS NULL + AND a.is_enabled = TRUE + AND t.deleted_at IS NULL + AND t.is_enabled = TRUE + ORDER BY + CASE a.alias_type + WHEN 'DISPLAY' THEN 1 + WHEN 'LEGACY_AREA' THEN 2 + WHEN 'LEGACY_REGION' THEN 3 + ELSE 9 + END ASC, + a.id ASC + LIMIT 1 + """ + ), + {"alias_value": alias_value}, + ) + ).mappings().first() + return dict(row) if row else None + + async def _table_exists(self, table_name: str) -> bool: + """缓存表存在性,兼容旧环境未完成租户基础表建表的场景。""" + cached = self._table_exists_cache.get(table_name) + if cached is not None: + return cached + + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = :table_name + ) AS table_exists + """ + ), + {"table_name": table_name}, + ) + ).scalar_one() + + exists = bool(row) + self._table_exists_cache[table_name] = exists + return exists + + @staticmethod + def _to_resolution( + *, + tenant: dict, + raw_value: str | None, + normalized_value: str | None, + source: str, + force_public: bool = False, + ) -> TenantResolution: + return TenantResolution( + tenant_code=str(tenant.get("tenant_code") or ""), + tenant_name=tenant.get("tenant_name"), + tenant_type=tenant.get("tenant_type"), + raw_value=raw_value, + normalized_value=normalized_value, + source=source, + is_public=bool(tenant.get("is_public")) or force_public, + ) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py new file mode 100644 index 0000000..d79cd7b --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/tenantServiceImpl.py @@ -0,0 +1,868 @@ +"""租户主数据服务实现。""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.Dto.tenantDto import ( + TenantCreateDTO, + TenantStatusUpdateDTO, + TenantUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantMaterializer import ( + GetRuleTenantMaterializerSingleton, + RuleTenantMaterializer, +) +from fastapi_modules.fastapi_leaudit.services.tenantService import ITenantService + + +class TenantServiceImpl(ITenantService): + """租户主数据服务实现。""" + + _BUILTIN_TENANT_CODES: tuple[str, ...] = ("PUBLIC", "PROVINCIAL") + _SUPPORTED_FEATURE_KEYS: tuple[str, ...] = ( + "home.entry_module", + "documents.upload", + "rag.dataset", + ) + + def __init__(self, RuleTenantMaterializer: RuleTenantMaterializer | None = None) -> None: + self._table_exists_cache: dict[str, bool] = {} + self.RuleTenantMaterializer = RuleTenantMaterializer or GetRuleTenantMaterializerSingleton() + + async def ListTenants(self, IncludeDisabled: bool = False) -> list[dict[str, Any]]: + if not await self._table_exists("sys_tenants"): + return await self._list_legacy_tenants() + filters = ["t.deleted_at IS NULL"] + params: dict[str, Any] = {} + if not IncludeDisabled: + filters.append("t.is_enabled = TRUE") + + where_sql = " AND ".join(filters) + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + f""" + SELECT + t.tenant_code, + t.tenant_name, + t.tenant_short_name, + t.tenant_type, + t.parent_tenant_code, + t.display_order, + t.is_enabled, + t.is_builtin, + t.is_public, + t.can_host_entry_module, + t.can_host_documents, + t.can_host_rag, + t.can_host_templates, + t.ext, + COALESCE( + ARRAY( + SELECT f.feature_key + FROM sys_tenant_feature_flags f + WHERE f.tenant_code = t.tenant_code + AND f.deleted_at IS NULL + AND f.is_enabled = TRUE + ORDER BY f.feature_key ASC + ), + ARRAY[]::VARCHAR[] + ) AS feature_keys, + COALESCE( + ARRAY( + SELECT a.alias_value + FROM sys_tenant_aliases a + WHERE a.tenant_code = t.tenant_code + AND a.deleted_at IS NULL + AND a.is_enabled = TRUE + ORDER BY + CASE a.alias_type + WHEN 'DISPLAY' THEN 1 + WHEN 'SHORT_NAME' THEN 2 + WHEN 'LEGACY_AREA' THEN 3 + ELSE 9 + END ASC, + a.id ASC + ), + ARRAY[]::VARCHAR[] + ) AS alias_values + FROM sys_tenants t + WHERE {where_sql} + ORDER BY t.display_order ASC, t.id ASC + """ + ), + params, + ) + ).mappings().all() + + return [dict(row) for row in rows] + + async def ListTenantOptions(self, FeatureKey: str | None = None) -> list[dict[str, Any]]: + if not await self._table_exists("sys_tenants"): + items = await self._list_legacy_tenant_options() + return items + filters = ["t.deleted_at IS NULL", "t.is_enabled = TRUE"] + params: dict[str, Any] = {} + join_sql = "" + if FeatureKey and FeatureKey.strip() and await self._table_exists("sys_tenant_feature_flags"): + join_sql = """ + JOIN sys_tenant_feature_flags f + ON f.tenant_code = t.tenant_code + AND f.deleted_at IS NULL + AND f.is_enabled = TRUE + AND f.feature_key = :feature_key + """ + params["feature_key"] = FeatureKey.strip() + + where_sql = " AND ".join(filters) + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + f""" + SELECT + t.tenant_code, + t.tenant_name, + t.tenant_short_name, + t.tenant_type, + t.is_public, + t.display_order + FROM sys_tenants t + {join_sql} + WHERE {where_sql} + ORDER BY t.display_order ASC, t.id ASC + """ + ), + params, + ) + ).mappings().all() + return [dict(row) for row in rows] + + async def GetTenant(self, TenantCode: str) -> dict[str, Any] | None: + tenant_code = str(TenantCode or "").strip() + if not tenant_code: + return None + if not await self._table_exists("sys_tenants"): + for item in await self._list_legacy_tenants(): + if str(item.get("tenant_code") or "").strip() == tenant_code: + return item + return None + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT + t.tenant_code, + t.tenant_name, + t.tenant_short_name, + t.tenant_type, + t.parent_tenant_code, + t.display_order, + t.is_enabled, + t.is_builtin, + t.is_public, + t.can_host_entry_module, + t.can_host_documents, + t.can_host_rag, + t.can_host_templates, + t.ext + FROM sys_tenants t + WHERE t.tenant_code = :tenant_code + AND t.deleted_at IS NULL + LIMIT 1 + """ + ), + {"tenant_code": tenant_code}, + ) + ).mappings().first() + return dict(row) if row else None + + async def GetTenantFeatures(self, TenantCode: str) -> list[str]: + tenant_code = str(TenantCode or "").strip() + if not tenant_code: + return [] + if not await self._table_exists("sys_tenant_feature_flags"): + return [] + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + """ + SELECT feature_key + FROM sys_tenant_feature_flags + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY feature_key ASC + """ + ), + {"tenant_code": tenant_code}, + ) + ).all() + return [str(row[0]) for row in rows] + + async def GetTenantAliases(self, TenantCode: str) -> list[str]: + tenant_code = str(TenantCode or "").strip() + if not tenant_code: + return [] + return await self._getTenantAliases(tenant_code) + + async def CreateTenant(self, CurrentUserId: int, Body: TenantCreateDTO) -> dict[str, Any]: + del CurrentUserId + await self._ensureWritableTenantFoundation() + + tenant_code = self._normalizeTenantCode(Body.tenant_code) + tenant_name = self._normalizeRequiredText(Body.tenant_name, "租户名称") + tenant_short_name = self._normalizeOptionalText(Body.tenant_short_name) or tenant_name + tenant_type = self._normalizeTenantType(Body.tenant_type) + parent_tenant_code = self._normalizeOptionalCode(Body.parent_tenant_code) + feature_keys = self._normalizeFeatureKeys(Body.feature_keys) + alias_values = self._normalizeAliasValues(Body.alias_values, tenant_name=tenant_name, tenant_short_name=tenant_short_name) + ext = Body.ext or {} + + async with GetAsyncSession() as session: + exists = ( + await session.execute( + text( + """ + SELECT 1 + FROM sys_tenants + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"tenant_code": tenant_code}, + ) + ).scalar_one_or_none() + if exists: + raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"租户编码已存在: {tenant_code}") + + if parent_tenant_code: + await self._assertTenantExists(session, parent_tenant_code, "父级租户不存在") + + await session.execute( + text( + """ + INSERT INTO sys_tenants ( + tenant_code, + tenant_name, + tenant_short_name, + tenant_type, + parent_tenant_code, + display_order, + is_enabled, + is_builtin, + is_public, + can_host_entry_module, + can_host_documents, + can_host_rag, + can_host_templates, + ext, + created_at, + updated_at, + deleted_at + ) VALUES ( + :tenant_code, + :tenant_name, + :tenant_short_name, + :tenant_type, + :parent_tenant_code, + :display_order, + :is_enabled, + FALSE, + :is_public, + :can_host_entry_module, + :can_host_documents, + :can_host_rag, + :can_host_templates, + CAST(:ext AS jsonb), + NOW(), + NOW(), + NULL + ) + """ + ), + { + "tenant_code": tenant_code, + "tenant_name": tenant_name, + "tenant_short_name": tenant_short_name, + "tenant_type": tenant_type, + "parent_tenant_code": parent_tenant_code, + "display_order": int(Body.display_order or 0), + "is_enabled": bool(Body.is_enabled), + "is_public": bool(Body.is_public), + "can_host_entry_module": bool(Body.can_host_entry_module), + "can_host_documents": bool(Body.can_host_documents), + "can_host_rag": bool(Body.can_host_rag), + "can_host_templates": bool(Body.can_host_templates), + "ext": self._dumpJson(ext), + }, + ) + await self._replaceTenantAliases(session, tenant_code, alias_values) + await self._replaceTenantFeatures(session, tenant_code, feature_keys) + await session.commit() + + await self.RuleTenantMaterializer.MaterializeTenant(tenant_code) + return await self._getTenantDetailOrFail(tenant_code) + + async def UpdateTenant(self, CurrentUserId: int, TenantCode: str, Body: TenantUpdateDTO) -> dict[str, Any]: + del CurrentUserId + await self._ensureWritableTenantFoundation() + tenant_code = self._normalizeTenantCode(TenantCode) + + async with GetAsyncSession() as session: + current = await self._loadTenantRow(session, tenant_code) + if not current: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在") + + is_builtin = bool(current.get("is_builtin")) + tenant_name = self._normalizeOptionalText(Body.tenant_name) if Body.tenant_name is not None else str(current.get("tenant_name") or "") + if not tenant_name: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户名称不能为空") + tenant_short_name = ( + self._normalizeOptionalText(Body.tenant_short_name) + if Body.tenant_short_name is not None + else self._normalizeOptionalText(current.get("tenant_short_name")) + ) or tenant_name + tenant_type = self._normalizeTenantType(Body.tenant_type) if Body.tenant_type is not None else str(current.get("tenant_type") or "CUSTOM") + parent_tenant_code = self._normalizeOptionalCode(Body.parent_tenant_code) if Body.parent_tenant_code is not None else current.get("parent_tenant_code") + if parent_tenant_code == tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "父级租户不能指向自身") + if parent_tenant_code: + await self._assertTenantExists(session, str(parent_tenant_code), "父级租户不存在", exclude_tenant_code=tenant_code) + + if is_builtin and tenant_code in self._BUILTIN_TENANT_CODES: + tenant_type = str(current.get("tenant_type") or tenant_type) + parent_tenant_code = current.get("parent_tenant_code") + + await session.execute( + text( + """ + UPDATE sys_tenants + SET tenant_name = :tenant_name, + tenant_short_name = :tenant_short_name, + tenant_type = :tenant_type, + parent_tenant_code = :parent_tenant_code, + display_order = :display_order, + is_public = :is_public, + can_host_entry_module = :can_host_entry_module, + can_host_documents = :can_host_documents, + can_host_rag = :can_host_rag, + can_host_templates = :can_host_templates, + ext = CAST(:ext AS jsonb), + updated_at = NOW() + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + """ + ), + { + "tenant_code": tenant_code, + "tenant_name": tenant_name, + "tenant_short_name": tenant_short_name, + "tenant_type": tenant_type, + "parent_tenant_code": parent_tenant_code, + "display_order": int(Body.display_order if Body.display_order is not None else current.get("display_order") or 0), + "is_public": bool(Body.is_public if Body.is_public is not None else current.get("is_public")), + "can_host_entry_module": bool( + Body.can_host_entry_module if Body.can_host_entry_module is not None else current.get("can_host_entry_module") + ), + "can_host_documents": bool( + Body.can_host_documents if Body.can_host_documents is not None else current.get("can_host_documents") + ), + "can_host_rag": bool(Body.can_host_rag if Body.can_host_rag is not None else current.get("can_host_rag")), + "can_host_templates": bool( + Body.can_host_templates if Body.can_host_templates is not None else current.get("can_host_templates") + ), + "ext": self._dumpJson(Body.ext if Body.ext is not None else (current.get("ext") or {})), + }, + ) + + if Body.alias_values is not None: + alias_values = self._normalizeAliasValues(Body.alias_values, tenant_name=tenant_name, tenant_short_name=tenant_short_name) + await self._replaceTenantAliases(session, tenant_code, alias_values) + if Body.feature_keys is not None: + feature_keys = self._normalizeFeatureKeys(Body.feature_keys) + await self._replaceTenantFeatures(session, tenant_code, feature_keys) + await session.commit() + + return await self._getTenantDetailOrFail(tenant_code) + + async def UpdateTenantStatus(self, CurrentUserId: int, TenantCode: str, Body: TenantStatusUpdateDTO) -> dict[str, Any]: + del CurrentUserId + await self._ensureWritableTenantFoundation() + tenant_code = self._normalizeTenantCode(TenantCode) + is_enabled = bool(Body.is_enabled) + + async with GetAsyncSession() as session: + current = await self._loadTenantRow(session, tenant_code) + if not current: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在") + if bool(current.get("is_builtin")) and tenant_code in self._BUILTIN_TENANT_CODES and not is_enabled: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "内建核心租户不允许禁用") + if not is_enabled: + await self._assertTenantCanBeDisabled(session, tenant_code) + + await session.execute( + text( + """ + UPDATE sys_tenants + SET is_enabled = :is_enabled, + updated_at = NOW() + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + """ + ), + {"tenant_code": tenant_code, "is_enabled": is_enabled}, + ) + await session.commit() + + return await self._getTenantDetailOrFail(tenant_code) + + async def _table_exists(self, table_name: str) -> bool: + cached = self._table_exists_cache.get(table_name) + if cached is not None: + return cached + + async with GetAsyncSession() as session: + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = :table_name + ) + """ + ), + {"table_name": table_name}, + ) + ).scalar_one() + ) + self._table_exists_cache[table_name] = exists + return exists + + async def _list_legacy_tenants(self) -> list[dict[str, Any]]: + async with GetAsyncSession() as session: + user_rows = ( + await session.execute( + text( + """ + SELECT DISTINCT COALESCE(NULLIF(area, ''), '') AS area + FROM sso_users + WHERE deleted_at IS NULL + AND COALESCE(NULLIF(area, ''), '') <> '' + ORDER BY COALESCE(NULLIF(area, ''), '') ASC + """ + ) + ) + ).all() + + items: list[dict[str, Any]] = [ + { + "tenant_code": "PUBLIC", + "tenant_name": "公共", + "tenant_short_name": "公共", + "tenant_type": "PUBLIC", + "parent_tenant_code": None, + "display_order": 0, + "is_enabled": True, + "is_builtin": True, + "is_public": True, + "can_host_entry_module": True, + "can_host_documents": True, + "can_host_rag": True, + "can_host_templates": True, + "ext": {}, + "feature_keys": [], + } + ] + for index, row in enumerate(user_rows, start=1): + area = str(row[0] or "").strip() + if not area or area == "公共": + continue + items.append( + { + "tenant_code": area, + "tenant_name": area, + "tenant_short_name": area, + "tenant_type": "LEGACY_AREA", + "parent_tenant_code": None, + "display_order": index * 10, + "is_enabled": True, + "is_builtin": False, + "is_public": False, + "can_host_entry_module": True, + "can_host_documents": True, + "can_host_rag": True, + "can_host_templates": True, + "ext": {}, + "feature_keys": [], + } + ) + return items + + async def _list_legacy_tenant_options(self) -> list[dict[str, Any]]: + return [ + { + "tenant_code": str(item.get("tenant_code") or ""), + "tenant_name": str(item.get("tenant_name") or ""), + "tenant_short_name": item.get("tenant_short_name"), + "tenant_type": item.get("tenant_type"), + "is_public": bool(item.get("is_public")), + "display_order": item.get("display_order"), + } + for item in await self._list_legacy_tenants() + ] + + async def _ensureWritableTenantFoundation(self) -> None: + required_tables = ("sys_tenants", "sys_tenant_aliases", "sys_tenant_feature_flags") + missing = [table_name for table_name in required_tables if not await self._table_exists(table_name)] + if missing: + joined = ", ".join(missing) + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, f"租户主数据底座未初始化,缺少表: {joined}") + + async def _getTenantDetailOrFail(self, tenant_code: str) -> dict[str, Any]: + item = await self.GetTenant(tenant_code) + if not item: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在") + item["feature_keys"] = await self.GetTenantFeatures(tenant_code) + item["alias_values"] = await self.GetTenantAliases(tenant_code) + return item + + async def _getTenantAliases(self, tenant_code: str) -> list[str]: + if not await self._table_exists("sys_tenant_aliases"): + return [] + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + """ + SELECT alias_value + FROM sys_tenant_aliases + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY + CASE alias_type + WHEN 'DISPLAY' THEN 1 + WHEN 'SHORT_NAME' THEN 2 + WHEN 'LEGACY_AREA' THEN 3 + ELSE 9 + END ASC, + id ASC + """ + ), + {"tenant_code": tenant_code}, + ) + ).all() + return [str(row[0]).strip() for row in rows if str(row[0] or "").strip()] + + async def _loadTenantRow(self, session: Any, tenant_code: str) -> dict[str, Any] | None: + row = ( + await session.execute( + text( + """ + SELECT + tenant_code, + tenant_name, + tenant_short_name, + tenant_type, + parent_tenant_code, + display_order, + is_enabled, + is_builtin, + is_public, + can_host_entry_module, + can_host_documents, + can_host_rag, + can_host_templates, + ext + FROM sys_tenants + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"tenant_code": tenant_code}, + ) + ).mappings().first() + return dict(row) if row else None + + async def _assertTenantExists( + self, + session: Any, + tenant_code: str, + error_message: str, + exclude_tenant_code: str | None = None, + ) -> None: + params: dict[str, Any] = {"tenant_code": tenant_code} + sql = """ + SELECT 1 + FROM sys_tenants + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + """ + if exclude_tenant_code: + sql += " AND tenant_code <> :exclude_tenant_code" + params["exclude_tenant_code"] = exclude_tenant_code + sql += " LIMIT 1" + exists = (await session.execute(text(sql), params)).scalar_one_or_none() + if not exists: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, error_message) + + async def _assertTenantCanBeDisabled(self, session: Any, tenant_code: str) -> None: + references = await self._collectDisableReferences(session, tenant_code) + if not references: + return + joined = ";".join(references) + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"当前租户仍被引用,不能禁用:{joined}") + + async def _collectDisableReferences(self, session: Any, tenant_code: str) -> list[str]: + references: list[str] = [] + + if await self._table_exists("sys_tenants"): + child_count = int( + ( + await session.execute( + text( + """ + SELECT COUNT(*) + FROM sys_tenants + WHERE parent_tenant_code = :tenant_code + AND deleted_at IS NULL + AND is_enabled = TRUE + """ + ), + {"tenant_code": tenant_code}, + ) + ).scalar_one() + ) + if child_count > 0: + references.append(f"存在 {child_count} 个启用中的子租户") + + if await self._table_exists("leaudit_entry_module_tenants"): + entry_module_count = int( + ( + await session.execute( + text( + """ + SELECT COUNT(DISTINCT emt.entry_module_id) + FROM leaudit_entry_module_tenants emt + WHERE emt.tenant_code = :tenant_code + AND emt.deleted_at IS NULL + AND COALESCE(emt.is_enabled, TRUE) = TRUE + """ + ), + {"tenant_code": tenant_code}, + ) + ).scalar_one() + ) + if entry_module_count > 0: + references.append(f"仍绑定 {entry_module_count} 个入口模块") + + if await self._table_exists("sso_users") and await self._column_exists("sso_users", "tenant_code"): + user_count = int( + ( + await session.execute( + text( + """ + SELECT COUNT(*) + FROM sso_users + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + AND status = 0 + """ + ), + {"tenant_code": tenant_code}, + ) + ).scalar_one() + ) + if user_count > 0: + references.append(f"仍有 {user_count} 个启用用户归属该租户") + + return references + + async def _replaceTenantAliases(self, session: Any, tenant_code: str, alias_values: list[str]) -> None: + await session.execute( + text( + """ + DELETE FROM sys_tenant_aliases + WHERE tenant_code = :tenant_code + """ + ), + {"tenant_code": tenant_code}, + ) + for index, alias in enumerate(alias_values): + alias_type = "DISPLAY" if index == 0 else "SHORT_NAME" + await session.execute( + text( + """ + INSERT INTO sys_tenant_aliases ( + tenant_code, + alias_type, + alias_value, + is_enabled, + created_at, + updated_at, + deleted_at + ) VALUES ( + :tenant_code, + :alias_type, + :alias_value, + TRUE, + NOW(), + NOW(), + NULL + ) + """ + ), + { + "tenant_code": tenant_code, + "alias_type": alias_type, + "alias_value": alias, + }, + ) + + async def _replaceTenantFeatures(self, session: Any, tenant_code: str, feature_keys: list[str]) -> None: + await session.execute( + text( + """ + DELETE FROM sys_tenant_feature_flags + WHERE tenant_code = :tenant_code + """ + ), + {"tenant_code": tenant_code}, + ) + for feature_key in feature_keys: + await session.execute( + text( + """ + INSERT INTO sys_tenant_feature_flags ( + tenant_code, + feature_key, + is_enabled, + created_at, + updated_at, + deleted_at + ) VALUES ( + :tenant_code, + :feature_key, + TRUE, + NOW(), + NOW(), + NULL + ) + """ + ), + { + "tenant_code": tenant_code, + "feature_key": feature_key, + }, + ) + + def _normalizeTenantCode(self, value: str | None) -> str: + tenant_code = str(value or "").strip() + if not tenant_code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户编码不能为空") + if len(tenant_code) > 64: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户编码长度不能超过 64") + return tenant_code + + def _normalizeOptionalCode(self, value: str | None) -> str | None: + normalized = self._normalizeOptionalText(value) + return normalized or None + + def _normalizeRequiredText(self, value: str | None, field_name: str) -> str: + normalized = self._normalizeOptionalText(value) + if not normalized: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"{field_name}不能为空") + return normalized + + @staticmethod + def _normalizeOptionalText(value: Any) -> str | None: + if value is None: + return None + text_value = str(value).strip() + return text_value or None + + def _normalizeTenantType(self, value: str | None) -> str: + tenant_type = self._normalizeOptionalText(value) or "CUSTOM" + if len(tenant_type) > 32: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户类型长度不能超过 32") + return tenant_type.upper() + + def _normalizeFeatureKeys(self, values: list[str] | None) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in values or []: + feature_key = self._normalizeOptionalText(item) + if not feature_key: + continue + if feature_key not in self._SUPPORTED_FEATURE_KEYS: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"不支持的功能标识: {feature_key}") + if feature_key in seen: + continue + seen.add(feature_key) + normalized.append(feature_key) + return normalized + + def _normalizeAliasValues(self, values: list[str] | None, *, tenant_name: str, tenant_short_name: str) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + seeds = [tenant_name, tenant_short_name, *(values or [])] + for item in seeds: + alias = self._normalizeOptionalText(item) + if not alias or alias in seen: + continue + if len(alias) > 100: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"租户别名过长: {alias}") + seen.add(alias) + normalized.append(alias) + return normalized + + async def _column_exists(self, table_name: str, column_name: str) -> bool: + cache_key = f"{table_name}.{column_name}" + cached = self._table_exists_cache.get(cache_key) + if cached is not None: + return cached + + async with GetAsyncSession() as session: + exists = bool( + ( + await session.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + AND column_name = :column_name + ) + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + ).scalar_one() + ) + self._table_exists_cache[cache_key] = exists + return exists + + @staticmethod + def _dumpJson(value: dict[str, Any]) -> str: + import json + + return json.dumps(value, ensure_ascii=False) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py index 978a97d..3dce801 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/usageStatsServiceImpl.py @@ -24,12 +24,17 @@ from fastapi_modules.fastapi_leaudit.domian.vo.usageStatsVo import ( UsageStatsUserItemVO, UsageStatsUserPageVO, ) +from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat +from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService class UsageStatsServiceImpl(IUsageStatsService): """系统使用统计服务实现。""" + def __init__(self, TenantResolverService: TenantResolver | None = None) -> None: + self.TenantResolver = TenantResolverService or TenantResolver() + async def RecordLoginEvent( self, *, @@ -51,7 +56,16 @@ class UsageStatsServiceImpl(IUsageStatsService): ou_id = str((UserInfo or {}).get("ou_id") or "") or None ou_name = str((UserInfo or {}).get("ou_name") or "") or None area = str((UserInfo or {}).get("area") or "") or None + tenant_code = str((UserInfo or {}).get("tenant_code") or "") or None + tenant_name = str((UserInfo or {}).get("tenant_name") or "") or None client_type = self._detect_client_type(UserAgent) + resolved_tenant = await self.TenantResolver.ResolveUserContext( + Area=area, + TenantCode=tenant_code, + TenantName=tenant_name, + Source="usage_login_event", + ) + normalized_failure_reason = self._normalize_failure_reason(FailureReason) await session.execute( text( @@ -59,12 +73,14 @@ class UsageStatsServiceImpl(IUsageStatsService): INSERT INTO usage_login_events ( user_id, sub, username_snapshot, nick_name_snapshot, department_name_snapshot, ou_id_snapshot, ou_name_snapshot, + tenant_code_snapshot, tenant_name_snapshot, area_snapshot, login_time, login_result, login_type, ip_address, user_agent, client_type, token_jti, failure_reason, extra, created_at, updated_at, deleted_at ) VALUES ( :user_id, :sub, :username_snapshot, :nick_name_snapshot, :department_name_snapshot, :ou_id_snapshot, :ou_name_snapshot, + :tenant_code_snapshot, :tenant_name_snapshot, :area_snapshot, NOW(), :login_result, :login_type, :ip_address, :user_agent, :client_type, :token_jti, :failure_reason, CAST(:extra AS jsonb), NOW(), NOW(), NULL @@ -79,14 +95,16 @@ class UsageStatsServiceImpl(IUsageStatsService): "department_name_snapshot": dep_name, "ou_id_snapshot": ou_id, "ou_name_snapshot": ou_name, - "area_snapshot": area, + "tenant_code_snapshot": resolved_tenant.tenant_code or tenant_code, + "tenant_name_snapshot": resolved_tenant.tenant_name or tenant_name or area, + "area_snapshot": resolved_tenant.tenant_name or area, "login_result": LoginResult, "login_type": LoginType, "ip_address": IpAddress, "user_agent": UserAgent, "client_type": client_type, "token_jti": TokenJti, - "failure_reason": FailureReason, + "failure_reason": normalized_failure_reason, "extra": "{}", }, ) @@ -288,88 +306,115 @@ class UsageStatsServiceImpl(IUsageStatsService): context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) page, page_size, offset = self._pagination(Filters) - area_condition, params = self._build_user_scope_condition(context, Filters, user_alias="u") - if Filters.get("keyword"): - params["keyword"] = f"%{str(Filters['keyword']).strip()}%" - area_condition += " AND (u.username ILIKE :keyword OR u.nick_name ILIKE :keyword)" - if Filters.get("departmentName"): - params["department_name"] = str(Filters["departmentName"]).strip() - area_condition += " AND COALESCE(u.dep_name, '') = :department_name" - if Filters.get("userId") is not None: - params["requested_user_id"] = int(Filters["userId"]) - area_condition += " AND u.id = :requested_user_id" - - login_date_clause, login_date_params = self._build_range_clause("e.login_time", Filters, prefix="login") - upload_date_clause, upload_date_params = self._build_range_clause("f.created_at", Filters, prefix="upload") - audit_date_clause, audit_date_params = self._build_range_clause("COALESCE(ar.started_at, ar.created_at)", Filters, prefix="audit") - - query = text( - f""" - WITH base_users AS ( - SELECT u.id, u.username, COALESCE(u.nick_name, '') AS nick_name, - COALESCE(u.dep_name, '') AS department_name, - COALESCE(u.area, '') AS area, - u.last_login_at - FROM sso_users u - WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition} - ), - login_stats AS ( - SELECT e.user_id, - COUNT(*)::int AS login_count, - MAX(e.login_time) AS last_login_time - FROM usage_login_events e - WHERE e.login_result = 'success' AND e.user_id IS NOT NULL {login_date_clause} - GROUP BY e.user_id - ), - upload_stats AS ( - SELECT f.created_by AS user_id, - COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, - COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count - FROM leaudit_document_files f - JOIN leaudit_documents d ON d.id = f.document_id - LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id - LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id - WHERE f.created_by IS NOT NULL AND f.deleted_at IS NULL {upload_date_clause} - GROUP BY f.created_by - ), - audit_stats AS ( - SELECT ar.trigger_user_id AS user_id, - COUNT(*)::int AS audit_run_count, - COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, - COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count - FROM leaudit_audit_runs ar - JOIN leaudit_documents d ON d.id = ar.document_id - LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id - LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id - WHERE ar.trigger_user_id IS NOT NULL {audit_date_clause} - GROUP BY ar.trigger_user_id - ) - SELECT - b.id AS user_id, - b.username, - b.nick_name, - b.department_name, - b.area, - COALESCE(ls.login_count, 0) AS login_count, - COALESCE(us.upload_document_count, 0) AS upload_document_count, - COALESCE(us.upload_attachment_count, 0) AS upload_attachment_count, - COALESCE(aus.audit_run_count, 0) AS audit_run_count, - COALESCE(aus.audit_completed_count, 0) AS audit_completed_count, - COALESCE(aus.audit_failed_count, 0) AS audit_failed_count, - COALESCE(ls.last_login_time, b.last_login_at) AS last_login_time - FROM base_users b - LEFT JOIN login_stats ls ON ls.user_id = b.id - LEFT JOIN upload_stats us ON us.user_id = b.id - LEFT JOIN audit_stats aus ON aus.user_id = b.id - ORDER BY b.id DESC - LIMIT :limit OFFSET :offset - """ - ) - count_query = text(f"SELECT COUNT(*)::int AS total FROM sso_users u WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition}") - async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) - merged_params = {**params, **login_date_params, **upload_date_params, **audit_date_params, "limit": page_size, "offset": offset} + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) + area_condition, params = self._build_user_scope_condition(context, Filters, user_alias="u") + if Filters.get("keyword"): + params["keyword"] = f"%{str(Filters['keyword']).strip()}%" + area_condition += " AND (u.username ILIKE :keyword OR u.nick_name ILIKE :keyword)" + if Filters.get("departmentName"): + params["department_name"] = str(Filters["departmentName"]).strip() + area_condition += " AND COALESCE(u.dep_name, '') = :department_name" + if Filters.get("userId") is not None: + params["requested_user_id"] = int(Filters["userId"]) + area_condition += " AND u.id = :requested_user_id" + + login_where, login_params = self._build_login_filters(context, Filters) + upload_where, upload_params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") + audit_where, audit_params = self._build_audit_filters(context, Filters) + + query = text( + f""" + WITH base_users AS ( + SELECT u.id, u.username, COALESCE(u.nick_name, '') AS nick_name, + COALESCE(u.dep_name, '') AS department_name, + COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, + u.last_login_at + FROM sso_users u + WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition} + ), + login_stats AS ( + SELECT e.user_id, + COUNT(*)::int AS login_count, + MAX(e.login_time) AS last_login_time + FROM usage_login_events e + WHERE {login_where} AND e.login_result = 'success' AND e.user_id IS NOT NULL + GROUP BY e.user_id + ), + upload_stats AS ( + SELECT f.created_by AS user_id, + COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, + COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count + FROM leaudit_document_files f + JOIN leaudit_documents d ON d.id = f.document_id + LEFT JOIN sso_users u ON u.id = f.created_by + LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id + LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id + WHERE {upload_where} AND f.created_by IS NOT NULL + GROUP BY f.created_by + ), + audit_stats AS ( + SELECT ar.trigger_user_id AS user_id, + COUNT(*)::int AS audit_run_count, + COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, + COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count + FROM leaudit_audit_runs ar + JOIN leaudit_documents d ON d.id = ar.document_id + LEFT JOIN sso_users u ON u.id = ar.trigger_user_id + LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id + LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id + WHERE {audit_where} AND ar.trigger_user_id IS NOT NULL + GROUP BY ar.trigger_user_id + ) + SELECT + b.id AS user_id, + b.username, + b.nick_name, + b.department_name, + b.area, + b.tenant_code, + b.tenant_name, + COALESCE(ls.login_count, 0) AS login_count, + COALESCE(us.upload_document_count, 0) AS upload_document_count, + COALESCE(us.upload_attachment_count, 0) AS upload_attachment_count, + COALESCE(aus.audit_run_count, 0) AS audit_run_count, + COALESCE(aus.audit_completed_count, 0) AS audit_completed_count, + COALESCE(aus.audit_failed_count, 0) AS audit_failed_count, + COALESCE(ls.last_login_time, b.last_login_at) AS last_login_time + FROM base_users b + LEFT JOIN login_stats ls ON ls.user_id = b.id + LEFT JOIN upload_stats us ON us.user_id = b.id + LEFT JOIN audit_stats aus ON aus.user_id = b.id + ORDER BY b.id DESC + LIMIT :limit OFFSET :offset + """ + ) + count_query = text( + f"SELECT COUNT(*)::int AS total FROM sso_users u WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition}" + ) + merged_params = { + **params, + **login_params, + **upload_params, + **audit_params, + "limit": page_size, + "offset": offset, + } total = int((await session.execute(count_query, params)).scalar_one() or 0) rows = (await session.execute(query, merged_params)).mappings().all() items = [ @@ -379,6 +424,8 @@ class UsageStatsServiceImpl(IUsageStatsService): nickName=str(row["nick_name"] or ""), departmentName=row["department_name"] or None, area=row["area"] or None, + tenantCode=row.get("tenant_code") or None, + tenantName=row.get("tenant_name") or row.get("area") or None, loginCount=int(row["login_count"] or 0), uploadDocumentCount=int(row["upload_document_count"] or 0), uploadAttachmentCount=int(row["upload_attachment_count"] or 0), @@ -435,13 +482,36 @@ class UsageStatsServiceImpl(IUsageStatsService): async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) + sso_user_columns = await SsoUserCompat.get_columns(session) + user_tenant_code_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_code", + ) + user_tenant_name_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_name", + ) login_where, login_params = self._build_login_filters(context, Filters) if area_scope == "document": upload_area_expr = "COALESCE(d.region, '')" audit_area_expr = "COALESCE(d.region, '')" + upload_tenant_code_expr = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL)" + upload_tenant_name_expr = self._document_tenant_name_sql("d") + audit_tenant_code_expr = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL)" + audit_tenant_name_expr = self._document_tenant_name_sql("d") else: upload_area_expr = "COALESCE(u.area, '')" audit_area_expr = "COALESCE(u.area, '')" + upload_tenant_code_expr = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL)" + upload_tenant_name_expr = ( + f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM({upload_area_expr}), ''), '未分配地区')" + ) + audit_tenant_code_expr = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL)" + audit_tenant_name_expr = ( + f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM({audit_area_expr}), ''), '未分配地区')" + ) doc_where, doc_params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") audit_where, audit_params = self._build_audit_filters(context, Filters) @@ -450,11 +520,13 @@ class UsageStatsServiceImpl(IUsageStatsService): text( f""" SELECT COALESCE(e.area_snapshot, '未分配地区') AS area, + COALESCE(NULLIF(BTRIM(e.tenant_code_snapshot), ''), NULL) AS tenant_code, + COALESCE(NULLIF(BTRIM(e.tenant_name_snapshot), ''), COALESCE(e.area_snapshot, '未分配地区')) AS tenant_name, COUNT(*)::int AS login_count, COUNT(DISTINCT e.user_id)::int AS login_user_count FROM usage_login_events e WHERE {login_where} AND e.login_result = 'success' - GROUP BY 1 + GROUP BY 1, 2, 3 """ ), login_params, @@ -465,6 +537,8 @@ class UsageStatsServiceImpl(IUsageStatsService): text( f""" SELECT {upload_area_expr} AS area, + {upload_tenant_code_expr} AS tenant_code, + {upload_tenant_name_expr} AS tenant_name, COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count FROM leaudit_document_files f @@ -473,7 +547,7 @@ class UsageStatsServiceImpl(IUsageStatsService): LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {doc_where} - GROUP BY 1 + GROUP BY 1, 2, 3 """ ), doc_params, @@ -484,6 +558,8 @@ class UsageStatsServiceImpl(IUsageStatsService): text( f""" SELECT {audit_area_expr} AS area, + {audit_tenant_code_expr} AS tenant_code, + {audit_tenant_name_expr} AS tenant_name, COUNT(*)::int AS audit_run_count, COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count @@ -493,30 +569,45 @@ class UsageStatsServiceImpl(IUsageStatsService): LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {audit_where} - GROUP BY 1 + GROUP BY 1, 2, 3 """ ), audit_params, ) ).mappings().all() - grouped: dict[str, UsageStatsAreaItemVO] = {} + grouped: dict[tuple[str | None, str], UsageStatsAreaItemVO] = {} for row in login_rows: area = str(row["area"] or "未分配地区") - grouped.setdefault(area, UsageStatsAreaItemVO(area=area)) - grouped[area].loginCount += int(row["login_count"] or 0) - grouped[area].loginUserCount += int(row["login_user_count"] or 0) + tenant_code = row.get("tenant_code") or None + group_key = (tenant_code, area) + grouped.setdefault( + group_key, + UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), + ) + grouped[group_key].loginCount += int(row["login_count"] or 0) + grouped[group_key].loginUserCount += int(row["login_user_count"] or 0) for row in upload_rows: area = str(row["area"] or "未分配地区") - grouped.setdefault(area, UsageStatsAreaItemVO(area=area)) - grouped[area].uploadDocumentCount += int(row["upload_document_count"] or 0) - grouped[area].uploadAttachmentCount += int(row["upload_attachment_count"] or 0) + tenant_code = row.get("tenant_code") or None + group_key = (tenant_code, area) + grouped.setdefault( + group_key, + UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), + ) + grouped[group_key].uploadDocumentCount += int(row["upload_document_count"] or 0) + grouped[group_key].uploadAttachmentCount += int(row["upload_attachment_count"] or 0) for row in audit_rows: area = str(row["area"] or "未分配地区") - grouped.setdefault(area, UsageStatsAreaItemVO(area=area)) - grouped[area].auditRunCount += int(row["audit_run_count"] or 0) - grouped[area].auditCompletedCount += int(row["audit_completed_count"] or 0) - grouped[area].auditFailedCount += int(row["audit_failed_count"] or 0) + tenant_code = row.get("tenant_code") or None + group_key = (tenant_code, area) + grouped.setdefault( + group_key, + UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), + ) + grouped[group_key].auditRunCount += int(row["audit_run_count"] or 0) + grouped[group_key].auditCompletedCount += int(row["audit_completed_count"] or 0) + grouped[group_key].auditFailedCount += int(row["audit_failed_count"] or 0) items = list(grouped.values()) items.sort(key=lambda item: (item.auditRunCount, item.uploadDocumentCount, item.loginCount), reverse=True) total = len(items) @@ -529,16 +620,40 @@ class UsageStatsServiceImpl(IUsageStatsService): data_type = str(Filters.get("dataType") or "audit").strip().lower() if data_type not in {"login", "upload", "audit"}: data_type = "audit" + area_scope = str(Filters.get("areaScope") or "user").strip().lower() + if area_scope not in {"user", "document"}: + area_scope = "user" async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) + sso_user_columns = await SsoUserCompat.get_columns(session) + user_tenant_code_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_code", + ) + user_tenant_name_expr = SsoUserCompat.raw_optional_column( + sso_user_columns, + alias="u", + column="tenant_name", + ) + if area_scope == "document": + detail_area_expr = "COALESCE(d.region, '')" + detail_tenant_code_select = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code" + detail_tenant_name_select = f"{self._document_tenant_name_sql('d')} AS tenant_name" + else: + detail_area_expr = "COALESCE(u.area, '')" + detail_tenant_code_select = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL) AS tenant_code" + detail_tenant_name_select = ( + f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM(COALESCE(u.area, '')), ''), NULL) AS tenant_name" + ) if data_type == "login": where_clause, params = self._build_login_filters(context, Filters) count_sql = text(f"SELECT COUNT(*)::int FROM usage_login_events e WHERE {where_clause}") list_sql = text( f""" SELECT e.login_time, e.user_id, e.username_snapshot, e.nick_name_snapshot, - e.department_name_snapshot, e.area_snapshot, e.login_result, + e.department_name_snapshot, e.area_snapshot, e.tenant_code_snapshot, e.tenant_name_snapshot, e.login_result, e.failure_reason, e.login_type FROM usage_login_events e WHERE {where_clause} @@ -557,6 +672,8 @@ class UsageStatsServiceImpl(IUsageStatsService): nickName=str(row.get("nick_name_snapshot") or ""), departmentName=row.get("department_name_snapshot") or None, area=row.get("area_snapshot") or None, + tenantCode=row.get("tenant_code_snapshot") or None, + tenantName=row.get("tenant_name_snapshot") or row.get("area_snapshot") or None, status=str(row.get("login_result") or ""), extra={"failureReason": row.get("failure_reason"), "loginType": row.get("login_type")}, ) @@ -579,7 +696,8 @@ class UsageStatsServiceImpl(IUsageStatsService): f""" SELECT f.created_at, f.created_by, COALESCE(u.username, '') AS username, COALESCE(u.nick_name, '') AS nick_name, COALESCE(u.dep_name, '') AS department_name, - COALESCE(u.area, '') AS area, d.id AS document_id, f.file_name, + {detail_area_expr} AS area, {detail_tenant_code_select}, + {detail_tenant_name_select}, d.id AS document_id, f.file_name, dt.id AS document_type_id, dt.name AS document_type_name, em.id AS entry_module_id, em.name AS entry_module_name, f.file_role @@ -604,6 +722,8 @@ class UsageStatsServiceImpl(IUsageStatsService): nickName=str(row.get("nick_name") or ""), departmentName=row.get("department_name") or None, area=row.get("area") or None, + tenantCode=row.get("tenant_code") or None, + tenantName=row.get("tenant_name") or row.get("area") or None, documentId=self._to_int(row.get("document_id")), documentName=row.get("file_name"), documentTypeId=self._to_int(row.get("document_type_id")), @@ -633,7 +753,8 @@ class UsageStatsServiceImpl(IUsageStatsService): SELECT COALESCE(ar.started_at, ar.created_at) AS event_time, ar.trigger_user_id, COALESCE(u.username, '') AS username, COALESCE(u.nick_name, '') AS nick_name, COALESCE(u.dep_name, '') AS department_name, - COALESCE(u.area, '') AS area, d.id AS document_id, + {detail_area_expr} AS area, {detail_tenant_code_select}, + {detail_tenant_name_select}, d.id AS document_id, COALESCE(f.file_name, d.normalized_name, '') AS document_name, dt.id AS document_type_id, dt.name AS document_type_name, em.id AS entry_module_id, em.name AS entry_module_name, @@ -660,6 +781,8 @@ class UsageStatsServiceImpl(IUsageStatsService): nickName=str(row.get("nick_name") or ""), departmentName=row.get("department_name") or None, area=row.get("area") or None, + tenantCode=row.get("tenant_code") or None, + tenantName=row.get("tenant_name") or row.get("area") or None, documentId=self._to_int(row.get("document_id")), documentName=row.get("document_name"), documentTypeId=self._to_int(row.get("document_type_id")), @@ -687,6 +810,8 @@ class UsageStatsServiceImpl(IUsageStatsService): department_name_snapshot VARCHAR(255) NULL, ou_id_snapshot VARCHAR(128) NULL, ou_name_snapshot VARCHAR(255) NULL, + tenant_code_snapshot VARCHAR(64) NULL, + tenant_name_snapshot VARCHAR(128) NULL, area_snapshot VARCHAR(64) NULL, login_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), login_result VARCHAR(16) NOT NULL, @@ -704,20 +829,38 @@ class UsageStatsServiceImpl(IUsageStatsService): """ ) ) + await session.execute(text("ALTER TABLE usage_login_events ADD COLUMN IF NOT EXISTS tenant_code_snapshot VARCHAR(64) NULL")) + await session.execute(text("ALTER TABLE usage_login_events ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(128) NULL")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_login_time ON usage_login_events(login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_user_id ON usage_login_events(user_id, login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_department ON usage_login_events(department_name_snapshot, login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_area ON usage_login_events(area_snapshot, login_time DESC)")) + await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_tenant_code ON usage_login_events(tenant_code_snapshot, login_time DESC)")) async def _get_current_user_context(self, current_user_id: int) -> dict[str, Any]: async with GetAsyncSession() as session: + sso_user_columns = await SsoUserCompat.get_columns(session) + tenant_code_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_code", + fallback_sql="''", + ) + tenant_name_select = SsoUserCompat.optional_coalesce_as( + sso_user_columns, + alias="u", + column="tenant_name", + fallback_sql="''", + ) row = ( await session.execute( text( - """ + f""" SELECT u.id, COALESCE(u.area, '') AS area, + {tenant_code_select}, + {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin @@ -733,7 +876,21 @@ class UsageStatsServiceImpl(IUsageStatsService): ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") - return {"area": str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_super_admin": bool(row["is_super_admin"])} + tenant = await self.TenantResolver.ResolveUserContext( + Area=str(row["area"] or ""), + TenantCode=str(row["tenant_code"] or "") or None, + TenantName=str(row["tenant_name"] or "") or None, + Source="usage_stats_user_context", + ) + return { + "area": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "tenant_code": tenant.tenant_code or str(row["tenant_code"] or "") or None, + "tenant_name": tenant.tenant_name or str(row["tenant_name"] or "") or str(row["area"] or "") or None, + "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), + "is_global": bool(row["is_global"]), + "can_manage": bool(row["can_manage"]), + "is_super_admin": bool(row["is_super_admin"]), + } def _assert_stats_access(self, context: dict[str, Any]) -> None: if context["is_global"] or context["can_manage"]: @@ -744,25 +901,60 @@ class UsageStatsServiceImpl(IUsageStatsService): conditions = ["1 = 1"] params: dict[str, Any] = {} requested_area = str(filters.get("area") or "").strip() + requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() + requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if context["is_global"]: - if requested_area: + if requested_tenant_code: + conditions.extend(self._user_tenant_filter_sql(user_alias, params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name: conditions.append(f"COALESCE({user_alias}.area, '') = :requested_area") - params["requested_area"] = requested_area + params["requested_area"] = requested_tenant_name else: - if not context["area"]: + scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() + scope_tenant_code = str(context.get("tenant_code") or "").strip() + if not scope_value and not scope_tenant_code: conditions.append("1 = 0") - elif requested_area and requested_area != context["area"]: + elif requested_tenant_code: + if scope_tenant_code and requested_tenant_code != scope_tenant_code: + conditions.append("1 = 0") + elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: + conditions.append("1 = 0") + else: + conditions.extend(self._user_tenant_filter_sql(user_alias, params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: - conditions.append(f"COALESCE({user_alias}.area, '') = :scope_area") - params["scope_area"] = context["area"] + conditions.extend(self._user_tenant_filter_sql(user_alias, params, scope_tenant_code or None, scope_value or None, "scope")) return " AND ".join(conditions), params def _build_login_filters(self, context: dict[str, Any], filters: dict[str, Any]) -> tuple[str, dict[str, Any]]: conditions = ["e.deleted_at IS NULL"] - scope_cond, params = self._build_user_scope_condition(context, filters, user_alias="e") - scope_cond = scope_cond.replace("e.area", "e.area_snapshot") - conditions.append(scope_cond) + params: dict[str, Any] = {} + requested_area = str(filters.get("area") or "").strip() + requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() + requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) + scope_tenant_code = str(context.get("tenant_code") or "").strip() + scope_tenant_name = str(context.get("tenant_scope_value") or context.get("area") or "").strip() + if context["is_global"]: + if requested_tenant_code: + conditions.extend(self._login_tenant_filter_sql(params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name: + conditions.append("COALESCE(e.area_snapshot, '') = :requested_area") + params["requested_area"] = requested_tenant_name + else: + if not scope_tenant_code and not scope_tenant_name: + conditions.append("1 = 0") + elif requested_tenant_code: + if scope_tenant_code and requested_tenant_code != scope_tenant_code: + conditions.append("1 = 0") + elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_tenant_name: + conditions.append("1 = 0") + else: + conditions.extend(self._login_tenant_filter_sql(params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name and requested_tenant_name != scope_tenant_name: + conditions.append("1 = 0") + else: + conditions.extend(self._login_tenant_filter_sql(params, scope_tenant_code or None, scope_tenant_name or None, "scope")) if filters.get("userId") is not None: conditions.append("e.user_id = :user_id") params["user_id"] = int(filters["userId"]) @@ -782,19 +974,31 @@ class UsageStatsServiceImpl(IUsageStatsService): params: dict[str, Any] = {} area_scope = str(filters.get("areaScope") or "user").strip().lower() requested_area = str(filters.get("area") or "").strip() + requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() + requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if area_scope == "document": if context["is_global"]: - if requested_area: + if requested_tenant_code: + conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name: conditions.append(f"COALESCE({alias_prefix}.region, '') = :requested_area") - params["requested_area"] = requested_area + params["requested_area"] = requested_tenant_name else: - if not context["area"]: + scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() + scope_tenant_code = str(context.get("tenant_code") or "").strip() + if not scope_value and not scope_tenant_code: conditions.append("1 = 0") - elif requested_area and requested_area != context["area"]: + elif requested_tenant_code: + if scope_tenant_code and requested_tenant_code != scope_tenant_code: + conditions.append("1 = 0") + elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: + conditions.append("1 = 0") + else: + conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: - conditions.append(f"COALESCE({alias_prefix}.region, '') = :scope_area") - params["scope_area"] = context["area"] + conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, scope_tenant_code or None, scope_value or None, "scope")) else: user_scope_cond, user_scope_params = self._build_user_scope_condition(context, filters, user_alias=user_alias) conditions.append(user_scope_cond) @@ -825,19 +1029,31 @@ class UsageStatsServiceImpl(IUsageStatsService): params: dict[str, Any] = {} area_scope = str(filters.get("areaScope") or "user").strip().lower() requested_area = str(filters.get("area") or "").strip() + requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() + requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if area_scope == "document": if context["is_global"]: - if requested_area: + if requested_tenant_code: + conditions.extend(self._document_tenant_filter_sql("d", params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name: conditions.append("COALESCE(d.region, '') = :requested_area") - params["requested_area"] = requested_area + params["requested_area"] = requested_tenant_name else: - if not context["area"]: + scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() + scope_tenant_code = str(context.get("tenant_code") or "").strip() + if not scope_value and not scope_tenant_code: conditions.append("1 = 0") - elif requested_area and requested_area != context["area"]: + elif requested_tenant_code: + if scope_tenant_code and requested_tenant_code != scope_tenant_code: + conditions.append("1 = 0") + elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: + conditions.append("1 = 0") + else: + conditions.extend(self._document_tenant_filter_sql("d", params, requested_tenant_code, requested_tenant_name, "requested")) + elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: - conditions.append("COALESCE(d.region, '') = :scope_area") - params["scope_area"] = context["area"] + conditions.extend(self._document_tenant_filter_sql("d", params, scope_tenant_code or None, scope_value or None, "scope")) else: user_scope_cond, user_scope_params = self._build_user_scope_condition(context, filters, user_alias="u") conditions.append(user_scope_cond) @@ -874,6 +1090,85 @@ class UsageStatsServiceImpl(IUsageStatsService): params[f"{prefix}_date_to"] = self._normalize_date(filters["dateTo"]) return "".join(clauses), params + def _resolve_requested_tenant_name( + self, + requested_tenant_code: str | None, + requested_area: str | None, + context: dict[str, Any], + ) -> str: + tenant_code = str(requested_tenant_code or "").strip() + if tenant_code: + if tenant_code == str(context.get("tenant_code") or "").strip(): + return str(context.get("tenant_scope_value") or context.get("tenant_name") or context.get("area") or "").strip() + if tenant_code == "PUBLIC": + return "公共" + if tenant_code == "PROVINCIAL": + return "省级" + return str(requested_area or "").strip() + + @staticmethod + def _document_tenant_name_sql(document_alias: str) -> str: + return ( + "CASE " + f"WHEN NULLIF(BTRIM({document_alias}.tenant_code), '') = 'PUBLIC' THEN '公共' " + f"WHEN NULLIF(BTRIM({document_alias}.tenant_code), '') = 'PROVINCIAL' THEN '省级' " + f"ELSE COALESCE(NULLIF(BTRIM({document_alias}.region), ''), '未分配地区') " + "END" + ) + + def _user_tenant_filter_sql( + self, + user_alias: str, + params: dict[str, Any], + tenant_code: str | None, + tenant_name: str | None, + prefix: str, + ) -> list[str]: + normalized_tenant_code = str(tenant_code or "").strip() + normalized_tenant_name = str(tenant_name or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + return [f"{user_alias}.tenant_code = :{prefix}_tenant_code"] + if normalized_tenant_name: + params[f"{prefix}_tenant_name"] = normalized_tenant_name + return [f"COALESCE({user_alias}.area, '') = :{prefix}_tenant_name"] + return ["1 = 0"] + + def _login_tenant_filter_sql( + self, + params: dict[str, Any], + tenant_code: str | None, + tenant_name: str | None, + prefix: str, + ) -> list[str]: + normalized_tenant_code = str(tenant_code or "").strip() + normalized_tenant_name = str(tenant_name or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + return [f"e.tenant_code_snapshot = :{prefix}_tenant_code"] + if normalized_tenant_name: + params[f"{prefix}_tenant_name"] = normalized_tenant_name + return [f"COALESCE(e.area_snapshot, '') = :{prefix}_tenant_name"] + return ["1 = 0"] + + def _document_tenant_filter_sql( + self, + alias_prefix: str, + params: dict[str, Any], + tenant_code: str | None, + tenant_name: str | None, + prefix: str, + ) -> list[str]: + normalized_tenant_code = str(tenant_code or "").strip() + normalized_tenant_name = str(tenant_name or "").strip() + if normalized_tenant_code: + params[f"{prefix}_tenant_code"] = normalized_tenant_code + return [f"{alias_prefix}.tenant_code = :{prefix}_tenant_code"] + if normalized_tenant_name: + params[f"{prefix}_tenant_name"] = normalized_tenant_name + return [f"COALESCE({alias_prefix}.region, '') = :{prefix}_tenant_name"] + return ["1 = 0"] + @staticmethod def _normalize_date(value: Any): from datetime import date as date_type @@ -890,6 +1185,20 @@ class UsageStatsServiceImpl(IUsageStatsService): return "pc" return "other" + @staticmethod + def _normalize_failure_reason(value: str | None, limit: int = 255) -> str | None: + """裁剪登录失败原因,避免审计表二次写入失败。""" + if value is None: + return None + normalized = " ".join(str(value).split()).strip() + if not normalized: + return None + if len(normalized) <= limit: + return normalized + if limit <= 3: + return normalized[:limit] + return f"{normalized[: limit - 3]}..." + @staticmethod def _to_int(value: Any) -> int | None: try: diff --git a/fastapi_modules/fastapi_leaudit/services/pageQualityService.py b/fastapi_modules/fastapi_leaudit/services/pageQualityService.py new file mode 100644 index 0000000..83030e4 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/pageQualityService.py @@ -0,0 +1,54 @@ +"""页级图片质量服务接口。""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import ( + PageQualityDetailVO, + PageQualityRecheckVO, + PageQualitySummaryVO, +) + + +class IPageQualityService(ABC): + """页级图片质量服务接口。""" + + @abstractmethod + async def DispatchForDocument( + self, + DocumentId: int, + TriggerUserId: int | None = None, + Force: bool = False, + Speed: str = "normal", + ) -> PageQualityRecheckVO | None: + """按文档触发页级模糊检测任务。""" + ... + + @abstractmethod + async def GetDocumentSummary( + self, + CurrentUserId: int, + DocumentId: int, + ) -> PageQualitySummaryVO: + """获取文档页级模糊检测摘要。""" + ... + + @abstractmethod + async def GetDocumentDetail( + self, + CurrentUserId: int, + DocumentId: int, + ) -> PageQualityDetailVO: + """获取文档页级模糊检测详情。""" + ... + + @abstractmethod + async def RecheckDocument( + self, + CurrentUserId: int, + DocumentId: int, + Speed: str = "normal", + ) -> PageQualityRecheckVO: + """手工重跑页级模糊检测。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/ragChatService.py b/fastapi_modules/fastapi_leaudit/services/ragChatService.py index 19fd995..747930f 100644 --- a/fastapi_modules/fastapi_leaudit/services/ragChatService.py +++ b/fastapi_modules/fastapi_leaudit/services/ragChatService.py @@ -21,10 +21,24 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import ( class IRagChatService(ABC): @abstractmethod - async def GetApps(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppListVO: ... + async def GetApps( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagChatAppListVO: ... @abstractmethod - async def GetDefaultApp(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppVO | None: ... + async def GetDefaultApp( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagChatAppVO | None: ... @abstractmethod async def SendMessage( @@ -36,25 +50,82 @@ class IRagChatService(ABC): Query: str, ConversationId: str | None, AppId: int | None, + TenantCode: str | None = None, + TenantName: str | None = None, ) -> AsyncGenerator[bytes, None]: ... @abstractmethod - async def GetConversations(self, CurrentUserId: int, AppId: int | None, Page: int, PageSize: int) -> RagConversationPageVO: ... + async def GetConversations( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + AppId: int | None, + Page: int, + PageSize: int, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagConversationPageVO: ... @abstractmethod - async def GetConversationMessages(self, CurrentUserId: int, ConversationId: str, Page: int, PageSize: int) -> RagMessagePageVO: ... + async def GetConversationMessages( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + Page: int, + PageSize: int, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagMessagePageVO: ... @abstractmethod - async def RenameConversation(self, CurrentUserId: int, ConversationId: str, Body: RagConversationRenameDTO) -> RagConversationRenameVO: ... + async def RenameConversation( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + Body: RagConversationRenameDTO, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagConversationRenameVO: ... @abstractmethod - async def DeleteConversation(self, CurrentUserId: int, ConversationId: str) -> RagOperationResultVO: ... + async def DeleteConversation( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + ConversationId: str, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: ... @abstractmethod - async def UpdateFeedback(self, CurrentUserId: int, MessageId: str, Body: RagMessageFeedbackDTO) -> RagOperationResultVO: ... + async def UpdateFeedback( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + MessageId: str, + Body: RagMessageFeedbackDTO, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: ... @abstractmethod - async def StopMessage(self, CurrentUserId: int, MessageId: str, Body: RagStopMessageDTO | None = None) -> RagOperationResultVO: ... + async def StopMessage( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + MessageId: str, + Body: RagStopMessageDTO | None = None, + TenantCode: str | None = None, + TenantName: str | None = None, + ) -> RagOperationResultVO: ... @abstractmethod async def GetAppParameters( @@ -63,4 +134,6 @@ class IRagChatService(ABC): UserArea: str | None, UserRole: str | None, AppId: int | None, + TenantCode: str | None = None, + TenantName: str | None = None, ) -> RagAppParametersVO: ... diff --git a/fastapi_modules/fastapi_leaudit/services/ragDatasetService.py b/fastapi_modules/fastapi_leaudit/services/ragDatasetService.py index 451b580..3e808ef 100644 --- a/fastapi_modules/fastapi_leaudit/services/ragDatasetService.py +++ b/fastapi_modules/fastapi_leaudit/services/ragDatasetService.py @@ -23,7 +23,10 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, Area: str | None, + TenantFilterCode: str | None, OnlyEnabled: bool | None, Page: int, PageSize: int, @@ -35,6 +38,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, Body: dict, ) -> RagDatasetDetailVO: ... @@ -44,6 +49,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Body: dict, ) -> RagDatasetDetailVO | None: ... @@ -54,17 +61,43 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, ) -> RagOperationResultVO: ... @abstractmethod - async def GetMyDatasets(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagDatasetPageVO: ... + async def GetMyDatasets( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + ) -> RagDatasetPageVO: ... @abstractmethod - async def GetDatasetDetail(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, DatasetId: int) -> RagDatasetDetailVO | None: ... + async def GetDatasetDetail( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + DatasetId: int, + ) -> RagDatasetDetailVO | None: ... @abstractmethod - async def UpdateDataset(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None, DatasetId: int, Body: RagDatasetUpdateDTO) -> RagDatasetDetailVO | None: ... + async def UpdateDataset( + self, + CurrentUserId: int, + UserArea: str | None, + UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, + DatasetId: int, + Body: RagDatasetUpdateDTO, + ) -> RagDatasetDetailVO | None: ... @abstractmethod async def GetDatasetDocuments( @@ -72,6 +105,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Page: int, Limit: int, @@ -84,6 +119,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> RagDatasetDocumentItemVO | None: ... @@ -94,6 +131,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, FileName: str, ContentType: str | None, @@ -107,6 +146,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, Page: int, @@ -120,6 +161,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> RagOperationResultVO: ... @@ -130,6 +173,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentIds: list[int], ) -> RagDatasetBatchDeleteResultVO: ... @@ -140,6 +185,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, Query: str, RetrievalModel: dict | None, @@ -151,6 +198,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, ) -> dict: ... @@ -161,6 +210,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, FileName: str, @@ -175,6 +226,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentIds: list[int], Enabled: bool, @@ -186,6 +239,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, @@ -197,6 +252,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, @@ -209,6 +266,8 @@ class IRagDatasetService(ABC): CurrentUserId: int, UserArea: str | None, UserRole: str | None, + TenantCode: str | None, + TenantName: str | None, DatasetId: int, DocumentId: int, SegmentId: str, diff --git a/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py index 73d5c05..6bd2ba6 100644 --- a/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py +++ b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py @@ -8,6 +8,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import ( RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, + UserTenantUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( OrganizationTreeVO, @@ -21,6 +22,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( RouteVO, UserListVO, UserRolesVO, + UserTenantUpdateVO, ) @@ -48,7 +50,15 @@ class IRbacAdminService(ABC): ... @abstractmethod - async def ListUsers(self, CurrentUserId: int, Page: int, PageSize: int, Area: str | None, NickName: str | None) -> UserListVO: + async def ListUsers( + self, + CurrentUserId: int, + Page: int, + PageSize: int, + Area: str | None, + TenantCode: str | None, + NickName: str | None, + ) -> UserListVO: """查询用户列表。""" ... @@ -58,7 +68,16 @@ class IRbacAdminService(ABC): ... @abstractmethod - async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO: + async def ListRoleUsers( + self, + CurrentUserId: int, + RoleId: int, + Page: int, + PageSize: int, + Area: str | None, + TenantCode: str | None, + UserName: str | None, + ) -> UserListVO: """查询指定角色下的用户列表。""" ... @@ -67,6 +86,11 @@ class IRbacAdminService(ABC): """为用户分配角色。""" ... + @abstractmethod + async def UpdateUserTenant(self, CurrentUserId: int, UserId: int, Body: UserTenantUpdateDTO) -> UserTenantUpdateVO: + """更新用户租户。""" + ... + @abstractmethod async def RevokeUserRole(self, CurrentUserId: int, UserId: int, RoleId: int) -> None: """移除用户角色。""" diff --git a/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py b/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py index a2734fb..cf0e0b9 100644 --- a/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py +++ b/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py @@ -9,17 +9,17 @@ class IRuleConfigService(ABC): """规则配置页聚合服务接口。""" @abstractmethod - async def ListPacks(self) -> list[RuleConfigPackVO]: + async def ListPacks(self, CurrentUserId: int | None = None) -> list[RuleConfigPackVO]: """列出规则配置页所需的全部 pack。""" ... @abstractmethod - async def ListPackSummaries(self) -> list[RuleConfigPackListVO]: + async def ListPackSummaries(self, CurrentUserId: int | None = None) -> list[RuleConfigPackListVO]: """列出规则列表页所需的轻量 pack。""" ... @abstractmethod - async def GetPack(self, PackId: int) -> RuleConfigPackVO: + async def GetPack(self, PackId: int, CurrentUserId: int | None = None) -> RuleConfigPackVO: """获取单个规则配置 pack。""" ... diff --git a/fastapi_modules/fastapi_leaudit/services/ruleService.py b/fastapi_modules/fastapi_leaudit/services/ruleService.py index 21e3bcd..235dbbc 100644 --- a/fastapi_modules/fastapi_leaudit/services/ruleService.py +++ b/fastapi_modules/fastapi_leaudit/services/ruleService.py @@ -15,17 +15,17 @@ class IRuleService(ABC): """规则服务接口。""" @abstractmethod - async def ListSets(self) -> list[RuleSetVO]: + async def ListSets(self, CurrentUserId: int | None = None) -> list[RuleSetVO]: """列出所有规则集。""" ... @abstractmethod - async def GetVersions(self, RuleType: str) -> list[RuleVersionVO]: + async def GetVersions(self, RuleType: str, CurrentUserId: int | None = None) -> list[RuleVersionVO]: """获取规则集的所有版本。""" ... @abstractmethod - async def GetContent(self, VersionId: int) -> RuleContentVO: + async def GetContent(self, VersionId: int, CurrentUserId: int | None = None) -> RuleContentVO: """获取指定版本的规则正文。""" ... @@ -41,6 +41,7 @@ class IRuleService(ABC): YamlText: str, ChangeNote: str | None = None, EditorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """创建规则版本。""" ... @@ -51,6 +52,7 @@ class IRuleService(ABC): RuleType: str, VersionId: int, OperatorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """发布指定版本。""" ... @@ -61,13 +63,19 @@ class IRuleService(ABC): RuleType: str, VersionId: int, OperatorUserId: int | None = None, + CurrentUserId: int | None = None, ) -> RuleVersionVO: """回滚到指定历史版本。""" ... @abstractmethod - async def ListBindings(self, RuleType: str | None = None, Region: str | None = None) -> list[RuleBindingVO]: - """列出规则类型绑定。可按规则类型/地区过滤。""" + async def ListBindings( + self, + RuleType: str | None = None, + Region: str | None = None, + CurrentUserId: int | None = None, + ) -> list[RuleBindingVO]: + """列出规则类型绑定;Region 仅保留兼容入参,不再作为新链路边界。""" ... @abstractmethod @@ -75,13 +83,14 @@ class IRuleService(ABC): self, DocTypeId: int, RuleSetId: int, - Region: str = "default", + Region: str = "公共", BindingMode: str = "explicit", Priority: int = 0, DocTypeCode: str | None = None, Note: str | None = None, + CurrentUserId: int | None = None, ) -> RuleBindingVO: - """创建规则类型绑定。""" + """创建规则类型绑定;新链路按二级分组生效,Region 仅兼容保留。""" ... @abstractmethod @@ -92,11 +101,12 @@ class IRuleService(ABC): Priority: int | None = None, BindingMode: str | None = None, Note: str | None = None, + CurrentUserId: int | None = None, ) -> RuleBindingVO: """更新规则类型绑定。""" ... @abstractmethod - async def DeleteBinding(self, BindingId: int) -> None: + async def DeleteBinding(self, BindingId: int, CurrentUserId: int | None = None) -> None: """删除规则类型绑定。""" ... diff --git a/fastapi_modules/fastapi_leaudit/services/tenantService.py b/fastapi_modules/fastapi_leaudit/services/tenantService.py new file mode 100644 index 0000000..fde5c77 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/tenantService.py @@ -0,0 +1,48 @@ +"""租户主数据服务接口。""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from fastapi_modules.fastapi_leaudit.domian.Dto.tenantDto import ( + TenantCreateDTO, + TenantStatusUpdateDTO, + TenantUpdateDTO, +) + + +class ITenantService(ABC): + """租户主数据服务接口。""" + + @abstractmethod + async def ListTenants(self, IncludeDisabled: bool = False) -> list[dict[str, Any]]: + """返回租户完整列表。""" + + @abstractmethod + async def ListTenantOptions(self, FeatureKey: str | None = None) -> list[dict[str, Any]]: + """返回前端下拉所需精简租户列表。""" + + @abstractmethod + async def GetTenant(self, TenantCode: str) -> dict[str, Any] | None: + """返回单个租户详情。""" + + @abstractmethod + async def GetTenantFeatures(self, TenantCode: str) -> list[str]: + """返回单个租户启用的功能列表。""" + + @abstractmethod + async def GetTenantAliases(self, TenantCode: str) -> list[str]: + """返回单个租户别名列表。""" + + @abstractmethod + async def CreateTenant(self, CurrentUserId: int, Body: TenantCreateDTO) -> dict[str, Any]: + """创建租户。""" + + @abstractmethod + async def UpdateTenant(self, CurrentUserId: int, TenantCode: str, Body: TenantUpdateDTO) -> dict[str, Any]: + """更新租户。""" + + @abstractmethod + async def UpdateTenantStatus(self, CurrentUserId: int, TenantCode: str, Body: TenantStatusUpdateDTO) -> dict[str, Any]: + """更新租户启停状态。""" diff --git a/leaudit-oss-yaml-files/contract.construction.general/1.2/rules.yaml b/leaudit-oss-yaml-files/contract.construction.general/1.2/rules.yaml new file mode 100644 index 0000000..1f945ac --- /dev/null +++ b/leaudit-oss-yaml-files/contract.construction.general/1.2/rules.yaml @@ -0,0 +1,543 @@ +metadata: + type_id: contract.construction.general + name: 建设工程合同 + version: '1.2' + last_updated: '2026-04-11' + parent: contract + inherits_from: + - base.common + - base.party_info + classification_keywords: + - 建设工程 + - 工程承包 + - 施工合同 + - 总承包 + tags: + - compliance + - high_priority + - prc_civil_code + - construction_specific + - safety_critical + applies_to_jurisdictions: + - prc + references_laws: + - 《民法典》第 788-808 条(建设工程合同章) + - 《建筑法》第 25-41 条 + - 《建设工程质量管理条例》 + description: '建设工程施工合同评查规则。 + + 覆盖民法典第 788-808 条(建设工程合同章)。 + + 评查重点:资质核验、金额一致性、质量安全条款、竣工验收。 + + ' + confidence_profile: + allow_weight_override: false + field_confidence_defaults: + 合同金额: 0.95 + 投标价: 0.95 + 中标价: 0.95 +extract: +- group: 当事人 + fields: + - name: 发包人名称 + type: verbatim + required_from: draft + deep_retry: false + - name: 发包人统一信用代码 + type: uscc + required_from: executed + deep_retry: false + - name: 承包人名称 + type: verbatim + required_from: draft + deep_retry: false + - name: 承包人统一信用代码 + type: uscc + required_from: executed + deep_retry: false + - name: 承包人资质等级 + type: enum + required_from: draft + allowed: + - 特级 + - 一级 + - 二级 + - 三级 + deep_retry: false + - name: 承包人资质证书编号 + type: verbatim + required_from: executed + deep_retry: false +- group: 合同基本信息 + fields: + - name: 合同编号 + type: verbatim + required_from: draft + deep_retry: false + - name: 签订日期 + type: date + required_from: executed + deep_retry: false +- group: 项目信息 + fields: + - name: 工程名称 + type: verbatim + required_from: draft + deep_retry: false + - name: 工程地点 + type: verbatim + required_from: draft + deep_retry: false + - name: 工程规模 + type: string + required_from: draft + deep_retry: false + - name: 开工日期 + type: date + required_from: executed + deep_retry: false + - name: 竣工日期 + type: date + required_from: executed + deep_retry: false +- group: 金额(三处一致性检查的核心) + fields: + - name: 合同金额 + type: money + required_from: draft + deep_retry: false + - name: 投标价 + type: money + required_from: draft + deep_retry: false + - name: 中标价 + type: money + required_from: draft + deep_retry: false + - name: 合同金额大写 + type: verbatim + required_from: executed + deep_retry: false +- group: 招投标信息 + fields: + - name: 招标文件编号 + type: verbatim + required_from: draft + deep_retry: false + - name: 中标通知书编号 + type: verbatim + required_from: draft + deep_retry: false +- group: 质量条款 + fields: + - name: 质量标准 + type: string + required_from: draft + deep_retry: false + - name: 质量等级 + type: enum + required_from: draft + allowed: + - 合格 + - 优良 + deep_retry: false + - name: 保修期限 + type: string + required_from: draft + deep_retry: false +- group: 安全条款 + fields: + - name: 安全文明施工要求 + type: string + required_from: draft + deep_retry: false +- group: 工程款支付 + fields: + - name: 预付款比例 + type: money + required_from: draft + deep_retry: false + - name: 进度款支付方式 + type: string + required_from: draft + deep_retry: false + - name: 质保金比例 + type: money + required_from: draft + deep_retry: false +- group: 违约与争议 + fields: + - name: 违约责任 + type: string + required_from: draft + deep_retry: false + - name: 争议解决 + type: string + required_from: draft + deep_retry: false +derived_fields: +- name: 工期天数 + type: integer + compute: (竣工日期 - 开工日期).days + depends_on: + - 开工日期 + - 竣工日期 +- name: 质保金金额 + type: money + compute: 合同金额 * 质保金比例 + depends_on: + - 合同金额 + - 质保金比例 +visual_elements: + seals: + - id: 发包人公章 + name: 发包人公章或合同专用章 + required: true + required_from: executed + allowed_types: + - 公章 + - 合同专用章 + expected_text_match: + field: 发包人名称 + - id: 承包人公章 + name: 承包人公章或合同专用章 + required: true + required_from: executed + allowed_types: + - 公章 + - 合同专用章 + expected_text_match: + field: 承包人名称 + cross_page_seals: + - id: 骑缝章 + name: 建设工程合同骑缝章 + required: true + required_from: executed + expected_text_match: + field: 发包人名称 +rules: +- group: 基础检查 + rules: + - rule_id: GC-000 + name: 基础信息完整性 + risk: high + score: 10 + version: 1 + stages: + - id: '1' + check: required + fields: + - 发包人名称 + - 承包人名称 + - 工程名称 + - 合同金额 + logic: and + logic: '1' + messages: + pass: 基础信息完整 + fail: 缺少发包人/承包人/工程名称/合同金额 + type: deterministic +- group: 主体资质 + rules: + - rule_id: GC-001 + name: 承包人资质合法性 + risk: high + score: 15 + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: '1' + check: format + field: 承包人统一信用代码 + format: uscc + - id: '2' + check: required + field: 承包人资质证书编号 + - id: '3' + check: required + field: 承包人资质等级 + logic: 1 AND 2 AND 3 + messages: + pass: 承包人资质信息完整 + fail: 承包人资质信息不完整或 USCC 无效 + references_laws: + - 《建筑法》第 13 条(施工企业资质) + type: deterministic +- group: 金额合规 + rules: + - rule_id: GC-002 + name: 金额三处一致性 + risk: high + score: 20 + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: '1' + check: required + fields: + - 合同金额 + - 投标价 + - 中标价 + logic: and + - id: '2' + check: match + pairs: + - source: 合同金额 + target: 中标价 + method: exact + - source: 中标价 + target: 投标价 + method: exact + logic: 1 AND 2 + messages: + pass: 合同金额与投标价、中标价一致 + fail: 合同金额与投标价/中标价不一致,涉嫌虚假招标 + references_laws: + - 《招标投标法》第 46 条(不得背离实质性内容订立合同) + - 《招标投标法实施条例》第 57 条 + remediation: + suggestions: + - 合同金额 {{合同金额}} / 中标价 {{中标价}} / 投标价 {{投标价}} + - 三者应完全一致。不一致时涉嫌"阴阳合同"或变相抬价 + - 建议立即核对招标文件原件 + actions: + - type: upload_file + label: 上传招标文件原件 + file_type: 招标文件 + accept: + - pdf + - type: escalate + label: 涉嫌阴阳合同,上报合规 + role: 合规专员 + type: deterministic +- group: 质量条款 + rules: + - rule_id: GC-003 + name: 质量标准明确性 + risk: high + score: 10 + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: '1' + check: required + field: 质量标准 + - id: '2' + type: string.min_length + field: 质量标准 + min: 30 + - id: '3' + check: contains + field: 质量标准 + any_of: + - GB 50 + - GB/T + - 合格 + - 优良 + - 现行国家标准 + logic: 1 AND 2 AND 3 + messages: + pass: 质量标准明确 + fail: 质量标准过于简略或未引用具体标准 + references_laws: + - 《民法典》第 802 条 + - 《建设工程质量管理条例》第 14 条 + type: deterministic + - rule_id: GC-OLD-003 + name: 旧版质量标准检查 + risk: medium + score: 5 + version: 1 + stages: + - id: '1' + check: required + field: 质量标准 + logic: '1' + messages: + pass: 有质量条款(注意:本规则已弃用,请使用 GC-003) + fail: 缺少质量条款 + type: deterministic + deprecated: + since: '2025-06-01' + replacement: GC-003 + reason: '旧版仅检查质量条款存在性,不检查标准引用的具体性。 + + GC-003 增加了对 GB/GB-T 国标引用的要求。 + + ' +- group: 安全条款 + rules: + - rule_id: GC-004 + name: 安全文明施工条款完备性 + risk: high + score: 15 + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: '1' + check: required + field: 安全文明施工要求 + - id: '2' + type: string.min_length + field: 安全文明施工要求 + min: 50 + - id: '3' + check: contains + field: 安全文明施工要求 + all_of: + - 三宝四口五临边 + - 安全帽 + - 扬尘 + - 噪音 + logic: 1 AND 2 AND 3 + messages: + pass: 安全文明施工条款完备 + fail: 安全文明施工条款不完备,缺少关键要素 + references_laws: + - 《建筑法》第 36-41 条 + - 《建设工程安全生产管理条例》 + type: deterministic +- group: 工期条款 + rules: + - rule_id: GC-005 + name: 工期合理性 + risk: medium + score: 5 + version: 1 + stages: + - id: '1' + check: required + fields: + - 开工日期 + - 竣工日期 + logic: and + - id: '2' + type: date.after + field: 竣工日期 + ref_field: 开工日期 + - id: '3' + check: compare + left: derived.工期天数 + op: '>' + right: 0 + logic: 1 AND 2 AND 3 + messages: + pass: 工期 {{derived.工期天数}} 天合理 + fail: 开工/竣工日期颠倒或工期异常 + type: deterministic +- group: 金额条款 + rules: + - rule_id: GC-006 + name: 质保金比例不超过 3% + risk: medium + score: 5 + version: 1 + stages: + - id: '1' + check: required + field: 质保金比例 + - id: '2' + check: compare + left: 质保金比例 + op: < + right: 0.03 + logic: 1 AND 2 + messages: + pass: 质保金比例 {{质保金比例}} 合规 + fail: 质保金比例超过 3% 上限 + references_laws: + - 《建设工程质量保证金管理办法》第 7 条 + type: deterministic +- group: 招投标合规 + rules: + - rule_id: GC-007 + name: 招投标文件齐全 + risk: high + score: 10 + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: '1' + check: required + fields: + - 招标文件编号 + - 中标通知书编号 + logic: and + logic: '1' + messages: + pass: 招投标文件齐全 + fail: 缺少招标文件或中标通知书编号 + type: deterministic +- group: 印章合规 + rules: + - rule_id: GC-SEAL-001 + name: 双方签章齐全 + risk: high + score: 15 + version: 1 + stages: + - id: '1' + type: seal.present + seal_id: 发包人公章 + - id: '2' + type: seal.present + seal_id: 承包人公章 + - id: '3' + type: seal.text_match + seal_id: 发包人公章 + - id: '4' + type: seal.text_match + seal_id: 承包人公章 + logic: 1 AND 2 AND 3 AND 4 + messages: + pass: 双方签章齐全且文字匹配 + fail: 缺少签章或印章文字与当事人名称不符 + type: deterministic + - rule_id: GC-SEAL-002 + name: 骑缝章完整 + risk: high + score: 10 + version: 1 + stages: + - id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 + logic: '1' + messages: + pass: 骑缝章完整 + fail: 骑缝章缺失,合同可能被替换页 + type: deterministic +- group: 质量综合 + rules: + - rule_id: GC-GROUP-QUALITY + name: 质量条款综合评查 + risk: high + score: 25 + logic: GC-003 AND GC-004 + messages: + pass: 质量与安全条款完备 + fail: 质量或安全条款有瑕疵 + type: rule_group + rules: + - GC-003 + - GC-004 +- group: 印章综合 + rules: + - rule_id: GC-GROUP-SEAL + name: 印章综合评查 + risk: high + score: 25 + logic: GC-SEAL-001 AND GC-SEAL-002 + messages: + pass: 签章与骑缝章齐全合规 + fail: 印章有瑕疵,合同可能被篡改 + type: rule_group + rules: + - GC-SEAL-001 + - GC-SEAL-002 diff --git a/leaudit-oss-yaml-files/contract.construction.general/v2/rules.yaml b/leaudit-oss-yaml-files/contract.construction.general/v2/rules.yaml new file mode 100644 index 0000000..e596a33 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.construction.general/v2/rules.yaml @@ -0,0 +1,15 @@ +metadata: + type_id: contract.construction.general + name: 建设工程合同 + version: v2 + last_updated: '2026-05-06' + parent: 合同 + classification_keywords: + - 合同 + - 建设工程合同 + - 合同管理 + description: > + 建设工程合同 规则模板。 入口模块:合同管理; 一级分组:合同; 二级分组:建设工程合同。 + +sub_documents: [] +rules: [] \ No newline at end of file diff --git a/leaudit-oss-yaml-files/contract.construction.general/v3/rules.yaml b/leaudit-oss-yaml-files/contract.construction.general/v3/rules.yaml new file mode 100644 index 0000000..c91c28e --- /dev/null +++ b/leaudit-oss-yaml-files/contract.construction.general/v3/rules.yaml @@ -0,0 +1,612 @@ +metadata: + type_id: contract.construction.general + name: 建设工程合同 + version: 'v3' + last_updated: '2026-05-07' + parent: contract + inherits_from: + - base.common + - base.party_info + classification_keywords: + - 建设工程 + - 工程承包 + - 施工合同 + - 总承包 + tags: + - compliance + - high_priority + - prc_civil_code + - construction_specific + - safety_critical + applies_to_jurisdictions: + - prc + references_laws: + - 《民法典》第 788-808 条(建设工程合同章) + - 《建筑法》第 25-41 条 + - 《建设工程质量管理条例》 + description: 建设工程施工合同评查规则。 + confidence_profile: + allow_weight_override: false + field_confidence_defaults: + 合同金额: 0.95 + 投标价: 0.95 + 中标价: 0.95 +extract: + - group: 当事人 + fields: + - name: 发包人名称 + type: verbatim + required_from: draft + desc: "" + - name: 发包人统一信用代码 + type: uscc + required_from: executed + desc: "" + - name: 承包人名称 + type: verbatim + required_from: draft + desc: "" + - name: 承包人统一信用代码 + type: uscc + required_from: executed + desc: "" + - name: 承包人资质等级 + type: enum + allowed: + - 特级 + - 一级 + - 二级 + - 三级 + required_from: draft + desc: "" + - name: 承包人资质证书编号 + type: verbatim + required_from: executed + desc: "" + - group: 合同基本信息 + fields: + - name: 合同编号 + type: verbatim + required_from: draft + desc: "" + - name: 签订日期 + type: date + required_from: executed + desc: "" + - group: 项目信息 + fields: + - name: 工程名称 + type: verbatim + required_from: draft + desc: "" + - name: 工程地点 + type: verbatim + required_from: draft + desc: "" + - name: 工程规模 + type: string + required_from: draft + desc: "" + - name: 开工日期 + type: date + required_from: executed + desc: "" + - name: 竣工日期 + type: date + required_from: executed + desc: "" + - group: 金额(三处一致性检查的核心) + fields: + - name: 合同金额 + type: money + required_from: draft + desc: "" + - name: 投标价 + type: money + required_from: draft + desc: "" + - name: 中标价 + type: money + required_from: draft + desc: "" + - name: 合同金额大写 + type: verbatim + required_from: executed + desc: "" + - group: 招投标信息 + fields: + - name: 招标文件编号 + type: verbatim + required_from: draft + desc: "" + - name: 中标通知书编号 + type: verbatim + required_from: draft + desc: "" + - group: 质量条款 + fields: + - name: 质量标准 + type: string + required_from: draft + desc: "" + - name: 质量等级 + type: enum + allowed: + - 合格 + - 优良 + required_from: draft + desc: "" + - name: 保修期限 + type: string + required_from: draft + desc: "" + - group: 安全条款 + fields: + - name: 安全文明施工要求 + type: string + required_from: draft + desc: "" + - group: 工程款支付 + fields: + - name: 预付款比例 + type: money + required_from: draft + desc: "" + - name: 进度款支付方式 + type: string + required_from: draft + desc: "" + - name: 质保金比例 + type: money + required_from: draft + desc: "" + - group: 违约与争议 + fields: + - name: 违约责任 + type: string + required_from: draft + desc: "" + - name: 争议解决 + type: string + required_from: draft + desc: "" +derived_fields: + - name: 工期天数 + type: integer + compute: (竣工日期 - 开工日期).days + depends_on: + - 开工日期 + - 竣工日期 + - name: 质保金金额 + type: money + compute: 合同金额 * 质保金比例 + depends_on: + - 合同金额 + - 质保金比例 +visual_elements: + seals: + - id: 发包人公章 + name: 发包人公章回归 + required: true + required_from: executed + expected_text_match: + field: 发包人名称 + allowed_types: + - 公章 + - 合同专用章 + - id: 承包人公章 + name: 承包人公章或合同专用章 + required: true + required_from: executed + expected_text_match: + field: 承包人名称 + allowed_types: + - 公章 + - 合同专用章 + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 建设工程合同骑缝章 + required: true + required_from: executed + expected_text_match: + field: 发包人名称 +rules: + - group: 基础检查 + rules: + - rule_id: GC-000 + name: 基础信息完整性 + risk: high + score: "10" + version: 1 + stages: + - id: "1" + check: required + fields: + - 发包人名称 + - 承包人名称 + - 工程名称 + - 合同金额 + logic: and + logic: "1" + messages: + pass: 基础信息完整 + fail: 缺少发包人/承包人/工程名称/合同金额 + type: deterministic + desc: "" + - group: 主体资质 + rules: + - rule_id: GC-001 + name: 承包人资质合法性 + risk: high + score: "15" + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: "1" + check: format + field: 承包人统一信用代码 + format: uscc + - id: "2" + check: required + field: 承包人资质证书编号 + - id: "3" + check: required + field: 承包人资质等级 + logic: 1 AND 2 AND 3 + messages: + pass: 承包人资质信息完整 + fail: 承包人资质信息不完整或 USCC 无效 + references_laws: + - 《建筑法》第 13 条(施工企业资质) + type: deterministic + desc: "" + - group: 金额合规 + rules: + - rule_id: GC-002 + name: 金额三处一致性 + risk: high + score: "20" + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: "1" + check: required + fields: + - 合同金额 + - 投标价 + - 中标价 + logic: and + - id: "2" + check: match + pairs: + - source: 合同金额 + target: 中标价 + method: exact + - source: 中标价 + target: 投标价 + method: exact + logic: 1 AND 2 + messages: + pass: 合同金额与投标价、中标价一致 + fail: 合同金额与投标价/中标价不一致,涉嫌虚假招标 + references_laws: + - 《招标投标法》第 46 条(不得背离实质性内容订立合同) + - 《招标投标法实施条例》第 57 条 + remediation: + suggestions: + - 合同金额 {{合同金额}} / 中标价 {{中标价}} / 投标价 {{投标价}} + - 三者应完全一致。不一致时涉嫌"阴阳合同"或变相抬价 + - 建议立即核对招标文件原件 + actions: + - type: upload_file + label: 上传招标文件原件 + file_type: 招标文件 + accept: + - pdf + - type: escalate + label: 涉嫌阴阳合同,上报合规 + role: 合规专员 + type: deterministic + desc: "" + - group: 质量条款 + rules: + - rule_id: GC-003 + name: 质量标准明确性 + risk: high + score: "10" + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: "1" + check: required + field: 质量标准 + - id: "2" + type: string.min_length + field: 质量标准 + min: 30 + - id: "3" + check: contains + field: 质量标准 + any_of: + - GB 50 + - GB/T + - 合格 + - 优良 + - 现行国家标准 + logic: 1 AND 2 AND 3 + messages: + pass: 质量标准明确 + fail: 质量标准过于简略或未引用具体标准 + references_laws: + - 《民法典》第 802 条 + - 《建设工程质量管理条例》第 14 条 + type: deterministic + desc: "" + - rule_id: GC-OLD-003 + name: 旧版质量标准检查 + risk: medium + score: "5" + version: 1 + stages: + - id: "1" + check: required + field: 质量标准 + logic: "1" + messages: + pass: 有质量条款(注意:本规则已弃用,请使用 GC-003) + fail: 缺少质量条款 + type: deterministic + deprecated: + since: 2025-06-01 + replacement: GC-003 + reason: | + 旧版仅检查质量条款存在性,不检查标准引用的具体性。 + GC-003 增加了对 GB/GB-T 国标引用的要求。 + desc: "" + - group: 安全条款 + rules: + - rule_id: GC-004 + name: 安全文明施工条款完备性 + risk: high + score: "15" + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: "1" + check: required + field: 安全文明施工要求 + - id: "2" + type: string.min_length + field: 安全文明施工要求 + min: 50 + - id: "3" + check: contains + field: 安全文明施工要求 + all_of: + - 三宝四口五临边 + - 安全帽 + - 扬尘 + - 噪音 + logic: 1 AND 2 AND 3 + messages: + pass: 安全文明施工条款完备 + fail: 安全文明施工条款不完备,缺少关键要素 + references_laws: + - 《建筑法》第 36-41 条 + - 《建设工程安全生产管理条例》 + type: deterministic + desc: "" + - group: 工期条款 + rules: + - rule_id: GC-005 + name: 工期合理性 + risk: medium + score: "5" + version: 1 + stages: + - id: "1" + check: required + fields: + - 开工日期 + - 竣工日期 + logic: and + - id: "2" + type: date.after + field: 竣工日期 + ref_field: 开工日期 + - id: "3" + check: compare + left: derived.工期天数 + op: ">" + right: 0 + logic: 1 AND 2 AND 3 + messages: + pass: 工期 {{derived.工期天数}} 天合理 + fail: 开工/竣工日期颠倒或工期异常 + type: deterministic + desc: "" + - group: 金额条款 + rules: + - rule_id: GC-006 + name: 质保金比例不超过 3% + risk: medium + score: "5" + version: 1 + stages: + - id: "1" + check: required + field: 质保金比例 + - id: "2" + check: compare + left: 质保金比例 + op: < + right: 0.03 + logic: 1 AND 2 + messages: + pass: 质保金比例 {{质保金比例}} 合规 + fail: 质保金比例超过 3% 上限 + references_laws: + - 《建设工程质量保证金管理办法》第 7 条 + type: deterministic + desc: "" + - group: 招投标合规 + rules: + - rule_id: GC-007 + name: 招投标文件齐全 + risk: high + score: "10" + version: 1 + depends_on: + - when: GC-000.passed + stages: + - id: "1" + check: required + fields: + - 招标文件编号 + - 中标通知书编号 + logic: and + logic: "1" + messages: + pass: 招投标文件齐全 + fail: 缺少招标文件或中标通知书编号 + type: deterministic + desc: "" + - group: 印章合规 + rules: + - rule_id: GC-SEAL-001 + name: 双方签章齐全 + risk: high + score: "15" + version: 1 + stages: + - id: "1" + type: seal.present + seal_id: 发包人公章 + - id: "2" + type: seal.present + seal_id: 承包人公章 + - id: "3" + type: seal.text_match + seal_id: 发包人公章 + - id: "4" + type: seal.text_match + seal_id: 承包人公章 + logic: 1 AND 2 AND 3 AND 4 + messages: + pass: 双方签章齐全且文字匹配 + fail: 缺少签章或印章文字与当事人名称不符 + type: deterministic + desc: "" + - rule_id: GC-SEAL-002 + name: 骑缝章完整 + risk: high + score: "10" + version: 1 + stages: + - id: "1" + type: cross_page_seal.complete + seal_id: 骑缝章 + logic: "1" + messages: + pass: 骑缝章完整 + fail: 骑缝章缺失,合同可能被替换页 + type: deterministic + desc: "" + - group: 质量综合 + rules: + - rule_id: GC-GROUP-QUALITY + name: 质量条款综合评查 + risk: high + score: "25" + logic: GC-003 AND GC-004 + messages: + pass: 质量与安全条款完备 + fail: 质量或安全条款有瑕疵 + type: rule_group + rules: + - GC-003 + - GC-004 + desc: "" + - group: 印章综合 + rules: + - rule_id: GC-GROUP-SEAL + name: 印章综合评查 + risk: high + score: "25" + logic: GC-SEAL-001 AND GC-SEAL-002 + messages: + pass: 签章与骑缝章齐全合规 + fail: 印章有瑕疵,合同可能被篡改 + type: rule_group + rules: + - GC-SEAL-001 + - GC-SEAL-002 + desc: "" + - group: 我方权益保护 + rules: + - rule_id: GC-OUR-001 + name: 我方缔约地位及不利条款审查 + risk: high + score: "10" + stages: + - id: "1" + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: "1" + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule + desc: 基于ctx合同全文识别我方缔约地位并评查我方权益风险。 +sub_documents: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/2.0/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/2.0/rules.yaml new file mode 100644 index 0000000..3abacb4 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/2.0/rules.yaml @@ -0,0 +1,1489 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: '2.0' + last_updated: '2026-04-14' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: '依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 + + 适用于评估、代理、咨询、查新等服务类委托合同的评查。 + + 覆盖签署前审查(draft)和签署后审计(executed)两个阶段。 + + 基于旧系统 00_通用规则.json + 02_委托合同.json 合并、去重、委托场景校准而成(38→33 条)。 + + ' +extract: +- group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + deep_retry: false + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + deep_retry: false + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + deep_retry: false + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + deep_retry: false + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + deep_retry: false + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + deep_retry: false + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + deep_retry: true + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + deep_retry: false + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + deep_retry: false + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + deep_retry: false + - name: 服务结束日期 + type: date + required_from: draft + desc: 委托事项完成/合同终止的日期 + deep_retry: false + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + deep_retry: false +- group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + deep_retry: false + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + deep_retry: false + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + deep_retry: false + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + deep_retry: false + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + deep_retry: false + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + deep_retry: false + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + deep_retry: false +- group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + deep_retry: false + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + deep_retry: false + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + deep_retry: false + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + deep_retry: false + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + deep_retry: false + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + deep_retry: false +- group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + deep_retry: false + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + deep_retry: false + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + deep_retry: false + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + deep_retry: false + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + deep_retry: false + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + deep_retry: false + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + deep_retry: false + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + deep_retry: false + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + deep_retry: false +- group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + deep_retry: false + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + deep_retry: false +- group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + deep_retry: false + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + deep_retry: false + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + deep_retry: false +- group: '签署要素 — required_from: executed' + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + deep_retry: false + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + deep_retry: false + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + deep_retry: false + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + deep_retry: false + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + deep_retry: false + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + deep_retry: false + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + deep_retry: false +- group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + deep_retry: false +- group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + deep_retry: false +- group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + deep_retry: false + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + deep_retry: false +- group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '合同中是否涉及商业秘密、技术秘密、客户信息等保密要求。 填"是"的条件:出现"保密""商业秘密""技术秘密""客户隐私""非公开资料"等关键词且有实质条款。 填"否"的条件:普通评估/代理等无保密性服务,或仅有通用条款不成实质。 + + ' + deep_retry: false + - name: 允许转委托 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '合同中是否允许受托人将部分事务转委托给第三方处理。 填"是"的条件:明确约定"经委托人同意可转委托""可分包"等。 填"否"的条件:明确禁止转委托,或未提及转委托(默认不允许)。 + + ' + deep_retry: false + - name: 含服务项目清单 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '合同是否包含服务项目清单(含单价×数量拆分)。 填"是"的条件:附件或正文中有"服务清单""项目明细""费用明细"且有单价数量拆分。 填"否"的条件:只有一个总服务费金额,无拆分明细。 + + ' + deep_retry: false +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签字或公章 + required: true + required_from: executed + - id: 受托人签章 + name: 受托人签字或公章 + required: true + required_from: executed + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed +rules: +- group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 委托方 + - id: '2' + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + + ' + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 委托事项 + - id: '2' + check: ai + prompt: '请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + + ' + logic: 1 AND 2 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 服务起始日期 + - id: '2' + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 服务费金额 + - id: '2' + check: required + field: 服务费金额大写 + - id: '3' + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + logic: 1 AND 2 AND 3 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 付款方式 + - id: '2' + check: ai + prompt: '请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + + ' + logic: 1 AND 2 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: 5 + stages: + - id: '1' + check: ai + prompt: '请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + + ' + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 报告义务条款 + - id: '2' + check: ai + prompt: '请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + + ' + logic: 1 AND 2 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 交付方式 + - id: '2' + check: ai + prompt: '请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + + ' + logic: 1 AND 2 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 任意解除权条款 + - id: '2' + check: ai + prompt: '请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + + ' + logic: 1 AND 2 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + + ' + logic: 1 AND 2 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: 7 + stages: + - id: '1' + check: required + field: 违约金金额 + - id: '2' + check: ai + prompt: '请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + + ' + logic: 1 AND 2 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + + ' + logic: 1 AND 2 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 不可抗力条款 + - id: '2' + check: ai + prompt: '请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + + ' + logic: 1 AND 2 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: 5 + stages: + - id: '1' + check: ai + prompt: '请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + + ' + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + + ' + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: 2 + stages: + - id: '1' + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic +- group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + + ' + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + + ' + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + + ' + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: 4 + stages: + - id: '1' + check: required + field: 生效条件 + - id: '2' + check: required + field: 合同份数 + logic: 1 AND 2 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + + ' + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 委托方 + - id: '2' + check: required + field: 受托人 + - id: '3' + check: required + field: 委托方地址 + - id: '4' + check: required + field: 受托人地址 + logic: 1 AND 2 AND 3 AND 4 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic +- group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: 9 + stages: + - id: '1' + check: ai + prompt: '请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + + ' + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + + ' + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + + ' + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + + ' + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 合同编号 + - id: '2' + check: required + field: 签约日期 + logic: 1 AND 2 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: 2 + stages: + - id: '1' + check: required + field: 收款方开户银行 + - id: '2' + check: required + field: 收款方银行账号 + - id: '3' + check: required + field: 收款方账户名称 + logic: 1 AND 2 AND 3 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 签约日期 + - id: '2' + check: required + field: 签约地点 + logic: 1 AND 2 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic +- group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: 2 + activate_if: 涉及保密信息 == '是' + stages: + - id: '1' + check: required + field: 保密条款 + - id: '2' + check: ai + prompt: '请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + + ' + logic: 1 AND 2 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: 2 + activate_if: 允许转委托 == '是' + stages: + - id: '1' + check: required + field: 转委托条款 + - id: '2' + check: ai + prompt: '请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + + ' + logic: 1 AND 2 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: 5 + activate_if: 含服务项目清单 == '是' + stages: + - id: '1' + check: required + field: 服务项目清单 + - id: '2' + check: ai + prompt: '请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + + ' + logic: 1 AND 2 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.entrust/v2/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v2/rules.yaml new file mode 100644 index 0000000..75b926f --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v2/rules.yaml @@ -0,0 +1,1474 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v2' + last_updated: '2026-05-06' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + deep_retry: false + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + deep_retry: false + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + deep_retry: false + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + deep_retry: false + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + deep_retry: false + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + deep_retry: false + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + deep_retry: true + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + deep_retry: false + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + deep_retry: false + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + deep_retry: false + - name: 服务结束日期 + type: date + required_from: draft + desc: 委托事项完成/合同终止的日期 + deep_retry: false + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + deep_retry: false + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + deep_retry: false + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + deep_retry: false + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + deep_retry: false + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + deep_retry: false + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + deep_retry: false + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + deep_retry: false + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + deep_retry: false + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + deep_retry: false + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + deep_retry: false + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + deep_retry: false + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + deep_retry: false + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + deep_retry: false + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + deep_retry: false + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + deep_retry: false + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + deep_retry: false + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + deep_retry: false + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + deep_retry: false + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + deep_retry: false + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + deep_retry: false + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + deep_retry: false + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + deep_retry: false + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + deep_retry: false + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + deep_retry: false + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + deep_retry: false + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + deep_retry: false + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + deep_retry: false + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + deep_retry: false + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + deep_retry: false + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + deep_retry: false + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + deep_retry: false + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + deep_retry: false + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + deep_retry: false + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + deep_retry: false + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + deep_retry: false + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + deep_retry: false + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + deep_retry: false + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + deep_retry: false + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + deep_retry: false + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: > + 合同中是否涉及商业秘密、技术秘密、客户信息等保密要求。 + 填"是"的条件:出现"保密""商业秘密""技术秘密""客户隐私""非公开资料"等关键词且有实质条款。 + 填"否"的条件:普通评估/代理等无保密性服务,或仅有通用条款不成实质。 + deep_retry: false + - name: 允许转委托 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: > + 合同中是否允许受托人将部分事务转委托给第三方处理。 填"是"的条件:明确约定"经委托人同意可转委托""可分包"等。 + 填"否"的条件:明确禁止转委托,或未提及转委托(默认不允许)。 + deep_retry: false + - name: 含服务项目清单 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: > + 合同是否包含服务项目清单(含单价×数量拆分)。 填"是"的条件:附件或正文中有"服务清单""项目明细""费用明细"且有单价数量拆分。 + 填"否"的条件:只有一个总服务费金额,无拆分明细。 + deep_retry: false +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签字或公章 + required: true + required_from: executed + - id: 受托人签章 + name: 受托人签字或公章 + required: true + required_from: executed + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" diff --git a/leaudit-oss-yaml-files/contract.entrust/v3/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v3/rules.yaml new file mode 100644 index 0000000..add9c0d --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v3/rules.yaml @@ -0,0 +1,1414 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v3' + last_updated: '2026-05-06' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: date + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: "" + required: false + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: "" + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v4/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v4/rules.yaml new file mode 100644 index 0000000..1eb3e70 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v4/rules.yaml @@ -0,0 +1,1438 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v4' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v5/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v5/rules.yaml new file mode 100644 index 0000000..411bae6 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v5/rules.yaml @@ -0,0 +1,1462 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v5' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: number + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v6/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v6/rules.yaml new file mode 100644 index 0000000..4129a4c --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v6/rules.yaml @@ -0,0 +1,1466 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v6' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: + - id: document-1778127173656 + name: 处罚决定书.当事人 + required: "false" + extract: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v7/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v7/rules.yaml new file mode 100644 index 0000000..f38cb2d --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v7/rules.yaml @@ -0,0 +1,1495 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v7' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + - id: ce s + name: 合同骑缝章 + required: true + allowed_types: + - 合同骑缝章 + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: + - id: document-1778127173656 + name: 处罚决定书.当事人 + required: "false" + extract: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v8/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v8/rules.yaml new file mode 100644 index 0000000..72b8f15 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v8/rules.yaml @@ -0,0 +1,1519 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v8' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + - id: ce s + name: 合同骑缝章 + required: true + allowed_types: + - 合同骑缝章 + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: >- + >- + |- + |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: ai_rule + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: ai_rule + desc: "" +sub_documents: + - id: document-1778127173656 + name: 处罚决定书.当事人 + required: "false" + extract: [] diff --git a/leaudit-oss-yaml-files/contract.entrust/v9/rules.yaml b/leaudit-oss-yaml-files/contract.entrust/v9/rules.yaml new file mode 100644 index 0000000..c41f839 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.entrust/v9/rules.yaml @@ -0,0 +1,1606 @@ +metadata: + type_id: contract.entrust + name: 通用委托合同 + version: 'v9' + last_updated: '2026-05-07' + tags: + - 合同 + - 委托 + - 服务 + - 评估 + - 代理 + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第九百一十九条至第九百三十六条 + description: 依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及委托合同章(第919-936条)。 +extract: + - group: 合同成立要素 — draft 必需 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 委托方 + type: verbatim + required_from: draft + desc: 委托方(甲方)全称 + - name: 受托人 + type: verbatim + required_from: draft + desc: 受托人(乙方)全称 + - name: 委托事项 + type: string + required_from: draft + desc: 委托事项内容的完整描述:委托做什么(评估/代理/查新/咨询等)、服务范围、服务标准 + - name: 服务范围 + type: string + required_from: draft + desc: 具体服务范围界定(如评估对象、代理事务类型、查新课题等) + - name: 服务标准 + type: string + required_from: draft + desc: 服务完成的标准/规范依据(国家标准、行业标准、技术规程等) + - name: 服务费金额 + type: money + required_from: draft + desc: 服务费/委托费的数字总金额 + - name: 服务费金额大写 + type: verbatim + required_from: draft + desc: 服务费的中文大写金额 + - name: 服务费计算方式 + type: string + required_from: draft + desc: 服务费的计算方式(一次性/分期/按项目/按工时等) + - name: 服务起始日期 + type: date + required_from: draft + desc: 委托事项开始执行的日期 + - name: 服务结束日期 + type: number + required_from: draft + desc: 委托事项完成/合同终止的日期 + - name: 付款方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - group: 主体资格信息 — draft 必需 + fields: + - name: 委托方证件号 + type: verbatim + required_from: draft + desc: 委托方身份证号(个人)或统一社会信用代码(单位) + - name: 受托人统一社会信用代码 + type: verbatim + required_from: executed + desc: 受托人 18 位 USCC(单位受托人)。签署阶段必填,draft 阶段可为空。 + - name: 委托方地址 + type: verbatim + required_from: draft + desc: 委托方住址或注册地址 + - name: 受托人地址 + type: verbatim + required_from: draft + desc: 受托人住址或注册地址 + - name: 委托方法定代表人 + type: verbatim + required_from: draft + desc: 委托方法定代表人或负责人姓名(单位) + - name: 受托人法定代表人 + type: verbatim + required_from: draft + desc: 受托人法定代表人或负责人姓名 + - name: 受托人资质信息 + type: string + required_from: draft + desc: 受托人从事委托事项所需的资质、许可或专业资格(如评估师资格、代理资格等) + - group: 履约核心条款 — draft 必需 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 服务成果的交付方式、时间、形式等约定(评估报告、查新报告、代理文件等) + - name: 服务成果形式 + type: string + required_from: draft + desc: 服务成果的具体形式(书面报告/电子文档/意见书等)及交付数量 + - name: 验收方式 + type: string + required_from: draft + desc: 服务成果的验收流程、验收标准、异议处理方式 + - name: 报告义务条款 + type: string + required_from: draft + desc: 受托人的报告义务:报告方式、报告周期、报告内容等 + - name: 转委托条款 + type: string + required_from: draft + desc: 转委托相关约定:是否允许、经委托人同意的条件等 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 服务履行地点(评估现场、代理事务地点等) + - group: 法定/必备条款 — draft 必需 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + required_from: draft + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + required_from: draft + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + required_from: draft + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 任意解除权条款 + type: string + required_from: draft + desc: 民法典§933 委托人或受托人任意解除合同的约定及解除后已完成服务的费用结算方式 + - name: 生效条件 + type: string + required_from: draft + desc: 合同生效条件(签字盖章、经批准等) + - group: 合规性辅助字段 — draft + fields: + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标、协商过程、签约原因等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章、技术标准的列表 + - group: 附件与补充 — draft + fields: + - name: 附件列表 + type: string + required_from: draft + desc: 合同附件的序号、名称、类型的列表(可能含服务项目清单) + - name: 服务项目清单 + type: string + required_from: draft + desc: 服务项目明细清单:各项服务名称、单价、数量、金额的完整内容 + - name: 补充协议条款 + type: string + required_from: draft + desc: 未尽事宜补充、补充协议效力等约定 + - group: "签署要素 — required_from: executed" + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 收款方开户银行 + type: verbatim + required_from: executed + desc: 收款方(通常为受托人)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: executed + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: executed + desc: 收款方账户名称(与受托人主体一致) + - group: 税务信息 — draft + fields: + - name: 服务费是否含税 + type: "- 是" + required_from: draft + desc: 服务费金额是否已包含税费。填"是":合同中明确"含税"或"费用已包含税费";填"否":另行约定税费分担或未说明。 + - group: 保密条款(条件激活规则使用) + fields: + - name: 保密条款 + type: string + required_from: draft + desc: 保密条款的完整内容:保密信息范围、保密期限、违约责任 + - group: 辅助信息(不做存在性检查,用于交叉校验) + fields: + - name: 委托方联系电话 + type: verbatim + required_from: draft + desc: 委托方联系电话 + - name: 受托人联系电话 + type: verbatim + required_from: draft + desc: 受托人联系电话 + - group: 合同特征分类字段(控制条件激活) + fields: + - name: 涉及保密信息 + type: "- 是" + required_from: draft + desc: ">" + - name: 允许转委托 + type: "- 是" + required_from: draft + desc: ">" + - name: 含服务项目清单 + type: "- 是" + required_from: draft + desc: ">" +visual_elements: + seals: + - id: 委托方签章 + name: 委托方签章 + required: false + - id: ce s + name: 合同骑缝章 + required: true + allowed_types: + - 合同骑缝章 + signatures: [] + cross_page_seals: + - id: 骑缝章 + name: 骑缝章 + required: false +rules: + - group: 完整性(17 条) + rules: + - rule_id: MM-ENT-001 + name: 当事人信息齐全 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + messages: + pass: 委托方和受托人信息齐全 + fail: 缺少委托方或受托人信息 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-002 + name: 当事人信息准确完整 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + >- + |- + |- + |- + |- + |- + 请检查合同当事人(委托方和受托人)的信息是否准确完整。 + + + 委托方:{{委托方}} + + 委托方法代:{{委托方法定代表人}} + + 委托方地址:{{委托方地址}} + + 委托方电话:{{委托方联系电话}} + + 委托方证件号:{{委托方证件号}} + + + 受托人:{{受托人}} + + 受托人法代:{{受托人法定代表人}} + + 受托人地址:{{受托人地址}} + + 受托人电话:{{受托人联系电话}} + + 受托人USCC:{{受托人统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 委托方为个人时是否有身份证号;委托方为单位时是否有 USCC + + 3. 受托人为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:受托人统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + + 法规依据:民法典§470 + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-003 + name: 委托事项明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 委托事项 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查委托事项是否明确。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 是否明确了委托内容(如评估、代理、查新、咨询等具体事项) + + 2. 服务范围是否具体(评估对象/代理事务范围/查新课题等) + + 3. 是否明确了服务标准(国标、行标、技术规程等) + + 4. 是否区分了特别委托和概括委托 + + 5. 委托事项是否合法且明确,不应使用模糊表述 + + + 法规依据:民法典§919、§920 + messages: + pass: 委托事项明确 + fail: 委托事项不明确或缺失 + references_laws: + - 《民法典》第九百一十九条 + - 《民法典》第九百二十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-004 + name: 服务期限明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 服务起始日期 + - id: "2" + check: required + field: 服务结束日期 + messages: + pass: 服务期限起止日期齐全 + fail: 缺少服务起止日期 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-005 + name: 服务费金额完整 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 服务费金额 + - id: "2" + check: required + field: 服务费金额大写 + - id: "3" + check: amount_match + number: 服务费金额 + chinese: 服务费金额大写 + messages: + pass: 服务费金额完整且大小写一致 + fail: 服务费金额缺失或大小写不一致 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-006 + name: 付款条款完整 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 付款方式 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查付款条款是否完整。 + + + 付款方式:{{付款方式}} + + 服务费计算方式:{{服务费计算方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期/节点是否明确(一次性、分期、按里程碑等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§928 + messages: + pass: 付款条款完整 + fail: 付款条款不完整 + references_laws: + - 《民法典》第九百二十八条 + type: deterministic + desc: "" + - rule_id: MM-ENT-007 + name: 合同地点具体准确 + risk: medium + score: "5" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查合同地点信息是否具体准确。 + + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + 委托方地址:{{委托方地址}} + + 受托人地址:{{受托人地址}} + + + 评查要点: + + 1. 服务履行地点是否具体(评估现场/代理事务办理地等) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-008 + name: 报告义务条款 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 报告义务条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查受托人的报告义务条款是否明确。 + + + 报告义务条款:{{报告义务条款}} + + + 评查要点: + + 1. 是否约定了受托人向委托人的报告义务 + + 2. 报告方式是否明确(书面/口头/电子等) + + 3. 报告周期/时点是否明确(阶段性报告、事项完成后报告等) + + 4. 报告内容要求是否明确(进度、困难、结果等) + + 5. 异常情况的特别报告义务是否有约定 + + + 法规依据:民法典§927 + messages: + pass: 报告义务条款完整 + fail: 报告义务条款缺失或不完整 + references_laws: + - 《民法典》第九百二十七条 + type: deterministic + desc: "" + - rule_id: MM-ENT-009 + name: 服务成果交付与验收 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 交付方式 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查服务成果交付与验收条款是否完整。 + + + 交付方式:{{交付方式}} + + 服务成果形式:{{服务成果形式}} + + 验收方式:{{验收方式}} + + + 评查要点: + + 1. 服务成果的具体形式是否明确(评估报告、查新报告、代理文件等) + + 2. 交付时间和方式是否明确 + + 3. 是否约定了完成标准/验收标准 + + 4. 是否约定了验收程序和异议处理方式 + + 5. 验收通过的时点和依据是否明确 + + + 法规依据:民法典§929 + messages: + pass: 服务成果交付与验收条款完整 + fail: 交付或验收条款缺失或不完整 + references_laws: + - 《民法典》第九百二十九条 + type: deterministic + desc: "" + - rule_id: MM-ENT-010 + name: 任意解除权与费用结算 + risk: high + score: "3" + stages: + - id: "1" + check: required + field: 任意解除权条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查委托合同特有的任意解除权及费用结算条款是否明确。 + + + 任意解除权条款:{{任意解除权条款}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(民法典§933): + + 1. 是否明确了委托人或受托人的任意解除权(委托合同特有) + + 2. 任意解除是否约定通知方式和提前期限 + + 3. 解除后已完成服务的费用结算方式是否明确 + + 4. 是否约定因任意解除造成损失的赔偿范围 + + + 法规依据:民法典§933 + messages: + pass: 任意解除权与费用结算条款完整 + fail: 任意解除权条款缺失或费用结算不明 + references_laws: + - 《民法典》第九百三十三条 + type: deterministic + desc: "" + - rule_id: MM-ENT-011 + name: 违约责任形式明确 + risk: high + score: "5" + stages: + - id: "1" + check: required + field: 违约责任条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(委托方违约和受托人违约均有约定) + + 4. 是否有兜底条款 + + + 法规依据:民法典§577 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + type: deterministic + desc: "" + - rule_id: MM-ENT-012 + name: 违约金条款完整合理 + risk: high + score: "7" + stages: + - id: "1" + check: required + field: 违约金金额 + - id: "2" + check: ai + prompt: >- + >- + |- + |- + |- + |- + |- + 请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否覆盖双方违约情形 + + 4. 是否区分根本违约和一般违约的责任差异 + + + 法规依据:民法典§585 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + type: deterministic + desc: "" + - rule_id: MM-ENT-013 + name: 争议解决方式明确 + risk: high + score: "4" + stages: + - id: "1" + check: required + field: 争议解决条款 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-014 + name: 不可抗力条款完整性 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 不可抗力条款 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-015 + name: 变更解除终止条款完整性 + risk: high + score: "5" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否约定了合同终止后的处理(结算、资料返还等) + + + 法规依据:民法典§543、§562、§563 + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + type: deterministic + desc: "" + - rule_id: MM-ENT-016 + name: 附件条款完整性 + risk: low + score: "2" + stages: + - id: "1" + check: ai + prompt: >- + >- + |- + |- + |- + |- + |- + 请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"服务项目清单""资质证明""业务约定书"等) + + 2. 【加分】附件有序号标识 + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:委托合同中附件是可选的辅助材料,只要名称清晰就视为合格。 + + + 法规依据:民法典§470 + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-017 + name: 补充协议条款完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 补充协议条款 + messages: + pass: 补充协议条款存在 + fail: 缺少补充协议兜底条款 + references_laws: + - 《民法典》第五百四十三条 + type: deterministic + desc: "" + - group: 规范性(6 条) + rules: + - rule_id: MM-ENT-018 + name: 合同名称合法有效 + risk: medium + score: "2" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 委托事项:{{委托事项}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"委托合同"且实际为委托关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称 + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + type: deterministic + desc: "" + - rule_id: MM-ENT-019 + name: 合同文本格式规范 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + >- + |- + |- + |- + |- + |- + 请检查合同文本格式是否规范。 + + + 合同名称:{{合同名称}} + + 附件列表:{{附件列表}} + + + 评查要点: + + 1. 合同条款是否按照"当事人—事项—履行—违约—争议—签署"的逻辑顺序编排 + + 2. 条款编号和层次结构是否清晰 + + 3. 是否有必要的附件清单 + + 4. 是否有签署位置(甲方/乙方签字盖章栏) + + + 法规依据:民法典§469 + messages: + pass: 合同文本格式规范 + fail: 合同文本格式不规范 + references_laws: + - 《民法典》第四百六十九条 + type: deterministic + desc: "" + - rule_id: MM-ENT-020 + name: 管辖机构名称准确 + risk: medium + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院") + + 2. 如约定仲裁,仲裁机构名称是否准确 + + 3. 指定的机构是否对本合同争议有管辖权 + + + 法规依据:民法典§470 + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-021 + name: 生效条件明确性 + risk: medium + score: "4" + stages: + - id: "1" + check: required + field: 生效条件 + - id: "2" + check: required + field: 合同份数 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-022 + name: 税务信息完整性 + risk: medium + score: "1" + stages: + - id: "1" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查税务信息是否完整。 + + + 付款方式:{{付款方式}} + + 服务费是否含税:{{服务费是否含税}} + + + 评查要点: + + 1. 是否明确了服务费是否含税 + + 2. 如服务费含税,是否约定由谁开具发票 + + 3. 如不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-023 + name: 签署方详细信息校验 + risk: medium + score: "3" + stages: + - id: "1" + check: required + field: 委托方 + - id: "2" + check: required + field: 受托人 + - id: "3" + check: required + field: 委托方地址 + - id: "4" + check: required + field: 受托人地址 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + type: deterministic + desc: "" + - group: 合规性(7 条) + rules: + - rule_id: MM-ENT-024 + name: 签约背景与法律依据 + risk: high + score: "9" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景/缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + type: deterministic + desc: "" + - rule_id: MM-ENT-025 + name: 标的内容合法 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查委托事项的内容是否合法。 + + + 委托事项:{{委托事项}} + + 服务范围:{{服务范围}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 委托事项不违反法律、行政法规的强制性规定 + + 2. 委托事项不涉及应由委托人亲自处理的事项(§920) + + 3. 如涉及特殊行业(评估/代理/查新等),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154、§920 + messages: + pass: 委托事项内容合法 + fail: 委托事项存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - 《民法典》第九百二十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-026 + name: 合同主体合法有效 + risk: high + score: "3" + stages: + - id: "1" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查合同主体是否合法有效。 + + + 委托方:{{委托方}} + + 委托方证件号:{{委托方证件号}} + + 受托人:{{受托人}} + + 受托人USCC:{{受托人统一社会信用代码}} + + 受托人法定代表人:{{受托人法定代表人}} + + + 评查要点: + + 1. 委托方主体身份明确(个人有身份证号/单位有 USCC) + + 2. 受托人为单位时签约代表是否为法定代表人或有授权 + + 3. 身份证号或 USCC 格式合法(18 位) + + + 特别说明:受托人 USCC 属签署阶段字段,draft 阶段为空可接受。 + + + 法规依据:民法典§143、§171 + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + type: deterministic + desc: "" + - rule_id: MM-ENT-027 + name: 受托人资质合格 + risk: high + score: "4" + stages: + - id: "1" + check: ai + prompt: >- + >- + >- + |- + |- + |- + |- + 请检查受托人是否具有从事委托事项所需的资质。 + + + 受托人:{{受托人}} + + 受托人资质信息:{{受托人资质信息}} + + 委托事项:{{委托事项}} + + 服务标准:{{服务标准}} + + + 评查要点: + + 1. 对专业服务(资产评估、车辆评估、科技查新、专利代理等),合同中是否说明受托人具备相应资质 + + 2. 是否明确受托人从事委托事项的合法主体资格 + + 3. 是否附有资质证书/许可证明(附件中) + + 4. 对普通咨询/代理等无特殊资质要求的委托,本检查可放宽 + + + 法规依据:民法典§505 + messages: + pass: 受托人资质说明完整 + fail: 缺少受托人资质说明 + references_laws: + - 《民法典》第五百零五条 + type: deterministic + desc: "" + - rule_id: MM-ENT-028 + name: 合同基本信息完整性 + risk: high + score: "2" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 合同编号 + - id: "2" + check: required + field: 签约日期 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-029 + name: 银行账户信息完整性 + risk: medium + score: "2" + stages: + - id: "1" + check: required + field: 收款方开户银行 + - id: "2" + check: required + field: 收款方银行账号 + - id: "3" + check: required + field: 收款方账户名称 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-030 + name: 签署信息完整性 + risk: high + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: required + field: 签约日期 + - id: "2" + check: required + field: 签约地点 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - rule_id: MM-ENT-031 + name: 骑缝章检查 + risk: medium + score: "3" + applies_in: + - executed + stages: + - id: "1" + check: visual + element: 骑缝章 + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + type: deterministic + desc: "" + - group: 条件激活(3 条) + rules: + - rule_id: MM-ENT-032 + name: 保密条款完整性 + risk: medium + score: "2" + activate_if: 涉及保密信息 == '是' + stages: + - id: "1" + check: required + field: 保密条款 + - id: "2" + check: ai + prompt: >- + |- + |- + |- + |- + |- + |- + 请检查保密条款是否完整(合同涉及保密信息时)。 + + + 保密条款:{{保密条款}} + + 委托事项:{{委托事项}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、客户信息等) + + 2. 是否约定了保密期限(一般覆盖合同期内+合同后若干年) + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + type: deterministic + desc: "" + - rule_id: MM-ENT-033 + name: 转委托条款完整 + risk: medium + score: "2" + activate_if: 允许转委托 == '是' + stages: + - id: "1" + check: required + field: 转委托条款 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查转委托条款是否完整(合同允许转委托时)。 + + + 转委托条款:{{转委托条款}} + + + 评查要点: + + 1. 是否明确了转委托的条件(如需经委托人书面同意) + + 2. 是否明确了转委托的范围 + + 3. 是否约定了受托人对转委托事务的责任承担(§923) + + 4. 转委托后的报告义务是否延续 + + + 法规依据:民法典§923 + messages: + pass: 转委托条款完整 + fail: 转委托条款要素不全 + references_laws: + - 《民法典》第九百二十三条 + type: deterministic + desc: "" + - rule_id: MM-ENT-034 + name: 服务项目清单金额校验 + risk: high + score: "5" + activate_if: 含服务项目清单 == '是' + stages: + - id: "1" + check: required + field: 服务项目清单 + - id: "2" + check: ai + prompt: |- + |- + |- + |- + |- + |- + |- + 请检查服务项目清单的金额校验(合同包含清单时)。 + + + 服务项目清单:{{服务项目清单}} + + 服务费金额:{{服务费金额}} + + + 评查要点: + + 1. 各项服务单价×数量是否等于各项合计 + + 2. 各项合计相加是否等于合同服务费总金额 + + 3. 清单中金额和大小写是否一致 + + 4. 是否有项目信息遗漏(单价或数量为空) + + + 法规依据:民法典§470 + messages: + pass: 服务项目清单金额校验通过 + fail: 服务项目清单金额校验失败 + references_laws: + - 《民法典》第四百七十条 + type: deterministic + desc: "" + - group: 我方权益保护 + rules: + - rule_id: MM-ENT-035 + name: 我方缔约地位及不利条款审查 + risk: high + score: "10" + stages: + - id: "1" + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: "1" + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule + desc: 基于ctx合同全文识别我方缔约地位并评查我方权益风险。 +sub_documents: + - id: document-1778127173656 + name: 处罚决定书.当事人 + required: "false" + extract: [] +derived_fields: [] diff --git a/leaudit-oss-yaml-files/contract.evaluation.delegation/0.1/rules.yaml b/leaudit-oss-yaml-files/contract.evaluation.delegation/0.1/rules.yaml new file mode 100644 index 0000000..831348e --- /dev/null +++ b/leaudit-oss-yaml-files/contract.evaluation.delegation/0.1/rules.yaml @@ -0,0 +1,197 @@ +metadata: + type_id: contract.evaluation.delegation + name: 委托评估合同 + version: '0.1' + last_updated: '2026-04-14' + parent: contract + classification_keywords: + - 委托评估 + - 评估合同 + - 房地产评估 + description: '最小规则集,用于端到端验证印章 / 签名 / 齐缝章识别流水线。 + + ' +extract: +- group: 默认分组 + fields: + - name: 甲方名称 + type: string + required_from: draft + desc: 委托估价方(甲方)全称 + deep_retry: false + - name: 乙方名称 + type: string + required_from: draft + desc: 受托估价方(乙方、评估机构)全称 + deep_retry: false +visual_elements: + seals: + - id: 乙方评估机构章 + name: 乙方合同专用章 / 公章 + required: true + required_from: executed + allowed_types: + - 合同专用章 + - 公章 + signatures: + - id: 甲乙方代理人签名 + name: 甲方 / 乙方法定代表人或代理人手写签名 + required: true + required_from: executed + cross_page_seals: + - id: 齐缝章 + name: 页间齐缝章 + required: true + required_from: executed +rules: +- group: 印章合规 + rules: + - rule_id: EVAL-SEAL-001 + name: 合同存在合同专用章或公章 + risk: high + score: 10 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: seal + expect: type_in + allowed_types: + - 合同专用章 + - 公章 + logic: '1' + messages: + pass: 检测到合同专用章或公章 + fail: 未检测到合同专用章或公章 + type: deterministic + - rule_id: EVAL-SEAL-002 + name: 乙方评估机构章文字匹配 + risk: medium + score: 10 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: seal + expect: text_match + expected_text: 广东博亿美房地产资产评估有限公司 合同专用章 4453020060917 + logic: '1' + messages: + pass: 乙方合同专用章文字命中 + fail: 乙方合同专用章文字未命中 + type: deterministic + - rule_id: EVAL-CROSS-001 + name: 存在齐缝章 + risk: high + score: 10 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: cross_page_seal + expect: present + min_count: 1 + logic: '1' + messages: + pass: 检测到齐缝章 + fail: 未检测到齐缝章 + type: deterministic + - rule_id: EVAL-CROSS-002 + name: 齐缝章两侧对齐且完整 + risk: medium + score: 10 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: cross_page_seal + expect: complete + logic: '1' + messages: + pass: 齐缝章两半对齐且完整 + fail: 齐缝章仅检测到单侧,无法判定完整性 + type: deterministic +- group: 签字合规 + rules: + - rule_id: EVAL-SIGN-001 + name: 合同尾页存在手写签名 + risk: high + score: 10 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: signature + expect: present + min_count: 2 + logic: '1' + messages: + pass: 合同存在甲乙双方的手写签名(≥2) + fail: 合同缺少甲方或乙方的手写签名 + type: deterministic +- group: 我方权益保护 + rules: + - rule_id: EVAL-OUR-001 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.gift.charity/1.0/rules.yaml b/leaudit-oss-yaml-files/contract.gift.charity/1.0/rules.yaml new file mode 100644 index 0000000..36ee269 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.gift.charity/1.0/rules.yaml @@ -0,0 +1,788 @@ +metadata: + type_id: contract.gift.charity + name: 定向公益捐赠合同 + version: '1.0' + last_updated: '2026-04-14' + tags: + - 合同 + - 赠与 + - 公益捐赠 + references_laws: + - 慈善法第3条 + - 慈善法第34条 + - 慈善法第35条 + - 慈善法第38条 + - 慈善法第41条 + - 慈善法第42条 + - 慈善法第56条 + - 慈善法第69条 + - 慈善法第70条 + - 慈善法第75条 + - 公益事业捐赠法第4条 + - 公益事业捐赠法第6条 + - 公益事业捐赠法第12条 + - 公益事业捐赠法第16条 + - 民法典第660条 + - 民法典第490条 + description: "依据《中华人民共和国慈善法》、《中华人民共和国公益事业捐赠法》和民法典§660,\n适用于三方或四方结构的定向公益捐赠合同:\n 捐赠人 → 受赠人(慈善机构/基金会)→ 项目实施方 →(使用人,仅四方)\n\ + 常见场景:企业通过慈善机构向特定公益项目定向捐赠资金或财产。\n若为普通双方赠与,请使用 contract.gift.general 评查规则。\n" +extract: +- group: 捐赠人(甲方)信息 + fields: + - name: 捐赠人 + type: verbatim + required_from: draft + desc: 捐赠人公司全称或姓名 + deep_retry: false + - name: 捐赠人地址 + type: verbatim + required_from: draft + desc: 捐赠人地址 + deep_retry: false + - name: 捐赠人联系电话 + type: verbatim + required_from: draft + desc: 捐赠人联系电话 + deep_retry: false +- group: 受赠人(慈善机构,乙方)信息 + fields: + - name: 受赠人 + type: verbatim + required_from: draft + desc: 受赠慈善机构/基金会/慈善会的全称 + deep_retry: false + - name: 受赠人地址 + type: verbatim + required_from: draft + desc: 受赠人地址 + deep_retry: false + - name: 受赠人联系电话 + type: verbatim + required_from: draft + desc: 受赠人联系电话 + deep_retry: false + - name: 受赠人法定代表人 + type: verbatim + required_from: draft + desc: 受赠人法定代表人姓名 + deep_retry: false +- group: 项目实施方(丙方)信息 + fields: + - name: 项目实施方 + type: verbatim + required_from: draft + desc: 项目实施方(党政机关、村委会、居委会、合作社等)的全称 + deep_retry: false + - name: 项目实施方地址 + type: verbatim + required_from: draft + desc: 项目实施方地址 + deep_retry: false + - name: 项目实施方联系电话 + type: verbatim + required_from: draft + desc: 项目实施方联系电话 + deep_retry: false +- group: 使用人(丁方,仅四方结构) + fields: + - name: 使用人 + type: verbatim + required_from: draft + desc: 最终使用捐赠财产的单位(如具体合作社、村委会)。仅四方结构填写;三方结构留空 + deep_retry: false +- group: 捐赠标的与条件 + fields: + - name: 捐赠金额 + type: money + required_from: draft + desc: 捐赠资金数额(人民币,元) + deep_retry: true + - name: 捐赠金额大写 + type: verbatim + required_from: draft + desc: 捐赠金额的中文大写 + deep_retry: false + - name: 定向用途 + type: string + required_from: draft + desc: 指定的捐赠用途(项目名称、地域、人群等) + deep_retry: false + - name: 交付时间 + type: string + required_from: draft + desc: 捐赠财产的交付时间/期限约定(含拨付给使用人的环节) + deep_retry: false + - name: 交付方式 + type: string + required_from: draft + desc: 捐赠财产的交付方式(银行转账等) + deep_retry: false +- group: 收款方账户 + fields: + - name: 收款方账户名称 + type: verbatim + required_from: draft + desc: 收款方(通常为受赠人或使用人)的账户名称 + deep_retry: false + - name: 收款方开户银行 + type: verbatim + required_from: draft + desc: 收款方的开户银行 + deep_retry: false + - name: 收款方银行账号 + type: verbatim + required_from: draft + desc: 收款方的银行账号 + deep_retry: false +- group: 重要条款 + fields: + - name: 各方权利义务条款 + type: string + required_from: draft + desc: 各方的权利义务及监督链条约定(通常为合同正文的权利义务章节) + deep_retry: false + - name: 票据义务条款 + type: string + required_from: draft + desc: 关于开具公益事业捐赠专用收据/合法票据的约定 + deep_retry: false + - name: 剩余财产处置 + type: string + required_from: draft + desc: 项目终止或完成后剩余定向捐赠财产的处置约定 + deep_retry: false + - name: 信息公开条款 + type: string + required_from: draft + desc: 捐赠信息公开相关条款(是否公开捐赠人、公告渠道等) + deep_retry: false + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约情形和责任约定 + deep_retry: false + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式及管辖机构 + deep_retry: false + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力的定义、通知义务、免责约定 + deep_retry: false +- group: 签署要素 + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + deep_retry: false + - name: 捐赠人统一社会信用代码 + type: uscc + required_from: executed + desc: 捐赠人 USCC + deep_retry: false + - name: 受赠人统一社会信用代码 + type: uscc + required_from: executed + desc: 受赠人(慈善机构)USCC + deep_retry: false +- group: 合同特征分类 + fields: + - name: 合同方结构 + type: enum + required_from: draft + allowed: + - 三方 + - 四方 + desc: '合同当事方数量结构。 "三方":捐赠人—受赠人(慈善机构)—项目实施方。 "四方":捐赠人—受赠人(慈善机构)—管理人—使用人(如多个具体执行合作社/村委会)。 + + ' + deep_retry: false +rules: +- group: 完整性 + rules: + - rule_id: ZY-CHY-001 + name: 捐赠人信息完整 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 捐赠人 + - id: '2' + check: required + field: 捐赠人地址 + - id: '3' + check: required + field: 捐赠人联系电话 + messages: + pass: 捐赠人信息完整 + fail: 捐赠人信息缺失 + type: deterministic + - rule_id: ZY-CHY-002 + name: 受赠慈善机构信息完整 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 受赠人 + - id: '2' + check: required + field: 受赠人地址 + - id: '3' + check: required + field: 受赠人联系电话 + - id: '4' + check: required + field: 受赠人法定代表人 + messages: + pass: 受赠人信息完整 + fail: 受赠人信息缺失 + type: deterministic + - rule_id: ZY-CHY-003 + name: 项目实施方信息完整 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 项目实施方 + - id: '2' + check: required + field: 项目实施方地址 + - id: '3' + check: required + field: 项目实施方联系电话 + messages: + pass: 项目实施方信息完整 + fail: 项目实施方信息缺失 + type: deterministic + - rule_id: ZY-CHY-004 + name: 使用人信息完整 + risk: medium + score: 5 + activate_if: 合同方结构 == "四方" + stages: + - id: '1' + check: required + field: 使用人 + messages: + pass: 使用人信息完整 + fail: 四方合同缺少使用人信息 + type: deterministic + - rule_id: ZY-CHY-005 + name: 捐赠金额明确 + risk: high + score: 10 + stages: + - id: '1' + check: required + field: 捐赠金额 + - id: '2' + check: required + field: 捐赠金额大写 + messages: + pass: 捐赠金额约定明确 + fail: 缺少捐赠金额或大写金额 + type: deterministic + - rule_id: ZY-CHY-006 + name: 定向用途具体明确 + risk: high + score: 10 + stages: + - id: '1' + check: required + field: 定向用途 + - id: '2' + check: ai + prompt: '请判断以下定向公益捐赠合同中"定向用途"是否具体明确(依据慈善法§38)。 + + + 定向用途:{{定向用途}} + + + 判断标准: + + 1. 用途是否具体到项目名称(如"灾后重建项目""乡村振兴帮扶项目"等) + + 2. 是否明确了地域范围(如"XX 县""XX 村") + + 3. 是否明确了受益人群或用途细节(如"困难群众""XX 工作""XX 设施修复") + + 4. 是否避免"支持慈善事业""公益用途"等笼统表述 + + 5. 是否有禁止擅自改变用途的条款 + + ' + messages: + pass: 定向用途具体明确 + fail: 定向用途描述过于笼统 + type: ai_rule + - rule_id: ZY-CHY-007 + name: 交付时间与方式明确 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 交付时间 + - id: '2' + check: required + field: 交付方式 + - id: '3' + check: ai + prompt: '请判断以下定向捐赠合同的交付条款是否明确(依据公益事业捐赠法§12)。 + + + 交付时间:{{交付时间}} + + 交付方式:{{交付方式}} + + + 判断标准: + + 1. 交付期限是否以具体工作日或具体日期约定(如"30个工作日内"、"2024年12月31日前") + + 2. 交付方式是否为银行转账等可追溯方式 + + 3. 对于三方/四方结构,是否约定了受赠人→使用人的拨付环节期限 + + 4. 是否避免"尽快""合理期限"等模糊表述 + + ' + messages: + pass: 交付条款明确 + fail: 交付条款不够明确 + type: ai_rule + - rule_id: ZY-CHY-008 + name: 收款方银行账户完整 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 收款方账户名称 + - id: '2' + check: required + field: 收款方开户银行 + - id: '3' + check: required + field: 收款方银行账号 + messages: + pass: 收款账户信息完整 + fail: 收款账户信息缺失 + type: deterministic + - rule_id: ZY-CHY-009 + name: 签约日期齐全 + risk: high + score: 8 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 签约日期 + messages: + pass: 签约日期已填写 + fail: 缺少签约日期 + type: deterministic +- group: 规范性 + rules: + - rule_id: ZY-CHY-010 + name: 捐赠人统一社会信用代码合法 + risk: medium + score: 5 + applies_in: + - executed + stages: + - id: '1' + check: format + field: 捐赠人统一社会信用代码 + format: uscc + messages: + pass: 捐赠人USCC合法 + fail: 捐赠人USCC格式错误或缺失 + type: deterministic + - rule_id: ZY-CHY-011 + name: 受赠人统一社会信用代码合法 + risk: medium + score: 5 + applies_in: + - executed + stages: + - id: '1' + check: format + field: 受赠人统一社会信用代码 + format: uscc + messages: + pass: 受赠人USCC合法 + fail: 受赠人USCC格式错误或缺失 + type: deterministic +- group: 合理性 + rules: + - rule_id: ZY-CHY-012 + name: 捐赠金额大小写一致 + risk: high + score: 10 + stages: + - id: '1' + check: amount_match + number: 捐赠金额 + chinese: 捐赠金额大写 + messages: + pass: 金额大小写一致 + fail: 金额数字与大写不一致 + type: deterministic + - rule_id: ZY-CHY-013 + name: 捐赠金额为正 + risk: low + score: 3 + stages: + - id: '1' + check: compare + left: 捐赠金额 + op: '>' + right: 0 + messages: + pass: 金额为正 + fail: 金额应大于0 + type: deterministic +- group: 合规性 + rules: + - rule_id: ZY-CHY-014 + name: 定向用途属于公益范围 + risk: medium + score: 5 + stages: + - id: '1' + check: ai + prompt: '请判断以下定向捐赠的用途是否属于《慈善法§3》列举的公益范围。 + + + 定向用途:{{定向用途}} + + + 判断标准(慈善法§3 列举的慈善活动): + + 1. 扶贫、济困 + + 2. 扶老、救孤、恤病、助残、优抚 + + 3. 救灾/防控突发公共卫生事件 + + 4. 促进教育、科学、文化、卫生、体育事业发展 + + 5. 防治污染和其他公害,保护和改善生态环境 + + 6. 其他符合社会公共利益的活动 + + + 判断方法:只要用途描述能明确归入上述任一大类即通过,无需强求精确对应。 + + ' + messages: + pass: 用途属于公益范围 + fail: 用途不属于公益慈善范围或属于商业行为 + type: ai_rule + - rule_id: ZY-CHY-015 + name: 监督链条完整 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 各方权利义务条款 + - id: '2' + check: ai + prompt: '请判断以下定向捐赠合同的监督链条是否完整(依据慈善法§75、公益捐赠法§20)。 + + + 各方权利义务条款:{{各方权利义务条款}} + + + 判断标准: + + 1. 捐赠人是否保留对捐赠财产使用情况的查询和监督权 + + 2. 受赠慈善机构是否承担向使用人/项目实施方拨付前的审核义务 + + 3. 项目实施方/使用人是否承担定期向捐赠人/慈善机构反馈项目实施情况的义务 + + 4. 是否约定审计权(如年度审计、完工专项审计) + + 5. 监督权是否具有可操作性(书面反馈、现场检查等) + + ' + messages: + pass: 监督链条完整 + fail: 监督链条不完整或职责不清 + type: ai_rule + - rule_id: ZY-CHY-016 + name: 专用票据义务约定 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 票据义务条款 + - id: '2' + check: ai + prompt: '请判断以下定向捐赠合同中关于票据的约定是否符合要求(依据公益事业捐赠法§16)。 + + + 票据义务条款:{{票据义务条款}} + + + 判断标准: + + 1. 是否约定受赠人向捐赠人开具公益事业捐赠专用收据 + + 2. 票据类型是否明确(财政部门统一印/监制的公益事业捐赠票据) + + 3. 开票时限是否明确(通常为接受捐赠后10日内) + + 4. 如为四方结构,使用人向受赠人开具的合法有效票据是否约定 + + ' + messages: + pass: 票据义务约定明确 + fail: 票据义务未约定或不符合要求 + type: ai_rule + - rule_id: ZY-CHY-017 + name: 剩余财产处置约定 + risk: medium + score: 3 + stages: + - id: '1' + check: ai + prompt: '请判断以下定向捐赠合同对项目终止或完成后剩余财产的处置是否明确(依据慈善法§56)。 + + + 剩余财产处置:{{剩余财产处置}} + + + 判断标准: + + 1. 是否约定项目终止时剩余财产的处置方式 + + 2. 剩余财产归属是否明确(通常汇缴慈善机构用于其他公益或协商另作公益) + + 3. 是否禁止剩余财产被挪作他用 + + ' + messages: + pass: 剩余财产处置约定明确 + fail: 剩余财产处置未约定 + type: ai_rule + - rule_id: ZY-CHY-018 + name: 信息公开条款 + risk: low + score: 3 + stages: + - id: '1' + check: ai + prompt: '请判断以下定向捐赠合同对信息公开的约定是否合理(依据慈善法§69-§73)。 + + + 信息公开条款:{{信息公开条款}} + + + 判断标准: + + 1. 是否约定捐赠人是否同意公开其姓名/名称(通常给出同意/不同意选项) + + 2. 是否约定公告渠道(受赠人网站公告、社会公告等) + + 3. 是否保障捐赠人对捐赠财产的使用情况享有知情权 + + ' + messages: + pass: 信息公开条款合理 + fail: 信息公开条款缺失或不合理 + type: ai_rule + - rule_id: ZY-CHY-019 + name: 违约责任条款充分 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请判断以下定向捐赠合同违约责任条款是否充分。 + + + 违约责任条款:{{违约责任条款}} + + + 判断标准: + + 1. 是否覆盖各方(甲/乙/丙/丁)的违约情形 + + 2. 是否约定捐赠人不按时足额交付的责任 + + 3. 是否约定受赠人/使用人挪用或不按约定管理使用的责任 + + 4. 是否约定终止合同、追回款项等救济手段 + + 5. 是否避免"按法律规定处理"等兜底性表述 + + ' + messages: + pass: 违约责任条款充分 + fail: 违约责任条款不够充分 + type: ai_rule + - rule_id: ZY-CHY-020 + name: 争议解决条款明确 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请判断以下定向捐赠合同争议解决条款是否明确。 + + + 争议解决条款:{{争议解决条款}} + + + 判断标准: + + 1. 是否约定争议解决方式(协商/仲裁/诉讼) + + 2. 如选择诉讼,是否指定具体管辖法院(受赠人所在地或当地人民法院) + + 3. 是否避免同时约定仲裁和诉讼 + + ' + messages: + pass: 争议解决条款明确 + fail: 争议解决条款不明确 + type: ai_rule + - rule_id: ZY-CHY-021 + name: 不可抗力条款完整 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 不可抗力条款 + - id: '2' + check: ai + prompt: '请判断以下定向捐赠合同不可抗力条款是否完整。 + + + 不可抗力条款:{{不可抗力条款}} + + + 判断标准: + + 1. 是否明确不可抗力的定义或范围(地震、洪水、台风、战争、政府行为等) + + 2. 是否约定通知义务及时限(通常5-10日内书面通知) + + 3. 是否约定法律后果(继续履行/解除合同/免责) + + ' + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款不完整 + type: ai_rule + - rule_id: ZY-CHY-022 + name: 任意撤销权排除 + risk: low + score: 3 + stages: + - id: '1' + check: ai + prompt: '请判断以下定向公益捐赠合同是否合理处理任意撤销权问题(依据民法典§660、慈善法§41)。 + + + 违约责任条款:{{违约责任条款}} + + 各方权利义务条款:{{各方权利义务条款}} + + + 判断标准: + + 1. 合同是否明示"依法不可任意撤销"或援引民法典§660/慈善法§41 + + 2. 合同条款是否违反公益捐赠不可任意撤销的规定 + + 3. 如仅约定赠与方经济状况严重恶化可终止(民法典§666),是否合理 + + + 注意:公益道德义务性赠与不适用任意撤销权;不硬性要求合同明示,只要条款整体与法律规定不冲突即可 pass。 + + ' + messages: + pass: 合同符合公益捐赠不可任意撤销规定 + fail: 合同存在与公益捐赠不可任意撤销规定冲突的条款 + type: ai_rule +- group: 我方权益保护 + rules: + - rule_id: ZY-CHY-023 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.gift.general/1.0/rules.yaml b/leaudit-oss-yaml-files/contract.gift.general/1.0/rules.yaml new file mode 100644 index 0000000..1f0c4c6 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.gift.general/1.0/rules.yaml @@ -0,0 +1,673 @@ +metadata: + type_id: contract.gift.general + name: 通用赠与合同 + version: '1.0' + last_updated: '2026-04-14' + tags: + - 合同 + - 赠与 + references_laws: + - 民法典第657条 + - 民法典第658条 + - 民法典第659条 + - 民法典第660条 + - 民法典第661条 + - 民法典第662条 + - 民法典第663条 + - 民法典第490条 + description: '依据《中华人民共和国民法典》合同编第十一章(第657-666条),适用于赠与人将 + + 自己的财产无偿给予受赠人的普通赠与合同(双方结构)。 + + 若为三方/四方定向公益捐赠(捐赠人→慈善机构→项目实施方),请使用 + + contract.gift.charity 评查规则。 + + ' +extract: +- group: 赠与方信息 + fields: + - name: 赠与方 + type: verbatim + required_from: draft + desc: 赠与人姓名或公司全称 + deep_retry: false + - name: 赠与方地址 + type: verbatim + required_from: draft + desc: 赠与人住所地或公司注册地址 + deep_retry: false + - name: 赠与方联系电话 + type: verbatim + required_from: draft + desc: 赠与人联系电话 + deep_retry: false +- group: 受赠方信息 + fields: + - name: 受赠方 + type: verbatim + required_from: draft + desc: 受赠人姓名或公司全称 + deep_retry: false + - name: 受赠方地址 + type: verbatim + required_from: draft + desc: 受赠人住所地或公司注册地址 + deep_retry: false + - name: 受赠方联系电话 + type: verbatim + required_from: draft + desc: 受赠人联系电话 + deep_retry: false +- group: 赠与标的与价值 + fields: + - name: 赠与标的 + type: string + required_from: draft + desc: 赠与财产的名称、类型、数量、规格等具体描述 + deep_retry: false + - name: 赠与价值 + type: string + required_from: draft + desc: 赠与财产的价值评估或说明(资金直接描述金额,实物描述市场价/评估价) + deep_retry: false + - name: 赠与金额 + type: money + required_from: draft + desc: 赠与资金数额。仅当赠与标的为资金时填写,实物赠与不填 + deep_retry: true + - name: 赠与金额大写 + type: verbatim + required_from: draft + desc: 赠与资金的中文大写金额。仅当赠与标的为资金时填写 + deep_retry: false +- group: 交付条款 + fields: + - name: 交付方式 + type: string + required_from: draft + desc: 赠与财产的交付方式(银行转账/实物交付/权利转让等) + deep_retry: false + - name: 交付时间 + type: string + required_from: draft + desc: 交付的具体时间或期限 + deep_retry: false + - name: 登记手续 + type: string + required_from: draft + desc: 需办理的登记/过户手续及费用承担约定(如不动产过户、车辆过户) + deep_retry: false +- group: 条件与义务 + fields: + - name: 附加义务 + type: string + required_from: draft + desc: 附义务赠与中受赠人需履行的义务内容。非附义务赠与留空 + deep_retry: false + - name: 履行要求 + type: string + required_from: draft + desc: 附加义务的履行标准、期限和未履行后果。非附义务赠与留空 + deep_retry: false +- group: 撤销权条款 + fields: + - name: 法定撤销情形 + type: string + required_from: draft + desc: 合同中约定的法定撤销情形(如严重侵害赠与人、不履行扶养义务、不履行附加义务等) + deep_retry: false + - name: 任意撤销权 + type: string + required_from: draft + desc: 赠与人任意撤销权的约定(权利转移前可撤销) + deep_retry: false +- group: 其他法定条款 + fields: + - name: 瑕疵责任条款 + type: string + required_from: draft + desc: 赠与财产瑕疵责任的约定(民法典§662) + deep_retry: false + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约情形与违约责任的约定 + deep_retry: false + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式与管辖机构约定 + deep_retry: false + - name: 是否经过公证 + type: string + required_from: draft + desc: 合同是否约定经过公证,以及公证机关 + deep_retry: false +- group: 签署要素 + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + deep_retry: false + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同编号 + deep_retry: false + - name: 赠与方统一社会信用代码 + type: uscc + required_from: executed + desc: 赠与方 USCC(仅赠与方为企业/组织时填写) + deep_retry: false + - name: 受赠方统一社会信用代码 + type: uscc + required_from: executed + desc: 受赠方 USCC(仅受赠方为企业/组织时填写) + deep_retry: false +- group: 合同特征分类 + fields: + - name: 赠与方为企业 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '赠与方是否为企业/组织(而非自然人)。 填"是"的条件:赠与方为公司、事业单位、社会团体、基金会、合作社等组织。 填"否"的条件:赠与方为自然人(个人)。 + + ' + deep_retry: false + - name: 受赠方为企业 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '受赠方是否为企业/组织(而非自然人)。 填"是"的条件:受赠方为公司、事业单位、社会团体、基金会、合作社等组织。 填"否"的条件:受赠方为自然人(个人)。 + + ' + deep_retry: false + - name: 赠与标的类型 + type: enum + required_from: draft + allowed: + - 资金 + - 实物 + - 不动产 + - 股权 + - 其他 + desc: '赠与财产的性质分类。 "资金":现金或银行存款;"实物":动产(设备、物品等); "不动产":土地、房屋;"股权":公司股权/股份; "其他":知识产权、债权等无形财产。 + + ' + deep_retry: false + - name: 是否附义务 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '赠与合同是否约定受赠人需履行特定义务(民法典§661)。 填"是"的条件:合同明确约定受赠人有履行某项义务(如扶养、使用限制、用途约束等)。 填"否"的条件:纯粹无偿赠与,不附加任何义务。 + + ' + deep_retry: false + - name: 是否公益性赠与 + type: + - 是 + - 否 + required_from: draft + allowed: + - 是 + - 否 + desc: '是否具有救灾、扶贫、助残、慈善等公益、道德义务性质(民法典§658)。 填"是"的条件:赠与用于救灾、扶贫、助残、公益慈善、教育、医疗、科研等社会公益目的。 填"否"的条件:普通民事赠与(亲属间赠与、个人间赠与等)。 注意:具有公益性质的赠与依法不得任意撤销;三方/四方定向公益捐赠应使用 + contract.gift.charity 评查规则。 + + ' + deep_retry: false +rules: +- group: 完整性 + rules: + - rule_id: ZY-GEN-001 + name: 赠与方信息完整 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 赠与方 + - id: '2' + check: required + field: 赠与方地址 + - id: '3' + check: required + field: 赠与方联系电话 + messages: + pass: 赠与方信息完整 + fail: 赠与方信息缺失 + type: deterministic + - rule_id: ZY-GEN-002 + name: 受赠方信息完整 + risk: high + score: 8 + stages: + - id: '1' + check: required + field: 受赠方 + - id: '2' + check: required + field: 受赠方地址 + - id: '3' + check: required + field: 受赠方联系电话 + messages: + pass: 受赠方信息完整 + fail: 受赠方信息缺失 + type: deterministic + - rule_id: ZY-GEN-003 + name: 赠与标的明确 + risk: high + score: 10 + stages: + - id: '1' + check: required + field: 赠与标的 + - id: '2' + check: required + field: 赠与价值 + - id: '3' + check: ai + prompt: '请判断以下赠与合同中的赠与标的是否明确具体(依据民法典§657)。 + + + 赠与标的描述:{{赠与标的}} + + 赠与价值:{{赠与价值}} + + + 判断标准: + + 1. 是否明确了赠与财产的具体内容(名称、数量、规格等) + + 2. 是否对赠与财产进行了价值评估或说明 + + 3. 描述是否足够具体,不存在"部分财产""相关物品"等模糊表述 + + 4. 如为不动产赠与,是否注明了地址、面积、权属证号等 + + 5. 如为实物赠与,是否注明了品名、型号、规格、数量 + + ' + messages: + pass: 赠与标的约定明确 + fail: 赠与标的约定不明确 + type: ai_rule + - rule_id: ZY-GEN-004 + name: 赠与交付方式明确 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 交付方式 + - id: '2' + check: ai + prompt: '请判断以下赠与合同的交付条款是否明确(依据民法典§659)。 + + + 交付方式:{{交付方式}} + + 交付时间:{{交付时间}} + + 登记手续:{{登记手续}} + + + 判断标准: + + 1. 是否明确了赠与财产的交付方式(银行转账/实物交付/权利转让等) + + 2. 是否约定了具体的交付时间或期限 + + 3. 如需办理登记(不动产过户、车辆过户、股权变更等),是否约定了办理手续的期限和费用承担 + + 4. 交付的确认方式是否明确(签收、登记完成等) + + ' + messages: + pass: 交付条款明确 + fail: 交付条款不明确 + type: ai_rule + - rule_id: ZY-GEN-005 + name: 签约要素齐全 + risk: high + score: 8 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 签约日期 + - id: '2' + check: required + field: 合同编号 + messages: + pass: 签约要素齐全 + fail: 缺少签约日期或合同编号 + type: deterministic +- group: 规范性 + rules: + - rule_id: ZY-GEN-006 + name: 赠与方统一社会信用代码合法 + risk: medium + score: 5 + applies_in: + - executed + activate_if: 赠与方为企业 == "是" + stages: + - id: '1' + check: format + field: 赠与方统一社会信用代码 + format: uscc + messages: + pass: 赠与方USCC合法 + fail: 赠与方USCC格式错误或缺失 + type: deterministic + - rule_id: ZY-GEN-007 + name: 受赠方统一社会信用代码合法 + risk: medium + score: 5 + applies_in: + - executed + activate_if: 受赠方为企业 == "是" + stages: + - id: '1' + check: format + field: 受赠方统一社会信用代码 + format: uscc + messages: + pass: 受赠方USCC合法 + fail: 受赠方USCC格式错误或缺失 + type: deterministic +- group: 合理性 + rules: + - rule_id: ZY-GEN-008 + name: 赠与金额大小写一致 + risk: high + score: 10 + activate_if: 赠与标的类型 == "资金" + stages: + - id: '1' + check: amount_match + number: 赠与金额 + chinese: 赠与金额大写 + messages: + pass: 金额大小写一致 + fail: 金额数字与大写不一致 + type: deterministic + - rule_id: ZY-GEN-009 + name: 赠与金额为正 + risk: low + score: 3 + activate_if: 赠与标的类型 == "资金" + stages: + - id: '1' + check: compare + left: 赠与金额 + op: '>' + right: 0 + messages: + pass: 金额为正 + fail: 金额应大于0 + type: deterministic +- group: 合规性 + rules: + - rule_id: ZY-GEN-010 + name: 附义务条款明确 + risk: medium + score: 5 + activate_if: 是否附义务 == "是" + stages: + - id: '1' + check: required + field: 附加义务 + - id: '2' + check: ai + prompt: '请判断以下附义务赠与合同中附加义务的约定是否明确(依据民法典§661)。 + + + 附加义务:{{附加义务}} + + 履行要求:{{履行要求}} + + 赠与价值:{{赠与价值}} + + + 判断标准: + + 1. 义务内容是否具体明确(如扶养内容、使用限制、用途约束等) + + 2. 受赠人履行义务的标准和期限是否有明确约定 + + 3. 未履行义务的后果是否约定(赠与人可撤销赠与) + + 4. 附加义务是否明显超过赠与财产的价值(按§662,赠与人在附义务限度内承担瑕疵责任) + + ' + messages: + pass: 附义务条款明确 + fail: 附义务条款不明确 + type: ai_rule + - rule_id: ZY-GEN-011 + name: 撤销权条款约定 + risk: medium + score: 3 + activate_if: 是否公益性赠与 == "否" + stages: + - id: '1' + check: ai + prompt: '请判断以下赠与合同是否合理约定了撤销权条款(依据民法典§658、§663)。 + + + 任意撤销权:{{任意撤销权}} + + 法定撤销情形:{{法定撤销情形}} + + 是否经过公证:{{是否经过公证}} + + + 判断标准: + + 1. 是否约定了赠与人的任意撤销权(权利转移前可撤销,§658) + + 2. 是否列明了法定撤销情形(§663:严重侵害赠与人权益、不履行扶养义务、不履行赠与附加义务) + + 3. 撤销权行使时间是否约定(知道撤销事由之日起1年内,§663) + + 4. 如已公证或属于公益道德义务性赠与,是否提示不可任意撤销 + + ' + messages: + pass: 撤销权条款约定合理 + fail: 撤销权条款缺失或不完整 + type: ai_rule + - rule_id: ZY-GEN-012 + name: 公证条款明确 + risk: low + score: 2 + stages: + - id: '1' + check: ai + prompt: '请判断赠与合同中关于公证的约定是否明确(依据民法典§658、§660)。 + + + 是否经过公证:{{是否经过公证}} + + + 判断标准: + + 1. 合同是否明确说明了是否经过公证 + + 2. 对于不动产、大额资产等重要赠与,是否建议公证以保障受赠人权益 + + 3. 如已公证,是否提示该合同不适用任意撤销权 + + ' + messages: + pass: 公证约定明确 + fail: 公证条款缺失或不明确 + type: ai_rule + - rule_id: ZY-GEN-013 + name: 瑕疵责任约定 + risk: low + score: 3 + activate_if: 是否附义务 == "是" + stages: + - id: '1' + check: ai + prompt: '请判断附义务赠与合同中瑕疵责任的约定是否合理(依据民法典§662)。 + + + 瑕疵责任条款:{{瑕疵责任条款}} + + + 判断标准: + + 1. 是否约定赠与人的告知义务(故意隐瞒瑕疵需担责) + + 2. 是否约定附义务限度内的瑕疵责任承担 + + 3. 瑕疵告知和检验方式是否约定 + + ' + messages: + pass: 瑕疵责任约定合理 + fail: 瑕疵责任未约定或不合理 + type: ai_rule + - rule_id: ZY-GEN-014 + name: 违约责任条款充分 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请判断以下赠与合同违约责任条款是否充分(依据民法典§577-585)。 + + + 违约责任条款:{{违约责任条款}} + + + 判断标准: + + 1. 是否明确了赠与方和受赠方各自的违约情形 + + 2. 是否约定了违约金或赔偿标准 + + 3. 是否覆盖核心违约情形(赠与方不交付、受赠方不履行附加义务等) + + 4. 是否避免"按民法典处理"等兜底性表述 + + ' + messages: + pass: 违约责任条款充分 + fail: 违约责任条款不够充分 + type: ai_rule + - rule_id: ZY-GEN-015 + name: 争议解决条款明确 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请判断以下赠与合同争议解决条款是否明确。 + + + 争议解决条款:{{争议解决条款}} + + + 判断标准: + + 1. 是否约定了争议解决方式(协商/仲裁/诉讼) + + 2. 如选择仲裁,是否明确指定仲裁机构 + + 3. 如选择诉讼,是否指定管辖法院 + + 4. 不得同时约定仲裁和诉讼 + + ' + messages: + pass: 争议解决条款明确 + fail: 争议解决条款不明确 + type: ai_rule +- group: 我方权益保护 + rules: + - rule_id: ZY-GEN-016 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.lease/2.0/rules.yaml b/leaudit-oss-yaml-files/contract.lease/2.0/rules.yaml new file mode 100644 index 0000000..862d1f4 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.lease/2.0/rules.yaml @@ -0,0 +1,1750 @@ +metadata: + type_id: contract.lease + name: 不动产租赁合同 + version: '2.0' + last_updated: '2026-04-14' + classification_keywords: + - 租赁 + - 不动产 + - 房屋 + - 租房 + - 出租 + tags: + - 合同 + - 租赁 + - 不动产 + - 房屋 + description: '依据《中华人民共和国民法典》合同编·通则(第467、470、490条)及租赁合同章(第703-734条)。 + + 适用于房屋、办公场所、店铺等不动产租赁合同的评查。 + + 覆盖签署前审查(draft)和签署后审计(executed)两个阶段 + + ' + references_laws: + - 《民法典》第四百六十七条 + - 《民法典》第四百七十条 + - 《民法典》第四百九十条 + - 《民法典》第七百零三条至第七百三十四条 +extract: +- group: 合同基本信息 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同标题/项目名称 + - name: 签约背景 + type: string + required_from: draft + desc: 合同签约背景/缘由(如招标方式、协商过程等开篇段落) + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同引用的法律、法规、规章的列表 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同份数 + type: verbatim + required_from: executed + deep_retry: true + desc: 合同总份数。从原文中找到"本合同一式X份"等表述,只抽取"一式X份"这几个字(如"一式肆份"、"一式四份")。不要包含后续的分配方式 + - name: 生效条件 + type: string + required_from: executed + desc: 合同生效条件(签字盖章、经批准等) + - name: 附件列表 + type: string + desc: 合同附件的序号、名称、类型的列表 + - name: 补充协议条款 + type: string + desc: 未尽事宜补充、补充协议效力等约定。注意:不包含合同份数、生效条件、争议解决等已有专门字段的内容。如合同中没有单独的补充协议条款,填空字符串 +- group: 当事人 + fields: + - name: 出租方 + type: verbatim + required_from: draft + desc: 出租方(甲方)全称,个人为姓名,单位为公司名 + - name: 承租方 + type: verbatim + required_from: draft + desc: 承租方(乙方)全称 + - name: 出租方负责人 + type: verbatim + required_from: draft + desc: 出租方负责人姓名(单位为法定代表人,个人为本人;如未列出可为空) + - name: 承租方负责人 + type: verbatim + required_from: draft + desc: 承租方负责人姓名(单位为法定代表人,个人为本人) + - name: 出租方地址 + type: verbatim + required_from: draft + desc: 出租方住址或注册地址 + - name: 承租方地址 + type: verbatim + required_from: draft + desc: 承租方住址或注册地址 + - name: 出租方联系电话 + type: verbatim + required_from: draft + desc: 出租方联系电话 + - name: 承租方联系电话 + type: verbatim + required_from: draft + desc: 承租方联系电话 + - name: 出租方证件号 + type: verbatim + required_from: draft + desc: 出租方身份证号(个人)或统一社会信用代码(单位) + - name: 承租方统一社会信用代码 + type: verbatim + required_from: executed + desc: 承租方18位统一社会信用代码(单位承租人)。签署阶段必填,draft 阶段可为空 +- group: 租赁标的 + fields: + - name: 租赁物描述 + type: string + required_from: draft + desc: 租赁物的名称、坐落地址、建筑面积的完整描述 + - name: 租赁用途 + type: string + required_from: draft + desc: 租赁物的约定使用用途(居住、办公、商用等) + - name: 履行地点 + type: verbatim + desc: 租赁房屋坐落地点(履行地点) + - name: 出租方权属声明 + type: string + desc: 出租方对房屋所有权/处分权的明确承诺条款原文,以及产权纠纷的责任承担约定。关键词包括'承诺合法取得所有权''有权对房屋进行处分''产权证明''抵押/查封情况'等。若合同未约定,填空字符串 +- group: 租金与支付 + fields: + - name: 月租金金额 + type: money + required_from: draft + desc: 月租金数字金额(如有) + - name: 月租金大写 + type: verbatim + required_from: draft + desc: 月租金中文大写金额(保留原文,如'贰仟捌佰元整') + - name: 年租金金额 + type: money + required_from: draft + desc: 年租金数字金额(如有) + - name: 年租金大写 + type: verbatim + required_from: draft + desc: 年租金中文大写金额(保留原文) + - name: 租金结算方式 + type: string + desc: 实际采用的租金结算方式(月付/季付/年付/一次性等),根据付款条款判断 + - name: 租金支付方式 + type: string + required_from: draft + desc: 付款周期、方式、时间节点、逾期处理的完整描述 + - name: 收款方开户银行 + type: verbatim + required_from: draft + desc: 收款方(通常为出租方)银行开户行全称 + - name: 收款方银行账号 + type: verbatim + required_from: draft + desc: 收款方银行账号 + - name: 收款方账户名称 + type: verbatim + required_from: draft + desc: 收款方账户名称(与出租方主体一致) + - name: 租金是否含税 + type: + - 是 + - 否 + desc: 租金金额是否已包含税费。填"是":合同中明确"含税"或"租金已包含税费";填"否":另行约定税费分担或未说明 + - name: 约定押金 + type: + - 是 + - 否 + desc: 合同中是否约定了押金、保证金或类似担保金额。填"是"的条件:明确约定"押金""保证金""定金"及其金额。填"否"的条件:未约定任何押金/保证金(一次性付清、无需担保等) +- group: 租赁期限 + fields: + - name: 租赁起始日期 + type: date + desc: 租赁期限起始日期 + - name: 租赁结束日期 + type: date + desc: 租赁期限结束日期 +- group: 履行方式 + fields: + - name: 交付方式 + type: string + desc: 房屋移交的方式和程序:交付时间、交付状态(空房/带装修/含家具)、钥匙和设施移交、验收程序。不含租金支付相关内容 +- group: 条款 + fields: + - name: 维修责任条款 + type: string + desc: 出租方和承租方的维修责任分工、费用负担 + - name: 转租条款 + type: string + desc: 是否允许转租、转租条件的完整约定 + - name: 退租返还条款 + type: string + desc: 租赁期满返还条件、返还状态、优先续租权的约定 + - name: 违约责任条款 + type: string + desc: 违约责任的完整条款内容(双方违约情形和责任) + - name: 违约金金额 + type: money + desc: 违约金具体金额或计算基数 + - name: 违约金计算方式 + type: string + desc: 违约金计算标准(固定金额/比例/按日计算等) + - name: 争议解决条款 + type: string + desc: 争议解决方式的完整条款(协商/诉讼/仲裁) + - name: 管辖机构 + type: verbatim + desc: 指定的法院或仲裁机构名称 + - name: 不可抗力条款 + type: string + desc: 不可抗力定义、通知义务、免责约定的完整条款 + - name: 变更解除终止条款 + type: string + desc: 合同变更、解除、终止的条件和程序 +- group: 特殊约定 + fields: + - name: 涉及保密信息 + type: + - 是 + - 否 + desc: 合同中是否存在保密条款或涉及商业秘密、技术秘密。填"是"的条件:出现"保密""商业秘密""技术秘密""不得泄露"等关键词且有实质条款。填"否"的条件:普通房屋租赁,无任何保密相关条款 +- group: 居间方(可选) + fields: + - name: 居间方名称 + type: verbatim + desc: 居间方/中介方公司全称(如有) + - name: 居间方服务费 + type: string + desc: 居间服务费金额及支付方式(如有),如'甲方1250元、乙方1250元' + - name: 居间方负责人 + type: verbatim + desc: 居间方法定代表人或负责人姓名(如有) + - name: 约定居间方 + type: + - 是 + - 否 + desc: 合同中是否有居间方/中介方参与。填'是'的条件:出现'中介方''居间方''丙方''中介服务费'等关键词且有具体机构名称。填'否'的条件:甲乙双方直接签订,无任何中介参与 +visual_elements: + seals: + - id: 出租方签章 + name: 出租方签字或公章 + required: true + required_from: executed + - id: 承租方签章 + name: 承租方盖章 + required: true + required_from: executed + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed +rules: +- group: 默认规则组 + rules: + - rule_id: ZL-LEASE-001 + name: 当事人信息齐全 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 出租方 + - id: '2' + check: required + field: 承租方 + logic: 1 AND 2 + messages: + pass: 出租方和承租方信息齐全 + fail: 缺少出租方或承租方信息 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-002 + name: 当事人信息准确完整 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同当事人(出租方和承租方)的信息是否准确完整。 + + + 出租方:{{出租方}} + + 出租方负责人:{{出租方负责人}} + + 出租方地址:{{出租方地址}} + + 出租方联系电话:{{出租方联系电话}} + + 出租方证件号:{{出租方证件号}} + + + 承租方:{{承租方}} + + 承租方负责人:{{承租方负责人}} + + 承租方地址:{{承租方地址}} + + 承租方联系电话:{{承租方联系电话}} + + 承租方统一社会信用代码:{{承租方统一社会信用代码}} + + + 评查要点: + + 1. 双方主体名称是否清晰可辨(个人为姓名,单位为公司名) + + 2. 出租方为个人时是否有身份证号;出租方为单位时是否有统一社会信用代码 + + 3. 承租方为单位时是否有法定代表人 + + 4. 双方联系地址和电话是否齐全 + + + 特别说明:承租方统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + 只有在已签署的合同中(合同编号、签约日期已填写)仍缺失 USCC,才应判 fail。 + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 当事人信息准确完整 + fail: 当事人信息有缺失或不准确 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-003 + name: 租赁标的明确 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查租赁合同中租赁标的信息是否明确。 + + + 租赁物描述:{{租赁物描述}} + + 租赁用途:{{租赁用途}} + + + 评查要点: + + 1. 是否明确了租赁物的名称(房屋、办公场所、店铺等) + + 2. 是否明确了租赁物的具体位置/地址 + + 3. 是否明确了租赁物的面积/规格 + + 4. 是否明确了租赁用途(居住、办公、商用等) + + 5. 租赁用途应当合法且与租赁物性质相符 + + + 法规依据:民法典§703、§704 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 租赁标的信息明确 + fail: 租赁标的信息不明确或不完整 + references_laws: + - 《民法典》第七百零三条 + - 《民法典》第七百零四条 + - rule_id: ZL-LEASE-004 + name: 租赁期限合规 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 租赁起始日期 + - id: '2' + check: required + field: 租赁结束日期 + - id: '3' + check: ai + prompt: '请检查租赁合同的租赁期限是否合规。 + + + 起始日期:{{租赁起始日期}} + + 结束日期:{{租赁结束日期}} + + 退租返还条款(含续租约定):{{退租返还条款}} + + + 评查要点: + + 1. 起止日期是否明确 + + 2. 租赁期限是否超过二十年(超过二十年的部分无效) + + 3. 租赁期限六个月以上的应当采用书面形式(本合同为书面) + + 4. 如有续租约定,续租后累计期限是否可能超过二十年 + + + 法规依据:民法典§705、§707 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 AND 3 + messages: + pass: 租赁期限明确且合规 + fail: 租赁期限缺失或超过法定上限 + references_laws: + - 《民法典》第七百零五条 + - 《民法典》第七百零七条 + - rule_id: ZL-LEASE-005 + name: 年租金完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 年租金金额 + - id: '2' + check: required + field: 年租金大写 + - id: '3' + check: amount_match + number: 年租金金额 + chinese: 年租金大写 + logic: 1 AND 2 AND 3 + messages: + pass: 年租金完整且大小写一致 + fail: 年租金缺失或大小写不一致(如有年租金金额,必须有对应的大写) + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-005a + name: 月租金完整 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 月租金金额 + - id: '2' + check: required + field: 月租金大写 + - id: '3' + check: amount_match + number: 月租金金额 + chinese: 月租金大写 + logic: 1 AND 2 AND 3 + messages: + pass: 月租金完整且大小写一致 + fail: 月租金缺失或大小写不一致(如有月租金金额,必须有对应的大写) + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-006 + name: 租金及支付方式完整 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 租金支付方式 + - id: '2' + check: ai + prompt: '请检查租金支付方式是否完整。 + + + 租金结算方式:{{租金结算方式}} + + 月租金金额:{{月租金金额}} + + 年租金金额:{{年租金金额}} + + 租金支付方式:{{租金支付方式}} + + 收款方开户银行:{{收款方开户银行}} + + 收款方银行账号:{{收款方银行账号}} + + + 评查要点: + + 1. 支付方式是否明确(银行转账、现金等) + + 2. 支付周期是否明确(月付、季付、年付、一次性等) + + 3. 是否约定了逾期支付的后果(滞纳金、解除权等) + + 4. 付款账户信息是否完整(开户行、账号、户名) + + + 法规依据:民法典§721、§722 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 租金支付方式约定完整 + fail: 租金支付方式约定不完整 + references_laws: + - 《民法典》第七百二十一条 + - 《民法典》第七百二十二条 + - rule_id: ZL-LEASE-007 + name: 合同地点具体准确 + risk: medium + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查合同地点信息是否具体准确。 + + + 履行地点(租赁房屋位置):{{履行地点}} + + 签约地点:{{签约地点}} + + 出租方地址:{{出租方地址}} + + 承租方地址:{{承租方地址}} + + + 评查要点: + + 1. 租赁房屋的坐落地点是否具体(到具体门牌号/楼层) + + 2. 签约地点是否明确 + + 3. 双方地址是否完整可供送达 + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同地点信息具体准确 + fail: 合同地点信息不具体或缺失 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-008 + name: 履行方式具体准确 + risk: medium + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同履行方式是否具体准确。 + + + 交付方式:{{交付方式}} + + 租赁用途:{{租赁用途}} + + + 评查要点: + + 1. 房屋交付时间和条件是否明确 + + 2. 是否约定了钥匙、设施设备的移交 + + 3. 交付状态(空房/带装修/带家具等)是否明确 + + 4. 验收或确认程序是否约定 + + + 法规依据:民法典§708、§709 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 履行方式具体明确 + fail: 履行方式不具体或缺失 + references_laws: + - 《民法典》第七百零八条 + - 《民法典》第七百零九条 + - rule_id: ZL-LEASE-009 + name: 维修责任约定 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 维修责任条款 + - id: '2' + check: ai + prompt: '请检查维修责任条款是否明确。 + + + 维修责任条款:{{维修责任条款}} + + + 评查要点: + + 1. 是否明确了出租方的维修义务范围(主体结构、设施设备等) + + 2. 是否明确了承租方的维修义务范围(日常维护、合理使用等) + + 3. 维修费用的承担方是否明确 + + 4. 是否约定维修期间的租金处理(如长期维修时租金减免) + + 5. 因承租方过错导致损坏的维修责任是否明确 + + + 法规依据:民法典§712、§713 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 维修责任约定明确 + fail: 维修责任缺失或不明确 + references_laws: + - 《民法典》第七百一十二条 + - 《民法典》第七百一十三条 + - rule_id: ZL-LEASE-010 + name: 转租条款 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 转租条款 + - id: '2' + check: ai + prompt: '请检查转租条款是否明确。 + + + 转租条款:{{转租条款}} + + + 评查要点: + + 1. 是否明确约定了是否允许转租 + + 2. 如允许转租,是否约定了转租的条件和程序(如需经出租方书面同意) + + 3. 如禁止转租,是否明确了违反禁止转租的后果 + + 4. 是否约定了转租后的责任承担 + + + 法规依据:民法典§716、§717、§718 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 转租条款约定明确 + fail: 转租条款缺失或不明确 + references_laws: + - 《民法典》第七百一十六条 + - 《民法典》第七百一十七条 + - 《民法典》第七百一十八条 + - rule_id: ZL-LEASE-011 + name: 租赁物返还与退租 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 退租返还条款 + - id: '2' + check: ai + prompt: '请检查退租和返还条款是否完整。 + + + 退租返还条款:{{退租返还条款}} + + + 评查要点: + + 1. 是否约定了租赁物返还的条件和时间 + + 2. 是否约定了返还时租赁物应处的状态(恢复原状、正常损耗等) + + 3. 是否约定了承租方的优先承租权(房屋租赁中法定享有) + + 4. 是否约定了提前退租的条件和违约责任 + + 5. 是否约定了租赁物返还时的交接验收程序 + + + 法规依据:民法典§733、§734 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 退租及返还条款完整 + fail: 退租及返还条款不完整 + references_laws: + - 《民法典》第七百三十三条 + - 《民法典》第七百三十四条 + - rule_id: ZL-LEASE-012 + name: 违约责任形式明确 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请检查违约责任条款是否形式明确。 + + + 违约责任条款:{{违约责任条款}} + + + 评查要点: + + 1. 是否明确了违约方和违约情形 + + 2. 责任形式是否具体(支付违约金、赔偿损失、继续履行等) + + 3. 是否覆盖双方(出租方违约和承租方违约均有约定) + + 4. 是否有兜底条款(如未尽事宜如何处理) + + + 法规依据:民法典§577 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 违约责任形式明确 + fail: 违约责任形式不明确或缺失 + references_laws: + - 《民法典》第五百七十七条 + - rule_id: ZL-LEASE-013 + name: 违约金条款完整合理 + risk: high + score: 6 + stages: + - id: '1' + check: required + field: 违约金金额 + - id: '2' + check: ai + prompt: '请检查违约金条款是否完整合理。 + + + 违约金金额:{{违约金金额}} + + 违约金计算方式:{{违约金计算方式}} + + 违约责任条款:{{违约责任条款}} + + 月租金金额:{{月租金金额}} + + 年租金金额:{{年租金金额}} + + + 评查要点: + + 1. 违约金金额或计算方式是否明确 + + 2. 违约金标准是否合理(约定过高可依法调整,一般不超过造成损失的 30%) + + 3. 是否约定了逾期支付租金的违约金 + + 4. 是否覆盖双方违约情形 + + + 法规依据:民法典§585 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 违约金条款完整合理 + fail: 违约金条款不完整或标准不合理 + references_laws: + - 《民法典》第五百八十五条 + - rule_id: ZL-LEASE-014 + name: 争议解决方式明确 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请检查争议解决方式是否明确。 + + + 争议解决条款:{{争议解决条款}} + + 管辖机构:{{管辖机构}} + + + 评查要点: + + 1. 是否明确了争议解决方式(协商/诉讼/仲裁,只能择一作为最终方式) + + 2. 不能同时约定仲裁和诉讼(互斥) + + 3. 如约定诉讼,是否指定了具体的管辖法院 + + 4. 如约定仲裁,是否指定了具体的仲裁机构 + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式不明确或约定冲突 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-016 + name: 不可抗力条款完整性 + risk: medium + score: 2 + stages: + - id: '1' + check: required + field: 不可抗力条款 + - id: '2' + check: ai + prompt: '请检查不可抗力条款是否完整(三要素)。 + + + 不可抗力条款:{{不可抗力条款}} + + + 评查要点(三要素): + + 1. 是否明确了不可抗力的定义/类型范围 + + 2. 是否约定了通知义务和通知时限 + + 3. 是否约定了免责后果和合同处理方式(如延期履行、解除合同等) + + + 法规依据:民法典§180、§590 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 不可抗力条款完整 + fail: 不可抗力条款缺失或要素不全 + references_laws: + - 《民法典》第一百八十条 + - 《民法典》第五百九十条 + - rule_id: ZL-LEASE-017 + name: 变更解除终止条款完整性 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查变更、解除、终止条款是否完整。 + + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 是否约定了合同变更的条件和程序 + + 2. 是否约定了合同解除/终止的条件(法定解除、约定解除、协商解除) + + 3. 是否约定了终止通知期限 + + 4. 是否有对己方(承租方)的保护条款 + + 5. 是否约定了合同终止后的处理(结算、返还等) + + + 法规依据:民法典§543、§562、§563 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整 + references_laws: + - 《民法典》第五百四十三条 + - 《民法典》第五百六十二条 + - 《民法典》第五百六十三条 + - rule_id: ZL-LEASE-019 + name: 附件条款完整性 + risk: low + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查附件条款是否具备基本形式要素。 + + + 附件列表:{{附件列表}} + + + 评查要点(满足任一核心要素即可 pass,多缺给 warn,全缺给 fail): + + 1. 【核心】至少列明了附件的名称(如"屋内资产清单""权属证明""家私家电清单"等) + + 2. 【加分】附件有序号标识(如"附件一"、"附件1") + + 3. 【加分】附件与合同正文有引用或关联说明 + + 4. 【加分】有"附件与合同具有同等法律效力"的声明 + + + 注意:租赁合同中附件本身是可选的辅助材料,只要名称清晰就视为合格; + + 只有完全缺失附件名称或无任何可识别附件信息时才判 fail。 + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 附件条款已列明 + fail: 附件条款完全缺失 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-022 + name: 合同名称合法有效 + risk: medium + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查合同名称是否合法有效。 + + + 合同名称:{{合同名称}} + + 租赁物描述:{{租赁物描述}} + + 租赁用途:{{租赁用途}} + + + 评查要点: + + 1. 合同名称必须与合同内容一致(名为"租赁合同"且实际为租赁关系) + + 2. 符合民法典有名合同特征的应当采用标准合同名称(如"房屋租赁合同") + + 3. 合同名称不应使用会引起误解的名称 + + + 法规依据:民法典§467 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + references_laws: + - 《民法典》第四百六十七条 + - rule_id: ZL-LEASE-015 + name: 管辖机构名称准确 + risk: medium + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查管辖机构名称是否准确。 + + + 管辖机构:{{管辖机构}} + + 争议解决条款:{{争议解决条款}} + + + 评查要点: + + 1. 如约定诉讼,法院名称是否准确规范(如"XX市XX区人民法院",而非简称) + + 2. 如约定仲裁,仲裁机构名称是否准确(如"中国国际经济贸易仲裁委员会") + + 3. 指定的机构是否对本合同争议有管辖权 + + 4. 名称不应模糊(如仅写"当地法院"是不合格的) + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 管辖机构名称准确 + fail: 管辖机构名称不准确或模糊 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-018 + name: 生效条件明确性 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 生效条件 + - id: '2' + check: required + field: 合同份数 + logic: 1 AND 2 + messages: + pass: 生效条件和合同份数明确 + fail: 生效条件或合同份数缺失 + references_laws: + - 《民法典》第五百零二条 + - rule_id: ZL-LEASE-027 + name: 税务信息完整性 + risk: medium + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查税务信息是否完整。 + + + 租金支付方式:{{租金支付方式}} + + 租金是否含税:{{租金是否含税}} + + + 评查要点: + + 1. 是否明确了租金是否含税(含税一口价或另行约定税费分担) + + 2. 如租金含税,是否约定由谁开具发票 + + 3. 如租金不含税,是否约定税费承担方 + + + 法规依据:民法典§470 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 税务信息完整 + fail: 税务信息不完整 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-029 + name: 签署方详细信息校验 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 出租方 + - id: '2' + check: required + field: 承租方 + - id: '3' + check: required + field: 出租方地址 + - id: '4' + check: required + field: 承租方地址 + logic: 1 AND 2 AND 3 AND 4 + messages: + pass: 签署方详细信息完整 + fail: 签署方详细信息有缺失 + - rule_id: ZL-LEASE-021 + name: 签约背景与法律依据 + risk: high + score: 8 + stages: + - id: '1' + check: ai + prompt: '请检查合同的签约背景和法律依据是否准确。 + + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + 生效条件:{{生效条件}} + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点: + + 1. 签约背景或缘由是否存在(如招标方式、协商过程、签约原因等) + + 2. 合同依据的法律、法规、规章必须准确、有效(不能引用已废止的法律) + + 3. 合同条款不违反法律禁止性规定,并具有实用性 + + 4. 合同按法律法规规定的方式生效、变更、解除并办理相应手续 + + + 法规依据:民法典§153、§502 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第五百零二条 + - rule_id: ZL-LEASE-024 + name: 标的内容合法 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查租赁标的内容是否合法。 + + + 租赁物描述:{{租赁物描述}} + + 租赁用途:{{租赁用途}} + + + 评查要点: + + 1. 租赁物不违反法律、行政法规的强制性规定(如不得租赁违章建筑、查封财产等) + + 2. 租赁用途不违反法律法规(如不得用于违法经营、危险品仓储等超出建筑设计用途的使用) + + 3. 如涉及特殊用途(如商用、经营),是否具备相应资质或许可 + + 4. 不违背公序良俗 + + + 法规依据:民法典§153、§154 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 租赁标的内容合法 + fail: 租赁标的内容存在违法情形 + references_laws: + - 《民法典》第一百五十三条 + - 《民法典》第一百五十四条 + - rule_id: ZL-LEASE-025 + name: 合同主体合法有效 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同主体是否合法有效。 + + + 出租方:{{出租方}} + + 出租方证件号:{{出租方证件号}} + + 承租方:{{承租方}} + + 承租方统一社会信用代码:{{承租方统一社会信用代码}} + + 承租方负责人:{{承租方负责人}} + + + 评查要点: + + 1. 承租方为单位时是否有法定代表人 + + 2. 出租方身份证号或 USCC 是否齐全 + + 3. 主体身份证明材料格式是否有效(身份证 18 位、USCC 18 位) + + + 特别说明:承租方统一社会信用代码属于签署阶段(executed)字段, + + 在草稿阶段(draft)合同模板中为空是正常情况,不作为判 fail 依据。 + + 只有在已签署的合同中(合同编号、签约日期已填写)仍缺失 USCC,才应判 fail。 + + + 出租方处分权请在 ZL-LEASE-026 专门检查,本规则不涉及。 + + + 法规依据:民法典§143、§171 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同主体合法有效 + fail: 合同主体存在合法性问题 + references_laws: + - 《民法典》第一百四十三条 + - 《民法典》第一百七十一条 + - rule_id: ZL-LEASE-026 + name: 出租方处分权 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查出租方是否具有房屋处分权。 + + + 出租方:{{出租方}} + + 租赁物描述:{{租赁物描述}} + + 出租方权属声明:{{出租方权属声明}} + + + 评查要点: + + 1. 合同中是否有出租方"合法取得所有权"或"有权对房屋进行处分"的承诺条款(重点看"出租方权属声明"字段) + + 2. 是否约定了产权纠纷时的责任承担 + + 3. 是否说明了权属证明(房产证、不动产权证、经济联合社证明等) + + 4. 对商业租赁,是否涉及必要的经营许可资质 + + + 注意:评判主要依据"出租方权属声明"字段。若该字段有明确的处分权承诺内容,判 pass 或 warn; + + 若该字段为空或仅有模糊表述(如"双方协商一致"),判 fail。 + + + 法规依据:民法典§505 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 出租方处分权已明示 + fail: 缺少出租方处分权说明 + references_laws: + - 《民法典》第五百零五条 + - rule_id: ZL-LEASE-028 + name: 合同基本信息完整性 + risk: high + score: 1 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 合同编号 + - id: '2' + check: required + field: 签约日期 + logic: 1 AND 2 + messages: + pass: 合同编号与签约日期已填写 + fail: 合同编号或签约日期缺失(草稿阶段可能未填写,签署后必须填写) + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-030 + name: 银行账户信息完整性 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 收款方开户银行 + - id: '2' + check: required + field: 收款方银行账号 + - id: '3' + check: required + field: 收款方账户名称 + logic: 1 AND 2 AND 3 + messages: + pass: 收款方银行账户信息完整 + fail: 收款方银行账户信息不完整 + references_laws: + - 《民法典》第四百七十条 + - rule_id: ZL-LEASE-031 + name: 签署信息完整性 + risk: high + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 签约日期 + - id: '2' + check: required + field: 签约地点 + logic: 1 AND 2 + messages: + pass: 签约日期与签约地点已填写 + fail: 签约日期或签约地点缺失 + references_laws: + - 《民法典》第四百九十条 + - rule_id: ZL-LEASE-032 + name: 骑缝章检查 + risk: medium + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: 骑缝章 + logic: '1' + messages: + pass: 骑缝章齐全 + fail: 缺少骑缝章或骑缝章不规范 + references_laws: + - 《民法典》第四百九十条 + - rule_id: ZL-LEASE-033 + name: 保密条款完整性 + risk: medium + score: 2 + activate_if: 涉及保密信息 == '是' + stages: + - id: '1' + check: ai + prompt: '请检查保密条款是否完整(合同涉及保密信息时)。 + + + 租赁物描述:{{租赁物描述}} + + 租赁用途:{{租赁用途}} + + + 评查要点(三要素): + + 1. 是否明确了保密信息的范围(商业秘密、技术秘密、个人信息等) + + 2. 是否约定了保密期限 + + 3. 是否约定了违反保密义务的违约责任 + + + 法规依据:民法典§501 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 保密条款完整 + fail: 保密条款要素不全 + references_laws: + - 《民法典》第五百零一条 + - rule_id: ZL-LEASE-034 + name: 押金/保证金条款 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查押金/保证金条款是否完整。 + + + 约定押金:{{约定押金}} + + 租金支付方式:{{租金支付方式}} + + 退租返还条款:{{退租返还条款}} + + + 评查要点: + + 1. **如果约定押金为"是"**:检查押金金额、退还条件、退还时间、可扣减情形是否明确,金额是否合理(一般为 1-3 个月租金) + + 2. **如果约定押金为"否"或未明确**:给予预警,说明租赁合同通常应约定押金以保障出租方权益,建议补充 + + + 法规依据:民法典§586、§587 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 押金条款完整 + fail: 押金条款缺失或要素不全(租赁合同建议约定押金) + references_laws: + - 《民法典》第五百八十六条 + - 《民法典》第五百八十七条 + - rule_id: ZL-LEASE-035 + name: 居间方条款完整性 + risk: low + score: 2 + activate_if: 约定居间方 == '是' + stages: + - id: '1' + check: ai + prompt: '请检查居间方/中介方条款是否完整(合同有居间方参与时)。 + + + 居间方名称:{{居间方名称}} + + 居间方服务费:{{居间方服务费}} + + 居间方负责人:{{居间方负责人}} + + + 评查要点: + + 1. 居间方名称是否明确(公司全称) + + 2. 居间服务费金额及支付方式是否明确(谁支付、支付金额、支付时间) + + 3. 居间方责任界定是否清晰(见证服务、促成交易、纠纷责任划分) + + 4. 服务费支付条件是否合理(如''合同取消不影响服务费收取''是否合理) + + + 法规依据:民法典§961-965(居间合同) + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 居间方条款完整 + fail: 居间方条款要素不全(缺少名称、服务费或责任界定) + references_laws: + - 《民法典》第九百六十一条 + - 《民法典》第九百六十三条 + - 《民法典》第九百六十五条 + - rule_id: ZL-LEASE-036 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.loan.general/1.0/rules.yaml b/leaudit-oss-yaml-files/contract.loan.general/1.0/rules.yaml new file mode 100644 index 0000000..8d880f3 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.loan.general/1.0/rules.yaml @@ -0,0 +1,553 @@ +metadata: + type_id: contract.loan.general + name: 借款合同 + version: '1.0' + last_updated: '2026-04-11' + parent: contract + inherits_from: + - base.common + - base.party_info + classification_keywords: + - 借款 + - 借贷 + - 民间借贷 + tags: + - compliance + - high_priority + - prc_civil_code + - usury_check + applies_to_jurisdictions: + - prc + references_laws: + - 《民法典》第 667-680 条(借款合同章) + - 《最高人民法院关于审理民间借贷案件适用法律若干问题的规定》(2020 修正) + - 《民法典》第 387-462 条(担保物权) + description: '适用于民间借贷 / 企业间借款 / 自然人借款等场景。 + + 覆盖民法典第 667-680 条(借款合同章)和最高法民间借贷司法解释。 + + 评查重点:利率合法性、担保完备性、禁止利滚利。 + + ' + confidence_profile: + allow_weight_override: false + field_confidence_defaults: + 借款本金: 0.95 + 年利率: 0.95 +extract: +- group: 当事人 + fields: + - name: 借款人姓名 + type: verbatim + required_from: draft + deep_retry: false + - name: 借款人身份证 + type: chinese-id + required_from: executed + deep_retry: false + - name: 借款人地址 + type: verbatim + required_from: draft + deep_retry: false + - name: 出借人姓名 + type: verbatim + required_from: draft + deep_retry: false + - name: 出借人身份证 + type: chinese-id + required_from: executed + deep_retry: false +- group: 合同信息 + fields: + - name: 合同编号 + type: verbatim + required_from: draft + deep_retry: false + - name: 签订日期 + type: date + required_from: executed + deep_retry: false + - name: 借款日期 + type: date + required_from: executed + deep_retry: false + - name: 还款日期 + type: date + required_from: executed + deep_retry: false +- group: 借款条款 + fields: + - name: 借款本金 + type: money + required_from: draft + deep_retry: false + - name: 借款本金大写 + type: verbatim + required_from: executed + deep_retry: false + - name: 借款用途 + type: string + required_from: draft + deep_retry: false + - name: 年利率 + type: money + required_from: draft + deep_retry: false + - name: 还款方式 + type: enum + required_from: draft + allowed: + - 一次性还本付息 + - 分期还款 + - 先息后本 + deep_retry: false + - name: 违约金金额 + type: money + required_from: draft + deep_retry: false + - name: 违约责任 + type: string + required_from: draft + deep_retry: false + - name: 争议解决 + type: string + required_from: draft + deep_retry: false +- group: 'Multi-entity: 担保人(核心特性展示)' + fields: + - name: 担保人 + type: multi_entity + required_from: draft + fields: + - name: 姓名 + type: verbatim + required_from: draft + deep_retry: false + - name: 身份证号 + type: chinese-id + required_from: executed + deep_retry: false + - name: 地址 + type: verbatim + required_from: draft + deep_retry: false + - name: 担保金额 + type: money + required_from: draft + deep_retry: false + - name: 担保方式 + type: enum + required_from: draft + allowed: + - 一般保证 + - 连带责任保证 + deep_retry: false + - name: 担保期限 + type: date + required_from: draft + deep_retry: false + deep_retry: false +derived_fields: +- name: 借款总天数 + type: integer + compute: (还款日期 - 借款日期).days + depends_on: + - 借款日期 + - 还款日期 +- name: 违约金比例 + type: money + compute: 违约金金额 / 借款本金 + depends_on: + - 违约金金额 + - 借款本金 +- name: 担保总额 + type: money + compute: sum(担保人[*].担保金额) + depends_on: + - 担保人 +- name: 担保人数量 + type: integer + compute: count(担保人) + depends_on: + - 担保人 +- name: LPR_4x + type: money + compute: external.lpr_lookup(tenor='1y') * 4 + depends_on: [] +visual_elements: + seals: + - id: 借款人签章 + name: 借款人签章 + required: true + required_from: executed + expected_text_match: + field: 借款人姓名 + - id: 出借人签章 + name: 出借人签章 + required: true + required_from: executed + expected_text_match: + field: 出借人姓名 + signatures: + - id: 借款人签名 + name: 借款人手写签名 + required: true + required_from: executed + expected_text_match: + field: 借款人姓名 + - id: 出借人签名 + name: 出借人手写签名 + required: true + required_from: executed + expected_text_match: + field: 出借人姓名 +rules: +- group: 合同主体 + rules: + - rule_id: JK-001 + name: 借款主体合法性 + risk: high + score: 10 + stages: + - id: '1' + check: required + fields: + - 借款人姓名 + - 出借人姓名 + logic: and + - id: '2' + check: format + field: 借款人身份证 + format: chinese_id + - id: '3' + check: format + field: 出借人身份证 + format: chinese_id + logic: 1 AND 2 AND 3 + messages: + pass: 借贷双方身份合法 + fail: 借贷双方身份信息不完整或身份证校验失败 + references_laws: + - 《民法典》第 667 条 + type: deterministic +- group: 利率合规 + rules: + - rule_id: JK-002 + name: 利率不超过法定上限(LPR × 4 倍) + risk: high + score: 20 + activate_if: derived.LPR_4x != null + stages: + - id: '1' + check: required + field: 年利率 + - id: '2' + check: compare + left: 年利率 + op: < + right_field: derived.LPR_4x + logic: 1 AND 2 + messages: + pass: 年利率 {{年利率}} 未超过 LPR 4 倍({{derived.LPR_4x}}) + fail: 年利率 {{年利率}} 超过法定上限 LPR 4 倍({{derived.LPR_4x}}) + references_laws: + - 《最高人民法院关于审理民间借贷案件适用法律若干问题的规定》(2020 修正)第 25 条 + - 《民法典》第 680 条(禁止高利放贷) + remediation: + suggestions: + - 本合同年利率 {{年利率}},超过当前 LPR 4 倍上限 {{derived.LPR_4x}} + - 按 2020 修正司法解释,超出部分法院不予保护 + - 请降低利率至 LPR 4 倍以内 + actions: + - type: edit_field + label: 降低年利率 + field: 年利率 + prompt: 请输入合法年利率(不超过 {{derived.LPR_4x}}) + - type: escalate + label: 疑似高利贷,上报合规组 + role: 合规专员 + type: deterministic + - rule_id: JK-GROUP-INTEREST + name: 利率合规总判定 + risk: high + score: 30 + logic: JK-002 AND JK-005 AND JK-006 + messages: + pass: 利率与违约条款全部合规 + fail: 利率或违约条款有瑕疵,存在合规风险 + type: rule_group + rules: + - JK-002 + - JK-005 + - JK-006 +- group: 担保条款 + rules: + - rule_id: JK-003 + name: 担保人身份合法性 + risk: high + score: 15 + activate_if: derived.担保人数量 > 0 + stages: + - id: '1' + type: multi_entity.count_ge + field: 担保人 + value: 1 + - id: '2' + check: format + field: 担保人[*].身份证号 + format: chinese_id + - id: '3' + check: required + field: 担保人[*].姓名 + logic: 1 AND 2 AND 3 + messages: + pass: 所有 {{derived.担保人数量}} 位担保人身份合法 + fail: 至少一位担保人身份信息缺失或身份证校验失败 + remediation: + suggestions: + - 共 {{derived.担保人数量}} 位担保人,至少一位的身份证校验失败 + - 请逐个核对每位担保人的身份证号 + actions: + - type: recheck_field + label: 重新核对所有担保人身份证 + field: 担保人[*].身份证号 + type: deterministic + - rule_id: JK-004 + name: 担保总额覆盖借款本金 + risk: high + score: 15 + activate_if: derived.担保人数量 > 0 + stages: + - id: '1' + check: compare + left: derived.担保总额 + op: '>=' + right_field: 借款本金 + logic: '1' + messages: + pass: 担保总额 {{derived.担保总额}} 覆盖借款本金 {{借款本金}} + fail: 担保总额 {{derived.担保总额}} 不足以覆盖借款本金 {{借款本金}} + remediation: + suggestions: + - 当前 {{derived.担保人数量}} 位担保人担保总额 {{derived.担保总额}} + - 借款本金 {{借款本金}},担保不足 + - 建议:(1) 增加担保人 (2) 提高现有担保金额 (3) 补充物的担保 + actions: + - type: edit_field + label: 调整担保金额 + field: 担保人[*].担保金额 + - type: escalate + label: 担保不足,上报风控 + role: 风控经理 + type: deterministic +- group: 条款合规 + rules: + - rule_id: JK-005 + name: 禁止利滚利与高利贷条款 + risk: high + score: 15 + stages: + - id: '1' + type: string.must_not_contain + field: 违约责任 + forbidden_keywords: + - 利滚利 + - 复利 + - 利息计入本金 + - 砍头息 + - 预扣利息 + - id: '2' + type: string.must_not_contain + field: 借款用途 + forbidden_keywords: + - 非法经营 + - 赌博 + - 洗钱 + logic: 1 AND 2 + messages: + pass: 未发现利滚利或非法用途条款 + fail: 发现违法条款,建议删除或修改 + references_laws: + - 《民法典》第 680 条 + - 《民法典》第 670 条(禁止预扣利息) + remediation: + suggestions: + - 检出禁止性条款,本合同可能被法院认定为无效或部分无效 + - 请删除违法条款或整体重新起草 + actions: + - type: edit_field + label: 修改违约责任条款 + field: 违约责任 + - type: escalate + label: 涉嫌违法条款,上报法务 + role: 法务经理 + type: deterministic +- group: 违约条款 + rules: + - rule_id: JK-006 + name: 违约金不超过借款本金 30% + risk: medium + score: 10 + stages: + - id: '1' + check: required + fields: + - 违约金金额 + - 借款本金 + logic: and + - id: '2' + type: money.ratio_within + numerator: 违约金金额 + denominator: 借款本金 + min: 0.0 + max: 0.3 + logic: 1 AND 2 + messages: + pass: 违约金比例 {{derived.违约金比例}} 合规 + fail: 违约金 {{违约金金额}} 占借款本金 {{借款本金}} 的比例超过 30% + references_laws: + - 《民法典》第 585 条(违约金不得过分高于损失) + type: deterministic +- group: 金额条款 + rules: + - rule_id: JK-007 + name: 借款本金大小写一致性 + risk: high + score: 10 + stages: + - id: '1' + check: required + fields: + - 借款本金 + - 借款本金大写 + logic: and + - id: '2' + check: amount_match + number: 借款本金 + chinese: 借款本金大写 + logic: 1 AND 2 + messages: + pass: 借款本金大小写一致 + fail: 借款本金大小写不一致,涉嫌篡改 + type: deterministic +- group: 期限条款 + rules: + - rule_id: JK-008 + name: 借款期限合理性 + risk: medium + score: 5 + depends_on: + - when: JK-001.passed + stages: + - id: '1' + check: required + fields: + - 借款日期 + - 还款日期 + logic: and + - id: '2' + type: date.after + field: 还款日期 + ref_field: 借款日期 + - id: '3' + check: assert + expr: parse_date(签订日期) != None and (today() - parse_date(签订日期)).days >= 0 and (today() - parse_date(签订日期)).days <= 1825 + logic: 1 AND 2 AND 3 + messages: + pass: 借款期限合规 + fail: 借款日期颠倒 或 合同签订过旧 + type: deterministic +- group: 印章合规 + rules: + - rule_id: JK-SEAL-001 + name: 借贷双方签章齐全 + risk: high + score: 10 + stages: + - id: '1' + type: seal.present + seal_id: 借款人签章 + - id: '2' + type: seal.present + seal_id: 出借人签章 + logic: 1 AND 2 + messages: + pass: 双方签章齐全 + fail: 缺少借款人或出借人签章 + remediation: + by_phase: + draft: + suggestions: + - 草稿阶段无需盖章 + actions: + - type: noop + executed: + suggestions: + - 民间借贷纠纷中,无签章的合同证据效力较弱 + - 请补盖双方印章或提供签名 + actions: + - type: upload_file + label: 补扫签章页 + file_type: 签章页 + type: deterministic +- group: 我方权益保护 + rules: + - rule_id: JK-OUR-001 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.purchase.general/1.0/rules.yaml b/leaudit-oss-yaml-files/contract.purchase.general/1.0/rules.yaml new file mode 100644 index 0000000..b48c6b1 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.purchase.general/1.0/rules.yaml @@ -0,0 +1,627 @@ +metadata: + type_id: contract.purchase.general + name: 买卖合同 + version: '1.0' + last_updated: '2026-04-11' + parent: contract + inherits_from: + - base.common + - base.party_info + classification_keywords: + - 买卖 + - 购销 + - 采购 + references_laws: + - 《民法典》第 595-647 条(买卖合同章) + - 《民法典》第 463-594 条(合同编通则) + description: '适用于一般商品买卖合同,覆盖民法典第 595-647 条。 + + 评查重点:当事人合法性、金额一致性、印章合规、交付条款、违约责任。 + + ' + confidence_profile: + allow_weight_override: false + thresholds: + auto_pass: 0.95 + field_confidence_defaults: + 合同金额: 0.95 + 合同金额大写: 0.9 +extract: +- group: 当事人(从起草就应该有) + fields: + - name: 甲方名称 + type: verbatim + required_from: draft + deep_retry: false + - name: 乙方名称 + type: verbatim + required_from: draft + deep_retry: false +- group: 当事人(执行时才必需) + fields: + - name: 甲方法定代表人 + type: verbatim + required_from: executed + deep_retry: false + - name: 甲方统一信用代码 + type: uscc + required_from: executed + deep_retry: false + - name: 甲方联系电话 + type: verbatim + required_from: draft + deep_retry: false + - name: 乙方法定代表人 + type: verbatim + required_from: executed + deep_retry: false + - name: 乙方统一信用代码 + type: uscc + required_from: executed + deep_retry: false + - name: 乙方联系电话 + type: verbatim + required_from: draft + deep_retry: false +- group: 合同基本信息 + fields: + - name: 合同编号 + type: verbatim + required_from: draft + deep_retry: false + - name: 签订日期 + type: date + required_from: executed + deep_retry: false + - name: 生效日期 + type: date + required_from: draft + deep_retry: false + - name: 终止日期 + type: date + required_from: draft + deep_retry: false +- group: 标的与金额 + fields: + - name: 标的物描述 + type: string + required_from: draft + deep_retry: false + - name: 合同金额 + type: money + required_from: draft + deep_retry: false + - name: 合同金额大写 + type: verbatim + required_from: executed + deep_retry: false + - name: 招标文件编号 + type: verbatim + required_from: draft + deep_retry: false +- group: 交付 + fields: + - name: 交货地点 + type: verbatim + required_from: draft + deep_retry: false + - name: 交货时间 + type: date + required_from: draft + deep_retry: false +- group: 关键条款(起草就应该有) + fields: + - name: 质量标准 + type: string + required_from: draft + deep_retry: false + - name: 付款方式 + type: string + required_from: draft + deep_retry: false + - name: 违约责任 + type: string + required_from: draft + deep_retry: false + - name: 争议解决 + type: string + required_from: draft + deep_retry: false +visual_elements: + seals: + - id: 甲方签章 + name: 甲方公章或合同专用章 + required: true + required_from: executed + allowed_types: + - 公章 + - 合同专用章 + - 法人章 + expected_text_match: + field: 甲方名称 + - id: 乙方签章 + name: 乙方公章或合同专用章 + required: true + required_from: executed + allowed_types: + - 公章 + - 合同专用章 + - 法人章 + expected_text_match: + field: 乙方名称 + signatures: + - id: 甲方法人签名 + name: 甲方法定代表人签名 + required: false + required_from: executed + expected_text_match: + field: 甲方法定代表人 + - id: 乙方法人签名 + name: 乙方法定代表人签名 + required: false + required_from: executed + expected_text_match: + field: 乙方法定代表人 + cross_page_seals: + - id: 骑缝章 + name: 合同骑缝章 + required: true + required_from: executed + expected_text_match: + field: 甲方名称 + prompt: '合同每相邻两页之间跨页盖章。 + + 每页只能看到印章的一半,两页拼合后构成完整圆章。 + + 通常在页面右侧边缘或底部,垂直跨越装订线。 + + 不要和页脚日期章或水印混淆。 + + ' +rules: +- group: 合同主体 + rules: + - rule_id: MM-001 + name: 当事人信息完整性 + risk: high + score: 10 + stages: + - id: '1' + check: required + fields: + - 甲方名称 + - 乙方名称 + - 甲方法定代表人 + - 乙方法定代表人 + logic: and + - id: '2' + check: format + field: 甲方统一信用代码 + format: uscc + - id: '3' + check: format + field: 乙方统一信用代码 + format: uscc + logic: 1 AND 2 AND 3 + messages: + pass: 当事人信息完整,统一信用代码校验通过 + fail: 当事人信息缺失或统一信用代码校验失败 + references_laws: + - 《民法典》第 471 条 + remediation: + by_phase: + draft: + suggestions: + - 草稿阶段当事人信息暂缺是正常的,签署前须补齐 + - 建议在定稿前确认:甲乙方名称、法定代表人、统一信用代码三项齐全 + actions: + - type: fill_field + label: 补充甲方法定代表人 + field: 甲方法定代表人 + prompt: 请输入甲方法定代表人姓名 + - type: fill_field + label: 补充乙方法定代表人 + field: 乙方法定代表人 + prompt: 请输入乙方法定代表人姓名 + executed: + suggestions: + - 已执行合同出现当事人信息缺失是严重瑕疵 + - 可能原因:起草遗漏 / OCR 错位 / USCC 校验位错 + - 须补充说明或要求当事人出具澄清 + actions: + - type: recheck_field + label: 核对甲方统一信用代码 + field: 甲方统一信用代码 + hint: 18 位,末位为校验位,请核对营业执照 + - type: recheck_field + label: 核对乙方统一信用代码 + field: 乙方统一信用代码 + - type: link_template + label: 下载主体信息补充说明函模板 + template_id: party_info_addendum + - type: escalate + label: 紧急:执行合同主体信息缺失,上报合规组 + role: 合规专员 + reason: 已执行合同出现当事人瑕疵,可能影响效力 + type: deterministic +- group: 金额与支付 + rules: + - rule_id: MM-002 + name: 合同金额大小写一致性 + risk: high + score: 10 + stages: + - id: '1' + check: required + fields: + - 合同金额 + - 合同金额大写 + logic: and + - id: '2' + check: amount_match + number: 合同金额 + chinese: 合同金额大写 + logic: 1 AND 2 + messages: + pass: 金额大小写一致 + fail: 金额大小写不一致,合同可能被篡改或抽取错误 + references_laws: + - 《民法典》第 470 条 + remediation: + on_rule_fail: + suggestions: + - 小写金额 {{合同金额}} 与大写 {{合同金额大写}} 不一致 + - 如果是 OCR 抽取错误,请人工核对原文 + - 如果合同原件本身不一致,以大写为准(法律惯例)并出具说明 + actions: + - type: recheck_field + label: 核对小写金额 + field: 合同金额 + - type: recheck_field + label: 核对大写金额 + field: 合同金额大写 + - type: link_template + label: 下载金额不一致说明函模板 + template_id: amount_inconsistency_explanation + - type: escalate + label: 涉嫌合同篡改,上报合规组 + role: 合规专员 + reason: 金额大小写不一致,可能存在恶意篡改风险 + on_confidence_low: + suggestions: + - 金额字段抽取置信度低,请人工确认原文 + actions: + - type: recheck_field + label: 重新核对合同金额 + field: 合同金额 + hint: 注意千分位符号、小数点、货币单位 + type: deterministic + - rule_id: MM-003 + name: 大额合同须招投标 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 合同金额 + - id: '2' + check: compare + left: 合同金额 + op: '>' + right: 1000000 + - id: '3' + check: required + required_if: '2' + field: 招标文件编号 + logic: 1 AND (NOT 2 OR 3) + messages: + pass: 小额合同或大额已附招标文件 + fail: 合同金额超过 100 万元,但未提供招标文件编号 + references_laws: + - 《政府采购法》第 27 条 + - 《招标投标法》第 3 条 + remediation: + suggestions: + - 本合同金额 {{合同金额}} 元,超过政府采购 100 万招投标阈值 + - 按《政府采购法》第 27 条,须采取公开招标或提供豁免说明 + - 下面三种操作任选其一即可通过本条规则 + actions: + - type: fill_field + label: 补充招标文件编号 + field: 招标文件编号 + prompt: 请输入招标文件编号(常见格式 ZB2024XXX) + - type: upload_file + label: 上传招标豁免说明函 + file_type: 招标豁免说明 + accept: + - pdf + - docx + - type: link_template + label: 下载招标豁免说明函模板 + template_id: bidding_exemption_letter + - type: escalate + label: 金额过大需采购总监审批 + role: 采购总监 + trigger_if: '{{合同金额}} > 5000000' + reason: 合同金额超过 500 万,需采购总监额外审批 + type: deterministic +- group: 合同期限 + rules: + - rule_id: MM-004 + name: 签订/生效/终止日期先后关系 + risk: high + score: 8 + stages: + - id: '1' + check: required + fields: + - 签订日期 + - 生效日期 + logic: and + - id: '2' + type: date.sequence + fields: + - 签订日期 + - 生效日期 + - 终止日期 + order: le + logic: 1 AND 2 + messages: + pass: 合同日期先后关系合规 + fail: 签订日/生效日/终止日顺序异常 + remediation: + suggestions: + - 签订日期 {{签订日期}} / 生效日期 {{生效日期}} / 终止日期 {{终止日期}} + - 正常顺序应为:签订 ≤ 生效 ≤ 终止 + - 可能原因:日期抽取错位 / OCR 识别错误 / 合同原件笔误 + actions: + - type: recheck_field + label: 核对签订日期 + field: 签订日期 + - type: recheck_field + label: 核对生效日期 + field: 生效日期 + - type: recheck_field + label: 核对终止日期 + field: 终止日期 + type: deterministic +- group: 标的物 + rules: + - rule_id: MM-005 + name: 质量标准明确性 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 质量标准 + - id: '2' + type: string.min_length + field: 质量标准 + min: 20 + - id: '3' + check: contains + field: 质量标准 + any_of: + - GB/T + - ISO + - 国标 + - 行业标准 + - 企业标准 + logic: 1 AND 2 AND 3 + messages: + pass: 质量标准明确(引用了具体标准) + fail: 质量标准过于简略或未引用具体标准 + references_laws: + - 《民法典》第 615 条 + type: deterministic +- group: 违约与争议 + rules: + - rule_id: MM-006 + name: 违约责任条款存在性 + risk: medium + score: 5 + stages: + - id: '1' + check: required + field: 违约责任 + - id: '2' + type: string.min_length + field: 违约责任 + min: 30 + logic: 1 AND 2 + messages: + pass: 违约责任条款明确 + fail: 违约责任条款缺失或过于简略 + type: deterministic +- group: 印章合规 + rules: + - rule_id: MM-SEAL-001 + name: 合同双方签章齐全 + risk: high + score: 15 + stages: + - id: '1' + type: seal.present + seal_id: 甲方签章 + - id: '2' + type: seal.present + seal_id: 乙方签章 + - id: '3' + type: seal.text_match + seal_id: 甲方签章 + - id: '4' + type: seal.text_match + seal_id: 乙方签章 + logic: 1 AND 2 AND 3 AND 4 + messages: + pass: 合同双方签章齐全且文字匹配当事人名称 + fail: 缺少签章 或 印章文字与当事人名称不一致(可能冒用) + references_laws: + - 《民法典》第 490 条(合同形式) + remediation: + by_phase: + draft: + suggestions: + - 草稿阶段无需盖章。请在定稿签署时加盖甲乙方公章 + - 合同超过一页时建议加盖骑缝章 + actions: + - type: noop + executed: + suggestions: + - 已执行合同缺少签章或印章文字不符,严重影响合同效力 + - 可能原因:(1) 漏扫签章页 (2) 印章是冒章 (3) 合同原件确实未盖章 + - 建议立即补救 + actions: + - type: upload_file + label: 紧急:补扫签章页 + file_type: 签章页 + accept: + - pdf + - jpg + - png + - type: recheck_field + label: 核对甲方印章文字 + field: visual.甲方签章 + hint: 核对印章上的单位名称与甲方名称是否一致 + - type: recheck_field + label: 核对乙方印章文字 + field: visual.乙方签章 + - type: link_template + label: 下载印章缺失情况说明模板 + template_id: seal_missing_statement + - type: escalate + label: 疑似冒章,上报合规组 + role: 合规专员 + reason: 已执行合同印章文字与当事人名称不一致 + type: deterministic + - rule_id: MM-SEAL-002 + name: 合同骑缝章完整 + risk: high + score: 10 + stages: + - id: '1' + type: cross_page_seal.complete + seal_id: 骑缝章 + logic: '1' + messages: + pass: 骑缝章完整 + fail: 骑缝章缺失,合同可能被替换页 + remediation: + suggestions: + - 骑缝章用于防止合同页面被替换。缺失或错位是严重的合规风险 + - 请确认:(1) 是否原件有骑缝章?(2) 扫描时是否漏扫或错位? + - 如原件确实无骑缝章,需双方补盖或出具情况说明 + actions: + - type: upload_file + label: 补扫完整骑缝章 + file_type: 骑缝章扫描件 + accept: + - pdf + - type: link_template + label: 下载印章缺失情况说明模板 + template_id: seal_missing_statement + - type: escalate + label: 疑似合同替换页,上报合规组 + role: 合规专员 + type: deterministic + - rule_id: MM-SEAL-003 + name: 高额合同须法人签名与公章并存 + risk: medium + score: 5 + stages: + - id: '1' + check: compare + left: 合同金额 + op: '>' + right: 500000 + - id: '2' + type: seal.present + required_if: '1' + seal_id: 甲方签章 + - id: '3' + type: signature.present + required_if: '1' + signature_id: 甲方法人签名 + logic: NOT 1 OR (2 AND 3) + messages: + pass: 小额合同或已有公章+法人签名双重确认 + fail: 大额合同缺少公章或法人签名 + remediation: + suggestions: + - 超过 50 万元的合同建议法人亲自签名 + 公章双重确认 + - 缺少任一都属于瑕疵,建议补齐 + actions: + - type: upload_file + label: 补传签章页(含法人签名) + file_type: 签章页 + accept: + - pdf + - jpg + - png + - type: document_override + label: 记录豁免(金额虽大但有特殊约定) + require_reason: true + require_role: 法务经理 + type: deterministic +- group: 我方权益保护 + rules: + - rule_id: MM-OUR-001 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.sale/2.1/rules.yaml b/leaudit-oss-yaml-files/contract.sale/2.1/rules.yaml new file mode 100644 index 0000000..3fdb9cd --- /dev/null +++ b/leaudit-oss-yaml-files/contract.sale/2.1/rules.yaml @@ -0,0 +1,1101 @@ +metadata: + type_id: contract.sale + name: 通用买卖合同 + version: '2.1' + last_updated: '2026-04-12' + description: '依据《中华人民共和国民法典》合同编·通则(第470条)及买卖合同章(第595-647条)。 + + 适用于一般货物/商品/设备/IT系统采购类买卖合同的评查。 + + 原始规则来源:旧系统 01_买卖合同.json(10条买卖专项评查点)+ 通用合同评查点。 + + ' + tags: + - 合同 + - 买卖 + - 采购 + - 通用 +extract: +- group: 合同成立要素 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同的完整名称/项目名称 + - name: 甲方 + type: verbatim + required_from: draft + desc: 买方/采购方公司全称 + - name: 乙方 + type: verbatim + required_from: draft + desc: 卖方/供应商公司全称 + - name: 合同标的描述 + type: string + required_from: draft + desc: 合同交易的标的物/服务内容概述 + - name: 合同金额 + type: money + required_from: draft + desc: 合同总金额(数字)。框架/年度采购合同无固定总价时填 0 或 null + - name: 合同金额大写 + type: verbatim + required_from: draft + desc: 合同总金额中文大写 +- group: 主体资格 + fields: + - name: 甲方法定代表人 + type: verbatim + required_from: draft + desc: 甲方法定代表人姓名 + - name: 乙方法定代表人 + type: verbatim + required_from: draft + desc: 乙方法定代表人姓名 + - name: 甲方地址 + type: verbatim + required_from: draft + desc: 甲方注册/办公地址 + - name: 乙方地址 + type: verbatim + required_from: draft + desc: 乙方注册/办公地址 + - name: 甲方统一社会信用代码 + type: uscc + required_from: executed + desc: 甲方18位统一社会信用代码 + - name: 乙方统一社会信用代码 + type: uscc + required_from: executed + desc: 乙方18位统一社会信用代码 +- group: 履约核心条款 + fields: + - name: 付款方式 + type: string + required_from: draft + desc: 付款条件、比例、节点、方式的完整描述 + - name: 交货期限 + type: string + required_from: draft + desc: 交货/交付时间要求 + - name: 交货地点 + type: verbatim + required_from: draft + desc: 交货/送达地点 + - name: 验收条款 + type: string + required_from: draft + desc: 验收标准、验收流程、初验终验时间和不合格处理 + - name: 质保期条款 + type: string + desc: 质保期限、质保范围、故障响应时间和运维服务内容 +- group: 买卖合同特有条款 + fields: + - name: 风险转移条款 + type: string + desc: 标的物风险转移时点和交付确认方式 + - name: 履约保证金条款 + type: string + desc: 保证金金额、缴纳方式、缴纳时间和退还条件 + - name: 知识产权条款 + type: string + desc: 知识产权归属、使用许可范围和侵权责任 + - name: 培训条款 + type: string + desc: 培训内容、培训方式和培训安排 + - name: 标的清单明细 + type: string + desc: 标的清单(序号、名称、数量、单价等明细及总价) + - name: 招投标信息 + type: string + desc: 招标文件编号、项目编号、中标通知书等招投标依据 +- group: 法定/必备条款 + fields: + - name: 违约责任条款 + type: string + required_from: draft + desc: 违约责任的完整条款内容 + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式(法院/仲裁)的完整描述 + - name: 不可抗力条款 + type: string + desc: 不可抗力相关条款的完整内容 +- group: 签署要素 + fields: + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 +- group: 辅助信息 + fields: + - name: 甲方联系人 + type: verbatim + desc: 甲方项目联系人姓名 + - name: 甲方联系电话 + type: verbatim + desc: 甲方联系电话 + - name: 乙方联系人 + type: verbatim + desc: 乙方项目联系人姓名 + - name: 乙方联系电话 + type: verbatim + desc: 乙方联系电话 + - name: 甲方开户银行 + type: verbatim + desc: 甲方银行开户行名称 + - name: 甲方银行账号 + type: verbatim + desc: 甲方银行账号 +- group: 其他条款 + fields: + - name: 保密条款 + type: string + desc: 保密义务相关条款内容,如有附件总结内容限制在100字内 +rules: +- group: 默认规则组 + rules: + - rule_id: MM-SALE-001 + name: 合同主体齐全 + risk: high + score: 7 + stages: + - id: '1' + check: required + field: 甲方 + - id: '2' + check: required + field: 乙方 + logic: 1 AND 2 + messages: + pass: 甲乙方信息完整 + fail: 缺少甲方或乙方信息 + - rule_id: MM-SALE-002 + name: 标的物与金额必填 + risk: high + score: 7 + stages: + - id: '1' + check: required + field: 合同标的描述 + - id: '2' + check: required + field: 合同金额 + logic: 1 AND 2 + messages: + pass: 标的物与金额信息完整 + fail: 缺少标的物描述或合同金额 + - rule_id: MM-SALE-003 + name: 合同名称必填 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 合同名称 + logic: '1' + messages: + pass: 合同名称已填写 + fail: 缺少合同名称 + - rule_id: MM-SALE-004 + name: 法定代表人齐全 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 甲方法定代表人 + - id: '2' + check: required + field: 乙方法定代表人 + logic: 1 AND 2 + messages: + pass: 甲乙方法定代表人信息完整 + fail: 缺少甲方或乙方法定代表人信息 + - rule_id: MM-SALE-005 + name: 交货期限必填 + risk: high + score: 6 + stages: + - id: '1' + check: required + field: 交货期限 + logic: '1' + messages: + pass: 交货期限已约定 + fail: 交货期限未约定 + - rule_id: MM-SALE-006 + name: 验收条款存在 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 验收条款 + logic: '1' + messages: + pass: 验收条款存在 + fail: 缺少验收条款 + - rule_id: MM-SALE-007 + name: 违约责任条款存在 + risk: high + score: 6 + stages: + - id: '1' + check: required + field: 违约责任条款 + logic: '1' + messages: + pass: 违约责任条款存在 + fail: 缺少违约责任条款 + - rule_id: MM-SALE-008 + name: 争议解决条款存在 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 争议解决条款 + logic: '1' + messages: + pass: 争议解决条款存在 + fail: 缺少争议解决条款 + - rule_id: MM-SALE-009 + name: 培训条款存在 + risk: low + score: 1 + stages: + - id: '1' + check: required + field: 培训条款 + logic: '1' + messages: + pass: 培训条款已约定 + fail: 培训条款缺失 + - rule_id: MM-SALE-010 + name: 签约日期必填 + risk: high + score: 5 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 签约日期 + logic: '1' + messages: + pass: 签约日期已填写 + fail: 缺少签约日期 + - rule_id: MM-SALE-011 + name: 合同编号必填 + risk: medium + score: 1 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 合同编号 + logic: '1' + messages: + pass: 合同编号已填写 + fail: 缺少合同编号 + - rule_id: MM-SALE-012 + name: 甲方信用代码校验 + risk: medium + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: format + field: 甲方统一社会信用代码 + format: uscc + logic: '1' + messages: + pass: 甲方统一社会信用代码校验通过 + fail: 甲方统一社会信用代码校验位错误 + - rule_id: MM-SALE-013 + name: 乙方信用代码校验 + risk: medium + score: 3 + applies_in: + - executed + stages: + - id: '1' + check: format + field: 乙方统一社会信用代码 + format: uscc + logic: '1' + messages: + pass: 乙方统一社会信用代码校验通过 + fail: 乙方统一社会信用代码校验位错误 + - rule_id: MM-SALE-014 + name: 金额大小写一致 + risk: high + score: 6 + stages: + - id: '1' + check: amount_match + number: 合同金额 + chinese: 合同金额大写 + logic: '1' + messages: + pass: 金额大小写一致 + fail: 合同金额数字与大写不一致 + - rule_id: MM-SALE-015 + name: 金额为正数 + risk: low + score: 1 + stages: + - id: '1' + check: compare + left: 合同金额 + op: '>' + right: 0 + logic: '1' + messages: + pass: 合同金额为正数 + fail: 合同金额不为正数 + - rule_id: MM-SALE-016 + name: 签约日期不是未来 + risk: low + score: 1 + applies_in: + - executed + stages: + - id: '1' + check: assert + expr: parse_date(签约日期) != None and (today() - parse_date(签约日期)).days >= 0 and (today() - parse_date(签约日期)).days <= 3650 + logic: '1' + messages: + pass: 签约日期在合理范围内 + fail: 签约日期为未来日期或距今超过10年 + - rule_id: MM-SALE-017 + name: 验收条款完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 验收条款 + - id: '2' + check: ai + prompt: '请检查合同的验收/检验条款是否完整。 + + + 验收条款:{{验收条款}} + + + 评查要点(依据民法典第620-622条): + + 1. 是否约定了明确的检验/验收期限 + + 2. 是否约定了验收标准(国家标准、行业标准、招标文件要求等) + + 3. 是否约定了验收流程(谁组织、谁参与) + + 4. 检验期限是否合理 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 验收条款完整 + fail: 验收条款不完整 + - rule_id: MM-SALE-018 + name: 风险转移条款明确 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 风险转移条款 + - id: '2' + check: ai + prompt: '请检查合同中是否有关于标的物/服务交付后风险转移的约定。 + + + 风险转移条款:{{风险转移条款}} + + + 评查要点(依据民法典第604-607条): + + 1. 是否明确了风险转移的时点(交付时、验收时或其他约定时点) + + 2. 对于软件/系统类标的,风险转移通常与验收挂钩 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 风险转移条款约定明确 + fail: 风险转移条款缺失或不明确 + - rule_id: MM-SALE-019 + name: 质保期条款完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 质保期条款 + - id: '2' + check: ai + prompt: '请检查合同的质保条款是否完整。 + + + 质保条款:{{质保期条款}} + + + 评查要点(依据民法典第617、621条): + + 1. 质保期限是否明确(起算时间、结束时间) + + 2. 质保范围是否清晰(哪些属于质保范围内、哪些除外) + + 3. 故障响应时间是否合理 + + 4. 是否约定了质保期内的服务标准 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 质保期条款完整 + fail: 质保期条款不完整 + - rule_id: MM-SALE-020 + name: 履约保证金条款完整 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 履约保证金条款 + - id: '2' + check: ai + prompt: '请检查合同中履约保证金条款是否完整。 + + + 保证金条款:{{履约保证金条款}} + + + 评查要点(依据民法典第586-587条): + + 1. 保证金金额是否明确 + + 2. 缴纳时间和方式是否清楚 + + 3. 退还条件是否合理、具体 + + 4. 退还时间是否明确 + + 5. 保证金比例一般不超过合同金额的10% + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 履约保证金条款完整 + fail: 履约保证金条款不完整 + - rule_id: MM-SALE-021 + name: 分期付款条款合理 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 付款方式 + - id: '2' + check: required + field: 合同金额 + - id: '3' + check: ai + prompt: '请审查合同分期付款条款的合理性。 + + + 付款条款:{{付款方式}} + + 合同总金额:{{合同金额}} + + 联合采购信息:{{联合采购信息}} + + + 评查要点(依据民法典第626-634条): + + 1. 各期付款比例之和是否覆盖应付总额(联合采购时:各期比例之和=本单位分摊比例即为100%覆盖,如4单位各付25%,则5%+10%+10%=25%=该单位全额,判为pass) + + 2. 预付款不超过30% + + 3. 付款节点与交付验收挂钩 + + 4. 有付款前置条件(发票、验收报告等) + + 请简洁回答,reason不超过100字。 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 AND 3 + messages: + pass: 分期付款条款合理 + fail: 分期付款条款存在问题 + - rule_id: MM-SALE-022 + name: 知识产权条款完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 知识产权条款 + - id: '2' + check: ai + prompt: '请检查合同中知识产权条款是否完整。 + + + 知识产权条款:{{知识产权条款}} + + + 评查要点(依据民法典第600条): + + 1. 是否明确了知识产权的归属(买方/卖方/共有) + + 2. 是否约定了使用许可的范围和方式 + + 3. 是否约定了第三方知识产权侵权的责任承担 + + 4. 对于软件/系统类采购,应特别关注源代码、数据归属 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 知识产权条款完整 + fail: 知识产权条款不完整 + - rule_id: MM-SALE-023 + name: 标的清单金额校验 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 标的清单明细 + - id: '2' + check: required + field: 合同金额 + - id: '3' + check: ai + prompt: '请校验合同标的清单的金额一致性。 + + + 标的清单明细:{{标的清单明细}} + + 合同总金额:{{合同金额}} + + + 评查要点(依据民法典第595-596条): + + 1. 各项单价x数量是否等于对应项总价(逐项计算校验) + + 2. 标的清单总价是否等于合同总金额 + + 3. 服务范围描述是否足够具体(非含糊表述) + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 AND 3 + messages: + pass: 标的清单金额校验通过 + fail: 标的清单金额不一致或服务范围不明确 + - rule_id: MM-SALE-024 + name: 招投标信息引用完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 招投标信息 + - id: '2' + check: ai + prompt: '请检查合同是否明确引用了招投标文件。 + + + 招投标信息:{{招投标信息}} + + + 评查要点: + + 1. 合同是否引用了招标文件编号/项目编号 + + 2. 合同是否将招标文件、投标文件作为合同附件或组成部分 + + 3. 合同主要条款不应实质性变更招投标内容 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 合同与招投标文件一致 + fail: 合同与招投标文件引用不完整 + - rule_id: MM-SALE-025 + name: 违约责任条款充分 + risk: medium + score: 4 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请判断以下违约责任条款是否充分、合规。 + + + 条款内容:{{违约责任条款}} + + + 充分的违约责任条款应当(依据民法典第577-585条): + + 1. 明确违约情形(如逾期付款、逾期交货、质量不合格等) + + 2. 明确违约金计算方式或赔偿标准 + + 3. 不能只是笼统的模糊表述 + + 4. 应当对双方的违约责任都有约定 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 违约责任条款充分 + fail: 违约责任条款不充分 + - rule_id: MM-SALE-026 + name: 争议解决方式明确 + risk: medium + score: 4 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请判断以下争议解决条款是否符合法律要求。 + + + 条款内容:{{争议解决条款}} + + + 合规的争议解决条款应当: + + 1. 明确指定具体的争议解决方式(仲裁或诉讼,二选一) + + 2. 如选择仲裁,应明确仲裁机构名称 + + 3. 如选择诉讼,应明确管辖法院 + + 4. 不能同时约定仲裁和诉讼 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 争议解决方式明确 + fail: 争议解决条款未明确具体的仲裁机构/管辖法院 + - rule_id: MM-SALE-027 + name: 付款条款明确 + risk: medium + score: 4 + stages: + - id: '1' + check: required + field: 付款方式 + - id: '2' + check: ai + prompt: '请判断以下付款条款是否明确。 + + + 条款内容:{{付款方式}} + + + 明确的付款条款应当包含: + + 1. 付款金额或比例 + + 2. 付款时间节点或触发条件 + + 3. 付款方式(如银行转账) + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 付款条款明确 + fail: 付款条款不够明确 + - rule_id: MM-SALE-028 + name: 保密条款完整 + risk: low + score: 3 + stages: + - id: '1' + check: required + field: 保密条款 + - id: '2' + check: ai + prompt: '请判断以下保密条款是否完整。 + + + 条款内容:{{保密条款}} + + + 完整的保密条款应当包含: + + 1. 保密信息的范围定义 + + 2. 保密义务的期限 + + 3. 违反保密义务的法律后果 + + + 请以JSON格式回答:{"result": "pass/warn/fail", "reason": "简要说明", "suggestion": "改进建议(仅warn/fail时填写)"} + + 判断标准: + + - pass:条款基本合理,能达到法律基本要求,道理上说得通即可 + + - warn:条款主体合理但有改进空间,不影响合同效力(如缺少锦上添花的条款、表述可以更精确等) + + - fail:条款存在严重缺陷,可能导致法律风险或合同纠纷(如完全缺失关键要素、违反强制性规定、金额计算错误等) + + ' + schema: + type: object + required: + - result + - reason + properties: + result: + type: string + reason: + type: string + suggestion: + type: string + pass_when: result != 'fail' + logic: 1 AND 2 + messages: + pass: 保密条款完整 + fail: 保密条款不够完整 + - rule_id: MM-SALE-029 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/contract.tech/1.0/rules.yaml b/leaudit-oss-yaml-files/contract.tech/1.0/rules.yaml new file mode 100644 index 0000000..6517cd2 --- /dev/null +++ b/leaudit-oss-yaml-files/contract.tech/1.0/rules.yaml @@ -0,0 +1,1636 @@ +metadata: + type_id: contract.tech + name: 技术合同(技术开发/技术服务/采购) + version: '1.0' + last_updated: '2026-04-14' + classification_keywords: + - 技术开发 + - 技术服务 + - 技术咨询 + - 软件开发 + - 系统建设 + - 平台采购 + tags: + - 合同 + - 技术 + - 开发 + - 服务 + - 采购 + description: '依据《中华人民共和国民法典》合同编通则(第470条)、技术合同章(第843-887条)及相关法规。 + + 适用于信息系统建设、软件开发、数据服务、平台采购等技术类合同的评查。 + + 覆盖签署前审查(draft)和签署后审计(executed)两个阶段。 + + 合并通用合同规则与技术合同专有规则,共 37 条评查规则。 + + ' +extract: +- group: 合同基本信息 + fields: + - name: 合同名称 + type: verbatim + required_from: draft + desc: 合同的完整名称 + - name: 签约背景 + type: string + required_from: draft + desc: 签约背景、缘由或项目依据 + - name: 引用法律法规 + type: string + required_from: draft + desc: 合同中引用的法律、法规名称 + - name: 合同编号 + type: verbatim + required_from: executed + desc: 合同唯一编号 + - name: 签约日期 + type: date + required_from: executed + desc: 合同签订日期 + - name: 签约地点 + type: verbatim + required_from: executed + desc: 合同签订地点 + - name: 合同份数 + type: integer + required_from: executed + desc: 合同正本份数 + - name: 生效条件 + type: string + required_from: executed + desc: 合同生效的条件描述 + - name: 审批情况 + type: string + required_from: draft + desc: 合同审批流程或审批信息 +- group: 当事人 + fields: + - name: 甲方 + type: verbatim + required_from: draft + desc: 甲方(委托方/采购方)公司全称 + - name: 乙方 + type: verbatim + required_from: draft + desc: 乙方(开发方/服务方)公司全称 + - name: 甲方法定代表人 + type: verbatim + required_from: draft + desc: 甲方法定代表人或负责人姓名 + - name: 乙方法定代表人 + type: verbatim + required_from: draft + desc: 乙方法定代表人姓名 + - name: 甲方地址 + type: verbatim + required_from: draft + desc: 甲方注册或办公地址 + - name: 乙方地址 + type: verbatim + required_from: draft + desc: 乙方注册或办公地址 + - name: 甲方联系人 + type: verbatim + required_from: draft + desc: 甲方项目联系人姓名 + - name: 甲方联系电话 + type: verbatim + required_from: draft + desc: 甲方联系电话 + - name: 乙方联系人 + type: verbatim + required_from: draft + desc: 乙方项目联系人姓名 + - name: 乙方联系电话 + type: verbatim + required_from: draft + desc: 乙方联系电话 + - name: 甲方统一社会信用代码 + type: uscc + required_from: draft + desc: 甲方18位统一社会信用代码 + - name: 乙方统一社会信用代码 + type: uscc + required_from: draft + desc: 乙方18位统一社会信用代码 + - name: 甲方资质信息 + type: string + required_from: draft + desc: 甲方相关资质证明描述 + - name: 乙方资质信息 + type: string + required_from: draft + desc: 乙方资质证明、从业资格等描述 + - name: 甲方授权委托信息 + type: string + required_from: draft + desc: 甲方签约代表的授权委托书信息 + - name: 乙方授权委托信息 + type: string + required_from: draft + desc: 乙方签约代表的授权委托书信息 +- group: 银行账户 + fields: + - name: 甲方开户银行 + type: verbatim + required_from: draft + desc: 甲方银行开户行名称 + - name: 甲方银行账号 + type: verbatim + required_from: draft + desc: 甲方银行账号 + - name: 乙方开户银行 + type: verbatim + required_from: draft + desc: 乙方银行开户行名称 + - name: 乙方银行账号 + type: verbatim + required_from: draft + desc: 乙方银行账号 +- group: 标的与技术 + fields: + - name: 合同标的描述 + type: string + required_from: draft + desc: 合同标的/服务内容的完整描述 + - name: 技术方案 + type: string + required_from: draft + desc: 技术实现方案:定性+半定量描述"怎么做"(架构/方法论)。包括技术路线、架构设计、开发方法论、技术栈选择。负向约束:不要抽取商务条款、交付要求、验收标准 + - name: 技术目标 + type: string + required_from: draft + desc: 技术目标:定性描述"做什么",即项目要达成的业务/功能目标。包括建设什么系统、实现什么功能。负向约束:不要抽取量化指标、商务条款、交付时间 + - name: 技术指标 + type: string + required_from: draft + desc: 技术指标:纯定量描述"做到什么程度"(必须有数字+单位)。包括并发用户数、响应时间、吞吐量、容量、准确率等。严禁抽取商务条款、交付条款、验收条款、服务条款 + - name: 技术标准规范 + type: string + required_from: draft + desc: 系统开发建设需遵循的技术标准、规范文件(编码规范、接口标准、安全标准、性能标准等) + - name: 质量标准 + type: string + required_from: draft + desc: 质量要求、检验方法的描述 +- group: 验收 + fields: + - name: 验收标准 + type: string + required_from: draft + desc: 功能验收、性能验收、安全验收等各项标准 + - name: 验收流程 + type: string + required_from: draft + desc: 验收组织方、参与方、步骤、期限 + - name: 不合格处理 + type: string + required_from: draft + desc: 验收不合格时的整改要求和处理方式 +- group: 金额与支付 + fields: + - name: 合同金额 + type: money + required_from: draft + desc: 合同含税总金额(数字) + - name: 合同金额大写 + type: verbatim + required_from: draft + desc: 合同含税总金额中文大写 + - name: 不含税金额 + type: money + required_from: draft + desc: 不含税金额 + - name: 税率 + type: string + required_from: draft + desc: 增值税税率(如6%、13%) + - name: 税额 + type: money + required_from: draft + desc: 增值税税额 + - name: 付款方式 + type: string + required_from: draft + desc: 付款方式(银行转账/现金等)及完整描述 + - name: 付款条件 + type: string + required_from: draft + desc: 付款阶段、比例、条件和期限的完整描述 + - name: 附加标的物价款标准 + type: string + required_from: draft + desc: 附加/额外服务的价款标准约定 +- group: 期限与地点 + fields: + - name: 合同起始日期 + type: date + required_from: draft + desc: 合同有效期起始日期 + - name: 合同终止日期 + type: date + required_from: draft + desc: 合同有效期终止日期 + - name: 合同期限描述 + type: string + required_from: draft + desc: 合同期限的文字描述 + - name: 履行地点 + type: verbatim + required_from: draft + desc: 项目实施/服务提供的地点 + - name: 实施计划 + type: string + required_from: draft + desc: 实施阶段划分、里程碑节点、交付时间 + - name: 交付物 + type: string + required_from: draft + desc: 各阶段应交付的成果物清单 +- group: 条款 + fields: + - name: 知识产权条款 + type: string + required_from: draft + desc: 知识产权归属、使用许可、后续改进的完整条款 + - name: 技术风险条款 + type: string + required_from: draft + desc: 技术风险分担方式、通知义务的约定 + - name: 技术支持条款 + type: string + required_from: draft + desc: 技术支持方式、响应时间、质保期约定 + - name: 资料移交清单 + type: string + required_from: draft + desc: 应移交的技术资料清单(文档、源代码、操作手册等) + - name: 违约责任条款 + type: verbatim + required_from: draft + desc: 违约责任/违约条款的完整内容(原文逐字抽取,用于费用相关评查) + - name: 争议解决条款 + type: string + required_from: draft + desc: 争议解决方式及管辖机构的完整描述 + - name: 不可抗力条款 + type: string + required_from: draft + desc: 不可抗力相关条款的完整内容 + - name: 变更解除终止条款 + type: string + required_from: draft + desc: 合同变更、解除、终止的条件和程序 + - name: 保密条款 + type: string + required_from: draft + desc: 保密义务相关条款内容(含正文及附件保密协议) + - name: 附件清单 + type: string + required_from: draft + desc: 合同附件的列表(序号、名称、类型) + - name: 补充协议条款 + type: string + required_from: draft + desc: 补充协议相关条款 +rules: +- group: 默认规则组 + rules: + - rule_id: JS-TECH-001 + name: 合同基本信息完整 + risk: high + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: required + field: 合同名称 + - id: '2' + check: required + field: 合同编号 + - id: '3' + check: required + field: 签约日期 + logic: 1 AND 2 AND 3 + messages: + pass: 合同名称、编号、签约日期齐全 + fail: 合同名称、编号或签约日期缺失 + - rule_id: JS-TECH-002 + name: 合同名称合法有效 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同名称是否与合同内容一致。 + + 合同名称:{{合同名称}} + + 合同标的描述:{{合同标的描述}} + + + 评查要点(依据民法典第467条): + + 1. 合同名称必须与合同实际内容一致(如名为"采购合同"但实际为技术服务则不一致) + + 2. 符合民法典有名合同特征的,应当采用标准名称或不会使人误解的通称 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同名称与内容一致 + fail: 合同名称与内容不一致 + - rule_id: JS-TECH-003 + name: 签约背景与法律依据 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查合同的签约背景和法律依据是否准确。 + + 签约背景:{{签约背景}} + + 引用法律法规:{{引用法律法规}} + + + 评查要点: + + 1. 签约背景或缘由是否存在(1分) + + 2. 合同依据的法律、法规必须准确、有效,不得引用已废止的法律(4分) + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明", "score": 0-5} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 签约背景存在且法律依据准确有效 + fail: 签约背景缺失或法律依据存在问题 + - rule_id: JS-TECH-004 + name: 当事人信息准确完整 + risk: high + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同各方当事人的信息是否准确完整。 + + 甲方名称:{{甲方}},法定代表人:{{甲方法定代表人}},地址:{{甲方地址}},联系电话:{{甲方联系电话}} + + 乙方名称:{{乙方}},法定代表人:{{乙方法定代表人}},地址:{{乙方地址}},联系电话:{{乙方联系电话}} + + + 评查要点(依据民法典第470条第1项): + + 1. 各方企业名称、法定代表人、地址、联系方式是否齐全 + + 2. 各项信息在合同正文中是否前后一致 + + 3. 如为自然人,应有姓名、身份证号、住址、联系电话 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 当事人信息完整一致 + fail: 当事人信息不完整或不一致 + - rule_id: JS-TECH-005 + name: 合同主体合法有效 + risk: high + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查该签署方的合同主体是否合法有效。 + + 名称:{{甲方}} + + 签约代表:{{甲方法定代表人}} + + 授权委托信息:{{甲方授权委托信息}} + + 对方名称:{{乙方}} + + 签约代表:{{乙方法定代表人}} + + 授权委托信息:{{乙方授权委托信息}} + + + 评查要点(依据民法典第143条、第171条): + + 1. 如为企业法人,签订期限应在经营期限内 + + 2. 代理人签订合同的,应提供合法、有效、明确的授权书 + + 3. 如为分支机构签订,应在法人授权范围内 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同主体合法有效 + fail: 合同主体合法性存疑 + - rule_id: JS-TECH-006 + name: 合同主体资质合格 + risk: high + score: 3 + stages: + - id: '1' + check: ai + prompt: '请检查合同对方主体的资质是否合格。 + + 对方名称:{{乙方}} + + 资质信息:{{乙方资质信息}} + + 服务类型:{{合同标的描述}} + + + 评查要点(依据民法典第505条): + + 1. 对方提供的资质证明必须符合法律法规规章规定的相应等级 + + 2. 从业人员必须具备相应资格,确保具有足够的履行合同能力 + + 3. 技术合同中乙方应具备相应的技术开发/服务能力资质 + + + 注意:如合同标的不涉及特定资质要求,可直接PASS。 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同主体资质合格或不涉及特定资质 + fail: 合同主体资质可能不合格 + - rule_id: JS-TECH-007 + name: 标的内容合法 + risk: high + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同标的是否合法。 + + 标的描述:{{合同标的描述}} + + + 评查要点(依据民法典第153条、第154条): + + 1. 标的是否属于法律禁止交易的服务或内容 + + 2. 是否涉及特殊行业许可要求 + + 3. 合同标的是否违反法律禁止性规定或公序良俗 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同标的内容合法 + fail: 合同标的合法性存疑 + - rule_id: JS-TECH-008 + name: 合同标的准确完整 + risk: high + score: 10 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同的标的信息是否准确完整。 + + 合同标的描述:{{合同标的描述}} + + 技术方案:{{技术方案}} + + 技术目标:{{技术目标}} + + 技术指标:{{技术指标}} + + + 评查要点(依据民法典第470条第2项、第510条): + + 1. 合同标的描述:是否列出了具体的功能模块和服务范围(不能只有"详见招标文件"等含糊表述) + + 2. 技术方案:是否说明了如何实现(技术路线、架构、方法论),不能只有功能罗列 + + 3. 技术目标:是否说明了要达成什么业务效果(建设什么系统、实现什么功能),定性描述 + + 4. 技术指标:是否包含量化指标(性能参数、响应时间、并发数等),必须有具体数字 + + + 容错规则(重要): + + - 如果"技术指标"中混入了商务条款(如付款、违约金、保证金、交货期、质保期等),这属于数据抽取问题,不应判定为"标的不完整"。请在 reason 中指出"技术指标字段混入了商务条款,但不影响标的完整性判定",并基于现有的性能参数部分进行判定。 + + - 只有当技术指标完全为空、或完全没有性能参数时,才判定为不完整。 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明(需指出哪个字段存在问题)"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 标的信息准确完整 + fail: 标的信息不完整或过于模糊 + - rule_id: JS-TECH-009 + name: 技术标准与质量条款 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中技术标准与质量条款的完整性和明确性。 + + 技术规范:{{技术标准规范}} + + 质量标准:{{质量标准}} + + + 评查要点(依据民法典第845条、第615-616条): + + 1. 是否引用了具体的技术规范文件(名称、编号、版本) + + 2. 是否引用了适用的国家标准(GB)、行业标准或国际标准(如GB/T 22239-2019、OGC标准等) + + 3. 标准引用是否完整(标准号、标准名称、版本年份) + + 4. 质量检验方法是否明确 + + 5. 不能仅有"符合相关标准""按行业惯例"等含糊表述 + + 6. 注意区分:"评查基础标准"等业务数据标准是系统处理的数据内容,不属于系统技术标准 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 技术标准引用明确,质量条款清晰 + fail: 技术标准与质量条款不够明确 + - rule_id: JS-TECH-010 + name: 合同金额大小写一致 + risk: high + score: 2 + stages: + - id: '1' + check: amount_match + number: 合同金额 + chinese: 合同金额大写 + logic: '1' + messages: + pass: 合同金额大小写一致 + fail: 合同金额数字与大写不一致 + - rule_id: JS-TECH-011 + name: 付款条款完整 + risk: high + score: 3 + stages: + - id: '1' + check: required + field: 付款方式 + - id: '2' + check: required + field: 付款条件 + logic: 1 AND 2 + messages: + pass: 付款条款完整 + fail: 付款方式或付款条件缺失 + - rule_id: JS-TECH-012 + name: 附加标的物价款标准 + risk: low + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查合同是否涉及附加、额外标的物,如涉及是否明确了价款标准。 + + 合同标的描述:{{合同标的描述}} + + 附加标的物价款标准:{{附加标的物价款标准}} + + + 评查要点(依据民法典第510条、第511条): + + 1. 如合同可能涉及额外工作量、附加服务,是否约定了价款标准 + + 2. 如合同标的已全部明确且无附加项,可直接PASS + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 附加标的物价款已明确或不涉及 + fail: 附加标的物价款标准缺失 + - rule_id: JS-TECH-013 + name: 银行账户信息完整 + risk: medium + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: ai + prompt: '请检查合同各方(甲方和乙方)的银行账户信息是否完整。 + + 甲方开户银行:{{甲方开户银行}} + + 甲方银行账号:{{甲方银行账号}} + + 乙方开户银行:{{乙方开户银行}} + + 乙方银行账号:{{乙方银行账号}} + + + 判断规则: + + 第一步:判断该方是付款方还是收款方。甲方一般为付款方(委托方),不需要提供收款账号,直接PASS。 + + 第二步:若该方是收款方(乙方/开发方/服务提供方),检查: + + 1. 开户银行不能为空 + + 2. 银行账号不能为空 + + 3. 银行账号应为数字,长度通常为16-20位 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 银行账户信息完整 + fail: 收款方银行账户信息不完整 + - rule_id: JS-TECH-014 + name: 税务信息完整 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 不含税金额 + - id: '2' + check: required + field: 税率 + - id: '3' + check: required + field: 税额 + logic: 1 AND 2 AND 3 + messages: + pass: 税务信息(不含税金额、税率、税额)完整 + fail: 缺少不含税金额、税率或税额,可能存在编制疏漏 + - rule_id: JS-TECH-015 + name: 合同期限具体准确 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: "请检查合同期限是否具体准确。\n起始日期:{{合同起始日期}}\n终止日期:{{合同终止日期}}\n合同期限描述:{{合同期限描述}}\n签约日期:{{签约日期}}\n\n评查要点(依据民法典第470条、第511条):\n\ + 1. **起始日期判断优先级**:\n - 优先级1:如果\"合同起始日期\"有具体日期,则以该日期为准\n - 优先级2:如果\"合同起始日期\"为空,但有明确的\"签约日期\",通常签约日期即为合同生效起始日期,应视为明确\n\ + \ - 优先级3:如果\"合同期限描述\"中说明\"自签约之日起\"\"自本合同签订之日起\"\"自双方签字盖章之日起\",则签约日期即为起始日期\n - 特殊情况:如果起始日期依赖于其他条件(如\"验收合格之日起\"\ + ),需说明该条件是否合理\n2. **合同期限完整性**:检查是否有明确的终止日期或合同期限(如1年、3年等)\n3. **日期格式**:起始/终止日期应为具体日期(年月日齐全),或可明确推算\n\n请以JSON格式回答:{\"\ + passed\": true/false, \"reason\": \"简要说明(需说明起始日期如何确定)\"}\n" + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同期限明确 + fail: 合同期限不明确 + - rule_id: JS-TECH-016 + name: 合同地点具体准确 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同中履行地点是否明确。 + + 履行地点:{{履行地点}} + + 签约地点:{{签约地点}} + + + 评查要点(依据民法典第470条、第511条): + + 1. 如合同涉及实施地点约定,地址是否具体 + + 2. 技术合同的服务实施地点是重要履约要素,建议明确,如提及"甲方指定地点",可PASS + + 3. 如合同无须约定具体地址,可PASS + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同地点明确 + fail: 合同地点不够具体 + - rule_id: JS-TECH-017 + name: 实施计划与里程碑完整 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中实施计划与里程碑的完整性。 + + 实施计划:{{实施计划}} + + 交付物:{{交付物}} + + + 评查要点(依据民法典第845条、第853条): + + 1. 是否明确划分了实施阶段(需求分析、方案设计、开发实施、测试验收等) + + 2. 各阶段是否有明确的时间节点或里程碑 + + 3. 各阶段是否明确了应交付的成果物 + + 4. 总工期是否合理 + + 5. 不能仅有"合同生效后XX个工作日内"的笼统描述 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 实施计划与里程碑约定完整 + fail: 实施计划与里程碑约定不完整 + - rule_id: JS-TECH-018 + name: 技术验收标准完整 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中验收标准条款的完整性。 + + 验收标准:{{验收标准}} + + 验收流程:{{验收流程}} + + 不合格处理:{{不合格处理}} + + + 评查要点(依据民法典第845条): + + 1. 验收标准是否明确(功能验收、性能验收、安全验收等各项标准) + + 2. 验收流程是否清晰(验收组织方、参与方、验收步骤、验收期限) + + 3. 是否约定了验收不合格时的整改要求和处理方式 + + 4. 验收标准应与技术目标和技术指标相对应 + + 5. 是否约定了分阶段验收还是整体验收 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 技术验收标准约定完整 + fail: 技术验收标准约定不完整 + - rule_id: JS-TECH-019 + name: 知识产权归属明确 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中知识产权归属条款的完整性。 + + 知识产权条款:{{知识产权条款}} + + + 评查要点(依据民法典第859-861条): + + 1. 是否明确约定技术成果(专利、软件著作权、技术秘密等)的归属 + + 2. 是否区分了委托方和开发方各自的权利 + + 3. 是否约定了技术成果使用许可的范围和方式 + + 4. 是否约定了后续改进技术成果的分享办法 + + 5. 对于软件开发类合同,应特别关注源代码、数据的归属 + + 6. 民法典规定委托开发的专利申请权默认属于研发方,如需归委托方应明确约定 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 知识产权归属约定明确 + fail: 知识产权归属约定不明确 + - rule_id: JS-TECH-020 + name: 技术风险分担 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中技术风险分担条款。 + + 技术风险条款:{{技术风险条款}} + + + 评查要点(依据民法典第858条): + + 1. 是否约定了因技术困难导致研发失败或部分失败时的风险分担方式 + + 2. 是否约定了研发过程中遇到技术困难时的通知义务和时限 + + 3. 无约定时风险由当事人合理分担(民法典默认规则),建议明确约定 + + + 如合同未涉及技术风险分担条款,返回FAIL并说明建议补充。 + + 如合同明确约定了相关条款,返回PASS。 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 技术风险分担约定明确 + fail: 技术风险分担约定缺失,建议补充 + - rule_id: JS-TECH-021 + name: 技术支持与资料移交 + risk: medium + score: 1 + stages: + - id: '1' + check: ai + prompt: '请检查技术合同中技术支持与资料移交条款的完整性。 + + 技术支持条款:{{技术支持条款}} + + 资料移交清单:{{资料移交清单}} + + + 评查要点(依据民法典第853条、第880条): + + 1. 是否约定了技术支持的方式(现场、远程、电话等)和质保期 + + 2. 是否约定了技术问题的响应时间和解决时限 + + 3. 是否列明了应移交的技术资料清单(技术文档、操作手册、源代码等) + + 4. 技术资料的移交时间和方式是否明确 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 技术支持与资料移交条款完整 + fail: 技术支持与资料移交条款不完整 + - rule_id: JS-TECH-022 + name: 违约责任形式明确 + risk: high + score: 4 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: '请检查违约责任条款是否明确。 + + 违约条款:{{违约责任条款}} + + 合同金额:{{合同金额}} + + + 评查要点(依据民法典第577条): + + 1. 区分合同各方主体分别的责任,并分别评查合法、合理性 + + 2. 违约情形是否有约定(具体情形或通用条款均可,"双方违反本合同任何条款"属于有效约定) + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 违约责任条款完整 + fail: 违约责任条款不完整 + - rule_id: JS-TECH-023 + name: 违约金条款完整性与合理性 + risk: high + score: 6 + stages: + - id: '1' + check: required + field: 违约责任条款 + - id: '2' + check: ai + prompt: "请审查违约金条款的完整性与合理性。\n违约责任条款:{{违约责任条款}}\n合同金额:{{合同金额}}\n\n评查方法:\n1. 【主体识别】:识别合同中涉及的所有责任主体(如甲方、乙方、多方合同中的各方)\n2.\ + \ 【情形拆分】:对每个主体,列出其所有违约情形(逾期、质量不合格、单方解除、转包等)\n3. 【条款分析】:对每个违约情形,抽取以下信息:\n - 违约金类型:日违约金/固定比例/赔偿损失/没收保证金/解除合同等\n \ + \ - 计算方式:日违约金的每日标准、固定比例的百分比、赔偿的计算依据\n - 上限约束:是否有最高限额(如\"不超过XX%\"、\"最高XX元\")\n - 累计条件:注意\"直至XX为止\"\"持续计算\"\"按日累计\"\ + 等可能无上限的表述\n4. 【合理性评估】(依据民法典第585条):\n - **上限优先原则**:如果违约金条款明确规定了金额上限(如\"不超过XX%\"、\"最高XX元\"),应以上限为准进行判定,不再计算年化率是否过高\n\ + \ - 日违约金年化:仅对无上限条款时参考,每日1‰≈36.5%/年,每日5‰≈182.5%/年\n - 累计上限:有上限的合理,无上限的需评估长期累计风险\n - 固定比例:一般不超过合同总额的30%\n -\ + \ 责任对等:对比各方违约责任,是否存在显失公平\n5. 该方权益是否有基本保护(如对方违约时自己能获得的补偿)\n\n评查要点:\n✓ 完整性:各方的主要违约情形是否都有约定,不得缺失某方的责任条款\n✓ 明确性:违约金标准是否可计算,不得使用\"\ + 另行协商\"\"按法律规定\"等模糊表述\n✓ 合理性:违约金是否过高或过低,日违约金需考虑累计风险\n✓ 对等性:各方违约责任是否基本对等,不得一方极重一方极轻\n✓ 上限保护:日违约金条款是否有合理上限,无上限的需特别注明风险\n\ + \n请以JSON格式回答:{\"passed\": true/false, \"reason\": \"按主体分项说明:哪些合理、哪些不合理及具体原因\"}\n\nuncertainty_handling:\n如合同原文表述模糊、信息缺失或存在歧义:\n\ + - 不要自行推断或补充内容\n- 在 reason 中明确标注\"原文未明确提及/表述模糊\"\n- passed 返回 false,并给出\"建议补充/明确...\"的实操建议\n" + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 违约金条款完整且合理 + fail: 违约金条款不完整或不合理 + - rule_id: JS-TECH-024 + name: 争议解决方式明确 + risk: high + score: 5 + stages: + - id: '1' + check: required + field: 争议解决条款 + - id: '2' + check: ai + prompt: '请检查合同争议解决条款。 + + 争议解决条款:{{争议解决条款}} + + 甲方地址:{{甲方地址}} + + 乙方地址:{{乙方地址}} + + 履行地点:{{履行地点}} + + + 请分两步审查争议解决条款: + + 【第一步:形式审查】 + + - 是否明确选择诉讼或仲裁其中一种? + + - 是否出现"或仲裁/或诉讼"等并列表述? + + → 若形式不通过,直接返回 {"passed": false, "reason": "形式违规:..."} + + + 【第二步:实质审查(仅当形式通过时执行)】 + + - 提取约定的管辖法院/仲裁机构所在地:{{管辖地点}} + + - 提取关联地点:甲方住所地{{甲方地址}}、乙方住所地{{乙方地址}}、合同履行地{{履行地点}} + + - 依据《民事诉讼法》第35条,判断{{管辖地点}}是否与上述任一地点存在实际联系 + + → 若无实际联系,返回 {"passed": false, "reason": "管辖约定可能因违反民诉法第35条而无效,建议修改为..."} + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: 1 AND 2 + messages: + pass: 争议解决方式明确 + fail: 争议解决方式约定不当 + - rule_id: JS-TECH-025 + name: 不可抗力条款存在 + risk: medium + score: 1 + stages: + - id: '1' + check: required + field: 不可抗力条款 + logic: '1' + messages: + pass: 不可抗力条款存在 + fail: 缺少不可抗力条款 + - rule_id: JS-TECH-026 + name: 变更解除终止条款完整 + risk: high + score: 4 + stages: + - id: '1' + check: ai + prompt: '请检查合同变更、解除、终止条款是否完整并保障甲方(委托方)权益。 + + 变更解除终止条款:{{变更解除终止条款}} + + + 评查要点(依据民法典第543条、第562条、第563条): + + 1. 合同变更条件和程序是否明确 + + 2. 解除或终止条件是否明确,通知期限是否约定 + + 3. 对方违约时甲方是否有足够的救济手段(解除合同、要求赔偿等) + + 4. 终止条款是否对甲方不利(如对方可随意终止而甲方不能) + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + + uncertainty_handling: + + 如合同原文表述模糊、信息缺失或存在歧义: + + - 不要自行推断或补充内容 + + - 在 reason 中明确标注"原文未明确提及/表述模糊" + + - passed 返回 false,并给出"建议补充/明确..."的实操建议 + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 变更解除终止条款完整 + fail: 变更解除终止条款不完整或不利于己方 + - rule_id: JS-TECH-027 + name: 生效条件明确 + risk: medium + score: 3 + stages: + - id: '1' + check: required + field: 合同份数 + - id: '2' + check: required + field: 生效条件 + logic: 1 AND 2 + messages: + pass: 合同份数和生效条件明确 + fail: 缺少合同份数或生效条件 + - rule_id: JS-TECH-028 + name: 保密条款存在 + risk: medium + score: 4 + stages: + - id: '1' + check: required + field: 保密条款 + logic: '1' + messages: + pass: 保密条款存在 + fail: 缺少保密条款 + - rule_id: JS-TECH-029 + name: 附件条款完整 + risk: low + score: 2 + stages: + - id: '1' + check: ai + prompt: "请检查合同附件条款是否完整。\n附件清单:{{附件清单}}\n\n评查方法:\n1. 首先判断\"附件清单\"字段是否有值:\n - 如果为空或null,说明合同未列明附件\n - 如果有值(如\"1.廉洁合同;2.保密协议\"\ + ),说明合同已列明附件\n2. 检查附件清单的完整性:\n - 是否有编号(如1.、2.、附件一、附件二)\n - 是否有附件名称\n - 常见附件如廉洁合同、保密协议是否列入\n3. 判断标准:\n - 附件清单有值且包含编号和名称\ + \ → PASS(合同已列明附件)\n - 附件清单为空但合同实际有附件 → FAIL(未列明)\n - 合同明确无附件 → PASS\n\n请以JSON格式回答:{\"passed\": true/false, \"\ + reason\": \"简要说明\"}\n" + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 附件条款完整或无附件 + fail: 附件条款不完整 + - rule_id: JS-TECH-030 + name: 补充协议条款完整 + risk: medium + score: 2 + stages: + - id: '1' + check: ai + prompt: '请检查合同中是否涉及补充协议条款。 + + 补充协议条款:{{补充协议条款}} + + + 评查要点: + + 1. 如合同包含补充协议或变更协议,应具有协议编号、原合同编号、生效日期 + + 2. 如合同不涉及补充协议,直接PASS + + 3. 补充协议应有与原合同的冲突解决条款 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 补充协议条款完整或无补充协议 + fail: 补充协议条款不完整 + - rule_id: JS-TECH-032 + name: 骑缝章检查 + risk: medium + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: visual + element: 骑缝章 + logic: '1' + messages: + pass: 已加盖骑缝章 + fail: 未检测到骑缝章 + - rule_id: JS-TECH-033 + name: 签署信息完整 + risk: high + score: 2 + applies_in: + - executed + stages: + - id: '1' + check: ai + prompt: '请检查合同各签署方的签署信息是否完整。 + + 甲方:{{甲方}},法定代表人:{{甲方法定代表人}} + + 乙方:{{乙方}},法定代表人:{{乙方法定代表人}} + + 签约日期:{{签约日期}} + + + 评查标准(依据民法典第490条): + + 1. 各方是否有签名或盖章(至少一项) + + 2. 签署日期应有具体日期 + + + 请以JSON格式回答:{"passed": true/false, "reason": "简要说明"} + + ' + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 签署信息完整 + fail: 签署信息不完整 + - rule_id: JS-TECH-034 + name: 签署权限审查 + risk: high + score: 2 + stages: + - id: '1' + check: ai + prompt: "请检查合同签署人的权限是否完整。\n授权信息:{{甲方授权委托信息}}\n签约日期:{{签约日期}}\n\n评查要点(依据民法典第61条、第170条):\n1. 如为法人本人签署(法定代表人),授权信息应包含法人姓名、职务\n\ + 2. 如为非法人签署(代理人),授权信息必须包含:\n - 签署人姓名\n - 签署人职位/职务\n - 权限来源(法定代表人授权书、董事会决议、股东会决议等)\n - 授权范围(有权签署何种类型的合同、金额上限等)\n\ + \n判定标准:\n- 法人本人签署:有姓名、职务即可通过\n- 非法人签署:必须包含职位、权限来源、授权范围,否则不通过\n- 草稿阶段授权信息为空时,判定为不通过(提醒补充)\n\n请以JSON格式回答:{\"passed\"\ + : true/false, \"reason\": \"签署人授权信息不完整,缺少职位/权限来源/授权范围\"}\n\nuncertainty_handling:\n如合同原文表述模糊、信息缺失或存在歧义:\n- 不要自行推断或补充内容\n\ + - 在 reason 中明确标注\"原文未明确提及/表述模糊\"\n- passed 返回 false,并给出\"建议补充/明确...\"的实操建议\n" + schema: + type: object + required: + - passed + - reason + properties: + passed: + type: boolean + reason: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 合同金额在授权范围内或已报上级审查 + fail: 合同可能超出授权范围 + - rule_id: JS-TECH-035 + name: 我方缔约地位及不利条款审查 + risk: high + score: 10 + stages: + - id: '1' + check: ai + field: ctx + prompt: |- + 请基于合同全文判断我方(中国烟草相关公司/专卖局)在本合同中的缔约地位,并审查是否存在强势条款或不利于我方的条款。 + + 合同全文字段来源:ctx。该字段由后端负责注入和匹配。 + 合同全文:{{ctx}} + + 评查步骤: + 1. 识别合同中是否存在中国烟草相关主体,包括但不限于“中国烟草”“烟草公司”“烟草专卖局”“中烟”“卷烟厂”“烟草工业”“烟草商业”等名称或其分支机构。 + 2. 判断该主体是我方,并识别我方在合同中的地位:甲方、乙方、发包人、承包人、委托方、受托方、出租方、承租方、出借人、借款人、赠与方、受赠方或其他。 + 3. 从合同全文审查是否存在明显偏向对方、加重我方责任、限制我方权利、降低对方责任、增加我方付款/赔偿/解除限制/验收风险/知识产权风险/保密风险/争议解决不利风险的条款。 + 4. 若无法识别我方主体或我方地位,应返回无法判断,并说明原因,不得臆测。 + + 强势或不利条款示例: + - 对方可单方变更、解除、延期履行,而我方缺少对应权利。 + - 我方承担高额违约金、无限责任、连带责任或无上限赔偿,对方责任明显较轻。 + - 付款条件、验收、交付、质量、质保、知识产权、保密、争议解决、管辖地等安排明显不利于我方。 + - 排除或限制我方依法解除、抗辩、追偿、索赔、验收异议或审计监督权利。 + + 请以JSON格式回答:{"passed": true/false, "our_party": "我方主体名称或无法判断", "our_position": "甲方/乙方/发包人/承包人/委托方/受托方/出租方/承租方/出借人/借款人/赠与方/受赠方/其他/无法判断", "has_strong_terms": true/false, "has_unfavorable_terms": true/false, "risk_terms": ["风险条款摘要"], "reason": "判断理由", "suggestion": "修改建议"} + schema: + type: object + required: + - passed + - our_party + - our_position + - has_strong_terms + - has_unfavorable_terms + - reason + properties: + passed: + type: boolean + our_party: + type: string + our_position: + type: string + has_strong_terms: + type: boolean + has_unfavorable_terms: + type: boolean + risk_terms: + type: array + reason: + type: string + suggestion: + type: string + pass_when: passed == True + logic: '1' + messages: + pass: 未发现明显强势条款或不利于我方的条款 + fail: 存在强势条款、不利于我方的条款或无法判断我方缔约地位 + type: ai_rule diff --git a/leaudit-oss-yaml-files/govdoc.general/0.1/rules.yaml b/leaudit-oss-yaml-files/govdoc.general/0.1/rules.yaml new file mode 100644 index 0000000..fe84a08 --- /dev/null +++ b/leaudit-oss-yaml-files/govdoc.general/0.1/rules.yaml @@ -0,0 +1,760 @@ +metadata: + type_id: govdoc.general + name: 通用公文格式规则 + version: '0.1' + last_updated: '2026-05-09' + parent: govdoc + classification_keywords: + - 公文 + - 内部公文 + - 通知 + - 报告 + - 请示 + - 批复 + - 函 + - 纪要 + description: 基于错误汇编 10 大类拆解的规则集;来源:公文文稿常见错误汇编(第一期)·2025-11 +extract: [] +rules: + - group: 标题(错误汇编 一) + rules: + - rule_id: GW-T-001 + name: 标题文种合规性 + risk: high + score: 10 + stages: + - check: ai + prompt: | + 审查公文标题是否符合规范。 + 标题:{{title.text}} + + 15 种合法文种:决议、决定、命令(令)、公报、公告、通告、意见、 + 通知、通报、报告、请示、批复、议案、函、纪要 + + 检查要点: + 1. 是否使用了合法文种 + 2. 方案/规划/办法/细则等是否以"通知"形式下发(应为"关于印发〈xxx〉的通知") + 3. 标题中是否有"印发"等动词 + messages: + pass: 标题文种合规 + fail: 标题文种不合规 + - rule_id: GW-T-002 + name: 标题不可有"请求"+"请示"重复 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“标题不可有"请求"+"请示"重复”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:title + 规则分类:标题 + 原始检查参数: + pattern: 关于请求.*的请示 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: '"请示"已包含"请求"之意,应删去"请求"' + messages: + pass: ok + fail: '"请示"已包含"请求"之意,应删去"请求"' + - rule_id: GW-T-003 + name: 标题不可有"上报"+"报告"重复 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“标题不可有"上报"+"报告"重复”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:title + 规则分类:标题 + 原始检查参数: + pattern: 关于上报.*的报告 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: '"报告"已包含"上报"之意,应删去"上报"' + messages: + pass: ok + fail: '"报告"已包含"上报"之意,应删去"上报"' + - rule_id: GW-T-004 + name: 标题介词连用 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“标题介词连用”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:title + 规则分类:标题 + 原始检查参数: + pattern: 关于对.*的(批复|通知|通报) + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: '"关于"+"对" 介词连用不规范' + messages: + pass: ok + fail: '"关于"+"对" 介词连用不规范' + - rule_id: GW-T-005 + name: 标题文种白名单 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“标题文种白名单”判断当前材料是否通过。 + 原始检查类型:wenzhong_whitelist + 评查对象:title + 规则分类:文种 + 原始检查参数: + 无额外参数 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: 文种合规 + fail: 非法定文种(出现"工作情况""汇报""方案""办法"等当文种) + messages: + pass: 文种合规 + fail: 非法定文种(出现"工作情况""汇报""方案""办法"等当文种) + - rule_id: GW-T-006 + name: 标题回行词意完整 + risk: medium + score: 5 + stages: + - check: ai + prompt: | + 只在标题里**明确出现破词**时才报错。 + 破词示例:「广东省烟草专卖局关于xx的通知」如果在"专"和"卖"之间有换行 → fail + 其它情况(单行标题、合理换行点、词意完整)→ **必须 pass** + + 判断准则: + - 标题已经是单行字符串,没有明显断点 → pass + - 不要凭直觉揣测,只判断是否能在原文中**逐字定位**破词位置 + - 找不到具体破词位置就 pass + + 标题原文: + {{title.text}} + messages: + pass: 标题回行合规 + fail: 标题回行破词 + - group: 发文字号(错误汇编 三、六.3) + rules: + - rule_id: GW-N-000 + name: 发文字号必须标注 + risk: high + score: 10 + stages: + - check: required + messages: + pass: ok + fail: 公文应标注发文字号 + - rule_id: GW-N-001 + name: 发文字号必须用六角括号 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“发文字号必须用六角括号”判断当前材料是否通过。 + 原始检查类型:forbid_chars + 评查对象:doc_number + 规则分类:发文 + 缺失提示:发文字号缺失,无法检查该项 + 原始检查参数: + chars: + - "[" + - "]" + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 发文字号年份应用六角括号「〔〕」,不得使用方括号 + messages: + pass: ok + fail: 发文字号年份应用六角括号「〔〕」,不得使用方括号 + - rule_id: GW-N-002 + name: 发文字号不可加"第"字 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“发文字号不可加"第"字”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:doc_number + 规则分类:发文 + 缺失提示:发文字号缺失,无法检查该项 + 原始检查参数: + pattern: 〔\d{4}〕第\d+号 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 发文字号顺序号前不应加"第"字 + messages: + pass: ok + fail: 发文字号顺序号前不应加"第"字 + - rule_id: GW-N-003 + name: 发文字号顺序号不编虚位 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“发文字号顺序号不编虚位”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:doc_number + 规则分类:发文 + 缺失提示:发文字号缺失,无法检查该项 + 原始检查参数: + pattern: 〔\d{4}〕0\d+号 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 发文字号顺序号不编虚位(如"02号"应为"2号") + messages: + pass: ok + fail: 发文字号顺序号不编虚位(如"02号"应为"2号") + - group: 格式(错误汇编 二) + rules: + - rule_id: GW-F-001 + name: 主标题用方正小标宋简体二号 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“主标题用方正小标宋简体二号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:title + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 方正小标宋简体 + size_pt: 22 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 主标题应使用方正小标宋简体二号 + messages: + pass: ok + fail: 主标题应使用方正小标宋简体二号 + - rule_id: GW-F-002 + name: 一级标题用黑体三号 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“一级标题用黑体三号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:适用范围 {"role":"heading_1"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 黑体 + size_pt: 16 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 一级标题应使用黑体三号 + messages: + pass: ok + fail: 一级标题应使用黑体三号 + - rule_id: GW-F-003 + name: 二级标题用楷体三号 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“二级标题用楷体三号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:适用范围 {"role":"heading_2"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 楷体 + size_pt: 16 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 二级标题应使用楷体三号 + messages: + pass: ok + fail: 二级标题应使用楷体三号 + - rule_id: GW-F-004 + name: 正文用仿宋三号 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“正文用仿宋三号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:适用范围 {"role":"body"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 仿宋 + size_pt: 16 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 正文应使用仿宋(GB2312)三号 + messages: + pass: ok + fail: 正文应使用仿宋(GB2312)三号 + - rule_id: GW-F-005 + name: 附件后不加冒号 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“附件后不加冒号”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:适用范围 {"role":"attachment_marker"} + 规则分类:格式 + 原始检查参数: + pattern: ^附件\d+: + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: '"附件1"等字样后不应加冒号' + messages: + pass: ok + fail: '"附件1"等字样后不应加冒号' + - rule_id: GW-F-006 + name: 不使用"(此页无正文)" + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“不使用"(此页无正文)"”判断当前材料是否通过。 + 原始检查类型:forbid_phrase + 评查对象:适用范围 {"role":"any"} + 规则分类:格式 + 原始检查参数: + phrases: + - (此页无正文) + - (此页无正文) + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 应通过编辑排版避免出现"(此页无正文)" + messages: + pass: ok + fail: 应通过编辑排版避免出现"(此页无正文)" + - rule_id: GW-F-007 + name: 附件项末尾不加标点 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“附件项末尾不加标点”判断当前材料是否通过。 + 原始检查类型:cross_role + 评查对象:适用范围 {"role":"any"} + 规则分类:格式 + 原始检查参数: + rules: + - type: attachment_item_no_trailing_punct + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 附件名称(内容)后不应使用标点符号 + messages: + pass: ok + fail: 附件名称(内容)后不应使用标点符号 + - rule_id: GW-F-008 + name: 三级标题用仿宋三号 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“三级标题用仿宋三号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:适用范围 {"role":"heading_3"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 仿宋 + size_pt: 16 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 三级标题应使用仿宋(GB2312)三号 + messages: + pass: ok + fail: 三级标题应使用仿宋(GB2312)三号 + - rule_id: GW-F-009 + name: 四级标题用仿宋三号 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“四级标题用仿宋三号”判断当前材料是否通过。 + 原始检查类型:font + 评查对象:适用范围 {"role":"heading_4"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 仿宋 + size_pt: 16 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 四级标题应使用仿宋(GB2312)三号 + messages: + pass: ok + fail: 四级标题应使用仿宋(GB2312)三号 + - rule_id: GW-F-010 + name: 附件标记用黑体三号不加粗 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“附件标记用黑体三号不加粗”判断当前材料是否通过。 + 原始检查类型:attachment_marker_style + 评查对象:适用范围 {"role":"attachment_marker"} + 规则分类:格式 + 原始检查参数: + expect: + eastasia: 黑体 + size_pt: 16 + bold: false + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: '"附件:"或"附件1"等标记应使用黑体三号,且不加粗' + messages: + pass: ok + fail: '"附件:"或"附件1"等标记应使用黑体三号,且不加粗' + - group: 层级序号(错误汇编 四) + rules: + - rule_id: GW-H-001 + name: 层级序号格式 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“层级序号格式”判断当前材料是否通过。 + 原始检查类型:hierarchy + 评查对象:适用范围 {"role":"any"} + 规则分类:层级 + 原始检查参数: + forbid_patterns: + - ^[一二三四五六七八九十]+、.*[、。]$ + - ^\d+、 + - ^([一二三四五六七八九十]+)、 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 层级序号格式错误 + messages: + pass: ok + fail: 层级序号格式错误 + - rule_id: GW-H-002 + name: 二级标题换行不带句号 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“二级标题换行不带句号”判断当前材料是否通过。 + 原始检查类型:cross_role + 评查对象:适用范围 {"role":"heading_2"} + 规则分类:层级 + 原始检查参数: + rules: + - type: h2_no_period_then_break + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 二级标题在换行分段时不应使用句号 + messages: + pass: ok + fail: 二级标题在换行分段时不应使用句号 + - group: 标点符号(错误汇编 六) + rules: + - rule_id: GW-P-001 + name: 多书名号/引号并列不加顿号 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“多书名号/引号并列不加顿号”判断当前材料是否通过。 + 原始检查类型:punctuation + 评查对象:适用范围 {"role":"any"} + 规则分类:标点 + 原始检查参数: + rules: + - type: no_dunhao_between_quotes + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 多个书名号/引号并列时不应用顿号分隔 + messages: + pass: ok + fail: 多个书名号/引号并列时不应用顿号分隔 + - rule_id: GW-P-002 + name: 句内括号末尾不加标点 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“句内括号末尾不加标点”判断当前材料是否通过。 + 原始检查类型:punctuation + 评查对象:适用范围 {"role":"any"} + 规则分类:标点 + 原始检查参数: + rules: + - type: no_punct_inside_inline_paren + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 句内括号行文末尾通常不应含标点 + messages: + pass: ok + fail: 句内括号行文末尾通常不应含标点 + - rule_id: GW-P-003 + name: 引号嵌套不规范 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“引号嵌套不规范”判断当前材料是否通过。 + 原始检查类型:punctuation + 评查对象:适用范围 {"role":"any"} + 规则分类:标点 + 原始检查参数: + rules: + - type: no_outer_quote_when_inner_quote + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 双引号内已含单引号强调时,外层不应再加双引号(如"卓'粤'创一流"应为 卓"粤"创一流) + messages: + pass: ok + fail: 双引号内已含单引号强调时,外层不应再加双引号(如"卓'粤'创一流"应为 卓"粤"创一流) + - group: 文字表述与提法(错误汇编 七、八、九) + rules: + - rule_id: GW-W-001 + name: 易混淆词使用 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“易混淆词使用”判断当前材料是否通过。 + 原始检查类型:confused_pair + 评查对象:适用范围 {"role":"any"} + 规则分类:文字 + 原始检查参数: + pairs: + - wrong: 截至到 + correct: 截止到 + reason: '"截至" 已含"到"之意' + - wrong: 下称 + correct: 以下简称 + reason: 标注简称应用"以下简称" + - wrong_pattern: 截止\d{4}年 + suggest: 截至YYYY年 + reason: 用于到某时点应为"截至" + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 易混淆词使用不当 + messages: + pass: ok + fail: 易混淆词使用不当 + - rule_id: GW-W-002 + name: 简称使用规范 + risk: medium + score: 5 + stages: + - check: ai + prompt: | + 只在文中出现以下两种省级职务简称错误时才报错,否则一律 pass: + - "X省省委书记" 错误(应为 "X省委书记",省字不重复) + - "X省长" 错误(应为 "X省省长",省字不可省略) + + 若文中没有"省委书记"或"省长"等省级职务字样,**必须 pass**。 + 若不能在文中找到准确的错误原文,**必须 pass**。 + 不要做语气、措辞、其它简称的检查。 + + 全文片段: + {{paragraphs}} + messages: + pass: 简称规范 + fail: 简称使用不规范 + - rule_id: GW-W-003 + name: 成文日期用阿拉伯数字 + risk: high + score: 10 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“成文日期用阿拉伯数字”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:date + 规则分类:提法 + 原始检查参数: + pattern: "[一二三四五六七八九十○〇零]+年" + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 成文日期应使用阿拉伯数字(如"2023年10月9日") + messages: + pass: ok + fail: 成文日期应使用阿拉伯数字(如"2023年10月9日") + - rule_id: GW-W-004 + name: 成文日期不编虚位 + risk: medium + score: 5 + stages: + - check: ai + prompt: |- + 请根据公文评查规则“成文日期不编虚位”判断当前材料是否通过。 + 原始检查类型:regex_forbid + 评查对象:date + 规则分类:提法 + 原始检查参数: + pattern: \d{4}年0\d月|\d{4}年\d{1,2}月0\d日 + 输出要求: + - 符合规则时返回 pass。 + - 不符合规则时返回 fail,并说明具体原因。 + - 无法定位评查对象且缺失策略不是 fail 时,按不适用或通过处理。 + 规则消息: + pass: ok + fail: 成文日期月、日不编虚位 + messages: + pass: ok + fail: 成文日期月、日不编虚位 + - group: 发文机关(错误汇编 十) + rules: + - rule_id: GW-S-001 + name: 发文机关署名不能用简称 + risk: high + score: 10 + stages: + - check: ai + prompt: | + 判断署名是否含**明确的简称错误**。 + 典型错误: + - "广东省烟草专卖局(公司)" — 用括号缩短两个机关 → 错 + - "省局" / "粤烟" 等单独缩写 → 错 + 典型正确: + - "广东省烟草专卖局" 单独出现 → pass(即使可能存在配套总公司,但单独存在不算简称) + - "广东省烟草专卖局 中国烟草总公司广东省公司" → pass + + 判断准则: + - 若署名是一个完整、官方、可独立成立的机关名 → **必须 pass** + - 若署名带"(公司)"、"省局"、明显缩写、行业内部代号 → fail + + 署名原文: + {{signature.text}} + messages: + pass: 署名规范 + fail: 发文机关署名使用了简称 + - rule_id: GW-S-002 + name: 发文机关确定严谨性 + risk: medium + score: 5 + stages: + - check: ai + prompt: | + 只判断**这一个明确条件**: + - 标题或正文里明确涉及"党组""党的xx工作""组织部""纪委"等党务事项, + 但署名是行政机关(局/公司/委员会等),未署"党组"或党务机构 → fail + - 其它情况(行政事务、缺乏证据、性质模糊)→ **必须 pass** + + 判断时需要看到**明确的党务关键词**(党组/党委/党的xx会议/党风/反腐倡廉等), + 没有这些关键词就 pass。 + + 署名原文:{{signature.text}} + 标题:{{title.text}} + messages: + pass: 发文机关一致 + fail: 发文机关与文稿性质不一致 \ No newline at end of file diff --git a/leaudit-oss-yaml-files/合同yaml修改列表.md b/leaudit-oss-yaml-files/合同yaml修改列表.md new file mode 100644 index 0000000..cac11b0 --- /dev/null +++ b/leaudit-oss-yaml-files/合同yaml修改列表.md @@ -0,0 +1 @@ +contract.construction.general/v3
contract.entrust/v9
contract.evaluation.delegation/0.1
contract.gift.charity/1.0
contract.gift.general/1.0
contract.lease/2.0
contract.loan.general/1.0
contract.purchase.general/1.0
contract.sale/2.1
contract.tech/1.0 \ No newline at end of file diff --git a/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml new file mode 100644 index 0000000..722b123 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml @@ -0,0 +1,2972 @@ +metadata: + type_id: 行政卷宗.行政处罚 + name: 烟草专卖行政处罚卷宗 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗 + inherits_from: + - base.common + - base.administrative_case + classification_keywords: + - 行政处罚 + - 烟草专卖 + - 处罚决定书 + - 立案报告 + - 询问笔录 + description: '烟草专卖行政处罚卷宗审核。 + + 覆盖:立案、现场检查、证据先行登记保存、取证核价、询问笔录、权利告知、 + + 调查终结、处理审批、事先告知、处罚决定、送达、执行、结案全流程。 + + ' + # 开 medium 风险规则的 LLM 救援(跨子文档对齐失败交给 rescue 模块 + # 的 L1 判定语义等价,如"投诉举报" vs "举报")。 + rescue_profile: + rescue_risk: [medium] + +# TOC 页定位(dossier_segmenter 使用) +# keywords: 本类卷宗的目录标题(OCR 空白自动规整,"卷 宗 目 录" 也命中) +# anti_keywords: 卷内"内部目录",避免被误判为卷宗级 TOC +# 两个列表均为"扩展默认值",下面显式列出本类卷宗实际会遇到的项 —— +# 默认的 卷宗目录 / 卷内目录 / Contents 等仍自动生效。 +toc: + keywords: + # 实际 OCR 观察到的目录标题(均为 defaults 一部分,显式列出作自注释) + - 卷宗目录 + - 卷内目录 + anti_keywords: + # 卷内子文档自带的"目录"标题,不是卷宗级 TOC —— 必须排除 + - 证据材料目录 # 重大执法行为法制审核送审表 里的证据清单 + - 物品目录 # 抽样取证物品清单等 + +# 跨子文档派生字段 —— 给规则里的 `activate_if` / 对级 `when` 用 +derived_fields: + # 当事人类型:决定一条规则里"个人字段 pair"还是"单位字段 pair"该不该对齐 + # + # 按 USCC 第 2 位判定(GB 32100-2015 法人和其他组织统一社会信用代码): + # 1 = 机关 → 单位 + # 2 = 个体工商户 → 个人(法律归类:自然人工商业) + # 3 = 农民专业合作社 → 单位 + # 4 = 事业单位 → 单位 + # 5 = 企业 → 单位 + # 8 = 社团 → 单位 + # 9 = 其他组织 → 单位 + # + # 个体户虽然有 USCC 和营业执照,但当事人栏填个人信息(姓名/身份证), + # 所以单位 pair 应跳过;执照字段作为辅助证据另行处理。 + # + # 注:不看"字号"——当前 OCR 常把案件文号误抽到 字号 字段; + # 执照名称/执照统一社会信用代码 在个体户里也存在,因此不作为单位标志。 + # 表达式必须单行(evaluate 不支持多行条件)。 + - name: 当事人类型 + type: string + # 嵌套 IfExp 走短路(BoolOp 求值所有分支,`not None` 会走 null-propagation + # 返回 None 而被 IfExp 当 False 走到 else,导致对 None 调 .startswith 崩溃) + # - USCC 空/缺失 → 个人 + # - USCC 以 '92' 开头 → 个人(个体工商户) + # - 其它 (91/93/94/95/… 开头) → 单位 + compute: "'个人' if 处罚决定书.统一社会信用代码 == None else ('个人' if 处罚决定书.统一社会信用代码.startswith('92') else '单位')" + desc: 案件当事人类型(个人 / 单位)—— 按 USCC 第 2 位判,个体户 (92xxx) 判为个人 + + # 证据复制(提取)单可能同时存在多张居民身份证(当事人、举报人、 + # 未成年人、相关人等)。抽取侧把整组居民身份证记录按 multi_entity 抽 + # 下来,由这里挑出归属当事人的那一份;规则再用 + # `证据复制(提取)单当事人.身份证*` 对齐处罚决定书/审批表/终结报告。 + # 只有一张身份证时引擎自动短路,不计 LLM 调用。 + - name: 证据复制(提取)单当事人 + type: object + compute_by: llm + prompt: |- + 以下是证据复制(提取)单中全部居民身份证记录: + + {居民身份证} + + 当事人姓名:{处罚决定书.当事人} + 当事人身份证号:{处罚决定书.身份证号码} + + 请挑出归属「当事人本人」(被处罚对象)的那一份身份证,按原字段 + 结构返回一个 JSON 对象(严格包含 身份证姓名/身份证性别/身份证民族/ + 身份证住址/身份证号/身份证背面,空值写 null)。 + + 优先以身份证号匹配当事人身份证号;若号码缺失,用姓名匹配。匹配不到 + 或无法判断归属时返回 JSON null。除该 JSON 外不要输出任何解释文字。 + depends_on: + - 居民身份证 + - 处罚决定书.当事人 + - 处罚决定书.身份证号码 + + # 询问笔录可能包含多份笔录记录(同一卷宗针对多人询问)。抽取侧把所 + # 有被询问人按 multi_entity 抽下来,由这里挑当事人那份;规则再用 + # `询问笔录当事人.被询问人*` 做一致性校验。只有一份被询问人记录时 + # 引擎自动短路,不计 LLM 调用。 + - name: 询问笔录当事人 + type: object + compute_by: llm + prompt: |- + 以下是询问笔录中全部「被询问人」记录: + + {被询问人} + + 当事人姓名:{处罚决定书.当事人} + 当事人身份证号(若有):{处罚决定书.身份证号码} + + 请挑出归属「当事人本人」(被处罚对象)的那一份笔录记录,按原字段 + 结构返回一个 JSON 对象(严格包含 被询问人姓名/被询问人性别/被询问人民族/ + 被询问人证件/被询问人电话/被询问人住址/被询问人经营地址,空值写 null)。 + + 优先以证件号匹配当事人身份证号;若号码缺失,用姓名匹配。匹配不到 + 或无法判断归属时返回 JSON null。除该 JSON 外不要输出任何解释文字。 + depends_on: + - 被询问人 + - 处罚决定书.当事人 + - 处罚决定书.身份证号码 + +sub_documents: +- id: 先行登记保存证据处理通知书 + name: 先行登记保存证据处理通知书 + required: false + classifier: + title_patterns: + - 先行登记保存证据处理通知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 处理方式 + type: verbatim + vlm_extract_mode: always + desc: 证据做出如下处理→选中的选项,要看打勾的选项 +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: + title_patterns: + - 卷内备考表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立卷时间 + type: verbatim + desc: 立卷时间 +- id: 卷宗封面 + name: 卷宗封面 + required: false + classifier: + title_patterns: + - ^##?\s*卷\s*宗\s*$ + keywords: + - 此卷共计 + - 归档日期 + - 保存期限 + min_score: 1.0 + extract: + - group: 基本信息 + fields: + - name: 处理结果 + type: string + desc: 处理结果 +- id: 处罚决定书 + name: 处罚决定书 + required: true + classifier: + title_patterns: + - 处罚决定书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 字号 + type: verbatim + desc: 字号 + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 性别 + type: enum + allowed: + - 男 + - 女 + desc: 性别 + - name: 民族 + type: verbatim + desc: 民族 + - name: 烟草专卖许可证号 + type: verbatim + desc: 烟草专卖许可证号 + - name: 经营地址 + type: string + desc: 经营地址 + - name: 统一社会信用代码 + type: uscc + desc: 统一社会信用代码 + - name: 落款日期 + type: date + desc: 落款日期 + - name: 身份证住址 + type: string + desc: 身份证住址 + - name: 身份证号码 + type: chinese-id + desc: 身份证号码 + - group: 罚款信息 + fields: + - name: 罚款项目 + type: string + desc: 正文→罚款项目 + - name: 罚款基数 + type: money + desc: 正文→罚款项目金额基数 + - name: 罚款比例 + type: string + desc: 正文→罚款百分比 保留原格式如"50%" + - name: 罚款总额 + type: money + desc: 正文→罚款总金额 + - name: 罚款说明 + type: string + desc: 正文→罚款说明 + - name: 证据列举 + type: string + desc: 正文→证据列举 + - group: 权利告知 + fields: + - name: 救济途径 + type: string + desc: 正文→救济途径 +- id: 抽样取证物品清单 + name: 抽样取证物品清单 + required: false + classifier: + title_patterns: + - 抽样取证物品清单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 品种规格 + type: string + desc: 表格内容→品种规格、样品基数 + - name: 表格有内容 + type: enum + allowed: + - 有 + - 无 + desc: 表格是否有内容 输出 有/无 + - name: 当事人签名 + type: enum + allowed: + - 有 + - 无 + desc: 当事人签名栏 输出 有/无 +- id: 案件处理审批表 + name: 案件处理审批表 + required: true + classifier: + title_patterns: + - 案件处理审批表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案编号 + type: verbatim + desc: 立案编号 + - name: 立案日期 + type: date + desc: 立案日期 + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 + - name: 违法事实 + type: string + desc: 从案件处理审批表正文、承办人意见、承办部门意见或拟处理意见中提取违法事实上下文,包含违法行为、涉案物品、数量、金额、货值或违法所得、合法来源证明、许可证或准运证、初次违法、从轻从重等裁量相关事实;不要只输出案由。 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: verbatim + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 审批意见 + fields: + - name: 承办人意见 + type: string + desc: 承办人意见→内容 + - name: 承办人日期 + type: date + desc: 承办人意见→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→内容 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 法制部门意见 + type: string + desc: 法制部门意见→内容 + - name: 法制部门日期 + type: date + desc: 法制部门意见→日期 + - name: 法制部门审核人签名 + type: enum + allowed: + - 有 + - 无 + desc: 法制部门意见→审核人签名 输出 有/无 + - name: 法制部门负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 法制部门意见→负责人签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 案件调查终结报告 + name: 案件调查终结报告 + required: true + classifier: + title_patterns: + - 案件调查终结报告 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案日期 + type: date + desc: 立案日期 + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: enum + allowed: + - 男 + - 女 + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 处理意见 + fields: + - name: 处理意见日期 + type: date + desc: 处理意见→日期 + - name: 处理意见承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 处理意见→承办人签名1 输出 有/无 + - name: 处理意见承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 处理意见→承办人签名2 输出 有/无 +- id: 涉案物品核价表 + name: 涉案物品核价表 + required: false + classifier: + title_patterns: + - 涉案物品核价表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 核价组印章 + type: enum + allowed: + - 有 + - 无 + desc: 涉案卷烟价格管理小组印章 输出 有/无 + - name: 核价明细 + type: string + desc: 表格内容→品种规格、数量(单位:条)、单价(元)、合计(元)、备注 + - name: 表格全文 + type: string + desc: 核价表完整内容 +- id: 涉案物品返还清单 + name: 涉案物品返还清单 + required: false + classifier: + title_patterns: + - 涉案物品返还清单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 日期 + type: date + desc: 日期 + - name: 补偿信息 + type: verbatim + desc: 补偿信息 + - name: 返还明细 + type: string + desc: 表格内容→品种规格、数量(单位:条)、单价(元)、合计(元)、备注 + - name: 返还确认 + type: verbatim + desc: 返还确认 + - name: 接收人签名 + type: enum + allowed: + - 有 + - 无 + desc: 接收人→签名 输出 有/无 + - name: 接收单位印章 + type: enum + allowed: + - 有 + - 无 + desc: 接收单位→印章 输出 有/无 +- id: 现场笔录 + name: 现场笔录 + required: true + classifier: + title_patterns: + - 现场笔录 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 检查时间 + type: verbatim + desc: 检查时间 + - name: 检查地点 + type: verbatim + desc: 检查地点 + - group: 被检查人 + fields: + - name: 单位名称 + type: string + desc: 被检查人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 法定代表人(负责人) + - name: 单位许可证号 + type: verbatim + desc: 烟草专卖许可证号码 + - name: 个人姓名 + type: verbatim + desc: 被检查人→个人→姓名 + - name: 个人性别 + type: enum + allowed: + - 男 + - 女 + desc: 被检查人→个人→性别 + - name: 个人证件 + type: verbatim + desc: 被检查人→个人→证件类型及号码 + - name: 地址 + type: string + desc: 被检查人→地址 + - name: 电话 + type: verbatim + desc: 被检查人→联系电话 + - name: 现场负责人 + type: verbatim + desc: 现场负责人→姓名、性别、证件类型及号码、与被检查人关系 + - group: 签名意见 + fields: + - name: 意见 + type: verbatim + desc: 被检查人或现场负责人→意见 + - name: 意见日期 + type: date + desc: 被检查人或现场负责人(签名)→日期 + - name: 意见签名 + type: enum + allowed: + - 有 + - 无 + desc: 被检查人或现场负责人(签名)输出 有/无 +- id: 立案报告表 + name: 立案报告表 + required: true + classifier: + title_patterns: + - 立案报告表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案编号 + type: verbatim + desc: 立案编号 如"郁烟立〔2024〕第35号" + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 如"投诉举报" + - name: 案发时间 + type: verbatim + desc: 案发时间 + - name: 案发地点 + type: verbatim + desc: 案发地点 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: verbatim + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人身份证号 + type: chinese-id + desc: 当事人→个人→居民身份证号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 案情 + fields: + - name: 案情摘要 + type: string + desc: 案情摘要正文 + - name: 案情品种 + type: string + desc: 案情摘要中的品种规格、单位、数量 + - group: 审批意见 + fields: + - name: 承办人意见 + type: string + desc: 承办人意见→意见 + - name: 承办人日期 + type: date + desc: 承办人意见→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→意见 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→意见内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 结案报告表 + name: 结案报告表 + required: true + classifier: + title_patterns: + - 结案报告表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 执行情况 + type: string + desc: 执行情况 + - group: 审批意见 + fields: + - name: 承办人结案理由 + type: string + desc: 承办人结案理由→内容 + - name: 承办人结案日期 + type: date + desc: 承办人结案理由→日期 + - name: 承办人结案签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人结案理由→签名1 输出 有/无 + - name: 承办人结案签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人结案理由→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→内容 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 缴款凭证 + name: 缴款凭证 + required: false + classifier: + title_patterns: + - 缴款凭证 + - 广东省非税收入一般缴款书[((]电子[))] + - 广东省非税收入一般缴款书(电子) + keywords: + - 非税收入 + - 缴款书 + - 收费项目 + - 收入项目 + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 收入项目 + type: string + desc: 收入项目名称(电子非税缴款书上可能写作"收费项目") + - name: 金额 + type: money + desc: 金额 + - name: 备注 + type: verbatim + desc: 备注 +- id: 行政处罚事先告知书 + name: 行政处罚事先告知书 + required: true + classifier: + title_patterns: + - 行政处罚事先告知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 正文前称呼 + type: string + desc: 正文前称呼 + - name: 权利告知 + type: string + desc: 正文→权利告知 +- id: 证据先行登记保存批准书 + name: 证据先行登记保存批准书 + required: false + classifier: + title_patterns: + - 证据先行登记保存批准书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 标题下方文本 + type: string + desc: 标题下方文本 + - name: 表格下方文字 + type: string + desc: 表格下方文字 含"对先行登记保存的证据,应当在...日内处理" + - name: 表格品规 + type: string + desc: 表格内容→品种规格、单位、数量 + - name: 表格全文 + type: string + desc: 表格完整内容 + - name: 盖章 + type: enum + allowed: + - 有 + - 无 + desc: 行政机关盖章 输出 有/无 + - group: 承办人 + fields: + - name: 承办人日期 + type: date + desc: 承办人→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名2 输出 有/无 + - group: 负责人 + fields: + - name: 负责人意见 + type: verbatim + desc: 负责人意见并签名→意见内容 + - name: 负责人意见有无 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见并签名→是否有意见 输出 有/无 + - name: 负责人日期 + type: date + desc: 负责人意见并签名→日期 + - name: 负责人签名姓名 + type: verbatim + desc: 负责人意见并签名→签名姓名 + - name: 负责人签名有无 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见并签名→是否有签名 输出 有/无 +- id: 证据先行登记保存通知书 + name: 证据先行登记保存通知书 + required: false + classifier: + title_patterns: + - 证据先行登记保存通知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 表格下方文字 + type: string + desc: 表格下方文字 + - name: 表格品规 + type: string + desc: 表格内容→品种规格、单位、数量 + - name: 表格全文 + type: string + desc: 表格完整内容 + - name: 盖章 + type: enum + allowed: + - 有 + - 无 + desc: 行政机关盖章 输出 有/无 + - name: 拒绝签名说明 + type: string + desc: 正文→拒绝签名说明 + - name: 当事人签名 + type: enum + allowed: + - 有 + - 无 + desc: 当事人签名 输出 有/无 + - group: 承办人 + fields: + - name: 承办人日期 + type: date + desc: 承办人→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名2 输出 有/无 +- id: 证据复制(提取)单 + name: 证据复制(提取)单 + required: true + classifier: + title_patterns: + - 证据复制[((]提取[))]单 + - 证据复制(提取)单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 复制时间 + type: verbatim + desc: |- + 证据复制(提取)单每页末尾的「复制(提取)时间」字段。 + 一份卷宗通常有多份证据(每页一份,说明事项各异)。 + 当**多份存在**时,取**第一份**的时间(整体活动起点, + 通常也是询问笔录期间制作的那份)。 + 示例:"2024 年 4 月 19 日 19 时 30 分"。 + - name: 复制地点 + type: verbatim + desc: |- + 证据复制(提取)单每页末尾的「复制(提取)地点」字段。 + 多份存在时取**第一份**的地点(与 复制时间 同一页)。 + 示例:"郁南县南江口镇西江路 124 号"。 + - name: 现场时间 + type: verbatim + desc: |- + 证据复制(提取)单中**现场检查相片**所属那份证据的 + 「复制(提取)时间」。特征:说明事项含"现场/外观/ + 查获时拍摄/查获违法走私卷烟时"等,与执法人员到场 + 同一时段。用于和 现场笔录.检查时间 对齐。 + 如没有纯"现场检查"相片,取第一份时间。 + - name: 现场地址 + type: string + desc: |- + 证据复制(提取)单中**现场检查相片**所属那份证据的 + 「复制(提取)地点」,与 现场时间 同一份。 + 用于和 现场笔录.检查地点 对齐。 + - name: 邮件回执 + type: verbatim + desc: 邮件回执 + - group: 居民身份证 + fields: + - name: 居民身份证 + type: multi_entity + desc: |- + 证据复制(提取)单中**每一张**居民身份证图片对应的一份记录。 + 一份证据复制单通常包含多张身份证(当事人、举报人、未成年人、相关人等), + 请把每张身份证都抽取为数组中的一项,**不要**只抽"当事人那份"。 + 由派生字段「证据复制(提取)单当事人」按姓名+身份证号挑出归属当事人的那一份。 + 如果只有一张身份证,返回只包含一项的数组即可(引擎会自动把那一项判为当事人)。 + fields: + - name: 身份证姓名 + type: verbatim + desc: 该份身份证上印的姓名 + - name: 身份证性别 + type: verbatim + desc: 该份身份证上印的性别(男/女) + - name: 身份证民族 + type: verbatim + desc: 该份身份证上印的民族 + - name: 身份证住址 + type: string + desc: 该份身份证上印的住址 + - name: 身份证号 + type: chinese-id + desc: 该份身份证上印的公民身份号码(18 位) + - name: 身份证背面 + type: enum + allowed: + - 有 + - 无 + desc: 该份身份证是否包含背面(签发机关/有效期那面)有/无 + - group: 许可证 + fields: + - name: 许可证企业名称 + type: string + desc: 烟草专卖零售许可证→企业名称 + - name: 许可证经营场所 + type: string + desc: 烟草专卖零售许可证→经营场所 + - name: 许可证号 + type: verbatim + desc: 烟草专卖零售许可证→许可证号 + - name: 许可证负责人 + type: verbatim + desc: 烟草专卖零售许可证→负责人姓名 + - group: 营业执照 + fields: + - name: 执照名称 + type: string + desc: 营业执照→名称 + - name: 执照住所 + type: string + desc: 营业执照→住所 + - name: 执照法代 + type: verbatim + desc: 营业执照→法定代表人 + - name: 执照统一社会信用代码 + type: uscc + desc: 营业执照→统一社会信用代码 +- id: 询问笔录 + name: 询问笔录 + required: true + classifier: + title_patterns: + - 询问笔录 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 询问时间 + type: verbatim + desc: 询问时间 + - name: 询问地点 + type: verbatim + desc: 询问地点 + - group: 被询问人 + fields: + - name: 被询问人 + type: multi_entity + desc: |- + 询问笔录中**每一份**笔录记录对应的被询问人基本信息。 + 一份卷宗可能包含多次询问笔录(针对不同人员),请把**每一份**笔录 + 中的被询问人都抽取为数组中的一项,**不要**只抽"当事人那份"。 + 由派生字段「询问笔录当事人」按姓名+证件号挑出归属当事人的那一份。 + 只有一份被询问人记录时,引擎自动把那一份判为当事人。 + fields: + - name: 被询问人姓名 + type: verbatim + desc: 被询问人→姓名 + - name: 被询问人性别 + type: enum + allowed: + - 男 + - 女 + desc: 被询问人→性别 + - name: 被询问人民族 + type: verbatim + desc: 被询问人→民族 + - name: 被询问人证件 + type: verbatim + desc: 被询问人→证件类型及号码(通常是"居民身份证:xxx") + - name: 被询问人电话 + type: verbatim + desc: 被询问人→联系电话 + - name: 被询问人住址 + type: string + desc: 被询问人→住址 + - name: 被询问人经营地址 + type: string + desc: 被询问人→经营地址 + - group: 笔录正文 + fields: + - name: 执法人员信息 + type: string + desc: 正文→执法人员信息 + - name: 权利告知 + type: string + desc: 正文→权利告知内容 + - name: 被询问人核实 + type: string + desc: 正文→被询问人核实 + - name: 拒绝签名说明 + type: string + desc: 正文→拒绝签名说明 + - group: 签名 + fields: + - name: 被询问人签名 + type: enum + allowed: + - 有 + - 无 + desc: 被询问人(签名)输出 有/无 + - name: 询问人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 询问人(签名)1 输出 有/无 + - name: 询问人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 询问人(签名)2 输出 有/无 +- id: 送达回证 + name: 送达回证 + required: true + classifier: + title_patterns: + - 送达回证 + keywords: [] + min_score: 0.5 + # 注意:本子文档内可能拼接多份送达回证表格(立案通知/先行登记保存通知/事先告知书/ + # 处罚决定书等各一份)。以下字段只抽取"送达文书名称"含"行政处罚决定书"的那份; + # 其他送达回证忽略(由专门的规则处理)。 + extract: + - group: 基本信息 + fields: + - name: 受送达人 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份表格里的受送达人 + - name: 回证编号 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份表格上方的回证编号 + - name: 送达方式 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达方式 + - name: 送达地点 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达地点 + - name: 送达文书名称 + type: string + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"的那一项(作为后续其他字段的定位基准) + - name: 送达文书文号 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达文书文号 + - group: 签收 + fields: + - name: 签收日期 + type: date + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的签收日期 + - name: 代收理由 + type: string + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的代收人代收理由 + - name: 印章 + type: enum + allowed: + - 有 + - 无 + desc: 印章 输出 有/无 + vlm_extract_mode: always + - name: 收件人签名 + type: enum + allowed: + - 有 + - 无 + desc: 收件人签名或盖章→签名 输出 有/无 + vlm_extract_mode: always + - name: 收件人盖章 + type: enum + allowed: + - 有 + - 无 + desc: 收件人签名或盖章→盖章 输出 有/无 + vlm_extract_mode: always + - group: 送达人 + fields: + - name: 送达人签名 + type: enum + allowed: + - 有 + - 无 + desc: 送达人签名 输出 有/无 + vlm_extract_mode: always +rules: +- group: JZG-JD + rules: + - rule_id: JZ-JD-001 + name: 当事人基本情况或立案情况记载准确性 + desc: 若当事人信息与证据复制(提取)单中信息不一致,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + - 案件调查终结报告 + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 案件处理审批表.案由 + target: 案件调查终结报告.案由 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + - source: 案件处理审批表.立案编号 + target: 立案报告表.立案编号 + - source: 案件处理审批表.立案日期 + target: 案件调查终结报告.立案日期 + - source: 案件处理审批表.单位名称 + target: 案件调查终结报告.单位名称 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 案件处理审批表.单位法代 + target: 案件调查终结报告.单位法代 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 案件处理审批表.单位地址 + target: 案件调查终结报告.单位地址 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 案件处理审批表.案由 + target: 案件调查终结报告.案由 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + - source: 案件处理审批表.立案日期 + target: 案件调查终结报告.立案日期 + - source: 案件处理审批表.立案编号 + target: 立案报告表.立案编号 + - source: 案件处理审批表.个人姓名 + target: 案件调查终结报告.个人姓名 + when: "当事人类型 != '单位'" + - source: 案件处理审批表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + method: substring + - source: 案件处理审批表.个人住址 + target: 案件调查终结报告.个人住址 + when: "当事人类型 != '单位'" + - id: '3' + check: ai + prompt: "请根据以下卷宗信息,判断当事人基本情况及立案情况的记载是否准确一致。\n\n 【第一步:判断案件类型】\n\n 检查\"案件处理审批表\"\ + 中的当事人单位名称字段值:\n {{案件处理审批表.单位名称}}\n\n - 如果该值为 \"/\"、\"-\"、空或其他占位符 → 这是**个人案件**,执行个人案件检查\n\ + \ - 如果该值是真实的单位名称 → 这是**单位案件**,执行单位案件检查\n\n ---\n\n 【第二步-A:单位案件检查】(当事人为单位时执行)\n\ + \n 请逐一比对以下字段,判断是否一致:\n\n 1. 案由\n - 案件处理审批表:{{案件处理审批表.案由}}\n - 案件调查终结报告:{{案件调查终结报告.案由}}\n\ + \n 2. 案件来源\n - 案件处理审批表:{{案件处理审批表.案件来源}}\n - 案件调查终结报告:{{案件调查终结报告.案件来源}}\n\n\ + \ 3. 立案编号\n - 案件处理审批表:{{案件处理审批表.立案编号}}\n - 立案报告表:{{立案报告表.立案编号}}\n\n 4. 立案日期\n\ + \ - 案件处理审批表:{{案件处理审批表.立案日期}}\n - 案件调查终结报告:{{案件调查终结报告.立案日期}}\n\n 5. 单位名称(三方核对)\n\ + \ - 案件处理审批表:{{案件处理审批表.单位名称}}\n - 案件调查终结报告:{{案件调查终结报告.单位名称}}\n - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照名称}}\n\ + \n 6. 法定代表人(三方核对)\n - 案件处理审批表:{{案件处理审批表.单位法代}}\n - 案件调查终结报告:{{案件调查终结报告.单位法代}}\n\ + \ - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照法代}}\n\n 7. 单位地址(三方核对)\n - 案件处理审批表:{{案件处理审批表.单位地址}}\n\ + \ - 案件调查终结报告:{{案件调查终结报告.单位地址}}\n - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照住所}}\n\n \ + \ ---\n\n 【第二步-B:个人案件检查】(当事人为个人或个体工商户时执行)\n\n 请逐一比对以下字段,判断是否一致:\n\n 1.\ + \ 案由\n - 案件处理审批表:{{案件处理审批表.案由}}\n - 案件调查终结报告:{{案件调查终结报告.案由}}\n\n 2. 案件来源\n\ + \ - 案件处理审批表:{{案件处理审批表.案件来源}}\n - 案件调查终结报告:{{案件调查终结报告.案件来源}}\n\n 3. 立案编号\n\ + \ - 案件处理审批表:{{案件处理审批表.立案编号}}\n - 立案报告表:{{立案报告表.立案编号}}\n\n 4. 立案日期\n - 案件处理审批表:{{案件处理审批表.立案日期}}\n\ + \ - 案件调查终结报告:{{案件调查终结报告.立案日期}}\n\n 5. 姓名\n - 案件处理审批表:{{案件处理审批表.个人姓名}}\n -\ + \ 案件调查终结报告:{{案件调查终结报告.个人姓名}}\n\n 6. 性别\n - 案件处理审批表:{{案件处理审批表.个人性别}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证性别}}\n\ + \n 7. 民族\n - 案件调查终结报告:{{案件调查终结报告.个人民族}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证民族}}\n\ + \n 8. 证件号码(包含匹配)\n - 案件调查终结报告:{{案件调查终结报告.个人证件}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证号}}\n\ + \ - 注意:审批表中证件字段格式可能为\"居民身份证:44xxxxxxxx\",判断时应提取纯号码部分进行比对\n\n 9. 住址\n - 案件处理审批表:{{案件处理审批表.个人住址}}\n\ + \ - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证住址}}\n\n ---\n\n 【判断规则】\n\n - \"/\"\ + 、\"-\"、\"—\" 等符号代表该字段不适用,不是有效值,遇到此类值的比对项直接跳过\n - 只要有任意一个有效字段不一致,判定为**不通过**\n\ + \ - 所有有效字段均一致(或均为占位符可跳过),判定为**通过**\n" + logic: 1 OR 2 OR 3 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: ai_rule + - rule_id: JZ-JD-002 + name: 处罚决定书证据列举 + desc: 若找不到"证据:"或者"证据:"之后无内容,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + stages: + - id: '1' + check: required + field: 处罚决定书.证据列举 + messages: + pass: 处罚决定书已列出相关证据。 + fail: 罚决定书未列出相关证据,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic + - rule_id: JZ-JD-003 + name: 救济途径或期限告知明确性 + desc: 若未找到文本匹配内容,则扣分。 + risk: medium + score: 5 + scope: + - 处罚决定书 + stages: + - id: '1' + check: required + field: 处罚决定书.救济途径 + messages: + pass: 已告知救济途径和期限。 + fail: 救济途径或期限告知不明确或不正确,请核对。 + references_laws: + - 《中华人民共和国烟草专卖法》第四十一条 + type: deterministic + - rule_id: JZ-JD-004 + name: 行政处罚决定当事人基本情况记载准确性 + desc: 检查首段信息是否填写齐全,若存在未填内容,(字号:可为空),若不齐全,则扣分。 若当事人信息与证据中提取的信息不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 处罚决定书.当事人 + target: 证据复制(提取)单.许可证企业名称 + when: "当事人类型 != '个人'" + - source: 处罚决定书.字号 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 处罚决定书.统一社会信用代码 + target: 证据复制(提取)单.执照统一社会信用代码 + when: "当事人类型 != '个人'" + - source: 处罚决定书.经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证经营场所 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 处罚决定书.当事人 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 处罚决定书.性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 处罚决定书.民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 处罚决定书.身份证住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 处罚决定书.身份证号码 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 处罚决定书.经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证经营场所 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - source: 处罚决定书.字号 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + logic: 1 OR 2 + messages: + pass: 当事人的基本情况记载齐全且准确。 + fail: 当事人的基本情况记载不齐全或不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic + - rule_id: JZ-JD-005 + name: 案由及裁量标准适用准确性 + desc: 结合案件处理审批表违法事实上下文判断案由是否合理准确;案由准确时,继续核对卷宗封面处理结果及处罚决定书罚款项目、基数、比例、总额是否符合对应裁量标准。 + risk: high + score: 10 + scope: + - 案件处理审批表 + - 立案报告表 + - 案件调查终结报告 + - 处罚决定书 + - 卷宗封面 + - 涉案物品核价表 + stages: + - id: '1' + check: ai + prompt: | + 请按以下顺序评查案由和裁量标准适用,不得跳步。 + + 一、先判断案由是否合理、准确 + 1. 读取违法事实上下文: + - 案件处理审批表.案由:{{案件处理审批表.案由}} + - 案件处理审批表.违法事实:{{案件处理审批表.违法事实}} + - 案件处理审批表.承办人意见:{{案件处理审批表.承办人意见}} + - 案件处理审批表.承办部门意见:{{案件处理审批表.承办部门意见}} + - 立案报告表.案由:{{立案报告表.案由}} + - 立案报告表.案情摘要:{{立案报告表.案情摘要}} + - 案件调查终结报告.案由:{{案件调查终结报告.案由}} + - 涉案物品核价表.核价明细:{{涉案物品核价表.核价明细}} + + 2. 根据违法事实判断案由是否与事实一致: + - 若事实涉及经营无合法来源证明进口烟草专卖品、电子烟等新型烟草制品,优先判断是否适用案由“经营无合法来源证明进口烟草专卖品、电子烟等新型烟草制品”。 + - 若事实涉及市场服务管理机构或电子商务平台发现上述行为后未制止、未报告,优先判断是否适用案由“不履行经营无合法来源证明进口烟草专卖品、新型烟草制品行为制止、报告义务”。 + - 若事实涉及明知他人经营无合法来源证明进口烟草专卖品、电子烟等新型烟草制品而提供运输、寄递、储存、资金、账号、发票、证明、包装、说明书、合格证等便利或服务,优先判断是否适用案由“为他人经营无合法来源证明进口烟草专卖品、新型烟草制品提供便利及服务”。 + - 其他一般烟草专卖行政处罚事项,按《广东省烟草专卖行政处罚裁量执行标准》中的违法行为判断,例如擅自收购烟叶、无烟草专卖品准运证运输烟草专卖品、为无准运证的单位或个人运输烟草专卖品、超限量邮寄或异地携带烟叶/烟草制品、无证生产、无证批发、未在当地烟草专卖批发企业进货、销售非法生产的烟草专卖品、为无烟草专卖零售许可证的单位或个人提供烟草制品、向未成年人销售卷烟等。 + + 3. 若案由与违法事实不一致、过宽、过窄、遗漏反走私新案由,或仅凭处罚结果倒推案由而事实不支持,判定不通过,并说明应适用的案由及事实依据。案由不准确时,不再继续判断罚款比例和总额。 + + 二、案由准确后,判断处理结果和罚款要素是否符合标准 + 1. 读取处罚内容: + - 卷宗封面.处理结果:{{卷宗封面.处理结果}} + - 处罚决定书.罚款项目:{{处罚决定书.罚款项目}} + - 处罚决定书.罚款基数:{{处罚决定书.罚款基数}} + - 处罚决定书.罚款比例:{{处罚决定书.罚款比例}} + - 处罚决定书.罚款总额:{{处罚决定书.罚款总额}} + - 处罚决定书.罚款说明:{{处罚决定书.罚款说明}} + + 2. 选择正确依据: + - 对“无合法来源证明进口烟草专卖品、电子烟等新型烟草制品”相关案件,优先适用《广东省反走私综合治理条例》及《广东省烟草专卖局实施〈广东省反走私综合治理条例〉配套规定》确定的三类案由和裁量分档。 + - 其他一般烟草专卖行政处罚案件,适用《广东省烟草专卖行政处罚裁量权管理办法》及《广东省烟草专卖行政处罚裁量执行标准》。 + - 若同一违法行为违反多个规范且均应罚款,注意不得重复罚款,应按罚款数额高的规定处罚。 + + 3. 重点校验: + - 处理结果是否包含对应标准要求的处罚种类,如没收违法所得、没收涉案物品、责令停止违法行为、责令关闭或停止经营、公开销毁、责令限期改正、警告、暂停业务或取消资格等。 + - 罚款项目是否与案由和处罚依据一致,不得把“进货总额”“货值”“违法销售总额”“违法所得”“销售总额”等基数口径混用。 + - 罚款基数是否能从违法事实、核价明细或处罚决定书说明中得到支持。 + - 罚款比例是否落入对应违法情节的裁量幅度;存在从轻、从重、减轻情节时,应结合裁量权管理办法判断是否说明并适用相应幅度。 + - 罚款总额是否与罚款基数和罚款比例匹配;若存在四舍五入或金额表述差异,应说明是否合理。 + + 三、输出要求 + - 通过:案由与违法事实一致,处理结果、罚款项目、罚款基数、罚款比例、罚款总额均符合对应标准。 + - 不通过:任一环节不符合即不通过,并列明问题字段、正确依据、应适用的案由或处罚幅度。 + - 证据不足:若违法事实、金额、数量、货值、违法所得等关键事实缺失,导致无法判断案由或裁量幅度,应判定不通过并提示补充核对,而不是猜测通过。 + messages: + pass: 案由与违法事实一致,处理结果和罚款要素符合对应裁量标准。 + fail: 案由、处理结果或罚款要素与违法事实及裁量标准不一致,请核对。 + references_laws: + - 《广东省烟草专卖行政处罚裁量权管理办法》 + - 《广东省烟草专卖行政处罚裁量执行标准》 + - 《广东省反走私综合治理条例》 + - 《广东省烟草专卖局实施〈广东省反走私综合治理条例〉配套规定》 + type: ai_rule +- group: JZG-SD + rules: + - rule_id: JZ-SD-001 + name: 法定时限送达 + desc: 若处罚决定书文尾的日期与处罚决定书的送达回证中的"签收日期",之间的范围不在法定时限内,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 送达回证 + stages: + - id: '1' + check: required + fields: + - 送达回证.签收日期 + - 处罚决定书.落款日期 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic + - rule_id: JZ-SD-002 + name: 送达回证基本信息规范 + desc: 若收件人签名、签收时间、送达人签名、印章任意一项不存在,则扣分 + risk: medium + score: 10 + scope: + - 送达回证 + stages: + - id: '1' + check: required + fields: + - 送达回证.回证编号 + - 送达回证.送达文书名称 + - 送达回证.送达方式 + - 送达回证.签收日期 + messages: + pass: 办案单位印章、送达人签名、收件人签名及签收时间填写规范。 + fail: 填写不规范,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic +- group: JZG-XC + rules: + - rule_id: JZ-XC-001 + name: 现场笔录时间地点完整性 + desc: 若现场笔录中时间或地点未记载,则扣分;若记载的时间与证据提取单中的时间、地点不一致,也扣分。 + risk: medium + score: 10 + scope: + - 现场笔录 + - 证据复制(提取)单 + stages: + # 地点是文字型字段,用确定性 match 足够(fuzzy 可容忍小差异) + - id: '1' + check: match + pairs: + - source: 现场笔录.检查地点 + target: 证据复制(提取)单.现场地址 + method: fuzzy + # 时间是语义型字段 —— 现场笔录.检查时间常是时间段("16:10至17:00"), + # 证据复制(提取)单.现场时间常是时间点("16:20")。不写字符串 parser, + # 直接让 LLM 按业务语义判定(点落在段内视为一致)。 + - id: '2' + check: ai + prompt: | + 判断以下两个时间在业务上是否一致: + + - 现场笔录.检查时间:{{现场笔录.检查时间}} + - 证据复制(提取)单.现场时间:{{证据复制(提取)单.现场时间}} + + 判断原则: + - 若两者都是时间点且值相同 → 一致 + - 若一方是时间段,另一方是时间点,且**点落在段内** → 一致 + - 若两者都是时间段且有重叠 → 一致 + - 若完全无关或对不上 → 不一致 + + 只判时间业务语义,不判格式差异("2024 年 11 月 18 日"和"2024-11-18"视为同日)。 + logic: 1 AND 2 + messages: + pass: 时间地点记录准确。 + fail: 时间地点记录缺失或与实际不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-002 + name: 被检查人基本情况记载完整性-有无 + desc: 被检查人基本情况记载 + risk: medium + score: 10 + scope: + - 现场笔录 + stages: + - id: '1' + check: required + fields: + - 现场笔录.单位名称 + - 现场笔录.单位法代 + - 现场笔录.地址 + - 现场笔录.电话 + - 现场笔录.单位许可证号 + - id: '2' + check: required + fields: + - 现场笔录.个人姓名 + - 现场笔录.个人性别 + - 现场笔录.个人证件 + - 现场笔录.地址 + - 现场笔录.电话 + - id: '3' + check: required + fields: + - 现场笔录.现场负责人 + - 现场笔录.电话 + - 现场笔录.地址 + logic: (1 OR 2) AND 3 + messages: + pass: 被检查人姓名、身份证号、地址、许可证号与证据一致,请检查其余基本信息是否完整准确。 + fail: 被检查人基本情况记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-003 + name: 被检查人基本情况记载完整性-一致 + desc: 检查现场笔录中被检查人信息与身份证/营业执照/许可证信息是否一致 + risk: medium + score: 10 + scope: + - 现场笔录 + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 现场笔录.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 现场笔录.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 现场笔录.单位许可证号 + target: 证据复制(提取)单.许可证号 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证企业名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证负责人 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 现场笔录.个人姓名 + target: 立案报告表.个人姓名 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 现场笔录.个人性别 + target: 立案报告表.个人性别 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 现场笔录.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 现场笔录.地址 + target: 立案报告表.个人住址 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + logic: 1 OR 2 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-004 + name: 被检查人签署意见合规性 + desc: 若被检查人拒绝签署意见及姓名,且执法人员未说明情况,则扣分。 + risk: medium + score: 10 + scope: + - 现场笔录 + stages: + - id: '1' + check: required + fields: + - 现场笔录.意见 + - 现场笔录.意见日期 + - 现场笔录.意见签名 + - id: '2' + check: required + field: 现场笔录.意见 + messages: + pass: 被检查人已签署意见及姓名,或执法人员已说明拒绝签署的情况。 + fail: 被检查人拒绝签署但执法人员未说明情况,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic +- group: JZG-DJ + rules: + - rule_id: JZ-DJ-001 + name: 批准保存时间记载完整性 + desc: 若负责人意见并签名栏后没有日期信息,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + field: 证据先行登记保存批准书.负责人日期 + messages: + pass: 已记载批准保存时间。 + fail: 批准保存时间未记载,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-002 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 若行政机关负责人没有签署意见或姓名,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人签名姓名 + - 证据先行登记保存批准书.负责人意见 + - 证据先行登记保存批准书.负责人日期 + messages: + pass: 行政机关负责人已签署意见和姓名。 + fail: 行政机关负责人未签署意见或姓名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-003 + name: 先行登记保存证据期限记载 + desc: 若没有文中"对先行登记保存的证据,应当在.....日内及时作出处理决定。"的描述,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.表格下方文字 + - 证据先行登记保存通知书.表格下方文字 + messages: + pass: 已注明先行登记保存证据期限和处理决定期限。 + fail: 未注明相关期限,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-004 + name: 先行登记保存批准书或通知书文件校验 + desc: 若现场笔录中的情况说明中出现物品名称及规格描述,且文件中无批准书或通知书,则扣分。 + risk: medium + score: 10 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.表格品规 + - 证据先行登记保存通知书.表格品规 + - id: '2' + check: ai + prompt: '请判断以下 {{证据先行登记保存批准书.表格全文}} 和 {{证据先行登记保存通知书.表格全文}} 表述和数量一致 + + ' + messages: + pass: 存在先行登记保存批准书或通知书。 + fail: 缺少先行登记保存批准书或通知书,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-005 + name: 批准书与通知书内容一致性 + desc: 若批准书和通知书内容不一致,则直接扣分;若一致,则与抽样清单中的物品数量进行比对,如果抽样清单中同一品种有多条记录则提示。 若当事人和见证人栏均无签名,则扣分 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: match + pairs: + - source: 证据先行登记保存通知书.表格品规 + target: 证据先行登记保存批准书.表格品规 + messages: + pass: 批准书与通知书内容一致 + fail: 批准书与通知书内容不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-006 + name: 证据先行登记保存批准/通知书承办人签名日期 + desc: 若没有证据先行登记保存批准/通知书承办人签字或盖章,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.承办人日期 + - 证据先行登记保存通知书.承办人日期 + - 证据先行登记保存批准书.承办人签名1 + - 证据先行登记保存批准书.承办人签名2 + - 证据先行登记保存通知书.承办人签名1 + - 证据先行登记保存通知书.承办人签名2 + messages: + pass: 有日期,案件承办人已签字或盖章。 + fail: 缺少印章、日期或承办人签字盖章,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-007 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 若没有填写两名承办人意见及签名,负责人意见及签名,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人日期 + - 证据先行登记保存批准书.负责人签名有无 + - 证据先行登记保存批准书.负责人意见有无 + messages: + pass: 两名承办人签名,负责人意见及签名完整。 + fail: 两名承办人签名或负责人意见及签名缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-008 + name: 保存理由和内容记载完整性 + desc: 若首部没有保存理由描述,表格中没有规格和数量信息,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + - 证据先行登记保存批准书 + stages: + - id: '1' + check: ai + prompt: '{{立案报告表.案由}} + + {{证据先行登记保存批准书.标题下方文本}} + + 案由应该要和标题下方文本同一个意思,案由会比较少字。帮我评查这个案由是否存在 在标题下方文本 中 + + ' + - id: '2' + check: ai + prompt: '{{立案报告表.案情品种}}中提及的具体规格品种、数量应出现在{{证据先行登记保存批准书.表格品规}}中,但案情摘要中不一定会将全部规格品种都写全,评查尺度可以适当放松 + + ' + - id: '3' + check: ai + prompt: '请根据以下信息判断案件类型,对个人(个体工商户)案件单独评查证据先行登记保存批准书内容是否完整。 + + + 当事人-单位-名称: {{立案报告表.单位名称}} + + 当事人-个人(个体工商户)-姓名: {{立案报告表.个人姓名}} + + 证据先行登记保存批准书-表格内容-品种规格、单位、数量: {{证据先行登记保存批准书.表格品规}} + + + 判断逻辑: + + 1. 如果单位-名称为空或为"/",且个人-姓名不为空,则这是个人(个体工商户)案件 + + 2. 对于个人案件:只要证据先行登记保存批准书-表格内容-品种规格、单位、数量有内容(非空);若为空 + + 3. 如果单位-名称有实际值(非空、非"/") + + ' + logic: (1 AND 2) OR 3 + messages: + pass: 已注明保存理由和内容。 + fail: 保存理由和内容未注明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-009 + name: 先行登记保存物品处理通知书当事人签字 + desc: 若通知书中当事人未签字或没有其他内容说明,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + field: 证据先行登记保存通知书.当事人签名 + - id: '2' + check: required + field: 证据先行登记保存通知书.拒绝签名说明 + logic: 1 OR 2 + messages: + pass: 当事人已在先行登记保存物品处理通知书上签字。 + fail: 当事人未签字或i没有情况说明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-010 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 检查涉案物品返还清单接收人签名、日期和印章是否完整,并通过正则检查损耗/返还信息 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人意见 + - 证据先行登记保存批准书.负责人签名姓名 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-011 + name: 证据先行登记保存批准/通知书盖章 + desc: 检查先行登记保存批准书和通知书是否加盖行政机关印章 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.盖章 + - 证据先行登记保存通知书.盖章 + messages: + pass: 有行政机关印章 + fail: 缺少印章,请核对 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None +- group: JZG-QR + rules: + - rule_id: JZ-QR-001 + name: 陈述申辩权利告知和听取 + desc: 若表述中不包含"享有陈述权和申辩权"、"...日内"、"...视为放弃",任意一项,则扣分, + risk: medium + score: 10 + scope: + - 行政处罚事先告知书 + stages: + - id: '1' + check: required + field: 行政处罚事先告知书.权利告知 + messages: + pass: 已告知当事人陈述申辩权利。 + fail: 未告知当事人陈述申辩相关权力,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十四条 + type: deterministic + - rule_id: JZ-QR-002 + name: 行政处罚事先告知对象准确性 + desc: 若告知书首句中的姓名与当事人意见中的签名不一致,则扣分。 + risk: medium + score: 10 + scope: + - 行政处罚事先告知书 + stages: + - id: '1' + check: match + pairs: + - source: 行政处罚事先告知书.当事人 + target: 行政处罚事先告知书.正文前称呼 + messages: + pass: 行政处罚事先告知对象正确。 + fail: 行政处罚事先告知对象错误,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十四条 + type: deterministic +- group: JZG-QZ + rules: + - rule_id: JZ-QZ-001 + name: 当事人身份证明提取规范性 + desc: 若没有提取当事人身份证明,则扣分。 + risk: medium + score: 10 + scope: + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 证据复制(提取)单当事人.身份证号 + - 证据复制(提取)单当事人.身份证背面 + messages: + pass: 当事人身份证明已规范提取。 + fail: 当事人身份证明提取不规范或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-QZ-002 + name: 查获物品情况记载准确性、合规性 + desc: 若批准书和通知书内容不一致,则直接扣分;若一致,则与抽样清单中的物品数量进行比对,如果抽样清单中同一品种有多条记录则提示。 若当事人和见证人栏均无签名,则扣分 + risk: medium + score: 10 + scope: + - 抽样取证物品清单 + - 涉案物品核价表 + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: ai + prompt: '请判断{{抽样取证物品清单.品种规格}}(若有)或{{涉案物品核价表.核价明细}},以及{{证据先行登记保存批准书.表格品规}}、{{证据先行登记保存通知书.表格品规}}表述和数量一致。 + + 如果{{抽样取证物品清单.品种规格}}、{{涉案物品核价表.核价明细}}都不存在,则只需判断{{证据先行登记保存批准书.表格品规}}和{{证据先行登记保存通知书.表格品规}}的一致性 + + ' + messages: + pass: 查获物品情况、数量及当事人或见证人姓名记录准确。 + fail: 记录不准确或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-QZ-003 + name: 核价文书记录的准确性(盖章) + desc: 检查涉案物品核价表是否有涉案卷烟价格管理小组印章 + risk: medium + score: 5 + scope: + - 涉案物品核价表 + stages: + - id: '1' + check: required + field: 涉案物品核价表.核价组印章 + messages: + pass: 已正确加盖印章。 + fail: 印章加盖错误,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 涉案物品核价表 != None + - rule_id: JZ-QZ-004 + name: 抽样取证物品清单完整性 + desc: 先行登记保存证据处理通知书"处理"方式选择第2项"送交...鉴定"时,卷宗内没有抽样取证物品清单,则扣分。 + risk: medium + score: 10 + scope: + - 先行登记保存证据处理通知书 + - 抽样取证物品清单 + stages: + - id: '1' + check: required + field: 先行登记保存证据处理通知书.处理方式 + - id: '2' + check: required + fields: + - 抽样取证物品清单.表格有内容 + - 抽样取证物品清单.当事人签名 + logic: (1 AND 2) OR (NOT 1) + messages: + pass: 抽样提取物证时有完整的物品清单。 + fail: 抽样提取物证时缺少物品清单,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 先行登记保存证据处理通知书 != None + - rule_id: JZ-QZ-005 + name: 核价文书记录准确性 + desc: 若核价文书或记录中没有准确记载(计算核价结果错误)涉案物品情况,核价错误,则扣分。 + risk: medium + score: 5 + scope: + - 涉案物品核价表 + stages: + - id: '1' + check: ai + prompt: '{{涉案物品核价表.表格全文}} + + 请判断以表格中各品种规格的数量、单价计算的合计金额是否正确,各品种规格合计金额计算总计金额是否正确,请在计算的时候保留小数点后两位 + + ' + messages: + pass: 涉案物件核价表存在 + fail: 涉案物件核价表不存在或者信息内容有误 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-QZ-006 + name: 价格证明合规性 + desc: 若批准书与通知书内容不一致,核价表中数量与批准书或通知书中不一致,则扣分。 + risk: medium + score: 10 + scope: + - 涉案物品核价表 + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: ai + prompt: '请判断以下三个表格物品和数量是否对应 + + {{涉案物品核价表.核价明细}} + + {{证据先行登记保存批准书.表格品规}} + + {{证据先行登记保存通知书.表格品规}} + + ' + messages: + pass: 价格证明符合要求,且有涉案物品核价依据或价格来源。 + fail: 价格证明不符合要求或缺少依据,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule +- group: JZG-XW + rules: + - rule_id: JZ-XW-001 + name: 被询问人签署"记录属实"合规性 + desc: 若每页页尾被询问人处没有签名,则扣分;如果最后一页没有手写内容则提示。 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + field: 询问笔录.被询问人核实 + messages: + pass: 被询问人已签署"记录属实"且逐页签名。 + fail: 被询问人未签署或未逐页签名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-002 + name: 询问笔录合规性 + desc: 通过AI判断询问笔录格式是否符合规范要求 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: ai + prompt: '请判断以下询问笔录中是否只有一名被询问人。被询问人信息:{{询问笔录当事人.被询问人姓名}} + + ' + messages: + pass: 笔录仅询问一名被询问人。 + fail: 一份笔录询问多名被询问人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: ai_rule + - rule_id: JZ-XW-003 + name: 执法人员身份表明和权利告知 + desc: 若未在询问开始时表明执法人员身份,并告知当事人享有陈述申辩权和申请回避权,则扣分。 + risk: medium + score: 5 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + fields: + - 询问笔录.执法人员信息 + - 询问笔录.权利告知 + messages: + pass: 执法人员已表明身份并告知相关权利。 + fail: 未表明身份或未告知权利,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-004 + name: 执法人员签名合规性 + desc: 若执法人员没有签名或只有一人签名,则扣分。 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + fields: + - 询问笔录.询问人签名1 + - 询问笔录.询问人签名2 + messages: + pass: 执法人员已签名,且有两人以上签名。 + fail: 执法人员签名缺失或不足两人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十二条 + type: deterministic + - rule_id: JZ-XW-005 + name: 被询问人基本情况记载全面性 + desc: 被询问人基本情况填写不全,或询问时间、地点未准确记载,则扣分。 + risk: medium + score: 5 + scope: + - 证据复制(提取)单 + - 询问笔录 + stages: + - id: '1' + check: match + pairs: + - source: 询问笔录.询问地点 + target: 证据复制(提取)单.复制地点 + - source: 询问笔录当事人.被询问人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 询问笔录.询问时间 + target: 证据复制(提取)单.复制时间 + method: fuzzy + - source: 询问笔录当事人.被询问人经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + messages: + pass: 被询问人基本情况、询问时间地点记录完整准确。 + fail: 记录不完整或不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-006 + name: 被询问人拒绝签署处理合规性 + desc: 检查被询问人拒绝签名时是否有情况说明记录 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + field: 询问笔录.被询问人签名 + - id: '2' + check: required + field: 询问笔录.拒绝签名说明 + logic: 1 OR 2 + messages: + pass: 被询问人已签署或已记载拒绝情况。 + fail: 被询问人未签署且未记录情况说明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic +- group: JZG-LA + rules: + - rule_id: JZ-LA-001 + name: 当事人基本情况记载完整、准确 + desc: 若当事人姓名、有效证件号码和地址未记载或与身份证中信息不一致,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 立案报告表.单位名称 + - 立案报告表.单位法代 + - 立案报告表.单位电话 + - 立案报告表.单位地址 + - id: '2' + check: required + fields: + - 立案报告表.个人姓名 + - 立案报告表.个人性别 + - 立案报告表.个人年龄 + - 立案报告表.个人民族 + - 立案报告表.个人证件 + - 立案报告表.个人电话 + - 立案报告表.个人住址 + - id: '3' + check: match + pairs: + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - id: '4' + check: match + pairs: + - source: 立案报告表.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '5' + check: match + pairs: + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '6' + check: ai + prompt: '请根据以下信息判断案件类型(个人案件或单位案件),并评查当事人基本情况是否记载完整。 + + + 当事人-单位-名称: {{立案报告表.单位名称}} + + 当事人-单位-法定代表人(负责人): {{立案报告表.单位法代}} + + 当事人-个人(个体工商户)-姓名: {{立案报告表.个人姓名}} + + 当事人-个人(个体工商户)-性别: {{立案报告表.个人性别}} + + 当事人-个人(个体工商户)-年龄: {{立案报告表.个人年龄}} + + 当事人-个人(个体工商户)-民族: {{立案报告表.个人民族}} + + 当事人-个人(个体工商户)-证件类型及号码: {{立案报告表.个人证件}} + + 当事人-个人(个体工商户)-联系电话: {{立案报告表.个人电话}} + + 当事人-个人(个体工商户)-住址: {{立案报告表.个人住址}} + + + 判断逻辑: + + 1. 如果单位-名称为空或为"/",且个人-姓名不为空,则这是个人(个体工商户)案件 + + 2. 对于个人案件:检查个人字段(姓名、性别、年龄、民族、证件类型及号码、联系电话、住址)是否都不为空—— + + 3. 如果单位-名称有实际值(非空、非"/") + + ' + logic: (1 AND 4) OR 6 + messages: + pass: 当事人基本情况记录完整,与身份证信息一致。 + fail: 当事人基本情况记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国烟草专卖法》第三十八条 + type: ai_rule + - rule_id: JZ-LA-002 + name: 案由、发案时间和发案地点记载准确性-有无 + desc: 若案由、发案时间和发案地点未记载或错误记载,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + field: 立案报告表.案由 + messages: + pass: 案由、发案时间和发案地点记录准确。 + fail: 案由、发案时间和发案地点记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-003 + name: 案件来源有无一致性校验 + desc: 若三处文档中的案件来源信息不一致或者存在未填写的情况,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + - 案件调查终结报告 + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.案件来源 + - 案件处理审批表.案件来源 + - 案件调查终结报告.案件来源 + - id: '2' + check: match + pairs: + - source: 立案报告表.案件来源 + target: 案件处理审批表.案件来源 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + messages: + pass: 案件来源完整 + fail: 没有记载案件来源或案件来源与其他文书不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-004 + name: 案由、发案时间和发案地点记载准确性-一致 + desc: 检查立案报告表案发时间/地点与现场笔录检查时间/地点是否一致 + risk: medium + score: 10 + scope: + - 现场笔录 + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.案发时间 + - 立案报告表.案发地点 + - 现场笔录.检查时间 + - 现场笔录.检查地点 + - id: '2' + check: match + pairs: + - source: 立案报告表.案发时间 + target: 现场笔录.检查时间 + method: substring + - id: '3' + check: match + pairs: + - source: 现场笔录.检查地点 + target: 立案报告表.案发地点 + messages: + pass: 案由、发案时间和发案地点记录准确。 + fail: 案由、发案时间和发案地点记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-LA-005 + name: 承办人和承办部门意见 + desc: 承办人栏无描述、无签名、承办部门处无描述、无签名,出现任一一项则扣分。 + risk: medium + score: 5 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.承办部门意见 + - 立案报告表.承办部门日期 + - 立案报告表.承办人意见 + - 立案报告表.承办人日期 + - 立案报告表.承办部门签名 + - 立案报告表.承办人签名2 + - 立案报告表.承办人签名1 + messages: + pass: 承办人和承办部门意见及签名完整。 + fail: 承办人和承办部门意见及签名存在缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-006 + name: 行政机关负责人明确意见、签字和日期 + desc: 若"负责人意见"栏中存在"不同意"或"不同意和意见描述",留空则扣分。;负责人意见栏无描述、无签名、无日期,出现任一一项则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.负责人意见 + - 立案报告表.负责人签名 + messages: + pass: 行政机关负责人意见、签字和日期完整。 + fail: 行政机关负责人意见、签字和日期缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-LA-007 + name: 立案文书完整性检查(签名) + desc: 检查立案报告表负责人意见处是否有签名 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + field: 立案报告表.负责人签名 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-LA-008 + name: 案件情况清晰 + desc: 通过AI检查立案报告表案由和案情摘要表述是否清晰;若案件情况描述中,需出现案件时间、货物名称、(案由描述+条款引用)中所有信息,未出现任一一项则扣分。 + risk: low + score: 1 + scope: + - 立案报告表 + stages: + - id: '1' + check: ai + prompt: | + 检查 案情摘要 是否覆盖以下 4 项要素(任一缺失才扣分): + 1. 案件时间(检查/发案时间) + 2. 涉案货物名称或品种 + 3. 案由描述(违法行为的事实陈述) + 4. 相关条款或法律依据的引用 + + 案由:{{立案报告表.案由}} + 案情摘要:{{立案报告表.案情摘要}} + + 判定规则: + - 4 项要素齐全 → pass + - 有缺项 → fail + - **不要**对文字风格、段落重复、句式冗余等格式问题扣分,只看内容是否齐全。 + messages: + pass: 案件情况描述清晰。 + fail: 案件情况记录不清晰或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: ai_rule +- group: JZG-ZJ + rules: + - rule_id: JZ-ZJ-001 + name: 调查终结报告文件校验 + desc: 若没有调查终结报告,则扣分 + risk: medium + score: 10 + scope: + - 案件调查终结报告 + stages: + - id: '1' + check: required + field: 案件调查终结报告.案由 + messages: + pass: 存在完整的调查终结报告。 + fail: 缺少调查终结报告,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-002 + name: 案由、立案时间和当事人基本情况记载 + desc: 若当事人信息与提取出的信息不一致,则扣分。 + risk: medium + score: 5 + scope: + - 案件调查终结报告 + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 案件调查终结报告.案件来源 + - 案件调查终结报告.案由 + - 案件调查终结报告.立案日期 + - 案件调查终结报告.单位名称 + - 案件调查终结报告.单位法代 + - 案件调查终结报告.单位电话 + - 案件调查终结报告.单位地址 + - id: '2' + check: required + fields: + - 案件调查终结报告.案件来源 + - 案件调查终结报告.案由 + - 案件调查终结报告.立案日期 + - 案件调查终结报告.个人姓名 + - 案件调查终结报告.个人性别 + - 案件调查终结报告.个人年龄 + - 案件调查终结报告.个人民族 + - 案件调查终结报告.个人电话 + - 案件调查终结报告.个人证件 + - 案件调查终结报告.个人住址 + - id: '3' + check: match + pairs: + - source: 案件调查终结报告.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '4' + check: match + pairs: + - source: 案件调查终结报告.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + logic: (1 AND 3) OR (2 AND 4) + messages: + pass: 当事人基本情况记载准确。请检查案后及时间是否正确。 + fail: 记载不准确或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-003 + name: 当事人基本情况记载-一致 + desc: 检查调查终结报告中当事人基本信息与身份证信息是否一致 + risk: medium + score: 1 + scope: + - 案件调查终结报告 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 案件调查终结报告.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + - source: 案件调查终结报告.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + - source: 案件调查终结报告.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-004 + name: 案件调查终结报告承办人及承办部门负责人签字日期 + desc: 若没有承办人及承办人负责人签字、或者没有签字日期,则扣分。 + risk: medium + score: 5 + scope: + - 案件调查终结报告 + stages: + - id: '1' + check: required + fields: + - 案件调查终结报告.处理意见日期 + - 案件调查终结报告.处理意见承办人签名1 + - 案件调查终结报告.处理意见承办人签名2 + messages: + pass: 承办人及承办部门负责人已签字并签署日期。 + fail: 缺少签字或日期,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic +- group: JZG-CL + rules: + - rule_id: JZ-CL-001 + name: 法制部门或法制员意见明确性 + desc: 若法制部门意见栏无文字描述内容,则扣分。 + risk: medium + score: 10 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.法制部门意见 + - 案件处理审批表.法制部门日期 + - 案件处理审批表.法制部门审核人签名 + - 案件处理审批表.法制部门负责人签名 + messages: + pass: 法制部门或法制员意见明确。 + fail: 法制部门或法制员意见缺失或不明确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十八条 + type: deterministic + - rule_id: JZ-CL-002 + name: 案件处理审批表承办人意见和签名 + desc: 若承办人意见栏中无文字内容或无签名日期,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.承办人意见 + - 案件处理审批表.承办人日期 + - 案件处理审批表.承办部门意见 + - 案件处理审批表.承办部门日期 + - 案件处理审批表.承办部门签名 + - 案件处理审批表.承办人签名1 + - 案件处理审批表.承办人签名2 + messages: + pass: 承办人意见和签名完整。 + fail: 缺少承办人意见或签名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十八条 + type: deterministic + - rule_id: JZ-CL-003 + name: 案件处理审批表负责人审批意见明确性 + desc: 检查案件处理审批表负责人审批意见内容和日期是否完整 + risk: medium + score: 10 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.负责人日期 + - 案件处理审批表.负责人意见 + - 案件处理审批表.负责人签名 + messages: + pass: 行政机关负责人审批意见明确,签名和审批时间规范。 + fail: 审批意见不明确或签名审批时间不规范,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic +- group: JZG-ZG + rules: + - rule_id: JZ-ZG-001 + name: 行政处罚事先告知书送达 + desc: 若送达方式为"直接送达",则收件人签名或盖章栏无信息,则扣分。 若送达方式为"邮寄送达",则校验证据复制(提取)中是否有邮件回执,若不存在,则扣分。 + risk: medium + score: 10 + scope: + - 证据复制(提取)单 + - 送达回证 + stages: + - id: '1' + check: contains + field: 送达回证.送达方式 + value: 直接送达 + - id: '2' + check: contains + field: 送达回证.送达方式 + value: 邮寄送达 + - id: '3' + check: required + field: 证据复制(提取)单.邮件回执 + logic: 1 OR (2 AND 3) + messages: + pass: 事先告知书已送达当事人。 + fail: 事先告知书可能未送达当事人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic +- group: JZG-ZX + rules: + - rule_id: JZ-ZX-001 + name: 罚款、没收违法所得处罚执行规范性 + desc: 若不存在《缴款凭证》(含《广东省非税收入一般缴款书(电子)》及其收款证明等任何形式的缴款凭证),则扣分。若缴款书中金额与处罚决定书中金额总计不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 缴款凭证 + stages: + - id: '1' + check: required + fields: + - 缴款凭证.金额 + - 缴款凭证.收入项目 + - 处罚决定书.罚款项目 + - 处罚决定书.罚款基数 + - 处罚决定书.罚款比例 + - 处罚决定书.罚款总额 + - id: '2' + check: ai + prompt: '请分析{{处罚决定书.罚款项目}}对应{{处罚决定书.罚款基数}}乘{{处罚决定书.罚款比例}},计算并校对与{{处罚决定书.罚款总额}}一致,同时{{处罚决定书.罚款总额}}与{{缴款凭证.金额}}需一致 + + ' + messages: + pass: 罚款、没收违法所得处罚已开具缴款书,有银行缴费收款证明,且与处罚决定书一致。 + fail: 未开具缴款书或无银行缴费证明,或与处罚决定书不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十六条、第六十七条 + type: ai_rule + activate_if: 缴款凭证 != None + - rule_id: JZ-ZX-002 + name: 发还当事人物品与先行登记保存物品-一致 + desc: 若两份文件表格中,数量不一致,则涉案物品返还清单中备注一列需要有内容,没有内容则扣分。 + risk: medium + score: 10 + scope: + - 涉案物品返还清单 + - 证据先行登记保存批准书 + stages: + - id: '1' + check: ai + prompt: '{{证据先行登记保存批准书.表格品规}}和{{涉案物品返还清单.返还明细}}表格中的物品和数量应当一致,若 涉案物品返还清单表格中的具体的品种规格和数量行列数据不一致,则通过涉案物品返还清单的备注的内容进一步判断是否一致(即数量+损耗数量) + + ' + messages: + pass: 发还物品与先行登记保存物品一致,或不一致时已说明原因。 + fail: 发还物品与先行登记保存物品不一致且未说明原因,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-ZX-003 + name: 损耗费用返还合规性 + desc: 若签名或盖章不存在,或日期未填写,则扣分。 + risk: medium + score: 10 + scope: + - 卷宗封面 + - 涉案物品返还清单 + stages: + - id: '1' + check: contains + field: 卷宗封面.处理结果 + value: 销毁 + - id: '2' + check: required + field: 卷宗封面.处理结果 + - id: '3' + check: required + fields: + - 涉案物品返还清单.日期 + - 涉案物品返还清单.补偿信息 + - 涉案物品返还清单.返还确认 + - 涉案物品返还清单.接收人签名 + - id: '4' + check: required + fields: + - 涉案物品返还清单.日期 + - 涉案物品返还清单.接收单位印章 + - 涉案物品返还清单.补偿信息 + - 涉案物品返还清单.返还确认 + logic: (1 AND 2) OR ((NOT 1) AND 2 AND (3 OR 4)) + messages: + pass: 已全部返还留样卷烟或鉴别检验损耗费用。 + fail: 未全部返还,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 涉案物品返还清单 != None or (卷宗封面 != None and 卷宗封面.处理结果 != None) + - rule_id: JZ-ZX-004 + name: 缴款凭证填写规范性 + desc: 若处罚中有没收而文件中不存在没收收据,则扣分。 + risk: medium + score: 5 + scope: + - 处罚决定书 + - 缴款凭证 + stages: + - id: '1' + check: required + fields: + - 处罚决定书.罚款说明 + - 缴款凭证.备注 + messages: + pass: 存在缴款凭证,请进一步确认填写是否规范。 + fail: 未找到缴款凭证,请核对文书是否齐全 + references_laws: + - 《中华人民共和国行政处罚法》第六十七条 + type: deterministic + activate_if: 缴款凭证 != None +- group: JZG-JA + rules: + - rule_id: JZ-JA-001 + name: 当事人名称、违法事实和处罚内容记载准确性 + desc: 若两份文书中的当事人名称不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 结案报告表 + stages: + - id: '1' + check: match + pairs: + - source: 结案报告表.当事人 + target: 处罚决定书.当事人 + messages: + pass: 当事人名称、处罚内容记载一致,请进一步检查违法事实是否一致。 + fail: 当事人记载不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic + - rule_id: JZ-JA-002 + name: 行政处罚决定的执行结果记载 + desc: 若执行情况栏后不存在描述内容,则扣分。 + risk: medium + score: 10 + scope: + - 结案报告表 + stages: + - id: '1' + check: required + field: 结案报告表.执行情况 + messages: + pass: 行政处罚决定的执行结果存在对应记载内容。 + fail: 执行结果记载不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第七十一条 + type: deterministic + - rule_id: JZ-JA-003 + name: 结案意见、签名及其时间填写规范性 + desc: 若承办人、承办机构负责人和办案单位负责人的意见、签名及其时间任意一项未找到,则扣分。 + risk: medium + score: 10 + scope: + - 结案报告表 + stages: + - id: '1' + check: required + fields: + - 结案报告表.承办人结案理由 + - 结案报告表.承办人结案日期 + - 结案报告表.承办部门意见 + - 结案报告表.承办部门日期 + - 结案报告表.负责人意见 + - 结案报告表.负责人日期 + - 结案报告表.负责人签名 + - 结案报告表.承办人结案签名1 + - 结案报告表.承办人结案签名2 + - 结案报告表.承办部门签名 + messages: + pass: 意见、签名及其时间填写规范。 + fail: 填写不规范,请核对并更正。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-JA-004 + name: 结案后按期立卷归档 + desc: 通过AI检查结案后是否在10日内立卷归档 + risk: medium + score: 10 + scope: + - 卷内备考表 + - 结案报告表 + stages: + - id: '1' + check: ai + prompt: '请你判断{{卷内备考表.立卷时间}}与{{结案报告表.负责人日期}}是否相差小于10天 + + ' + messages: + pass: 结案后已按期立卷归档。 + fail: 结案后未按期立卷归档,请核对。 + references_laws: + - 《烟草专卖行政处罚程序规定》 + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml.old b/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml.old new file mode 100644 index 0000000..cf5a576 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政处罚/1.0/rules.yaml.old @@ -0,0 +1,2898 @@ +metadata: + type_id: 行政卷宗.行政处罚 + name: 烟草专卖行政处罚卷宗 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗 + inherits_from: + - base.common + - base.administrative_case + classification_keywords: + - 行政处罚 + - 烟草专卖 + - 处罚决定书 + - 立案报告 + - 询问笔录 + description: '烟草专卖行政处罚卷宗审核。 + + 覆盖:立案、现场检查、证据先行登记保存、取证核价、询问笔录、权利告知、 + + 调查终结、处理审批、事先告知、处罚决定、送达、执行、结案全流程。 + + ' + # 开 medium 风险规则的 LLM 救援(跨子文档对齐失败交给 rescue 模块 + # 的 L1 判定语义等价,如"投诉举报" vs "举报")。 + rescue_profile: + rescue_risk: [medium] + +# TOC 页定位(dossier_segmenter 使用) +# keywords: 本类卷宗的目录标题(OCR 空白自动规整,"卷 宗 目 录" 也命中) +# anti_keywords: 卷内"内部目录",避免被误判为卷宗级 TOC +# 两个列表均为"扩展默认值",下面显式列出本类卷宗实际会遇到的项 —— +# 默认的 卷宗目录 / 卷内目录 / Contents 等仍自动生效。 +toc: + keywords: + # 实际 OCR 观察到的目录标题(均为 defaults 一部分,显式列出作自注释) + - 卷宗目录 + - 卷内目录 + anti_keywords: + # 卷内子文档自带的"目录"标题,不是卷宗级 TOC —— 必须排除 + - 证据材料目录 # 重大执法行为法制审核送审表 里的证据清单 + - 物品目录 # 抽样取证物品清单等 + +# 跨子文档派生字段 —— 给规则里的 `activate_if` / 对级 `when` 用 +derived_fields: + # 当事人类型:决定一条规则里"个人字段 pair"还是"单位字段 pair"该不该对齐 + # + # 按 USCC 第 2 位判定(GB 32100-2015 法人和其他组织统一社会信用代码): + # 1 = 机关 → 单位 + # 2 = 个体工商户 → 个人(法律归类:自然人工商业) + # 3 = 农民专业合作社 → 单位 + # 4 = 事业单位 → 单位 + # 5 = 企业 → 单位 + # 8 = 社团 → 单位 + # 9 = 其他组织 → 单位 + # + # 个体户虽然有 USCC 和营业执照,但当事人栏填个人信息(姓名/身份证), + # 所以单位 pair 应跳过;执照字段作为辅助证据另行处理。 + # + # 注:不看"字号"——当前 OCR 常把案件文号误抽到 字号 字段; + # 执照名称/执照统一社会信用代码 在个体户里也存在,因此不作为单位标志。 + # 表达式必须单行(evaluate 不支持多行条件)。 + - name: 当事人类型 + type: string + # 嵌套 IfExp 走短路(BoolOp 求值所有分支,`not None` 会走 null-propagation + # 返回 None 而被 IfExp 当 False 走到 else,导致对 None 调 .startswith 崩溃) + # - USCC 空/缺失 → 个人 + # - USCC 以 '92' 开头 → 个人(个体工商户) + # - 其它 (91/93/94/95/… 开头) → 单位 + compute: "'个人' if 处罚决定书.统一社会信用代码 == None else ('个人' if 处罚决定书.统一社会信用代码.startswith('92') else '单位')" + desc: 案件当事人类型(个人 / 单位)—— 按 USCC 第 2 位判,个体户 (92xxx) 判为个人 + + # 证据复制(提取)单可能同时存在多张居民身份证(当事人、举报人、 + # 未成年人、相关人等)。抽取侧把整组居民身份证记录按 multi_entity 抽 + # 下来,由这里挑出归属当事人的那一份;规则再用 + # `证据复制(提取)单当事人.身份证*` 对齐处罚决定书/审批表/终结报告。 + # 只有一张身份证时引擎自动短路,不计 LLM 调用。 + - name: 证据复制(提取)单当事人 + type: object + compute_by: llm + prompt: |- + 以下是证据复制(提取)单中全部居民身份证记录: + + {居民身份证} + + 当事人姓名:{处罚决定书.当事人} + 当事人身份证号:{处罚决定书.身份证号码} + + 请挑出归属「当事人本人」(被处罚对象)的那一份身份证,按原字段 + 结构返回一个 JSON 对象(严格包含 身份证姓名/身份证性别/身份证民族/ + 身份证住址/身份证号/身份证背面,空值写 null)。 + + 优先以身份证号匹配当事人身份证号;若号码缺失,用姓名匹配。匹配不到 + 或无法判断归属时返回 JSON null。除该 JSON 外不要输出任何解释文字。 + depends_on: + - 居民身份证 + - 处罚决定书.当事人 + - 处罚决定书.身份证号码 + + # 询问笔录可能包含多份笔录记录(同一卷宗针对多人询问)。抽取侧把所 + # 有被询问人按 multi_entity 抽下来,由这里挑当事人那份;规则再用 + # `询问笔录当事人.被询问人*` 做一致性校验。只有一份被询问人记录时 + # 引擎自动短路,不计 LLM 调用。 + - name: 询问笔录当事人 + type: object + compute_by: llm + prompt: |- + 以下是询问笔录中全部「被询问人」记录: + + {被询问人} + + 当事人姓名:{处罚决定书.当事人} + 当事人身份证号(若有):{处罚决定书.身份证号码} + + 请挑出归属「当事人本人」(被处罚对象)的那一份笔录记录,按原字段 + 结构返回一个 JSON 对象(严格包含 被询问人姓名/被询问人性别/被询问人民族/ + 被询问人证件/被询问人电话/被询问人住址/被询问人经营地址,空值写 null)。 + + 优先以证件号匹配当事人身份证号;若号码缺失,用姓名匹配。匹配不到 + 或无法判断归属时返回 JSON null。除该 JSON 外不要输出任何解释文字。 + depends_on: + - 被询问人 + - 处罚决定书.当事人 + - 处罚决定书.身份证号码 + +sub_documents: +- id: 先行登记保存证据处理通知书 + name: 先行登记保存证据处理通知书 + required: false + classifier: + title_patterns: + - 先行登记保存证据处理通知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 处理方式 + type: verbatim + vlm_extract_mode: always + desc: 证据做出如下处理→选中的选项,要看打勾的选项 +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: + title_patterns: + - 卷内备考表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立卷时间 + type: verbatim + desc: 立卷时间 +- id: 卷宗封面 + name: 卷宗封面 + required: false + classifier: + title_patterns: + - ^##?\s*卷\s*宗\s*$ + keywords: + - 此卷共计 + - 归档日期 + - 保存期限 + min_score: 1.0 + extract: + - group: 基本信息 + fields: + - name: 处理结果 + type: string + desc: 处理结果 +- id: 处罚决定书 + name: 处罚决定书 + required: true + classifier: + title_patterns: + - 处罚决定书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 字号 + type: verbatim + desc: 字号 + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 性别 + type: enum + allowed: + - 男 + - 女 + desc: 性别 + - name: 民族 + type: verbatim + desc: 民族 + - name: 烟草专卖许可证号 + type: verbatim + desc: 烟草专卖许可证号 + - name: 经营地址 + type: string + desc: 经营地址 + - name: 统一社会信用代码 + type: uscc + desc: 统一社会信用代码 + - name: 落款日期 + type: date + desc: 落款日期 + - name: 身份证住址 + type: string + desc: 身份证住址 + - name: 身份证号码 + type: chinese-id + desc: 身份证号码 + - group: 罚款信息 + fields: + - name: 罚款项目 + type: string + desc: 正文→罚款项目 + - name: 罚款基数 + type: money + desc: 正文→罚款项目金额基数 + - name: 罚款比例 + type: string + desc: 正文→罚款百分比 保留原格式如"50%" + - name: 罚款总额 + type: money + desc: 正文→罚款总金额 + - name: 罚款说明 + type: string + desc: 正文→罚款说明 + - name: 证据列举 + type: string + desc: 正文→证据列举 + - group: 权利告知 + fields: + - name: 救济途径 + type: string + desc: 正文→救济途径 +- id: 抽样取证物品清单 + name: 抽样取证物品清单 + required: false + classifier: + title_patterns: + - 抽样取证物品清单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 品种规格 + type: string + desc: 表格内容→品种规格、样品基数 + - name: 表格有内容 + type: enum + allowed: + - 有 + - 无 + desc: 表格是否有内容 输出 有/无 + - name: 当事人签名 + type: enum + allowed: + - 有 + - 无 + desc: 当事人签名栏 输出 有/无 +- id: 案件处理审批表 + name: 案件处理审批表 + required: true + classifier: + title_patterns: + - 案件处理审批表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案编号 + type: verbatim + desc: 立案编号 + - name: 立案日期 + type: date + desc: 立案日期 + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: verbatim + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 审批意见 + fields: + - name: 承办人意见 + type: string + desc: 承办人意见→内容 + - name: 承办人日期 + type: date + desc: 承办人意见→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→内容 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 法制部门意见 + type: string + desc: 法制部门意见→内容 + - name: 法制部门日期 + type: date + desc: 法制部门意见→日期 + - name: 法制部门审核人签名 + type: enum + allowed: + - 有 + - 无 + desc: 法制部门意见→审核人签名 输出 有/无 + - name: 法制部门负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 法制部门意见→负责人签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 案件调查终结报告 + name: 案件调查终结报告 + required: true + classifier: + title_patterns: + - 案件调查终结报告 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案日期 + type: date + desc: 立案日期 + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: enum + allowed: + - 男 + - 女 + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 处理意见 + fields: + - name: 处理意见日期 + type: date + desc: 处理意见→日期 + - name: 处理意见承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 处理意见→承办人签名1 输出 有/无 + - name: 处理意见承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 处理意见→承办人签名2 输出 有/无 +- id: 涉案物品核价表 + name: 涉案物品核价表 + required: false + classifier: + title_patterns: + - 涉案物品核价表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 核价组印章 + type: enum + allowed: + - 有 + - 无 + desc: 涉案卷烟价格管理小组印章 输出 有/无 + - name: 核价明细 + type: string + desc: 表格内容→品种规格、数量(单位:条)、单价(元)、合计(元)、备注 + - name: 表格全文 + type: string + desc: 核价表完整内容 +- id: 涉案物品返还清单 + name: 涉案物品返还清单 + required: false + classifier: + title_patterns: + - 涉案物品返还清单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 日期 + type: date + desc: 日期 + - name: 补偿信息 + type: verbatim + desc: 补偿信息 + - name: 返还明细 + type: string + desc: 表格内容→品种规格、数量(单位:条)、单价(元)、合计(元)、备注 + - name: 返还确认 + type: verbatim + desc: 返还确认 + - name: 接收人签名 + type: enum + allowed: + - 有 + - 无 + desc: 接收人→签名 输出 有/无 + - name: 接收单位印章 + type: enum + allowed: + - 有 + - 无 + desc: 接收单位→印章 输出 有/无 +- id: 现场笔录 + name: 现场笔录 + required: true + classifier: + title_patterns: + - 现场笔录 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 检查时间 + type: verbatim + desc: 检查时间 + - name: 检查地点 + type: verbatim + desc: 检查地点 + - group: 被检查人 + fields: + - name: 单位名称 + type: string + desc: 被检查人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 法定代表人(负责人) + - name: 单位许可证号 + type: verbatim + desc: 烟草专卖许可证号码 + - name: 个人姓名 + type: verbatim + desc: 被检查人→个人→姓名 + - name: 个人性别 + type: enum + allowed: + - 男 + - 女 + desc: 被检查人→个人→性别 + - name: 个人证件 + type: verbatim + desc: 被检查人→个人→证件类型及号码 + - name: 地址 + type: string + desc: 被检查人→地址 + - name: 电话 + type: verbatim + desc: 被检查人→联系电话 + - name: 现场负责人 + type: verbatim + desc: 现场负责人→姓名、性别、证件类型及号码、与被检查人关系 + - group: 签名意见 + fields: + - name: 意见 + type: verbatim + desc: 被检查人或现场负责人→意见 + - name: 意见日期 + type: date + desc: 被检查人或现场负责人(签名)→日期 + - name: 意见签名 + type: enum + allowed: + - 有 + - 无 + desc: 被检查人或现场负责人(签名)输出 有/无 +- id: 立案报告表 + name: 立案报告表 + required: true + classifier: + title_patterns: + - 立案报告表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 立案编号 + type: verbatim + desc: 立案编号 如"郁烟立〔2024〕第35号" + - name: 案由 + type: string + desc: 案由 + - name: 案件来源 + type: string + desc: 案件来源 如"投诉举报" + - name: 案发时间 + type: verbatim + desc: 案发时间 + - name: 案发地点 + type: verbatim + desc: 案发地点 + - group: 当事人-单位 + fields: + - name: 单位名称 + type: string + desc: 当事人→单位→名称 + - name: 单位法代 + type: verbatim + desc: 当事人→单位→法定代表人(负责人) + - name: 单位电话 + type: verbatim + desc: 当事人→单位→联系电话 + - name: 单位地址 + type: string + desc: 当事人→单位→地址 + - group: 当事人-个人 + fields: + - name: 个人姓名 + type: verbatim + desc: 当事人→个人(个体工商户)→姓名 + - name: 个人性别 + type: verbatim + desc: 当事人→个人→性别 + - name: 个人年龄 + type: verbatim + desc: 当事人→个人→年龄 + - name: 个人民族 + type: verbatim + desc: 当事人→个人→民族 + - name: 个人证件 + type: verbatim + desc: 当事人→个人→证件类型及号码 + - name: 个人身份证号 + type: chinese-id + desc: 当事人→个人→居民身份证号码 + - name: 个人电话 + type: verbatim + desc: 当事人→个人→联系电话 + - name: 个人住址 + type: string + desc: 当事人→个人→住址 + - group: 案情 + fields: + - name: 案情摘要 + type: string + desc: 案情摘要正文 + - name: 案情品种 + type: string + desc: 案情摘要中的品种规格、单位、数量 + - group: 审批意见 + fields: + - name: 承办人意见 + type: string + desc: 承办人意见→意见 + - name: 承办人日期 + type: date + desc: 承办人意见→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人意见→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→意见 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→意见内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 结案报告表 + name: 结案报告表 + required: true + classifier: + title_patterns: + - 结案报告表 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 执行情况 + type: string + desc: 执行情况 + - group: 审批意见 + fields: + - name: 承办人结案理由 + type: string + desc: 承办人结案理由→内容 + - name: 承办人结案日期 + type: date + desc: 承办人结案理由→日期 + - name: 承办人结案签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人结案理由→签名1 输出 有/无 + - name: 承办人结案签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人结案理由→签名2 输出 有/无 + - name: 承办部门意见 + type: string + desc: 承办部门意见→内容 + - name: 承办部门日期 + type: date + desc: 承办部门意见→日期 + - name: 承办部门签名 + type: enum + allowed: + - 有 + - 无 + desc: 承办部门意见→签名 输出 有/无 + - name: 负责人意见 + type: string + desc: 负责人意见→内容 + - name: 负责人日期 + type: date + desc: 负责人意见→日期 + - name: 负责人签名 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见→签名 输出 有/无 +- id: 缴款凭证 + name: 缴款凭证 + required: false + classifier: + title_patterns: + - 缴款凭证 + - 广东省非税收入一般缴款书[((]电子[))] + - 广东省非税收入一般缴款书(电子) + keywords: + - 非税收入 + - 缴款书 + - 收费项目 + - 收入项目 + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 收入项目 + type: string + desc: 收入项目名称(电子非税缴款书上可能写作"收费项目") + - name: 金额 + type: money + desc: 金额 + - name: 备注 + type: verbatim + desc: 备注 +- id: 行政处罚事先告知书 + name: 行政处罚事先告知书 + required: true + classifier: + title_patterns: + - 行政处罚事先告知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 当事人 + type: verbatim + desc: 当事人 + - name: 正文前称呼 + type: string + desc: 正文前称呼 + - name: 权利告知 + type: string + desc: 正文→权利告知 +- id: 证据先行登记保存批准书 + name: 证据先行登记保存批准书 + required: false + classifier: + title_patterns: + - 证据先行登记保存批准书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 标题下方文本 + type: string + desc: 标题下方文本 + - name: 表格下方文字 + type: string + desc: 表格下方文字 含"对先行登记保存的证据,应当在...日内处理" + - name: 表格品规 + type: string + desc: 表格内容→品种规格、单位、数量 + - name: 表格全文 + type: string + desc: 表格完整内容 + - name: 盖章 + type: enum + allowed: + - 有 + - 无 + desc: 行政机关盖章 输出 有/无 + - group: 承办人 + fields: + - name: 承办人日期 + type: date + desc: 承办人→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名2 输出 有/无 + - group: 负责人 + fields: + - name: 负责人意见 + type: verbatim + desc: 负责人意见并签名→意见内容 + - name: 负责人意见有无 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见并签名→是否有意见 输出 有/无 + - name: 负责人日期 + type: date + desc: 负责人意见并签名→日期 + - name: 负责人签名姓名 + type: verbatim + desc: 负责人意见并签名→签名姓名 + - name: 负责人签名有无 + type: enum + allowed: + - 有 + - 无 + desc: 负责人意见并签名→是否有签名 输出 有/无 +- id: 证据先行登记保存通知书 + name: 证据先行登记保存通知书 + required: false + classifier: + title_patterns: + - 证据先行登记保存通知书 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 表格下方文字 + type: string + desc: 表格下方文字 + - name: 表格品规 + type: string + desc: 表格内容→品种规格、单位、数量 + - name: 表格全文 + type: string + desc: 表格完整内容 + - name: 盖章 + type: enum + allowed: + - 有 + - 无 + desc: 行政机关盖章 输出 有/无 + - name: 拒绝签名说明 + type: string + desc: 正文→拒绝签名说明 + - name: 当事人签名 + type: enum + allowed: + - 有 + - 无 + desc: 当事人签名 输出 有/无 + - group: 承办人 + fields: + - name: 承办人日期 + type: date + desc: 承办人→日期 + - name: 承办人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名1 输出 有/无 + - name: 承办人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 承办人→签名2 输出 有/无 +- id: 证据复制(提取)单 + name: 证据复制(提取)单 + required: true + classifier: + title_patterns: + - 证据复制[((]提取[))]单 + - 证据复制(提取)单 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 复制时间 + type: verbatim + desc: |- + 证据复制(提取)单每页末尾的「复制(提取)时间」字段。 + 一份卷宗通常有多份证据(每页一份,说明事项各异)。 + 当**多份存在**时,取**第一份**的时间(整体活动起点, + 通常也是询问笔录期间制作的那份)。 + 示例:"2024 年 4 月 19 日 19 时 30 分"。 + - name: 复制地点 + type: verbatim + desc: |- + 证据复制(提取)单每页末尾的「复制(提取)地点」字段。 + 多份存在时取**第一份**的地点(与 复制时间 同一页)。 + 示例:"郁南县南江口镇西江路 124 号"。 + - name: 现场时间 + type: verbatim + desc: |- + 证据复制(提取)单中**现场检查相片**所属那份证据的 + 「复制(提取)时间」。特征:说明事项含"现场/外观/ + 查获时拍摄/查获违法走私卷烟时"等,与执法人员到场 + 同一时段。用于和 现场笔录.检查时间 对齐。 + 如没有纯"现场检查"相片,取第一份时间。 + - name: 现场地址 + type: string + desc: |- + 证据复制(提取)单中**现场检查相片**所属那份证据的 + 「复制(提取)地点」,与 现场时间 同一份。 + 用于和 现场笔录.检查地点 对齐。 + - name: 邮件回执 + type: verbatim + desc: 邮件回执 + - group: 居民身份证 + fields: + - name: 居民身份证 + type: multi_entity + desc: |- + 证据复制(提取)单中**每一张**居民身份证图片对应的一份记录。 + 一份证据复制单通常包含多张身份证(当事人、举报人、未成年人、相关人等), + 请把每张身份证都抽取为数组中的一项,**不要**只抽"当事人那份"。 + 由派生字段「证据复制(提取)单当事人」按姓名+身份证号挑出归属当事人的那一份。 + 如果只有一张身份证,返回只包含一项的数组即可(引擎会自动把那一项判为当事人)。 + fields: + - name: 身份证姓名 + type: verbatim + desc: 该份身份证上印的姓名 + - name: 身份证性别 + type: verbatim + desc: 该份身份证上印的性别(男/女) + - name: 身份证民族 + type: verbatim + desc: 该份身份证上印的民族 + - name: 身份证住址 + type: string + desc: 该份身份证上印的住址 + - name: 身份证号 + type: chinese-id + desc: 该份身份证上印的公民身份号码(18 位) + - name: 身份证背面 + type: enum + allowed: + - 有 + - 无 + desc: 该份身份证是否包含背面(签发机关/有效期那面)有/无 + - group: 许可证 + fields: + - name: 许可证企业名称 + type: string + desc: 烟草专卖零售许可证→企业名称 + - name: 许可证经营场所 + type: string + desc: 烟草专卖零售许可证→经营场所 + - name: 许可证号 + type: verbatim + desc: 烟草专卖零售许可证→许可证号 + - name: 许可证负责人 + type: verbatim + desc: 烟草专卖零售许可证→负责人姓名 + - group: 营业执照 + fields: + - name: 执照名称 + type: string + desc: 营业执照→名称 + - name: 执照住所 + type: string + desc: 营业执照→住所 + - name: 执照法代 + type: verbatim + desc: 营业执照→法定代表人 + - name: 执照统一社会信用代码 + type: uscc + desc: 营业执照→统一社会信用代码 +- id: 询问笔录 + name: 询问笔录 + required: true + classifier: + title_patterns: + - 询问笔录 + keywords: [] + min_score: 0.5 + extract: + - group: 基本信息 + fields: + - name: 询问时间 + type: verbatim + desc: 询问时间 + - name: 询问地点 + type: verbatim + desc: 询问地点 + - group: 被询问人 + fields: + - name: 被询问人 + type: multi_entity + desc: |- + 询问笔录中**每一份**笔录记录对应的被询问人基本信息。 + 一份卷宗可能包含多次询问笔录(针对不同人员),请把**每一份**笔录 + 中的被询问人都抽取为数组中的一项,**不要**只抽"当事人那份"。 + 由派生字段「询问笔录当事人」按姓名+证件号挑出归属当事人的那一份。 + 只有一份被询问人记录时,引擎自动把那一份判为当事人。 + fields: + - name: 被询问人姓名 + type: verbatim + desc: 被询问人→姓名 + - name: 被询问人性别 + type: enum + allowed: + - 男 + - 女 + desc: 被询问人→性别 + - name: 被询问人民族 + type: verbatim + desc: 被询问人→民族 + - name: 被询问人证件 + type: verbatim + desc: 被询问人→证件类型及号码(通常是"居民身份证:xxx") + - name: 被询问人电话 + type: verbatim + desc: 被询问人→联系电话 + - name: 被询问人住址 + type: string + desc: 被询问人→住址 + - name: 被询问人经营地址 + type: string + desc: 被询问人→经营地址 + - group: 笔录正文 + fields: + - name: 执法人员信息 + type: string + desc: 正文→执法人员信息 + - name: 权利告知 + type: string + desc: 正文→权利告知内容 + - name: 被询问人核实 + type: string + desc: 正文→被询问人核实 + - name: 拒绝签名说明 + type: string + desc: 正文→拒绝签名说明 + - group: 签名 + fields: + - name: 被询问人签名 + type: enum + allowed: + - 有 + - 无 + desc: 被询问人(签名)输出 有/无 + - name: 询问人签名1 + type: enum + allowed: + - 有 + - 无 + desc: 询问人(签名)1 输出 有/无 + - name: 询问人签名2 + type: enum + allowed: + - 有 + - 无 + desc: 询问人(签名)2 输出 有/无 +- id: 送达回证 + name: 送达回证 + required: true + classifier: + title_patterns: + - 送达回证 + keywords: [] + min_score: 0.5 + # 注意:本子文档内可能拼接多份送达回证表格(立案通知/先行登记保存通知/事先告知书/ + # 处罚决定书等各一份)。以下字段只抽取"送达文书名称"含"行政处罚决定书"的那份; + # 其他送达回证忽略(由专门的规则处理)。 + extract: + - group: 基本信息 + fields: + - name: 受送达人 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份表格里的受送达人 + - name: 回证编号 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份表格上方的回证编号 + - name: 送达方式 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达方式 + - name: 送达地点 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达地点 + - name: 送达文书名称 + type: string + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"的那一项(作为后续其他字段的定位基准) + - name: 送达文书文号 + type: verbatim + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的送达文书文号 + - group: 签收 + fields: + - name: 签收日期 + type: date + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的签收日期 + - name: 代收理由 + type: string + desc: 本子文档内若含多份送达回证,只抽"送达文书名称"含"行政处罚决定书"那份的代收人代收理由 + - name: 印章 + type: enum + allowed: + - 有 + - 无 + desc: 印章 输出 有/无 + vlm_extract_mode: always + - name: 收件人签名 + type: enum + allowed: + - 有 + - 无 + desc: 收件人签名或盖章→签名 输出 有/无 + vlm_extract_mode: always + - name: 收件人盖章 + type: enum + allowed: + - 有 + - 无 + desc: 收件人签名或盖章→盖章 输出 有/无 + vlm_extract_mode: always + - group: 送达人 + fields: + - name: 送达人签名 + type: enum + allowed: + - 有 + - 无 + desc: 送达人签名 输出 有/无 + vlm_extract_mode: always +rules: +- group: JZG-JD + rules: + - rule_id: JZ-JD-001 + name: 当事人基本情况或立案情况记载准确性 + desc: 若当事人信息与证据复制(提取)单中信息不一致,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + - 案件调查终结报告 + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 案件处理审批表.案由 + target: 案件调查终结报告.案由 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + - source: 案件处理审批表.立案编号 + target: 立案报告表.立案编号 + - source: 案件处理审批表.立案日期 + target: 案件调查终结报告.立案日期 + - source: 案件处理审批表.单位名称 + target: 案件调查终结报告.单位名称 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 案件处理审批表.单位法代 + target: 案件调查终结报告.单位法代 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 案件处理审批表.单位地址 + target: 案件调查终结报告.单位地址 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 案件处理审批表.案由 + target: 案件调查终结报告.案由 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + - source: 案件处理审批表.立案日期 + target: 案件调查终结报告.立案日期 + - source: 案件处理审批表.立案编号 + target: 立案报告表.立案编号 + - source: 案件处理审批表.个人姓名 + target: 案件调查终结报告.个人姓名 + when: "当事人类型 != '单位'" + - source: 案件处理审批表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + method: substring + - source: 案件处理审批表.个人住址 + target: 案件调查终结报告.个人住址 + when: "当事人类型 != '单位'" + - id: '3' + check: ai + prompt: "请根据以下卷宗信息,判断当事人基本情况及立案情况的记载是否准确一致。\n\n 【第一步:判断案件类型】\n\n 检查\"案件处理审批表\"\ + 中的当事人单位名称字段值:\n {{案件处理审批表.单位名称}}\n\n - 如果该值为 \"/\"、\"-\"、空或其他占位符 → 这是**个人案件**,执行个人案件检查\n\ + \ - 如果该值是真实的单位名称 → 这是**单位案件**,执行单位案件检查\n\n ---\n\n 【第二步-A:单位案件检查】(当事人为单位时执行)\n\ + \n 请逐一比对以下字段,判断是否一致:\n\n 1. 案由\n - 案件处理审批表:{{案件处理审批表.案由}}\n - 案件调查终结报告:{{案件调查终结报告.案由}}\n\ + \n 2. 案件来源\n - 案件处理审批表:{{案件处理审批表.案件来源}}\n - 案件调查终结报告:{{案件调查终结报告.案件来源}}\n\n\ + \ 3. 立案编号\n - 案件处理审批表:{{案件处理审批表.立案编号}}\n - 立案报告表:{{立案报告表.立案编号}}\n\n 4. 立案日期\n\ + \ - 案件处理审批表:{{案件处理审批表.立案日期}}\n - 案件调查终结报告:{{案件调查终结报告.立案日期}}\n\n 5. 单位名称(三方核对)\n\ + \ - 案件处理审批表:{{案件处理审批表.单位名称}}\n - 案件调查终结报告:{{案件调查终结报告.单位名称}}\n - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照名称}}\n\ + \n 6. 法定代表人(三方核对)\n - 案件处理审批表:{{案件处理审批表.单位法代}}\n - 案件调查终结报告:{{案件调查终结报告.单位法代}}\n\ + \ - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照法代}}\n\n 7. 单位地址(三方核对)\n - 案件处理审批表:{{案件处理审批表.单位地址}}\n\ + \ - 案件调查终结报告:{{案件调查终结报告.单位地址}}\n - 证据复制(提取)单-营业执照:{{证据复制(提取)单.执照住所}}\n\n \ + \ ---\n\n 【第二步-B:个人案件检查】(当事人为个人或个体工商户时执行)\n\n 请逐一比对以下字段,判断是否一致:\n\n 1.\ + \ 案由\n - 案件处理审批表:{{案件处理审批表.案由}}\n - 案件调查终结报告:{{案件调查终结报告.案由}}\n\n 2. 案件来源\n\ + \ - 案件处理审批表:{{案件处理审批表.案件来源}}\n - 案件调查终结报告:{{案件调查终结报告.案件来源}}\n\n 3. 立案编号\n\ + \ - 案件处理审批表:{{案件处理审批表.立案编号}}\n - 立案报告表:{{立案报告表.立案编号}}\n\n 4. 立案日期\n - 案件处理审批表:{{案件处理审批表.立案日期}}\n\ + \ - 案件调查终结报告:{{案件调查终结报告.立案日期}}\n\n 5. 姓名\n - 案件处理审批表:{{案件处理审批表.个人姓名}}\n -\ + \ 案件调查终结报告:{{案件调查终结报告.个人姓名}}\n\n 6. 性别\n - 案件处理审批表:{{案件处理审批表.个人性别}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证性别}}\n\ + \n 7. 民族\n - 案件调查终结报告:{{案件调查终结报告.个人民族}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证民族}}\n\ + \n 8. 证件号码(包含匹配)\n - 案件调查终结报告:{{案件调查终结报告.个人证件}}\n - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证号}}\n\ + \ - 注意:审批表中证件字段格式可能为\"居民身份证:44xxxxxxxx\",判断时应提取纯号码部分进行比对\n\n 9. 住址\n - 案件处理审批表:{{案件处理审批表.个人住址}}\n\ + \ - 证据复制(提取)单-居民身份证:{{证据复制(提取)单当事人.身份证住址}}\n\n ---\n\n 【判断规则】\n\n - \"/\"\ + 、\"-\"、\"—\" 等符号代表该字段不适用,不是有效值,遇到此类值的比对项直接跳过\n - 只要有任意一个有效字段不一致,判定为**不通过**\n\ + \ - 所有有效字段均一致(或均为占位符可跳过),判定为**通过**\n" + logic: 1 OR 2 OR 3 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: ai_rule + - rule_id: JZ-JD-002 + name: 处罚决定书证据列举 + desc: 若找不到"证据:"或者"证据:"之后无内容,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + stages: + - id: '1' + check: required + field: 处罚决定书.证据列举 + messages: + pass: 处罚决定书已列出相关证据。 + fail: 罚决定书未列出相关证据,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic + - rule_id: JZ-JD-003 + name: 救济途径或期限告知明确性 + desc: 若未找到文本匹配内容,则扣分。 + risk: medium + score: 5 + scope: + - 处罚决定书 + stages: + - id: '1' + check: required + field: 处罚决定书.救济途径 + messages: + pass: 已告知救济途径和期限。 + fail: 救济途径或期限告知不明确或不正确,请核对。 + references_laws: + - 《中华人民共和国烟草专卖法》第四十一条 + type: deterministic + - rule_id: JZ-JD-004 + name: 行政处罚决定当事人基本情况记载准确性 + desc: 检查首段信息是否填写齐全,若存在未填内容,(字号:可为空),若不齐全,则扣分。 若当事人信息与证据中提取的信息不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 处罚决定书.当事人 + target: 证据复制(提取)单.许可证企业名称 + when: "当事人类型 != '个人'" + - source: 处罚决定书.字号 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 处罚决定书.统一社会信用代码 + target: 证据复制(提取)单.执照统一社会信用代码 + when: "当事人类型 != '个人'" + - source: 处罚决定书.经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证经营场所 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 处罚决定书.当事人 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 处罚决定书.性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 处罚决定书.民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 处罚决定书.身份证住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 处罚决定书.身份证号码 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 处罚决定书.经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证经营场所 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - source: 处罚决定书.字号 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + logic: 1 OR 2 + messages: + pass: 当事人的基本情况记载齐全且准确。 + fail: 当事人的基本情况记载不齐全或不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic +- group: JZG-SD + rules: + - rule_id: JZ-SD-001 + name: 法定时限送达 + desc: 若处罚决定书文尾的日期与处罚决定书的送达回证中的"签收日期",之间的范围不在法定时限内,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 送达回证 + stages: + - id: '1' + check: required + fields: + - 送达回证.签收日期 + - 处罚决定书.落款日期 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic + - rule_id: JZ-SD-002 + name: 送达回证基本信息规范 + desc: 若收件人签名、签收时间、送达人签名、印章任意一项不存在,则扣分 + risk: medium + score: 10 + scope: + - 送达回证 + stages: + - id: '1' + check: required + fields: + - 送达回证.回证编号 + - 送达回证.送达文书名称 + - 送达回证.送达方式 + - 送达回证.签收日期 + messages: + pass: 办案单位印章、送达人签名、收件人签名及签收时间填写规范。 + fail: 填写不规范,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic +- group: JZG-XC + rules: + - rule_id: JZ-XC-001 + name: 现场笔录时间地点完整性 + desc: 若现场笔录中时间或地点未记载,则扣分;若记载的时间与证据提取单中的时间、地点不一致,也扣分。 + risk: medium + score: 10 + scope: + - 现场笔录 + - 证据复制(提取)单 + stages: + # 地点是文字型字段,用确定性 match 足够(fuzzy 可容忍小差异) + - id: '1' + check: match + pairs: + - source: 现场笔录.检查地点 + target: 证据复制(提取)单.现场地址 + method: fuzzy + # 时间是语义型字段 —— 现场笔录.检查时间常是时间段("16:10至17:00"), + # 证据复制(提取)单.现场时间常是时间点("16:20")。不写字符串 parser, + # 直接让 LLM 按业务语义判定(点落在段内视为一致)。 + - id: '2' + check: ai + prompt: | + 判断以下两个时间在业务上是否一致: + + - 现场笔录.检查时间:{{现场笔录.检查时间}} + - 证据复制(提取)单.现场时间:{{证据复制(提取)单.现场时间}} + + 判断原则: + - 若两者都是时间点且值相同 → 一致 + - 若一方是时间段,另一方是时间点,且**点落在段内** → 一致 + - 若两者都是时间段且有重叠 → 一致 + - 若完全无关或对不上 → 不一致 + + 只判时间业务语义,不判格式差异("2024 年 11 月 18 日"和"2024-11-18"视为同日)。 + logic: 1 AND 2 + messages: + pass: 时间地点记录准确。 + fail: 时间地点记录缺失或与实际不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-002 + name: 被检查人基本情况记载完整性-有无 + desc: 被检查人基本情况记载 + risk: medium + score: 10 + scope: + - 现场笔录 + stages: + - id: '1' + check: required + fields: + - 现场笔录.单位名称 + - 现场笔录.单位法代 + - 现场笔录.地址 + - 现场笔录.电话 + - 现场笔录.单位许可证号 + - id: '2' + check: required + fields: + - 现场笔录.个人姓名 + - 现场笔录.个人性别 + - 现场笔录.个人证件 + - 现场笔录.地址 + - 现场笔录.电话 + - id: '3' + check: required + fields: + - 现场笔录.现场负责人 + - 现场笔录.电话 + - 现场笔录.地址 + logic: (1 OR 2) AND 3 + messages: + pass: 被检查人姓名、身份证号、地址、许可证号与证据一致,请检查其余基本信息是否完整准确。 + fail: 被检查人基本情况记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-003 + name: 被检查人基本情况记载完整性-一致 + desc: 检查现场笔录中被检查人信息与身份证/营业执照/许可证信息是否一致 + risk: medium + score: 10 + scope: + - 现场笔录 + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 现场笔录.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 现场笔录.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 现场笔录.单位许可证号 + target: 证据复制(提取)单.许可证号 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证企业名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 证据复制(提取)单.许可证负责人 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '2' + check: match + pairs: + - source: 现场笔录.个人姓名 + target: 立案报告表.个人姓名 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 现场笔录.个人性别 + target: 立案报告表.个人性别 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 现场笔录.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 现场笔录.地址 + target: 立案报告表.个人住址 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + logic: 1 OR 2 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-XC-004 + name: 被检查人签署意见合规性 + desc: 若被检查人拒绝签署意见及姓名,且执法人员未说明情况,则扣分。 + risk: medium + score: 10 + scope: + - 现场笔录 + stages: + - id: '1' + check: required + fields: + - 现场笔录.意见 + - 现场笔录.意见日期 + - 现场笔录.意见签名 + - id: '2' + check: required + field: 现场笔录.意见 + messages: + pass: 被检查人已签署意见及姓名,或执法人员已说明拒绝签署的情况。 + fail: 被检查人拒绝签署但执法人员未说明情况,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic +- group: JZG-DJ + rules: + - rule_id: JZ-DJ-001 + name: 批准保存时间记载完整性 + desc: 若负责人意见并签名栏后没有日期信息,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + field: 证据先行登记保存批准书.负责人日期 + messages: + pass: 已记载批准保存时间。 + fail: 批准保存时间未记载,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-002 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 若行政机关负责人没有签署意见或姓名,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人签名姓名 + - 证据先行登记保存批准书.负责人意见 + - 证据先行登记保存批准书.负责人日期 + messages: + pass: 行政机关负责人已签署意见和姓名。 + fail: 行政机关负责人未签署意见或姓名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-003 + name: 先行登记保存证据期限记载 + desc: 若没有文中"对先行登记保存的证据,应当在.....日内及时作出处理决定。"的描述,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.表格下方文字 + - 证据先行登记保存通知书.表格下方文字 + messages: + pass: 已注明先行登记保存证据期限和处理决定期限。 + fail: 未注明相关期限,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-004 + name: 先行登记保存批准书或通知书文件校验 + desc: 若现场笔录中的情况说明中出现物品名称及规格描述,且文件中无批准书或通知书,则扣分。 + risk: medium + score: 10 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.表格品规 + - 证据先行登记保存通知书.表格品规 + - id: '2' + check: ai + prompt: '请判断以下 {{证据先行登记保存批准书.表格全文}} 和 {{证据先行登记保存通知书.表格全文}} 表述和数量一致 + + ' + messages: + pass: 存在先行登记保存批准书或通知书。 + fail: 缺少先行登记保存批准书或通知书,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-005 + name: 批准书与通知书内容一致性 + desc: 若批准书和通知书内容不一致,则直接扣分;若一致,则与抽样清单中的物品数量进行比对,如果抽样清单中同一品种有多条记录则提示。 若当事人和见证人栏均无签名,则扣分 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: match + pairs: + - source: 证据先行登记保存通知书.表格品规 + target: 证据先行登记保存批准书.表格品规 + messages: + pass: 批准书与通知书内容一致 + fail: 批准书与通知书内容不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-006 + name: 证据先行登记保存批准/通知书承办人签名日期 + desc: 若没有证据先行登记保存批准/通知书承办人签字或盖章,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.承办人日期 + - 证据先行登记保存通知书.承办人日期 + - 证据先行登记保存批准书.承办人签名1 + - 证据先行登记保存批准书.承办人签名2 + - 证据先行登记保存通知书.承办人签名1 + - 证据先行登记保存通知书.承办人签名2 + messages: + pass: 有日期,案件承办人已签字或盖章。 + fail: 缺少印章、日期或承办人签字盖章,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-007 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 若没有填写两名承办人意见及签名,负责人意见及签名,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人日期 + - 证据先行登记保存批准书.负责人签名有无 + - 证据先行登记保存批准书.负责人意见有无 + messages: + pass: 两名承办人签名,负责人意见及签名完整。 + fail: 两名承办人签名或负责人意见及签名缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-008 + name: 保存理由和内容记载完整性 + desc: 若首部没有保存理由描述,表格中没有规格和数量信息,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + - 证据先行登记保存批准书 + stages: + - id: '1' + check: ai + prompt: '{{立案报告表.案由}} + + {{证据先行登记保存批准书.标题下方文本}} + + 案由应该要和标题下方文本同一个意思,案由会比较少字。帮我评查这个案由是否存在 在标题下方文本 中 + + ' + - id: '2' + check: ai + prompt: '{{立案报告表.案情品种}}中提及的具体规格品种、数量应出现在{{证据先行登记保存批准书.表格品规}}中,但案情摘要中不一定会将全部规格品种都写全,评查尺度可以适当放松 + + ' + - id: '3' + check: ai + prompt: '请根据以下信息判断案件类型,对个人(个体工商户)案件单独评查证据先行登记保存批准书内容是否完整。 + + + 当事人-单位-名称: {{立案报告表.单位名称}} + + 当事人-个人(个体工商户)-姓名: {{立案报告表.个人姓名}} + + 证据先行登记保存批准书-表格内容-品种规格、单位、数量: {{证据先行登记保存批准书.表格品规}} + + + 判断逻辑: + + 1. 如果单位-名称为空或为"/",且个人-姓名不为空,则这是个人(个体工商户)案件 + + 2. 对于个人案件:只要证据先行登记保存批准书-表格内容-品种规格、单位、数量有内容(非空);若为空 + + 3. 如果单位-名称有实际值(非空、非"/") + + ' + logic: (1 AND 2) OR 3 + messages: + pass: 已注明保存理由和内容。 + fail: 保存理由和内容未注明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-009 + name: 先行登记保存物品处理通知书当事人签字 + desc: 若通知书中当事人未签字或没有其他内容说明,则扣分。 + risk: medium + score: 5 + scope: + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + field: 证据先行登记保存通知书.当事人签名 + - id: '2' + check: required + field: 证据先行登记保存通知书.拒绝签名说明 + logic: 1 OR 2 + messages: + pass: 当事人已在先行登记保存物品处理通知书上签字。 + fail: 当事人未签字或i没有情况说明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存通知书 != None + - rule_id: JZ-DJ-010 + name: 证据先行登记保存批准书负责人意见并签名 + desc: 检查涉案物品返还清单接收人签名、日期和印章是否完整,并通过正则检查损耗/返还信息 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.负责人意见 + - 证据先行登记保存批准书.负责人签名姓名 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None + - rule_id: JZ-DJ-011 + name: 证据先行登记保存批准/通知书盖章 + desc: 检查先行登记保存批准书和通知书是否加盖行政机关印章 + risk: medium + score: 5 + scope: + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: required + fields: + - 证据先行登记保存批准书.盖章 + - 证据先行登记保存通知书.盖章 + messages: + pass: 有行政机关印章 + fail: 缺少印章,请核对 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 证据先行登记保存批准书 != None and 证据先行登记保存通知书 != None +- group: JZG-QR + rules: + - rule_id: JZ-QR-001 + name: 陈述申辩权利告知和听取 + desc: 若表述中不包含"享有陈述权和申辩权"、"...日内"、"...视为放弃",任意一项,则扣分, + risk: medium + score: 10 + scope: + - 行政处罚事先告知书 + stages: + - id: '1' + check: required + field: 行政处罚事先告知书.权利告知 + messages: + pass: 已告知当事人陈述申辩权利。 + fail: 未告知当事人陈述申辩相关权力,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十四条 + type: deterministic + - rule_id: JZ-QR-002 + name: 行政处罚事先告知对象准确性 + desc: 若告知书首句中的姓名与当事人意见中的签名不一致,则扣分。 + risk: medium + score: 10 + scope: + - 行政处罚事先告知书 + stages: + - id: '1' + check: match + pairs: + - source: 行政处罚事先告知书.当事人 + target: 行政处罚事先告知书.正文前称呼 + messages: + pass: 行政处罚事先告知对象正确。 + fail: 行政处罚事先告知对象错误,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十四条 + type: deterministic +- group: JZG-QZ + rules: + - rule_id: JZ-QZ-001 + name: 当事人身份证明提取规范性 + desc: 若没有提取当事人身份证明,则扣分。 + risk: medium + score: 10 + scope: + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 证据复制(提取)单当事人.身份证号 + - 证据复制(提取)单当事人.身份证背面 + messages: + pass: 当事人身份证明已规范提取。 + fail: 当事人身份证明提取不规范或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-QZ-002 + name: 查获物品情况记载准确性、合规性 + desc: 若批准书和通知书内容不一致,则直接扣分;若一致,则与抽样清单中的物品数量进行比对,如果抽样清单中同一品种有多条记录则提示。 若当事人和见证人栏均无签名,则扣分 + risk: medium + score: 10 + scope: + - 抽样取证物品清单 + - 涉案物品核价表 + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: ai + prompt: '请判断{{抽样取证物品清单.品种规格}}(若有)或{{涉案物品核价表.核价明细}},以及{{证据先行登记保存批准书.表格品规}}、{{证据先行登记保存通知书.表格品规}}表述和数量一致。 + + 如果{{抽样取证物品清单.品种规格}}、{{涉案物品核价表.核价明细}}都不存在,则只需判断{{证据先行登记保存批准书.表格品规}}和{{证据先行登记保存通知书.表格品规}}的一致性 + + ' + messages: + pass: 查获物品情况、数量及当事人或见证人姓名记录准确。 + fail: 记录不准确或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-QZ-003 + name: 核价文书记录的准确性(盖章) + desc: 检查涉案物品核价表是否有涉案卷烟价格管理小组印章 + risk: medium + score: 5 + scope: + - 涉案物品核价表 + stages: + - id: '1' + check: required + field: 涉案物品核价表.核价组印章 + messages: + pass: 已正确加盖印章。 + fail: 印章加盖错误,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 涉案物品核价表 != None + - rule_id: JZ-QZ-004 + name: 抽样取证物品清单完整性 + desc: 先行登记保存证据处理通知书"处理"方式选择第2项"送交...鉴定"时,卷宗内没有抽样取证物品清单,则扣分。 + risk: medium + score: 10 + scope: + - 先行登记保存证据处理通知书 + - 抽样取证物品清单 + stages: + - id: '1' + check: required + field: 先行登记保存证据处理通知书.处理方式 + - id: '2' + check: required + fields: + - 抽样取证物品清单.表格有内容 + - 抽样取证物品清单.当事人签名 + logic: (1 AND 2) OR (NOT 1) + messages: + pass: 抽样提取物证时有完整的物品清单。 + fail: 抽样提取物证时缺少物品清单,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 先行登记保存证据处理通知书 != None + - rule_id: JZ-QZ-005 + name: 核价文书记录准确性 + desc: 若核价文书或记录中没有准确记载(计算核价结果错误)涉案物品情况,核价错误,则扣分。 + risk: medium + score: 5 + scope: + - 涉案物品核价表 + stages: + - id: '1' + check: ai + prompt: '{{涉案物品核价表.表格全文}} + + 请判断以表格中各品种规格的数量、单价计算的合计金额是否正确,各品种规格合计金额计算总计金额是否正确,请在计算的时候保留小数点后两位 + + ' + messages: + pass: 涉案物件核价表存在 + fail: 涉案物件核价表不存在或者信息内容有误 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-QZ-006 + name: 价格证明合规性 + desc: 若批准书与通知书内容不一致,核价表中数量与批准书或通知书中不一致,则扣分。 + risk: medium + score: 10 + scope: + - 涉案物品核价表 + - 证据先行登记保存批准书 + - 证据先行登记保存通知书 + stages: + - id: '1' + check: ai + prompt: '请判断以下三个表格物品和数量是否对应 + + {{涉案物品核价表.核价明细}} + + {{证据先行登记保存批准书.表格品规}} + + {{证据先行登记保存通知书.表格品规}} + + ' + messages: + pass: 价格证明符合要求,且有涉案物品核价依据或价格来源。 + fail: 价格证明不符合要求或缺少依据,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule +- group: JZG-XW + rules: + - rule_id: JZ-XW-001 + name: 被询问人签署"记录属实"合规性 + desc: 若每页页尾被询问人处没有签名,则扣分;如果最后一页没有手写内容则提示。 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + field: 询问笔录.被询问人核实 + messages: + pass: 被询问人已签署"记录属实"且逐页签名。 + fail: 被询问人未签署或未逐页签名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-002 + name: 询问笔录合规性 + desc: 通过AI判断询问笔录格式是否符合规范要求 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: ai + prompt: '请判断以下询问笔录中是否只有一名被询问人。被询问人信息:{{询问笔录当事人.被询问人姓名}} + + ' + messages: + pass: 笔录仅询问一名被询问人。 + fail: 一份笔录询问多名被询问人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: ai_rule + - rule_id: JZ-XW-003 + name: 执法人员身份表明和权利告知 + desc: 若未在询问开始时表明执法人员身份,并告知当事人享有陈述申辩权和申请回避权,则扣分。 + risk: medium + score: 5 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + fields: + - 询问笔录.执法人员信息 + - 询问笔录.权利告知 + messages: + pass: 执法人员已表明身份并告知相关权利。 + fail: 未表明身份或未告知权利,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-004 + name: 执法人员签名合规性 + desc: 若执法人员没有签名或只有一人签名,则扣分。 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + fields: + - 询问笔录.询问人签名1 + - 询问笔录.询问人签名2 + messages: + pass: 执法人员已签名,且有两人以上签名。 + fail: 执法人员签名缺失或不足两人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第四十二条 + type: deterministic + - rule_id: JZ-XW-005 + name: 被询问人基本情况记载全面性 + desc: 被询问人基本情况填写不全,或询问时间、地点未准确记载,则扣分。 + risk: medium + score: 5 + scope: + - 证据复制(提取)单 + - 询问笔录 + stages: + - id: '1' + check: match + pairs: + - source: 询问笔录.询问地点 + target: 证据复制(提取)单.复制地点 + - source: 询问笔录当事人.被询问人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - source: 询问笔录当事人.被询问人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 询问笔录.询问时间 + target: 证据复制(提取)单.复制时间 + method: fuzzy + - source: 询问笔录当事人.被询问人经营地址 + target: 证据复制(提取)单.许可证经营场所 + when: "当事人类型 != '个人'" + messages: + pass: 被询问人基本情况、询问时间地点记录完整准确。 + fail: 记录不完整或不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic + - rule_id: JZ-XW-006 + name: 被询问人拒绝签署处理合规性 + desc: 检查被询问人拒绝签名时是否有情况说明记录 + risk: medium + score: 10 + scope: + - 询问笔录 + stages: + - id: '1' + check: required + field: 询问笔录.被询问人签名 + - id: '2' + check: required + field: 询问笔录.拒绝签名说明 + logic: 1 OR 2 + messages: + pass: 被询问人已签署或已记载拒绝情况。 + fail: 被询问人未签署且未记录情况说明,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十五条 + type: deterministic +- group: JZG-LA + rules: + - rule_id: JZ-LA-001 + name: 当事人基本情况记载完整、准确 + desc: 若当事人姓名、有效证件号码和地址未记载或与身份证中信息不一致,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 立案报告表.单位名称 + - 立案报告表.单位法代 + - 立案报告表.单位电话 + - 立案报告表.单位地址 + - id: '2' + check: required + fields: + - 立案报告表.个人姓名 + - 立案报告表.个人性别 + - 立案报告表.个人年龄 + - 立案报告表.个人民族 + - 立案报告表.个人证件 + - 立案报告表.个人电话 + - 立案报告表.个人住址 + - id: '3' + check: match + pairs: + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 立案报告表.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + - id: '4' + check: match + pairs: + - source: 立案报告表.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '5' + check: match + pairs: + - source: 立案报告表.个人姓名 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 立案报告表.个人住址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '6' + check: ai + prompt: '请根据以下信息判断案件类型(个人案件或单位案件),并评查当事人基本情况是否记载完整。 + + + 当事人-单位-名称: {{立案报告表.单位名称}} + + 当事人-单位-法定代表人(负责人): {{立案报告表.单位法代}} + + 当事人-个人(个体工商户)-姓名: {{立案报告表.个人姓名}} + + 当事人-个人(个体工商户)-性别: {{立案报告表.个人性别}} + + 当事人-个人(个体工商户)-年龄: {{立案报告表.个人年龄}} + + 当事人-个人(个体工商户)-民族: {{立案报告表.个人民族}} + + 当事人-个人(个体工商户)-证件类型及号码: {{立案报告表.个人证件}} + + 当事人-个人(个体工商户)-联系电话: {{立案报告表.个人电话}} + + 当事人-个人(个体工商户)-住址: {{立案报告表.个人住址}} + + + 判断逻辑: + + 1. 如果单位-名称为空或为"/",且个人-姓名不为空,则这是个人(个体工商户)案件 + + 2. 对于个人案件:检查个人字段(姓名、性别、年龄、民族、证件类型及号码、联系电话、住址)是否都不为空—— + + 3. 如果单位-名称有实际值(非空、非"/") + + ' + logic: (1 AND 4) OR 6 + messages: + pass: 当事人基本情况记录完整,与身份证信息一致。 + fail: 当事人基本情况记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国烟草专卖法》第三十八条 + type: ai_rule + - rule_id: JZ-LA-002 + name: 案由、发案时间和发案地点记载准确性-有无 + desc: 若案由、发案时间和发案地点未记载或错误记载,则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + field: 立案报告表.案由 + messages: + pass: 案由、发案时间和发案地点记录准确。 + fail: 案由、发案时间和发案地点记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-003 + name: 案件来源有无一致性校验 + desc: 若三处文档中的案件来源信息不一致或者存在未填写的情况,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + - 案件调查终结报告 + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.案件来源 + - 案件处理审批表.案件来源 + - 案件调查终结报告.案件来源 + - id: '2' + check: match + pairs: + - source: 立案报告表.案件来源 + target: 案件处理审批表.案件来源 + - source: 案件处理审批表.案件来源 + target: 案件调查终结报告.案件来源 + # 案件来源是开放词汇(投诉举报/群众举报/电话举报/来电举报/上级交办… + # 无穷枚举),不用 canonicalize 字典维护。字面不等时走 rescue L1 + # match 做语义等价判定。 + messages: + pass: 案件来源完整 + fail: 没有记载案件来源或案件来源与其他文书不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-004 + name: 案由、发案时间和发案地点记载准确性-一致 + desc: 检查立案报告表案发时间/地点与现场笔录检查时间/地点是否一致 + risk: medium + score: 10 + scope: + - 现场笔录 + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.案发时间 + - 立案报告表.案发地点 + - 现场笔录.检查时间 + - 现场笔录.检查地点 + - id: '2' + check: match + pairs: + - source: 立案报告表.案发时间 + target: 现场笔录.检查时间 + method: substring + - id: '3' + check: match + pairs: + - source: 现场笔录.检查地点 + target: 立案报告表.案发地点 + messages: + pass: 案由、发案时间和发案地点记录准确。 + fail: 案由、发案时间和发案地点记录有误或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + - rule_id: JZ-LA-005 + name: 承办人和承办部门意见 + desc: 承办人栏无描述、无签名、承办部门处无描述、无签名,出现任一一项则扣分。 + risk: medium + score: 5 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.承办部门意见 + - 立案报告表.承办部门日期 + - 立案报告表.承办人意见 + - 立案报告表.承办人日期 + - 立案报告表.承办部门签名 + - 立案报告表.承办人签名2 + - 立案报告表.承办人签名1 + messages: + pass: 承办人和承办部门意见及签名完整。 + fail: 承办人和承办部门意见及签名存在缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-LA-006 + name: 行政机关负责人明确意见、签字和日期 + desc: 若"负责人意见"栏中存在"不同意"或"不同意和意见描述",留空则扣分。;负责人意见栏无描述、无签名、无日期,出现任一一项则扣分。 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + fields: + - 立案报告表.负责人意见 + - 立案报告表.负责人签名 + messages: + pass: 行政机关负责人意见、签字和日期完整。 + fail: 行政机关负责人意见、签字和日期缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-LA-007 + name: 立案文书完整性检查(签名) + desc: 检查立案报告表负责人意见处是否有签名 + risk: medium + score: 10 + scope: + - 立案报告表 + stages: + - id: '1' + check: required + field: 立案报告表.负责人签名 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-LA-008 + name: 案件情况清晰 + desc: 通过AI检查立案报告表案由和案情摘要表述是否清晰;若案件情况描述中,需出现案件时间、货物名称、(案由描述+条款引用)中所有信息,未出现任一一项则扣分。 + risk: low + score: 1 + scope: + - 立案报告表 + stages: + - id: '1' + check: ai + prompt: | + 检查 案情摘要 是否覆盖以下 4 项要素(任一缺失才扣分): + 1. 案件时间(检查/发案时间) + 2. 涉案货物名称或品种 + 3. 案由描述(违法行为的事实陈述) + 4. 相关条款或法律依据的引用 + + 案由:{{立案报告表.案由}} + 案情摘要:{{立案报告表.案情摘要}} + + 判定规则: + - 4 项要素齐全 → pass + - 有缺项 → fail + - **不要**对文字风格、段落重复、句式冗余等格式问题扣分,只看内容是否齐全。 + messages: + pass: 案件情况描述清晰。 + fail: 案件情况记录不清晰或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: ai_rule +- group: JZG-ZJ + rules: + - rule_id: JZ-ZJ-001 + name: 调查终结报告文件校验 + desc: 若没有调查终结报告,则扣分 + risk: medium + score: 10 + scope: + - 案件调查终结报告 + stages: + - id: '1' + check: required + field: 案件调查终结报告.案由 + messages: + pass: 存在完整的调查终结报告。 + fail: 缺少调查终结报告,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-002 + name: 案由、立案时间和当事人基本情况记载 + desc: 若当事人信息与提取出的信息不一致,则扣分。 + risk: medium + score: 5 + scope: + - 案件调查终结报告 + - 证据复制(提取)单 + stages: + - id: '1' + check: required + fields: + - 案件调查终结报告.案件来源 + - 案件调查终结报告.案由 + - 案件调查终结报告.立案日期 + - 案件调查终结报告.单位名称 + - 案件调查终结报告.单位法代 + - 案件调查终结报告.单位电话 + - 案件调查终结报告.单位地址 + - id: '2' + check: required + fields: + - 案件调查终结报告.案件来源 + - 案件调查终结报告.案由 + - 案件调查终结报告.立案日期 + - 案件调查终结报告.个人姓名 + - 案件调查终结报告.个人性别 + - 案件调查终结报告.个人年龄 + - 案件调查终结报告.个人民族 + - 案件调查终结报告.个人电话 + - 案件调查终结报告.个人证件 + - 案件调查终结报告.个人住址 + - id: '3' + check: match + pairs: + - source: 案件调查终结报告.单位名称 + target: 证据复制(提取)单.执照名称 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位法代 + target: 证据复制(提取)单.执照法代 + when: "当事人类型 != '个人'" + - source: 案件调查终结报告.单位地址 + target: 证据复制(提取)单.执照住所 + when: "当事人类型 != '个人'" + - id: '4' + check: match + pairs: + - source: 案件调查终结报告.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + when: "当事人类型 != '单位'" + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + when: "当事人类型 != '单位'" + logic: (1 AND 3) OR (2 AND 4) + messages: + pass: 当事人基本情况记载准确。请检查案后及时间是否正确。 + fail: 记载不准确或缺失,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-003 + name: 当事人基本情况记载-一致 + desc: 检查调查终结报告中当事人基本信息与身份证信息是否一致 + risk: medium + score: 1 + scope: + - 案件调查终结报告 + - 证据复制(提取)单 + stages: + - id: '1' + check: match + pairs: + - source: 案件调查终结报告.个人姓名 + target: 证据复制(提取)单当事人.身份证姓名 + - source: 案件调查终结报告.个人性别 + target: 证据复制(提取)单当事人.身份证性别 + - source: 案件调查终结报告.个人民族 + target: 证据复制(提取)单当事人.身份证民族 + - source: 案件调查终结报告.个人住址 + target: 证据复制(提取)单当事人.身份证住址 + - source: 案件调查终结报告.个人证件 + target: 证据复制(提取)单当事人.身份证号 + messages: + pass: 文档检查通过,符合规范要求。 + fail: 文档存在以下问题,请修改后重新提交。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic + - rule_id: JZ-ZJ-004 + name: 案件调查终结报告承办人及承办部门负责人签字日期 + desc: 若没有承办人及承办人负责人签字、或者没有签字日期,则扣分。 + risk: medium + score: 5 + scope: + - 案件调查终结报告 + stages: + - id: '1' + check: required + fields: + - 案件调查终结报告.处理意见日期 + - 案件调查终结报告.处理意见承办人签名1 + - 案件调查终结报告.处理意见承办人签名2 + messages: + pass: 承办人及承办部门负责人已签字并签署日期。 + fail: 缺少签字或日期,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic +- group: JZG-CL + rules: + - rule_id: JZ-CL-001 + name: 法制部门或法制员意见明确性 + desc: 若法制部门意见栏无文字描述内容,则扣分。 + risk: medium + score: 10 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.法制部门意见 + - 案件处理审批表.法制部门日期 + - 案件处理审批表.法制部门审核人签名 + - 案件处理审批表.法制部门负责人签名 + messages: + pass: 法制部门或法制员意见明确。 + fail: 法制部门或法制员意见缺失或不明确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十八条 + type: deterministic + - rule_id: JZ-CL-002 + name: 案件处理审批表承办人意见和签名 + desc: 若承办人意见栏中无文字内容或无签名日期,则扣分。 + risk: medium + score: 5 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.承办人意见 + - 案件处理审批表.承办人日期 + - 案件处理审批表.承办部门意见 + - 案件处理审批表.承办部门日期 + - 案件处理审批表.承办部门签名 + - 案件处理审批表.承办人签名1 + - 案件处理审批表.承办人签名2 + messages: + pass: 承办人意见和签名完整。 + fail: 缺少承办人意见或签名,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十八条 + type: deterministic + - rule_id: JZ-CL-003 + name: 案件处理审批表负责人审批意见明确性 + desc: 检查案件处理审批表负责人审批意见内容和日期是否完整 + risk: medium + score: 10 + scope: + - 案件处理审批表 + stages: + - id: '1' + check: required + fields: + - 案件处理审批表.负责人日期 + - 案件处理审批表.负责人意见 + - 案件处理审批表.负责人签名 + messages: + pass: 行政机关负责人审批意见明确,签名和审批时间规范。 + fail: 审批意见不明确或签名审批时间不规范,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十七条 + type: deterministic +- group: JZG-ZG + rules: + - rule_id: JZ-ZG-001 + name: 行政处罚事先告知书送达 + desc: 若送达方式为"直接送达",则收件人签名或盖章栏无信息,则扣分。 若送达方式为"邮寄送达",则校验证据复制(提取)中是否有邮件回执,若不存在,则扣分。 + risk: medium + score: 10 + scope: + - 证据复制(提取)单 + - 送达回证 + stages: + - id: '1' + check: contains + field: 送达回证.送达方式 + value: 直接送达 + - id: '2' + check: contains + field: 送达回证.送达方式 + value: 邮寄送达 + - id: '3' + check: required + field: 证据复制(提取)单.邮件回执 + logic: 1 OR (2 AND 3) + messages: + pass: 事先告知书已送达当事人。 + fail: 事先告知书可能未送达当事人,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十一条 + type: deterministic +- group: JZG-ZX + rules: + - rule_id: JZ-ZX-001 + name: 罚款、没收违法所得处罚执行规范性 + desc: 若不存在《缴款凭证》(含《广东省非税收入一般缴款书(电子)》及其收款证明等任何形式的缴款凭证),则扣分。若缴款书中金额与处罚决定书中金额总计不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 缴款凭证 + stages: + - id: '1' + check: required + fields: + - 缴款凭证.金额 + - 缴款凭证.收入项目 + - 处罚决定书.罚款项目 + - 处罚决定书.罚款基数 + - 处罚决定书.罚款比例 + - 处罚决定书.罚款总额 + - id: '2' + check: ai + prompt: '请分析{{处罚决定书.罚款项目}}对应{{处罚决定书.罚款基数}}乘{{处罚决定书.罚款比例}},计算并校对与{{处罚决定书.罚款总额}}一致,同时{{处罚决定书.罚款总额}}与{{缴款凭证.金额}}需一致 + + ' + messages: + pass: 罚款、没收违法所得处罚已开具缴款书,有银行缴费收款证明,且与处罚决定书一致。 + fail: 未开具缴款书或无银行缴费证明,或与处罚决定书不一致,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第六十六条、第六十七条 + type: ai_rule + activate_if: 缴款凭证 != None + - rule_id: JZ-ZX-002 + name: 发还当事人物品与先行登记保存物品-一致 + desc: 若两份文件表格中,数量不一致,则涉案物品返还清单中备注一列需要有内容,没有内容则扣分。 + risk: medium + score: 10 + scope: + - 涉案物品返还清单 + - 证据先行登记保存批准书 + stages: + - id: '1' + check: ai + prompt: '{{证据先行登记保存批准书.表格品规}}和{{涉案物品返还清单.返还明细}}表格中的物品和数量应当一致,若 涉案物品返还清单表格中的具体的品种规格和数量行列数据不一致,则通过涉案物品返还清单的备注的内容进一步判断是否一致(即数量+损耗数量) + + ' + messages: + pass: 发还物品与先行登记保存物品一致,或不一致时已说明原因。 + fail: 发还物品与先行登记保存物品不一致且未说明原因,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: ai_rule + - rule_id: JZ-ZX-003 + name: 损耗费用返还合规性 + desc: 若签名或盖章不存在,或日期未填写,则扣分。 + risk: medium + score: 10 + scope: + - 卷宗封面 + - 涉案物品返还清单 + stages: + - id: '1' + check: contains + field: 卷宗封面.处理结果 + value: 销毁 + - id: '2' + check: required + field: 卷宗封面.处理结果 + - id: '3' + check: required + fields: + - 涉案物品返还清单.日期 + - 涉案物品返还清单.补偿信息 + - 涉案物品返还清单.返还确认 + - 涉案物品返还清单.接收人签名 + - id: '4' + check: required + fields: + - 涉案物品返还清单.日期 + - 涉案物品返还清单.接收单位印章 + - 涉案物品返还清单.补偿信息 + - 涉案物品返还清单.返还确认 + logic: (1 AND 2) OR ((NOT 1) AND 2 AND (3 OR 4)) + messages: + pass: 已全部返还留样卷烟或鉴别检验损耗费用。 + fail: 未全部返还,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十六条 + type: deterministic + activate_if: 涉案物品返还清单 != None or (卷宗封面 != None and 卷宗封面.处理结果 != None) + - rule_id: JZ-ZX-004 + name: 缴款凭证填写规范性 + desc: 若处罚中有没收而文件中不存在没收收据,则扣分。 + risk: medium + score: 5 + scope: + - 处罚决定书 + - 缴款凭证 + stages: + - id: '1' + check: required + fields: + - 处罚决定书.罚款说明 + - 缴款凭证.备注 + messages: + pass: 存在缴款凭证,请进一步确认填写是否规范。 + fail: 未找到缴款凭证,请核对文书是否齐全 + references_laws: + - 《中华人民共和国行政处罚法》第六十七条 + type: deterministic + activate_if: 缴款凭证 != None +- group: JZG-JA + rules: + - rule_id: JZ-JA-001 + name: 当事人名称、违法事实和处罚内容记载准确性 + desc: 若两份文书中的当事人名称不一致,则扣分。 + risk: medium + score: 10 + scope: + - 处罚决定书 + - 结案报告表 + stages: + - id: '1' + check: match + pairs: + - source: 结案报告表.当事人 + target: 处罚决定书.当事人 + messages: + pass: 当事人名称、处罚内容记载一致,请进一步检查违法事实是否一致。 + fail: 当事人记载不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第五十九条 + type: deterministic + - rule_id: JZ-JA-002 + name: 行政处罚决定的执行结果记载 + desc: 若执行情况栏后不存在描述内容,则扣分。 + risk: medium + score: 10 + scope: + - 结案报告表 + stages: + - id: '1' + check: required + field: 结案报告表.执行情况 + messages: + pass: 行政处罚决定的执行结果存在对应记载内容。 + fail: 执行结果记载不准确,请核对。 + references_laws: + - 《中华人民共和国行政处罚法》第七十一条 + type: deterministic + - rule_id: JZ-JA-003 + name: 结案意见、签名及其时间填写规范性 + desc: 若承办人、承办机构负责人和办案单位负责人的意见、签名及其时间任意一项未找到,则扣分。 + risk: medium + score: 10 + scope: + - 结案报告表 + stages: + - id: '1' + check: required + fields: + - 结案报告表.承办人结案理由 + - 结案报告表.承办人结案日期 + - 结案报告表.承办部门意见 + - 结案报告表.承办部门日期 + - 结案报告表.负责人意见 + - 结案报告表.负责人日期 + - 结案报告表.负责人签名 + - 结案报告表.承办人结案签名1 + - 结案报告表.承办人结案签名2 + - 结案报告表.承办部门签名 + messages: + pass: 意见、签名及其时间填写规范。 + fail: 填写不规范,请核对并更正。 + references_laws: + - 《中华人民共和国行政处罚法》第五十四条 + type: deterministic + - rule_id: JZ-JA-004 + name: 结案后按期立卷归档 + desc: 通过AI检查结案后是否在10日内立卷归档 + risk: medium + score: 10 + scope: + - 卷内备考表 + - 结案报告表 + stages: + - id: '1' + check: ai + prompt: '请你判断{{卷内备考表.立卷时间}}与{{结案报告表.负责人日期}}是否相差小于10天 + + ' + messages: + pass: 结案后已按期立卷归档。 + fail: 结案后未按期立卷归档,请核对。 + references_laws: + - 《烟草专卖行政处罚程序规定》 + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.停业/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.停业/1.0/rules.yaml new file mode 100644 index 0000000..9bec4c2 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.停业/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.停业 + name: 烟草专卖零售许可证-停业办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [停业, 烟草专卖零售许可证, 停业申请] + description: '烟草专卖零售许可证停业办理卷宗审核。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证处理、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.变更/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.变更/1.0/rules.yaml new file mode 100644 index 0000000..41031d7 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.变更/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.变更 + name: 烟草专卖零售许可证-变更办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [变更, 烟草专卖零售许可证, 变更申请] + description: '烟草专卖零售许可证变更办理卷宗审核。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证颁发、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.延续/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.延续/1.0/rules.yaml new file mode 100644 index 0000000..d5959bc --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.延续/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.延续 + name: 烟草专卖零售许可证-延续办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [延续, 烟草专卖零售许可证, 续期, 延期] + description: '烟草专卖零售许可证延续办理卷宗审核(许可证到期续期)。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证颁发、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 身份证明 + name: 身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证 + name: 烟草专卖零售许可证 + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.身份证号 + - 身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.姓名 + - 身份证明.性别 + - 身份证明.民族 + - 身份证明.住址 + - 身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证.许可证号, 烟草专卖零售许可证.副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.恢复营业/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.恢复营业/1.0/rules.yaml new file mode 100644 index 0000000..68b17a0 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.恢复营业/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.恢复营业 + name: 烟草专卖零售许可证-恢复营业办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [恢复营业, 烟草专卖零售许可证, 复业] + description: '烟草专卖零售许可证恢复营业办理卷宗审核(停业期满或提前复业)。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证颁发、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.收回/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.收回/1.0/rules.yaml new file mode 100644 index 0000000..34197d8 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.收回/1.0/rules.yaml @@ -0,0 +1,519 @@ +metadata: + type_id: 行政卷宗.行政许可.收回 + name: 烟草专卖零售许可证-收回办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [收回, 烟草专卖零售许可证, 依职权收回] + description: '烟草专卖零售许可证收回办理卷宗审核(行政机关依职权收回许可证)。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证处理、送达、归档、收回资料。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 许可类事项申请表 + name: 许可类事项申请表 + required: true + classifier: {title_patterns: [许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 身份证明 + name: 身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 受理单 + name: 受理单 + required: true + classifier: {title_patterns: [受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 实地核查记录表 + name: 实地核查记录表 + required: true + classifier: {title_patterns: [实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + - {name: 标题, type: verbatim, desc: 公告标题} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证正副本 + name: 烟草专卖零售许可证正副本 + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +- id: 内部沟通表 + name: 内部沟通表 + required: conditional + required_if: 卷宗封面.申请类型 contains "收回" + classifier: {title_patterns: [内部沟通表], keywords: [存在的主要问题及建议, 受理核查及处理结果, 处理结果跟踪及信息反馈], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 填报部门, type: verbatim, desc: 填报部门} + - group: 主要问题及建议 + fields: + - {name: 问题许可证号, type: verbatim, desc: 存在的主要问题及建议→许可证号} + - {name: 问题经营地址, type: verbatim, desc: 存在的主要问题及建议→经营地址} + - {name: 问题描述, type: string, desc: 存在的主要问题及建议→问题及建议描述} + - {name: 问题负责人签名, type: enum, allowed: [有, 无], desc: 存在的主要问题及建议→负责人签名 输出 有/无} + - group: 受理核查及处理结果 + fields: + - {name: 受理部门, type: verbatim, desc: 受理核查及处理结果→受理部门} + - {name: 处理结果, type: string, desc: 受理核查及处理结果→处理结果} + - {name: 受理负责人签名, type: enum, allowed: [有, 无], desc: 受理核查及处理结果→负责人签名 输出 有/无} + - group: 处理结果跟踪及信息反馈 + fields: + - {name: 反馈填报部门, type: verbatim, desc: 处理结果跟踪及信息反馈→填报部门} + - {name: 反馈内容, type: string, desc: 处理结果跟踪及信息反馈→信息反馈内容} + - {name: 反馈负责人签名, type: enum, allowed: [有, 无], desc: 处理结果跟踪及信息反馈→负责人签名 输出 有/无} + +- id: 情况反馈表 + name: 情况反馈表 + required: conditional + required_if: 卷宗封面.申请类型 contains "收回" + classifier: {title_patterns: [情况反馈表], keywords: [稽查大队长审核意见, 证件管理员核实情况], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 稽查大队长签名, type: enum, allowed: [有, 无], desc: 稽查大队长审核意见→负责人 输出 有/无} + - {name: 证件管理员签名, type: enum, allowed: [有, 无], desc: 证件管理员核实情况意见反馈→证件管理员 输出 有/无} + - {name: 卷烟营销负责人签名, type: enum, allowed: [有, 无], desc: 卷烟营销部门根据反馈情况确认→负责人 输出 有/无} + - {name: 稽查员签名, type: enum, allowed: [有, 无], desc: 稽查员实地核查情况处理确认→核查人 输出 有/无} + +- id: 职权办理审批表 + name: 职权办理审批表 + required: conditional + required_if: 卷宗封面.申请类型 contains "收回" + classifier: {title_patterns: [职权办理审批表, 依职权办理审批表], keywords: [承办人意见, 审核意见, 审批意见], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - {name: 承办人签名, type: enum, allowed: [有, 无], desc: 承办人意见→承办人 输出 有/无} + - {name: 审核人签名, type: enum, allowed: [有, 无], desc: 审核意见(无审核环节不填)→审核人 输出 有/无} + - {name: 法制负责人签名, type: enum, allowed: [有, 无], desc: 法制部门意见(无审核环节不填)→法制部门负责人 输出 有/无} + - {name: 审批人签名, type: enum, allowed: [有, 无], desc: 审批意见→审批人 输出 有/无} + +- id: 实地走访照片 + name: 实地走访照片 + required: conditional + required_if: 卷宗封面.申请类型 contains "收回" + classifier: {title_patterns: [实地走访照片, 走访照片], keywords: [照片], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 照片, type: verbatim, desc: 照片(有/无 或 描述)} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可类事项申请表, 营业执照, 身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 许可类事项申请表.企业名称 + - 许可类事项申请表.经营地址 + - 许可类事项申请表.经营者 + - 许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.身份证号 + - 身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 许可类事项申请表.经营地址 + - 许可类事项申请表.经营者 + - 许可类事项申请表.证件号 + - 许可类事项申请表.证件住址 + - 许可类事项申请表.企业类型 + - 许可类事项申请表.统一社会信用代码 + - 许可类事项申请表.有效期限 + - 许可类事项申请表.企业名称 + - 许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.姓名 + - 身份证明.性别 + - 身份证明.民族 + - 身份证明.住址 + - 身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [许可类事项申请表, 实地核查记录表] + stages: + - {id: '1', check: contains, field: 许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 实地核查记录表.标题 + - 实地核查记录表.核查人员签名1 + - 实地核查记录表.核查人员签名2 + - 实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证正副本] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证正副本.许可证号, 烟草专卖零售许可证正副本.副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule +- group: JZG-XK-SHW + rules: + - rule_id: JZ-XK-SHW-001 + name: 收回烟草专卖零售许可证资料完整性 + desc: 收回许可证需提供内部沟通表、情况反馈表、职权办理审批表及实地走访照片等资料,缺失则扣分。 + risk: low + score: 10 + scope: [内部沟通表, 情况反馈表, 公告, 职权办理审批表, 实地走访照片, 卷宗封面] + stages: + - id: '1' + check: required + fields: + - 内部沟通表.填报部门 + - 内部沟通表.问题许可证号 + - 内部沟通表.问题经营地址 + - 内部沟通表.问题描述 + - 内部沟通表.受理部门 + - 内部沟通表.处理结果 + - 内部沟通表.反馈填报部门 + - 内部沟通表.反馈内容 + - 情况反馈表.许可证号 + - 职权办理审批表.标题 + - 公告.标题 + - 内部沟通表.问题负责人签名 + - 内部沟通表.受理负责人签名 + - 内部沟通表.反馈负责人签名 + - 职权办理审批表.承办人签名 + - 职权办理审批表.审核人签名 + - 职权办理审批表.法制负责人签名 + - 职权办理审批表.审批人签名 + - 实地走访照片.照片 + - 情况反馈表.稽查员签名 + - 情况反馈表.卷烟营销负责人签名 + - 情况反馈表.证件管理员签名 + - 情况反馈表.稽查大队长签名 + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 AND 2) OR (NOT 2) AND 3 + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第四十五条, 《烟草专卖许可证管理办法》第五十条] + remediation: + suggestions: + - 收回许可证需提供内部沟通表、情况反馈表、职权办理审批表及实地走访照片等资料,缺失则扣分。 + type: deterministic diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.新办/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.新办/1.0/rules.yaml new file mode 100644 index 0000000..1ea73bc --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.新办/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.新办 + name: 烟草专卖零售许可证-新办办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [新办, 烟草专卖零售许可证, 新申请, 申领] + description: '烟草专卖零售许可证新办办理卷宗审核(首次申领)。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证颁发、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 申请表 + name: 申请表 + required: true + classifier: {title_patterns: [申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 身份证明 + name: 身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 受理单 + name: 受理单 + required: true + classifier: {title_patterns: [受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 实地核查记录表 + name: 实地核查记录表 + required: true + classifier: {title_patterns: [实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 许可证 + name: 许可证 + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [申请表, 委托书] + stages: + - {id: '1', check: required, field: 申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 申请表, 营业执照, 身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 申请表.企业名称 + - 申请表.经营地址 + - 申请表.经营者 + - 申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.身份证号 + - 身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 申请表.经营地址 + - 申请表.经营者 + - 申请表.证件号 + - 申请表.证件住址 + - 申请表.企业类型 + - 申请表.统一社会信用代码 + - 申请表.有效期限 + - 申请表.企业名称 + - 申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 身份证明.姓名 + - 身份证明.性别 + - 身份证明.民族 + - 身份证明.住址 + - 身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [申请表, 实地核查记录表] + stages: + - {id: '1', check: contains, field: 申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 实地核查记录表.标题 + - 实地核查记录表.核查人员签名1 + - 实地核查记录表.核查人员签名2 + - 实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可证] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [许可证.许可证号, 许可证.副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.歇业/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.歇业/1.0/rules.yaml new file mode 100644 index 0000000..0de62fc --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.歇业/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.歇业 + name: 烟草专卖零售许可证-歇业办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [歇业, 烟草专卖零售许可证, 歇业申请] + description: '烟草专卖零售许可证歇业办理卷宗审核。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证处理、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.注销/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.注销/1.0/rules.yaml new file mode 100644 index 0000000..3dc6d11 --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.注销/1.0/rules.yaml @@ -0,0 +1,451 @@ +metadata: + type_id: 行政卷宗.行政许可.注销 + name: 烟草专卖零售许可证-注销办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [注销, 烟草专卖零售许可证, 注销申请] + description: '烟草专卖零售许可证注销办理卷宗审核。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证处理、送达、归档、注销审批。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +- id: 烟草专卖许可证依职权办理审批表 + name: 烟草专卖许可证依职权办理审批表 + required: conditional + required_if: 卷宗封面.申请类型 contains "注销" + classifier: {title_patterns: [烟草专卖许可证依职权办理审批表, 依职权办理审批表], keywords: [持证人基本信息, 内部审批意见], min_score: 0.5} + extract: + - group: 持证人 + fields: + - {name: 许可证号, type: verbatim, desc: 持证人基本信息→许可证号} + - {name: 许可证有效期, type: verbatim, desc: 持证人基本信息→许可证有效期} + - group: 内部审批 + fields: + - {name: 承办人签名, type: enum, allowed: [有, 无], desc: 内部审批意见→承办人意见→承办人 输出 有/无} + - {name: 审核人签名, type: enum, allowed: [有, 无], desc: 内部审批意见→审核意见→审核人 输出 有/无} + - {name: 法制负责人签名, type: enum, allowed: [有, 无], desc: 内部审批意见→法制部门意见→法制部门负责人 输出 有/无} + - {name: 审批人签名, type: enum, allowed: [有, 无], desc: 内部审批意见→审批意见→审批人 输出 有/无} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule +- group: JZG-XK-ZX + rules: + - rule_id: JZ-XK-ZX-001 + name: 注销烟草专卖零售许可证审批表合规性 + desc: 注销许可需填写依职权办理审批表,包含许可证号、有效期及各级审批人签名,缺失则扣分。 + risk: medium + score: 5 + scope: [卷宗封面, 烟草专卖许可证依职权办理审批表] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销]} + - id: '2' + check: required + fields: + - 烟草专卖许可证依职权办理审批表.许可证号 + - 烟草专卖许可证依职权办理审批表.许可证有效期 + - 烟草专卖许可证依职权办理审批表.承办人签名 + - 烟草专卖许可证依职权办理审批表.审核人签名 + - 烟草专卖许可证依职权办理审批表.法制负责人签名 + - 烟草专卖许可证依职权办理审批表.审批人签名 + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 AND 2) OR (NOT 1) AND 3 + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第四十八条] + type: deterministic diff --git a/leaudit-oss-yaml-files/行政卷宗.行政许可.补办/1.0/rules.yaml b/leaudit-oss-yaml-files/行政卷宗.行政许可.补办/1.0/rules.yaml new file mode 100644 index 0000000..15f1bef --- /dev/null +++ b/leaudit-oss-yaml-files/行政卷宗.行政许可.补办/1.0/rules.yaml @@ -0,0 +1,410 @@ +metadata: + type_id: 行政卷宗.行政许可.补办 + name: 烟草专卖零售许可证-补办办理 + version: '1.0' + last_updated: '2026-04-18' + parent: 行政卷宗.行政许可 + inherits_from: [base.common, base.administrative_case] + classification_keywords: [补办, 烟草专卖零售许可证, 补办申请, 遗失] + description: '烟草专卖零售许可证补办办理卷宗审核(许可证遗失后补发)。 + + 覆盖:申请材料、受理、实地核查、审批决定、许可证颁发、送达、归档。 + + ' +sub_documents: + +- id: 卷宗封面 + name: 卷宗封面 + required: true + classifier: {title_patterns: [卷宗封面], keywords: [办理类型, 依申请办理, 行政决定], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 办理类型→依申请办理→申请类型} + - {name: 行政决定, type: verbatim, desc: 办理类型→依申请办理→行政决定} + - {name: 行政决定日期, type: date, desc: 办理类型→依申请办理→行政决定作出日期} + +- id: 烟草专卖零售许可证许可类事项申请表 + name: 烟草专卖零售许可证许可类事项申请表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证许可类事项申请表, 许可类事项申请表], keywords: [申请事项基本信息, 申请人基本信息], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请类型, type: verbatim, desc: 申请类型} + - {name: 联系人, type: verbatim, desc: 申请事项基本信息→联系人} + - {name: 委托代理人, type: verbatim, desc: 申请事项基本信息→委托代理人} + - group: 申请人 + fields: + - {name: 企业名称, type: verbatim, desc: 申请人基本信息→企业名称/个体工商户字号} + - {name: 企业类型, type: verbatim, desc: 申请人基本信息→企业类型} + - {name: 群体类型, type: verbatim, desc: 申请人基本信息→群体类型} + - {name: 经营者, type: verbatim, desc: 申请人基本信息→经营者/法定代表人(负责人)} + - {name: 证件号, type: verbatim, desc: 申请人基本信息→证件类型及号码} + - {name: 证件住址, type: verbatim, desc: 申请人基本信息→证件登记住址} + - {name: 经营地址, type: verbatim, desc: 申请人基本信息→经营地址} + - {name: 有效期限, type: verbatim, desc: 申请人基本信息→有效期限} + - {name: 统一社会信用代码, type: uscc, desc: 申请人基本信息→统一社会信用代码/注册号} + +- id: 委托书 + name: 授权委托书 + required: conditional + required_if: 烟草专卖零售许可证许可类事项申请表.委托代理人 != null + classifier: {title_patterns: [委托书, 授权委托书], keywords: [兹委托, 被授权委托人], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 被授权委托人, type: verbatim, desc: 被授权委托人(乙方)} + +- id: 营业执照 + name: 营业执照 + required: true + classifier: {title_patterns: [营业执照], keywords: [统一社会信用代码, 营业执照, 经营场所], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 名称, type: verbatim, desc: 名称} + - {name: 类型, type: verbatim, desc: 类型} + - {name: 经营者, type: verbatim, desc: 经营者} + - {name: 经营场所, type: verbatim, desc: 经营场所} + - {name: 注册日期, type: date, desc: 注册日期} + - {name: 统一社会信用代码, type: uscc, desc: 统一社会信用代码/注册号} + +- id: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + name: 个体工商户经营者、法定代表人或其他组织负责人的身份证明 + required: true + classifier: {title_patterns: [身份证, 居民身份证], keywords: [中华人民共和国居民身份证, 公民身份号码], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 姓名, type: verbatim, desc: 姓名} + - {name: 性别, type: enum, allowed: [男, 女], desc: 性别} + - {name: 民族, type: verbatim, desc: 民族} + - {name: 住址, type: verbatim, desc: 住址} + - {name: 身份证号, type: chinese-id, desc: 公民身份号码} + +- id: 烟草专卖零售许可证受理单 + name: 烟草专卖零售许可证受理单 + required: true + classifier: {title_patterns: [烟草专卖零售许可证受理单, 受理单], keywords: [签收时间, 承诺办结时限], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 签收时间, type: date, desc: 签收时间} + - {name: 承诺办结时限, type: verbatim, desc: 说明→承诺办结时限} + +- id: 烟草专卖零售许可证实地核查记录表 + name: 烟草专卖零售许可证实地核查记录表 + required: true + classifier: {title_patterns: [烟草专卖零售许可证实地核查记录表, 实地核查记录表], keywords: [核查人员, 被核查方], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + - group: 签名 + fields: + - {name: 核查人员签名1, type: enum, allowed: [有, 无], desc: 核查人员→签名1 输出 有/无} + - {name: 核查人员签名2, type: enum, allowed: [有, 无], desc: 核查人员→签名2 输出 有/无} + - {name: 被核查方签名, type: enum, allowed: [有, 无], desc: 被核查方→签名 输出 有/无} + +- id: 许可决定书 + name: 许可决定书 + required: true + classifier: {title_patterns: [许可决定书, 准予许可决定书], keywords: [决定如下, 落款], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 申请日期, type: date, desc: 申请日期} + - {name: 正文日期, type: date, desc: 正文→日期} + - {name: 落款日期, type: date, desc: 落款→日期} + +- id: 送达回证 + name: 送达回证 + required: true + classifier: {title_patterns: [送达回证], keywords: [送达日期, 送达地点, 文书送达方式], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 受送达人, type: verbatim, desc: 受送达人} + - {name: 送达方式, type: verbatim, desc: 文书送达方式} + - {name: 送达文书名称, type: verbatim, desc: 送达内容→送达文书名称} + - {name: 送达文书编号, type: verbatim, desc: 送达内容→送达文书编号} + - {name: 送达地点, type: verbatim, desc: 送达地点} + - {name: 送达日期, type: date, desc: 送达日期} + - group: 签收 + fields: + - {name: 收件人签名, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→签名 输出 有/无} + - {name: 收件人盖章, type: enum, allowed: [有, 无], desc: 受送达人(签字或盖章)→盖章 输出 有/无} + - group: 送达人 + fields: + - {name: 送达人签名1, type: enum, allowed: [有, 无], desc: 送达人→签名1 输出 有/无} + - {name: 送达人签名2, type: enum, allowed: [有, 无], desc: 送达人→签名2 输出 有/无} + +- id: 挂号信回执 + name: 挂号信回执 + required: true + classifier: {title_patterns: [挂号信回执], keywords: [挂号信], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 正文, type: string, desc: 挂号信回执正文} + +- id: 公告 + name: 公告 + required: true + classifier: {title_patterns: [公告], keywords: [公告编号], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 编号, type: verbatim, desc: 公告编号} + +- id: 延长审批期限批准书 + name: 延长审批期限批准书 + required: true + classifier: {title_patterns: [延长审批期限批准书], keywords: [延长审批, 批准], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 标题, type: verbatim, desc: 标题} + +- id: 烟草专卖零售许可证(正、副本) + name: 烟草专卖零售许可证(正、副本) + required: true + classifier: {title_patterns: [烟草专卖零售许可证], keywords: [许可证号, 副本], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 许可证号, type: verbatim, desc: 许可证号} + - {name: 副本, type: verbatim, desc: 副本标识(是否为副本)} + +- id: 卷内备考表 + name: 卷内备考表 + required: true + classifier: {title_patterns: [卷内备考表], keywords: [立卷时间], min_score: 0.5} + extract: + - group: 基本信息 + fields: + - {name: 立卷时间, type: date, desc: 立卷时间} + +rules: +- group: JZG-XK-SQ + rules: + - rule_id: JZ-XK-SQ-001 + name: 代理人授权委托书文件校验 + desc: 若未找到授权委托书,则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 委托书] + stages: + - {id: '1', check: required, field: 烟草专卖零售许可证许可类事项申请表.委托代理人} + - {id: '2', check: required, field: 委托书.被授权委托人} + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.联系人} + logic: (3 AND (NOT 1)) OR (1 AND 2) + messages: {pass: 找到对应的授权委托书。, fail: 未出具授权委托书,请核对。} + references_laws: [《烟草专卖许可证管理办法》第九条, 《烟草专卖许可证管理办法》第四十一条] + type: deterministic + - rule_id: JZ-XK-SQ-002 + name: 申请人主体资格材料完整性 + desc: 若对应的资格材料文件不存在,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证许可类事项申请表, 营业执照, 个体工商户经营者、法定代表人或其他组织负责人的身份证明] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, any_of: [补办, 歇业, 停业, 恢复营业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 营业执照.名称 + - {id: '3', check: contains, field: 卷宗封面.申请类型, any_of: [新办, 延续, 变更]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证许可类事项申请表.经营地址 + - 烟草专卖零售许可证许可类事项申请表.经营者 + - 烟草专卖零售许可证许可类事项申请表.证件号 + - 烟草专卖零售许可证许可类事项申请表.证件住址 + - 烟草专卖零售许可证许可类事项申请表.企业类型 + - 烟草专卖零售许可证许可类事项申请表.统一社会信用代码 + - 烟草专卖零售许可证许可类事项申请表.有效期限 + - 烟草专卖零售许可证许可类事项申请表.企业名称 + - 烟草专卖零售许可证许可类事项申请表.群体类型 + - 营业执照.名称 + - 营业执照.统一社会信用代码 + - 营业执照.注册日期 + - 营业执照.类型 + - 营业执照.经营场所 + - 营业执照.经营者 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.姓名 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.性别 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.民族 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.住址 + - 个体工商户经营者、法定代表人或其他组织负责人的身份证明.身份证号 + - {id: '5', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (3 AND 4) OR (NOT (1 AND 3))) AND 5 + messages: {pass: 申请人主体资格材料齐全,请进一步检查准确性。, fail: 申请人主体资格材料不齐全,请核对。} + references_laws: [《烟草专卖许可证管理办法》第十三条, 《烟草专卖许可证管理办法》第二十一条] + type: deterministic +- group: JZG-XK-SL + rules: + - rule_id: JZ-XK-SL-001 + name: 受理通知书日期记载准确性 + desc: 若签收时间处没有完整手写年月日,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [注销, 收回]} + - {id: '2', check: required, field: 烟草专卖零售许可证受理单.签收时间} + logic: ((NOT 1) AND 2) OR 1 + messages: {pass: 受理通知书日期记载完整。, fail: 受理通知书日期记载不准确,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十一条, 《烟草专卖许可证管理办法》第二十二条] + type: deterministic +- group: JZG-XK-HC + rules: + - rule_id: JZ-XK-HC-001 + name: 实地核查执法人员人数合规性 + desc: 新办、延续、变更、恢复营业、歇业类许可需实地核查,核查记录表应至少有两名执法人员签名及被核查方签名,缺少则扣分。 + risk: medium + score: 10 + scope: [烟草专卖零售许可证许可类事项申请表, 烟草专卖零售许可证实地核查记录表] + stages: + - {id: '1', check: contains, field: 烟草专卖零售许可证许可类事项申请表.申请类型, any_of: [新办, 延续, 变更, 恢复营业, 歇业]} + - id: '2' + check: required + fields: + - 烟草专卖零售许可证实地核查记录表.标题 + - 烟草专卖零售许可证实地核查记录表.核查人员签名1 + - 烟草专卖零售许可证实地核查记录表.核查人员签名2 + - 烟草专卖零售许可证实地核查记录表.被核查方签名 + - {id: '3', check: required, field: 烟草专卖零售许可证许可类事项申请表.申请类型} + logic: 1 OR ((NOT 1) AND 2) AND 3 + messages: {pass: 无需实地核查或实地核查执法人员人数符合要求。, fail: 缺少实地核查记录表或实地核查执法人员人数不足,应至少有两名执法人员,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: deterministic +- group: JZG-XK-SP + rules: + - rule_id: JZ-XK-SP-001 + name: 烟草专卖许可证颁发时效合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 许可决定书, 送达回证] + stages: + - id: '1' + check: ai + prompt: '请判断 {{送达回证.送达日期}} 是否晚于 {{许可决定书.落款日期}},且差值小于10天; + + 若早于或差值大于10天为不符合。 + + ' + - {id: '2', check: contains, field: 卷宗封面.申请类型, value: 收回} + - {id: '3', check: required, field: 卷宗封面.申请类型} + logic: (1 OR 2) AND 3 + messages: {pass: 烟草专卖许可证已在规定时效内颁发。, fail: 烟草专卖许可证颁发超出规定时效,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule + - rule_id: JZ-XK-SP-002 + name: 延长审批期限告知文件校验 + desc: 若未找到延长审批期限告知书,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证受理单, 延长审批期限批准书, 许可决定书] + stages: + - id: '1' + check: ai + prompt: '请判断 {{许可决定书.落款日期}} 减去 {{许可决定书.正文日期}}, + + 是否在 {{烟草专卖零售许可证受理单.承诺办结时限}} 的工作日数内, + + 差值超过承诺办结时间为不符合。 + + ' + - {id: '2', check: required, field: 延长审批期限批准书.标题} + - {id: '3', check: contains, field: 卷宗封面.申请类型, none_of: [注销, 收回]} + - id: '4' + check: required + fields: + - 烟草专卖零售许可证受理单.承诺办结时限 + - 卷宗封面.申请类型 + - 卷宗封面.行政决定 + - 许可决定书.落款日期 + - 许可决定书.正文日期 + logic: 1 OR ((NOT 1) AND 2) OR (3 AND 4) + messages: {pass: 文档检查通过,符合规范要求。, fail: 文档存在以下问题,请修改后重新提交。} + references_laws: [《烟草专卖许可证管理办法》第二十三条] + type: ai_rule +- group: JZG-XK-XZ + rules: + - rule_id: JZ-XK-XZ-001 + name: 烟草专卖许可证颁发合规性 + desc: 若所有许可证图片中,均未找到"副本"字样,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 烟草专卖零售许可证(正、副本)] + stages: + - {id: '1', check: contains, field: 卷宗封面.申请类型, all_of: [新办, 补办, 延续, 变更]} + - {id: '2', check: required, fields: [烟草专卖零售许可证(正、副本).许可证号, 烟草专卖零售许可证(正、副本).副本]} + - {id: '3', check: required, fields: [卷宗封面.申请类型, 卷宗封面.行政决定]} + logic: ((1 AND 2) OR (NOT 1)) AND 3 + messages: {pass: 已颁发加盖印章的烟草专卖许可证正副本。, fail: 未全部颁发加盖印章的烟草专卖许可证,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第二十四条] + type: deterministic +- group: JZG-XK-SD + rules: + - rule_id: JZ-XK-SD-001 + name: 送达回证信息完整性 + desc: 若基础字段有漏填,则扣分。 若受送达人处无签名或盖章,则扣分。 + risk: medium + score: 10 + scope: [送达回证, 挂号信回执, 公告] + stages: + - id: '1' + check: required + fields: + - 送达回证.受送达人 + - 送达回证.送达文书名称 + - 送达回证.送达文书编号 + - 送达回证.送达日期 + - 送达回证.送达地点 + - 送达回证.送达人签名1 + - 送达回证.送达人签名2 + - {id: '2', check: contains, field: 送达回证.送达方式, any_of: [直接送达, 代收送达, 留置送达]} + - {id: '3', check: required, fields: [送达回证.收件人签名, 送达回证.收件人盖章], logic: or} + - {id: '4', check: contains, field: 送达回证.送达方式, value: 公告送达} + - {id: '5', check: required, field: 公告.编号} + - {id: '6', check: contains, field: 送达回证.送达方式, value: 挂号信} + - {id: '7', check: required, field: 挂号信回执.正文} + logic: 1 AND (2 AND 3) OR (4 AND 5) OR (6 AND 7) + messages: {pass: 送达回证填写完整。, fail: 送达回证填写不完整,请核对。} + references_laws: [《烟草专卖许可证管理办法》第二十三条, 《烟草专卖许可证管理办法》第六十一条] + type: deterministic +- group: JZG-XK-GD + rules: + - rule_id: JZ-XK-GD-001 + name: 行政许可案件归档合规性 + desc: 若两个时间之间的差值大于60天,则扣分。 + risk: medium + score: 10 + scope: [卷宗封面, 卷内备考表] + stages: + - id: '1' + check: ai + prompt: '请判断 {{卷内备考表.立卷时间}} 是否晚于 {{卷宗封面.行政决定日期}}, + + 且差值小于60天;早于或差值超过60天为不符合。 + + ' + messages: {pass: 行政许可案件已及时归档并制作案卷。, fail: 行政许可案件未及时归档,请核对。} + references_laws: [《烟草专卖许可证管理办法》第三十八条] + type: ai_rule diff --git a/legal-platform-frontend b/legal-platform-frontend index dc81598..fb2fb0b 160000 --- a/legal-platform-frontend +++ b/legal-platform-frontend @@ -1 +1 @@ -Subproject commit dc8159837b1911c29f8a94b5dc5861862c7ca174 +Subproject commit fb2fb0b76aae8d302bef370d1fb83c324d056a75 diff --git a/pyproject.toml b/pyproject.toml index fbc3d5b..1fce0dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,11 @@ dependencies = [ "leaudit", ] +[project.optional-dependencies] +test = [ + "pytest>=8.3.0", +] + [tool.setuptools.packages.find] include = [ "fastapi_common*", diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3bc0a99 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +markers = + release: release-grade black-box acceptance tests against a running backend + diff --git a/scripts/import_oss_yaml_rules.py b/scripts/import_oss_yaml_rules.py new file mode 100644 index 0000000..bef7fa6 --- /dev/null +++ b/scripts/import_oss_yaml_rules.py @@ -0,0 +1,591 @@ +"""Import local rule YAML files into OSS and rule version tables. + +This script is intentionally conservative: +- uploads every local rules.yaml to the canonical OSS key; +- upserts matching rule version rows by tenant/rule_type/version_no; +- switches each rule set to the highest local version for that rule_type; +- does not delete old OSS objects or historical DB versions. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import re +from datetime import datetime +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils +from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleValidator +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl + + +@dataclass(frozen=True) +class LocalRule: + rule_type: str + version_no: str + rule_name: str + description: str | None + path: Path + yaml_text: str + sha256: str + size: int + + +def _version_sort_key(value: str) -> tuple[int, tuple[int | str, ...]]: + raw = str(value or "").strip() + normalized = raw[1:] if raw.lower().startswith("v") else raw + parts: list[int | str] = [] + for part in re.split(r"([0-9]+)", normalized): + if not part: + continue + parts.append(int(part) if part.isdigit() else part) + return (0 if raw.lower().startswith("v") else 1, tuple(parts)) + + +def _domain_type(rule_type: str) -> str: + if rule_type.startswith("contract."): + return "contract" + if rule_type.startswith("govdoc."): + return "govdoc" + if rule_type.startswith("行政卷宗."): + return "case_file" + return "custom" + + +def _legacy_region_for_tenant(tenant_code: str) -> str: + normalized = str(tenant_code or "").strip().upper() + return normalized or "PUBLIC" + + +def load_local_rules(root: Path) -> list[LocalRule]: + validator = RuleValidator() + items: list[LocalRule] = [] + failures: list[str] = [] + for path in sorted(root.glob("*/*/rules.yaml")): + yaml_text = path.read_text(encoding="utf-8") + result = validator.ValidateYaml(yaml_text) + if not result.valid: + failures.append(f"{path}: {'; '.join(result.errors or [])}") + continue + data = yaml.safe_load(yaml_text) + metadata = data.get("metadata") or {} + rule_type = str(metadata.get("type_id") or "").strip() + version_no = str(metadata.get("version") or "").strip() + if not rule_type or not version_no: + failures.append(f"{path}: metadata.type_id/version is required") + continue + if version_no != path.parent.name: + failures.append(f"{path}: metadata.version={version_no!r} does not match directory {path.parent.name!r}") + continue + items.append( + LocalRule( + rule_type=rule_type, + version_no=version_no, + rule_name=str(metadata.get("name") or rule_type), + description=metadata.get("description"), + path=path, + yaml_text=yaml_text, + sha256=hashlib.sha256(yaml_text.encode("utf-8")).hexdigest(), + size=len(yaml_text.encode("utf-8")), + ) + ) + if failures: + raise RuntimeError("Local YAML validation failed:\n" + "\n".join(failures)) + return items + + +async def _load_rule_sets(session) -> list[dict[str, Any]]: + rows = ( + await session.execute( + text( + """ + SELECT + rs.id, + rs.rule_type, + rs.rule_name, + rs.current_version_id, + COALESCE(NULLIF(BTRIM(rs.tenant_code), ''), 'PUBLIC') AS tenant_code, + COALESCE(NULLIF(BTRIM(rs.scope_type), ''), 'PUBLIC') AS scope_type + FROM leaudit_rule_sets rs + WHERE rs.deleted_at IS NULL + ORDER BY rs.tenant_code ASC, rs.rule_type ASC, rs.id ASC + """ + ) + ) + ).mappings().all() + return [dict(row) for row in rows] + + +async def _tenant_name(session, tenant_code: str) -> str | None: + row = ( + await session.execute( + text( + """ + SELECT tenant_name + FROM sys_tenants + WHERE tenant_code = :tenant_code + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"tenant_code": tenant_code}, + ) + ).mappings().first() + return str(row["tenant_name"]) if row else None + + +async def _ensure_rule_set(session, local: LocalRule, tenant_code: str) -> dict[str, Any]: + row = ( + await session.execute( + text( + """ + SELECT id, rule_type, rule_name, current_version_id, tenant_code, scope_type + FROM leaudit_rule_sets + WHERE rule_type = :rule_type + AND tenant_code = :tenant_code + AND deleted_at IS NULL + ORDER BY id ASC + LIMIT 1 + """ + ), + {"rule_type": local.rule_type, "tenant_code": tenant_code}, + ) + ).mappings().first() + if row: + await session.execute( + text( + """ + UPDATE leaudit_rule_sets + SET rule_name = :rule_name, + domain_type = :domain_type, + description = :description, + updated_at = NOW() + WHERE id = :id + """ + ), + { + "id": int(row["id"]), + "rule_name": local.rule_name, + "domain_type": _domain_type(local.rule_type), + "description": local.description, + }, + ) + return dict(row) + + tenant_name = await _tenant_name(session, tenant_code) + scope_type = "PUBLIC" if tenant_code == "PUBLIC" else "TENANT" + created = ( + await session.execute( + text( + """ + INSERT INTO leaudit_rule_sets ( + rule_type, rule_name, domain_type, description, entry_module, + current_version_id, status, is_builtin, owner_user_id, + created_at, updated_at, deleted_at, region, + tenant_code, scope_type, source_rule_set_id, tenant_name_snapshot + ) VALUES ( + :rule_type, :rule_name, :domain_type, :description, NULL, + NULL, 'draft', FALSE, NULL, + NOW(), NOW(), NULL, :region, + :tenant_code, :scope_type, NULL, :tenant_name + ) + RETURNING id, rule_type, rule_name, current_version_id, tenant_code, scope_type + """ + ), + { + "rule_type": local.rule_type, + "rule_name": local.rule_name, + "domain_type": _domain_type(local.rule_type), + "description": local.description, + "region": _legacy_region_for_tenant(tenant_code), + "tenant_code": tenant_code, + "scope_type": scope_type, + "tenant_name": tenant_name, + }, + ) + ).mappings().first() + return dict(created) + + +async def _upsert_version(session, local: LocalRule, rule_set_id: int, tenant_code: str, oss_url: str) -> tuple[int, str]: + existing = ( + await session.execute( + text( + """ + SELECT id, file_sha256, status, version_seq + FROM leaudit_rule_versions + WHERE rule_set_id = :rule_set_id + AND version_no = :version_no + AND deleted_at IS NULL + ORDER BY id ASC + LIMIT 1 + """ + ), + {"rule_set_id": rule_set_id, "version_no": local.version_no}, + ) + ).mappings().first() + if existing: + await session.execute( + text( + """ + UPDATE leaudit_rule_versions + SET source_type = 'oss_yaml', + dsl_format = 'yaml', + oss_url = :oss_url, + file_sha256 = :file_sha256, + file_size = :file_size, + metadata_type_id = :metadata_type_id, + metadata_name = :metadata_name, + metadata_version = :metadata_version, + tenant_code_snapshot = :tenant_code, + scope_type_snapshot = :scope_type, + updated_at = NOW() + WHERE id = :id + """ + ), + { + "id": int(existing["id"]), + "oss_url": oss_url, + "file_sha256": local.sha256, + "file_size": local.size, + "metadata_type_id": local.rule_type, + "metadata_name": local.rule_name, + "metadata_version": local.version_no, + "tenant_code": tenant_code, + "scope_type": "PUBLIC" if tenant_code == "PUBLIC" else "TENANT", + }, + ) + action = "updated" if existing["file_sha256"] != local.sha256 else "refreshed" + return int(existing["id"]), action + + seq_row = ( + await session.execute( + text( + """ + SELECT COALESCE(MAX(version_seq), 0) + 1 AS next_seq + FROM leaudit_rule_versions + WHERE rule_set_id = :rule_set_id + """ + ), + {"rule_set_id": rule_set_id}, + ) + ).mappings().first() + created = ( + await session.execute( + text( + """ + INSERT INTO leaudit_rule_versions ( + rule_set_id, version_no, version_seq, status, source_type, dsl_format, + oss_url, file_sha256, file_size, local_cache_path, + metadata_type_id, metadata_name, metadata_version, change_note, + editor_user_id, publisher_user_id, published_at, + created_at, updated_at, deleted_at, + tenant_code_snapshot, scope_type_snapshot, source_version_id + ) VALUES ( + :rule_set_id, :version_no, :version_seq, 'draft', 'oss_yaml', 'yaml', + :oss_url, :file_sha256, :file_size, NULL, + :metadata_type_id, :metadata_name, :metadata_version, :change_note, + NULL, NULL, NULL, + NOW(), NOW(), NULL, + :tenant_code, :scope_type, NULL + ) + RETURNING id + """ + ), + { + "rule_set_id": rule_set_id, + "version_no": local.version_no, + "version_seq": int(seq_row["next_seq"] or 1), + "oss_url": oss_url, + "file_sha256": local.sha256, + "file_size": local.size, + "metadata_type_id": local.rule_type, + "metadata_name": local.rule_name, + "metadata_version": local.version_no, + "change_note": "从 leaudit-oss-yaml-files 全量导入", + "tenant_code": tenant_code, + "scope_type": "PUBLIC" if tenant_code == "PUBLIC" else "TENANT", + }, + ) + ).mappings().first() + return int(created["id"]), "created" + + +async def import_rules(root: Path, dry_run: bool) -> None: + locals_by_type: dict[str, list[LocalRule]] = {} + for local in load_local_rules(root): + locals_by_type.setdefault(local.rule_type, []).append(local) + for rules in locals_by_type.values(): + rules.sort(key=lambda item: _version_sort_key(item.version_no)) + + oss = OssServiceImpl() + async with GetAsyncSession() as session: + rule_sets = await _load_rule_sets(session) + tenant_codes = sorted({str(row["tenant_code"]).strip().upper() for row in rule_sets if row.get("tenant_code")}) + tenant_codes = [code for code in tenant_codes if code != "PROVINCIAL"] + if "PUBLIC" not in tenant_codes: + tenant_codes.insert(0, "PUBLIC") + + print(f"local_rule_files={sum(len(v) for v in locals_by_type.values())}") + print(f"local_rule_types={len(locals_by_type)}") + print(f"target_tenants={','.join(tenant_codes)}") + + stats = {"uploaded": 0, "created_versions": 0, "updated_versions": 0, "refreshed_versions": 0, "published_sets": 0} + for tenant_code in tenant_codes: + for rule_type, versions in sorted(locals_by_type.items()): + current_version_id: int | None = None + rule_set_id: int | None = None + for local in versions: + object_key = OssPathUtils.BuildRuleYamlKey(local.rule_type, local.version_no) + if dry_run: + oss_url = object_key + else: + oss_url = await oss.UploadText( + ObjectKey=object_key, + Content=local.yaml_text, + ContentType="application/x-yaml; charset=utf-8", + ) + stats["uploaded"] += 1 + rule_set = await _ensure_rule_set(session, local, tenant_code) + rule_set_id = int(rule_set["id"]) + version_id, action = await _upsert_version(session, local, rule_set_id, tenant_code, oss_url) + if action == "created": + stats["created_versions"] += 1 + elif action == "updated": + stats["updated_versions"] += 1 + else: + stats["refreshed_versions"] += 1 + if local is versions[-1]: + current_version_id = version_id + + if rule_set_id is not None and current_version_id is not None: + await session.execute( + text( + """ + UPDATE leaudit_rule_versions + SET status = CASE + WHEN id = :current_version_id THEN 'published' + WHEN status = 'published' THEN 'deprecated' + ELSE status + END, + published_at = CASE + WHEN id = :current_version_id AND published_at IS NULL THEN NOW() + ELSE published_at + END, + updated_at = NOW() + WHERE rule_set_id = :rule_set_id + AND deleted_at IS NULL + """ + ), + {"rule_set_id": rule_set_id, "current_version_id": current_version_id}, + ) + await session.execute( + text( + """ + UPDATE leaudit_rule_sets + SET current_version_id = :current_version_id, + status = 'active', + updated_at = NOW() + WHERE id = :rule_set_id + """ + ), + {"rule_set_id": rule_set_id, "current_version_id": current_version_id}, + ) + stats["published_sets"] += 1 + + if dry_run: + await session.rollback() + print("dry_run=true rolled_back=true") + else: + await session.commit() + print("dry_run=false committed=true") + for key, value in stats.items(): + print(f"{key}={value}") + + +async def _backup_rule_domain(session) -> Path: + backup_dir = Path("docs/规则编辑/backups") + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = backup_dir / f"rule-domain-before-reset-{stamp}.sql" + rule_sets = ( + await session.execute( + text( + """ + SELECT * + FROM leaudit_rule_sets + ORDER BY id ASC + """ + ) + ) + ).mappings().all() + rule_versions = ( + await session.execute( + text( + """ + SELECT * + FROM leaudit_rule_versions + ORDER BY id ASC + """ + ) + ) + ).mappings().all() + lines = [ + "-- Rule domain backup before reset", + f"-- generated_at: {datetime.now().isoformat(timespec='seconds')}", + f"-- rule_sets: {len(rule_sets)}", + f"-- rule_versions: {len(rule_versions)}", + "", + "-- This file is an audit snapshot, not an automatic restore script.", + "-- Use the rows below to inspect pre-reset IDs, current_version_id, oss_url and sha.", + "", + ] + for row in rule_sets: + lines.append( + "-- rule_set " + + " ".join( + [ + f"id={row.get('id')}", + f"tenant_code={row.get('tenant_code')}", + f"rule_type={row.get('rule_type')}", + f"current_version_id={row.get('current_version_id')}", + f"status={row.get('status')}", + f"deleted_at={row.get('deleted_at')}", + ] + ) + ) + lines.append("") + for row in rule_versions: + lines.append( + "-- rule_version " + + " ".join( + [ + f"id={row.get('id')}", + f"rule_set_id={row.get('rule_set_id')}", + f"version_no={row.get('version_no')}", + f"status={row.get('status')}", + f"oss_url={row.get('oss_url')}", + f"sha={row.get('file_sha256')}", + f"deleted_at={row.get('deleted_at')}", + ] + ) + ) + backup_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return backup_path + + +async def reset_and_import_rules(root: Path, *, dry_run: bool, prune_oss: bool) -> None: + local_rules = load_local_rules(root) + canonical_keys = { + OssPathUtils.BuildRuleYamlKey(local.rule_type, local.version_no) + for local in local_rules + } + async with GetAsyncSession() as session: + backup_path = await _backup_rule_domain(session) + referenced_keys = { + str(row["oss_url"]) + for row in ( + await session.execute( + text( + """ + SELECT DISTINCT rule_source_oss_url AS oss_url + FROM leaudit_audit_runs + WHERE COALESCE(rule_source_oss_url, '') <> '' + """ + ) + ) + ).mappings().all() + } + existing_version_keys = { + str(row["oss_url"]) + for row in ( + await session.execute( + text( + """ + SELECT DISTINCT oss_url + FROM leaudit_rule_versions + WHERE COALESCE(oss_url, '') <> '' + """ + ) + ) + ).mappings().all() + } + deletable_oss_keys = sorted(existing_version_keys - canonical_keys - referenced_keys) + print(f"backup_path={backup_path}") + print(f"canonical_keys={len(canonical_keys)}") + print(f"existing_version_keys={len(existing_version_keys)}") + print(f"audit_referenced_keys={len(referenced_keys)}") + print(f"deletable_oss_keys={len(deletable_oss_keys)}") + for key in deletable_oss_keys[:50]: + print(f"deletable_oss_key={key}") + + if dry_run: + await session.rollback() + print("reset_dry_run=true rolled_back=true") + return + + await session.execute( + text( + """ + UPDATE leaudit_rule_sets + SET current_version_id = NULL, + status = 'draft', + updated_at = NOW() + WHERE deleted_at IS NULL + """ + ) + ) + await session.execute( + text( + """ + UPDATE leaudit_rule_versions + SET deleted_at = NOW(), + status = CASE WHEN status = 'published' THEN 'deprecated' ELSE status END, + updated_at = NOW() + WHERE deleted_at IS NULL + """ + ) + ) + await session.commit() + + if prune_oss and deletable_oss_keys: + oss = OssServiceImpl() + client = oss.Client._GetMinioClient() + bucket = oss.Client.bucket + deleted = 0 + for key in deletable_oss_keys: + try: + client.remove_object(bucket, key) + deleted += 1 + except Exception as exc: + print(f"delete_oss_failed key={key} error={exc}") + print(f"oss_deleted={deleted}") + elif prune_oss: + print("oss_deleted=0") + + await import_rules(root, dry_run=False) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default="leaudit-oss-yaml-files") + parser.add_argument("--execute", action="store_true") + parser.add_argument("--reset-rule-domain", action="store_true") + parser.add_argument("--prune-oss", action="store_true") + args = parser.parse_args() + if args.reset_rule_domain: + asyncio.run(reset_and_import_rules(Path(args.root), dry_run=not args.execute, prune_oss=args.prune_oss)) + else: + asyncio.run(import_rules(Path(args.root), dry_run=not args.execute)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_rag_public_orphan_defaults_migration.sh b/scripts/run_rag_public_orphan_defaults_migration.sh new file mode 100755 index 0000000..90195a1 --- /dev/null +++ b/scripts/run_rag_public_orphan_defaults_migration.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +用法: + scripts/run_rag_public_orphan_defaults_migration.sh [连接参数] [--migrate --yes] + +连接方式二选一: + 1. 使用 DATABASE_URL: + DATABASE_URL='postgresql://user:password@host:5432/dbname' scripts/run_rag_public_orphan_defaults_migration.sh + + 2. 使用 psql 参数: + scripts/run_rag_public_orphan_defaults_migration.sh -h -U -d [-p ] + +默认行为: + 只执行迁移前检查,不修改数据库。 + +执行迁移: + 先确认 precheck 输出符合预期,再追加 --migrate --yes: + scripts/run_rag_public_orphan_defaults_migration.sh -h -U -d --migrate --yes + +环境变量: + PGPASSWORD 可用于非交互输入数据库密码。 +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PRECHECK_SQL="${REPO_ROOT}/scripts/创建sql/precheck_rag_public_orphan_defaults.sql" +MIGRATE_SQL="${REPO_ROOT}/scripts/创建sql/migrate_rag_public_orphan_defaults.sql" +LOG_DIR="${REPO_ROOT}/logs/rag-public-orphan-defaults" + +PSQL_ARGS=() +RUN_MIGRATION=false +CONFIRMED=false + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--host) + [[ $# -ge 2 ]] || { echo "缺少 $1 的值" >&2; exit 2; } + PSQL_ARGS+=("-h" "$2") + shift 2 + ;; + -U|--username) + [[ $# -ge 2 ]] || { echo "缺少 $1 的值" >&2; exit 2; } + PSQL_ARGS+=("-U" "$2") + shift 2 + ;; + -d|--dbname) + [[ $# -ge 2 ]] || { echo "缺少 $1 的值" >&2; exit 2; } + PSQL_ARGS+=("-d" "$2") + shift 2 + ;; + -p|--port) + [[ $# -ge 2 ]] || { echo "缺少 $1 的值" >&2; exit 2; } + PSQL_ARGS+=("-p" "$2") + shift 2 + ;; + --migrate) + RUN_MIGRATION=true + shift + ;; + --yes) + CONFIRMED=true + shift + ;; + --help) + usage + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + usage + exit 2 + ;; + esac +done + +if ! command -v psql >/dev/null 2>&1; then + echo "未找到 psql,请先安装 PostgreSQL client。" >&2 + exit 1 +fi + +if [[ ! -f "${PRECHECK_SQL}" ]]; then + echo "找不到预检查 SQL: ${PRECHECK_SQL}" >&2 + exit 1 +fi + +if [[ ! -f "${MIGRATE_SQL}" ]]; then + echo "找不到迁移 SQL: ${MIGRATE_SQL}" >&2 + exit 1 +fi + +if [[ -n "${DATABASE_URL:-}" && ${#PSQL_ARGS[@]} -gt 0 ]]; then + echo "请不要同时使用 DATABASE_URL 和 -h/-U/-d 参数。" >&2 + exit 2 +fi + +if [[ -z "${DATABASE_URL:-}" && ${#PSQL_ARGS[@]} -eq 0 ]]; then + echo "缺少数据库连接参数。请设置 DATABASE_URL,或传入 -h/-U/-d。" >&2 + usage + exit 2 +fi + +if [[ "${RUN_MIGRATION}" == true && "${CONFIRMED}" != true ]]; then + echo "迁移会修改数据库。请先检查 precheck 输出,确认后追加 --yes。" >&2 + exit 2 +fi + +mkdir -p "${LOG_DIR}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +PRECHECK_LOG="${LOG_DIR}/precheck-${TIMESTAMP}.log" +MIGRATE_LOG="${LOG_DIR}/migrate-${TIMESTAMP}.log" + +run_psql_file() { + local sql_file="$1" + local log_file="$2" + + if [[ -n "${DATABASE_URL:-}" ]]; then + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${sql_file}" 2>&1 | tee "${log_file}" + else + psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -f "${sql_file}" 2>&1 | tee "${log_file}" + fi +} + +echo "开始执行 RAG 公共知识库未归属默认项预检查..." +echo "预检查输出: ${PRECHECK_LOG}" +run_psql_file "${PRECHECK_SQL}" "${PRECHECK_LOG}" + +if [[ "${RUN_MIGRATION}" != true ]]; then + cat <') AS area_value, + COUNT(*) AS point_count +FROM tmp_evaluation_points_precheck +GROUP BY 1 +ORDER BY point_count DESC, area_value ASC; + +-- -------------------------------------------------------------------------- +-- 3. area 无法映射租户编码的残留清单 +-- 规则:别名表 -> 租户名 -> 共享域兼容值 +-- -------------------------------------------------------------------------- +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_points AS ( + SELECT + ep.id, + ep.code, + ep.name, + ep.area, + COALESCE( + NULLIF(BTRIM(ep.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN ep.area IS NULL OR BTRIM(ep.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(ep.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(ep.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(ep.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code + FROM tmp_evaluation_points_precheck ep + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = tn.normalized_tenant_name +) +SELECT + id, + code, + name, + area +FROM resolved_points +WHERE resolved_tenant_code IS NULL +ORDER BY id ASC; + +-- -------------------------------------------------------------------------- +-- 4. 共享域残留统计 +-- -------------------------------------------------------------------------- +SELECT + CASE + WHEN area IS NULL OR BTRIM(area) = '' THEN '' + ELSE BTRIM(area) + END AS shared_area_value, + COUNT(*) AS point_count +FROM tmp_evaluation_points_precheck +WHERE area IS NULL + OR BTRIM(area) = '' + OR LOWER(BTRIM(area)) = 'default' + OR BTRIM(area) IN ('公共', '省级', '省局') +GROUP BY 1 +ORDER BY point_count DESC, shared_area_value ASC; + +-- -------------------------------------------------------------------------- +-- 5. tenant_name 映射冲突预检 +-- -------------------------------------------------------------------------- +SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + COUNT(DISTINCT tenant_code) AS tenant_code_count, + ARRAY_AGG(DISTINCT tenant_code ORDER BY tenant_code) AS tenant_codes +FROM public.sys_tenants +WHERE deleted_at IS NULL + AND is_enabled = TRUE + AND tenant_name IS NOT NULL + AND BTRIM(tenant_name) <> '' +GROUP BY 1 +HAVING COUNT(DISTINCT tenant_code) > 1 +ORDER BY normalized_tenant_name ASC; + +-- -------------------------------------------------------------------------- +-- 6. alias 映射冲突预检 +-- -------------------------------------------------------------------------- +SELECT + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + COUNT(DISTINCT tenant_code) AS tenant_code_count, + ARRAY_AGG(DISTINCT tenant_code ORDER BY tenant_code) AS tenant_codes +FROM public.sys_tenant_aliases +WHERE deleted_at IS NULL + AND is_enabled = TRUE + AND alias_value IS NOT NULL + AND BTRIM(alias_value) <> '' +GROUP BY 1 +HAVING COUNT(DISTINCT tenant_code) > 1 +ORDER BY normalized_alias_value ASC; + +-- -------------------------------------------------------------------------- +-- 7. 编码唯一性现状预检 +-- 注意:当前 service 仍按全局唯一校验 code,不是按 tenant_code + code 校验 +-- -------------------------------------------------------------------------- +SELECT + LOWER(BTRIM(code)) AS normalized_code, + COUNT(*) AS duplicate_count, + ARRAY_AGG(id ORDER BY id) AS point_ids +FROM tmp_evaluation_points_precheck +WHERE code IS NOT NULL + AND BTRIM(code) <> '' +GROUP BY 1 +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, normalized_code ASC; + +-- -------------------------------------------------------------------------- +-- 8. 若后续想把 code 改成“按租户唯一”,先看跨租户重复情况 +-- 这里用“预估 tenant_code”做模拟,不代表当前表已有 tenant_code +-- -------------------------------------------------------------------------- +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_points AS ( + SELECT + ep.id, + LOWER(BTRIM(ep.code)) AS normalized_code, + COALESCE( + NULLIF(BTRIM(ep.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN ep.area IS NULL OR BTRIM(ep.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(ep.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(ep.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(ep.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code + FROM tmp_evaluation_points_precheck ep + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = tn.normalized_tenant_name + WHERE ep.code IS NOT NULL + AND BTRIM(ep.code) <> '' +) +SELECT + normalized_code, + COUNT(*) AS duplicate_count, + COUNT(DISTINCT resolved_tenant_code) AS tenant_count, + ARRAY_AGG( + CONCAT(id, ':', COALESCE(resolved_tenant_code, '')) + ORDER BY id + ) AS point_ids_with_tenant +FROM resolved_points +GROUP BY normalized_code +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, normalized_code ASC; + +-- -------------------------------------------------------------------------- +-- 9. 执行后验收时建议对照的基线统计 +-- -------------------------------------------------------------------------- +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS missing_tenant_code, + COUNT(*) FILTER (WHERE tenant_name IS NULL OR BTRIM(tenant_name) = '') AS missing_tenant_name +FROM tmp_evaluation_points_precheck; diff --git a/scripts/创建sql/precheck_rag_public_orphan_defaults.sql b/scripts/创建sql/precheck_rag_public_orphan_defaults.sql new file mode 100644 index 0000000..8aba326 --- /dev/null +++ b/scripts/创建sql/precheck_rag_public_orphan_defaults.sql @@ -0,0 +1,134 @@ +-- ============================================================================ +-- RAG 公共知识库未归属默认项迁移前检查 +-- +-- 用途: +-- 检查历史 tenant_code 为空,area 为空/default/公共,且 is_public=true 的 RAG 知识库/应用。 +-- 这些记录在页面显示为“未分配地区”,且如果 is_default=true,会导致无法删除。 +-- +-- 执行: +-- psql -h -U -d -v ON_ERROR_STOP=1 \ +-- -f scripts/创建sql/precheck_rag_public_orphan_defaults.sql +-- ============================================================================ + +-- 1. 历史未归属公共知识库迁移候选 +SELECT + d.id, + d.name, + d.description, + d.area, + d.tenant_code, + d.is_public, + d.is_default, + d.status, + d.created_at, + d.updated_at +FROM public.rag_dataset d +WHERE d.deleted_at IS NULL + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') + AND d.is_public IS TRUE + AND ( + d.area IS NULL + OR BTRIM(d.area) = '' + OR LOWER(BTRIM(d.area)) = 'default' + OR BTRIM(d.area) = '公共' + ) +ORDER BY d.is_default DESC, d.created_at DESC, d.id DESC; + +-- 2. 当前 PUBLIC 组默认知识库 +SELECT + d.id, + d.name, + d.description, + d.area, + d.tenant_code, + d.is_public, + d.is_default, + d.status, + d.created_at, + d.updated_at +FROM public.rag_dataset d +WHERE d.deleted_at IS NULL + AND BTRIM(COALESCE(d.tenant_code, '')) = 'PUBLIC' +ORDER BY d.is_default DESC, d.created_at DESC, d.id DESC; + +-- 3. 迁移后 PUBLIC 组默认冲突预览 +WITH candidate AS ( + SELECT + d.id, + d.name, + d.area, + CASE + WHEN BTRIM(COALESCE(d.tenant_code, '')) = 'PUBLIC' THEN 'existing_public' + ELSE 'orphan_public' + END AS source_scope, + COALESCE(NULLIF(BTRIM(d.tenant_code), ''), 'PUBLIC') AS target_tenant_code, + d.is_default, + d.is_public, + d.created_at + FROM public.rag_dataset d + WHERE d.deleted_at IS NULL + AND ( + BTRIM(COALESCE(d.tenant_code, '')) = 'PUBLIC' + OR ( + (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') + AND d.is_public IS TRUE + AND ( + d.area IS NULL + OR BTRIM(d.area) = '' + OR LOWER(BTRIM(d.area)) = 'default' + OR BTRIM(d.area) = '公共' + ) + ) + ) +), +ranked AS ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY c.target_tenant_code + ORDER BY + c.is_default DESC, + CASE WHEN c.source_scope = 'existing_public' THEN 0 ELSE 1 END, + c.created_at DESC, + c.id DESC + ) AS keep_default_rank + FROM candidate c +) +SELECT + target_tenant_code, + id, + name, + area, + source_scope, + is_public, + is_default AS current_is_default, + keep_default_rank, + CASE WHEN keep_default_rank = 1 THEN '迁移后保留默认' ELSE '迁移后取消默认' END AS planned_default_action +FROM ranked +ORDER BY target_tenant_code, keep_default_rank, id; + +-- 4. 关联应用候选 +SELECT + a.id, + a.name, + a.area, + a.tenant_code, + a.dataset_id, + a.is_default, + a.status, + a.created_at, + a.updated_at +FROM public.rag_chat_app a +JOIN public.rag_dataset d ON d.id = a.dataset_id +WHERE a.deleted_at IS NULL + AND d.deleted_at IS NULL + AND (a.tenant_code IS NULL OR BTRIM(a.tenant_code) = '') + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') + AND d.is_public IS TRUE + AND ( + d.area IS NULL + OR BTRIM(d.area) = '' + OR LOWER(BTRIM(d.area)) = 'default' + OR BTRIM(d.area) = '公共' + ) +ORDER BY a.is_default DESC, a.created_at DESC, a.id DESC; diff --git a/scripts/创建sql/precheck_rule_domain_tenant_phase1.sql b/scripts/创建sql/precheck_rule_domain_tenant_phase1.sql new file mode 100644 index 0000000..46c6c53 --- /dev/null +++ b/scripts/创建sql/precheck_rule_domain_tenant_phase1.sql @@ -0,0 +1,118 @@ +-- ============================================================================ +-- Rule Domain Tenant Phase 1 Precheck +-- 目标: +-- 1. 执行前确认规则域现状 +-- 2. 识别历史全局规则资产规模 +-- 3. 识别运行结果快照回填影响范围 +-- ============================================================================ + +-- 1. 核心表是否存在 +SELECT table_name +FROM information_schema.tables +WHERE table_schema = current_schema() + AND table_name IN ( + 'leaudit_rule_sets', + 'leaudit_rule_versions', + 'leaudit_rule_group_bindings', + 'leaudit_rule_type_bindings', + 'leaudit_audit_runs', + 'leaudit_rule_results', + 'leaudit_run_errors', + 'leaudit_run_metrics' + ) +ORDER BY table_name; + +-- 2. 当前列缺失情况 +SELECT + table_name, + column_name +FROM information_schema.columns +WHERE table_schema = current_schema() + AND table_name IN ( + 'leaudit_rule_sets', + 'leaudit_rule_versions', + 'leaudit_rule_group_bindings', + 'leaudit_rule_type_bindings', + 'leaudit_audit_runs', + 'leaudit_rule_results', + 'leaudit_run_errors', + 'leaudit_run_metrics' + ) + AND column_name IN ( + 'tenant_code', + 'scope_type', + 'tenant_code_snapshot', + 'scope_type_snapshot', + 'source_rule_set_id', + 'source_version_id', + 'tenant_name_snapshot', + 'group_id_snapshot', + 'rule_binding_id_snapshot' + ) +ORDER BY table_name, column_name; + +-- 3. 规则集与规则版本规模 +SELECT 'leaudit_rule_sets' AS table_name, COUNT(*) AS total +FROM leaudit_rule_sets +WHERE deleted_at IS NULL +UNION ALL +SELECT 'leaudit_rule_versions' AS table_name, COUNT(*) AS total +FROM leaudit_rule_versions +UNION ALL +SELECT 'leaudit_rule_group_bindings' AS table_name, COUNT(*) AS total +FROM leaudit_rule_group_bindings +WHERE deleted_at IS NULL; + +-- 4. 历史全局规则资产规模 +SELECT + COUNT(*) AS global_rule_sets_without_tenant_code +FROM leaudit_rule_sets +WHERE deleted_at IS NULL + AND ( + tenant_code IS NULL + OR BTRIM(tenant_code) = '' + ); + +SELECT + COUNT(*) AS global_group_bindings_without_tenant_code +FROM leaudit_rule_group_bindings +WHERE deleted_at IS NULL + AND ( + tenant_code IS NULL + OR BTRIM(tenant_code) = '' + ); + +-- 5. 运行主表与结果表快照缺口 +SELECT + COUNT(*) AS audit_runs_missing_tenant_code +FROM leaudit_audit_runs +WHERE tenant_code IS NULL + OR BTRIM(tenant_code) = ''; + +SELECT + COUNT(*) AS rule_results_missing_tenant_code +FROM leaudit_rule_results +WHERE tenant_code IS NULL + OR BTRIM(tenant_code) = ''; + +SELECT + COUNT(*) AS run_errors_missing_tenant_code +FROM leaudit_run_errors +WHERE tenant_code IS NULL + OR BTRIM(tenant_code) = ''; + +SELECT + COUNT(*) AS run_metrics_missing_tenant_code +FROM leaudit_run_metrics +WHERE tenant_code IS NULL + OR BTRIM(tenant_code) = ''; + +-- 6. 文档租户缺口,会直接影响 audit_runs 回填质量 +SELECT + COUNT(*) AS documents_missing_tenant_code +FROM leaudit_documents +WHERE deleted_at IS NULL + AND ( + tenant_code IS NULL + OR BTRIM(tenant_code) = '' + ); diff --git a/scripts/创建sql/schema_add_page_quality_module.sql b/scripts/创建sql/schema_add_page_quality_module.sql new file mode 100644 index 0000000..e1edd86 --- /dev/null +++ b/scripts/创建sql/schema_add_page_quality_module.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS public.leaudit_page_quality_runs ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL, + document_file_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + summary_status VARCHAR(32) NULL, + total_pages INTEGER NOT NULL DEFAULT 0, + review_page_count INTEGER NOT NULL DEFAULT 0, + reject_page_count INTEGER NOT NULL DEFAULT 0, + skip_reason VARCHAR(64) NULL, + task_id VARCHAR(128) NULL, + error_message TEXT NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_by BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS public.leaudit_page_quality_results ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + page_num INTEGER NOT NULL, + quality_status VARCHAR(32) NOT NULL, + quality_score NUMERIC(10, 4) NULL, + reason_text TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_leaudit_page_quality_runs_document_id +ON public.leaudit_page_quality_runs(document_id) +WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_leaudit_page_quality_results_run_id +ON public.leaudit_page_quality_results(run_id); diff --git a/scripts/创建sql/schema_entry_module_tenants.sql b/scripts/创建sql/schema_entry_module_tenants.sql new file mode 100644 index 0000000..b7c778d --- /dev/null +++ b/scripts/创建sql/schema_entry_module_tenants.sql @@ -0,0 +1,78 @@ +-- 入口模块租户关系表 +-- 目的: +-- 1. 替代 leaudit_entry_modules.areas 的硬编码地区数组 +-- 2. 支持新增自定义租户后为入口模块直接分配 tenant_code +-- 3. 保留旧 areas 字段作为兼容读写镜像,逐步退出 + +BEGIN; + +CREATE TABLE IF NOT EXISTS leaudit_entry_module_tenants ( + id BIGSERIAL PRIMARY KEY, + entry_module_id BIGINT NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + tenant_name VARCHAR(128) NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + UNIQUE (entry_module_id, tenant_code) +); + +CREATE INDEX IF NOT EXISTS idx_entry_module_tenants_module + ON leaudit_entry_module_tenants(entry_module_id, sort_order, id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_entry_module_tenants_tenant + ON leaudit_entry_module_tenants(tenant_code, is_enabled, sort_order) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE leaudit_entry_module_tenants IS '入口模块与租户的多对多配置关系表'; +COMMENT ON COLUMN leaudit_entry_module_tenants.entry_module_id IS '入口模块ID'; +COMMENT ON COLUMN leaudit_entry_module_tenants.tenant_code IS '租户编码,引用 sys_tenants.tenant_code'; +COMMENT ON COLUMN leaudit_entry_module_tenants.tenant_name IS '配置快照名称,便于前端直接展示'; + +INSERT INTO leaudit_entry_module_tenants ( + entry_module_id, tenant_code, tenant_name, is_enabled, sort_order, created_at, updated_at, deleted_at +) +SELECT + em.id, + alias_map.tenant_code, + COALESCE(t.tenant_name, area_item->>'area') AS tenant_name, + COALESCE((area_item->>'enabled')::boolean, TRUE) AS is_enabled, + COALESCE((area_item->>'sort_order')::int, 0) AS sort_order, + NOW(), + NOW(), + NULL +FROM leaudit_entry_modules em +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item +LEFT JOIN LATERAL ( + SELECT a.tenant_code + FROM sys_tenant_aliases a + WHERE a.alias_value = area_item->>'area' + AND a.deleted_at IS NULL + AND a.is_enabled = TRUE + ORDER BY + CASE a.alias_type + WHEN 'DISPLAY' THEN 1 + WHEN 'LEGACY_AREA' THEN 2 + WHEN 'LEGACY_REGION' THEN 3 + ELSE 9 + END ASC, + a.id ASC + LIMIT 1 +) alias_map ON TRUE +LEFT JOIN sys_tenants t + ON t.tenant_code = alias_map.tenant_code + AND t.deleted_at IS NULL +WHERE em.deleted_at IS NULL + AND alias_map.tenant_code IS NOT NULL +ON CONFLICT (entry_module_id, tenant_code) DO UPDATE +SET + tenant_name = EXCLUDED.tenant_name, + is_enabled = EXCLUDED.is_enabled, + sort_order = EXCLUDED.sort_order, + updated_at = NOW(), + deleted_at = NULL; + +COMMIT; diff --git a/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql b/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql new file mode 100644 index 0000000..958af49 --- /dev/null +++ b/scripts/创建sql/schema_evaluation_points_tenant_cleanup.sql @@ -0,0 +1,198 @@ +BEGIN; + +-- ============================================================================ +-- Evaluation Points Tenant Cleanup +-- 目标: +-- 1. 为旧表 evaluation_points 补 tenant_code / tenant_name +-- 2. 建立基础索引,支持评查点模块彻底切到 tenant_code 主链 +-- 3. 基于 sys_tenant_aliases / sys_tenants 做首轮历史回填 +-- 说明: +-- - 当前真实运行链路仍使用旧表 evaluation_points +-- - 本脚本只做“补列 + 建索引 + 回填” +-- - 不在本阶段删除旧 area 字段 +-- - 不在本阶段强制 NOT NULL +-- ============================================================================ + +-- -------------------------------------------------------------------------- +-- 1. 补字段 +-- -------------------------------------------------------------------------- +ALTER TABLE public.evaluation_points + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(128); + +COMMENT ON COLUMN public.evaluation_points.tenant_code IS '所属租户编码:评查点历史收尾阶段新增,后续替代 area 作为真实归属主字段'; +COMMENT ON COLUMN public.evaluation_points.tenant_name IS '所属租户名称:展示字段,和 tenant_code 配套回填'; + +-- -------------------------------------------------------------------------- +-- 2. 基础索引 +-- -------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_evaluation_points_tenant_code + ON public.evaluation_points(tenant_code); + +CREATE INDEX IF NOT EXISTS idx_evaluation_points_group_tenant_code + ON public.evaluation_points(evaluation_point_groups_id, tenant_code); + +CREATE INDEX IF NOT EXISTS idx_evaluation_points_group_pid_tenant_code + ON public.evaluation_points(evaluation_point_groups_pid, tenant_code); + +-- -------------------------------------------------------------------------- +-- 3. 公共 / 省级规范租户兜底 +-- -------------------------------------------------------------------------- +INSERT INTO public.sys_tenants ( + tenant_code, tenant_name, tenant_short_name, tenant_type, + parent_tenant_code, is_enabled, is_public, display_order, ext, + created_at, updated_at, deleted_at +) +SELECT + 'PUBLIC', '公共资源域', '公共', 'PUBLIC', + NULL, TRUE, TRUE, 0, '{}'::jsonb, + NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_tenants WHERE tenant_code = 'PUBLIC' +); + +INSERT INTO public.sys_tenants ( + tenant_code, tenant_name, tenant_short_name, tenant_type, + parent_tenant_code, is_enabled, is_public, display_order, ext, + created_at, updated_at, deleted_at +) +SELECT + 'PROVINCIAL', '省级统管域', '省级', 'GOV', + NULL, TRUE, FALSE, 1, '{}'::jsonb, + NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_tenants WHERE tenant_code = 'PROVINCIAL' +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PUBLIC', 'DISPLAY', '公共', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PUBLIC' + AND alias_value = '公共' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PUBLIC', 'LEGACY_REGION', 'default', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PUBLIC' + AND alias_value = 'default' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PROVINCIAL', 'DISPLAY', '省级', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PROVINCIAL' + AND alias_value = '省级' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PROVINCIAL', 'LEGACY_REGION', '省局', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PROVINCIAL' + AND alias_value = '省局' + AND deleted_at IS NULL +); + +-- -------------------------------------------------------------------------- +-- 4. 历史回填:优先按别名表映射 +-- -------------------------------------------------------------------------- +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_points AS ( + SELECT + ep.id, + COALESCE( + NULLIF(BTRIM(ep.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN ep.area IS NULL OR BTRIM(ep.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(ep.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(ep.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(ep.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code, + COALESCE( + NULLIF(BTRIM(ep.tenant_name), ''), + st.tenant_name, + NULLIF(BTRIM(ep.area), ''), + CASE + WHEN ep.area IS NULL OR BTRIM(ep.area) = '' THEN '公共资源域' + WHEN LOWER(BTRIM(ep.area)) = 'default' THEN '公共资源域' + WHEN BTRIM(ep.area) = '公共' THEN '公共资源域' + WHEN BTRIM(ep.area) IN ('省级', '省局') THEN '省级统管域' + ELSE NULL + END + ) AS resolved_tenant_name + FROM public.evaluation_points ep + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(ep.area, ''))) = tn.normalized_tenant_name + LEFT JOIN public.sys_tenants st + ON st.tenant_code = COALESCE( + NULLIF(BTRIM(ep.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN ep.area IS NULL OR BTRIM(ep.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(ep.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(ep.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(ep.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) + AND st.deleted_at IS NULL + AND st.is_enabled = TRUE + WHERE + ep.tenant_code IS NULL + OR BTRIM(ep.tenant_code) = '' + OR ep.tenant_name IS NULL + OR BTRIM(ep.tenant_name) = '' +) +UPDATE public.evaluation_points ep +SET tenant_code = COALESCE(NULLIF(BTRIM(ep.tenant_code), ''), rp.resolved_tenant_code), + tenant_name = COALESCE(NULLIF(BTRIM(ep.tenant_name), ''), rp.resolved_tenant_name) +FROM resolved_points rp +WHERE ep.id = rp.id + AND ( + rp.resolved_tenant_code IS NOT NULL + OR rp.resolved_tenant_name IS NOT NULL + ); + +COMMIT; diff --git a/scripts/创建sql/schema_rule_domain_tenant_phase1.sql b/scripts/创建sql/schema_rule_domain_tenant_phase1.sql new file mode 100644 index 0000000..c7da7ec --- /dev/null +++ b/scripts/创建sql/schema_rule_domain_tenant_phase1.sql @@ -0,0 +1,230 @@ +BEGIN; + +-- ============================================================================ +-- Rule Domain Tenant Phase 1 +-- 目标: +-- 1. 为规则域核心表补 tenant_code / scope_type / 运行结果快照字段 +-- 2. 为运行时 TENANT -> PROVINCIAL -> PUBLIC 解析建立物理载体 +-- 3. 将历史全局规则资产默认回填到 PROVINCIAL,而不是 PUBLIC +-- 说明: +-- - 本阶段只做补列、建索引、基础回填 +-- - 不强制 NOT NULL +-- - 不删除旧 region / 全局兼容逻辑 +-- ============================================================================ + +-- -------------------------------------------------------------------------- +-- 1. leaudit_rule_sets +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_rule_sets + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS scope_type VARCHAR(32) DEFAULT 'PROVINCIAL', + ADD COLUMN IF NOT EXISTS source_rule_set_id BIGINT, + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(255); + +COMMENT ON COLUMN public.leaudit_rule_sets.tenant_code IS '规则集所属租户编码'; +COMMENT ON COLUMN public.leaudit_rule_sets.scope_type IS '规则集作用域: TENANT / PROVINCIAL / PUBLIC'; +COMMENT ON COLUMN public.leaudit_rule_sets.source_rule_set_id IS '派生规则集来源 rule_set_id'; +COMMENT ON COLUMN public.leaudit_rule_sets.tenant_name_snapshot IS '规则集所属租户名称快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_sets_tenant_code + ON public.leaudit_rule_sets(tenant_code) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_sets_scope_type + ON public.leaudit_rule_sets(scope_type) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 2. leaudit_rule_versions +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_rule_versions + ADD COLUMN IF NOT EXISTS tenant_code_snapshot VARCHAR(64), + ADD COLUMN IF NOT EXISTS scope_type_snapshot VARCHAR(32), + ADD COLUMN IF NOT EXISTS source_version_id BIGINT; + +COMMENT ON COLUMN public.leaudit_rule_versions.tenant_code_snapshot IS '规则版本所属租户编码快照'; +COMMENT ON COLUMN public.leaudit_rule_versions.scope_type_snapshot IS '规则版本作用域快照'; +COMMENT ON COLUMN public.leaudit_rule_versions.source_version_id IS '派生版本来源 version_id'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_versions_tenant_code_snapshot + ON public.leaudit_rule_versions(tenant_code_snapshot); + +-- -------------------------------------------------------------------------- +-- 3. leaudit_rule_group_bindings +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_rule_group_bindings + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS scope_type VARCHAR(32) DEFAULT 'PROVINCIAL', + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(255); + +COMMENT ON COLUMN public.leaudit_rule_group_bindings.tenant_code IS '业务组绑定所属租户编码'; +COMMENT ON COLUMN public.leaudit_rule_group_bindings.scope_type IS '业务组绑定作用域: TENANT / PROVINCIAL / PUBLIC'; +COMMENT ON COLUMN public.leaudit_rule_group_bindings.tenant_name_snapshot IS '业务组绑定所属租户名称快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_tenant + ON public.leaudit_rule_group_bindings(group_id, tenant_code) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_scope_type + ON public.leaudit_rule_group_bindings(scope_type) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 4. leaudit_rule_type_bindings +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_rule_type_bindings + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS scope_type VARCHAR(32) DEFAULT 'PROVINCIAL'; + +COMMENT ON COLUMN public.leaudit_rule_type_bindings.tenant_code IS '旧规则类型绑定所属租户编码,仅用于兼容与迁移'; +COMMENT ON COLUMN public.leaudit_rule_type_bindings.scope_type IS '旧规则类型绑定作用域,仅用于兼容与迁移'; + +-- -------------------------------------------------------------------------- +-- 5. leaudit_audit_runs +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_audit_runs + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(255), + ADD COLUMN IF NOT EXISTS scope_type_snapshot VARCHAR(32), + ADD COLUMN IF NOT EXISTS group_id_snapshot BIGINT, + ADD COLUMN IF NOT EXISTS rule_binding_id_snapshot BIGINT; + +COMMENT ON COLUMN public.leaudit_audit_runs.tenant_code IS '本次运行所属租户编码快照'; +COMMENT ON COLUMN public.leaudit_audit_runs.tenant_name_snapshot IS '本次运行所属租户名称快照'; +COMMENT ON COLUMN public.leaudit_audit_runs.scope_type_snapshot IS '本次运行命中的规则作用域快照'; +COMMENT ON COLUMN public.leaudit_audit_runs.group_id_snapshot IS '本次运行命中的业务组快照'; +COMMENT ON COLUMN public.leaudit_audit_runs.rule_binding_id_snapshot IS '本次运行命中的规则绑定快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_audit_runs_tenant_code + ON public.leaudit_audit_runs(tenant_code, created_at DESC); + +-- -------------------------------------------------------------------------- +-- 6. leaudit_rule_results +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_rule_results + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(255); + +COMMENT ON COLUMN public.leaudit_rule_results.tenant_code IS '规则结果所属租户编码快照'; +COMMENT ON COLUMN public.leaudit_rule_results.tenant_name_snapshot IS '规则结果所属租户名称快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_rule_results_tenant_code + ON public.leaudit_rule_results(tenant_code); + +-- -------------------------------------------------------------------------- +-- 7. leaudit_run_errors +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_run_errors + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(255); + +COMMENT ON COLUMN public.leaudit_run_errors.tenant_code IS '运行错误所属租户编码快照'; +COMMENT ON COLUMN public.leaudit_run_errors.tenant_name_snapshot IS '运行错误所属租户名称快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_run_errors_tenant_code + ON public.leaudit_run_errors(tenant_code); + +-- -------------------------------------------------------------------------- +-- 8. leaudit_run_metrics +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_run_metrics + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64); + +COMMENT ON COLUMN public.leaudit_run_metrics.tenant_code IS '运行指标所属租户编码快照'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_run_metrics_tenant_code + ON public.leaudit_run_metrics(tenant_code); + +-- -------------------------------------------------------------------------- +-- 9. 历史资产默认回填到 PROVINCIAL +-- -------------------------------------------------------------------------- +UPDATE public.leaudit_rule_sets +SET tenant_code = COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL'), + scope_type = COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') +WHERE deleted_at IS NULL; + +-- 兼容历史唯一约束 (rule_type, region):租户私有规则集必须使用租户编码作为 legacy region, +-- 否则会与省级默认 region=default 冲突。 +UPDATE public.leaudit_rule_sets +SET region = tenant_code +WHERE deleted_at IS NULL + AND scope_type = 'TENANT' + AND tenant_code IS NOT NULL + AND BTRIM(tenant_code) <> '' + AND region = 'default'; + +UPDATE public.leaudit_rule_versions rv +SET tenant_code_snapshot = COALESCE( + NULLIF(BTRIM(rv.tenant_code_snapshot), ''), + NULLIF(BTRIM(rs.tenant_code), ''), + 'PROVINCIAL' + ), + scope_type_snapshot = COALESCE( + NULLIF(BTRIM(rv.scope_type_snapshot), ''), + NULLIF(BTRIM(rs.scope_type), ''), + 'PROVINCIAL' + ) +FROM public.leaudit_rule_sets rs +WHERE rv.rule_set_id = rs.id; + +UPDATE public.leaudit_rule_group_bindings rgb +SET tenant_code = COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL'), + scope_type = COALESCE(NULLIF(BTRIM(rgb.scope_type), ''), 'PROVINCIAL') +WHERE rgb.deleted_at IS NULL; + +UPDATE public.leaudit_rule_type_bindings +SET tenant_code = COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL'), + scope_type = COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') +WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 10. 运行与结果快照回填 +-- -------------------------------------------------------------------------- +UPDATE public.leaudit_audit_runs ar +SET tenant_code = COALESCE( + NULLIF(BTRIM(ar.tenant_code), ''), + NULLIF(BTRIM(d.tenant_code), ''), + 'PROVINCIAL' + ), + scope_type_snapshot = COALESCE( + NULLIF(BTRIM(ar.scope_type_snapshot), ''), + 'PROVINCIAL' + ), + group_id_snapshot = COALESCE(ar.group_id_snapshot, d.group_id) +FROM public.leaudit_documents d +WHERE ar.document_id = d.id; + +UPDATE public.leaudit_rule_results rr +SET tenant_code = COALESCE( + NULLIF(BTRIM(rr.tenant_code), ''), + NULLIF(BTRIM(ar.tenant_code), ''), + NULLIF(BTRIM(d.tenant_code), ''), + 'PROVINCIAL' + ) +FROM public.leaudit_audit_runs ar +LEFT JOIN public.leaudit_documents d ON d.id = ar.document_id +WHERE rr.run_id = ar.id; + +UPDATE public.leaudit_run_errors re +SET tenant_code = COALESCE( + NULLIF(BTRIM(re.tenant_code), ''), + NULLIF(BTRIM(ar.tenant_code), ''), + NULLIF(BTRIM(d.tenant_code), ''), + 'PROVINCIAL' + ) +FROM public.leaudit_audit_runs ar +LEFT JOIN public.leaudit_documents d ON d.id = ar.document_id +WHERE re.run_id = ar.id; + +UPDATE public.leaudit_run_metrics rm +SET tenant_code = COALESCE( + NULLIF(BTRIM(rm.tenant_code), ''), + NULLIF(BTRIM(ar.tenant_code), ''), + NULLIF(BTRIM(d.tenant_code), ''), + 'PROVINCIAL' + ) +FROM public.leaudit_audit_runs ar +LEFT JOIN public.leaudit_documents d ON d.id = ar.document_id +WHERE rm.run_id = ar.id; + +COMMIT; diff --git a/scripts/创建sql/schema_tenant_code_high_risk_phase1.sql b/scripts/创建sql/schema_tenant_code_high_risk_phase1.sql new file mode 100644 index 0000000..1529607 --- /dev/null +++ b/scripts/创建sql/schema_tenant_code_high_risk_phase1.sql @@ -0,0 +1,453 @@ +BEGIN; + +-- ============================================================================ +-- High Risk Tenant Code Phase 1 +-- 目标: +-- 1. 为高风险核心业务表补 tenant_code / tenant 快照字段 +-- 2. 补基础索引,支持后续 service 改为 tenant_code 主链 +-- 3. 基于 sys_tenant_aliases / sys_tenants 做第一轮历史回填 +-- 说明: +-- - 本脚本只做“补列 + 建索引 + 回填” +-- - 不在本阶段强制 NOT NULL +-- - 不在本阶段删除旧 region/area 字段 +-- ============================================================================ + +-- -------------------------------------------------------------------------- +-- 1. leaudit_documents +-- -------------------------------------------------------------------------- +ALTER TABLE public.leaudit_documents + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64); + +COMMENT ON COLUMN public.leaudit_documents.tenant_code IS '所属租户编码:高风险阶段新增,后续替代 region 作为真实归属主字段'; + +CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code + ON public.leaudit_documents(tenant_code) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_leaudit_documents_type_tenant_name_latest + ON public.leaudit_documents(type_id, tenant_code, normalized_name, is_latest_version) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 2. contract_templates +-- -------------------------------------------------------------------------- +ALTER TABLE public.contract_templates + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(128); + +COMMENT ON COLUMN public.contract_templates.tenant_code IS '所属租户编码:高风险阶段新增,后续替代 region 作为真实归属主字段'; +COMMENT ON COLUMN public.contract_templates.tenant_name IS '所属租户名称:展示字段,和 tenant_code 配套回填'; + +CREATE INDEX IF NOT EXISTS idx_contract_templates_tenant_code_active + ON public.contract_templates(tenant_code) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_tenant_code_code_active + ON public.contract_templates(tenant_code, template_code) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 3. usage_login_events +-- -------------------------------------------------------------------------- +ALTER TABLE public.usage_login_events + ADD COLUMN IF NOT EXISTS tenant_code_snapshot VARCHAR(64), + ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(128); + +COMMENT ON COLUMN public.usage_login_events.tenant_code_snapshot IS '登录时租户编码快照'; +COMMENT ON COLUMN public.usage_login_events.tenant_name_snapshot IS '登录时租户名称快照'; + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_tenant_code + ON public.usage_login_events(tenant_code_snapshot, login_time DESC); + +-- -------------------------------------------------------------------------- +-- 4. rag_dataset +-- -------------------------------------------------------------------------- +ALTER TABLE public.rag_dataset + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64); + +COMMENT ON COLUMN public.rag_dataset.tenant_code IS '所属租户编码:高风险阶段新增,后续替代 area 作为真实归属主字段'; + +CREATE INDEX IF NOT EXISTS idx_rag_dataset_tenant_code + ON public.rag_dataset(tenant_code) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 5. rag_chat_app +-- -------------------------------------------------------------------------- +ALTER TABLE public.rag_chat_app + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64); + +COMMENT ON COLUMN public.rag_chat_app.tenant_code IS '所属租户编码:高风险阶段新增,后续替代 area 作为真实归属主字段'; + +CREATE INDEX IF NOT EXISTS idx_rag_chat_app_tenant_code + ON public.rag_chat_app(tenant_code) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 6. 公共 / 省级规范租户兜底 +-- -------------------------------------------------------------------------- +INSERT INTO public.sys_tenants ( + tenant_code, tenant_name, tenant_short_name, tenant_type, + parent_tenant_code, is_enabled, is_public, display_order, ext, + created_at, updated_at, deleted_at +) +SELECT + 'PUBLIC', '公共资源域', '公共', 'PUBLIC', + NULL, TRUE, TRUE, 0, '{}'::jsonb, + NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_tenants WHERE tenant_code = 'PUBLIC' +); + +INSERT INTO public.sys_tenants ( + tenant_code, tenant_name, tenant_short_name, tenant_type, + parent_tenant_code, is_enabled, is_public, display_order, ext, + created_at, updated_at, deleted_at +) +SELECT + 'PROVINCIAL', '省级统管域', '省级', 'GOV', + NULL, TRUE, FALSE, 1, '{}'::jsonb, + NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_tenants WHERE tenant_code = 'PROVINCIAL' +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PUBLIC', 'DISPLAY', '公共', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PUBLIC' + AND alias_value = '公共' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PUBLIC', 'LEGACY_REGION', 'default', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PUBLIC' + AND alias_value = 'default' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PROVINCIAL', 'DISPLAY', '省级', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PROVINCIAL' + AND alias_value = '省级' + AND deleted_at IS NULL +); + +INSERT INTO public.sys_tenant_aliases ( + tenant_code, alias_type, alias_value, is_enabled, created_at, updated_at, deleted_at +) +SELECT 'PROVINCIAL', 'LEGACY_REGION', '省局', TRUE, NOW(), NOW(), NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.sys_tenant_aliases + WHERE tenant_code = 'PROVINCIAL' + AND alias_value = '省局' + AND deleted_at IS NULL +); + +-- -------------------------------------------------------------------------- +-- 7. 第一轮历史回填:优先按别名表映射 +-- -------------------------------------------------------------------------- +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_documents AS ( + SELECT + d.id, + COALESCE( + NULLIF(BTRIM(d.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN d.region IS NULL OR BTRIM(d.region) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(d.region)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(d.region) = '公共' THEN 'PUBLIC' + WHEN BTRIM(d.region) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code + FROM public.leaudit_documents d + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(d.region, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(d.region, ''))) = tn.normalized_tenant_name + WHERE d.deleted_at IS NULL + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') +) +UPDATE public.leaudit_documents d +SET tenant_code = rd.resolved_tenant_code +FROM resolved_documents rd +WHERE d.id = rd.id + AND rd.resolved_tenant_code IS NOT NULL; + +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_templates AS ( + SELECT + t.id, + COALESCE( + NULLIF(BTRIM(t.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN t.region IS NULL OR BTRIM(t.region) = '' THEN 'PROVINCIAL' + WHEN LOWER(BTRIM(t.region)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(t.region) = '公共' THEN 'PUBLIC' + WHEN BTRIM(t.region) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code, + COALESCE( + NULLIF(BTRIM(t.tenant_name), ''), + NULLIF(BTRIM(t.region), ''), + st.tenant_name, + CASE + WHEN t.region IS NULL OR BTRIM(t.region) = '' THEN '省级' + WHEN LOWER(BTRIM(t.region)) = 'default' THEN '公共' + WHEN BTRIM(t.region) = '公共' THEN '公共' + WHEN BTRIM(t.region) IN ('省级', '省局') THEN '省级' + ELSE NULL + END + ) AS resolved_tenant_name + FROM public.contract_templates t + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(t.region, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(t.region, ''))) = tn.normalized_tenant_name + LEFT JOIN public.sys_tenants st + ON st.tenant_code = COALESCE( + NULLIF(BTRIM(t.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN t.region IS NULL OR BTRIM(t.region) = '' THEN 'PROVINCIAL' + WHEN LOWER(BTRIM(t.region)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(t.region) = '公共' THEN 'PUBLIC' + WHEN BTRIM(t.region) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) + AND st.deleted_at IS NULL + AND st.is_enabled = TRUE + WHERE t.deleted_at IS NULL + AND ( + t.tenant_code IS NULL + OR BTRIM(t.tenant_code) = '' + OR t.tenant_name IS NULL + OR BTRIM(t.tenant_name) = '' + ) +) +UPDATE public.contract_templates t +SET tenant_code = COALESCE(t.tenant_code, rt.resolved_tenant_code), + tenant_name = COALESCE(NULLIF(BTRIM(t.tenant_name), ''), rt.resolved_tenant_name) +FROM resolved_templates rt +WHERE t.id = rt.id + AND ( + rt.resolved_tenant_code IS NOT NULL + OR rt.resolved_tenant_name IS NOT NULL + ); + +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_rag_dataset AS ( + SELECT + d.id, + COALESCE( + NULLIF(BTRIM(d.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN d.area IS NULL OR BTRIM(d.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(d.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(d.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(d.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code + FROM public.rag_dataset d + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(d.area, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(d.area, ''))) = tn.normalized_tenant_name + WHERE d.deleted_at IS NULL + AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '') +) +UPDATE public.rag_dataset d +SET tenant_code = rrd.resolved_tenant_code +FROM resolved_rag_dataset rrd +WHERE d.id = rrd.id + AND rrd.resolved_tenant_code IS NOT NULL; + +WITH alias_map AS ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC +), +tenant_name_map AS ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE +), +resolved_rag_chat_app AS ( + SELECT + a.id, + COALESCE( + NULLIF(BTRIM(a.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN a.area IS NULL OR BTRIM(a.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(a.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(a.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(a.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code + FROM public.rag_chat_app a + LEFT JOIN alias_map am + ON LOWER(BTRIM(COALESCE(a.area, ''))) = am.normalized_alias_value + LEFT JOIN tenant_name_map tn + ON LOWER(BTRIM(COALESCE(a.area, ''))) = tn.normalized_tenant_name + WHERE a.deleted_at IS NULL + AND (a.tenant_code IS NULL OR BTRIM(a.tenant_code) = '') +) +UPDATE public.rag_chat_app a +SET tenant_code = rca.resolved_tenant_code +FROM resolved_rag_chat_app rca +WHERE a.id = rca.id + AND rca.resolved_tenant_code IS NOT NULL; + +WITH user_tenant_map AS ( + SELECT + u.id AS user_id, + COALESCE( + NULLIF(BTRIM(u.tenant_code), ''), + am.tenant_code, + tn.tenant_code, + CASE + WHEN u.area IS NULL OR BTRIM(u.area) = '' THEN 'PUBLIC' + WHEN LOWER(BTRIM(u.area)) = 'default' THEN 'PUBLIC' + WHEN BTRIM(u.area) = '公共' THEN 'PUBLIC' + WHEN BTRIM(u.area) IN ('省级', '省局') THEN 'PROVINCIAL' + ELSE NULL + END + ) AS resolved_tenant_code, + COALESCE( + NULLIF(BTRIM(u.tenant_name), ''), + t.tenant_name, + NULLIF(BTRIM(u.area), ''), + CASE + WHEN u.area IS NULL OR BTRIM(u.area) = '' THEN '公共资源域' + ELSE NULL + END + ) AS resolved_tenant_name + FROM public.sso_users u + LEFT JOIN public.sys_tenant_aliases sa + ON LOWER(BTRIM(COALESCE(u.area, ''))) = LOWER(BTRIM(sa.alias_value)) + AND sa.deleted_at IS NULL + AND sa.is_enabled = TRUE + LEFT JOIN public.sys_tenants t + ON t.tenant_code = COALESCE(NULLIF(BTRIM(u.tenant_code), ''), sa.tenant_code) + AND t.deleted_at IS NULL + AND t.is_enabled = TRUE + LEFT JOIN ( + SELECT DISTINCT ON (LOWER(BTRIM(alias_value))) + LOWER(BTRIM(alias_value)) AS normalized_alias_value, + tenant_code + FROM public.sys_tenant_aliases + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ORDER BY LOWER(BTRIM(alias_value)), id ASC + ) am + ON LOWER(BTRIM(COALESCE(u.area, ''))) = am.normalized_alias_value + LEFT JOIN ( + SELECT + LOWER(BTRIM(tenant_name)) AS normalized_tenant_name, + tenant_code + FROM public.sys_tenants + WHERE deleted_at IS NULL + AND is_enabled = TRUE + ) tn + ON LOWER(BTRIM(COALESCE(u.area, ''))) = tn.normalized_tenant_name + WHERE u.deleted_at IS NULL +) +UPDATE public.usage_login_events e +SET tenant_code_snapshot = COALESCE(e.tenant_code_snapshot, utm.resolved_tenant_code), + tenant_name_snapshot = COALESCE(e.tenant_name_snapshot, utm.resolved_tenant_name) +FROM user_tenant_map utm +WHERE e.deleted_at IS NULL + AND e.user_id = utm.user_id + AND ( + e.tenant_code_snapshot IS NULL + OR BTRIM(e.tenant_code_snapshot) = '' + OR e.tenant_name_snapshot IS NULL + OR BTRIM(e.tenant_name_snapshot) = '' + ); + +COMMIT; diff --git a/scripts/创建sql/schema_tenant_foundation.sql b/scripts/创建sql/schema_tenant_foundation.sql new file mode 100644 index 0000000..9e5f3d5 --- /dev/null +++ b/scripts/创建sql/schema_tenant_foundation.sql @@ -0,0 +1,163 @@ +-- 租户主数据底座 +-- 目的: +-- 1. 为“地区 -> 租户”升级提供稳定主数据 +-- 2. 为后续入口模块、RAG、文档、模板等模块提供 tenant_code +-- 3. 保持对现有 area / region / tenant_name 的兼容 + +BEGIN; + +CREATE TABLE IF NOT EXISTS sys_tenants ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL UNIQUE, + tenant_name VARCHAR(128) NOT NULL, + tenant_short_name VARCHAR(64) NULL, + tenant_type VARCHAR(32) NOT NULL, + parent_tenant_code VARCHAR(64) NULL, + display_order INT NOT NULL DEFAULT 0, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + can_host_entry_module BOOLEAN NOT NULL DEFAULT TRUE, + can_host_documents BOOLEAN NOT NULL DEFAULT TRUE, + can_host_rag BOOLEAN NOT NULL DEFAULT TRUE, + can_host_templates BOOLEAN NOT NULL DEFAULT TRUE, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_sys_tenants_enabled_order + ON sys_tenants(is_enabled, display_order, id) + WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS sys_tenant_aliases ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + alias_value VARCHAR(128) NOT NULL, + alias_type VARCHAR(32) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + UNIQUE (tenant_code, alias_value) +); + +CREATE INDEX IF NOT EXISTS idx_sys_tenant_aliases_lookup + ON sys_tenant_aliases(alias_value, alias_type) + WHERE deleted_at IS NULL AND is_enabled = TRUE; + +CREATE TABLE IF NOT EXISTS sys_tenant_feature_flags ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + feature_key VARCHAR(64) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + UNIQUE (tenant_code, feature_key) +); + +CREATE INDEX IF NOT EXISTS idx_sys_tenant_features_lookup + ON sys_tenant_feature_flags(tenant_code, feature_key) + WHERE deleted_at IS NULL; + +ALTER TABLE sso_users + ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) NULL; + +CREATE INDEX IF NOT EXISTS idx_sso_users_tenant_code + ON sso_users(tenant_code) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE sys_tenants IS '系统租户主数据表'; +COMMENT ON TABLE sys_tenant_aliases IS '租户历史别名与展示别名映射表'; +COMMENT ON TABLE sys_tenant_feature_flags IS '租户能力开关表'; +COMMENT ON COLUMN sso_users.tenant_code IS '用户主归属租户编码,优先于 area 作为租户边界'; + +INSERT INTO sys_tenants ( + tenant_code, tenant_name, tenant_short_name, tenant_type, parent_tenant_code, + display_order, is_enabled, is_builtin, is_public, + can_host_entry_module, can_host_documents, can_host_rag, can_host_templates, ext +) +VALUES + ('MZ', '梅州', '梅州', 'LOCAL', NULL, 10, TRUE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb), + ('YF', '云浮', '云浮', 'LOCAL', NULL, 20, TRUE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb), + ('JY', '揭阳', '揭阳', 'LOCAL', NULL, 30, TRUE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb), + ('CZ', '潮州', '潮州', 'LOCAL', NULL, 40, TRUE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb), + ('PROVINCIAL', '省局', '省局', 'HEADQUARTER', NULL, 90, TRUE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb), + ('PUBLIC', '公共资源域', '公共', 'PUBLIC', NULL, 100, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, '{}'::jsonb) +ON CONFLICT (tenant_code) DO UPDATE +SET + tenant_name = EXCLUDED.tenant_name, + tenant_short_name = EXCLUDED.tenant_short_name, + tenant_type = EXCLUDED.tenant_type, + parent_tenant_code = EXCLUDED.parent_tenant_code, + display_order = EXCLUDED.display_order, + is_enabled = EXCLUDED.is_enabled, + is_builtin = EXCLUDED.is_builtin, + is_public = EXCLUDED.is_public, + can_host_entry_module = EXCLUDED.can_host_entry_module, + can_host_documents = EXCLUDED.can_host_documents, + can_host_rag = EXCLUDED.can_host_rag, + can_host_templates = EXCLUDED.can_host_templates, + ext = EXCLUDED.ext, + updated_at = NOW(); + +INSERT INTO sys_tenant_aliases (tenant_code, alias_value, alias_type, is_enabled) +VALUES + ('MZ', '梅州', 'LEGACY_AREA', TRUE), + ('YF', '云浮', 'LEGACY_AREA', TRUE), + ('JY', '揭阳', 'LEGACY_AREA', TRUE), + ('CZ', '潮州', 'LEGACY_AREA', TRUE), + ('PROVINCIAL', '省局', 'DISPLAY', TRUE), + ('PUBLIC', '省级', 'LEGACY_REGION', TRUE), + ('PUBLIC', 'default', 'LEGACY_REGION', TRUE), + ('PUBLIC', '', 'LEGACY_REGION', TRUE) +ON CONFLICT (tenant_code, alias_value) DO UPDATE +SET + alias_type = EXCLUDED.alias_type, + is_enabled = EXCLUDED.is_enabled, + updated_at = NOW(); + +INSERT INTO sys_tenant_feature_flags (tenant_code, feature_key, is_enabled) +VALUES + ('MZ', 'home.entry_module', TRUE), + ('YF', 'home.entry_module', TRUE), + ('JY', 'home.entry_module', TRUE), + ('CZ', 'home.entry_module', TRUE), + ('PROVINCIAL', 'home.entry_module', TRUE), + ('PUBLIC', 'home.entry_module', TRUE), + ('MZ', 'rag.dataset', TRUE), + ('YF', 'rag.dataset', TRUE), + ('JY', 'rag.dataset', TRUE), + ('CZ', 'rag.dataset', TRUE), + ('PROVINCIAL', 'rag.dataset', TRUE), + ('PUBLIC', 'rag.dataset', TRUE), + ('MZ', 'documents.upload', TRUE), + ('YF', 'documents.upload', TRUE), + ('JY', 'documents.upload', TRUE), + ('CZ', 'documents.upload', TRUE), + ('PROVINCIAL', 'documents.upload', TRUE), + ('PUBLIC', 'documents.upload', TRUE) +ON CONFLICT (tenant_code, feature_key) DO UPDATE +SET + is_enabled = EXCLUDED.is_enabled, + updated_at = NOW(); + +UPDATE sso_users u +SET tenant_code = mapped.tenant_code +FROM ( + SELECT DISTINCT ON (a.alias_value) + a.alias_value, + a.tenant_code + FROM sys_tenant_aliases a + WHERE a.deleted_at IS NULL + AND a.is_enabled = TRUE + ORDER BY a.alias_value, a.id ASC +) mapped +WHERE u.deleted_at IS NULL + AND COALESCE(BTRIM(u.tenant_code), '') = '' + AND COALESCE(BTRIM(u.area), '') = mapped.alias_value; + +COMMIT; diff --git a/scripts/创建sql/seed_page_quality_permissions.sql b/scripts/创建sql/seed_page_quality_permissions.sql new file mode 100644 index 0000000..4ce284b --- /dev/null +++ b/scripts/创建sql/seed_page_quality_permissions.sql @@ -0,0 +1 @@ +-- 预留:页级图片质量模块权限初始化脚本 diff --git a/scripts/创建sql/seed_page_quality_routes.sql b/scripts/创建sql/seed_page_quality_routes.sql new file mode 100644 index 0000000..1ce694c --- /dev/null +++ b/scripts/创建sql/seed_page_quality_routes.sql @@ -0,0 +1 @@ +-- 预留:页级图片质量模块路由初始化脚本 diff --git a/scripts/创建sql/user_rbac_seed.sql b/scripts/创建sql/user_rbac_seed.sql index 993ba30..f989335 100644 --- a/scripts/创建sql/user_rbac_seed.sql +++ b/scripts/创建sql/user_rbac_seed.sql @@ -344,9 +344,13 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('admin', 'rules:version_list:read', 'GRANT', 'DEPT'), ('admin', 'rules:content:read', 'GRANT', 'DEPT'), ('admin', 'rules:validate:execute', 'GRANT', 'DEPT'), + ('admin', 'rules:version_create:write', 'GRANT', 'DEPT'), + ('admin', 'rules:publish:write', 'GRANT', 'DEPT'), + ('admin', 'rules:rollback:write', 'GRANT', 'DEPT'), ('admin', 'rules:binding_list:read', 'GRANT', 'DEPT'), ('admin', 'rules:binding_create:write', 'GRANT', 'DEPT'), ('admin', 'rules:binding_update:write', 'GRANT', 'DEPT'), + ('admin', 'rules:binding_delete:delete', 'GRANT', 'DEPT'), ('admin', 'cross_review:task:create', 'GRANT', 'DEPT'), ('admin', 'cross_review:task:read', 'GRANT', 'DEPT'), ('admin', 'cross_review:progress:view', 'GRANT', 'DEPT'), diff --git a/scripts/创建sql/verify_rule_domain_tenant_phase1.sql b/scripts/创建sql/verify_rule_domain_tenant_phase1.sql new file mode 100644 index 0000000..d843de1 --- /dev/null +++ b/scripts/创建sql/verify_rule_domain_tenant_phase1.sql @@ -0,0 +1,69 @@ +-- ============================================================================ +-- Rule Domain Tenant Phase 1 Verify +-- 目标: +-- 1. 验证字段与索引已成功补齐 +-- 2. 验证历史资产已具备基础 tenant_code / scope_type +-- 3. 验证运行结果链路具备租户快照 +-- ============================================================================ + +-- 1. 字段检查 +SELECT + table_name, + column_name +FROM information_schema.columns +WHERE table_schema = current_schema() + AND ( + (table_name = 'leaudit_rule_sets' AND column_name IN ('tenant_code', 'scope_type', 'source_rule_set_id', 'tenant_name_snapshot')) + OR (table_name = 'leaudit_rule_versions' AND column_name IN ('tenant_code_snapshot', 'scope_type_snapshot', 'source_version_id')) + OR (table_name = 'leaudit_rule_group_bindings' AND column_name IN ('tenant_code', 'scope_type', 'tenant_name_snapshot')) + OR (table_name = 'leaudit_rule_type_bindings' AND column_name IN ('tenant_code', 'scope_type')) + OR (table_name = 'leaudit_audit_runs' AND column_name IN ('tenant_code', 'tenant_name_snapshot', 'scope_type_snapshot', 'group_id_snapshot', 'rule_binding_id_snapshot')) + OR (table_name = 'leaudit_rule_results' AND column_name IN ('tenant_code', 'tenant_name_snapshot')) + OR (table_name = 'leaudit_run_errors' AND column_name IN ('tenant_code', 'tenant_name_snapshot')) + OR (table_name = 'leaudit_run_metrics' AND column_name IN ('tenant_code')) + ) +ORDER BY table_name, column_name; + +-- 2. 历史规则资产 tenant_code / scope_type 覆盖率 +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS rule_sets_missing_tenant_code, + COUNT(*) FILTER (WHERE scope_type IS NULL OR BTRIM(scope_type) = '') AS rule_sets_missing_scope_type +FROM leaudit_rule_sets +WHERE deleted_at IS NULL; + +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS rule_group_bindings_missing_tenant_code, + COUNT(*) FILTER (WHERE scope_type IS NULL OR BTRIM(scope_type) = '') AS rule_group_bindings_missing_scope_type +FROM leaudit_rule_group_bindings +WHERE deleted_at IS NULL; + +-- 3. 运行与结果快照覆盖率 +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS audit_runs_missing_tenant_code, + COUNT(*) FILTER (WHERE scope_type_snapshot IS NULL OR BTRIM(scope_type_snapshot) = '') AS audit_runs_missing_scope_type +FROM leaudit_audit_runs; + +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS rule_results_missing_tenant_code +FROM leaudit_rule_results; + +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS run_errors_missing_tenant_code +FROM leaudit_run_errors; + +SELECT + COUNT(*) FILTER (WHERE tenant_code IS NULL OR BTRIM(tenant_code) = '') AS run_metrics_missing_tenant_code +FROM leaudit_run_metrics; + +-- 4. 作用域分布,确认默认回填结果 +SELECT scope_type, COUNT(*) AS total +FROM leaudit_rule_sets +WHERE deleted_at IS NULL +GROUP BY scope_type +ORDER BY scope_type; + +SELECT scope_type, COUNT(*) AS total +FROM leaudit_rule_group_bindings +WHERE deleted_at IS NULL +GROUP BY scope_type +ORDER BY scope_type; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9c432b5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package root.""" diff --git a/tests/release/__init__.py b/tests/release/__init__.py new file mode 100644 index 0000000..7fd8805 --- /dev/null +++ b/tests/release/__init__.py @@ -0,0 +1 @@ +"""Release acceptance tests.""" diff --git a/tests/release/conftest.py b/tests/release/conftest.py new file mode 100644 index 0000000..dd92a9d --- /dev/null +++ b/tests/release/conftest.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from typing import Any, Callable + +import pytest + +from .helpers import ReleaseApiClient, ReleaseTestConfig + + +@dataclass(frozen=True) +class TenantSeed: + tenant_code: str + tenant_name: str + + +@dataclass(frozen=True) +class SeededUser: + user_id: int + sub: str + username: str + nickname: str + role_key: str + tenant_code: str + token: str + + +@dataclass(frozen=True) +class DocumentTypeSeed: + type_id: int + type_code: str + type_name: str + + +@dataclass(frozen=True) +class ReleaseDocumentSeed: + document_id: int + file_id: int | None + type_id: int + type_code: str + tenant_code: str + tenant_name: str + file_name: str + + +@dataclass(frozen=True) +class ReleaseDatasetSeed: + dataset_id: int + dataset_name: str + tenant_code: str + tenant_name: str + app_id: int | None + app_name: str | None + + +def _env(name: str, default: str) -> str: + return str(os.getenv(name, default)).strip() + + +@pytest.fixture(scope="session") +def release_config() -> ReleaseTestConfig: + return ReleaseTestConfig( + base_url=_env("LEAUDIT_TEST_BASE_URL", "http://127.0.0.1:8096").rstrip("/"), + admin_username=_env("LEAUDIT_TEST_ADMIN_USERNAME", "000"), + admin_password=_env("LEAUDIT_TEST_ADMIN_PASSWORD", "admin06111"), + timeout_seconds=float(_env("LEAUDIT_TEST_TIMEOUT_SECONDS", "30")), + tenant_a_code=_env("LEAUDIT_TEST_TENANT_A_CODE", "PTA01"), + tenant_a_name=_env("LEAUDIT_TEST_TENANT_A_NAME", "Pytest租户A"), + tenant_b_code=_env("LEAUDIT_TEST_TENANT_B_CODE", "PTB01"), + tenant_b_name=_env("LEAUDIT_TEST_TENANT_B_NAME", "Pytest租户B"), + module_name=_env("LEAUDIT_TEST_MODULE_NAME", f"Pytest发布验收入口模块-{time.time_ns()}"), + module_path=_env("LEAUDIT_TEST_MODULE_PATH", "/documents"), + ) + + +@pytest.fixture(scope="session") +def anonymous_client(release_config: ReleaseTestConfig) -> ReleaseApiClient: + client = ReleaseApiClient(release_config) + yield client + client.close() + + +@pytest.fixture(scope="session") +def admin_auth(release_config: ReleaseTestConfig, anonymous_client: ReleaseApiClient) -> dict[str, Any]: + return anonymous_client.login_password(release_config.admin_username, release_config.admin_password) + + +@pytest.fixture(scope="session") +def admin_client(release_config: ReleaseTestConfig, admin_auth: dict[str, Any]) -> ReleaseApiClient: + client = ReleaseApiClient(release_config, token=str(admin_auth["access_token"])) + yield client + client.close() + + +@pytest.fixture(scope="session") +def role_map(admin_client: ReleaseApiClient) -> dict[str, int]: + response = admin_client.get("/api/v3/rbac/roles?page=1&page_size=200") + items = ReleaseApiClient.json_data(response)["items"] + mapping = {str(item["role_key"]): int(item["id"]) for item in items} + for role_key in ("super_admin", "provincial_admin", "admin", "common"): + assert role_key in mapping, f"缺少角色种子: {role_key}" + return mapping + + +def _ensure_tenant(admin_client: ReleaseApiClient, tenant_code: str, tenant_name: str) -> TenantSeed: + detail_response = admin_client.get(f"/api/v3/tenants/{tenant_code}", expected_status=None) + body = detail_response.json() + desired_payload = { + "tenant_name": tenant_name, + "tenant_short_name": tenant_name, + "tenant_type": "CUSTOM", + "parent_tenant_code": None, + "display_order": 500, + "is_public": False, + "can_host_entry_module": True, + "can_host_documents": True, + "can_host_rag": True, + "can_host_templates": True, + "feature_keys": ["home.entry_module", "documents.upload", "rag.dataset"], + "alias_values": [tenant_name], + "ext": {"created_by": "pytest-release"}, + } + if detail_response.status_code == 404: + create_payload = { + "tenant_code": tenant_code, + "is_enabled": True, + **desired_payload, + } + admin_client.post("/api/v3/tenants", json=create_payload, expected_status=200) + else: + assert detail_response.status_code == 200, body + admin_client.put(f"/api/v3/tenants/{tenant_code}", json=desired_payload, expected_status=200) + admin_client.patch(f"/api/v3/tenants/{tenant_code}/status", json={"is_enabled": True}, expected_status=200) + return TenantSeed(tenant_code=tenant_code, tenant_name=tenant_name) + + +@pytest.fixture(scope="session") +def tenant_a(admin_client: ReleaseApiClient, release_config: ReleaseTestConfig) -> TenantSeed: + return _ensure_tenant(admin_client, release_config.tenant_a_code, release_config.tenant_a_name) + + +@pytest.fixture(scope="session") +def tenant_b(admin_client: ReleaseApiClient, release_config: ReleaseTestConfig) -> TenantSeed: + return _ensure_tenant(admin_client, release_config.tenant_b_code, release_config.tenant_b_name) + + +def _ensure_user( + *, + anonymous_client: ReleaseApiClient, + admin_client: ReleaseApiClient, + role_map: dict[str, int], + sub: str, + username: str, + nickname: str, + role_key: str, + tenant: TenantSeed, +) -> SeededUser: + login_data = anonymous_client.login_oauth( + sub=sub, + username=username, + nickname=nickname, + ou_id=f"pytest-{tenant.tenant_code.lower()}", + ou_name=f"{tenant.tenant_name}测试组织", + area=tenant.tenant_name, + ) + user_info = login_data["user_info"] + user_id = int(user_info["user_id"]) + admin_client.post( + f"/api/v3/rbac/users/{user_id}/roles", + json={"role_ids": [role_map[role_key]]}, + expected_status=200, + ) + admin_client.put( + f"/api/v3/rbac/users/{user_id}/tenant", + json={"tenant_code": tenant.tenant_code}, + expected_status=200, + ) + refreshed = anonymous_client.login_oauth( + sub=sub, + username=username, + nickname=nickname, + ou_id=f"pytest-{tenant.tenant_code.lower()}", + ou_name=f"{tenant.tenant_name}测试组织", + area=tenant.tenant_name, + ) + refreshed_user = refreshed["user_info"] + assert str(refreshed_user.get("tenant_code") or "") == tenant.tenant_code + return SeededUser( + user_id=user_id, + sub=sub, + username=username, + nickname=nickname, + role_key=role_key, + tenant_code=tenant.tenant_code, + token=str(refreshed["access_token"]), + ) + + +@pytest.fixture(scope="session") +def tenant_admin_user( + anonymous_client: ReleaseApiClient, + admin_client: ReleaseApiClient, + role_map: dict[str, int], + tenant_a: TenantSeed, +) -> SeededUser: + return _ensure_user( + anonymous_client=anonymous_client, + admin_client=admin_client, + role_map=role_map, + sub="pytest-admin-pta01", + username="pytest_admin_pta01", + nickname="Pytest租户A管理员", + role_key="admin", + tenant=tenant_a, + ) + + +@pytest.fixture(scope="session") +def tenant_admin_user_b( + anonymous_client: ReleaseApiClient, + admin_client: ReleaseApiClient, + role_map: dict[str, int], + tenant_b: TenantSeed, +) -> SeededUser: + return _ensure_user( + anonymous_client=anonymous_client, + admin_client=admin_client, + role_map=role_map, + sub="pytest-admin-ptb01", + username="pytest_admin_ptb01", + nickname="Pytest租户B管理员", + role_key="admin", + tenant=tenant_b, + ) + + +@pytest.fixture(scope="session") +def tenant_common_user_a( + anonymous_client: ReleaseApiClient, + admin_client: ReleaseApiClient, + role_map: dict[str, int], + tenant_a: TenantSeed, +) -> SeededUser: + return _ensure_user( + anonymous_client=anonymous_client, + admin_client=admin_client, + role_map=role_map, + sub="pytest-common-pta01", + username="pytest_common_pta01", + nickname="Pytest租户A普通用户", + role_key="common", + tenant=tenant_a, + ) + + +@pytest.fixture(scope="session") +def tenant_common_user_b( + anonymous_client: ReleaseApiClient, + admin_client: ReleaseApiClient, + role_map: dict[str, int], + tenant_b: TenantSeed, +) -> SeededUser: + return _ensure_user( + anonymous_client=anonymous_client, + admin_client=admin_client, + role_map=role_map, + sub="pytest-common-ptb01", + username="pytest_common_ptb01", + nickname="Pytest租户B普通用户", + role_key="common", + tenant=tenant_b, + ) + + +@pytest.fixture +def tenant_admin_api(release_config: ReleaseTestConfig, tenant_admin_user: SeededUser) -> ReleaseApiClient: + client = ReleaseApiClient(release_config, token=tenant_admin_user.token) + yield client + client.close() + + +@pytest.fixture +def tenant_admin_api_b(release_config: ReleaseTestConfig, tenant_admin_user_b: SeededUser) -> ReleaseApiClient: + client = ReleaseApiClient(release_config, token=tenant_admin_user_b.token) + yield client + client.close() + + +@pytest.fixture +def common_api_a(release_config: ReleaseTestConfig, tenant_common_user_a: SeededUser) -> ReleaseApiClient: + client = ReleaseApiClient(release_config, token=tenant_common_user_a.token) + yield client + client.close() + + +@pytest.fixture +def common_api_b(release_config: ReleaseTestConfig, tenant_common_user_b: SeededUser) -> ReleaseApiClient: + client = ReleaseApiClient(release_config, token=tenant_common_user_b.token) + yield client + client.close() + + +def _find_module_by_name(admin_client: ReleaseApiClient, module_name: str) -> dict[str, Any] | None: + response = admin_client.get(f"/api/v3/entry-modules?page=1&page_size=200&name={module_name}".replace("page_size", "page_size"), expected_status=None) + items = ReleaseApiClient.json_data(response)["items"] + for item in items: + if str(item.get("name") or "") == module_name: + return item + return None + + +@pytest.fixture +def release_entry_module( + admin_client: ReleaseApiClient, + release_config: ReleaseTestConfig, + tenant_b: TenantSeed, +) -> dict[str, Any]: + module_name = release_config.module_name + existing = _find_module_by_name(admin_client, module_name) + payload = { + "name": module_name, + "description": "pytest release acceptance only", + "path": release_config.module_path, + "route_path": release_config.module_path, + "tenants": [ + { + "tenant_code": tenant_b.tenant_code, + "tenant_name": tenant_b.tenant_name, + "enabled": True, + "sort_order": 1, + } + ], + } + created = False + if existing: + module_id = int(existing["id"]) + response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200) + else: + response = admin_client.post("/api/v3/entry-modules", json=payload, expected_status=None) + if response.status_code == 200: + created = True + else: + retried = False + for attempt in range(1, 4): + module_name = f"{release_config.module_name}-{attempt}" + payload["name"] = module_name + existing = _find_module_by_name(admin_client, module_name) + if existing: + module_id = int(existing["id"]) + response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200) + retried = True + break + response = admin_client.post("/api/v3/entry-modules", json=payload, expected_status=None) + if response.status_code == 200: + created = True + retried = True + break + assert retried, response.text + module = ReleaseApiClient.json_data(response) + try: + yield module + finally: + if created: + admin_client.delete(f"/api/v3/entry-modules/{int(module['id'])}", expected_status=200) + + +@pytest.fixture(scope="session") +def release_document_type(admin_client: ReleaseApiClient) -> DocumentTypeSeed: + response = admin_client.get("/api/document-types") + items = ReleaseApiClient.json_data(response) + assert isinstance(items, list), items + for item in items: + type_id = int(item.get("id") or 0) + type_code = str(item.get("code") or "").strip() + type_name = str(item.get("name") or "").strip() + if type_id > 0 and type_code: + return DocumentTypeSeed(type_id=type_id, type_code=type_code, type_name=type_name or type_code) + pytest.skip("当前环境没有可用于发布验收的文档类型") + + +@pytest.fixture +def make_release_document( + admin_client: ReleaseApiClient, + release_document_type: DocumentTypeSeed, +) -> Callable[..., ReleaseDocumentSeed]: + created_document_ids: list[int] = [] + + def _make_document( + *, + client: ReleaseApiClient, + tenant: TenantSeed, + file_name: str, + content: bytes | None = None, + content_type: str = "text/plain", + ) -> ReleaseDocumentSeed: + payload_bytes = content or f"pytest release document for {tenant.tenant_code} / {file_name}\n".encode("utf-8") + response = client.post( + "/api/upload", + data={ + "typeId": str(release_document_type.type_id), + "typeCode": release_document_type.type_code, + "tenant_code": tenant.tenant_code, + "region": tenant.tenant_name, + "fileRole": "primary", + "autoRun": "false", + "speed": "normal", + }, + files={ + "file": (file_name, payload_bytes, content_type), + }, + expected_status=200, + ) + data = ReleaseApiClient.json_data(response) + document_id = int(data["documentId"]) + created_document_ids.append(document_id) + return ReleaseDocumentSeed( + document_id=document_id, + file_id=int(data["fileId"]) if data.get("fileId") is not None else None, + type_id=release_document_type.type_id, + type_code=release_document_type.type_code, + tenant_code=tenant.tenant_code, + tenant_name=tenant.tenant_name, + file_name=file_name, + ) + + try: + yield _make_document + finally: + for document_id in reversed(created_document_ids): + admin_client.delete(f"/api/documents/{document_id}", expected_status=None) + + +@pytest.fixture +def make_release_dataset( + admin_client: ReleaseApiClient, +) -> Callable[..., ReleaseDatasetSeed]: + created_dataset_ids: list[int] = [] + + def _make_dataset( + *, + tenant: TenantSeed, + dataset_name: str, + is_public: bool = False, + is_default: bool = False, + ) -> ReleaseDatasetSeed: + response = admin_client.post( + "/api/v3/rag/datasets/admin", + json={ + "tenant_code": tenant.tenant_code, + "tenant_name": tenant.tenant_name, + "area": tenant.tenant_name, + "name": dataset_name, + "description": f"{dataset_name} - pytest release", + "is_public": is_public, + "is_default": is_default, + "status": 1, + "sort_order": 0, + }, + expected_status=200, + ) + data = ReleaseApiClient.json_data(response) + dataset_id = int(data["id"]) + created_dataset_ids.append(dataset_id) + return ReleaseDatasetSeed( + dataset_id=dataset_id, + dataset_name=str(data.get("name") or dataset_name), + tenant_code=str(data.get("tenantCode") or tenant.tenant_code), + tenant_name=str(data.get("tenantName") or tenant.tenant_name), + app_id=int(data["appId"]) if data.get("appId") is not None else None, + app_name=str(data.get("appName") or "") or None, + ) + + try: + yield _make_dataset + finally: + for dataset_id in reversed(created_dataset_ids): + admin_client.delete(f"/api/v3/rag/datasets/admin/{dataset_id}", expected_status=None) diff --git a/tests/release/helpers.py b/tests/release/helpers.py new file mode 100644 index 0000000..6218e24 --- /dev/null +++ b/tests/release/helpers.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(frozen=True) +class ReleaseTestConfig: + base_url: str + admin_username: str + admin_password: str + timeout_seconds: float + tenant_a_code: str + tenant_a_name: str + tenant_b_code: str + tenant_b_name: str + module_name: str + module_path: str + + +class ReleaseApiClient: + def __init__(self, config: ReleaseTestConfig, token: str | None = None) -> None: + self.config = config + self.token = token + self._client = httpx.Client(base_url=config.base_url, timeout=config.timeout_seconds) + + def close(self) -> None: + self._client.close() + + def with_token(self, token: str) -> "ReleaseApiClient": + return ReleaseApiClient(self.config, token=token) + + def request(self, method: str, path: str, *, expected_status: int | None = 200, **kwargs: Any) -> httpx.Response: + headers = dict(kwargs.pop("headers", {}) or {}) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + response = self._client.request(method, path, headers=headers, **kwargs) + if expected_status is not None: + assert response.status_code == expected_status, self._format_error(response) + return response + + def get(self, path: str, **kwargs: Any) -> httpx.Response: + return self.request("GET", path, **kwargs) + + def post(self, path: str, **kwargs: Any) -> httpx.Response: + return self.request("POST", path, **kwargs) + + def put(self, path: str, **kwargs: Any) -> httpx.Response: + return self.request("PUT", path, **kwargs) + + def patch(self, path: str, **kwargs: Any) -> httpx.Response: + return self.request("PATCH", path, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> httpx.Response: + return self.request("DELETE", path, **kwargs) + + def login_password(self, username: str, password: str) -> dict[str, Any]: + response = self.post( + "/api/auth/login", + json={"username": username, "password": password}, + expected_status=200, + ) + payload = response.json() + assert payload.get("success") is True, payload + data = payload.get("data") or {} + assert data.get("access_token"), payload + return data + + def login_oauth( + self, + *, + sub: str, + username: str, + nickname: str, + ou_id: str, + ou_name: str, + area: str | None = None, + ) -> dict[str, Any]: + response = self.post( + "/api/auth/login", + json={ + "userInfo": { + "sub": sub, + "username": username, + "nickname": nickname, + "email": f"{username}@pytest.local", + "phone_number": "13800000000", + "ou_id": ou_id, + "ou_name": ou_name, + "is_leader": False, + }, + "area": area, + "expiresIn": 3600, + }, + expected_status=200, + ) + payload = response.json() + assert payload.get("success") is True, payload + data = payload.get("data") or {} + assert data.get("access_token"), payload + return data + + @staticmethod + def json_data(response: httpx.Response) -> Any: + payload = response.json() + if isinstance(payload, dict) and "data" in payload: + return payload["data"] + return payload + + @staticmethod + def _format_error(response: httpx.Response) -> str: + try: + body = response.json() + except Exception: + body = response.text + return f"{response.request.method} {response.request.url} -> {response.status_code}: {body}" + + +def flatten_route_paths(routes: list[dict[str, Any]]) -> set[str]: + paths: set[str] = set() + + def collect(items: list[dict[str, Any]]) -> None: + for item in items: + route_path = str(item.get("route_path") or "") + if route_path: + paths.add(route_path) + children = item.get("children") or [] + if isinstance(children, list): + collect(children) + + collect(routes) + return paths diff --git a/tests/release/test_g1_rbac_context.py b/tests/release/test_g1_rbac_context.py new file mode 100644 index 0000000..d87521a --- /dev/null +++ b/tests/release/test_g1_rbac_context.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import pytest + +from .helpers import ReleaseApiClient, flatten_route_paths + + +@pytest.mark.release +def test_g1_admin_auth_and_rbac_context(admin_client: ReleaseApiClient) -> None: + me_response = admin_client.get("/api/auth/me") + me = ReleaseApiClient.json_data(me_response) + assert int(me["user_id"]) > 0 + assert isinstance(me.get("roles"), list) and me["roles"] + assert isinstance(me.get("permissions"), list) + + routes_response = admin_client.get("/api/rbac/user/routes") + routes_data = ReleaseApiClient.json_data(routes_response) + route_paths = flatten_route_paths(routes_data["routes"]) + assert "/home" in route_paths + assert "/settings" in route_paths + assert "/role-permissions" in route_paths + + users_response = admin_client.get("/api/v3/rbac/users?page=1&page_size=20") + users_data = ReleaseApiClient.json_data(users_response) + assert users_data["total"] >= 1 + assert isinstance(users_data["items"], list) + + org_response = admin_client.get("/api/admin/users/organizations/tree?include_users=false") + org_data = ReleaseApiClient.json_data(org_response) + assert "organizations" in org_data + assert org_data["total_organizations"] >= 1 + + tenant_option_response = admin_client.get("/api/v3/tenants/options?feature_key=home.entry_module") + tenant_option_data = ReleaseApiClient.json_data(tenant_option_response) + assert tenant_option_data["total"] >= 1 diff --git a/tests/release/test_g2_g3_tenant_entry_chain.py b/tests/release/test_g2_g3_tenant_entry_chain.py new file mode 100644 index 0000000..8c46a4f --- /dev/null +++ b/tests/release/test_g2_g3_tenant_entry_chain.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest + +from .conftest import SeededUser, TenantSeed +from .helpers import ReleaseApiClient + + +def _set_module_tenants(admin_client: ReleaseApiClient, module_id: int, module_name: str, tenants: list[TenantSeed], path: str) -> dict: + payload = { + "name": module_name, + "description": "pytest release acceptance only", + "path": path, + "route_path": path, + "tenants": [ + { + "tenant_code": tenant.tenant_code, + "tenant_name": tenant.tenant_name, + "enabled": True, + "sort_order": index, + } + for index, tenant in enumerate(tenants, start=1) + ], + } + response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200) + return ReleaseApiClient.json_data(response) + + +def _home_module_names(client: ReleaseApiClient) -> list[str]: + response = client.get("/api/home/entry-modules") + modules = ReleaseApiClient.json_data(response) + return [str(item.get("name") or "") for item in modules] + + +@pytest.mark.release +def test_g2_tenant_detail_and_user_tenant_switch( + admin_client: ReleaseApiClient, + anonymous_client: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_common_user_a: SeededUser, +) -> None: + detail_response = admin_client.get(f"/api/v3/tenants/{tenant_a.tenant_code}") + detail = ReleaseApiClient.json_data(detail_response) + assert detail["tenant_code"] == tenant_a.tenant_code + assert detail["tenant_name"] == tenant_a.tenant_name + assert "home.entry_module" in detail["feature_keys"] + + switch_response = admin_client.put( + f"/api/v3/rbac/users/{tenant_common_user_a.user_id}/tenant", + json={"tenant_code": tenant_a.tenant_code}, + expected_status=200, + ) + switch_data = ReleaseApiClient.json_data(switch_response) + assert switch_data["tenant_code"] == tenant_a.tenant_code + + relogin = anonymous_client.login_oauth( + sub=tenant_common_user_a.sub, + username=tenant_common_user_a.username, + nickname=tenant_common_user_a.nickname, + ou_id=f"pytest-{tenant_a.tenant_code.lower()}", + ou_name=f"{tenant_a.tenant_name}测试组织", + area=tenant_a.tenant_name, + ) + assert relogin["user_info"]["tenant_code"] == tenant_a.tenant_code + + +@pytest.mark.release +def test_g3_entry_module_visibility_follows_tenant_assignment( + admin_client: ReleaseApiClient, + release_entry_module: dict, + release_config, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + common_api_a: ReleaseApiClient, + common_api_b: ReleaseApiClient, +) -> None: + module_id = int(release_entry_module["id"]) + module_name = str(release_entry_module["name"]) + + _set_module_tenants(admin_client, module_id, module_name, [tenant_b], release_config.module_path) + names_a = _home_module_names(common_api_a) + names_b = _home_module_names(common_api_b) + assert module_name not in names_a + assert module_name in names_b + + _set_module_tenants(admin_client, module_id, module_name, [tenant_a, tenant_b], release_config.module_path) + names_a = _home_module_names(common_api_a) + names_b = _home_module_names(common_api_b) + assert module_name in names_a + assert module_name in names_b diff --git a/tests/release/test_g4_documents.py b/tests/release/test_g4_documents.py new file mode 100644 index 0000000..cee7004 --- /dev/null +++ b/tests/release/test_g4_documents.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import time + +import fitz +import pytest + +from .conftest import TenantSeed +from .helpers import ReleaseApiClient + + +def _document_ids(items: list[dict]) -> set[int]: + return {int(item["documentId"]) for item in items if item.get("documentId") is not None} + + +def _sample_pdf_bytes(text: str) -> bytes: + document = fitz.open() + page = document.new_page() + page.insert_text((72, 72), text) + return document.tobytes() + + +@pytest.mark.release +def test_g4_document_list_and_detail_respect_tenant_boundary( + tenant_admin_api: ReleaseApiClient, + tenant_admin_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + make_release_document, +) -> None: + own_doc = make_release_document( + client=tenant_admin_api, + tenant=tenant_a, + file_name=f"pytest-g4-a-{int(time.time())}.txt", + ) + other_doc = make_release_document( + client=tenant_admin_api_b, + tenant=tenant_b, + file_name=f"pytest-g4-b-{int(time.time())}.txt", + ) + + own_list = ReleaseApiClient.json_data(tenant_admin_api.get("/api/documents/list?page=1&pageSize=100")) + own_ids = _document_ids(own_list["documents"]) + assert own_doc.document_id in own_ids + assert other_doc.document_id not in own_ids + + own_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/documents/{own_doc.document_id}")) + assert int(own_detail["documentId"]) == own_doc.document_id + assert str(own_detail.get("tenantCode") or "") == tenant_a.tenant_code + + cross_detail = tenant_admin_api.get(f"/api/documents/{other_doc.document_id}", expected_status=404) + assert "无权访问" in cross_detail.text or "不存在" in cross_detail.text + + +@pytest.mark.release +def test_g4_document_update_append_and_delete_reject_cross_tenant_access( + tenant_admin_api: ReleaseApiClient, + tenant_admin_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + make_release_document, +) -> None: + own_doc = make_release_document( + client=tenant_admin_api, + tenant=tenant_a, + file_name=f"pytest-g4-own-{int(time.time())}.pdf", + content=_sample_pdf_bytes("pytest g4 own document"), + content_type="application/pdf", + ) + + update_response = tenant_admin_api.put( + f"/api/documents/{own_doc.document_id}", + json={"remark": "pytest g4 own update"}, + expected_status=200, + ) + update_data = ReleaseApiClient.json_data(update_response) + assert int(update_data["documentId"]) == own_doc.document_id + + append_response = tenant_admin_api.post( + f"/api/documents/{own_doc.document_id}/attachments", + data={"mergeMode": "new", "remark": "pytest attachment"}, + files=[("files", ("attachment.pdf", _sample_pdf_bytes("pytest attachment"), "application/pdf"))], + expected_status=200, + ) + append_data = ReleaseApiClient.json_data(append_response) + new_document_id = int(append_data["documentId"]) + assert new_document_id != own_doc.document_id + assert str(append_data.get("tenantCode") or "") == tenant_a.tenant_code + assert int(append_data.get("previousVersionId") or 0) == own_doc.document_id + + cross_update = tenant_admin_api_b.put( + f"/api/documents/{new_document_id}", + json={"remark": "should fail"}, + expected_status=404, + ) + assert "无权访问" in cross_update.text or "不存在" in cross_update.text + + cross_append = tenant_admin_api_b.post( + f"/api/documents/{new_document_id}/attachments", + data={"mergeMode": "new", "remark": "should fail"}, + files=[("files", ("cross.pdf", _sample_pdf_bytes("cross tenant"), "application/pdf"))], + expected_status=404, + ) + assert "无权访问" in cross_append.text or "不存在" in cross_append.text + + cross_delete = tenant_admin_api_b.delete(f"/api/documents/{new_document_id}", expected_status=404) + assert "无权访问" in cross_delete.text or "不存在" in cross_delete.text + + +@pytest.mark.release +def test_g4_common_user_only_sees_self_created_documents( + common_api_a: ReleaseApiClient, + tenant_admin_api: ReleaseApiClient, + tenant_a: TenantSeed, + make_release_document, +) -> None: + user_doc = make_release_document( + client=common_api_a, + tenant=tenant_a, + file_name=f"pytest-g4-common-self-{int(time.time())}.txt", + ) + admin_doc = make_release_document( + client=tenant_admin_api, + tenant=tenant_a, + file_name=f"pytest-g4-common-other-{int(time.time())}.txt", + ) + + own_list = ReleaseApiClient.json_data(common_api_a.get("/api/documents/list?page=1&pageSize=100")) + own_ids = _document_ids(own_list["documents"]) + assert user_doc.document_id in own_ids + assert admin_doc.document_id not in own_ids + + own_detail = ReleaseApiClient.json_data(common_api_a.get(f"/api/documents/{user_doc.document_id}")) + assert int(own_detail["documentId"]) == user_doc.document_id + + hidden_detail = common_api_a.get(f"/api/documents/{admin_doc.document_id}", expected_status=404) + assert "无权访问" in hidden_detail.text or "不存在" in hidden_detail.text + + +@pytest.mark.release +def test_g4_govdoc_list_endpoint_does_not_fail_when_backfilling_version_groups( + tenant_admin_api: ReleaseApiClient, +) -> None: + response = tenant_admin_api.get("/api/govdoc/documents?page=1&pageSize=10", expected_status=200) + payload = response.json() + assert payload.get("code") in {0, 200}, payload + assert "data" in payload, payload diff --git a/tests/release/test_g5_rag.py b/tests/release/test_g5_rag.py new file mode 100644 index 0000000..913ea62 --- /dev/null +++ b/tests/release/test_g5_rag.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import time + +import pytest + +from .conftest import ReleaseDatasetSeed, TenantSeed +from .helpers import ReleaseApiClient + + +def _dataset_ids(items: list[dict]) -> set[int]: + return {int(item["id"]) for item in items if item.get("id") is not None} + + +def _app_ids(items: list[dict]) -> set[str]: + return {str(item["appId"]) for item in items if item.get("appId") is not None} + + +@pytest.mark.release +def test_g5_rag_dataset_admin_scope_is_limited_to_own_tenant( + admin_client: ReleaseApiClient, + tenant_admin_api: ReleaseApiClient, + tenant_admin_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + make_release_dataset, +) -> None: + dataset_a = make_release_dataset( + tenant=tenant_a, + dataset_name=f"Pytest G5 A {int(time.time())}", + ) + dataset_b = make_release_dataset( + tenant=tenant_b, + dataset_name=f"Pytest G5 B {int(time.time())}", + ) + + global_admin_list = ReleaseApiClient.json_data(admin_client.get("/api/v3/rag/datasets/admin?page=1&pageSize=100")) + ids_a = _dataset_ids(global_admin_list["data"]) + assert dataset_a.dataset_id in ids_a + assert dataset_b.dataset_id in ids_a + + tenant_admin_list = ReleaseApiClient.json_data( + tenant_admin_api.get("/api/v3/rag/datasets/admin?page=1&pageSize=100") + ) + tenant_admin_ids = _dataset_ids(tenant_admin_list["data"]) + assert dataset_a.dataset_id in tenant_admin_ids + assert dataset_b.dataset_id not in tenant_admin_ids + + forbidden_query = tenant_admin_api.get( + f"/api/v3/rag/datasets/admin?page=1&pageSize=100&tenant_code={tenant_b.tenant_code}", + expected_status=403, + ) + assert "本地区知识库配置" in forbidden_query.text or "本租户知识库" in forbidden_query.text + + +@pytest.mark.release +def test_g5_rag_dataset_detail_and_update_respect_tenant_boundary( + admin_client: ReleaseApiClient, + tenant_admin_api: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + make_release_dataset, +) -> None: + dataset_a = make_release_dataset( + tenant=tenant_a, + dataset_name=f"Pytest G5 Detail A {int(time.time())}", + ) + dataset_b = make_release_dataset( + tenant=tenant_b, + dataset_name=f"Pytest G5 Detail B {int(time.time())}", + ) + + own_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/v3/rag/datasets/{dataset_a.dataset_id}")) + assert int(own_detail["id"]) == dataset_a.dataset_id + assert str(own_detail.get("tenantCode") or "") == tenant_a.tenant_code + + hidden_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/v3/rag/datasets/{dataset_b.dataset_id}")) + assert hidden_detail is None + + own_update = ReleaseApiClient.json_data( + tenant_admin_api.patch( + f"/api/v3/rag/datasets/{dataset_a.dataset_id}", + json={"name": f"{dataset_a.dataset_name}-tenant-updated"}, + expected_status=200, + ) + ) + assert str(own_update["name"]).endswith("-tenant-updated") + + hidden_update = ReleaseApiClient.json_data( + tenant_admin_api.patch( + f"/api/v3/rag/datasets/{dataset_b.dataset_id}", + json={"name": "should-not-work"}, + expected_status=200, + ) + ) + assert hidden_update is None + + admin_update = ReleaseApiClient.json_data( + admin_client.patch( + f"/api/v3/rag/datasets/{dataset_a.dataset_id}", + json={"name": f"{dataset_a.dataset_name}-updated"}, + expected_status=200, + ) + ) + assert str(admin_update["name"]).endswith("-updated") + + +@pytest.mark.release +def test_g5_rag_apps_and_public_dataset_visibility( + tenant_admin_api: ReleaseApiClient, + tenant_admin_api_b: ReleaseApiClient, + common_api_a: ReleaseApiClient, + common_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + make_release_dataset, +) -> None: + dataset_a = make_release_dataset( + tenant=tenant_a, + dataset_name=f"Pytest G5 App A {int(time.time())}", + is_default=True, + ) + dataset_b = make_release_dataset( + tenant=tenant_b, + dataset_name=f"Pytest G5 App B {int(time.time())}", + is_default=True, + ) + public_dataset = make_release_dataset( + tenant=tenant_a, + dataset_name=f"Pytest G5 Public {int(time.time())}", + is_public=True, + is_default=False, + ) + + apps_a = ReleaseApiClient.json_data(common_api_a.get("/api/v3/rag/apps")) + app_ids_a = _app_ids(apps_a["data"]) + assert dataset_a.app_id is not None + assert str(dataset_a.app_id) in app_ids_a + assert dataset_b.app_id is not None + assert str(dataset_b.app_id) not in app_ids_a + assert public_dataset.app_id is not None + assert str(public_dataset.app_id) in app_ids_a + + apps_b = ReleaseApiClient.json_data(common_api_b.get("/api/v3/rag/apps")) + app_ids_b = _app_ids(apps_b["data"]) + assert str(dataset_b.app_id) in app_ids_b + assert str(dataset_a.app_id) not in app_ids_b + assert str(public_dataset.app_id) in app_ids_b + + default_app_a = ReleaseApiClient.json_data(common_api_a.get("/api/v3/rag/apps/default")) + assert default_app_a is not None + assert str(default_app_a.get("tenantCode") or "") in {tenant_a.tenant_code, public_dataset.tenant_code} + + +@pytest.mark.release +def test_g5_common_user_cannot_access_rag_admin_endpoints( + common_api_a: ReleaseApiClient, + tenant_a: TenantSeed, +) -> None: + forbidden_admin_list = common_api_a.get("/api/v3/rag/datasets/admin?page=1&pageSize=20", expected_status=403) + assert "管理知识库权限" in forbidden_admin_list.text + + forbidden_create = common_api_a.post( + "/api/v3/rag/datasets/admin", + json={ + "tenant_code": tenant_a.tenant_code, + "tenant_name": tenant_a.tenant_name, + "area": tenant_a.tenant_name, + "name": f"should-forbid-{int(time.time())}", + "description": "forbidden", + "status": 1, + }, + expected_status=403, + ) + assert "创建知识库权限" in forbidden_create.text diff --git a/tests/release/test_g5_rule_cross_review_matrix.py b/tests/release/test_g5_rule_cross_review_matrix.py new file mode 100644 index 0000000..60f0db1 --- /dev/null +++ b/tests/release/test_g5_rule_cross_review_matrix.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import time + +import pytest + +from .conftest import SeededUser, TenantSeed +from .helpers import ReleaseApiClient + + +@pytest.mark.release +def test_g5_rule_sets_are_globally_readable_but_bindings_and_groups_follow_tenant_scope( + admin_client: ReleaseApiClient, + tenant_admin_api: ReleaseApiClient, + common_api_a: ReleaseApiClient, +) -> None: + admin_rule_sets = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets")) + tenant_rule_sets = ReleaseApiClient.json_data(tenant_admin_api.get("/api/rule-sets")) + common_rule_sets = ReleaseApiClient.json_data(common_api_a.get("/api/rule-sets")) + + assert len(admin_rule_sets) > 0 + assert len(tenant_rule_sets) == len(admin_rule_sets) + assert len(common_rule_sets) == len(admin_rule_sets) + + admin_bindings = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets/bindings")) + tenant_bindings = ReleaseApiClient.json_data(tenant_admin_api.get("/api/rule-sets/bindings")) + common_bindings = ReleaseApiClient.json_data(common_api_a.get("/api/rule-sets/bindings")) + assert isinstance(admin_bindings, list) + assert len(admin_bindings) > 0 + assert tenant_bindings == [] + assert common_bindings == [] + + admin_groups = admin_client.get("/api/v3/evaluation-point-groups/all") + tenant_groups = tenant_admin_api.get("/api/v3/evaluation-point-groups/all") + common_groups = common_api_a.get("/api/v3/evaluation-point-groups/all") + assert ReleaseApiClient.json_data(admin_groups) != [] + assert ReleaseApiClient.json_data(tenant_groups) == [] + assert ReleaseApiClient.json_data(common_groups) == [] + + +@pytest.mark.release +def test_g5_group_detail_and_children_follow_same_scope_boundary( + admin_client: ReleaseApiClient, + tenant_admin_api: ReleaseApiClient, + common_api_a: ReleaseApiClient, +) -> None: + admin_groups = ReleaseApiClient.json_data(admin_client.get("/api/v3/evaluation-point-groups/all")) + assert admin_groups != [] + + target_group = admin_groups[0] + target_group_id = int(target_group["id"]) + + admin_detail = ReleaseApiClient.json_data( + admin_client.get(f"/api/v3/evaluation-point-groups/{target_group_id}") + ) + assert int(admin_detail["id"]) == target_group_id + + admin_children = admin_client.get( + f"/api/v3/evaluation-point-groups/{target_group_id}/children?page=1&page_size=20" + ).json() + assert isinstance(admin_children, dict) + assert "data" in admin_children + + tenant_detail = tenant_admin_api.get( + f"/api/v3/evaluation-point-groups/{target_group_id}", + expected_status=404, + ) + assert "规则分组不存在" in tenant_detail.text + + tenant_children = tenant_admin_api.get( + f"/api/v3/evaluation-point-groups/{target_group_id}/children?page=1&page_size=20", + expected_status=404, + ) + assert "规则分组不存在" in tenant_children.text + + common_detail = common_api_a.get( + f"/api/v3/evaluation-point-groups/{target_group_id}", + expected_status=404, + ) + assert "规则分组不存在" in common_detail.text + + +@pytest.mark.release +def test_g5_cross_review_rejects_cross_tenant_documents_and_members( + tenant_admin_api: ReleaseApiClient, + common_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + tenant_common_user_a: SeededUser, + tenant_common_user_b: SeededUser, + make_release_document, +) -> None: + doc_a = make_release_document( + client=tenant_admin_api, + tenant=tenant_a, + file_name=f"pytest-cross-a-{int(time.time())}.txt", + ) + doc_b = make_release_document( + client=common_api_b, + tenant=tenant_b, + file_name=f"pytest-cross-b-{int(time.time())}.txt", + ) + + same_tenant = tenant_admin_api.post( + "/api/v3/cross-review/tasks", + json={ + "taskName": f"Pytest Cross Same {int(time.time())}", + "taskType": "CITY", + "memberUserIds": [tenant_common_user_a.user_id], + "principalUserIds": [], + "documentIds": [doc_a.document_id], + }, + expected_status=200, + ) + same_tenant_data = ReleaseApiClient.json_data(same_tenant) + assert int(same_tenant_data["documentCount"]) == 1 + + cross_doc = tenant_admin_api.post( + "/api/v3/cross-review/tasks", + json={ + "taskName": f"Pytest Cross Doc {int(time.time())}", + "taskType": "CITY", + "memberUserIds": [tenant_common_user_a.user_id], + "principalUserIds": [], + "documentIds": [doc_b.document_id], + }, + expected_status=403, + ) + assert "其他租户文档" in cross_doc.text + + cross_member = tenant_admin_api.post( + "/api/v3/cross-review/tasks", + json={ + "taskName": f"Pytest Cross Member {int(time.time())}", + "taskType": "CITY", + "memberUserIds": [tenant_common_user_b.user_id], + "principalUserIds": [], + "documentIds": [doc_a.document_id], + }, + expected_status=403, + ) + assert "其他租户用户" in cross_member.text + + +@pytest.mark.release +def test_g5_cross_review_task_visibility_progress_and_documents_follow_member_scope( + tenant_admin_api: ReleaseApiClient, + tenant_admin_api_b: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_common_user_a: SeededUser, + make_release_document, +) -> None: + doc_a = make_release_document( + client=tenant_admin_api, + tenant=tenant_a, + file_name=f"pytest-cross-query-{int(time.time())}.txt", + ) + created = tenant_admin_api.post( + "/api/v3/cross-review/tasks", + json={ + "taskName": f"Pytest Query Task {int(time.time())}", + "taskType": "CITY", + "memberUserIds": [tenant_common_user_a.user_id], + "principalUserIds": [], + "documentIds": [doc_a.document_id], + }, + expected_status=200, + ) + created_data = ReleaseApiClient.json_data(created) + task_id = int(created_data["taskId"]) + + queried = ReleaseApiClient.json_data( + tenant_admin_api.post( + "/api/v3/cross-review/tasks/query", + json={"page": 1, "pageSize": 50, "keyword": "Pytest Query Task"}, + expected_status=200, + ) + ) + task_ids = {int(item["taskId"]) for item in queried["items"]} + assert task_id in task_ids + + created_item = next(item for item in queried["items"] if int(item["taskId"]) == task_id) + evaluation_tenant_codes = {str(item.get("tenantCode") or "") for item in created_item.get("evaluationTenants") or []} + assert tenant_a.tenant_code in evaluation_tenant_codes + + progress = ReleaseApiClient.json_data( + tenant_admin_api.get(f"/api/v3/cross-review/tasks/{task_id}/progress", expected_status=200) + ) + assert int(progress["taskId"]) == task_id + assert int(progress["totalDocuments"]) == 1 + + documents = ReleaseApiClient.json_data( + tenant_admin_api.get( + f"/api/v3/cross-review/tasks/{task_id}/documents?page=1&pageSize=20", + expected_status=200, + ) + ) + assert int(documents["taskId"]) == task_id + returned_document_ids = {int(item["documentId"]) for item in documents["items"]} + assert doc_a.document_id in returned_document_ids + + cross_tenant_query = ReleaseApiClient.json_data( + tenant_admin_api_b.post( + "/api/v3/cross-review/tasks/query", + json={"page": 1, "pageSize": 50, "keyword": "Pytest Query Task"}, + expected_status=200, + ) + ) + cross_tenant_task_ids = {int(item["taskId"]) for item in cross_tenant_query["items"]} + assert task_id not in cross_tenant_task_ids + + cross_tenant_progress = tenant_admin_api_b.get( + f"/api/v3/cross-review/tasks/{task_id}/progress", + expected_status=403, + ) + assert "当前用户不是交叉评查任务成员" in cross_tenant_progress.text + + cross_tenant_documents = tenant_admin_api_b.get( + f"/api/v3/cross-review/tasks/{task_id}/documents?page=1&pageSize=20", + expected_status=403, + ) + assert "当前用户不是交叉评查任务成员" in cross_tenant_documents.text diff --git a/tests/release/test_g6_rule_version_management.py b/tests/release/test_g6_rule_version_management.py new file mode 100644 index 0000000..46ba927 --- /dev/null +++ b/tests/release/test_g6_rule_version_management.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +import re +import time +from typing import Any + +import pytest + +from .helpers import ReleaseApiClient + + +def _select_rule_set(client: ReleaseApiClient) -> dict[str, Any]: + preferred_rule_type = os.getenv("LEAUDIT_TEST_RULE_TYPE", "contract.entrust").strip() + rule_sets = ReleaseApiClient.json_data(client.get("/api/rule-sets")) + assert isinstance(rule_sets, list) + assert rule_sets, "当前环境没有规则集,无法测试版本管理" + + for item in rule_sets: + if str(item.get("ruleType") or "") == preferred_rule_type and item.get("currentVersionId"): + return item + + for item in rule_sets: + if item.get("currentVersionId"): + return item + + pytest.skip("当前环境没有带 currentVersionId 的规则集,无法测试发布/回滚闭环") + + +def _set_metadata_version(yaml_text: str, version_no: str) -> str: + lines = yaml_text.splitlines() + metadata_index: int | None = None + version_pattern = re.compile(r"^(\s*)version\s*:") + + for index, line in enumerate(lines): + if line.strip() == "metadata:": + metadata_index = index + continue + if metadata_index is None: + continue + if line and not line.startswith((" ", "\t")): + break + match = version_pattern.match(line) + if match: + lines[index] = f"{match.group(1)}version: '{version_no}'" + return "\n".join(lines) + ("\n" if yaml_text.endswith("\n") else "") + + if metadata_index is None: + return f"metadata:\n version: '{version_no}'\n{yaml_text}" + + lines.insert(metadata_index + 1, f" version: '{version_no}'") + return "\n".join(lines) + ("\n" if yaml_text.endswith("\n") else "") + + +def _rule_set_by_type(client: ReleaseApiClient, rule_type: str) -> dict[str, Any]: + rule_sets = ReleaseApiClient.json_data(client.get("/api/rule-sets")) + for item in rule_sets: + if str(item.get("ruleType") or "") == rule_type: + return item + raise AssertionError(f"规则集不存在: {rule_type}") + + +@pytest.mark.release +def test_g6_rule_detail_version_management_save_publish_and_rollback(admin_client: ReleaseApiClient) -> None: + """覆盖规则配置详情页的版本管理闭环。 + + 该用例会创建一个真实历史版本并短暂发布,最后回滚到执行前的 currentVersionId。 + """ + + target_rule_set = _select_rule_set(admin_client) + rule_type = str(target_rule_set["ruleType"]) + original_rule_set_id = int(target_rule_set["id"]) + original_current_version_id = int(target_rule_set["currentVersionId"]) + original_tenant_code = str(target_rule_set.get("effectiveTenantCode") or "") + + versions_before = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/{rule_type}/versions")) + assert isinstance(versions_before, list) + assert {int(item["ruleSetId"]) for item in versions_before} == {original_rule_set_id} + assert original_current_version_id in {int(item["id"]) for item in versions_before} + + content = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/versions/{original_current_version_id}/content")) + assert int(content["ruleSetId"]) == original_rule_set_id + assert str(content["ruleType"]) == rule_type + assert str(content["yamlText"]).strip() + + new_version_no = f"pytest-vm-{int(time.time())}" + yaml_text = _set_metadata_version(str(content["yamlText"]), new_version_no) + created_version_id: int | None = None + + try: + created = ReleaseApiClient.json_data( + admin_client.post( + f"/api/rule-sets/{rule_type}/versions", + json={ + "yamlText": yaml_text, + "changeNote": f"pytest rule detail version management smoke {new_version_no}", + }, + expected_status=200, + ) + ) + created_version_id = int(created["id"]) + assert int(created["ruleSetId"]) == original_rule_set_id + assert str(created["versionNo"]) == new_version_no + assert str(created["status"]) == "draft" + + versions_after_create = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/{rule_type}/versions")) + assert created_version_id in {int(item["id"]) for item in versions_after_create} + assert {int(item["ruleSetId"]) for item in versions_after_create} == {original_rule_set_id} + + published = ReleaseApiClient.json_data( + admin_client.post( + f"/api/rule-sets/{rule_type}/publish", + json={"versionId": created_version_id}, + expected_status=200, + ) + ) + assert int(published["id"]) == created_version_id + assert int(published["ruleSetId"]) == original_rule_set_id + assert str(published["status"]) == "published" + + current_after_publish = _rule_set_by_type(admin_client, rule_type) + assert int(current_after_publish["id"]) == original_rule_set_id + assert int(current_after_publish["currentVersionId"]) == created_version_id + assert str(current_after_publish.get("effectiveTenantCode") or "") == original_tenant_code + + finally: + if created_version_id: + admin_client.post( + f"/api/rule-sets/{rule_type}/rollback", + json={"versionId": original_current_version_id}, + expected_status=200, + ) + + restored = _rule_set_by_type(admin_client, rule_type) + assert int(restored["id"]) == original_rule_set_id + assert int(restored["currentVersionId"]) == original_current_version_id + assert str(restored.get("effectiveTenantCode") or "") == original_tenant_code + + +@pytest.mark.release +def test_g6_rule_detail_version_management_rejects_cross_rule_set_publish(admin_client: ReleaseApiClient) -> None: + """发布接口必须拒绝把其他规则集的版本发布到当前规则类型。""" + + rule_sets = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets")) + candidates = [item for item in rule_sets if item.get("currentVersionId")] + if len(candidates) < 2: + pytest.skip("当前环境少于两个带 currentVersionId 的规则集,无法测试跨规则集发布拦截") + + left = candidates[0] + right = next((item for item in candidates[1:] if str(item["ruleType"]) != str(left["ruleType"])), None) + if right is None: + pytest.skip("当前环境没有可用于跨规则类型发布拦截的第二个规则集") + + response = admin_client.post( + f"/api/rule-sets/{left['ruleType']}/publish", + json={"versionId": int(right["currentVersionId"])}, + expected_status=403, + ) + assert "当前租户不能发布或回滚其他租户的规则版本" in response.text diff --git a/tests/release/test_role_tenant_matrix.py b/tests/release/test_role_tenant_matrix.py new file mode 100644 index 0000000..7e874de --- /dev/null +++ b/tests/release/test_role_tenant_matrix.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import pytest + +from .conftest import SeededUser, TenantSeed +from .helpers import ReleaseApiClient + + +@pytest.mark.release +def test_global_admin_can_query_cross_tenant_scope( + admin_client: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, +) -> None: + data_a = ReleaseApiClient.json_data( + admin_client.get(f"/api/v3/rbac/users?page=1&page_size=100&tenant_code={tenant_a.tenant_code}") + ) + data_b = ReleaseApiClient.json_data( + admin_client.get(f"/api/v3/rbac/users?page=1&page_size=100&tenant_code={tenant_b.tenant_code}") + ) + assert isinstance(data_a["items"], list) + assert isinstance(data_b["items"], list) + + +@pytest.mark.release +def test_tenant_admin_is_limited_to_own_tenant_scope( + tenant_admin_api: ReleaseApiClient, + tenant_a: TenantSeed, + tenant_b: TenantSeed, + tenant_common_user_a: SeededUser, + tenant_common_user_b: SeededUser, +) -> None: + own_scope = ReleaseApiClient.json_data(tenant_admin_api.get("/api/v3/rbac/users?page=1&page_size=100")) + tenant_codes = {str(item.get("tenant_code") or "") for item in own_scope["items"]} + assert tenant_a.tenant_code in tenant_codes + assert tenant_b.tenant_code not in tenant_codes + + forbidden_query = tenant_admin_api.get( + f"/api/v3/rbac/users?page=1&page_size=20&tenant_code={tenant_b.tenant_code}", + expected_status=403, + ) + assert "不能查询其他租户用户" in forbidden_query.text + + same_tenant_update = tenant_admin_api.put( + f"/api/v3/rbac/users/{tenant_common_user_a.user_id}/tenant", + json={"tenant_code": tenant_a.tenant_code}, + expected_status=200, + ) + same_tenant_data = ReleaseApiClient.json_data(same_tenant_update) + assert same_tenant_data["tenant_code"] == tenant_a.tenant_code + + cross_tenant_update = tenant_admin_api.put( + f"/api/v3/rbac/users/{tenant_common_user_b.user_id}/tenant", + json={"tenant_code": tenant_a.tenant_code}, + expected_status=403, + ) + assert "不能修改其他租户用户" in cross_tenant_update.text + + +@pytest.mark.release +def test_common_user_cannot_access_management_but_keeps_business_entry( + common_api_a: ReleaseApiClient, + release_entry_module: dict, + admin_client: ReleaseApiClient, + release_config, + tenant_a: TenantSeed, + tenant_b: TenantSeed, +) -> None: + module_id = int(release_entry_module["id"]) + module_name = str(release_entry_module["name"]) + admin_client.put( + f"/api/v3/entry-modules/{module_id}", + json={ + "name": module_name, + "description": "pytest release acceptance only", + "path": release_config.module_path, + "route_path": release_config.module_path, + "tenants": [ + { + "tenant_code": tenant_a.tenant_code, + "tenant_name": tenant_a.tenant_name, + "enabled": True, + "sort_order": 1, + }, + { + "tenant_code": tenant_b.tenant_code, + "tenant_name": tenant_b.tenant_name, + "enabled": True, + "sort_order": 2, + }, + ], + }, + expected_status=200, + ) + + users_response = common_api_a.get("/api/v3/rbac/users?page=1&page_size=20", expected_status=403) + assert "系统设置管理权限" in users_response.text + + tenants_response = common_api_a.get("/api/v3/tenants", expected_status=403) + assert "租户" in tenants_response.text + + home_response = common_api_a.get("/api/home/entry-modules") + home_modules = ReleaseApiClient.json_data(home_response) + home_names = [str(item.get("name") or "") for item in home_modules] + assert module_name in home_names diff --git a/tests/test_rule_config_source_fields.py b/tests/test_rule_config_source_fields.py new file mode 100644 index 0000000..b030f08 --- /dev/null +++ b/tests/test_rule_config_source_fields.py @@ -0,0 +1,174 @@ +import asyncio + +from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import RuleConfigServiceImpl + + +def test_rule_config_source_fields_are_backfilled_from_binding_and_rule_set_meta(): + service = RuleConfigServiceImpl() + + tenant_binding = {"id": 101, "group_id": 11, "rule_set_id": 201, "tenant_code": "MZ"} + provincial_binding = {"id": 102, "group_id": 12, "rule_set_id": 202, "tenant_code": "PROVINCIAL"} + public_binding = {"id": 103, "group_id": 13, "rule_set_id": 203, "tenant_code": "PUBLIC"} + rule_set_map = { + 201: { + "rule_type": "contract", + "rule_name": "Tenant Rule", + "current_version_id": 301, + "fallback_version_id": None, + "has_usable_version": True, + "usable_rule_count": 2, + "source_rule_set_id": 901, + }, + 202: { + "rule_type": "contract", + "rule_name": "Provincial Rule", + "current_version_id": 302, + "fallback_version_id": None, + "has_usable_version": True, + "usable_rule_count": 1, + "source_rule_set_id": 902, + }, + 203: { + "rule_type": "contract", + "rule_name": "Public Rule", + "current_version_id": 303, + "fallback_version_id": None, + "has_usable_version": True, + "usable_rule_count": 1, + "source_rule_set_id": 903, + }, + } + + row = { + "group_id": 11, + "root_group_id": 1, + "document_type_id": 21, + "document_type_name": "合同", + "main_type": "主类", + "subtype": "子类", + "entry_module_name": "模块", + } + + async def run(): + service._load_effective_binding = fake_load_effective_binding + service._load_yaml_text_by_version_id = fake_load_yaml_text_by_version_id + service._load_latest_version_id = fake_load_latest_version_id + service._load_current_user = fake_load_current_user + + tenant_pack = await service._build_pack_vo(row, rule_set_map, CurrentUserId=1) + provincial_pack = await service._build_pack_vo({**row, "group_id": 12}, rule_set_map, CurrentUserId=1) + public_pack = await service._build_pack_vo({**row, "group_id": 13}, rule_set_map, CurrentUserId=1) + + tenant_summary = service._build_pack_summary_item( + row={**row, "group_id": 11}, + binding=tenant_binding, + rule_set_map=rule_set_map, + latest_version_map={}, + current_user={"tenant_code": "MZ", "is_global": False}, + ) + provincial_summary = service._build_pack_summary_item( + row={**row, "group_id": 12}, + binding=provincial_binding, + rule_set_map=rule_set_map, + latest_version_map={}, + current_user={"tenant_code": "MZ", "is_global": False}, + ) + public_summary = service._build_pack_summary_item( + row={**row, "group_id": 13}, + binding=public_binding, + rule_set_map=rule_set_map, + latest_version_map={}, + current_user={"tenant_code": "MZ", "is_global": False}, + ) + return tenant_pack, provincial_pack, public_pack, tenant_summary, provincial_summary, public_summary + + async def fake_load_effective_binding(group_id: int, CurrentUserId=None): + return { + 11: tenant_binding, + 12: provincial_binding, + 13: public_binding, + }[group_id] + + async def fake_load_yaml_text_by_version_id(version_id: int): + return f"yaml-{version_id}" + + async def fake_load_latest_version_id(rule_set_id: int): + return None + + async def fake_load_current_user(CurrentUserId=None): + return {"tenant_code": "MZ", "is_global": False} + + tenant_pack, provincial_pack, public_pack, tenant_summary, provincial_summary, public_summary = asyncio.run(run()) + + assert tenant_pack.effectiveTenantCode == "MZ" + assert tenant_pack.effectiveScopeType == "TENANT" + assert tenant_pack.isInherited is False + assert tenant_pack.sourceRuleSetId == 901 + + assert provincial_pack.effectiveTenantCode == "PROVINCIAL" + assert provincial_pack.effectiveScopeType == "PROVINCIAL" + assert provincial_pack.isInherited is True + assert provincial_pack.sourceRuleSetId == 902 + + assert public_pack.effectiveTenantCode == "PUBLIC" + assert public_pack.effectiveScopeType == "PUBLIC" + assert public_pack.isInherited is True + assert public_pack.sourceRuleSetId == 903 + + assert tenant_summary["effectiveTenantCode"] == "MZ" + assert tenant_summary["effectiveScopeType"] == "TENANT" + assert tenant_summary["isInherited"] is False + assert tenant_summary["sourceRuleSetId"] == 901 + + assert provincial_summary["effectiveTenantCode"] == "PROVINCIAL" + assert provincial_summary["effectiveScopeType"] == "PROVINCIAL" + assert provincial_summary["isInherited"] is True + assert provincial_summary["sourceRuleSetId"] == 902 + + assert public_summary["effectiveTenantCode"] == "PUBLIC" + assert public_summary["effectiveScopeType"] == "PUBLIC" + assert public_summary["isInherited"] is True + assert public_summary["sourceRuleSetId"] == 903 + + +def test_rule_config_pack_does_not_fallback_to_cross_tenant_rule_set_latest_version(): + service = RuleConfigServiceImpl() + row = { + "group_id": 11, + "root_group_id": 1, + "document_type_id": 21, + "document_type_name": "合同", + "main_type": "主类", + "subtype": "子类", + "entry_module_name": "模块", + } + cross_tenant_binding = {"id": 101, "group_id": 11, "rule_set_id": 999, "tenant_code": "JY"} + latest_version_calls: list[int] = [] + + async def run(): + service._load_effective_binding = fake_load_effective_binding + service._load_yaml_text_by_version_id = fake_load_yaml_text_by_version_id + service._load_latest_version_id = fake_load_latest_version_id + service._load_current_user = fake_load_current_user + + return await service._build_pack_vo(row, {}, CurrentUserId=1) + + async def fake_load_effective_binding(group_id: int, CurrentUserId=None): + return cross_tenant_binding + + async def fake_load_yaml_text_by_version_id(version_id: int): + return f"yaml-{version_id}" + + async def fake_load_latest_version_id(rule_set_id: int): + latest_version_calls.append(rule_set_id) + return 10010 + + async def fake_load_current_user(CurrentUserId=None): + return {"tenant_code": "MZ", "is_global": False} + + pack = asyncio.run(run()) + + assert pack.ruleSetId is None + assert pack.resolvedVersionId is None + assert pack.yamlText == "" + assert latest_version_calls == [] diff --git a/tests/test_rule_group_binding_scope.py b/tests/test_rule_group_binding_scope.py new file mode 100644 index 0000000..69b71b6 --- /dev/null +++ b/tests/test_rule_group_binding_scope.py @@ -0,0 +1,65 @@ +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.services.impl.evaluationPointGroupServiceImpl import ( + EvaluationPointGroupServiceImpl, +) + + +def test_binding_scope_payload_for_tenant_user_uses_exact_tenant(): + service = EvaluationPointGroupServiceImpl() + + payload = service._build_binding_scope_payload( + current_user={"tenant_code": "MZ", "tenant_name": "梅州", "is_global": False}, + rule_set_meta={"effective_tenant_code": "PROVINCIAL", "effective_scope_type": "PROVINCIAL"}, + ) + + assert payload == { + "tenant_code": "MZ", + "scope_type": "TENANT", + "tenant_name_snapshot": "梅州", + } + + +def test_binding_scope_payload_for_global_user_inherits_rule_set_scope(): + service = EvaluationPointGroupServiceImpl() + + payload = service._build_binding_scope_payload( + current_user={"tenant_code": None, "tenant_name": None, "is_global": True}, + rule_set_meta={"effective_tenant_code": "PUBLIC", "effective_scope_type": "PUBLIC"}, + ) + + assert payload == { + "tenant_code": "PUBLIC", + "scope_type": "PUBLIC", + "tenant_name_snapshot": None, + } + + +def test_binding_scope_payload_rejects_tenant_user_binding_public_asset(): + service = EvaluationPointGroupServiceImpl() + + try: + service._build_binding_scope_payload( + current_user={"tenant_code": "MZ", "tenant_name": "梅州", "is_global": False}, + rule_set_meta={"effective_tenant_code": "PUBLIC", "effective_scope_type": "PUBLIC"}, + ) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_403_FORBIDDEN + + +def test_binding_inheritance_state_marks_tenant_user_using_provincial_binding_as_inherited(): + service = EvaluationPointGroupServiceImpl() + + state = service._build_binding_scope_state( + binding_row={"tenant_code": "PROVINCIAL", "scope_type": "PROVINCIAL"}, + current_user={"tenant_code": "MZ", "is_global": False}, + rule_set_meta={"source_rule_set_id": 88}, + ) + + assert state["effective_tenant_code"] == "PROVINCIAL" + assert state["effective_scope_type"] == "PROVINCIAL" + assert state["is_inherited"] is True + assert state["source_rule_set_id"] == 88 + diff --git a/tests/test_rule_scoped_asset_resolution.py b/tests/test_rule_scoped_asset_resolution.py new file mode 100644 index 0000000..f49685e --- /dev/null +++ b/tests/test_rule_scoped_asset_resolution.py @@ -0,0 +1,50 @@ +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantScope import ( + candidate_scope_tenant_codes, + normalize_scoped_tenant_code, + pick_effective_scoped_row, +) + + +def test_candidate_scope_tenant_codes_prefers_exact_then_public_then_legacy_provincial(): + assert candidate_scope_tenant_codes("MZ") == ["MZ", "PUBLIC", "PROVINCIAL"] + + +def test_normalize_scoped_tenant_code_treats_empty_as_provincial_by_default(): + assert normalize_scoped_tenant_code("") == "PROVINCIAL" + assert normalize_scoped_tenant_code(None) == "PROVINCIAL" + + +def test_pick_effective_scoped_row_prefers_exact_tenant(): + rows = [ + {"tenant_code": "PROVINCIAL", "id": 2}, + {"tenant_code": "MZ", "id": 1}, + {"tenant_code": "PUBLIC", "id": 3}, + ] + + result = pick_effective_scoped_row(rows, "MZ") + + assert result is not None + assert result["id"] == 1 + + +def test_pick_effective_scoped_row_prefers_public_over_legacy_empty_provincial_fallback(): + rows = [ + {"tenant_code": "", "id": 9}, + {"tenant_code": "PUBLIC", "id": 3}, + ] + + result = pick_effective_scoped_row(rows, "MZ") + + assert result is not None + assert result["id"] == 3 + + +def test_pick_effective_scoped_row_falls_back_to_public(): + rows = [ + {"tenant_code": "PUBLIC", "id": 3}, + ] + + result = pick_effective_scoped_row(rows, "MZ") + + assert result is not None + assert result["id"] == 3 diff --git a/tests/test_rule_tenant_materializer.py b/tests/test_rule_tenant_materializer.py new file mode 100644 index 0000000..b8f5fb2 --- /dev/null +++ b/tests/test_rule_tenant_materializer.py @@ -0,0 +1,44 @@ +from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantMaterializer import RuleTenantMaterializer +from fastapi_modules.fastapi_leaudit.services.impl.tenantServiceImpl import TenantServiceImpl + + +def test_rule_tenant_materializer_excludes_platform_template_tenants(): + materializer = RuleTenantMaterializer() + + tenants = [ + {"tenant_code": "PUBLIC", "is_enabled": True}, + {"tenant_code": "PROVINCIAL", "is_enabled": True}, + {"tenant_code": "MZ", "is_enabled": True}, + {"tenant_code": "JY", "is_enabled": False}, + {"tenant_code": "GZ", "is_enabled": True}, + ] + + assert materializer._filter_materializable_tenants(tenants) == ["MZ", "GZ"] + + +def test_rule_tenant_materializer_uses_public_as_template_source_before_legacy_provincial(): + materializer = RuleTenantMaterializer() + + assert materializer._template_source_order() == ["PUBLIC", "PROVINCIAL"] + + +def test_rule_tenant_materializer_builds_tenant_region_from_tenant_code(): + materializer = RuleTenantMaterializer() + + assert materializer._legacy_region_for_tenant("MZ") == "MZ" + assert materializer._legacy_region_for_tenant("gz") == "GZ" + + +def test_tenant_service_exposes_rule_materializer_hook(): + service = TenantServiceImpl() + + assert service.RuleTenantMaterializer is not None + + +def test_rule_tenant_materializer_only_copies_versions_into_empty_tenant_rule_set(): + materializer = RuleTenantMaterializer() + + sql_text = str(materializer._materialize_versions.__code__.co_consts) + + assert "tenant_existing.rule_set_id = tenant_rs.id" in sql_text + assert "existing.version_seq = src_v.version_seq" in sql_text diff --git a/tests/test_rule_tenant_resolution.py b/tests/test_rule_tenant_resolution.py new file mode 100644 index 0000000..20e7c39 --- /dev/null +++ b/tests/test_rule_tenant_resolution.py @@ -0,0 +1,55 @@ +from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import ( + _candidate_binding_tenant_codes, + _pick_effective_binding, +) + + +def test_candidate_binding_tenant_codes_prefers_tenant_then_public_then_legacy_provincial(): + assert _candidate_binding_tenant_codes("MZ") == ["MZ", "PUBLIC", "PROVINCIAL"] + + +def test_candidate_binding_tenant_codes_deduplicates_special_tenant_values(): + assert _candidate_binding_tenant_codes("PROVINCIAL") == ["PUBLIC", "PROVINCIAL"] + assert _candidate_binding_tenant_codes("PUBLIC") == ["PUBLIC"] + + +def test_pick_effective_binding_prefers_exact_tenant_binding(): + bindings = [ + {"tenant_code": "PROVINCIAL", "id": 2}, + {"tenant_code": "MZ", "id": 1}, + {"tenant_code": "PUBLIC", "id": 3}, + ] + + result = _pick_effective_binding(bindings, "MZ") + + assert result is not None + assert result["id"] == 1 + + +def test_pick_effective_binding_falls_back_to_public_then_legacy_provincial(): + provincial_only = [ + {"tenant_code": "PROVINCIAL", "id": 2}, + {"tenant_code": "PUBLIC", "id": 3}, + ] + public_only = [ + {"tenant_code": "PUBLIC", "id": 3}, + ] + + provincial_result = _pick_effective_binding(provincial_only, "MZ") + public_result = _pick_effective_binding(public_only, "MZ") + + assert provincial_result is not None + assert provincial_result["id"] == 3 + assert public_result is not None + assert public_result["id"] == 3 + + +def test_pick_effective_binding_handles_legacy_empty_tenant_as_global_fallback(): + bindings = [ + {"tenant_code": "", "id": 9}, + ] + + result = _pick_effective_binding(bindings, "MZ") + + assert result is not None + assert result["id"] == 9 diff --git a/tests/test_rule_version_lineage.py b/tests/test_rule_version_lineage.py new file mode 100644 index 0000000..3d0b505 --- /dev/null +++ b/tests/test_rule_version_lineage.py @@ -0,0 +1,35 @@ +from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl + + +def test_build_version_snapshot_uses_explicit_source_version(): + service = RuleServiceImpl() + + snapshot = service._build_version_scope_snapshot( + writable_tenant_code="MZ", + writable_scope_type="TENANT", + rule_set_row={"tenant_code": "MZ"}, + source_version_id=321, + ) + + assert snapshot == { + "tenant_code_snapshot": "MZ", + "scope_type_snapshot": "TENANT", + "source_version_id": 321, + } + + +def test_build_version_snapshot_defaults_to_rule_set_scope_when_tenant_missing(): + service = RuleServiceImpl() + + snapshot = service._build_version_scope_snapshot( + writable_tenant_code=None, + writable_scope_type="PROVINCIAL", + rule_set_row={"tenant_code": "PUBLIC"}, + source_version_id=None, + ) + + assert snapshot == { + "tenant_code_snapshot": "PUBLIC", + "scope_type_snapshot": "PROVINCIAL", + "source_version_id": None, + } diff --git a/tests/test_rule_write_scope.py b/tests/test_rule_write_scope.py new file mode 100644 index 0000000..8a86a69 --- /dev/null +++ b/tests/test_rule_write_scope.py @@ -0,0 +1,221 @@ +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.services.impl.rbacAdminServiceImpl import RbacAdminServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl + + +def test_pick_writable_rule_set_prefers_exact_tenant(): + service = RuleServiceImpl() + rows = [ + {"id": 10, "tenant_code": "PROVINCIAL"}, + {"id": 11, "tenant_code": "MZ"}, + {"id": 12, "tenant_code": "PUBLIC"}, + ] + + result = service._pick_writable_rule_set_row(rows, current_user={"tenant_code": "MZ", "is_global": False}) + + assert result is not None + assert int(result["id"]) == 11 + + +def test_pick_writable_rule_set_prefers_user_tenant_even_for_global_role(): + service = RuleServiceImpl() + rows = [ + {"id": 10, "tenant_code": "PROVINCIAL"}, + {"id": 11, "tenant_code": "MZ"}, + {"id": 12, "tenant_code": "PUBLIC"}, + ] + + result = service._pick_writable_rule_set_row(rows, current_user={"tenant_code": "MZ", "is_global": True}) + + assert result is not None + assert int(result["id"]) == 11 + + +def test_pick_writable_rule_set_prefers_public_for_global_user_without_tenant(): + service = RuleServiceImpl() + rows = [ + {"id": 10, "tenant_code": "PROVINCIAL"}, + {"id": 12, "tenant_code": "PUBLIC"}, + ] + + result = service._pick_writable_rule_set_row(rows, current_user={"tenant_code": None, "is_global": True}) + + assert result is not None + assert int(result["id"]) == 12 + + +def test_pick_writable_rule_set_rejects_public_for_tenant_user(): + service = RuleServiceImpl() + rows = [ + {"id": 12, "tenant_code": "PUBLIC"}, + ] + + try: + service._pick_writable_rule_set_row(rows, current_user={"tenant_code": "MZ", "is_global": False}) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_403_FORBIDDEN + + +def test_assert_version_belongs_to_writable_rule_set_rejects_cross_tenant_publish(): + service = RuleServiceImpl() + writable_rule_set = {"id": 11, "tenant_code": "MZ"} + version_row = {"id": 1001, "rule_set_id": 10} + + try: + service._assert_version_belongs_to_writable_rule_set(version_row, writable_rule_set) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_403_FORBIDDEN + + +def test_assert_rollback_target_rejects_current_version(): + service = RuleServiceImpl() + rule_set = {"id": 11, "current_version_id": 1001} + version_row = {"id": 1001, "rule_set_id": 11, "status": "published"} + + try: + service._assert_rollback_target(version_row, rule_set) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_400_BAD_REQUEST + + +def test_assert_rollback_target_rejects_draft_version(): + service = RuleServiceImpl() + rule_set = {"id": 11, "current_version_id": 1001} + version_row = {"id": 1002, "rule_set_id": 11, "status": "draft"} + + try: + service._assert_rollback_target(version_row, rule_set) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_400_BAD_REQUEST + + +def test_assert_rollback_target_allows_previous_version(): + service = RuleServiceImpl() + rule_set = {"id": 11, "current_version_id": 1002} + version_row = {"id": 1001, "rule_set_id": 11, "status": "deprecated"} + + service._assert_rollback_target(version_row, rule_set) + + +def test_tenant_user_requires_rule_tenant_schema_before_write(): + service = RuleServiceImpl() + + try: + service._assert_rule_tenant_schema_ready_for_write( + use_tenant_scope=False, + current_user={"tenant_code": "JY", "is_global": False}, + ) + assert False, "expected LeauditException" + except LeauditException as exc: + assert exc.status == StatusCodeEnum.HTTP_409_CONFLICT + + +def test_global_user_can_write_when_rule_tenant_schema_missing_for_legacy_compatibility(): + service = RuleServiceImpl() + + service._assert_rule_tenant_schema_ready_for_write( + use_tenant_scope=False, + current_user={"tenant_code": None, "is_global": True}, + ) + + +def test_build_tenant_binding_clone_payload_uses_tenant_scope(): + service = RuleServiceImpl() + + payload = service._build_tenant_binding_clone_payload( + current_user={"tenant_code": "JY", "tenant_name": "揭阳", "is_global": False}, + source_binding={"group_id": 3, "priority": 100, "note": "省级绑定"}, + tenant_rule_set_id=88, + ) + + assert payload == { + "group_id": 3, + "rule_set_id": 88, + "tenant_code": "JY", + "scope_type": "TENANT", + "tenant_name_snapshot": "揭阳", + "priority": 100, + "note": "由租户规则集派生自动补绑", + } + + +def test_legacy_region_for_tenant_scope_uses_tenant_code_to_avoid_old_unique_constraint(): + service = RuleServiceImpl() + + assert service._legacy_region_for_scope("JY", "TENANT") == "JY" + assert service._legacy_region_for_scope("PROVINCIAL", "PROVINCIAL") == "default" + assert service._legacy_region_for_scope("PUBLIC", "PUBLIC") == "PUBLIC" + + +def test_rbac_manageable_permissions_include_rule_version_lifecycle(): + permission_keys = { + item["permission_key"] + for item in RbacAdminServiceImpl._MANAGEABLE_PERMISSION_BLUEPRINTS + if item["route_path"] == "/rules" + } + + assert "rules:list:read" in permission_keys + assert "rules:version_list:read" in permission_keys + assert "rules:content:read" in permission_keys + assert "rules:validate:execute" in permission_keys + assert "rules:version_create:write" in permission_keys + assert "rules:publish:write" in permission_keys + assert "rules:rollback:write" in permission_keys + assert "rules:binding_list:read" in permission_keys + assert "rules:binding_create:write" in permission_keys + assert "rules:binding_update:write" in permission_keys + assert "rules:binding_delete:delete" in permission_keys + + +def test_rbac_rule_group_permissions_are_folded_into_rules_menu(): + route_paths = {item["route_path"] for item in RbacAdminServiceImpl._MANAGEABLE_ROUTE_BLUEPRINTS} + group_permission_paths = { + item["route_path"] + for item in RbacAdminServiceImpl._MANAGEABLE_PERMISSION_BLUEPRINTS + if item["permission_key"].startswith("evaluation_group:") + } + + assert "/rule-groups" not in route_paths + assert group_permission_paths == {"/rules"} + + +def test_user_route_compat_menu_does_not_expose_rule_groups(): + service = RbacServiceImpl() + routes = service._buildCompatibilityRoutes(["admin"], {"evaluation_group:list:read", "rules:list:read"}) + paths = service._collectRoutePaths(routes) + rules_route = next(route for route in routes if route.route_path == "/rules") + + assert "/rule-groups" not in paths + assert "evaluation_group:list:read" in rules_route.permissions + + +def test_rbac_seed_cache_reuses_recent_route_map(): + service = RbacAdminServiceImpl() + route_map = { + str(item["route_path"]): index + for index, item in enumerate(RbacAdminServiceImpl._MANAGEABLE_ROUTE_BLUEPRINTS, start=1) + } + + service._remember_admin_seed_route_map(route_map) + + assert service._get_cached_admin_seed_route_map() == route_map + + +def test_permission_cache_is_shared_and_can_invalidate_user(): + first = PermissionServiceImpl() + second = PermissionServiceImpl() + + first._permission_cache[12345] = (0.0, ({"rules:list:read"}, set())) + + assert 12345 in second._permission_cache + PermissionServiceImpl.InvalidateUser(12345) + assert 12345 not in first._permission_cache + assert 12345 not in second._permission_cache