feat(tplus): 新增 T+ 只读同步服务与同步管理
This commit is contained in:
1 parent
78bef43c06
commit
4fe5916c2c
43 files changed
+3309
-388
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
Reference in new issue
Block a user