feat(oa): add permission-controlled shared finance remarks to done center

Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks.

Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
This commit is contained in:
zlwu committed 2026-10-30 20:42:05 +08:00
1 parent 86df360f35
commit 0e64b413ce
15 files changed
+1188 -78

No files matched your search

+49 -3
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
@@ -15,6 +15,7 @@ from app.crud import oa_instance_draft as oid_crud
from app.crud import oa_receive_account as ora_crud
from app.models.models import SysUser
from app.services.oa_receive_account import normalize_account_fields
from app.services import oa_finance_remark as finance_remark_svc
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
@@ -1054,6 +1055,44 @@ async def list_oa_instance_form_attachments(
return error_response(400, str(e))
class OaFinanceRemarkBody(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
finance_remark: str = Field(..., alias="financeRemark", max_length=512, strict=True)
@router.get("/instances/{instance_id}/finance-remark")
async def get_oa_finance_remark(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
return success_response(finance_remark_svc.get_finance_remark(db, access, instance_id))
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
@router.put("/instances/{instance_id}/finance-remark")
async def update_oa_finance_remark(
instance_id: str,
body: OaFinanceRemarkBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
result = finance_remark_svc.update_finance_remark(db, access, instance_id, body.finance_remark)
db.commit()
return success_response(result, message="财务备注已保存")
except finance_remark_svc.OaFinanceRemarkError as exc:
db.rollback()
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except Exception:
db.rollback()
raise
@router.get("/instances/{instance_id}/bank-receipts")
async def list_oa_bank_receipts(
instance_id: str,
@@ -1365,7 +1404,8 @@ async def list_oa_tasks(
from app.services.oa_engine import OaEngineError, list_tasks
try:
tenant_no = _get_tenant_no(db, user)
access = finance_remark_svc.resolve_access(db, user) if kind == "done" else None
tenant_no = access.tenant_no if access else _get_tenant_no(db, user)
data = list_tasks(
db,
tenant_no=tenant_no,
@@ -1381,7 +1421,13 @@ async def list_oa_tasks(
page=page,
page_size=pageSize,
)
return success_response({"list": data.get("list") or [], "total": int(data.get("total") or 0)})
result = {"list": data.get("list") or [], "total": int(data.get("total") or 0)}
if access is not None:
finance_remark_svc.add_list_remarks(db, access, result["list"])
result["canManageFinanceRemark"] = access.can_manage
return success_response(result)
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except OaEngineError as e:
return error_response(400, str(e))
+1
View File
@@ -920,6 +920,7 @@ class OaInstance(Base):
default="running",
comment="running/returned/completed/rejected/cancelled",
)
finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)")
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+100
View File
@@ -0,0 +1,100 @@
"""已办中心实例级财务备注;权限与本人已办范围同时满足才可访问。"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.models.models import OaInstance, OaTask, SysUser
from app.services.web_auth_roles import iter_user_menu_perm_codes
PERMISSION = "oa:done:finance-remark"
DONE_STATUSES = ("approved", "rejected", "returned", "transferred")
class OaFinanceRemarkError(ValueError):
def __init__(self, status_code: int, message: str):
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FinanceRemarkAccess:
user_id: str
tenant_no: str
can_manage: bool
def resolve_access(db: Session, user: dict) -> FinanceRemarkAccess:
# 不使用缺省租户、客户端租户或角色名称作为敏感字段的授权依据。
uid = str(user.get("user_id") or "").strip()
identity = db.query(SysUser.id, SysUser.tenant_no).filter(
SysUser.id == uid, SysUser.del_flag == 0, SysUser.status == "1"
).first()
if not identity or not str(identity.tenant_no or "").strip():
raise OaFinanceRemarkError(403, "用户不可用")
tenant = str(identity.tenant_no).strip()
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
return FinanceRemarkAccess(uid, tenant, PERMISSION in granted)
def _accessible_instances(db: Session, access: FinanceRemarkAccess):
own_done = db.query(OaTask.id).filter(
OaTask.instance_id == OaInstance.id,
OaTask.tenant_no == access.tenant_no,
OaTask.assignee_user_id == access.user_id,
OaTask.task_type == "approval",
OaTask.status.in_(DONE_STATUSES),
OaTask.del_flag == 0,
).exists()
return db.query(OaInstance).filter(
OaInstance.tenant_no == access.tenant_no,
OaInstance.del_flag == 0,
own_done,
)
def _require_instance(db: Session, access: FinanceRemarkAccess, instance_id: str) -> OaInstance:
if not access.can_manage:
raise OaFinanceRemarkError(403, "无权查看或修改财务备注")
instance = _accessible_instances(db, access).filter(OaInstance.id == instance_id).first()
if instance is None:
raise OaFinanceRemarkError(404, "审批单不在本人已办范围内")
return instance
def _remark_data(instance: OaInstance) -> Dict[str, str]:
return {"instanceId": instance.id, "financeRemark": instance.finance_remark or ""}
def get_finance_remark(db: Session, access: FinanceRemarkAccess, instance_id: str) -> Dict[str, str]:
return _remark_data(_require_instance(db, access, instance_id))
def update_finance_remark(
db: Session, access: FinanceRemarkAccess, instance_id: str, finance_remark: str
) -> Dict[str, str]:
instance = _require_instance(db, access, instance_id)
if not isinstance(finance_remark, str) or len(finance_remark) > 512:
raise OaFinanceRemarkError(422, "财务备注最多 512 字")
instance.finance_remark = finance_remark
instance.update_by = access.user_id
instance.update_time = datetime.now()
db.flush()
return _remark_data(instance)
def add_list_remarks(db: Session, access: FinanceRemarkAccess, rows: List[Dict[str, Any]]) -> None:
# 即使调用方未来调整序列化,也先移除敏感字段,再按权限补充。
for row in rows:
row.pop("financeRemark", None)
if not access.can_manage:
return
ids = {row.get("instanceId") for row in rows if row.get("instanceId")}
if not ids:
return
instances = _accessible_instances(db, access).filter(OaInstance.id.in_(ids)).all()
remarks = {inst.id: inst.finance_remark or "" for inst in instances}
for row in rows:
if row.get("instanceId") in remarks:
row["financeRemark"] = remarks[row["instanceId"]]
+195
View File
@@ -0,0 +1,195 @@
"""OA 财务备注:真实隔离数据库和 HTTP 路由的读写/权限契约。"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.oa_approval import router
from app.core.auth import get_current_user
from app.core.database import Base, get_db
from app.models.models import (
OaApp, OaAppVersion, OaInstance, OaTask,
SysUser, SysRole, SysUserRole, SysMenu, SysRoleMenu,
SysPost, SysPostRole, SysUserPost,
)
PERM = 'oa:done:finance-remark'
PREFIX = '/web/admin/oa-approval'
URL = PREFIX + '/instances/i1/finance-remark'
@pytest.fixture
def case():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
tables = (OaApp, OaAppVersion, OaInstance, OaTask, SysUser, SysRole,
SysUserRole, SysMenu, SysRoleMenu, SysPost, SysPostRole, SysUserPost)
Base.metadata.create_all(engine, tables=[m.__table__ for m in tables])
db = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
for uid, tenant in [('u1', 'a'), ('u2', 'a'), ('outsider', 'a'), ('other', 'b')]:
db.add(SysUser(id=uid, user_no=uid, nick_name=uid, password='unused', tenant_no=tenant, status='1'))
db.add_all([
OaApp(id='app', app_name='报销', tenant_no='a'),
OaAppVersion(id='v1', app_id='app', version_no=1, tenant_no='a'),
SysRole(id='finance', role_name='财务', tenant_no='a', status='1'),
SysMenu(id='menu', menu_name='财务备注', parent_id='done', menu_type='F', perms=PERM, tenant_no='a', status='1'),
SysRoleMenu(id='rm', role_id='finance', menu_id='menu', tenant_no='a'),
])
for uid in ['u1', 'u2', 'outsider']:
db.add(SysUserRole(id='ur-'+uid, user_id=uid, role_id='finance', tenant_no='a'))
for iid, tenant in [('i1', 'a'), ('i2', 'a'), ('foreign', 'b')]:
inst = OaInstance(id=iid, app_id='app', app_version_id='v1', title=iid,
initiator_user_id='u1', tenant_no=tenant, status='completed', form_data_json='{}')
inst.finance_remark = '原备注'
db.add(inst)
for tid, uid, iid, tenant, status in [
('t1', 'u1', 'i1', 'a', 'approved'), ('t2', 'u2', 'i1', 'a', 'rejected'),
('pending', 'u1', 'i2', 'a', 'pending'), ('foreign-task', 'u1', 'foreign', 'b', 'approved'),
]:
db.add(OaTask(id=tid, instance_id=iid, assignee_user_id=uid, tenant_no=tenant,
task_type='approval', status=status))
db.commit()
user = {'user_id': 'u1', 'tenant_no': 'a', 'role': 'admin'}
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, client, user
db.close()
engine.dispose()
def test_shared_save_reload_and_clear(case):
db, client, user = case
result = client.get(URL)
assert result.status_code == 200
assert result.json()['data'] == {'instanceId': 'i1', 'financeRemark': '原备注'}
for text in ['财务核对完成\n<标签按原文显示>', '汉' * 512, '']:
user['user_id'] = 'u1'
response = client.put(URL, json={'financeRemark': text})
assert response.status_code == 200
assert response.json()['code'] == 200
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == text
assert db.get(OaInstance, 'i1').update_by == 'u1'
user['user_id'] = 'u2'
assert client.get(URL).json()['data']['financeRemark'] == text
rows = client.get(PREFIX + '/tasks/done').json()['data']
assert rows['canManageFinanceRemark'] is True
assert rows['list'][0]['financeRemark'] == text
@pytest.mark.parametrize('role', ['admin', 'super_admin'])
def test_no_permission_hides_list_and_denies_both_routes(case, role):
db, client, user = case
db.get(SysRoleMenu, 'rm').del_flag = 1
db.commit()
user['role'] = role
data = client.get(PREFIX + '/tasks/done').json()['data']
assert data['canManageFinanceRemark'] is False
assert len(data['list']) == 1
assert 'financeRemark' not in data['list'][0]
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '泄漏'}).status_code == 403
@pytest.mark.parametrize('target', ['i2', 'foreign', 'missing'])
def test_only_own_done_instances_are_accessible(case, target):
_, client, _ = case
url = PREFIX + '/instances/' + target + '/finance-remark'
assert client.get(url).status_code == 404
assert client.put(url, json={'financeRemark': '越权'}).status_code == 404
def test_authorized_outsider_cannot_read_or_write(case):
_, client, user = case
user['user_id'] = 'outsider'
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
@pytest.mark.parametrize('model,key', [(OaInstance, 'i1'), (OaTask, 't1')])
def test_deleted_instance_or_done_task_denies_access(case, model, key):
db, client, _ = case
db.get(model, key).del_flag = 1
db.commit()
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
data = client.get(PREFIX + '/tasks/done').json()['data']
assert all('financeRemark' not in row for row in data['list'])
@pytest.mark.parametrize('status', ['approved', 'rejected', 'returned', 'transferred'])
def test_all_existing_done_statuses_can_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 200
@pytest.mark.parametrize('status', ['pending', 'done', 'cancelled'])
def test_non_done_statuses_cannot_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 404
def test_forged_tenant_uses_real_user_tenant(case):
_, client, user = case
user['tenant_no'] = 'b'
assert client.get(URL).json()['data']['financeRemark'] == '原备注'
data = client.get(PREFIX + '/tasks/done').json()['data']
assert [r['instanceId'] for r in data['list']] == ['i1']
assert client.get(PREFIX + '/instances/foreign/finance-remark').status_code == 404
@pytest.mark.parametrize('model,key,field,value', [
(SysUser, 'u1', 'status', '0'), (SysUser, 'u1', 'del_flag', 1),
(SysRole, 'finance', 'status', '0'), (SysRole, 'finance', 'tenant_no', 'b'),
(SysMenu, 'menu', 'status', '0'), (SysMenu, 'menu', 'tenant_no', 'b'),
(SysUserRole, 'ur-u1', 'tenant_no', 'b'),
])
def test_inactive_or_cross_tenant_grants_deny_access(case, model, key, field, value):
db, client, _ = case
setattr(db.get(model, key), field, value)
db.commit()
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 403
def test_post_role_permission_union(case):
db, client, _ = case
db.get(SysUserRole, 'ur-u1').del_flag = 1
db.add_all([
SysPost(id='post', post_name='财务岗', post_code='finance', tenant_no='a', status='1'),
SysUserPost(id='up', user_id='u1', post_id='post', tenant_no='a'),
SysPostRole(id='pr', post_id='post', role_id='finance', tenant_no='a'),
])
db.commit()
assert client.get(URL).status_code == 200
db.get(SysPost, 'post').status = '0'
db.commit()
assert client.get(URL).status_code == 403
@pytest.mark.parametrize('body', [
{}, {'financeRemark': None}, {'financeRemark': 123}, {'financeRemark': 'a' * 513},
{'financeRemark': 'note', 'tenantNo': 'b'}, {'financeRemark': 'note', 'status': 'running'},
])
def test_invalid_body_rejected_without_writes(case, body):
db, client, _ = case
assert client.put(URL, json=body).status_code == 422
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == '原备注'
def test_generic_instance_serialization_does_not_expose_finance_remark(case):
db, client, _ = case
from app.services.oa_engine import _instance_to_dict
assert 'financeRemark' not in _instance_to_dict(db.get(OaInstance, 'i1'))
for kind in ['mine', 'pending']:
data = client.get(PREFIX + '/tasks/' + kind).json()['data']
assert all('financeRemark' not in row for row in data['list'])