feat: 添加数据库迁移脚本并更新Dockerfile配置

- 在Dockerfile中添加alembic配置文件和目录的复制指令
- 更新alembic/env.py注册新的模块模型:events、workflows、writebacks
- 生成完整的初始数据库schema迁移脚本,包含以下表:
  - approval_requests, attendance_records, audit_logs, domain_events
  - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs
  - official_writeback_runs, performance_metrics, policies, procurements
  - projects, report_push_runs, risk_event_actions, risk_events
  - standards, suppliers, work_reports, work_tasks, workflow_actions
  - workflow_instances等21个数据表结构定义
- 在API路由器中添加新模块的路由:events、workflows、writebacks、observability
```
This commit is contained in:
2026-07-08 14:08:03 +08:00
parent 92f490b97e
commit 19e59e83cc
59 changed files with 3271 additions and 247 deletions

View File

@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import StatusValue
@@ -22,6 +23,7 @@ os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["FEISHU_APPROVAL_APPROVER_IDS"] = json.dumps(["ou_card_approver"])
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
@@ -36,6 +38,18 @@ from app.core.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_approval_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventStatus,
EventType,
)
from app.modules.events.service import EventService
from app.modules.feishu.constants import (
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
FEISHU_APPROVER_NOT_ALLOWED,
)
from app.modules.legacy_mysql.service import LegacyMySQLService
from app.modules.reports.constants import (
LifecycleAttentionKey,
@@ -46,6 +60,10 @@ from app.modules.reports.constants import (
ReportTitle,
ReportType,
)
from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance
from app.modules.writebacks.constants import WritebackStatus
Base.metadata.create_all(bind=engine)
@@ -55,6 +73,42 @@ audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
def approve_change(
domain: str,
action: str,
payload: dict,
record_id: str | int | None = None,
) -> str:
request_payload: dict[str, object] = {
"domain": domain,
"action": action,
"reason": "pytest approval",
"payload": payload,
}
if record_id is not None:
request_payload["record_id"] = str(record_id)
response = client.post("/api/v1/approvals", headers=headers, json=request_payload)
assert response.status_code == 200
ticket_id = response.json()["ticket_id"]
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "pytest approved"},
)
assert approve_response.status_code == 200
return ticket_id
def create_business_record(domain: str, data: dict, actor: str = "pytest"):
ticket_id = approve_change(domain, f"create:{domain}", data)
return client.post(
f"/api/v1/business/{domain}",
headers=headers,
json={"actor": actor, "approval_ticket_id": ticket_id, "data": data},
)
def teardown_module() -> None:
engine.dispose()
path = Path(_db.name)
@@ -63,19 +117,15 @@ def teardown_module() -> None:
def test_project_report_and_feishu_command_preview() -> None:
response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "P-SMOKE-001",
"name": "Smoke Project",
"owner": "tester",
"status": "执行中",
"budget_amount": 1000,
"actual_amount": 200,
},
response = create_business_record(
"projects",
{
"code": "P-SMOKE-001",
"name": "Smoke Project",
"owner": "tester",
"status": "执行中",
"budget_amount": 1000,
"actual_amount": 200,
},
)
assert response.status_code == 200
@@ -132,6 +182,167 @@ def test_feishu_webhook_routes_message_event() -> None:
assert AUDIT_REDACTED_VALUE in audit_payload
def test_v3_request_id_health_and_metrics() -> None:
response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"})
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "rid-v3-smoke"
assert response.json()["status"] == "ok"
response = client.get("/api/v1/health/ready")
assert response.status_code == 200
assert response.json()["status"] in {"ok", "degraded"}
response = client.get("/api/v1/metrics", headers=headers)
assert response.status_code == 200
assert "metrics" in response.json()
def test_v3_event_idempotency_and_workflow_dispatch() -> None:
from app.core.database import SessionLocal
db = SessionLocal()
try:
service = EventService(db)
event = service.emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id="risk-v3-idem",
actor="pytest",
payload={
EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN,
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
duplicate = service.emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id="risk-v3-idem",
actor="pytest",
payload={
EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN,
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
assert duplicate.event_id == event.event_id
assert event.status == EventStatus.PROCESSED
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.RISK_EVENT_REVIEW,
WorkflowInstance.aggregate_id == "risk-v3-idem",
)
).scalar_one()
assert workflow.status == WorkflowStatus.RUNNING
finally:
db.close()
def test_v3_risk_action_creates_workflow() -> None:
response = create_business_record(
"risk-events",
{
"code": "RISK-V3-WF-001",
"title": "V3 workflow risk",
"risk_type": "manual",
"source_domain": "projects",
},
)
assert response.status_code == 200
risk_id = response.json()["data"]["id"]
response = client.post(
f"/api/v1/risks/events/{risk_id}/assign",
headers=headers,
json={"assigned_to": "risk-owner", "comment": "route to owner"},
)
assert response.status_code == 200
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.RISK_EVENT_REVIEW},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == str(risk_id) and item["status"] == WorkflowStatus.RUNNING
for item in workflows
)
def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> None:
response = client.post(
"/api/v1/writebacks",
headers=headers,
json={
"domain": "projects",
"record_id": "P-V3-WB",
"action": "sync",
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 200
writeback_code = response.json()["data"]["code"]
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={},
)
assert response.status_code == 409
response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "projects",
"record_id": "P-V3-WB",
"action": "writeback:projects",
"reason": "V3 writeback gate",
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 200
ticket_id = response.json()["ticket_id"]
response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "approved for disabled adapter test"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={"approval_ticket_id": ticket_id},
)
assert response.status_code == 200
assert response.json()["data"]["status"] == WritebackStatus.DISABLED
response = client.get(f"/api/v1/approvals/{ticket_id}", headers=approval_headers)
assert response.status_code == 200
assert response.json()["status"] == "approved"
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.OFFICIAL_WRITEBACK},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == writeback_code and item["status"] == WorkflowStatus.BLOCKED
for item in workflows
)
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
response = client.post(
"/api/v1/integrations/feishu/webhook",
@@ -224,16 +435,13 @@ def test_config_and_pagination_guardrails() -> None:
def test_dashboard_and_response_masking() -> None:
expense_response = client.post(
"/api/v1/business/expenses",
headers=headers,
json={
"data": {
"code": "EXP-MASK-001",
"expense_type": "办公",
"amount": 20,
"payment_account": "6222000000000000",
},
expense_response = create_business_record(
"expenses",
{
"code": "EXP-MASK-001",
"expense_type": "办公",
"amount": 20,
"payment_account": "6222000000000000",
},
)
assert expense_response.status_code == 200
@@ -255,15 +463,12 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"]))
get_settings.cache_clear()
try:
response = client.post(
"/api/v1/business/expenses",
headers=headers,
json={
"data": {
"code": "EXP-MASK-CONFIG-001",
"expense_type": "测试",
"amount": 123,
},
response = create_business_record(
"expenses",
{
"code": "EXP-MASK-CONFIG-001",
"expense_type": "测试",
"amount": 123,
},
)
assert response.status_code == 200
@@ -282,6 +487,37 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
get_settings.cache_clear()
def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
blocked_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"data": {
"code": "P-APPROVAL-BLOCKED",
"name": "Blocked project",
},
},
)
assert blocked_response.status_code == 409
readonly_payload = {
"code": "P-READONLY-001",
"name": "Readonly project",
"created_at": "2026-07-08T00:00:00",
}
ticket_id = approve_change("projects", "create:projects", readonly_payload)
readonly_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"approval_ticket_id": ticket_id,
"data": readonly_payload,
},
)
assert readonly_response.status_code == 422
assert readonly_response.json()["detail"] == "Field 'created_at' is read-only"
def test_approval_gate_for_high_risk_update() -> None:
create_payload = {
"code": "FUND-SMOKE-001",
@@ -494,6 +730,76 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
assert duplicate_response.json()["result"]["status"] == "approved"
def test_feishu_approval_card_action_requires_approver_allowlist(monkeypatch) -> None:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "")
get_settings.cache_clear()
try:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-missing-allowlist",
"action": "update:fund-accounts",
"reason": "Card action missing allowlist test",
"payload": {"current_balance": 301},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 503
assert callback_response.json()["detail"] == FEISHU_APPROVAL_APPROVER_IDS_REQUIRED
finally:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "ou_card_approver")
get_settings.cache_clear()
def test_feishu_approval_card_action_rejects_unlisted_approver() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-unlisted-approver",
"action": "update:fund-accounts",
"reason": "Card action allowlist test",
"payload": {"current_balance": 302},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_not_allowed"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 403
assert callback_response.json()["detail"] == FEISHU_APPROVER_NOT_ALLOWED
def test_new_ledgers_reports_and_risk_events() -> None:
domains_response = client.get("/api/v1/business/domains", headers=headers)
assert domains_response.status_code == 200
@@ -503,34 +809,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
assert "risk-events" in domains
today = date.today()
attendance_response = client.post(
"/api/v1/business/attendance-records",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "ATT-SMOKE-001",
"employee_name": "Tester",
"department": "QA",
"work_date": today.isoformat(),
"status": "正常",
},
attendance_response = create_business_record(
"attendance-records",
{
"code": "ATT-SMOKE-001",
"employee_name": "Tester",
"department": "QA",
"work_date": today.isoformat(),
"status": "正常",
},
)
assert attendance_response.status_code == 200
task_response = client.post(
"/api/v1/business/tasks",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "TASK-RISK-001",
"title": "Overdue smoke task",
"owner": "tester",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
},
task_response = create_business_record(
"tasks",
{
"code": "TASK-RISK-001",
"title": "Overdue smoke task",
"owner": "tester",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
},
)
assert task_response.status_code == 200
@@ -568,91 +866,71 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
today = date.today()
project_code = "P-LIFECYCLE-001"
project_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": project_code,
"name": "Lifecycle Project",
"owner": "lifecycle-owner",
"status": "执行中",
"progress_percent": 40,
"budget_amount": 1000,
"actual_amount": 1500,
"due_date": (today - timedelta(days=1)).isoformat(),
},
project_response = create_business_record(
"projects",
{
"code": project_code,
"name": "Lifecycle Project",
"owner": "lifecycle-owner",
"status": "执行中",
"progress_percent": 40,
"budget_amount": 1000,
"actual_amount": 1500,
"due_date": (today - timedelta(days=1)).isoformat(),
},
)
assert project_response.status_code == 200
task_response = client.post(
"/api/v1/business/tasks",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "TASK-LIFECYCLE-001",
"title": "Lifecycle overdue task",
"project_code": project_code,
"owner": "lifecycle-owner",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
"blocker": "waiting for decision",
},
task_response = create_business_record(
"tasks",
{
"code": "TASK-LIFECYCLE-001",
"title": "Lifecycle overdue task",
"project_code": project_code,
"owner": "lifecycle-owner",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
"blocker": "waiting for decision",
},
)
assert task_response.status_code == 200
procurement_response = client.post(
"/api/v1/business/procurements",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "PROC-LIFECYCLE-001",
"name": "Lifecycle procurement",
"project_code": project_code,
"expected_amount": 300,
"actual_amount": 100,
"approval_status": StatusValue.PENDING_APPROVAL,
"delivery_status": StatusValue.UNDELIVERED,
"payment_status": StatusValue.UNPAID,
},
procurement_response = create_business_record(
"procurements",
{
"code": "PROC-LIFECYCLE-001",
"name": "Lifecycle procurement",
"project_code": project_code,
"expected_amount": 300,
"actual_amount": 100,
"approval_status": StatusValue.PENDING_APPROVAL,
"delivery_status": StatusValue.UNDELIVERED,
"payment_status": StatusValue.UNPAID,
},
)
assert procurement_response.status_code == 200
expense_response = client.post(
"/api/v1/business/expenses",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "EXP-LIFECYCLE-001",
"expense_type": "差旅",
"amount": 80,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
"payment_status": StatusValue.UNPAID,
},
expense_response = create_business_record(
"expenses",
{
"code": "EXP-LIFECYCLE-001",
"expense_type": "差旅",
"amount": 80,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
"payment_status": StatusValue.UNPAID,
},
)
assert expense_response.status_code == 200
attendance_response = client.post(
"/api/v1/business/attendance-records",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "ATT-LIFECYCLE-001",
"employee_name": "Lifecycle Tester",
"project_code": project_code,
"work_date": today.isoformat(),
"status": StatusValue.MISSING_PUNCH,
},
attendance_response = create_business_record(
"attendance-records",
{
"code": "ATT-LIFECYCLE-001",
"employee_name": "Lifecycle Tester",
"project_code": project_code,
"work_date": today.isoformat(),
"status": StatusValue.MISSING_PUNCH,
},
)
assert attendance_response.status_code == 200
@@ -707,35 +985,27 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
today = date.today()
project_code = "P-BACKLOG-001"
old_created_at = (today - timedelta(days=30)).isoformat() + "T00:00:00"
report_day = today - timedelta(days=7)
procurement_response = client.post(
"/api/v1/business/procurements",
headers=headers,
json={
"data": {
"code": "PROC-BACKLOG-001",
"name": "Backlog procurement",
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
"created_at": old_created_at,
},
procurement_response = create_business_record(
"procurements",
{
"code": "PROC-BACKLOG-001",
"name": "Backlog procurement",
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
},
)
assert procurement_response.status_code == 200
expense_response = client.post(
"/api/v1/business/expenses",
headers=headers,
json={
"data": {
"code": "EXP-BACKLOG-001",
"expense_type": "办公",
"amount": 50,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
"created_at": old_created_at,
},
expense_response = create_business_record(
"expenses",
{
"code": "EXP-BACKLOG-001",
"expense_type": "办公",
"amount": 50,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
},
)
assert expense_response.status_code == 200
@@ -746,8 +1016,8 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
json={
"report_type": ReportType.DAILY,
"project_code": project_code,
"period_start": today.isoformat(),
"period_end": today.isoformat(),
"period_start": report_day.isoformat(),
"period_end": report_day.isoformat(),
"persist": False,
},
)
@@ -810,19 +1080,16 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
def test_risk_event_workflow_records_actions() -> None:
create_response = client.post(
"/api/v1/business/risk-events",
headers=headers,
json={
"data": {
"code": "RISK-FLOW-001",
"title": "Workflow risk",
"risk_type": "manual",
"risk_level": "medium",
"source_domain": "projects",
"source_record_id": "P-SMOKE-001",
"status": "open",
},
create_response = create_business_record(
"risk-events",
{
"code": "RISK-FLOW-001",
"title": "Workflow risk",
"risk_type": "manual",
"risk_level": "medium",
"source_domain": "projects",
"source_record_id": "P-SMOKE-001",
"status": "open",
},
)
assert create_response.status_code == 200
@@ -851,20 +1118,85 @@ def test_risk_event_workflow_records_actions() -> None:
assert resolve_response.status_code == 200
assert resolve_response.json()["risk_event"]["status"] == "resolved"
blocked_close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
headers=headers,
json={
"closed_reason": "verified",
"review_summary": "handled",
},
)
assert blocked_close_response.status_code == 409
close_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Close risk event",
"payload": {
"status": "closed",
"closed_reason": "verified",
"review_summary": "handled",
},
},
)
assert close_ticket_response.status_code == 200
close_ticket_id = close_ticket_response.json()["ticket_id"]
approve_close_response = client.post(
f"/api/v1/approvals/{close_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk close approved"},
)
assert approve_close_response.status_code == 200
close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
headers=headers,
json={"closed_reason": "verified", "review_summary": "handled"},
json={
"closed_reason": "verified",
"review_summary": "handled",
"approval_ticket_id": close_ticket_id,
},
)
assert close_response.status_code == 200
assert close_response.json()["risk_event"]["status"] == "closed"
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
reopen_response = client.post(
blocked_reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck"},
)
assert blocked_reopen_response.status_code == 409
reopen_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Reopen risk event",
"payload": {"status": "open", "comment": "recheck"},
},
)
assert reopen_ticket_response.status_code == 200
reopen_ticket_id = reopen_ticket_response.json()["ticket_id"]
approve_reopen_response = client.post(
f"/api/v1/approvals/{reopen_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk reopen approved"},
)
assert approve_reopen_response.status_code == 200
reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck", "approval_ticket_id": reopen_ticket_id},
)
assert reopen_response.status_code == 200
assert reopen_response.json()["risk_event"]["status"] == "open"