feat: 支持合同模板上传与地区隔离

This commit is contained in:
wren
2026-05-19 22:59:11 +08:00
parent 980996d933
commit 7c6f134808
10 changed files with 803 additions and 131 deletions
@@ -54,6 +54,7 @@ class OssPathUtils:
@staticmethod @staticmethod
def BuildContractTemplateKey( def BuildContractTemplateKey(
Region: str,
CategoryName: str, CategoryName: str,
TemplateCode: str, TemplateCode: str,
FileRole: str, FileRole: str,
@@ -61,12 +62,13 @@ class OssPathUtils:
) -> str: ) -> str:
"""生成合同模板 object key。""" """生成合同模板 object key。"""
ext = Path(FileName).suffix or "" ext = Path(FileName).suffix or ""
safe_region = OssPathUtils.BuildSafeFileStem(Region or "shared")
safe_category = OssPathUtils.BuildSafeFileStem(CategoryName or "uncategorized") safe_category = OssPathUtils.BuildSafeFileStem(CategoryName or "uncategorized")
safe_template_code = OssPathUtils.BuildSafeFileStem(TemplateCode or "template") safe_template_code = OssPathUtils.BuildSafeFileStem(TemplateCode or "template")
safe_stem = OssPathUtils.BuildSafeFileStem(FileName) safe_stem = OssPathUtils.BuildSafeFileStem(FileName)
safe_role = OssPathUtils.BuildSafeFileStem(FileRole or "file") safe_role = OssPathUtils.BuildSafeFileStem(FileRole or "file")
return ( return (
f"contract-templates/{safe_category}/{safe_template_code}/" f"contract-templates/{safe_region}/{safe_category}/{safe_template_code}/"
f"{safe_role}__{safe_stem}{ext}" f"{safe_role}__{safe_stem}{ext}"
) )
@@ -1,11 +1,12 @@
"""合同模板控制器。""" """合同模板控制器。"""
from fastapi import Depends, Query from fastapi import Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token 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.domian.Dto.contractTemplateDto import ( from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO, ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO, ContractTemplateSearchQueryDTO,
) )
@@ -39,6 +40,7 @@ class ContractTemplateController(BaseController):
keyword: str | None = Query(None, description="关键词"), keyword: str | None = Query(None, description="关键词"),
category_id: int | None = Query(None, description="分类ID"), category_id: int | None = Query(None, description="分类ID"),
category_name: str | None = Query(None, description="分类名称"), category_name: str | None = Query(None, description="分类名称"),
region: str | None = Query(None, description="地区"),
file_format: str | None = Query(None, description="文件格式"), file_format: str | None = Query(None, description="文件格式"),
is_featured: bool | None = Query(None, description="是否推荐"), is_featured: bool | None = Query(None, description="是否推荐"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
@@ -53,6 +55,7 @@ class ContractTemplateController(BaseController):
keyword=keyword, keyword=keyword,
category_id=category_id, category_id=category_id,
category_name=category_name, category_name=category_name,
region=region,
file_format=file_format, file_format=file_format,
is_featured=is_featured, is_featured=is_featured,
page=page, page=page,
@@ -60,7 +63,32 @@ class ContractTemplateController(BaseController):
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
) )
data = await self.ContractTemplateService.ListTemplates(query) data = await self.ContractTemplateService.ListTemplates(query, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.post("")
async def CreateContractTemplate(
title: str = Form(...),
template_code: str = Form(...),
category_id: int = Form(...),
region: str | None = Form(default=None),
description: str | None = Form(default=None),
is_featured: bool = Form(default=False),
file: UploadFile = File(...),
pdf_file: UploadFile | None = File(default=None),
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})
body = ContractTemplateCreateDTO(
title=title,
template_code=template_code,
category_id=category_id,
region=region,
description=description,
is_featured=is_featured,
)
data = await self.ContractTemplateService.CreateTemplate(body, file, pdf_file, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.get("/search") @self.router.get("/search")
@@ -68,6 +96,7 @@ class ContractTemplateController(BaseController):
q: str = Query(..., min_length=1, description="搜索关键词"), q: str = Query(..., min_length=1, description="搜索关键词"),
category_id: int | None = Query(None, description="分类ID"), category_id: int | None = Query(None, description="分类ID"),
category_name: str | None = Query(None, description="分类名称"), category_name: str | None = Query(None, description="分类名称"),
region: str | None = Query(None, description="地区"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(12, ge=1, le=200, description="分页大小"), page_size: int = Query(12, ge=1, le=200, description="分页大小"),
sort_by: str = Query("updated_at", description="排序字段"), sort_by: str = Query("updated_at", description="排序字段"),
@@ -80,12 +109,13 @@ class ContractTemplateController(BaseController):
q=q, q=q,
category_id=category_id, category_id=category_id,
category_name=category_name, category_name=category_name,
region=region,
page=page, page=page,
page_size=page_size, page_size=page_size,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
) )
data = await self.ContractTemplateService.SearchTemplates(query) data = await self.ContractTemplateService.SearchTemplates(query, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.get("/{TemplateId}") @self.router.get("/{TemplateId}")
@@ -95,11 +125,21 @@ class ContractTemplateController(BaseController):
): ):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]): 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}) return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None})
data = await self.ContractTemplateService.GetTemplateDetail(TemplateId) data = await self.ContractTemplateService.GetTemplateDetail(TemplateId, int(payload["user_id"]))
if not data: if not data:
return JSONResponse(status_code=404, content={"code": 404, "msg": "合同模板不存在", "data": None}) 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()}) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.delete("/{TemplateId}")
async def DeleteContractTemplate(
TemplateId: int,
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:delete:delete"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除合同模板权限", "data": None})
await self.ContractTemplateService.DeleteTemplate(TemplateId, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": True})
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
for permission_key in permission_keys: for permission_key in permission_keys:
if await self.PermissionService.CheckPermission(user_id, permission_key): if await self.PermissionService.CheckPermission(user_id, permission_key):
@@ -7,6 +7,7 @@ class ContractTemplateListQueryDTO(BaseModel):
keyword: str | None = Field(None, description="关键词") keyword: str | None = Field(None, description="关键词")
category_id: int | None = Field(None, description="分类ID") category_id: int | None = Field(None, description="分类ID")
category_name: str | None = Field(None, description="分类名称") category_name: str | None = Field(None, description="分类名称")
region: str | None = Field(None, description="地区")
file_format: str | None = Field(None, description="文件格式") file_format: str | None = Field(None, description="文件格式")
is_featured: bool | None = Field(None, description="是否推荐") is_featured: bool | None = Field(None, description="是否推荐")
page: int = Field(1, ge=1, description="页码") page: int = Field(1, ge=1, description="页码")
@@ -21,7 +22,19 @@ class ContractTemplateSearchQueryDTO(BaseModel):
q: str = Field(..., min_length=1, description="搜索关键词") q: str = Field(..., min_length=1, description="搜索关键词")
category_id: int | None = Field(None, description="分类ID") category_id: int | None = Field(None, description="分类ID")
category_name: str | None = Field(None, description="分类名称") category_name: str | None = Field(None, description="分类名称")
region: str | None = Field(None, description="地区")
page: int = Field(1, ge=1, description="页码") page: int = Field(1, ge=1, description="页码")
page_size: int = Field(12, ge=1, le=200, description="分页大小") page_size: int = Field(12, ge=1, le=200, description="分页大小")
sort_by: str = Field("updated_at", description="排序字段") sort_by: str = Field("updated_at", description="排序字段")
sort_order: str = Field("desc", description="排序方向") sort_order: str = Field("desc", description="排序方向")
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="所属地区")
description: str | None = Field(None, description="模板简介")
is_featured: bool = Field(False, description="是否推荐")
@@ -23,10 +23,17 @@ class ContractTemplateListItemVO(BaseModel):
category_name: str | None = Field(None, description="分类名称") category_name: str | None = Field(None, description="分类名称")
category_icon: str | None = Field(None, description="分类图标") category_icon: str | None = Field(None, description="分类图标")
description: str | None = Field(None, description="模板简介") description: str | None = Field(None, description="模板简介")
region: str = Field(..., description="所属地区")
file_path: str | None = Field(None, description="原始模板文件路径") file_path: str | None = Field(None, description="原始模板文件路径")
pdf_file_path: str | None = Field(None, description="PDF 预览文件路径") pdf_file_path: str | None = Field(None, description="PDF 预览文件路径")
file_format: str = Field(..., description="文件格式") file_format: str = Field(..., description="文件格式")
original_file_name: str | None = Field(None, description="原始上传文件名")
mime_type: str | None = Field(None, description="MIME 类型")
file_size: int = Field(0, description="文件大小")
pdf_file_size: int | None = Field(None, description="预览 PDF 文件大小")
is_featured: bool = Field(False, description="是否推荐") is_featured: bool = Field(False, description="是否推荐")
created_by: int | None = Field(None, description="创建人")
updated_by: int | None = Field(None, description="更新人")
created_at: str | None = Field(None, description="创建时间") created_at: str | None = Field(None, description="创建时间")
updated_at: str | None = Field(None, description="更新时间") updated_at: str | None = Field(None, description="更新时间")
@@ -48,6 +55,10 @@ class ContractTemplateDetailVO(ContractTemplateListItemVO):
placeholder_schema: dict | None = Field(None, description="模板占位符结构") placeholder_schema: dict | None = Field(None, description="模板占位符结构")
class ContractTemplateCreateVO(ContractTemplateDetailVO):
"""合同模板上传结果。"""
class ContractTemplateSearchCategoryVO(BaseModel): class ContractTemplateSearchCategoryVO(BaseModel):
"""搜索结果分类统计。""" """搜索结果分类统计。"""
@@ -1,11 +1,14 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from fastapi import UploadFile
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO, ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO, ContractTemplateSearchQueryDTO,
) )
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO, ContractTemplateCategoryVO,
ContractTemplateCreateVO,
ContractTemplateDetailVO, ContractTemplateDetailVO,
ContractTemplatePageVO, ContractTemplatePageVO,
ContractTemplateSearchResultVO, ContractTemplateSearchResultVO,
@@ -20,13 +23,27 @@ class IContractTemplateService(ABC):
... ...
@abstractmethod @abstractmethod
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO:
... ...
@abstractmethod @abstractmethod
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO:
... ...
@abstractmethod @abstractmethod
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None:
...
@abstractmethod
async def CreateTemplate(
self,
Body: ContractTemplateCreateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
...
@abstractmethod
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
... ...
@@ -1,16 +1,24 @@
from __future__ import annotations from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any from typing import Any
from fastapi import UploadFile
from sqlalchemy import bindparam, text from sqlalchemy import bindparam, text
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils
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.contractTemplateDto import ( from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO, ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO, ContractTemplateSearchQueryDTO,
) )
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO, ContractTemplateCategoryVO,
ContractTemplateCreateVO,
ContractTemplateDetailVO, ContractTemplateDetailVO,
ContractTemplateListItemVO, ContractTemplateListItemVO,
ContractTemplatePageVO, ContractTemplatePageVO,
@@ -18,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateSearchResultVO, ContractTemplateSearchResultVO,
) )
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
from fastapi_modules.fastapi_leaudit.services.ossService import IOssService
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
_ALLOWED_SORT_FIELDS = { _ALLOWED_SORT_FIELDS = {
"id": "t.id", "id": "t.id",
@@ -30,8 +40,14 @@ _ALLOWED_SORT_FIELDS = {
class ContractTemplateServiceImpl(IContractTemplateService): class ContractTemplateServiceImpl(IContractTemplateService):
"""合同模板服务实现。""" """合同模板服务实现。"""
def __init__(self, OssService: IOssService | None = None) -> None:
self.OssService = OssService or OssServiceImpl()
async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]: 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" count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count"
filters = ["c.deleted_at IS NULL"]
if not IncludeDisabled:
filters.append("1=1")
sql = text( sql = text(
f""" f"""
SELECT SELECT
@@ -45,24 +61,31 @@ class ContractTemplateServiceImpl(IContractTemplateService):
FROM contract_categories c FROM contract_categories c
LEFT JOIN contract_templates t LEFT JOIN contract_templates t
ON t.category_id = c.id ON t.category_id = c.id
WHERE 1=1 AND t.deleted_at IS NULL
WHERE {' AND '.join(filters)}
GROUP BY c.id, c.name, c.icon, c.description, c.sort_order GROUP BY c.id, c.name, c.icon, c.description, c.sort_order
ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC
""" """
) )
async with GetAsyncSession() as session: async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
rows = (await session.execute(sql)).mappings().all() rows = (await session.execute(sql)).mappings().all()
return [self._to_category_vo(row) for row in rows] return [self._to_category_vo(row) for row in rows]
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
where_clause, params, needs_category_name_filter = self._build_template_filters( where_clause, params, needs_category_name_filter = self._build_template_filters(
keyword=Query.keyword, keyword=Query.keyword,
category_id=Query.category_id, category_id=Query.category_id,
category_name=Query.category_name, category_name=Query.category_name,
region=Query.region,
file_format=Query.file_format, file_format=Query.file_format,
is_featured=Query.is_featured, is_featured=Query.is_featured,
currentUser=currentUser,
) )
order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc") 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 offset = max(Query.page - 1, 0) * Query.page_size
@@ -87,11 +110,18 @@ class ContractTemplateServiceImpl(IContractTemplateService):
c.name AS category_name, c.name AS category_name,
c.icon AS category_icon, c.icon AS category_icon,
c.description AS category_description, c.description AS category_description,
t.region,
t.description, t.description,
t.file_path, t.file_path,
t.pdf_file_path, t.pdf_file_path,
t.file_format, t.file_format,
t.original_file_name,
t.mime_type,
COALESCE(t.file_size, 0) AS file_size,
t.pdf_file_size,
COALESCE(t.is_featured, FALSE) AS is_featured, COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_by,
t.updated_by,
t.created_at, t.created_at,
t.updated_at t.updated_at
{from_sql} {from_sql}
@@ -101,8 +131,6 @@ class ContractTemplateServiceImpl(IContractTemplateService):
""" """
) )
count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params) 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()) total = int((await session.execute(count_sql, params)).scalar_one())
rows = (await session.execute(list_sql, params)).mappings().all() rows = (await session.execute(list_sql, params)).mappings().all()
@@ -114,18 +142,19 @@ class ContractTemplateServiceImpl(IContractTemplateService):
templates=[self._to_list_item_vo(row) for row in rows], templates=[self._to_list_item_vo(row) for row in rows],
) )
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO:
list_query = ContractTemplateListQueryDTO( list_query = ContractTemplateListQueryDTO(
keyword=Query.q, keyword=Query.q,
category_id=Query.category_id, category_id=Query.category_id,
category_name=Query.category_name, category_name=Query.category_name,
region=Query.region,
page=Query.page, page=Query.page,
page_size=Query.page_size, page_size=Query.page_size,
sort_by=Query.sort_by, sort_by=Query.sort_by,
sort_order=Query.sort_order, sort_order=Query.sort_order,
) )
page_result = await self.ListTemplates(list_query) page_result = await self.ListTemplates(list_query, CurrentUserId)
category_stats = await self._load_search_category_stats(Query.q) category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId)
return ContractTemplateSearchResultVO( return ContractTemplateSearchResultVO(
total=page_result.total, total=page_result.total,
@@ -136,9 +165,14 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_stats=category_stats, category_stats=category_stats,
) )
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None)
sql = text( sql = text(
""" f"""
SELECT SELECT
t.id, t.id,
t.template_code, t.template_code,
@@ -147,44 +181,280 @@ class ContractTemplateServiceImpl(IContractTemplateService):
c.name AS category_name, c.name AS category_name,
c.icon AS category_icon, c.icon AS category_icon,
c.description AS category_description, c.description AS category_description,
t.region,
t.description, t.description,
t.file_path, t.file_path,
t.pdf_file_path, t.pdf_file_path,
t.file_format, t.file_format,
t.original_file_name,
t.mime_type,
COALESCE(t.file_size, 0) AS file_size,
t.pdf_file_size,
COALESCE(t.is_featured, FALSE) AS is_featured, COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_by,
t.updated_by,
t.created_at, t.created_at,
t.updated_at t.updated_at
FROM contract_templates t FROM contract_templates t
LEFT JOIN contract_categories c ON c.id = t.category_id LEFT JOIN contract_categories c ON c.id = t.category_id
WHERE t.id = :template_id WHERE t.id = :template_id
AND t.deleted_at IS NULL
AND c.deleted_at IS NULL
AND {' AND '.join(scope_filters)}
LIMIT 1 LIMIT 1
""" """
) )
row = (await session.execute(sql, params)).mappings().first()
async with GetAsyncSession() as session:
row = (await session.execute(sql, {"template_id": TemplateId})).mappings().first()
if not row: if not row:
return None return None
return self._to_detail_vo(row) return self._to_detail_vo(row)
async def CreateTemplate(
self,
Body: ContractTemplateCreateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
if File is None or not File.filename:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板主文件不能为空")
fileContent = await File.read()
if not fileContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板主文件内容不能为空")
normalizedCode = (Body.template_code or "").strip()
normalizedTitle = (Body.title or "").strip()
if not normalizedCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板编码不能为空")
if not normalizedTitle:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板标题不能为空")
fileExt = Path(File.filename).suffix.lstrip(".").lower()
if fileExt not in {"doc", "docx", "pdf"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持上传 DOC、DOCX、PDF 模板")
mimeType = File.content_type or mimetypes.guess_type(File.filename)[0] or "application/octet-stream"
pdfContent: bytes | None = None
pdfMimeType: str | None = None
pdfFileName: str | None = None
if PdfFile and PdfFile.filename:
pdfContent = await PdfFile.read()
if not pdfContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览 PDF 文件内容不能为空")
pdfExt = Path(PdfFile.filename).suffix.lstrip(".").lower()
if pdfExt != "pdf":
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览文件仅支持 PDF")
pdfMimeType = PdfFile.content_type or "application/pdf"
pdfFileName = PdfFile.filename
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
resolvedRegion = self._resolve_upload_region(currentUser, Body.region)
categoryRow = (
await session.execute(
text(
"""
SELECT id, name
FROM contract_categories
WHERE id = :category_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"category_id": Body.category_id},
)
).mappings().first()
if not categoryRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板分类不存在")
duplicateRow = (
await session.execute(
text(
"""
SELECT id
FROM contract_templates
WHERE region = :region
AND template_code = :template_code
AND deleted_at IS NULL
LIMIT 1
"""
),
{"region": resolvedRegion, "template_code": normalizedCode},
)
).mappings().first()
if duplicateRow:
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前地区已存在模板编码 {normalizedCode}")
categoryName = str(categoryRow["name"] or "未分类")
objectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="source",
FileName=File.filename,
)
filePath = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=fileContent,
ContentType=mimeType,
)
pdfPath: str | None = None
if pdfContent is not None and pdfFileName:
pdfObjectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="preview",
FileName=pdfFileName,
)
pdfPath = await self.OssService.UploadBytes(
ObjectKey=pdfObjectKey,
Content=pdfContent,
ContentType=pdfMimeType or "application/pdf",
)
createdRow = (
await session.execute(
text(
"""
INSERT INTO contract_templates (
template_code,
title,
category_id,
region,
description,
file_path,
pdf_file_path,
file_format,
original_file_name,
mime_type,
file_size,
pdf_file_size,
is_featured,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:template_code,
:title,
:category_id,
:region,
:description,
:file_path,
:pdf_file_path,
:file_format,
:original_file_name,
:mime_type,
:file_size,
:pdf_file_size,
:is_featured,
:created_by,
:updated_by,
NOW(),
NOW()
)
RETURNING id
"""
),
{
"template_code": normalizedCode,
"title": normalizedTitle,
"category_id": Body.category_id,
"region": resolvedRegion,
"description": (Body.description or "").strip() or None,
"file_path": filePath,
"pdf_file_path": pdfPath,
"file_format": fileExt,
"original_file_name": File.filename,
"mime_type": mimeType,
"file_size": len(fileContent),
"pdf_file_size": len(pdfContent) if pdfContent is not None else None,
"is_featured": Body.is_featured,
"created_by": CurrentUserId,
"updated_by": CurrentUserId,
},
)
).mappings().first()
await session.commit()
if not createdRow:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建失败")
detail = await self.GetTemplateDetail(int(createdRow["id"]), CurrentUserId)
if not detail:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建成功但详情读取失败")
return ContractTemplateCreateVO(**detail.model_dump())
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True)
row = (
await session.execute(
text(
f"""
SELECT id
FROM contract_templates t
WHERE t.id = :template_id
AND t.deleted_at IS NULL
AND {' AND '.join(scope_filters)}
LIMIT 1
"""
),
params,
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权删除")
await session.execute(
text(
"""
UPDATE contract_templates
SET deleted_at = NOW(),
updated_at = NOW(),
updated_by = :updated_by
WHERE id = :template_id
AND deleted_at IS NULL
"""
),
{"template_id": TemplateId, "updated_by": CurrentUserId},
)
await session.commit()
async def _load_search_category_stats( async def _load_search_category_stats(
self, self,
keyword: str, keyword: str,
requestedRegion: str | None,
CurrentUserId: int,
) -> list[ContractTemplateSearchCategoryVO]: ) -> list[ContractTemplateSearchCategoryVO]:
clean_keyword = (keyword or "").strip() clean_keyword = (keyword or "").strip()
if not clean_keyword: if not clean_keyword:
return [] return []
async with GetAsyncSession() as session:
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)
filters = [ filters = [
"c.deleted_at IS NULL",
"t.deleted_at IS NULL",
"(" "("
"t.title ILIKE :keyword " "t.title ILIKE :keyword "
"OR COALESCE(t.description, '') ILIKE :keyword " "OR COALESCE(t.description, '') ILIKE :keyword "
"OR COALESCE(t.template_code, '') ILIKE :keyword " "OR COALESCE(t.template_code, '') ILIKE :keyword "
"OR COALESCE(c.name, '') ILIKE :keyword" "OR COALESCE(c.name, '') ILIKE :keyword"
")" ")",
*scope_filters,
] ]
params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"}
sql = text( sql = text(
f""" f"""
@@ -199,8 +469,6 @@ class ContractTemplateServiceImpl(IContractTemplateService):
ORDER BY c.name ASC ORDER BY c.name ASC
""" """
) )
async with GetAsyncSession() as session:
rows = (await session.execute(sql, params)).mappings().all() rows = (await session.execute(sql, params)).mappings().all()
return [ return [
@@ -218,13 +486,17 @@ class ContractTemplateServiceImpl(IContractTemplateService):
keyword: str | None, keyword: str | None,
category_id: int | None, category_id: int | None,
category_name: str | None, category_name: str | None,
region: str | None,
file_format: str | None, file_format: str | None,
is_featured: bool | None, is_featured: bool | None,
currentUser: dict[str, Any],
) -> tuple[str, dict[str, Any], bool]: ) -> tuple[str, dict[str, Any], bool]:
filters = ["1=1"] filters = ["t.deleted_at IS NULL", "c.deleted_at IS NULL"]
params: dict[str, Any] = {} params: dict[str, Any] = {}
needs_category_name_filter = False needs_category_name_filter = False
filters.extend(self._build_template_scope_filters(currentUser, params, region))
if category_id is not None: if category_id is not None:
filters.append("t.category_id = :category_id") filters.append("t.category_id = :category_id")
params["category_id"] = category_id params["category_id"] = category_id
@@ -234,7 +506,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
needs_category_name_filter = True needs_category_name_filter = True
if file_format: if file_format:
filters.append("t.file_format = :file_format") filters.append("LOWER(t.file_format) = :file_format")
params["file_format"] = file_format.strip().lower() params["file_format"] = file_format.strip().lower()
if is_featured is not None: if is_featured is not None:
@@ -271,8 +543,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
def _bind_expanding(self, *sql_objects_and_params: Any): def _bind_expanding(self, *sql_objects_and_params: Any):
sql_objects = list(sql_objects_and_params[:-1]) sql_objects = list(sql_objects_and_params[:-1])
params = sql_objects_and_params[-1] params = sql_objects_and_params[-1]
if "category_ids" in params: if "visible_regions" in params:
sql_objects = [sql.bindparams(bindparam("category_ids", expanding=True)) for sql in sql_objects] sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects]
return tuple(sql_objects) return tuple(sql_objects)
def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO: def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO:
@@ -295,10 +567,17 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_name=row.get("category_name"), category_name=row.get("category_name"),
category_icon=row.get("category_icon"), category_icon=row.get("category_icon"),
description=row.get("description"), description=row.get("description"),
region=str(row.get("region") or "省级"),
file_path=row.get("file_path"), file_path=row.get("file_path"),
pdf_file_path=row.get("pdf_file_path"), pdf_file_path=row.get("pdf_file_path"),
file_format=str(row.get("file_format") or ""), file_format=str(row.get("file_format") or ""),
original_file_name=row.get("original_file_name"),
mime_type=row.get("mime_type"),
file_size=int(row.get("file_size") or 0),
pdf_file_size=int(row["pdf_file_size"]) if row.get("pdf_file_size") is not None else None,
is_featured=bool(row.get("is_featured", False)), is_featured=bool(row.get("is_featured", False)),
created_by=int(row["created_by"]) if row.get("created_by") is not None else None,
updated_by=int(row["updated_by"]) if row.get("updated_by") is not None else None,
created_at=self._stringify_time(row.get("created_at")), created_at=self._stringify_time(row.get("created_at")),
updated_at=self._stringify_time(row.get("updated_at")), updated_at=self._stringify_time(row.get("updated_at")),
) )
@@ -315,3 +594,178 @@ class ContractTemplateServiceImpl(IContractTemplateService):
if value is None: if value is None:
return None return None
return str(value) return str(value)
def _build_template_scope_filters(
self,
currentUser: dict[str, Any],
params: dict[str, Any],
requestedRegion: str | None,
writable: bool = False,
) -> list[str]:
requested = (requestedRegion or "").strip()
area = str(currentUser["area"] or "").strip()
if currentUser["is_global"]:
if requested:
params["requested_region"] = requested
return ["t.region = :requested_region"]
return ["1=1"]
if writable:
if not area:
return ["1=0"]
if requested and requested != area:
return ["1=0"]
params["scope_region"] = area
return ["t.region = :scope_region"]
if currentUser["can_manage"]:
if not area:
return ["1=0"]
if requested:
if requested == "省级":
params["requested_region"] = requested
return ["t.region = :requested_region"]
if requested != area:
return ["1=0"]
params["requested_region"] = requested
return ["t.region = :requested_region"]
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
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"]
return ["1=0"]
if area:
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
params["requested_region"] = "省级"
return ["t.region = :requested_region"]
async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]:
own_session = False
if session is None:
own_session = True
session_cm = GetAsyncSession()
session = await session_cm.__aenter__()
try:
row = (
await session.execute(
text(
"""
SELECT
u.id,
COALESCE(u.area, '') AS area,
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
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 not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在")
return {
"id": int(row["id"]),
"area": str(row["area"] or ""),
"is_global": bool(row["is_global"]),
"can_manage": bool(row["can_manage"]),
}
finally:
if own_session:
await session_cm.__aexit__(None, None, None)
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str:
area = str(currentUser["area"] or "").strip()
if currentUser["can_manage"]:
if not area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前管理员账号未配置地区,无法上传合同模板")
return area
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有上传合同模板权限")
async def _ensureContractTemplateSchema(self, session) -> None:
statements = [
"""
ALTER TABLE contract_categories
ADD COLUMN IF NOT EXISTS created_by BIGINT
""",
"""
ALTER TABLE contract_categories
ADD COLUMN IF NOT EXISTS updated_by BIGINT
""",
"""
ALTER TABLE contract_categories
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级'
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500)
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT ''
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200)
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS created_by BIGINT
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS updated_by BIGINT
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ
""",
]
for statement in statements:
await session.execute(text(statement))
await session.execute(
text(
"""
UPDATE contract_templates
SET region = '省级'
WHERE region IS NULL OR BTRIM(region) = ''
"""
)
)
await session.execute(
text(
"""
UPDATE contract_templates
SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE
WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format)
ELSE ''
END)
WHERE original_file_name IS NULL OR BTRIM(original_file_name) = ''
"""
)
)
await session.commit()
@@ -225,6 +225,9 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"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: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: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": "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": "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: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: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: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"},
@@ -164,12 +164,14 @@ def resolve_pdf_path(template: LegacyTemplate, object_keys: set[str]) -> str:
def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]: def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]:
docx_key = OssPathUtils.BuildContractTemplateKey( docx_key = OssPathUtils.BuildContractTemplateKey(
Region="省级",
CategoryName=template.category_name, CategoryName=template.category_name,
TemplateCode=template.template_code, TemplateCode=template.template_code,
FileRole="source", FileRole="source",
FileName=Path(docx_path).name, FileName=Path(docx_path).name,
) )
pdf_key = OssPathUtils.BuildContractTemplateKey( pdf_key = OssPathUtils.BuildContractTemplateKey(
Region="省级",
CategoryName=template.category_name, CategoryName=template.category_name,
TemplateCode=template.template_code, TemplateCode=template.template_code,
FileRole="preview", FileRole="preview",
+133 -15
View File
@@ -3,9 +3,9 @@ BEGIN;
-- ============================================================================ -- ============================================================================
-- LeAudit Platform Contract Template Schema -- LeAudit Platform Contract Template Schema
-- 目标: -- 目标:
-- 1. 在主库 leaudit_platform 创建合同模板分类表 -- 1. 在主库 leaudit_platform 创建 / 升级合同模板分类表
-- 2. 在主库 leaudit_platform 创建合同模板表 -- 2. 在主库 leaudit_platform 创建 / 升级合同模板
-- 3. 补齐索引与基础约束 -- 3. 补齐地区字段、审计字段、软删除字段、索引与 updated_at 触发器
-- 说明: -- 说明:
-- - 本脚本不依赖旧库 docauditai -- - 本脚本不依赖旧库 docauditai
-- - 幂等脚本,可重复执行 -- - 幂等脚本,可重复执行
@@ -17,50 +17,168 @@ CREATE TABLE IF NOT EXISTS public.contract_categories (
icon VARCHAR(100) NULL, icon VARCHAR(100) NULL,
description TEXT NULL, description TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
created_by BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_by BIGINT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_categories_name ALTER TABLE public.contract_categories
ON public.contract_categories(name); ADD COLUMN IF NOT EXISTS icon VARCHAR(100),
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS public.contract_templates ( CREATE TABLE IF NOT EXISTS public.contract_templates (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(50) NOT NULL, template_code VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
category_id INTEGER NOT NULL REFERENCES public.contract_categories(id) ON DELETE RESTRICT, category_id INTEGER NOT NULL REFERENCES public.contract_categories(id) ON DELETE RESTRICT,
region VARCHAR(50) NOT NULL DEFAULT '省级',
description TEXT NULL, description TEXT NULL,
file_path VARCHAR(500) NULL, file_path VARCHAR(500) NULL,
pdf_file_path VARCHAR(500) NULL,
file_format VARCHAR(10) NOT NULL, file_format VARCHAR(10) NOT NULL,
original_file_name VARCHAR(500) NOT NULL DEFAULT '',
mime_type VARCHAR(200) NULL,
file_size BIGINT NOT NULL DEFAULT 0,
pdf_file_size BIGINT NULL,
is_featured BOOLEAN NOT NULL DEFAULT FALSE, is_featured BOOLEAN NOT NULL DEFAULT FALSE,
created_by BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by BIGINT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
pdf_file_path VARCHAR(500) NULL deleted_at TIMESTAMPTZ NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_templates_code ALTER TABLE public.contract_templates
ON public.contract_templates(template_code); ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级',
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS file_path VARCHAR(500),
ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500),
ADD COLUMN IF NOT EXISTS file_format VARCHAR(10),
ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200),
ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT,
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_contract_templates_category_id UPDATE public.contract_templates
ON public.contract_templates(category_id); SET region = '省级'
WHERE region IS NULL OR BTRIM(region) = '';
CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_at UPDATE public.contract_templates
ON public.contract_templates(updated_at DESC); SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE
WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format)
ELSE ''
END)
WHERE original_file_name IS NULL OR BTRIM(original_file_name) = '';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_contract_categories_name'
) THEN
EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_categories_name';
END IF;
IF EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_contract_templates_code'
) THEN
EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_templates_code';
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_categories_name_active
ON public.contract_categories(name)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_categories_sort_active
ON public.contract_categories(sort_order)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_region_code_active
ON public.contract_templates(region, template_code)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_region_active
ON public.contract_templates(region)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_category_active
ON public.contract_templates(category_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_active
ON public.contract_templates(updated_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_contract_templates_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY['contract_categories', 'contract_templates']
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I', t, t);
EXECUTE format(
'CREATE TRIGGER trg_%s_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION update_contract_templates_updated_at()',
t, t
);
END LOOP;
END;
$$;
COMMENT ON TABLE public.contract_categories IS '合同模板分类表'; COMMENT ON TABLE public.contract_categories IS '合同模板分类表';
COMMENT ON COLUMN public.contract_categories.name IS '分类名称'; COMMENT ON COLUMN public.contract_categories.name IS '分类名称';
COMMENT ON COLUMN public.contract_categories.icon IS '分类图标'; COMMENT ON COLUMN public.contract_categories.icon IS '分类图标';
COMMENT ON COLUMN public.contract_categories.description IS '分类描述'; COMMENT ON COLUMN public.contract_categories.description IS '分类描述';
COMMENT ON COLUMN public.contract_categories.sort_order IS '排序值'; COMMENT ON COLUMN public.contract_categories.sort_order IS '排序值';
COMMENT ON COLUMN public.contract_categories.created_by IS '创建人';
COMMENT ON COLUMN public.contract_categories.updated_by IS '更新人';
COMMENT ON COLUMN public.contract_categories.deleted_at IS '软删除时间';
COMMENT ON TABLE public.contract_templates IS '合同模板主表'; COMMENT ON TABLE public.contract_templates IS '合同模板主表';
COMMENT ON COLUMN public.contract_templates.template_code IS '模板编码'; COMMENT ON COLUMN public.contract_templates.template_code IS '模板编码';
COMMENT ON COLUMN public.contract_templates.title 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.category_id IS '所属分类ID';
COMMENT ON COLUMN public.contract_templates.region IS '所属地区,省级模板使用“省级”';
COMMENT ON COLUMN public.contract_templates.description IS '模板描述'; COMMENT ON COLUMN public.contract_templates.description IS '模板描述';
COMMENT ON COLUMN public.contract_templates.file_path 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预览文件路径'; COMMENT ON COLUMN public.contract_templates.pdf_file_path IS 'PDF预览文件路径';
COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式';
COMMENT ON COLUMN public.contract_templates.original_file_name IS '原始上传文件名';
COMMENT ON COLUMN public.contract_templates.mime_type IS '文件 MIME 类型';
COMMENT ON COLUMN public.contract_templates.file_size IS '主文件大小(字节)';
COMMENT ON COLUMN public.contract_templates.pdf_file_size IS '预览 PDF 文件大小(字节)';
COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板';
COMMENT ON COLUMN public.contract_templates.created_by IS '创建人';
COMMENT ON COLUMN public.contract_templates.updated_by IS '更新人';
COMMENT ON COLUMN public.contract_templates.deleted_at IS '软删除时间';
COMMIT; COMMIT;
@@ -3,8 +3,8 @@ BEGIN;
-- ============================================================================ -- ============================================================================
-- LeAudit Platform Contract Template RBAC Seed -- LeAudit Platform Contract Template RBAC Seed
-- 目标: -- 目标:
-- 1. 补齐合同模板读权限 -- 1. 补齐合同模板读写删权限
-- 2. 给 super_admin / provincial_admin / admin 分配模板权限 -- 2. 给 super_admin / provincial_admin / admin 分配模板权限
-- 说明: -- 说明:
-- - 依赖 user_rbac_schema_patch.sql -- - 依赖 user_rbac_schema_patch.sql
-- - 依赖合同模板前端路由已存在于 sys_routes -- - 依赖合同模板前端路由已存在于 sys_routes
@@ -62,7 +62,10 @@ FROM (
VALUES VALUES
('contract_template:list:read', 'contract_template', 'list', 'read', '查看合同模板列表', '查看合同模板列表', '/contract-template/list', 310, '/api/v3/contract-templates', 'GET'), ('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: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') ('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET'),
('contract_template:create:write', 'contract_template', 'create', 'write', '上传合同模板', '上传合同模板', '/contract-template/list', 313, '/api/v3/contract-templates', 'POST'),
('contract_template:update:write', 'contract_template', 'update', 'write', '更新合同模板', '更新合同模板', '/contract-template/list', 314, '/api/v3/contract-templates/{id}', 'PUT'),
('contract_template:delete:delete', 'contract_template', 'delete', 'delete', '删除合同模板', '删除合同模板', '/contract-template/list', 315, '/api/v3/contract-templates/{id}', 'DELETE')
) AS seed( ) AS seed(
permission_key, permission_key,
module, module,
@@ -105,14 +108,23 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'), ('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'), ('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:detail:read', 'GRANT', 'ALL'), ('super_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:create:write', 'GRANT', 'ALL'),
('super_admin', 'contract_template:update:write', 'GRANT', 'ALL'),
('super_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:list:read', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:search:read', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:detail:read', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:create:write', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:update:write', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'),
('admin', 'contract_template:list:read', 'GRANT', 'DEPT'), ('admin', 'contract_template:list:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:search:read', 'GRANT', 'DEPT'), ('admin', 'contract_template:search:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:detail:read', 'GRANT', 'DEPT') ('admin', 'contract_template:detail:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:create:write', 'GRANT', 'DEPT'),
('admin', 'contract_template:update:write', 'GRANT', 'DEPT'),
('admin', 'contract_template:delete:delete', 'GRANT', 'DEPT')
) )
INSERT INTO role_permissions ( INSERT INTO role_permissions (
role_id, role_id,