2026-08-03 09:34:08 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, Depends, Request
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.v1.endpoints.platform import ok, fail
|
|
|
|
|
|
from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin
|
|
|
|
|
|
from app.db.platform_store import get_platform_store
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/projects", tags=["project"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _actor(request: Request) -> str | None:
|
|
|
|
|
|
auth = request.headers.get("Authorization", "")
|
|
|
|
|
|
token = auth.replace("Bearer ", "").strip()
|
|
|
|
|
|
return token or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_no_pending_approval(resource_type: str, resource_id: str) -> None:
|
|
|
|
|
|
"""第 4 周:写操作审批拦截——存在待审批实例时拒绝执行。"""
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
pending = [
|
|
|
|
|
|
i for i in store.approval_instances(status="pending")
|
|
|
|
|
|
if i["resource_type"] == resource_type and i["resource_id"] == resource_id
|
|
|
|
|
|
]
|
|
|
|
|
|
if pending:
|
|
|
|
|
|
raise fail(409, "存在待审批的变更,请先完成审批")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_approval_or_admin(
|
|
|
|
|
|
resource_type: str,
|
|
|
|
|
|
resource_id: str,
|
|
|
|
|
|
current_user: dict[str, Any],
|
|
|
|
|
|
action_desc: str = "",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
action: str = "project.change",
|
2026-08-03 09:34:08 +08:00
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
|
"""高风险操作审批旁路:admin 直接放行,普通用户创建审批实例(code=202)。"""
|
|
|
|
|
|
if is_admin(current_user):
|
|
|
|
|
|
return None
|
2026-08-21 09:49:48 +08:00
|
|
|
|
if not has_resource_access(resource_type, resource_id, current_user, "write"):
|
|
|
|
|
|
raise fail(403, "no permission to request this project change")
|
2026-08-03 09:34:08 +08:00
|
|
|
|
store = get_platform_store()
|
2026-08-21 09:49:48 +08:00
|
|
|
|
if store.consume_approved_approval(resource_type, resource_id, str(current_user.get("id") or ""), action):
|
|
|
|
|
|
return None
|
2026-08-03 09:34:08 +08:00
|
|
|
|
instance = store.create_approval_instance({
|
|
|
|
|
|
"resource_type": resource_type,
|
|
|
|
|
|
"resource_id": resource_id,
|
|
|
|
|
|
"applicant_id": current_user.get("id"),
|
|
|
|
|
|
"template_id": None,
|
2026-08-21 09:49:48 +08:00
|
|
|
|
"action": action,
|
|
|
|
|
|
"tenant_id": current_user.get("tenant_id") or "default",
|
|
|
|
|
|
"reason": action_desc,
|
2026-08-03 09:34:08 +08:00
|
|
|
|
})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 202,
|
|
|
|
|
|
"message": f"操作已提交审批,等待管理员批准:{action_desc}",
|
|
|
|
|
|
"data": {"approval_required": True, "approval_id": instance["id"]},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("")
|
|
|
|
|
|
def list_projects(
|
|
|
|
|
|
tenant_id: str = "default",
|
|
|
|
|
|
status: str | None = None,
|
|
|
|
|
|
keyword: str | None = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
projects = store.projects(tenant_id=tenant_id, status=status, keyword=keyword)
|
|
|
|
|
|
# #1 ACL 过滤:admin 直接放行,普通用户只能看到自己被授权的项目
|
|
|
|
|
|
accessible_ids = set(
|
|
|
|
|
|
filter_accessible_resource_ids("project", [p["id"] for p in projects], current_user)
|
|
|
|
|
|
)
|
|
|
|
|
|
filtered = [p for p in projects if p["id"] in accessible_ids]
|
|
|
|
|
|
return ok(filtered)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("")
|
2026-08-21 09:49:48 +08:00
|
|
|
|
def create_project(payload: dict[str, Any] = Body(...), request: Request = None, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
|
|
|
|
|
if not is_admin(current_user) and str(payload.get("tenant_id") or current_user.get("tenant_id") or "default") != str(current_user.get("tenant_id") or "default"):
|
|
|
|
|
|
raise fail(403, "cannot create project in another tenant")
|
|
|
|
|
|
payload.setdefault("tenant_id", current_user.get("tenant_id") or "default")
|
|
|
|
|
|
payload.setdefault("create_by", current_user.get("id"))
|
2026-08-03 09:34:08 +08:00
|
|
|
|
store = get_platform_store()
|
2026-08-21 09:49:48 +08:00
|
|
|
|
try:
|
|
|
|
|
|
store.assert_active_tenant(payload["tenant_id"])
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise fail(400, str(exc))
|
2026-08-03 09:34:08 +08:00
|
|
|
|
proj = store.create_project(payload)
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.create",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project",
|
|
|
|
|
|
target_id=proj["id"],
|
|
|
|
|
|
tenant_id=proj.get("tenant_id"),
|
|
|
|
|
|
detail=f"name={proj.get('name')}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(proj)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{project_id}")
|
|
|
|
|
|
def get_project(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
|
|
|
|
|
# #2 访问控制:普通用户无 read 权限则拒绝
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "read"):
|
|
|
|
|
|
raise fail(403, "no permission to access this project")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ok(get_platform_store().project(project_id))
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{project_id}")
|
|
|
|
|
|
def update_project(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
payload: dict[str, Any] = Body(...),
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "write"):
|
|
|
|
|
|
raise fail(403, "no permission to update this project")
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
try:
|
|
|
|
|
|
proj = store.update_project(project_id, payload)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project not found")
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.update",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
tenant_id=proj.get("tenant_id"),
|
|
|
|
|
|
detail=f"fields={','.join(payload.keys())}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(proj)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{project_id}/archive")
|
|
|
|
|
|
def archive_project(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
_require_no_pending_approval("project", project_id)
|
2026-08-21 09:49:48 +08:00
|
|
|
|
pending = _require_approval_or_admin("project", project_id, current_user, f"归档项目 {project_id}", "project.archive")
|
2026-08-03 09:34:08 +08:00
|
|
|
|
if pending:
|
|
|
|
|
|
return pending
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
try:
|
|
|
|
|
|
proj = store.archive_project(project_id)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project not found")
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.archive",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
tenant_id=proj.get("tenant_id"),
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(proj)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{project_id}")
|
|
|
|
|
|
def delete_project(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
_require_no_pending_approval("project", project_id)
|
2026-08-21 09:49:48 +08:00
|
|
|
|
pending = _require_approval_or_admin("project", project_id, current_user, f"删除项目 {project_id}", "project.delete")
|
2026-08-03 09:34:08 +08:00
|
|
|
|
if pending:
|
|
|
|
|
|
return pending
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
store.delete_project(project_id)
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.delete",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{project_id}/members")
|
|
|
|
|
|
def list_members(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "read"):
|
|
|
|
|
|
raise fail(403, "no permission to access this project")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ok(get_platform_store().project_members(project_id))
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{project_id}/members")
|
|
|
|
|
|
def add_member(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
payload: dict[str, Any] = Body(...),
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "write"):
|
|
|
|
|
|
raise fail(403, "no permission to manage members of this project")
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
try:
|
|
|
|
|
|
member = store.add_project_member(project_id, payload)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project not found")
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.member.add",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project.member",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
detail=f"user_id={payload.get('user_id')},role={payload.get('role')}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(member)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{project_id}/members/{user_id}")
|
|
|
|
|
|
def update_member(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
payload: dict[str, Any] = Body(...),
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "write"):
|
|
|
|
|
|
raise fail(403, "no permission to manage members of this project")
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
try:
|
|
|
|
|
|
member = store.update_project_member_role(project_id, user_id, payload)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise fail(404, "project or member not found")
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.member.update",
|
2026-08-21 09:49:48 +08:00
|
|
|
|
actor_id=current_user.get("id"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
target_type="project.member",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
detail=f"user_id={user_id},role={payload.get('role')}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(member)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{project_id}/members/{user_id}")
|
|
|
|
|
|
def remove_member(
|
|
|
|
|
|
project_id: str,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
if not has_resource_access("project", project_id, current_user, "write"):
|
|
|
|
|
|
raise fail(403, "no permission to manage members of this project")
|
|
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
store.remove_project_member(project_id, user_id)
|
|
|
|
|
|
store.record_audit(
|
|
|
|
|
|
action="project.member.remove",
|
|
|
|
|
|
actor_id=_actor(request) if request else None,
|
|
|
|
|
|
target_type="project.member",
|
|
|
|
|
|
target_id=project_id,
|
|
|
|
|
|
detail=f"user_id={user_id}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return ok(None)
|