feat: expand module workflows, dynamic config, notifications and console API

This commit is contained in:
2026-09-04 09:49:37 +08:00
parent 8df5266abb
commit b124d757b0
309 changed files with 89358 additions and 6232 deletions
+503
View File
@@ -0,0 +1,503 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any, Mapping
from urllib.request import Request, urlopen
import pytest
from gyxx_flow.adapters.workflow_config import (
PostgresWorkflowConfigStore,
WorkflowConfigConflictError,
normalize_config,
normalize_style_config,
)
from gyxx_flow.console import (
ConsoleConflictError,
WorkflowConsoleService,
create_console_server,
)
from gyxx_flow.core.config import Settings
from gyxx_flow.modules.product_commerce.config.style_config_loader import (
StyleConfigLoader,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class FakeDynamicConfigStore:
def __init__(self) -> None:
self.records = [
{
"id": 1,
"style_name": "极星pro",
"erp_codes": ["10398"],
"platforms": [
{
"id": 11,
"platform": "天猫",
"item_ids": ["832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
],
"enabled": True,
"revision": 1,
}
]
def snapshot(
self,
*,
search: str = "",
platform: str = "",
enabled: bool | None = None,
) -> dict[str, Any]:
del search, platform, enabled
return {
"styles": self.records,
"summary": {"total": 1, "enabled": 1, "platform_configs": 1, "matched": 1},
"platforms": [{"name": "天猫", "count": 1}],
"source": "postgresql",
}
def runtime_records(self) -> list[dict[str, Any]]:
return self.records
def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
return {"id": 2, **normalize_style_config(payload), "revision": 1}
def update(
self,
config_id: int,
payload: Mapping[str, Any],
*,
expected_revision: int,
) -> dict[str, Any]:
if expected_revision != 1:
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
return {
"id": config_id,
**normalize_style_config(payload),
"revision": 2,
}
def delete(self, config_id: int, *, expected_revision: int) -> None:
del config_id
if expected_revision != 1:
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
def _payload() -> dict[str, Any]:
return {
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": "1039810398, 10400",
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
"platforms": [
{
"platform": "天猫",
"item_ids": ["832214449817", "832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
],
}
def test_normalize_config_cleans_ids_and_markdown_urls() -> None:
payload = {
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": "1039810398, 10400",
"item_ids": ["832214449817", "832214449817"],
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"tm_persona_bitable_url": "",
"jd_persona_bitable_url": "",
"dy_persona_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
}
payload["sales_bitable_url"] = "[销量表](https://example.feishu.cn/base/abc?table=tbl1)"
normalized = normalize_config(payload)
assert normalized["erp_codes"] == ["10398", "10400"]
assert normalized["item_ids"] == ["832214449817"]
assert normalized["sales_bitable_url"] == (
"https://example.feishu.cn/base/abc?table=tbl1"
)
def test_normalize_style_config_owns_erp_once_and_platform_ids_per_child() -> None:
normalized = normalize_style_config(_payload())
assert normalized["erp_codes"] == ["10398", "10400"]
assert normalized["platforms"] == [
{
"platform": "天猫",
"item_ids": ["832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
]
def test_normalize_style_config_supports_extensible_business_targets() -> None:
payload = _payload()
payload["destinations"] = [
{
"key": "sales_bitable_url",
"label": "销量表",
"url": "https://example.feishu.cn/base/sales?table=tblSales",
"description": "销量日报写入目标",
"enabled": True,
},
{
"key": "style_quality_analysis",
"label": "款式质量分析",
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
"description": "未来分析逻辑写入的结果表",
"enabled": True,
},
]
normalized = normalize_style_config(payload)
assert [item["key"] for item in normalized["destinations"]] == [
"sales_bitable_url",
"style_quality_analysis",
]
assert normalized["sales_bitable_url"] == (
"https://example.feishu.cn/base/sales?table=tblSales"
)
assert normalized["destinations"][1]["description"] == (
"未来分析逻辑写入的结果表"
)
def test_postgres_store_omits_empty_dsn_for_split_connection_fields(
monkeypatch,
) -> None:
from gyxx_flow.adapters import workflow_config
calls: list[dict[str, Any]] = []
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
pass
def close(self) -> None:
pass
def connect(**kwargs):
calls.append(kwargs)
return FakeConnection()
monkeypatch.setattr(workflow_config.psycopg, "connect", connect)
store = PostgresWorkflowConfigStore(
{
"PG_HOST": "db.example.test",
"PG_PORT": "5432",
"PG_DB": "app",
"PG_USER": "app",
"PG_PASSWORD": "test-only",
}
)
with store._connection():
pass
assert calls[0]["host"] == "db.example.test"
assert "conninfo" not in calls[0]
def test_migration_seed_captures_all_feishu_records() -> None:
seed = json.loads(
(PROJECT_ROOT / "config" / "workflow-dynamic-config-seed.json").read_text(
encoding="utf-8"
)
)
assert seed["record_count"] == 215
assert len(seed["records"]) == 215
assert len({item["source_record_id"] for item in seed["records"]}) == 215
assert any(
item["config"]["style_name"] == "极星pro"
and item["config"]["platform"] == "天猫"
for item in seed["records"]
)
def test_style_loader_reads_database_provider_and_never_needs_feishu(
tmp_path: Path,
) -> None:
loader = StyleConfigLoader(
cache_path=tmp_path / "style-cache.json",
records_provider=lambda: [
{
**normalize_config(
{
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": ["10398", "10400"],
"item_ids": ["832214449817"],
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"tm_persona_bitable_url": "",
"jd_persona_bitable_url": "",
"dy_persona_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
}
),
"sales_bitable_url": (
"https://example.feishu.cn/base/abc?table=tbl1&view=vew1"
),
}
],
)
loaded = loader.load()
assert loaded["source"] == "database"
assert loaded["styles"]["极星pro"]["tm"]["item_ids"] == [
"832214449817"
]
assert loaded["styles"]["极星pro"]["erp_codes"] == ["10398", "10400"]
def test_style_loader_preserves_extensible_business_targets(tmp_path: Path) -> None:
destinations = [
{
"key": "style_quality_analysis",
"label": "款式质量分析",
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
"description": "质量分析结果表",
"enabled": True,
}
]
loader = StyleConfigLoader(
cache_path=tmp_path / "style-cache.json",
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": ["10398"],
"item_ids": ["832214449817"],
"sales_bitable_url": (
"https://example.feishu.cn/base/sales?table=tblSales"
),
"destinations": destinations,
}
],
)
loaded = loader.load()
assert loaded["styles"]["极星pro"]["destinations"] == destinations
def test_grouped_database_keeps_original_platform_and_target_contracts() -> None:
shared = {
"style_name": "款式甲",
"brand": "光影行星",
"erp_codes": ["ERP-1"],
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
"tm_persona_bitable_url": "https://example.feishu.cn/base/tm?table=tblTm",
"jd_persona_bitable_url": "https://example.feishu.cn/base/jd?table=tblJd",
"dy_persona_bitable_url": "https://example.feishu.cn/base/dy?table=tblDy",
"style_analysis_bitable_url": "https://example.feishu.cn/base/report?table=tblReport",
}
rows = [
{**shared, "platform": "天猫", "item_ids": ["TM-1"]},
{**shared, "platform": "京东旗舰店", "item_ids": ["JD-SPU-1"]},
{**shared, "platform": "京东自营", "item_ids": ["JD-SKU-1"]},
{**shared, "platform": "抖音", "item_ids": ["DY-1"]},
]
loader = StyleConfigLoader(records_provider=lambda: rows)
style = loader._aggregate(loader._load_records_from_database())["styles"]["款式甲"]
assert style["erp_codes"] == ["ERP-1"]
assert style["tm"]["item_ids"] == ["TM-1"]
assert style["jd"]["spus"] == ["JD-SPU-1"]
assert style["jd_self"]["skus"] == ["JD-SKU-1"]
assert style["dy"]["item_ids"] == ["DY-1"]
assert style["sales_bitable"]["table_id"] == "tblSales"
assert style["main_image_bitable"]["table_id"] == "tblImage"
assert style["persona_bitable"]["table_id"] == "tblTm"
assert style["jd_persona_bitable"]["table_id"] == "tblJd"
assert style["dy_persona_bitable"]["table_id"] == "tblDy"
assert style["style_analysis_bitable"]["table_id"] == "tblReport"
def test_console_dynamic_config_crud_maps_optimistic_conflicts(
tmp_path: Path,
) -> None:
service = WorkflowConsoleService(
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
dynamic_configs=FakeDynamicConfigStore(),
)
assert service.dynamic_config_snapshot()["summary"]["total"] == 1
created = service.create_dynamic_config(_payload())
assert created["revision"] == 1
updated = service.update_dynamic_config(1, _payload(), expected_revision=1)
assert updated["revision"] == 2
with pytest.raises(ConsoleConflictError, match="其他人更新"):
service.update_dynamic_config(1, _payload(), expected_revision=9)
def test_console_assets_include_dynamic_config_crud_page() -> None:
html = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "index.html").read_text(
encoding="utf-8"
)
script = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "app.js").read_text(
encoding="utf-8"
)
assert 'id="dynamic-config-view"' in html
assert 'id="dynamic-config-form"' in html
assert "/api/dynamic-configs" in script
assert "#/configs/products" in script
def test_content_summary_targets_are_loaded_from_project_database(monkeypatch) -> None:
from gyxx_flow.modules.content_marketing import weekly_summary_all
monkeypatch.setattr(
weekly_summary_all,
"runtime_workflow_configs",
lambda: [
{
"style_name": "极星pro",
"weekly_note_analysis_url": (
"https://example.feishu.cn/base/base1?table=table1&view=view1"
),
}
],
)
assert weekly_summary_all.load_index_styles() == {
"极星pro": (
"base1",
"table1",
"https://example.feishu.cn/base/base1?table=table1&view=view1",
)
}
def test_content_mapping_rejects_legacy_static_cache(
monkeypatch, tmp_path: Path
) -> None:
from gyxx_flow.modules.content_marketing import feishu_mapping
cache_path = tmp_path / "mapping.json"
cache_path.write_text(
json.dumps({"platform": "xhs_pgy", "tables": [{"name": "旧款"}]}),
encoding="utf-8",
)
monkeypatch.setattr(feishu_mapping, "MAPPING_PATH", cache_path)
monkeypatch.setattr(feishu_mapping, "refresh_mapping", lambda **_kwargs: None)
with pytest.raises(RuntimeError, match="项目数据库不可用"):
feishu_mapping.load_mapping(force_refresh=False)
def test_retired_feishu_master_is_not_referenced_by_runtime_modules() -> None:
retired_token = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
runtime_root = PROJECT_ROOT / "src" / "gyxx_flow"
offenders = []
for path in runtime_root.rglob("*.py"):
if path == runtime_root / "migration" / "feishu_style_config_seed.py":
continue
if retired_token in path.read_text(encoding="utf-8"):
offenders.append(path.relative_to(PROJECT_ROOT).as_posix())
assert offenders == []
def test_dynamic_config_http_crud_uses_revision_preconditions(tmp_path: Path) -> None:
server = create_console_server(
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
host="127.0.0.1",
port=0,
dynamic_configs=FakeDynamicConfigStore(),
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_address[1]}"
def request(
method: str,
path: str,
payload: dict[str, Any] | None = None,
revision: int | None = None,
) -> tuple[int, dict[str, Any]]:
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": base_url,
}
if revision is not None:
headers["If-Match"] = str(revision)
response = urlopen(
Request(base_url + path, data=data, headers=headers, method=method),
timeout=5,
)
return response.status, json.loads(response.read().decode("utf-8"))
try:
status, snapshot = request("GET", "/api/dynamic-configs")
assert status == 200
assert snapshot["summary"]["total"] == 1
status, created = request("POST", "/api/dynamic-configs", _payload())
assert status == 201
assert created["revision"] == 1
status, updated = request(
"PUT", "/api/dynamic-configs/1", _payload(), revision=1
)
assert status == 200
assert updated["revision"] == 2
status, deleted = request(
"DELETE", "/api/dynamic-configs/1", revision=1
)
assert status == 200
assert deleted == {"deleted": True, "id": 1}
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)