feat(api): 计提参数查看放开登录用户、修改仅管理员(#17/#6 答复)

- deps 新增 LoginUser 依赖;params 只读七端点改登录即可,写(提交/撤回)保持 AdminUser
- 前端 params 路由 requiresAdmin → allowedRoles(admin/finance/leader);页面写按钮按角色显隐
- 权限用例分层:普通用户目录 200、提交/撤回 403、未登录拦截

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-19 11:19:16 +08:00
parent 2652397720
commit 7008de0c69
5 changed files with 54 additions and 19 deletions

View File

@ -114,6 +114,10 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
AdminUser = Annotated[User, Depends(require_admin)] AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str): def require_roles(*role_codes: str):
"""要求当前用户具备任一指定角色(依赖工厂)。 """要求当前用户具备任一指定角色(依赖工厂)。

View File

@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。 """计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读); 权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志, 端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。 (提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。 导出为文件流(非统一信封),前端以 blob 接收。
""" """
@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import ( from app.schemas.param import (
ParamBatchDetailOut, ParamBatchDetailOut,
@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
) )
def get_catalog( def get_catalog(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None, as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]: ) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。""" """全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@ -63,7 +64,7 @@ def submit_change(
) )
def list_changes( def list_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
query: Annotated[ParamBatchQuery, Query()], query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]: ) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。""" """按生效日期区间分页查询变更批次(创建时间倒序)。"""
@ -75,7 +76,7 @@ def list_changes(
) )
def get_batch( def get_batch(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
batch_id: Annotated[int, Path(ge=1)], batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]: ) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。""" """批次头 + 逐项旧值 → 新值。"""
@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比") @router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare( def compare(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")], from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")], to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]: ) -> ApiResponse[list[ParamDiffOut]]:
@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel") @router.get("/export", summary="导出变更记录 Excel")
def export_changes( def export_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None, start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None, end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse: ) -> StreamingResponse:
@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览") @router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview( def preview(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
payload: ParamPreviewIn, payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]: ) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。""" """输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@ -139,7 +140,7 @@ def preview(
) )
def get_history( def get_history(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
param_code: Annotated[str, Path(max_length=64)], param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()], query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]: ) -> ApiResponse[PageResult[ParamHistoryOut]]:

View File

@ -437,12 +437,23 @@ class TestPreview:
class TestPermission: class TestPermission:
"""权限:普通用户全部 403。""" """权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None: def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 19
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
# 均属拦截;显式断言不泄露数据即可)
assert client.get(_PARAMS).status_code in (401, 403)
def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER]) user = make_user([ROLE_USER])
headers = auth_headers(user) headers = auth_headers(user)
assert client.get(_PARAMS, headers=headers).status_code == 403
resp = client.post( resp = client.post(
f"{_PARAMS}/changes", f"{_PARAMS}/changes",
json={ json={
@ -452,3 +463,8 @@ class TestPermission:
headers=headers, headers=headers,
) )
assert resp.status_code == 403 assert resp.status_code == 403
def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403

View File

@ -62,10 +62,17 @@ const routes: RouteRecordRaw[] = [
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }, meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
}, },
{ {
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
// 管理员」);页面内写按钮按 admin 角色显隐
path: '/system/params', path: '/system/params',
name: 'system-params', name: 'system-params',
component: () => import('@/views/system/params/index.vue'), component: () => import('@/views/system/params/index.vue'),
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 }, meta: {
title: '计提参数',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 6,
},
}, },
{ {
path: '/system/settings', path: '/system/settings',

View File

@ -15,6 +15,8 @@ import type {
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@ -24,9 +26,13 @@ import { handledError } from '@/utils/error'
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」, * - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
* 以批次提交(一次一批,逐项留痕旧值/新值) * 以批次提交(一次一批,逐项留痕旧值/新值)
* - 支持立即生效或指定日期生效;未生效批次可整批撤回 * - 支持立即生效或指定日期生效;未生效批次可整批撤回
* - 权限分层(澄清 #17 答复 2026-10-19):查看对财务/领导放开(只读),
* 修改仅管理员——写按钮按角色显隐,越权仍有后端 403 兜底
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流 * - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
*/ */
const { confirmAndRun } = useConfirmAction() const { confirmAndRun } = useConfirmAction()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
// ==================== 目录 ==================== // ==================== 目录 ====================
@ -472,6 +478,7 @@ onMounted(() => {
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} 待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag> </el-tag>
<el-button <el-button
v-if="isAdmin"
link link
type="primary" type="primary"
size="small" size="small"
@ -487,7 +494,7 @@ onMounted(() => {
</td> </td>
<td> <td>
<el-button <el-button
v-if="!item.values.length" v-if="isAdmin && !item.values.length"
link link
type="primary" type="primary"
size="small" size="small"
@ -504,8 +511,8 @@ onMounted(() => {
</el-collapse> </el-collapse>
</el-card> </el-card>
<!-- 待提交批次 --> <!-- 待提交批次(仅管理员可见可操作) -->
<el-card v-if="pendingList.length" shadow="never" class="pending-card"> <el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
<template #header> <template #header>
<div class="pending-head"> <div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b> <b>待提交变更({{ pendingList.length }} 项)</b>
@ -670,7 +677,7 @@ onMounted(() => {
<el-table-column label="操作" width="80"> <el-table-column label="操作" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="row.effective_date > localToday()" v-if="isAdmin && row.effective_date > localToday()"
link link
type="danger" type="danger"
size="small" size="small"