fix: stabilize rule config and cross-review backend

This commit is contained in:
wren
2026-05-11 02:03:01 +08:00
parent 900fc2e8a2
commit 32fb2a4812
14 changed files with 444 additions and 46 deletions
+25 -4
View File
@@ -4,7 +4,7 @@
目标: 目标:
- 把内网 Collabora `http://10.79.97.17:9980` - 把内网 Collabora `http://172.16.0.58:9980`
- 暴露成浏览器可访问的统一入口 - 暴露成浏览器可访问的统一入口
- 供前端配置为 `http://nas.7bm.co/collabora` - 供前端配置为 `http://nas.7bm.co/collabora`
@@ -18,7 +18,7 @@
## 默认代理关系 ## 默认代理关系
- 浏览器入口:`http://nas.7bm.co/collabora` - 浏览器入口:`http://nas.7bm.co/collabora`
- 代理目标:`http://10.79.97.17:9980` - 代理目标:`http://172.16.0.58:9980`
## 启动 ## 启动
@@ -45,7 +45,7 @@ collabora-proxy ok
再验证 Collabora 页面是否被代理出来: 再验证 Collabora 页面是否被代理出来:
```bash ```bash
curl -I http://127.0.0.1:9981/collabora/browser/dist/cool.html curl -I http://127.0.0.1:9981/browser/dist/cool.html
``` ```
如果这一步通了,再让上层网关或宿主 nginx 把: 如果这一步通了,再让上层网关或宿主 nginx 把:
@@ -54,7 +54,28 @@ curl -I http://127.0.0.1:9981/collabora/browser/dist/cool.html
转发到: 转发到:
- `http://<部署该容器的主机>:9981/collabora` - `http://<部署该容器的主机>:9981/`
推荐外层 nginx
```nginx
location /collabora/ {
proxy_pass http://127.0.0.1:9981/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
```
## 前端联动配置 ## 前端联动配置
+111 -9
View File
@@ -4,16 +4,15 @@ map $http_upgrade $connection_upgrade {
} }
server { server {
listen 80; listen 5173;
server_name _; server_name _;
# Expose Collabora behind /collabora so the browser no longer calls # Local WOPI routes must stay on the frontend app.
# a private IP directly from the public frontend page. location /wopi/ {
location /collabora/ { proxy_pass http://127.0.0.1:5193/wopi/;
proxy_pass http://10.79.97.17:9980/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
@@ -25,8 +24,111 @@ server {
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
} }
location = / { # Collabora shell endpoint.
return 200 'collabora-proxy ok'; location /collabora/ {
add_header Content-Type text/plain; proxy_pass http://172.16.0.58:9980/;
proxy_http_version 1.1;
proxy_set_header Host nas.7bm.co:5173;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host nas.7bm.co:5173;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
# Collabora absolute asset and websocket endpoints.
location /browser/ {
# Keep the original escaped URI; Collabora websocket/document paths
# break if nginx normalizes `%2F` inside the encoded WOPI URL.
proxy_pass http://172.16.0.58:9980;
proxy_http_version 1.1;
proxy_set_header Host nas.7bm.co:5173;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host nas.7bm.co:5173;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
location /cool/ {
# Websocket path contains an encoded WOPI URL in the URI path.
# Do not append a URI here, otherwise nginx may decode `%2F`.
proxy_pass http://172.16.0.58:9980;
proxy_http_version 1.1;
proxy_set_header Host nas.7bm.co:5173;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host nas.7bm.co:5173;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
location /hosting/ {
proxy_pass http://172.16.0.58:9980;
proxy_http_version 1.1;
proxy_set_header Host nas.7bm.co:5173;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host nas.7bm.co:5173;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
location /loleaflet/ {
proxy_pass http://172.16.0.58:9980;
proxy_http_version 1.1;
proxy_set_header Host nas.7bm.co:5173;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host nas.7bm.co:5173;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
# Everything else remains on Next dev server.
location / {
proxy_pass http://127.0.0.1:5193;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
} }
} }
+1 -8
View File
@@ -3,15 +3,8 @@ services:
image: nginx:1.27-alpine image: nginx:1.27-alpine
container_name: leaudit-collabora-proxy container_name: leaudit-collabora-proxy
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "9981:80"
volumes: volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro - ./conf.d:/etc/nginx/conf.d:ro
- ./logs:/var/log/nginx - ./logs:/var/log/nginx
networks:
- collabora-proxy
networks:
collabora-proxy:
driver: bridge
+18
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import mimetypes import mimetypes
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -36,12 +37,29 @@ if _NATIVE_LEAUDIT_SRC.exists() and str(_NATIVE_LEAUDIT_SRC) not in sys.path:
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""创建并配置 FastAPI 应用。""" """创建并配置 FastAPI 应用。"""
async def _warm_rule_config_summary_cache() -> None:
import logging
logger = logging.getLogger("APP")
try:
from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import GetRuleConfigServiceSingleton
logger.info("start warming rule-config summary cache")
await GetRuleConfigServiceSingleton().WarmPackSummaries(force=True)
logger.info("rule-config summary cache warmed")
except Exception as exc:
logger.warning("rule-config summary cache warm failed: %s", exc)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
import logging import logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logging.getLogger("APP").info(f"{APP_NAME} starting...") logging.getLogger("APP").info(f"{APP_NAME} starting...")
warm_task = asyncio.create_task(_warm_rule_config_summary_cache())
app.state.rule_config_warm_task = warm_task
yield yield
if not warm_task.done():
warm_task.cancel()
logging.getLogger("APP").info(f"{APP_NAME} shutting down...") logging.getLogger("APP").info(f"{APP_NAME} shutting down...")
app = FastAPI( app = FastAPI(
@@ -9,7 +9,7 @@ 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.controller import BaseController
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import RuleConfigServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import GetRuleConfigServiceSingleton
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService
@@ -19,7 +19,7 @@ class RuleConfigController(BaseController):
def __init__(self): def __init__(self):
super().__init__(prefix="/v3/rule-config-packs", tags=["规则配置"]) super().__init__(prefix="/v3/rule-config-packs", tags=["规则配置"])
self.RuleConfigService: IRuleConfigService = RuleConfigServiceImpl() self.RuleConfigService: IRuleConfigService = GetRuleConfigServiceSingleton()
self.PermissionService: IPermissionService = PermissionServiceImpl() self.PermissionService: IPermissionService = PermissionServiceImpl()
@self.router.get("") @self.router.get("")
@@ -42,7 +42,7 @@ class RuleConfigController(BaseController):
return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()})
async def _check_permission(self, user_id: int) -> bool: async def _check_permission(self, user_id: int) -> bool:
for permission_key in ("rules:list:read", "rules:content:read", "evaluation_group:list:read"): return await self.PermissionService.HasAnyPermission(
if await self.PermissionService.CheckPermission(user_id, permission_key): user_id,
return True ["rules:list:read", "rules:content:read", "evaluation_group:list:read"],
return False )
@@ -18,7 +18,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import (
RuleVersionVO, RuleVersionVO,
) )
from fastapi_modules.fastapi_leaudit.services import IRuleService from fastapi_modules.fastapi_leaudit.services import IRuleService
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton
class RuleController(BaseController): class RuleController(BaseController):
@@ -26,7 +26,7 @@ class RuleController(BaseController):
def __init__(self): def __init__(self):
super().__init__(prefix="/rule-sets", tags=["规则管理"]) super().__init__(prefix="/rule-sets", tags=["规则管理"])
self.RuleService: IRuleService = RuleServiceImpl() self.RuleService: IRuleService = GetRuleServiceSingleton()
@self.router.get("", response_model=Result[list[RuleSetVO]]) @self.router.get("", response_model=Result[list[RuleSetVO]])
async def ListRuleSets(): async def ListRuleSets():
@@ -27,6 +27,8 @@ class AuthServiceImpl(IAuthService):
"""账密登录。 """账密登录。
现阶段仍兼容旧库明文密码,后续应迁移到哈希校验。 现阶段仍兼容旧库明文密码,后续应迁移到哈希校验。
登录标识同时兼容旧系统常见的 `sub` 与 `username`
避免前端展示用户名为 `admin`、实际登录只能输入 `000`。
""" """
async with GetAsyncSession() as session: async with GetAsyncSession() as session:
from sqlalchemy import text from sqlalchemy import text
@@ -36,14 +38,17 @@ class AuthServiceImpl(IAuthService):
"SELECT id, sub, username, nick_name, phone_number, email, " "SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, password, status, deleted_at, " "ou_id, ou_name, is_leader, password, status, deleted_at, "
"try_count, try_login_time, area, tenant_name, dep_name, dep_short_name " "try_count, try_login_time, area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub" "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 "
"LIMIT 1"
), ),
{"sub": Sub}, {"identifier": Sub},
) )
row = result.fetchone() row = result.fetchone()
if not row: if not row:
logger.warning("登录失败: 用户不存在 - sub=%s", Sub) logger.warning("登录失败: 用户不存在 - identifier=%s", Sub)
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误") raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误")
user = dict(row._mapping) user = dict(row._mapping)
@@ -44,6 +44,102 @@ from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import Do
class CrossReviewServiceImpl(ICrossReviewService): class CrossReviewServiceImpl(ICrossReviewService):
"""交叉评查服务实现。""" """交叉评查服务实现。"""
_SCHEMA_BOOTSTRAP_STATEMENTS: tuple[str, ...] = (
"""
CREATE TABLE IF NOT EXISTS leaudit_cross_review_tasks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_name VARCHAR(255) NOT NULL,
task_type VARCHAR(32) NOT NULL,
doc_type_id BIGINT,
doc_type_code VARCHAR(64),
assigner_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'in_progress',
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delete_time TIMESTAMPTZ
)
""",
"CREATE INDEX IF NOT EXISTS idx_lcr_tasks_assigner_id ON leaudit_cross_review_tasks (assigner_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_tasks_status ON leaudit_cross_review_tasks (status)",
"CREATE INDEX IF NOT EXISTS idx_lcr_tasks_doc_type_id ON leaudit_cross_review_tasks (doc_type_id)",
"""
CREATE TABLE IF NOT EXISTS leaudit_cross_review_task_members (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
member_role VARCHAR(32) NOT NULL DEFAULT 'participant',
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delete_time TIMESTAMPTZ
)
""",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_members_task_id ON leaudit_cross_review_task_members (task_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_members_user_id ON leaudit_cross_review_task_members (user_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_members_role ON leaudit_cross_review_task_members (member_role)",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_task_members_task_user_active
ON leaudit_cross_review_task_members (task_id, user_id)
WHERE delete_time IS NULL
""",
"""
CREATE TABLE IF NOT EXISTS leaudit_cross_review_task_documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id BIGINT NOT NULL,
document_id BIGINT NOT NULL,
audit_status INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delete_time TIMESTAMPTZ
)
""",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_task_id ON leaudit_cross_review_task_documents (task_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_document_id ON leaudit_cross_review_task_documents (document_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_task_status ON leaudit_cross_review_task_documents (task_id, audit_status)",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_task_documents_task_document_active
ON leaudit_cross_review_task_documents (task_id, document_id)
WHERE delete_time IS NULL
""",
"""
CREATE TABLE IF NOT EXISTS leaudit_cross_review_proposals (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id BIGINT NOT NULL,
document_id BIGINT NOT NULL,
rule_result_id BIGINT NOT NULL,
proposer_id BIGINT NOT NULL,
proposed_score_delta NUMERIC(10, 2) NOT NULL,
reason TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delete_time TIMESTAMPTZ
)
""",
"CREATE INDEX IF NOT EXISTS idx_lcr_proposals_task_id ON leaudit_cross_review_proposals (task_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_proposals_document_id ON leaudit_cross_review_proposals (document_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_proposals_rule_result_id ON leaudit_cross_review_proposals (rule_result_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_proposals_proposer_id ON leaudit_cross_review_proposals (proposer_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_proposals_status ON leaudit_cross_review_proposals (status)",
"""
CREATE TABLE IF NOT EXISTS leaudit_cross_review_votes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
proposal_id BIGINT NOT NULL,
voter_id BIGINT NOT NULL,
vote_type VARCHAR(16) NOT NULL,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delete_time TIMESTAMPTZ
)
""",
"CREATE INDEX IF NOT EXISTS idx_lcr_votes_proposal_id ON leaudit_cross_review_votes (proposal_id)",
"CREATE INDEX IF NOT EXISTS idx_lcr_votes_voter_id ON leaudit_cross_review_votes (voter_id)",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_votes_proposal_voter_active
ON leaudit_cross_review_votes (proposal_id, voter_id)
WHERE delete_time IS NULL
""",
)
def __init__(self): def __init__(self):
self.DocumentService: IDocumentService = DocumentServiceImpl() self.DocumentService: IDocumentService = DocumentServiceImpl()
@@ -56,6 +152,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
principalUserIds = self._unique_int_list(Body.principalUserIds) principalUserIds = self._unique_int_list(Body.principalUserIds)
documentIds = self._unique_int_list(Body.documentIds) documentIds = self._unique_int_list(Body.documentIds)
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
taskRow = ( taskRow = (
await session.execute( await session.execute(
@@ -400,6 +497,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
if not permission.canConfirm: if not permission.canConfirm:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, permission.reason) raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, permission.reason)
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
mapping = ( mapping = (
await session.execute( await session.execute(
@@ -517,6 +615,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
if Body.deductionScore > 0 and currentScore >= fullScore: if Body.deductionScore > 0 and currentScore >= fullScore:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前分值已满分,不能继续加分") raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前分值已满分,不能继续加分")
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
proposalRow = ( proposalRow = (
await session.execute( await session.execute(
@@ -576,6 +675,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
if str(proposal["status"]) in {"approved", "rejected", "cancelled"}: if str(proposal["status"]) in {"approved", "rejected", "cancelled"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许继续投票") raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许继续投票")
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
if voteType == "cancel": if voteType == "cancel":
deleted = await session.execute( deleted = await session.execute(
@@ -638,6 +738,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
if str(proposal["status"]) not in {"pending"}: if str(proposal["status"]) not in {"pending"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许撤销") raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许撤销")
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
await session.execute( await session.execute(
text( text(
@@ -745,6 +846,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
async with GetAsyncSession() as session: async with GetAsyncSession() as session:
await self._ensure_tables_ready(session) await self._ensure_tables_ready(session)
await self._reset_transaction_for_write(session)
async with session.begin(): async with session.begin():
exists = bool( exists = bool(
await session.scalar( await session.scalar(
@@ -1153,6 +1255,31 @@ class CrossReviewServiceImpl(ICrossReviewService):
"leaudit_cross_review_proposals", "leaudit_cross_review_proposals",
"leaudit_cross_review_votes", "leaudit_cross_review_votes",
] ]
missing_tables: list[str] = []
for tableName in required:
exists = bool(
await session.scalar(
text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table_name
)
"""
),
{"table_name": tableName},
)
)
if not exists:
missing_tables.append(tableName)
if missing_tables:
for statement in self._SCHEMA_BOOTSTRAP_STATEMENTS:
await session.execute(text(statement))
await session.commit()
for tableName in required: for tableName in required:
exists = bool( exists = bool(
await session.scalar( await session.scalar(
@@ -1195,6 +1322,11 @@ class CrossReviewServiceImpl(ICrossReviewService):
if not exists: if not exists:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户不是交叉评查任务成员") raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户不是交叉评查任务成员")
async def _reset_transaction_for_write(self, session) -> None:
"""显式写事务前清理查询阶段开启的隐式事务。"""
if session.in_transaction():
await session.rollback()
def _unique_int_list(self, values: list[int]) -> list[int]: def _unique_int_list(self, values: list[int]) -> list[int]:
"""去重并保留原顺序。""" """去重并保留原顺序。"""
seen: set[int] = set() seen: set[int] = set()
@@ -39,14 +39,14 @@ from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import (
ensure_rule_group_schema, ensure_rule_group_schema,
sync_doc_type_bindings_from_group, sync_doc_type_bindings_from_group,
) )
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton
class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
"""评查点分组服务实现。""" """评查点分组服务实现。"""
def __init__(self) -> None: def __init__(self) -> None:
self.RuleService = RuleServiceImpl() self.RuleService = GetRuleServiceSingleton()
async def ListGroups( async def ListGroups(
self, self,
@@ -21,6 +21,7 @@ class HomeServiceImpl(IHomeService):
"/files/upload", "/files/upload",
"/documents", "/documents",
"/chat-with-llm/chat", "/chat-with-llm/chat",
"/cross-checking",
) )
def __init__(self) -> None: def __init__(self) -> None:
@@ -162,7 +163,7 @@ class HomeServiceImpl(IHomeService):
if not self._isAllowedTargetPath(targetPath, allowedPaths): if not self._isAllowedTargetPath(targetPath, allowedPaths):
continue continue
requiresDocumentTypes = targetPath not in {"/chat-with-llm/chat"} requiresDocumentTypes = targetPath not in {"/chat-with-llm/chat", "/cross-checking"}
modules.append( modules.append(
HomeEntryModuleVO( HomeEntryModuleVO(
@@ -210,9 +211,6 @@ class HomeServiceImpl(IHomeService):
if RawPath == "/contract-template/search" and HasDocumentTypes: if RawPath == "/contract-template/search" and HasDocumentTypes:
return "/files/upload" return "/files/upload"
if RawPath == "/cross-checking":
return None
if any( if any(
RawPath == enabledPath or RawPath.startswith(f"{enabledPath}/") RawPath == enabledPath or RawPath.startswith(f"{enabledPath}/")
for enabledPath in self._MINIMAL_ENABLED_TARGETS for enabledPath in self._MINIMAL_ENABLED_TARGETS
@@ -9,6 +9,7 @@
from fastapi_common.fastapi_common_logger import logger from fastapi_common.fastapi_common_logger import logger
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
import time
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
@@ -16,6 +17,9 @@ from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissi
class PermissionServiceImpl(IPermissionService): class PermissionServiceImpl(IPermissionService):
"""权限检查服务实现。""" """权限检查服务实现。"""
def __init__(self) -> None:
self._permission_cache: dict[int, tuple[float, tuple[set[str], set[str]]]] = {}
async def CheckPermission(self, UserId: int, PermissionKey: str) -> bool: async def CheckPermission(self, UserId: int, PermissionKey: str) -> bool:
"""检查用户是否拥有指定权限。 """检查用户是否拥有指定权限。
@@ -74,6 +78,11 @@ class PermissionServiceImpl(IPermissionService):
async def _getUserPermissions(self, UserId: int) -> tuple[set[str], set[str]]: async def _getUserPermissions(self, UserId: int) -> tuple[set[str], set[str]]:
"""从数据库查询用户的 GRANT 和 DENY 权限集合。""" """从数据库查询用户的 GRANT 和 DENY 权限集合。"""
cached = self._permission_cache.get(UserId)
now = time.monotonic()
if cached and now - cached[0] <= 60:
return cached[1]
grants: set[str] = set() grants: set[str] = set()
denies: set[str] = set() denies: set[str] = set()
@@ -107,6 +116,7 @@ class PermissionServiceImpl(IPermissionService):
denies.add(permKey) denies.add(permKey)
logger.debug(f"用户权限: user={UserId}, grants={len(grants)}, denies={len(denies)}") logger.debug(f"用户权限: user={UserId}, grants={len(grants)}, denies={len(denies)}")
self._permission_cache[UserId] = (now, (grants, denies))
return grants, denies return grants, denies
@staticmethod @staticmethod
@@ -124,7 +124,7 @@ class RbacAdminServiceImpl(IRbacAdminService):
"route_path": "/cross-checking/result", "route_path": "/cross-checking/result",
"route_name": "cross-checking-result", "route_name": "cross-checking-result",
"component": "cross-checking.result", "component": "cross-checking.result",
"route_title": "评查结果", "route_title": "评查任务列表",
"icon": "ri-file-list-3-line", "icon": "ri-file-list-3-line",
"sort_order": 2, "sort_order": 2,
"parent_path": "/cross-checking", "parent_path": "/cross-checking",
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
import re import re
import time
from typing import Any from typing import Any
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
@@ -20,17 +21,49 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ruleConfigVo import (
from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleValidator from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleValidator
from fastapi_modules.fastapi_leaudit.services import IOssService 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.ossServiceImpl import OssServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import GetRuleServiceSingleton
from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService
class RuleConfigServiceImpl(IRuleConfigService): 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_WARM_LOCK: asyncio.Lock | None = None
def __init__(self, OssService: IOssService | None = None) -> None: def __init__(self, OssService: IOssService | None = None) -> None:
self.OssService = OssService or OssServiceImpl() self.OssService = OssService or OssServiceImpl()
self.RuleService = RuleServiceImpl(self.OssService) self.RuleService = GetRuleServiceSingleton()
self.Validator = RuleValidator() self.Validator = RuleValidator()
self._yaml_summary_cache = self.__class__._GLOBAL_YAML_SUMMARY_CACHE
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
@classmethod
def _get_warm_lock(cls) -> asyncio.Lock:
if cls._GLOBAL_WARM_LOCK is None:
cls._GLOBAL_WARM_LOCK = asyncio.Lock()
return cls._GLOBAL_WARM_LOCK
def InvalidateSummaryCaches(self, version_ids: list[int] | None = None) -> None:
"""清理规则摘要缓存;version_ids 为空时清空全部。"""
self.__class__._set_pack_summary_cache(None)
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 with self.__class__._get_warm_lock():
if force:
self.InvalidateSummaryCaches()
return await self.ListPackSummaries()
async def ListPacks(self) -> list[RuleConfigPackVO]: async def ListPacks(self) -> list[RuleConfigPackVO]:
"""列出规则配置页所需的全部 pack。""" """列出规则配置页所需的全部 pack。"""
@@ -40,6 +73,11 @@ class RuleConfigServiceImpl(IRuleConfigService):
async def ListPackSummaries(self) -> list[RuleConfigPackListVO]: async def ListPackSummaries(self) -> list[RuleConfigPackListVO]:
"""列出规则列表页所需的轻量 pack。""" """列出规则列表页所需的轻量 pack。"""
cached = self.__class__._GLOBAL_PACK_SUMMARY_CACHE
now = time.monotonic()
if cached and now - cached[0] <= 60:
return cached[1]
rows = await self._load_pack_rows() rows = await self._load_pack_rows()
if not rows: if not rows:
return [] return []
@@ -121,6 +159,9 @@ class RuleConfigServiceImpl(IRuleConfigService):
item["usableRuleCount"] = len(rules) item["usableRuleCount"] = len(rules)
packs.append(RuleConfigPackListVO(**item)) packs.append(RuleConfigPackListVO(**item))
cache_value = (time.monotonic(), packs)
self.__class__._set_pack_summary_cache(cache_value)
self._pack_summary_cache = cache_value
return packs return packs
async def GetPack(self, PackId: int) -> RuleConfigPackVO: async def GetPack(self, PackId: int) -> RuleConfigPackVO:
@@ -471,10 +512,16 @@ class RuleConfigServiceImpl(IRuleConfigService):
async def _load_one(version_id: int, oss_url: str): async def _load_one(version_id: int, oss_url: str):
if not oss_url: if not oss_url:
return version_id, {"loaded": False, "yaml_name": "", "rules": []} return version_id, {"loaded": False, "yaml_name": "", "rules": []}
cached = self._yaml_summary_cache.get(version_id)
now = time.monotonic()
if cached and now - cached[0] <= 120:
return version_id, cached[1]
try: try:
yaml_text = (await self.OssService.DownloadBytes(oss_url)).decode("utf-8") yaml_text = (await self.OssService.DownloadBytes(oss_url)).decode("utf-8")
if not yaml_text.strip(): if not yaml_text.strip():
return version_id, {"loaded": False, "yaml_name": "", "rules": []} summary = {"loaded": False, "yaml_name": "", "rules": []}
self._yaml_summary_cache[version_id] = (time.monotonic(), summary)
return version_id, summary
rules_file = self.Validator.ParseValidated(yaml_text) rules_file = self.Validator.ParseValidated(yaml_text)
groups: dict[str, str] = {} groups: dict[str, str] = {}
try: try:
@@ -540,13 +587,17 @@ class RuleConfigServiceImpl(IRuleConfigService):
seen_dependencies.add(normalized_dependency) seen_dependencies.add(normalized_dependency)
merged_dependencies.append(normalized_dependency) merged_dependencies.append(normalized_dependency)
item["dependencies"] = merged_dependencies item["dependencies"] = merged_dependencies
return version_id, { summary = {
"loaded": True, "loaded": True,
"yaml_name": str(getattr(getattr(rules_file, "metadata", None), "name", "") or ""), "yaml_name": str(getattr(getattr(rules_file, "metadata", None), "name", "") or ""),
"rules": summaries, "rules": summaries,
} }
self._yaml_summary_cache[version_id] = (time.monotonic(), summary)
return version_id, summary
except Exception: except Exception:
return version_id, {"loaded": False, "yaml_name": "", "rules": []} summary = {"loaded": False, "yaml_name": "", "rules": []}
self._yaml_summary_cache[version_id] = (time.monotonic(), summary)
return version_id, summary
results = await asyncio.gather(*[_load_one(version_id, oss_url) for version_id, oss_url in version_oss_map.items()]) results = await asyncio.gather(*[_load_one(version_id, oss_url) for version_id, oss_url in version_oss_map.items()])
return dict(results) return dict(results)
@@ -592,3 +643,14 @@ class RuleConfigServiceImpl(IRuleConfigService):
if value is None: if value is None:
return None return None
return int(value) return int(value)
_RULE_CONFIG_SERVICE_SINGLETON: RuleConfigServiceImpl | None = None
def GetRuleConfigServiceSingleton() -> RuleConfigServiceImpl:
"""返回共享的规则配置服务实例,供控制器和预热任务共用缓存。"""
global _RULE_CONFIG_SERVICE_SINGLETON
if _RULE_CONFIG_SERVICE_SINGLETON is None:
_RULE_CONFIG_SERVICE_SINGLETON = RuleConfigServiceImpl()
return _RULE_CONFIG_SERVICE_SINGLETON
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging
import time
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession 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.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
@@ -25,6 +27,9 @@ from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import sync_
class RuleServiceImpl(IRuleService): class RuleServiceImpl(IRuleService):
"""规则服务实现。""" """规则服务实现。"""
_GLOBAL_LIST_SETS_CACHE: tuple[float, list[RuleSetVO]] | None = None
_GLOBAL_RULE_COUNT_CACHE: dict[int, tuple[float, int]] = {}
def __init__( def __init__(
self, self,
OssService: IOssService | None = None, OssService: IOssService | None = None,
@@ -32,6 +37,21 @@ class RuleServiceImpl(IRuleService):
) -> None: ) -> None:
self.OssService = OssService or OssServiceImpl() self.OssService = OssService or OssServiceImpl()
self.Validator = Validator or RuleValidator() self.Validator = Validator or RuleValidator()
self._list_sets_cache = self.__class__._GLOBAL_LIST_SETS_CACHE
self._rule_count_cache = self.__class__._GLOBAL_RULE_COUNT_CACHE
@classmethod
def _set_list_sets_cache(cls, value: tuple[float, list[RuleSetVO]] | None) -> None:
cls._GLOBAL_LIST_SETS_CACHE = value
def InvalidateCaches(self, version_ids: list[int] | None = None) -> None:
"""清理规则集及规则数量缓存。"""
self.__class__._set_list_sets_cache(None)
if version_ids is None:
self._rule_count_cache.clear()
return
for version_id in {int(item) for item in version_ids if item is not None}:
self._rule_count_cache.pop(version_id, None)
async def _resolve_unique_child_group_id(self, Session, DocTypeId: int) -> int | None: async def _resolve_unique_child_group_id(self, Session, DocTypeId: int) -> int | None:
"""仅当文档类型唯一对应一个二级分组时,返回该分组ID。""" """仅当文档类型唯一对应一个二级分组时,返回该分组ID。"""
@@ -71,6 +91,11 @@ class RuleServiceImpl(IRuleService):
async def ListSets(self) -> list[RuleSetVO]: async def ListSets(self) -> list[RuleSetVO]:
"""列出所有规则集。""" """列出所有规则集。"""
now = time.monotonic()
cached = self.__class__._GLOBAL_LIST_SETS_CACHE
if cached and now - cached[0] <= 30:
return cached[1]
async with GetAsyncSession() as Session: async with GetAsyncSession() as Session:
Result = await Session.execute( Result = await Session.execute(
text( text(
@@ -114,7 +139,7 @@ class RuleServiceImpl(IRuleService):
if usable_version_id is not None and int(usable_version_id) not in usable_counts: if usable_version_id is not None and int(usable_version_id) not in usable_counts:
usable_counts[int(usable_version_id)] = await self._GetRuleCountByVersionId(int(usable_version_id)) usable_counts[int(usable_version_id)] = await self._GetRuleCountByVersionId(int(usable_version_id))
return [ items = [
RuleSetVO( RuleSetVO(
id=int(Row["id"]), id=int(Row["id"]),
ruleType=Row["rule_type"], ruleType=Row["rule_type"],
@@ -130,9 +155,18 @@ class RuleServiceImpl(IRuleService):
) )
for Row in rows for Row in rows
] ]
cache_value = (time.monotonic(), items)
self.__class__._set_list_sets_cache(cache_value)
self._list_sets_cache = cache_value
return items
async def _GetRuleCountByVersionId(self, VersionId: int) -> int: async def _GetRuleCountByVersionId(self, VersionId: int) -> int:
"""读取指定可用规则版本的规则数。""" """读取指定可用规则版本的规则数。"""
cached = self._rule_count_cache.get(VersionId)
now = time.monotonic()
if cached and now - cached[0] <= 120:
return cached[1]
async with GetAsyncSession() as Session: async with GetAsyncSession() as Session:
Result = await Session.execute( Result = await Session.execute(
text( text(
@@ -153,7 +187,9 @@ class RuleServiceImpl(IRuleService):
try: try:
yaml_text = (await self.OssService.DownloadBytes(Row["oss_url"])).decode("utf-8") yaml_text = (await self.OssService.DownloadBytes(Row["oss_url"])).decode("utf-8")
validation = self.Validator.ValidateYaml(yaml_text) validation = self.Validator.ValidateYaml(yaml_text)
return int(validation.ruleCount or 0) count = int(validation.ruleCount or 0)
self._rule_count_cache[VersionId] = (time.monotonic(), count)
return count
except Exception: except Exception:
return 0 return 0
@@ -786,6 +822,16 @@ class RuleServiceImpl(IRuleService):
) )
await Session.commit() await Session.commit()
self.InvalidateCaches()
try:
from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import GetRuleConfigServiceSingleton
RuleConfigService = GetRuleConfigServiceSingleton()
RuleConfigService.InvalidateSummaryCaches()
await RuleConfigService.WarmPackSummaries(force=False)
except Exception as exc:
logging.getLogger("RULE").warning("刷新规则配置摘要缓存失败: %s", exc)
VersionRow = await self._GetVersion(Session, VersionId) VersionRow = await self._GetVersion(Session, VersionId)
return self._BuildRuleVersionVo(VersionRow) return self._BuildRuleVersionVo(VersionRow)
@@ -848,3 +894,14 @@ class RuleServiceImpl(IRuleService):
"legal_doc": "legal_doc", "legal_doc": "legal_doc",
} }
return Mapping.get(Prefix, Prefix or "unknown") return Mapping.get(Prefix, Prefix or "unknown")
_RULE_SERVICE_SINGLETON: RuleServiceImpl | None = None
def GetRuleServiceSingleton() -> RuleServiceImpl:
"""返回共享规则服务实例,供控制器与聚合服务复用缓存。"""
global _RULE_SERVICE_SINGLETON
if _RULE_SERVICE_SINGLETON is None:
_RULE_SERVICE_SINGLETON = RuleServiceImpl()
return _RULE_SERVICE_SINGLETON