按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
162 lines
8.2 KiB
Python
162 lines
8.2 KiB
Python
"""真实MySQL验证周期竞争、重试、worker消费和独立长任务心跳。"""
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import nullcontext
|
|
from datetime import datetime, timedelta
|
|
from time import sleep
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from sqlalchemy import delete, func, select, text
|
|
|
|
from app.core.config import Settings
|
|
from app.core.db import get_session_factory
|
|
from app.models import (
|
|
PerformanceLedger,
|
|
TPlusAutomationState,
|
|
TPlusSourceHead,
|
|
TPlusSyncBatch,
|
|
TPlusSyncError,
|
|
TPlusSyncRecord,
|
|
)
|
|
from app.services.tplus import automation, worker
|
|
|
|
pytestmark = pytest.mark.integration
|
|
NOW = datetime(2026, 10, 30, 10)
|
|
|
|
|
|
@pytest.fixture
|
|
def auto_source(monkeypatch):
|
|
source = 'auto_test_' + uuid4().hex[:12]
|
|
settings = Settings(_env_file=None, app_secret_key='x' * 32, t_plus_enabled=True,
|
|
t_plus_database=source, t_plus_user='reader', t_plus_odbc_driver='unit', t_plus_automation_enabled=True)
|
|
monkeypatch.setattr(automation, 'get_settings', lambda: settings)
|
|
monkeypatch.setattr('app.services.tplus.task_service.get_settings', lambda: settings)
|
|
yield settings
|
|
with get_session_factory()() as s:
|
|
numbers = list(s.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.source_database == source)))
|
|
s.execute(delete(TPlusSourceHead).where(TPlusSourceHead.source_database == source))
|
|
s.execute(delete(TPlusSyncRecord).where(TPlusSyncRecord.batch_no.in_(numbers)))
|
|
s.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
|
s.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.source_database == source))
|
|
s.execute(delete(TPlusAutomationState).where(TPlusAutomationState.source_database == source))
|
|
s.commit()
|
|
|
|
|
|
def test_并发周期检查只排队一个完整采集(auto_source):
|
|
def call():
|
|
with get_session_factory()() as s:
|
|
return automation.tick(s, settings=auto_source, now=NOW)
|
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
|
results = list(pool.map(lambda _: call(), range(3)))
|
|
assert sum(r['queued'] for r in results) == 1
|
|
with get_session_factory()() as s:
|
|
rows = list(s.scalars(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database)))
|
|
assert len(rows) == 1 and len(rows[0].entity_types_json) == 9
|
|
|
|
|
|
def test_周期任务经原worker读取九类且正式工资不变(auto_source, monkeypatch):
|
|
def values(_reader, entity, **_args):
|
|
return iter([{'id': 123456789, 'code': 'AUTO-' + entity}])
|
|
monkeypatch.setattr('app.services.tplus.reader.TPlusReader.iter_entity', values)
|
|
monkeypatch.setattr('app.services.tplus.reader.TPlusReader.count_entities',
|
|
lambda _reader, entities: dict.fromkeys(entities, 1))
|
|
with get_session_factory()() as s:
|
|
result = automation.tick(s, settings=auto_source, now=NOW)
|
|
number = worker.run_once(s, source_factory=lambda: nullcontext(None), worker_owner='integration-owner')
|
|
assert number == result['batch_no']
|
|
batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
|
assert batch.status == 'SUCCEEDED' and batch.source_record_count == 9
|
|
assert batch.trigger_type == 'SCHEDULED'
|
|
assert batch.audit_result_json['progress']['total_records'] == 9
|
|
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
|
|
|
|
|
|
def test_长查询期间心跳使用独立会话(auto_source):
|
|
with get_session_factory()() as s, automation.WorkerHeartbeat(s, interval=0.1, owner='long-task'):
|
|
with get_session_factory()() as inspect_session:
|
|
first = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database)).heartbeat_at
|
|
sleep(1.3)
|
|
with get_session_factory()() as inspect_session:
|
|
state = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database))
|
|
assert state.heartbeat_at > first and state.worker_phase == 'RUNNING'
|
|
|
|
|
|
def test_自动重试保留父批次并受两次上限约束(auto_source):
|
|
with get_session_factory()() as s:
|
|
first = automation.tick(s, settings=auto_source, now=NOW)
|
|
current = first['batch_no']
|
|
for i in range(3):
|
|
batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == current))
|
|
batch.status, batch.finished_at = 'FAILED', NOW + timedelta(hours=i)
|
|
s.add(TPlusSyncError(batch_no=current, entity_type='system', source_table='system', error_code='SOURCE_TIMEOUT', error_message='固定超时说明'))
|
|
s.commit()
|
|
outcome = automation.tick(s, settings=auto_source, now=NOW + timedelta(hours=i, minutes=20))
|
|
assert outcome['queued'] == (i < 2)
|
|
if i < 2:
|
|
child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome['batch_no']))
|
|
assert child.parent_batch_no == current
|
|
current = child.batch_no
|
|
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 3
|
|
|
|
|
|
def test_人工恢复成功后不再对原批次自动重试(auto_source):
|
|
from app.schemas.tplus import TPlusRetryIn
|
|
from app.services.tplus import task_service
|
|
|
|
with get_session_factory()() as s:
|
|
first = automation.tick(s, settings=auto_source, now=NOW)
|
|
original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no']))
|
|
original.status, original.finished_at = 'FAILED', NOW
|
|
s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system',
|
|
error_code='SOURCE_TIMEOUT', error_message='固定说明'))
|
|
s.commit()
|
|
manual = task_service.retry_task(s, original.batch_no, TPlusRetryIn(request_key=uuid4()))
|
|
manual.status, manual.finished_at = 'SUCCEEDED', NOW + timedelta(minutes=2)
|
|
s.commit()
|
|
assert not automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10))['queued']
|
|
state = s.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database))
|
|
assert state.current_batch_no == manual.batch_no and state.last_success_at == manual.finished_at
|
|
|
|
|
|
def test_人工与自动竞争同一失败批次只新增一个重试(auto_source):
|
|
from app.core.exceptions import BadRequestError
|
|
from app.schemas.tplus import TPlusRetryIn
|
|
from app.services.tplus import task_service
|
|
|
|
with get_session_factory()() as s:
|
|
first = automation.tick(s, settings=auto_source, now=NOW)
|
|
original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no']))
|
|
original.status, original.finished_at = 'FAILED', NOW
|
|
s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system',
|
|
error_code='SOURCE_TIMEOUT', error_message='固定说明'))
|
|
s.commit()
|
|
|
|
def manual():
|
|
with get_session_factory()() as s:
|
|
try:
|
|
return task_service.retry_task(s, first['batch_no'], TPlusRetryIn(request_key=uuid4())).batch_no
|
|
except BadRequestError:
|
|
return '已有自动重试'
|
|
|
|
def scheduled():
|
|
with get_session_factory()() as s:
|
|
return automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10))
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
futures = [pool.submit(manual), pool.submit(scheduled)]
|
|
for future in futures:
|
|
future.result(timeout=20)
|
|
with get_session_factory()() as s:
|
|
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where(
|
|
TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 2
|
|
|
|
|
|
def test_数据库状态默认值没有多余引号(auto_source):
|
|
key, database = automation.source_identity(auto_source)
|
|
with get_session_factory()() as s:
|
|
s.execute(text("INSERT INTO tplus_automation_states (source_key,source_database) VALUES (:key,:database)"),
|
|
{"key": key, "database": database})
|
|
s.commit()
|
|
assert s.scalar(select(TPlusAutomationState.worker_phase).where(TPlusAutomationState.source_key == key)) == 'STOPPED'
|