feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
6 changed files with 590 additions and 0 deletions
Showing only changes of commit 7a0f3f4b98 - Show all commits

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import logs as system_logs
from app.api.v1.system import roles as system_roles
from app.api.v1.system import users as system_users
@ -25,6 +26,7 @@ v1_router.include_router(auth.router)
system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router)
system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router)
system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
v1_router.include_router(system_router)

View File

@ -0,0 +1,122 @@
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleStatusIn,
BusinessRoleUpdateIn,
TemplateOptionOut,
)
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[BusinessRoleQuery, Query()],
) -> ApiResponse[PageResult[BusinessRoleOut]]:
"""分页查询业务岗位角色(含停用)。"""
return ok(business_role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: BusinessRoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
return ok(
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
message="岗位已创建",
)
@router.get(
"/meta/templates",
response_model=ApiResponse[list[TemplateOptionOut]],
summary="公式模板下拉",
)
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
"""取公式模板下拉(含声明参数,供岗位表单展示)。
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
def get_role(
session: DbSession,
_: AdminUser,
role_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessRoleOut]:
"""按主键查询岗位详情。"""
return ok(business_role_service.get_role(session, role_id))
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""编辑岗位名称/模板/说明/排序。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
"""
return ok(
business_role_service.update_role(
session, role_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.put(
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
)
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""启用或禁用岗位。内置岗位不可停用。"""
result = business_role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="岗位已删除")

View File

@ -50,6 +50,7 @@ PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"

View File

@ -0,0 +1,88 @@
"""业务岗位角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。"""
if for_update:
stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(BusinessRole, role_id)
def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
"""按编码查询岗位。编码是参数维度值(dimension_value)的锚点。"""
stmt = select(BusinessRole).where(BusinessRole.role_code == role_code)
return session.execute(stmt).scalar_one_or_none()
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
return stmt
def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各岗位下的人员数(删除保护的依据)。
② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY,
禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。
"""
return {}
# ==================== 写入 ====================
def create(session: Session, role: BusinessRole) -> BusinessRole:
"""新增岗位。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: BusinessRole) -> None:
"""删除岗位。调用方须先完成保护规则校验。"""
session.delete(role)
session.flush()

View File

@ -0,0 +1,90 @@
"""业务岗位角色的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。
# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点,
# 保持单一风格避免大小写/全角混用造成对照困难
BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class BusinessRoleOut(BaseModel):
"""岗位响应(列表与详情共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
calc_template: str
description: str | None = None
sort_order: int = 0
status: int = 1
is_builtin: int = 0
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
code: str
name: str
class TemplateOptionOut(BaseModel):
"""公式模板下拉项。"""
code: str
name: str
description: str
params: list[TemplateParamOut]
class BusinessRoleCreateIn(BaseModel):
"""新增岗位。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not BUSINESS_ROLE_CODE_PATTERN.match(value):
raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class BusinessRoleUpdateIn(BaseModel):
"""编辑岗位。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class BusinessRoleStatusIn(BaseModel):
"""启用/禁用岗位。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class BusinessRoleQuery(PageQuery):
"""岗位列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -0,0 +1,287 @@
"""业务岗位角色管理业务编排。
需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表),
新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**):
- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** ——
参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行)
- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value)
与模板绑定语义的锚点
- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码
- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效,
当前钩子恒为空(见 business_role_repository.count_personnel_by_role)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_BUSINESS_ROLE,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
TemplateOptionOut,
TemplateParamOut,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import TEMPLATES
from app.services.param_registry import PARAM_REGISTRY
logger = logging.getLogger(__name__)
def _to_out(role: BusinessRole) -> BusinessRoleOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return BusinessRoleOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
calc_template=role.calc_template,
description=role.description,
sort_order=role.sort_order,
status=role.status,
is_builtin=role.is_builtin,
)
def _ensure_template(calc_template: str) -> None:
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
if calc_template not in TEMPLATES:
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
:raises NotFoundError: 岗位不存在
"""
role = business_role_repository.get_by_id(session, role_id)
if role is None:
raise NotFoundError("岗位不存在")
return _to_out(role)
def list_template_options() -> list[TemplateOptionOut]:
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
options: list[TemplateOptionOut] = []
for tpl in TEMPLATES.values():
options.append(
TemplateOptionOut(
code=tpl.code,
name=tpl.name,
description=tpl.description,
params=[
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
for code in tpl.params
],
)
)
return options
def create_role(
session: Session,
payload: BusinessRoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
:raises BadRequestError: 编码已存在 / 模板不存在
"""
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_CREATE,
summary=f"新增岗位 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if business_role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在")
role = BusinessRole(
role_code=payload.role_code,
role_name=payload.role_name,
calc_template=payload.calc_template,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_builtin=0,
)
try:
business_role_repository.create(session, role)
out = _to_out(role)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code
session.rollback()
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc
logger.info(
"新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template
)
return out
def update_role(
session: Session,
role_id: int,
payload: BusinessRoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 岗位不存在
:raises BadRequestError: 模板不存在
"""
role = business_role_repository.get_by_id(session, role_id)
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_UPDATE,
summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
role.role_name = payload.role_name
role.calc_template = payload.calc_template
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role)
session.commit()
logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图停用内置岗位
"""
role = business_role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1 and status == 0:
raise ForbiddenError("内置岗位不可停用")
role.status = status
out = _to_out(role)
session.commit()
logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后),
锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图删除内置岗位
:raises BadRequestError: 岗位下仍有人引用
"""
role = business_role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_DELETE,
summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1:
raise ForbiddenError("内置岗位不可删除")
counts = business_role_repository.count_personnel_by_role(session, [role_id])
person_count = counts.get(role_id, 0)
if person_count > 0:
raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属")
role_code = role.role_code # 固化:对象随删除失效
business_role_repository.delete(session, role)
session.commit()
logger.info("删除岗位 | role_code=%s", role_code)