feat(tplus): 新增 T+ 只读同步服务与同步管理

This commit is contained in:
liuwenxin committed 2026-10-28 11:42:15 +08:00
1 parent 78bef43c06
commit 4fe5916c2c
43 files changed
+3309 -388

No files matched your search

+33
View File
@@ -324,3 +324,36 @@ def normal(make_user, auth_headers) -> dict:
user = make_user([ROLE_USER])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def tplus_tasks(monkeypatch):
"""隔离测试任务,仅清理此夹具创建的批次和异常。"""
from app.core.config import Settings
from app.models import TPlusSyncBatch, TPlusSyncError
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_enabled=True,
t_plus_database="unit_tplus",
t_plus_user="reader",
t_plus_odbc_driver="unit",
)
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
keys = []
def payload():
key = str(uuid.uuid4())
keys.append(key)
return {"task_type": "READ", "entity_types": ["personnel"], "request_key": key}
yield payload
with get_session_factory()() as db:
numbers = list(
db.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.request_key.in_(keys)))
)
if numbers:
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
db.commit()
+11 -1
View File
@@ -487,8 +487,18 @@ class TestManagerAggregate:
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
def test_月末取参_预约变更影响本月聚合(
self, client: TestClient, admin: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
class ScenarioDate(date):
@classmethod
def today(cls):
return cls(2026, 10, 20)
# 固定预约场景的申请日,避免现实日期超过 10-25 后误测成“禁止追溯修改”。
monkeypatch.setattr("app.services.param_service.date", ScenarioDate)
resp = client.post(
"/api/v1/system/params/changes",
json={
@@ -0,0 +1,90 @@
"""在隔离 MySQL 中验证幂等竞争、父批次串行化和连接级锁。"""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from uuid import uuid4
import pytest
from sqlalchemy import delete, func, select, text
from app.core.db import get_session_factory
from app.core.exceptions import BadRequestError
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusSyncBatch
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
from app.services.tplus import task_service
pytestmark = pytest.mark.integration
def _parallel(action):
barrier = Barrier(2)
def run():
with get_session_factory()() as session:
barrier.wait(timeout=10)
return action(session)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(run) for _ in range(2)]
return [future.result(timeout=20) for future in futures]
def test_并发相同请求只创建一个批次(tplus_tasks):
payload = TPlusCreateIn(**tplus_tasks())
numbers = _parallel(lambda session: task_service.create_task(session, payload).batch_no)
assert numbers[0] == numbers[1]
with get_session_factory()() as session:
assert (
session.scalar(
select(func.count())
.select_from(TPlusSyncBatch)
.where(TPlusSyncBatch.request_key == str(payload.request_key))
)
== 1
)
def test_并发不同请求重试同父批次只允许一个(tplus_tasks):
with get_session_factory()() as session:
parent = task_service.create_task(session, TPlusCreateIn(**tplus_tasks()))
parent.status = "FAILED"
session.commit()
number = parent.batch_no
def retry(session):
try:
return task_service.retry_task(
session, number, TPlusRetryIn(request_key=uuid4())
).batch_no
except BadRequestError:
session.rollback()
return "REJECTED"
try:
numbers = _parallel(retry)
assert numbers.count("REJECTED") == 1
with get_session_factory()() as session:
assert (
session.scalar(
select(func.count())
.select_from(TPlusSyncBatch)
.where(TPlusSyncBatch.parent_batch_no == number)
)
== 1
)
finally:
with get_session_factory()() as session:
session.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.parent_batch_no == number))
session.commit()
def test_执行锁跨提交保持且释放后可获取():
with get_session_factory()() as owner, get_session_factory()() as other:
with sync_lock(owner):
owner.execute(text("SELECT 1"))
owner.commit()
with pytest.raises(TPlusBusyError), sync_lock(other):
pytest.fail("另一执行者不应取得锁")
with sync_lock(other):
assert other.scalar(text("SELECT 1")) == 1
@@ -0,0 +1,129 @@
"""管理员可管理原始读取任务;权限、幂等和异常脱敏由真实接口验证。"""
import pytest
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import TPlusSyncBatch, TPlusSyncError
pytestmark = pytest.mark.integration
BASE = "/api/v1/system/tplus"
def test_未登录和普通用户不能访问管理(client, admin, normal, tplus_tasks):
assert client.get(BASE + "/overview").status_code == 401
assert client.get(BASE + "/overview", headers=normal["headers"]).status_code == 403
assert (
client.post(BASE + "/batches", json=tplus_tasks(), headers=normal["headers"]).status_code
== 403
)
def test_创建重复请求返回同一批次并可分页查看(client, admin, tplus_tasks):
payload = tplus_tasks()
first = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
assert first.status_code == 200
second = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
assert second.status_code == 200
number = first.json()["data"]["batch_no"]
assert second.json()["data"]["batch_no"] == number
page = client.get(
BASE + "/batches", params={"pageSize": 1, "status": "QUEUED"}, headers=admin["headers"]
)
assert page.status_code == 200 and page.json()["data"]["pageSize"] == 1
detail = client.get(BASE + "/batches/" + number, headers=admin["headers"])
assert detail.status_code == 200
assert detail.json()["data"]["status"] == "QUEUED"
def test_异常返回固定说明不泄漏数据库参数(client, admin, tplus_tasks):
response = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"])
assert response.status_code == 200
number = response.json()["data"]["batch_no"]
with get_session_factory()() as db:
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
batch.status, batch.error_message = "FAILED", "password=private-value phone=13800000000"
db.add(
TPlusSyncError(
batch_no=number,
entity_type="personnel",
source_table="AA_Person",
source_id="12",
error_code="READ_ERROR",
error_message=batch.error_message,
)
)
db.commit()
for path in (f"/batches/{number}", f"/batches/{number}/errors"):
result = client.get(BASE + path, headers=admin["headers"])
assert result.status_code == 200
assert "private-value" not in result.text and "13800000000" not in result.text
def test_不支持的实体和不存在的批次有明确错误(client, admin, tplus_tasks):
payload = tplus_tasks()
payload["entity_types"] = ["users;drop"]
assert client.post(BASE + "/batches", json=payload, headers=admin["headers"]).status_code == 400
assert client.get(BASE + "/batches/TPmissing", headers=admin["headers"]).status_code == 404
def test_配置关闭时可查询历史但不能排队(client, admin, tplus_tasks, monkeypatch):
from app.services.tplus import task_service
settings = task_service.get_settings().model_copy(update={"t_plus_enabled": False})
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
response = client.get(BASE + "/overview", headers=admin["headers"])
assert response.status_code == 200
assert response.json()["data"]["is_configured"] is False
assert response.json()["data"]["business_import_enabled"] is False
assert response.json()["data"]["compatibility_status"] == "PENDING"
assert (
client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).status_code
== 400
)
def test_失败批次重试保留异常并返回新批次(client, admin, tplus_tasks):
from app.models import OperationLog
parent = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()[
"data"
]
number = parent["batch_no"]
with get_session_factory()() as db:
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
batch.status, batch.failed_record_count = "FAILED", 1
db.add(
TPlusSyncError(
batch_no=number,
entity_type="personnel",
source_table="AA_Person",
source_id="1",
error_code="READ_ERROR",
error_message="读取失败",
)
)
db.commit()
request = {"request_key": tplus_tasks()["request_key"]}
first = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
replay = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
assert first.status_code == replay.status_code == 200
child = first.json()["data"]
assert child["parent_batch_no"] == number and child["batch_no"] != number
assert replay.json()["data"]["batch_no"] == child["batch_no"]
detail = client.get(BASE + f"/batches/{number}", headers=admin["headers"]).json()["data"]
assert detail["can_retry"] is False
with get_session_factory()() as db:
assert (
db.scalar(select(TPlusSyncError.retry_count).where(TPlusSyncError.batch_no == number))
== 1
)
assert db.scalar(
select(OperationLog.id)
.where(
OperationLog.user_id == admin["id"],
OperationLog.module == "T+数据同步",
OperationLog.action == "重试读取任务",
)
.limit(1)
)