feat: add contract template v3 api and legacy oss migration
This commit is contained in:
@@ -52,6 +52,24 @@ class OssPathUtils:
|
||||
prefix = f"{Region}/" if Region else ""
|
||||
return f"{prefix}rules/{RuleType}/{VersionNo}/validation_report.json"
|
||||
|
||||
@staticmethod
|
||||
def BuildContractTemplateKey(
|
||||
CategoryName: str,
|
||||
TemplateCode: str,
|
||||
FileRole: str,
|
||||
FileName: str,
|
||||
) -> str:
|
||||
"""生成合同模板 object key。"""
|
||||
ext = Path(FileName).suffix or ""
|
||||
safe_category = OssPathUtils.BuildSafeFileStem(CategoryName or "uncategorized")
|
||||
safe_template_code = OssPathUtils.BuildSafeFileStem(TemplateCode or "template")
|
||||
safe_stem = OssPathUtils.BuildSafeFileStem(FileName)
|
||||
safe_role = OssPathUtils.BuildSafeFileStem(FileRole or "file")
|
||||
return (
|
||||
f"contract-templates/{safe_category}/{safe_template_code}/"
|
||||
f"{safe_role}__{safe_stem}{ext}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def BuildSafeFileStem(FileName: str) -> str:
|
||||
"""生成适合放进 object key 的可读文件名主体。"""
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""合同模板控制器。"""
|
||||
|
||||
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.contractTemplateDto import (
|
||||
ContractTemplateListQueryDTO,
|
||||
ContractTemplateSearchQueryDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
|
||||
|
||||
|
||||
class ContractTemplateController(BaseController):
|
||||
"""合同模板控制器。"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/v3/contract-templates", tags=["合同模板"])
|
||||
self.ContractTemplateService: IContractTemplateService = ContractTemplateServiceImpl()
|
||||
self.PermissionService: IPermissionService = PermissionServiceImpl()
|
||||
|
||||
@self.router.get("/categories")
|
||||
async def ListContractTemplateCategories(
|
||||
include_disabled: bool = Query(False, description="是否包含禁用分类"),
|
||||
with_template_count: bool = Query(True, description="是否附带模板数量"),
|
||||
payload: dict = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), ["contract_template:list:read", "contract_template:search:read"]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板分类权限", "data": None})
|
||||
data = await self.ContractTemplateService.ListCategories(include_disabled, with_template_count)
|
||||
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": [item.model_dump() for item in data]})
|
||||
|
||||
@self.router.get("")
|
||||
async def ListContractTemplates(
|
||||
keyword: str | None = Query(None, description="关键词"),
|
||||
category_id: int | None = Query(None, description="分类ID"),
|
||||
category_name: 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="页码"),
|
||||
page_size: int = Query(12, ge=1, le=200, description="分页大小"),
|
||||
sort_by: str = Query("updated_at", description="排序字段"),
|
||||
sort_order: str = Query("desc", description="排序方向"),
|
||||
payload: dict = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), ["contract_template:list:read"]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板列表权限", "data": None})
|
||||
query = ContractTemplateListQueryDTO(
|
||||
keyword=keyword,
|
||||
category_id=category_id,
|
||||
category_name=category_name,
|
||||
file_format=file_format,
|
||||
is_featured=is_featured,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
data = await self.ContractTemplateService.ListTemplates(query)
|
||||
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
|
||||
|
||||
@self.router.get("/search")
|
||||
async def SearchContractTemplates(
|
||||
q: str = Query(..., min_length=1, description="搜索关键词"),
|
||||
category_id: int | None = Query(None, description="分类ID"),
|
||||
category_name: 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="排序字段"),
|
||||
sort_order: str = Query("desc", description="排序方向"),
|
||||
payload: dict = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), ["contract_template:search:read"]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有搜索合同模板权限", "data": None})
|
||||
query = ContractTemplateSearchQueryDTO(
|
||||
q=q,
|
||||
category_id=category_id,
|
||||
category_name=category_name,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
data = await self.ContractTemplateService.SearchTemplates(query)
|
||||
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
|
||||
|
||||
@self.router.get("/{TemplateId}")
|
||||
async def GetContractTemplateDetail(
|
||||
TemplateId: int,
|
||||
payload: dict = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None})
|
||||
data = await self.ContractTemplateService.GetTemplateDetail(TemplateId)
|
||||
if not data:
|
||||
return JSONResponse(status_code=404, content={"code": 404, "msg": "合同模板不存在", "data": None})
|
||||
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
|
||||
|
||||
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
|
||||
for permission_key in permission_keys:
|
||||
if await self.PermissionService.CheckPermission(user_id, permission_key):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContractTemplateListQueryDTO(BaseModel):
|
||||
"""合同模板列表查询参数。"""
|
||||
|
||||
keyword: str | None = Field(None, description="关键词")
|
||||
category_id: int | None = Field(None, description="分类ID")
|
||||
category_name: 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="页码")
|
||||
page_size: int = Field(12, ge=1, le=200, description="分页大小")
|
||||
sort_by: str = Field("updated_at", description="排序字段")
|
||||
sort_order: str = Field("desc", description="排序方向")
|
||||
|
||||
|
||||
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="分类名称")
|
||||
page: int = Field(1, ge=1, description="页码")
|
||||
page_size: int = Field(12, ge=1, le=200, description="分页大小")
|
||||
sort_by: str = Field("updated_at", description="排序字段")
|
||||
sort_order: str = Field("desc", description="排序方向")
|
||||
@@ -0,0 +1,67 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContractTemplateCategoryVO(BaseModel):
|
||||
"""合同模板分类。"""
|
||||
|
||||
id: int = Field(..., description="分类ID")
|
||||
name: str = Field(..., description="分类名称")
|
||||
icon: str | None = Field(None, description="分类图标")
|
||||
description: str | None = Field(None, description="分类描述")
|
||||
sort_order: int = Field(0, description="排序")
|
||||
template_count: int = Field(0, description="分类下模板数量")
|
||||
is_enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
|
||||
class ContractTemplateListItemVO(BaseModel):
|
||||
"""合同模板列表项。"""
|
||||
|
||||
id: int = Field(..., description="模板ID")
|
||||
template_code: str = Field(..., description="模板编码")
|
||||
title: str = Field(..., description="模板标题")
|
||||
category_id: int = Field(..., description="分类ID")
|
||||
category_name: str | None = Field(None, description="分类名称")
|
||||
category_icon: str | None = Field(None, description="分类图标")
|
||||
description: 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="文件格式")
|
||||
is_featured: bool = Field(False, description="是否推荐")
|
||||
created_at: str | None = Field(None, description="创建时间")
|
||||
updated_at: str | None = Field(None, description="更新时间")
|
||||
|
||||
|
||||
class ContractTemplatePageVO(BaseModel):
|
||||
"""合同模板分页结果。"""
|
||||
|
||||
total: int = Field(..., description="总数")
|
||||
page: int = Field(..., description="当前页")
|
||||
page_size: int = Field(..., description="分页大小")
|
||||
total_pages: int = Field(..., description="总页数")
|
||||
templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表")
|
||||
|
||||
|
||||
class ContractTemplateDetailVO(ContractTemplateListItemVO):
|
||||
"""合同模板详情。"""
|
||||
|
||||
category_description: str | None = Field(None, description="分类描述")
|
||||
placeholder_schema: dict | None = Field(None, description="模板占位符结构")
|
||||
|
||||
|
||||
class ContractTemplateSearchCategoryVO(BaseModel):
|
||||
"""搜索结果分类统计。"""
|
||||
|
||||
id: int = Field(..., description="分类ID")
|
||||
name: str = Field(..., description="分类名称")
|
||||
search_count: int = Field(0, description="当前关键词命中的模板数")
|
||||
|
||||
|
||||
class ContractTemplateSearchResultVO(BaseModel):
|
||||
"""合同模板搜索结果。"""
|
||||
|
||||
total: int = Field(..., description="总数")
|
||||
page: int = Field(..., description="当前页")
|
||||
page_size: int = Field(..., description="分页大小")
|
||||
total_pages: int = Field(..., description="总页数")
|
||||
templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表")
|
||||
category_stats: list[ContractTemplateSearchCategoryVO] = Field(default_factory=list, description="分类统计")
|
||||
@@ -0,0 +1,32 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
|
||||
ContractTemplateListQueryDTO,
|
||||
ContractTemplateSearchQueryDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
|
||||
ContractTemplateCategoryVO,
|
||||
ContractTemplateDetailVO,
|
||||
ContractTemplatePageVO,
|
||||
ContractTemplateSearchResultVO,
|
||||
)
|
||||
|
||||
|
||||
class IContractTemplateService(ABC):
|
||||
"""合同模板服务接口。"""
|
||||
|
||||
@abstractmethod
|
||||
async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None:
|
||||
...
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import bindparam, text
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
|
||||
ContractTemplateListQueryDTO,
|
||||
ContractTemplateSearchQueryDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
|
||||
ContractTemplateCategoryVO,
|
||||
ContractTemplateDetailVO,
|
||||
ContractTemplateListItemVO,
|
||||
ContractTemplatePageVO,
|
||||
ContractTemplateSearchCategoryVO,
|
||||
ContractTemplateSearchResultVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
|
||||
|
||||
_ALLOWED_SORT_FIELDS = {
|
||||
"id": "t.id",
|
||||
"title": "t.title",
|
||||
"created_at": "t.created_at",
|
||||
"updated_at": "t.updated_at",
|
||||
}
|
||||
|
||||
|
||||
class ContractTemplateServiceImpl(IContractTemplateService):
|
||||
"""合同模板服务实现。"""
|
||||
|
||||
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"
|
||||
sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.icon,
|
||||
c.description,
|
||||
COALESCE(c.sort_order, 0) AS sort_order,
|
||||
{count_select},
|
||||
TRUE AS is_enabled
|
||||
FROM contract_categories c
|
||||
LEFT JOIN contract_templates t
|
||||
ON t.category_id = c.id
|
||||
WHERE 1=1
|
||||
GROUP BY c.id, c.name, c.icon, c.description, c.sort_order
|
||||
ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC
|
||||
"""
|
||||
)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (await session.execute(sql)).mappings().all()
|
||||
|
||||
return [self._to_category_vo(row) for row in rows]
|
||||
|
||||
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO:
|
||||
where_clause, params, needs_category_name_filter = self._build_template_filters(
|
||||
keyword=Query.keyword,
|
||||
category_id=Query.category_id,
|
||||
category_name=Query.category_name,
|
||||
file_format=Query.file_format,
|
||||
is_featured=Query.is_featured,
|
||||
)
|
||||
order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc")
|
||||
offset = max(Query.page - 1, 0) * Query.page_size
|
||||
params.update({"limit": Query.page_size, "offset": offset})
|
||||
|
||||
from_sql = self._build_template_from_sql(needs_category_name_filter)
|
||||
|
||||
count_sql = text(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
{from_sql}
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
)
|
||||
list_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.template_code,
|
||||
t.title,
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
c.icon AS category_icon,
|
||||
c.description AS category_description,
|
||||
t.description,
|
||||
t.file_path,
|
||||
t.pdf_file_path,
|
||||
t.file_format,
|
||||
COALESCE(t.is_featured, FALSE) AS is_featured,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
{from_sql}
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_sql}
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
total = int((await session.execute(count_sql, params)).scalar_one())
|
||||
rows = (await session.execute(list_sql, params)).mappings().all()
|
||||
|
||||
return ContractTemplatePageVO(
|
||||
total=total,
|
||||
page=Query.page,
|
||||
page_size=Query.page_size,
|
||||
total_pages=max((total + Query.page_size - 1) // Query.page_size, 1) if total else 0,
|
||||
templates=[self._to_list_item_vo(row) for row in rows],
|
||||
)
|
||||
|
||||
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO:
|
||||
list_query = ContractTemplateListQueryDTO(
|
||||
keyword=Query.q,
|
||||
category_id=Query.category_id,
|
||||
category_name=Query.category_name,
|
||||
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)
|
||||
category_stats = await self._load_search_category_stats(Query.q)
|
||||
|
||||
return ContractTemplateSearchResultVO(
|
||||
total=page_result.total,
|
||||
page=page_result.page,
|
||||
page_size=page_result.page_size,
|
||||
total_pages=page_result.total_pages,
|
||||
templates=page_result.templates,
|
||||
category_stats=category_stats,
|
||||
)
|
||||
|
||||
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.template_code,
|
||||
t.title,
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
c.icon AS category_icon,
|
||||
c.description AS category_description,
|
||||
t.description,
|
||||
t.file_path,
|
||||
t.pdf_file_path,
|
||||
t.file_format,
|
||||
COALESCE(t.is_featured, FALSE) AS is_featured,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM contract_templates t
|
||||
LEFT JOIN contract_categories c ON c.id = t.category_id
|
||||
WHERE t.id = :template_id
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
row = (await session.execute(sql, {"template_id": TemplateId})).mappings().first()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
return self._to_detail_vo(row)
|
||||
|
||||
async def _load_search_category_stats(
|
||||
self,
|
||||
keyword: str,
|
||||
) -> list[ContractTemplateSearchCategoryVO]:
|
||||
clean_keyword = (keyword or "").strip()
|
||||
if not clean_keyword:
|
||||
return []
|
||||
|
||||
filters = [
|
||||
"("
|
||||
"t.title ILIKE :keyword "
|
||||
"OR COALESCE(t.description, '') ILIKE :keyword "
|
||||
"OR COALESCE(t.template_code, '') ILIKE :keyword "
|
||||
"OR COALESCE(c.name, '') ILIKE :keyword"
|
||||
")"
|
||||
]
|
||||
params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"}
|
||||
|
||||
sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
COUNT(t.id)::int AS search_count
|
||||
FROM contract_categories c
|
||||
LEFT JOIN contract_templates t ON t.category_id = c.id
|
||||
WHERE {' AND '.join(filters)}
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY c.name ASC
|
||||
"""
|
||||
)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (await session.execute(sql, params)).mappings().all()
|
||||
|
||||
return [
|
||||
ContractTemplateSearchCategoryVO(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"] or ""),
|
||||
search_count=int(row["search_count"] or 0),
|
||||
)
|
||||
for row in rows
|
||||
if row.get("id") is not None
|
||||
]
|
||||
|
||||
def _build_template_filters(
|
||||
self,
|
||||
keyword: str | None,
|
||||
category_id: int | None,
|
||||
category_name: str | None,
|
||||
file_format: str | None,
|
||||
is_featured: bool | None,
|
||||
) -> tuple[str, dict[str, Any], bool]:
|
||||
filters = ["1=1"]
|
||||
params: dict[str, Any] = {}
|
||||
needs_category_name_filter = False
|
||||
|
||||
if category_id is not None:
|
||||
filters.append("t.category_id = :category_id")
|
||||
params["category_id"] = category_id
|
||||
elif category_name:
|
||||
filters.append("c.name = :category_name")
|
||||
params["category_name"] = category_name.strip()
|
||||
needs_category_name_filter = True
|
||||
|
||||
if file_format:
|
||||
filters.append("t.file_format = :file_format")
|
||||
params["file_format"] = file_format.strip().lower()
|
||||
|
||||
if is_featured is not None:
|
||||
filters.append("COALESCE(t.is_featured, FALSE) = :is_featured")
|
||||
params["is_featured"] = is_featured
|
||||
|
||||
clean_keyword = (keyword or "").strip()
|
||||
if clean_keyword:
|
||||
filters.append(
|
||||
"("
|
||||
"t.title ILIKE :keyword "
|
||||
"OR COALESCE(t.description, '') ILIKE :keyword "
|
||||
"OR COALESCE(t.template_code, '') ILIKE :keyword "
|
||||
"OR COALESCE(c.name, '') ILIKE :keyword"
|
||||
")"
|
||||
)
|
||||
params["keyword"] = f"%{clean_keyword}%"
|
||||
needs_category_name_filter = True
|
||||
|
||||
return " AND ".join(filters), params, needs_category_name_filter
|
||||
|
||||
def _build_template_from_sql(self, needs_category_name_filter: bool) -> str:
|
||||
_ = needs_category_name_filter
|
||||
return """
|
||||
FROM contract_templates t
|
||||
LEFT JOIN contract_categories c ON c.id = t.category_id
|
||||
"""
|
||||
|
||||
def _build_order_clause(self, sort_by: str | None, sort_order: str | None, default_field: str, default_order: str) -> str:
|
||||
field = _ALLOWED_SORT_FIELDS.get(str(sort_by or "").strip().lower(), _ALLOWED_SORT_FIELDS[default_field])
|
||||
direction = "DESC" if str(sort_order or default_order).strip().lower() == "desc" else "ASC"
|
||||
return f"{field} {direction}, t.id ASC"
|
||||
|
||||
def _bind_expanding(self, *sql_objects_and_params: Any):
|
||||
sql_objects = list(sql_objects_and_params[:-1])
|
||||
params = sql_objects_and_params[-1]
|
||||
if "category_ids" in params:
|
||||
sql_objects = [sql.bindparams(bindparam("category_ids", expanding=True)) for sql in sql_objects]
|
||||
return tuple(sql_objects)
|
||||
|
||||
def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO:
|
||||
return ContractTemplateCategoryVO(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"] or ""),
|
||||
icon=row.get("icon"),
|
||||
description=row.get("description"),
|
||||
sort_order=int(row.get("sort_order") or 0),
|
||||
template_count=int(row.get("template_count") or 0),
|
||||
is_enabled=bool(row.get("is_enabled", True)),
|
||||
)
|
||||
|
||||
def _to_list_item_vo(self, row: Any) -> ContractTemplateListItemVO:
|
||||
return ContractTemplateListItemVO(
|
||||
id=int(row["id"]),
|
||||
template_code=str(row.get("template_code") or ""),
|
||||
title=str(row.get("title") or ""),
|
||||
category_id=int(row.get("category_id") or 0),
|
||||
category_name=row.get("category_name"),
|
||||
category_icon=row.get("category_icon"),
|
||||
description=row.get("description"),
|
||||
file_path=row.get("file_path"),
|
||||
pdf_file_path=row.get("pdf_file_path"),
|
||||
file_format=str(row.get("file_format") or ""),
|
||||
is_featured=bool(row.get("is_featured", False)),
|
||||
created_at=self._stringify_time(row.get("created_at")),
|
||||
updated_at=self._stringify_time(row.get("updated_at")),
|
||||
)
|
||||
|
||||
def _to_detail_vo(self, row: Any) -> ContractTemplateDetailVO:
|
||||
base = self._to_list_item_vo(row)
|
||||
return ContractTemplateDetailVO(
|
||||
**base.model_dump(),
|
||||
category_description=row.get("category_description"),
|
||||
placeholder_schema=None,
|
||||
)
|
||||
|
||||
def _stringify_time(self, value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
@@ -222,6 +222,9 @@ class RbacAdminServiceImpl(IRbacAdminService):
|
||||
{"permission_key": "usage_stats:departments:read", "display_name": "查看部门统计", "module": "usage_stats", "resource": "departments", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-departments", "route_path": "/usage-stats"},
|
||||
{"permission_key": "usage_stats:areas:read", "display_name": "查看地区统计", "module": "usage_stats", "resource": "areas", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-areas", "route_path": "/usage-stats"},
|
||||
{"permission_key": "usage_stats:details:read", "display_name": "查看统计明细", "module": "usage_stats", "resource": "details", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/details", "route_path": "/usage-stats"},
|
||||
{"permission_key": "contract_template:list:read", "display_name": "查看合同模板列表", "module": "contract_template", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"},
|
||||
{"permission_key": "contract_template:search:read", "display_name": "搜索合同模板", "module": "contract_template", "resource": "search", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates/search", "route_path": "/contract-template/search"},
|
||||
{"permission_key": "contract_template:detail:read", "display_name": "查看合同模板详情", "module": "contract_template", "resource": "detail", "action": "read", "api_method": "GET", "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"},
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate legacy contract templates from docauditai to leaudit_platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
from minio import Minio
|
||||
|
||||
from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_TOML = ROOT / "app.toml"
|
||||
OLD_BUCKET = "docauditai"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyCategory:
|
||||
id: int
|
||||
name: str
|
||||
icon: str | None
|
||||
description: str | None
|
||||
sort_order: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyTemplate:
|
||||
id: int
|
||||
template_code: str
|
||||
title: str
|
||||
category_id: int
|
||||
description: str | None
|
||||
file_path: str | None
|
||||
file_format: str | None
|
||||
is_featured: bool | None
|
||||
created_at: object
|
||||
updated_at: object
|
||||
pdf_file_path: str | None
|
||||
category_name: str
|
||||
|
||||
|
||||
def load_target_config() -> dict[str, str]:
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
with APP_TOML.open("rb") as fh:
|
||||
config = tomllib.load(fh)
|
||||
|
||||
db = config["DB"]
|
||||
oss = config["OSS"]
|
||||
return {
|
||||
"target_dsn": (
|
||||
f"postgresql://{db['USER']}:{db['PASSWORD']}"
|
||||
f"@{db['HOST']}:{db['PORT']}/{db['NAME']}"
|
||||
),
|
||||
"oss_endpoint": oss["ENDPOINT"],
|
||||
"oss_base_url": oss.get("BASE_URL", ""),
|
||||
"oss_access_key": oss["ACCESS_KEY"],
|
||||
"oss_secret_key": oss["SECRET_KEY"],
|
||||
"oss_bucket": oss["BUCKET"],
|
||||
}
|
||||
|
||||
|
||||
def build_legacy_dsn(args: argparse.Namespace) -> str:
|
||||
return (
|
||||
f"postgresql://{args.legacy_user}:{args.legacy_password}"
|
||||
f"@{args.legacy_host}:{args.legacy_port}/{args.legacy_db}"
|
||||
)
|
||||
|
||||
|
||||
def build_minio_client(config: dict[str, str]) -> Minio:
|
||||
endpoint = config["oss_endpoint"]
|
||||
base_url = config.get("oss_base_url", "")
|
||||
if base_url.startswith("http://"):
|
||||
secure = False
|
||||
elif base_url.startswith("https://"):
|
||||
secure = True
|
||||
else:
|
||||
secure = endpoint.startswith("https://")
|
||||
host = endpoint.replace("http://", "").replace("https://", "")
|
||||
return Minio(
|
||||
host,
|
||||
access_key=config["oss_access_key"],
|
||||
secret_key=config["oss_secret_key"],
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_legacy_categories(conn: asyncpg.Connection) -> list[LegacyCategory]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, icon, description, COALESCE(sort_order, 0) AS sort_order
|
||||
FROM public.contract_categories
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
return [LegacyCategory(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def fetch_legacy_templates(conn: asyncpg.Connection) -> list[LegacyTemplate]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.template_code,
|
||||
t.title,
|
||||
t.category_id,
|
||||
t.description,
|
||||
t.file_path,
|
||||
t.file_format,
|
||||
t.is_featured,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.pdf_file_path,
|
||||
c.name AS category_name
|
||||
FROM public.contract_templates t
|
||||
LEFT JOIN public.contract_categories c ON c.id = t.category_id
|
||||
ORDER BY t.id
|
||||
"""
|
||||
)
|
||||
return [LegacyTemplate(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
def resolve_docx_path(template: LegacyTemplate, object_keys: set[str]) -> str:
|
||||
file_path = (template.file_path or "").strip()
|
||||
if not file_path:
|
||||
raise ValueError(f"template {template.id} missing file_path")
|
||||
if file_path in object_keys:
|
||||
pdf_path = (template.pdf_file_path or "").strip()
|
||||
if pdf_path and pdf_path in object_keys:
|
||||
expected_docx = str(Path(pdf_path).with_suffix(".docx"))
|
||||
if expected_docx in object_keys:
|
||||
current_name = Path(file_path).name
|
||||
expected_name = Path(expected_docx).name
|
||||
if current_name != expected_name:
|
||||
return expected_docx
|
||||
return file_path
|
||||
|
||||
pdf_path = (template.pdf_file_path or "").strip()
|
||||
if pdf_path:
|
||||
expected_docx = str(Path(pdf_path).with_suffix(".docx"))
|
||||
if expected_docx in object_keys:
|
||||
return expected_docx
|
||||
|
||||
raise FileNotFoundError(f"template {template.id} docx not found: {file_path}")
|
||||
|
||||
|
||||
def resolve_pdf_path(template: LegacyTemplate, object_keys: set[str]) -> str:
|
||||
pdf_path = (template.pdf_file_path or "").strip()
|
||||
if not pdf_path:
|
||||
raise ValueError(f"template {template.id} missing pdf_file_path")
|
||||
if pdf_path in object_keys:
|
||||
return pdf_path
|
||||
raise FileNotFoundError(f"template {template.id} pdf not found: {pdf_path}")
|
||||
|
||||
|
||||
def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]:
|
||||
docx_key = OssPathUtils.BuildContractTemplateKey(
|
||||
CategoryName=template.category_name,
|
||||
TemplateCode=template.template_code,
|
||||
FileRole="source",
|
||||
FileName=Path(docx_path).name,
|
||||
)
|
||||
pdf_key = OssPathUtils.BuildContractTemplateKey(
|
||||
CategoryName=template.category_name,
|
||||
TemplateCode=template.template_code,
|
||||
FileRole="preview",
|
||||
FileName=Path(pdf_path).name,
|
||||
)
|
||||
return docx_key, pdf_key
|
||||
|
||||
|
||||
def copy_object_bytes(
|
||||
client: Minio,
|
||||
*,
|
||||
source_bucket: str,
|
||||
source_key: str,
|
||||
target_bucket: str,
|
||||
target_key: str,
|
||||
) -> None:
|
||||
response = client.get_object(source_bucket, source_key)
|
||||
try:
|
||||
payload = response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
client.put_object(
|
||||
target_bucket,
|
||||
target_key,
|
||||
data=BytesIO(payload),
|
||||
length=len(payload),
|
||||
)
|
||||
|
||||
|
||||
def ensure_bucket(client: Minio, bucket: str) -> None:
|
||||
if not client.bucket_exists(bucket):
|
||||
client.make_bucket(bucket)
|
||||
|
||||
|
||||
async def reset_target_tables(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute("TRUNCATE TABLE public.contract_templates RESTART IDENTITY CASCADE")
|
||||
await conn.execute("TRUNCATE TABLE public.contract_categories RESTART IDENTITY CASCADE")
|
||||
|
||||
|
||||
async def insert_categories(conn: asyncpg.Connection, categories: list[LegacyCategory]) -> None:
|
||||
for category in categories:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO public.contract_categories (id, name, icon, description, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
category.id,
|
||||
category.name,
|
||||
category.icon,
|
||||
category.description,
|
||||
category.sort_order,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('public.contract_categories', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM public.contract_categories), 1),
|
||||
TRUE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def insert_templates(
|
||||
conn: asyncpg.Connection,
|
||||
templates: list[LegacyTemplate],
|
||||
template_paths: dict[int, tuple[str, str]],
|
||||
) -> None:
|
||||
for template in templates:
|
||||
file_path, pdf_file_path = template_paths[template.id]
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO public.contract_templates (
|
||||
id,
|
||||
template_code,
|
||||
title,
|
||||
category_id,
|
||||
description,
|
||||
file_path,
|
||||
file_format,
|
||||
is_featured,
|
||||
created_at,
|
||||
updated_at,
|
||||
pdf_file_path
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
""",
|
||||
template.id,
|
||||
template.template_code,
|
||||
template.title,
|
||||
template.category_id,
|
||||
template.description,
|
||||
file_path,
|
||||
(template.file_format or "docx").lower(),
|
||||
bool(template.is_featured),
|
||||
template.created_at,
|
||||
template.updated_at,
|
||||
pdf_file_path,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('public.contract_templates', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM public.contract_templates), 1),
|
||||
TRUE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Migrate legacy contract templates.")
|
||||
parser.add_argument("--legacy-host", default="nas.7bm.co")
|
||||
parser.add_argument("--legacy-port", type=int, default=54302)
|
||||
parser.add_argument("--legacy-db", default="docauditai")
|
||||
parser.add_argument("--legacy-user", default="root")
|
||||
parser.add_argument("--legacy-password", default="postgresql.2025.qwe")
|
||||
parser.add_argument("--apply", action="store_true", help="Apply migration to OSS and target DB.")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_target_config()
|
||||
legacy_dsn = build_legacy_dsn(args)
|
||||
target_dsn = config["target_dsn"]
|
||||
target_bucket = config["oss_bucket"]
|
||||
minio_client = build_minio_client(config)
|
||||
|
||||
legacy_conn = await asyncpg.connect(legacy_dsn)
|
||||
target_conn = await asyncpg.connect(target_dsn)
|
||||
try:
|
||||
ensure_bucket(minio_client, target_bucket)
|
||||
categories = await fetch_legacy_categories(legacy_conn)
|
||||
templates = await fetch_legacy_templates(legacy_conn)
|
||||
object_keys = {
|
||||
obj.object_name
|
||||
for obj in minio_client.list_objects(OLD_BUCKET, prefix="contract-template/", recursive=True)
|
||||
}
|
||||
|
||||
template_paths: dict[int, tuple[str, str]] = {}
|
||||
for template in templates:
|
||||
docx_path = resolve_docx_path(template, object_keys)
|
||||
pdf_path = resolve_pdf_path(template, object_keys)
|
||||
template_paths[template.id] = build_new_object_keys(template, docx_path, pdf_path)
|
||||
|
||||
print(f"legacy categories: {len(categories)}")
|
||||
print(f"legacy templates: {len(templates)}")
|
||||
for template in templates:
|
||||
old_docx = resolve_docx_path(template, object_keys)
|
||||
old_pdf = resolve_pdf_path(template, object_keys)
|
||||
new_docx, new_pdf = template_paths[template.id]
|
||||
print(
|
||||
f"[{template.id}] {template.template_code} | "
|
||||
f"{old_docx} -> {new_docx} | {old_pdf} -> {new_pdf}"
|
||||
)
|
||||
|
||||
if not args.apply:
|
||||
print("dry-run complete; rerun with --apply to execute migration")
|
||||
return
|
||||
|
||||
if args.apply:
|
||||
found_correction = False
|
||||
for template in templates:
|
||||
old_docx = resolve_docx_path(template, object_keys)
|
||||
old_pdf = resolve_pdf_path(template, object_keys)
|
||||
new_docx, new_pdf = template_paths[template.id]
|
||||
if old_docx != (template.file_path or "").strip():
|
||||
print(
|
||||
f"corrected docx path for template {template.id}: "
|
||||
f"{template.file_path} -> {old_docx}"
|
||||
)
|
||||
found_correction = True
|
||||
copy_object_bytes(
|
||||
minio_client,
|
||||
source_bucket=OLD_BUCKET,
|
||||
source_key=old_docx,
|
||||
target_bucket=target_bucket,
|
||||
target_key=new_docx,
|
||||
)
|
||||
copy_object_bytes(
|
||||
minio_client,
|
||||
source_bucket=OLD_BUCKET,
|
||||
source_key=old_pdf,
|
||||
target_bucket=target_bucket,
|
||||
target_key=new_pdf,
|
||||
)
|
||||
if not found_correction:
|
||||
print("no legacy path corrections required")
|
||||
|
||||
async with target_conn.transaction():
|
||||
await reset_target_tables(target_conn)
|
||||
await insert_categories(target_conn, categories)
|
||||
await insert_templates(target_conn, templates, template_paths)
|
||||
|
||||
print("migration applied successfully")
|
||||
finally:
|
||||
await legacy_conn.close()
|
||||
await target_conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -46,6 +46,11 @@ psql -h <host> -U <user> -d <db_name> -v ON_ERROR_STOP=1 -f scripts/创建sql/<f
|
||||
3. `schema_v2_add_evaluation_tables.sql`
|
||||
4. `seed_home_entry_modules.sql`
|
||||
|
||||
### 合同模板模块上线
|
||||
|
||||
1. `schema_contract_templates.sql`
|
||||
2. `seed_contract_templates_rbac.sql`
|
||||
|
||||
### 系统使用统计上线
|
||||
|
||||
1. `schema_add_usage_stats.sql`
|
||||
@@ -221,6 +226,91 @@ ORDER BY permission_key;
|
||||
1. `schema_v3_add_cross_review_phase1.sql`
|
||||
2. `seed_cross_review_phase1_permissions.sql`
|
||||
|
||||
### 七、合同模板
|
||||
|
||||
- `schema_contract_templates.sql`
|
||||
- 用途:在主库创建合同模板分类表和模板表
|
||||
- 主要内容:新增 `contract_categories`、`contract_templates`、补索引和注释
|
||||
- 执行时机:上线合同模板新后端接口前必跑
|
||||
|
||||
- `seed_contract_templates_rbac.sql`
|
||||
- 用途:补齐合同模板只读权限点
|
||||
- 主要内容:新增 `contract_template:list:read`、`contract_template:search:read`、`contract_template:detail:read`
|
||||
- 依赖:`sys_routes` 中已经存在 `/contract-template/list` 和 `/contract-template/search`
|
||||
|
||||
- `migrate_legacy_contract_templates.py`
|
||||
- 用途:把老库 `docauditai` 的合同模板分类、模板记录和旧 bucket 文件迁入主库 `leaudit_platform`
|
||||
- 主要内容:
|
||||
- 读取老库 `public.contract_categories`、`public.contract_templates`
|
||||
- 从旧 bucket `docauditai` 读取 `contract-template/...` 对象
|
||||
- 复制到新 bucket `leaudit` 的 `contract-templates/...` 相对路径
|
||||
- 回写主库 `contract_categories`、`contract_templates.file_path`、`contract_templates.pdf_file_path`
|
||||
- 适用场景:主库已完成建表与权限初始化,但仍是 demo 数据或空数据时
|
||||
- 注意:
|
||||
- 脚本会重置主库 `contract_categories` / `contract_templates` 当前数据并按老库正式数据重建
|
||||
- 当前已知会自动修正 1 条老脏数据:
|
||||
- `contract_templates.id=3`
|
||||
- 标题:`房屋租赁合同(我方承租)`
|
||||
- 老 `file_path` 误指向“我方出租”docx,迁移时会自动改成“我方承租”docx
|
||||
|
||||
#### 推荐顺序
|
||||
|
||||
1. `schema_contract_templates.sql`
|
||||
2. `seed_contract_templates_rbac.sql`
|
||||
3. `python scripts/migrate_legacy_contract_templates.py`
|
||||
4. `python scripts/migrate_legacy_contract_templates.py --apply`
|
||||
|
||||
#### 标准执行命令
|
||||
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <db_name> -v ON_ERROR_STOP=1 -f scripts/创建sql/schema_contract_templates.sql
|
||||
psql -h <host> -U <user> -d <db_name> -v ON_ERROR_STOP=1 -f scripts/创建sql/seed_contract_templates_rbac.sql
|
||||
|
||||
# 先 dry-run,看旧路径 -> 新路径映射
|
||||
python scripts/migrate_legacy_contract_templates.py
|
||||
|
||||
# 确认无误后正式执行:复制 OSS 文件 + 回写主库
|
||||
python scripts/migrate_legacy_contract_templates.py --apply
|
||||
```
|
||||
|
||||
#### 执行后验收
|
||||
|
||||
```sql
|
||||
SELECT to_regclass('public.contract_categories');
|
||||
SELECT to_regclass('public.contract_templates');
|
||||
|
||||
SELECT permission_key
|
||||
FROM permissions
|
||||
WHERE permission_key LIKE 'contract_template:%'
|
||||
ORDER BY permission_key;
|
||||
|
||||
SELECT r.role_key, p.permission_key, rp.grant_type, rp.data_scope
|
||||
FROM role_permissions rp
|
||||
JOIN roles r ON r.id = rp.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.permission_key LIKE 'contract_template:%'
|
||||
ORDER BY r.role_key, p.permission_key;
|
||||
|
||||
SELECT COUNT(*) AS category_count FROM public.contract_categories;
|
||||
SELECT COUNT(*) AS template_count FROM public.contract_templates;
|
||||
|
||||
SELECT id, template_code, title, file_path, pdf_file_path
|
||||
FROM public.contract_templates
|
||||
ORDER BY id
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
#### 当前基线验收结果
|
||||
|
||||
- 主库 `leaudit_platform`
|
||||
- `contract_categories = 9`
|
||||
- `contract_templates = 27`
|
||||
- 新 bucket `leaudit`
|
||||
- `contract-templates/...` 对象总数 = `54`
|
||||
- 新路径样例
|
||||
- `contract-templates/买卖合同/mmht/source__买卖合同范本.docx`
|
||||
- `contract-templates/买卖合同/mmht/preview__买卖合同范本.pdf`
|
||||
|
||||
### 七、RAG
|
||||
|
||||
- `schema_add_rag_chat.sql`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- LeAudit Platform Contract Template Schema
|
||||
-- 目标:
|
||||
-- 1. 在主库 leaudit_platform 创建合同模板分类表
|
||||
-- 2. 在主库 leaudit_platform 创建合同模板表
|
||||
-- 3. 补齐索引与基础约束
|
||||
-- 说明:
|
||||
-- - 本脚本不依赖旧库 docauditai
|
||||
-- - 幂等脚本,可重复执行
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.contract_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
icon VARCHAR(100) NULL,
|
||||
description TEXT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_categories_name
|
||||
ON public.contract_categories(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.contract_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
category_id INTEGER NOT NULL REFERENCES public.contract_categories(id) ON DELETE RESTRICT,
|
||||
description TEXT NULL,
|
||||
file_path VARCHAR(500) NULL,
|
||||
file_format VARCHAR(10) NOT NULL,
|
||||
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
pdf_file_path VARCHAR(500) NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_templates_code
|
||||
ON public.contract_templates(template_code);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_templates_category_id
|
||||
ON public.contract_templates(category_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_at
|
||||
ON public.contract_templates(updated_at DESC);
|
||||
|
||||
COMMENT ON TABLE public.contract_categories IS '合同模板分类表';
|
||||
COMMENT ON COLUMN public.contract_categories.name IS '分类名称';
|
||||
COMMENT ON COLUMN public.contract_categories.icon IS '分类图标';
|
||||
COMMENT ON COLUMN public.contract_categories.description IS '分类描述';
|
||||
COMMENT ON COLUMN public.contract_categories.sort_order IS '排序值';
|
||||
|
||||
COMMENT ON TABLE public.contract_templates IS '合同模板主表';
|
||||
COMMENT ON COLUMN public.contract_templates.template_code IS '模板编码';
|
||||
COMMENT ON COLUMN public.contract_templates.title IS '模板标题';
|
||||
COMMENT ON COLUMN public.contract_templates.category_id IS '所属分类ID';
|
||||
COMMENT ON COLUMN public.contract_templates.description IS '模板描述';
|
||||
COMMENT ON COLUMN public.contract_templates.file_path IS '源模板文件路径';
|
||||
COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式';
|
||||
COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板';
|
||||
COMMENT ON COLUMN public.contract_templates.pdf_file_path IS 'PDF预览文件路径';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,140 @@
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- LeAudit Platform Contract Template RBAC Seed
|
||||
-- 目标:
|
||||
-- 1. 补齐合同模板读权限
|
||||
-- 2. 给 super_admin / provincial_admin / admin 分配模板读权限
|
||||
-- 说明:
|
||||
-- - 依赖 user_rbac_schema_patch.sql
|
||||
-- - 依赖合同模板前端路由已存在于 sys_routes
|
||||
-- - 幂等脚本,可重复执行
|
||||
-- ============================================================================
|
||||
|
||||
WITH route_map AS (
|
||||
SELECT id, route_path
|
||||
FROM sys_routes
|
||||
WHERE deleted_at IS NULL
|
||||
AND route_path IN ('/contract-template/list', '/contract-template/search')
|
||||
)
|
||||
INSERT INTO permissions (
|
||||
permission_key,
|
||||
module,
|
||||
resource,
|
||||
action,
|
||||
description,
|
||||
display_name,
|
||||
permission_type,
|
||||
is_system,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by,
|
||||
parent_id,
|
||||
sort_order,
|
||||
route_id,
|
||||
api_path,
|
||||
api_method,
|
||||
related_routes
|
||||
)
|
||||
SELECT
|
||||
seed.permission_key,
|
||||
seed.module,
|
||||
seed.resource,
|
||||
seed.action,
|
||||
seed.description,
|
||||
seed.display_name,
|
||||
'API',
|
||||
TRUE,
|
||||
NULL::jsonb,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL::bigint,
|
||||
NULL::bigint,
|
||||
NULL::bigint,
|
||||
seed.sort_order,
|
||||
route_map.id,
|
||||
seed.api_path,
|
||||
seed.api_method,
|
||||
NULL::bigint[]
|
||||
FROM (
|
||||
VALUES
|
||||
('contract_template:list:read', 'contract_template', 'list', 'read', '查看合同模板列表', '查看合同模板列表', '/contract-template/list', 310, '/api/v3/contract-templates', 'GET'),
|
||||
('contract_template:search:read', 'contract_template', 'search', 'read', '搜索合同模板', '搜索合同模板', '/contract-template/search', 311, '/api/v3/contract-templates/search','GET'),
|
||||
('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET')
|
||||
) AS seed(
|
||||
permission_key,
|
||||
module,
|
||||
resource,
|
||||
action,
|
||||
description,
|
||||
display_name,
|
||||
route_path,
|
||||
sort_order,
|
||||
api_path,
|
||||
api_method
|
||||
)
|
||||
JOIN route_map ON route_map.route_path = seed.route_path
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
module = EXCLUDED.module,
|
||||
resource = EXCLUDED.resource,
|
||||
action = EXCLUDED.action,
|
||||
description = EXCLUDED.description,
|
||||
display_name = EXCLUDED.display_name,
|
||||
permission_type = EXCLUDED.permission_type,
|
||||
is_system = EXCLUDED.is_system,
|
||||
route_id = EXCLUDED.route_id,
|
||||
api_path = EXCLUDED.api_path,
|
||||
api_method = EXCLUDED.api_method,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW();
|
||||
|
||||
WITH role_map AS (
|
||||
SELECT id, role_key
|
||||
FROM roles
|
||||
WHERE role_key IN ('super_admin', 'provincial_admin', 'admin')
|
||||
),
|
||||
perm_map AS (
|
||||
SELECT id, permission_key
|
||||
FROM permissions
|
||||
WHERE permission_key LIKE 'contract_template:%'
|
||||
),
|
||||
seed(role_key, permission_key, grant_type, data_scope) AS (
|
||||
VALUES
|
||||
('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
|
||||
|
||||
('provincial_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
|
||||
|
||||
('admin', 'contract_template:list:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'contract_template:search:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'contract_template:detail:read', 'GRANT', 'DEPT')
|
||||
)
|
||||
INSERT INTO role_permissions (
|
||||
role_id,
|
||||
permission_id,
|
||||
grant_type,
|
||||
data_scope,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
role_map.id,
|
||||
perm_map.id,
|
||||
seed.grant_type,
|
||||
seed.data_scope,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM seed
|
||||
JOIN role_map ON role_map.role_key = seed.role_key
|
||||
JOIN perm_map ON perm_map.permission_key = seed.permission_key
|
||||
ON CONFLICT (role_id, permission_id) DO UPDATE SET
|
||||
grant_type = EXCLUDED.grant_type,
|
||||
data_scope = EXCLUDED.data_scope,
|
||||
updated_at = NOW();
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user