Compare commits

...
79 changed files with 4312 additions and 286 deletions
+23 -5
View File
@@ -1,11 +1,17 @@
# Agent 开发规则
本仓库采用精简的单人 DevHarness 工作流:Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源;Gitea Wiki 只维护长期产品、架构、契约、业务规则、安全边界和运行说明;Git 保存源码、迁移、测试、版本绑定资料和 Wiki 的本地镜像。`docs/` 中显式映射的 Markdown 是 Wiki 只读镜像;既有 Wiki 任务归档和 `docs/task/` 仅作历史兼容,只有用户明确要求专项快照时才创建或导出。
本仓库采用轻量治理的精简单人 DevHarness 工作流:需要工单的任务以 Gitea 工单作为单次需求、变化、实现、测试、提交和验收的事实来源;Gitea Wiki 只维护长期产品、架构、契约、业务规则、安全边界和运行说明;Git 保存源码、迁移、测试、版本绑定资料和 Wiki 的本地镜像。`docs/` 中显式映射的 Markdown 是 Wiki 只读镜像;既有 Wiki 任务归档和 `docs/task/` 仅作历史兼容,只有用户明确要求专项快照时才创建或导出。
当前文档规则参考 DevHarness 提交 `4bbacf4d7fb265984396bb5589c544105043fa0b`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
当前文档规则参考 DevHarness 提交 `ecab899`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。涉及采集、采购或设备行为时另读 `docs/03-business-rules-and-glossary.md` 和当前工单。
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 常用命令
所有命令默认从仓库根目录执行。
@@ -16,6 +22,7 @@
| 检查模板结构 | `python dev_scripts/harness.py check --strict` |
| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` |
| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
| 深度检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --deep-check` |
| 导出并完整校验 | `python dev_scripts/harness.py sync --verify` |
| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
| 增量导出已有快照 | `python dev_scripts/harness.py export` |
@@ -40,18 +47,28 @@
- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。
- 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。
### 项目治理模式与明确授权后的执行
- GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
- 采购、创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作按高风险任务处理;未取得有效人工授权时必须停止。
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时必须重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 2. 哪些改动需要工单
新功能、缺陷修复、重构,以及接口、数据库、权限、并发、状态机、安全或用户界面变化必须先有单元工单。
完整独立需求、新页面、跨模块功能、范围不明确的变化,以及接口、数据库、权限、并发、状态机或安全边界变化必须先有单元工单。采购、创建订单、数据库迁移、删除数据、发布和其他不可逆操作无论规模大小都必须建单。
以下小改动只有在范围明确、容易回退且不涉及上面的必须建单项时才可以直接提交:
以下低风险改动在范围明确、容易回退、不改变接口、数据结构、权限或安全边界,且不属于上述高风险事项时可以直接提交:
- 只改错别字、注释或文档措辞;
- 只做格式化、导入排序或不跨文件的内部变量改名;
- 补充类型标注或文档字符串且不改变行为;
- 补充不改变产品行为的测试;
- 删除已经确认无人使用的死代码;
- 修复单文件、低风险且只恢复已有明确行为的缺陷;
- 修复预期行为明确、影响局限于单个模块的低风险缺陷;
- 不改变对外行为、入口、配置和验证方式的局部内部重构;
- 只修改用户看到的界面显示文案,并且满足本文件「工单与设计证据双门禁」的全部豁免条件。
直接提交仍须保护无关改动、执行受影响范围的最小验证并写清提交说明。有任何不确定,或涉及接口、数据库、状态、权限、安全、并发、用户界面时,必须退回单元工单流程。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。
@@ -184,6 +201,7 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- 提交只包含当前工单相关文件,提交信息引用工单号。
- 优先运行项目档案记录的格式、单元、契约和集成测试。
- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不得仅为设置编码重复启动一层 PowerShell。
- Windows 命令不得默认套用 Bash 语法。复杂正则优先使用变量或 `rg -e`;包含引号和换行的脚本正文优先使用单引号 PowerShell here-string;`foreach`、`if` 等语句块保持在同一个 PowerShell 解析上下文中;`rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数。
- 文本文件读写在命令支持时显式指定 UTF-8;文件解码和控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才设置当前进程的输出编码或 Python UTF-8 环境变量。
- 不得默认使用 `-ExecutionPolicy Bypass`;只有可信 `.ps1` 确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
- 涉及创建订单、权限、安全、并发、迁移和删除数据属于高风险,真机或正式实施前必须再次等待人工确认。
+59 -20
View File
@@ -67,6 +67,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/00-project-profile.md": (
"## 基本信息",
"## 项目治理模式",
"## 建设基线",
"## 交付单元",
"## 文档事实来源",
@@ -74,6 +75,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
"## 当前阶段",
),
"docs/01-workflow.md": (
"## 语言与术语",
"## 事实来源",
"## 权威源与事实边界",
"## Gitea 交互与工单最小读取",
@@ -102,6 +104,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/04-local-development-and-verification.md": (
"## Windows PowerShell 与 UTF-8",
"### PowerShell 语法与外部命令",
"## 通用检查",
"## 服务端验证",
"## Web 验证",
@@ -271,9 +274,14 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"#### 明确停止条件",
"单元任务是唯一正式实施单位",
"高风险修改必须停止",
"### 项目治理模式与明确授权后的执行",
"默认采用轻量治理",
"不能单独构成人工授权",
"不得仅因操作不可逆而重复询问或拒绝",
"平台自身强制的审批、安全策略或权限限制继续有效",
"用户没有明确验收通过前不得关闭",
"只有长期事实变化时才更新 Wiki",
"Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源",
"需要工单的任务以 Gitea 工单作为单次需求、变化、实现、测试、提交和验收的事实来源",
"Wiki 同步由长期事实变化触发,不由任务完成触发",
"标准流程不创建 Wiki 任务归档",
"工单默认只在开始实施、集中回写待验收、验收关闭三个节点更新",
@@ -289,6 +297,11 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据",
"提交只包含当前工单相关文件",
"不得仅为设置编码重复启动一层 PowerShell",
"不得默认套用 Bash",
"复杂正则优先使用变量或 `rg -e`",
"单引号 PowerShell here-string",
"`foreach`、`if` 等语句块",
"使用真实目录配合 `-g/--glob`",
"文件解码和控制台输出分别处理",
"不得默认使用 `-ExecutionPolicy Bypass`",
"### 工单与设计证据双门禁",
@@ -316,6 +329,9 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"不得臆造用户原话",
"不复制完整聊天",
"Gitea 工单全文不导出到仓库",
"用户可以使用中文、英文或合理的中英混合语言交流",
"默认使用中文分析、回复、编写工单和维护内部项目文档",
"日志和错误原文保持原样",
)
for section in missing_sections(content, required):
errors.append(f"AGENTS.md 缺少:{section}")
@@ -562,32 +578,47 @@ def run_check(args: argparse.Namespace) -> int:
def run_sync(args: argparse.Namespace) -> int:
"""--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。"""
"""同步核心镜像;--verify 复用本轮完整读取完成初始化验证。"""
if args.verify:
steps = (
("同步", lambda: run_sync(
argparse.Namespace(check=False, verify=False, config=args.config))),
("结构检查", lambda: run_check(argparse.Namespace(strict=True))),
("一致性校验", lambda: run_sync(
argparse.Namespace(check=True, verify=False, config=args.config))),
)
for name, step in steps:
code = step()
if code != 0:
print(f"错误:{name}未通过,已停止")
return code
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(
config, WikiClient(config), check=False, deep_check=True
)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
code = run_check(argparse.Namespace(strict=True))
if code != 0:
print("错误:结构检查未通过,已停止")
return code
print("Wiki 镜像初始化验证通过")
return 0
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(config, WikiClient(config), check=args.check)
deep_check = getattr(args, "deep_check", False)
messages = sync_all(
config,
WikiClient(config),
check=args.check or deep_check,
deep_check=deep_check,
)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
print(
"Wiki 镜像深度检查通过"
if deep_check
else "Wiki 镜像检查通过"
if args.check
else "Wiki 镜像同步完成"
)
return 0
@@ -652,13 +683,21 @@ def main() -> int:
p_check.set_defaults(func=run_check)
p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像")
p_sync.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
sync_mode = p_sync.add_mutually_exclusive_group()
sync_mode.add_argument(
"--check",
action="store_true",
help="按 revision 快速检查 Wiki 与镜像,不写文件",
)
p_sync.add_argument(
sync_mode.add_argument(
"--deep-check",
action="store_true",
help="下载全部 Wiki 正文并逐页检查镜像,不写文件",
)
sync_mode.add_argument(
"--verify",
action="store_true",
help="依次执行导出、check --strict 和一致性校验",
help="完整读取并导出 Wiki,再执行 check --strict 初始化验证",
)
p_sync.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
+137 -14
View File
@@ -8,6 +8,7 @@ import os
import re
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
@@ -26,6 +27,7 @@ HEADER_PATTERN = re.compile(
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
re.DOTALL,
)
RATE_LIMIT_RETRY_DELAYS = (1.0, 2.0, 4.0)
class WikiDocsError(RuntimeError):
@@ -142,11 +144,33 @@ class WikiClient:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=30) as response:
body = response.read()
except HTTPError as exc:
if self.token and method == "GET" and exc.code in {401, 403, 404}:
request_error: HTTPError | URLError | None = None
body = b""
for attempt in range(len(RATE_LIMIT_RETRY_DELAYS) + 1):
try:
with urlopen(request, timeout=30) as response:
body = response.read()
request_error = None
break
except HTTPError as exc:
if (
method == "GET"
and exc.code == 429
and attempt < len(RATE_LIMIT_RETRY_DELAYS)
):
exc.close()
time.sleep(RATE_LIMIT_RETRY_DELAYS[attempt])
continue
request_error = exc
break
except URLError as exc:
request_error = exc
break
else: # pragma: no cover - for 循环必定通过成功或异常分支退出
raise WikiDocsError(f"Gitea API {method} {api_path} 请求失败")
if isinstance(request_error, HTTPError):
if self.token and method == "GET" and request_error.code in {401, 403, 404}:
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
anonymous_headers = {"Accept": "application/json"}
@@ -167,12 +191,13 @@ class WikiClient:
f"无法连接 Gitea:{anonymous_exc.reason}"
) from anonymous_exc
else:
detail = exc.read().decode("utf-8", errors="replace")
detail = request_error.read().decode("utf-8", errors="replace")
raise WikiDocsError(
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
) from exc
except URLError as exc:
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
f"Gitea API {method} {api_path} 返回 "
f"{request_error.code}: {detail}"
) from request_error
elif isinstance(request_error, URLError):
raise WikiDocsError(f"无法连接 Gitea:{request_error.reason}") from request_error
if not body:
return None
try:
@@ -381,8 +406,73 @@ def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
return errors
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
def _metadata_revision(metadata: dict[str, Any]) -> str | None:
last_commit = metadata.get("last_commit")
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
return revision if isinstance(revision, str) and revision else None
def _find_page_metadata(
pages: list[dict[str, Any]], page_name: str
) -> dict[str, Any]:
metadata = next(
(
item
for item in pages
if item.get("title") == page_name or item.get("sub_url") == page_name
),
None,
)
if metadata is None:
raise WikiDocsError(
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
)
return metadata
def _metadata_identity(
config: Config, metadata: dict[str, Any], fallback_title: str
) -> tuple[str, str] | None:
title = metadata.get("title")
sub_url = metadata.get("sub_url")
if not isinstance(title, str) or not title:
title = fallback_title
if not isinstance(sub_url, str) or not sub_url:
return None
url = (
f"{config.gitea_url}/{quote(config.owner, safe='')}/"
f"{quote(config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
)
return title, url
def _local_revision(
path: Path, *, expected_title: str, expected_url: str
) -> str | None:
if not path.is_file():
return None
try:
metadata, _body = parse_mirror(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, WikiDocsError):
return None
if (
metadata.get("wiki_page") != expected_title
or metadata.get("wiki_url") != expected_url
or not metadata.get("synchronized_at")
):
return None
revision = metadata.get("wiki_revision")
return revision if revision else None
def sync_all(
config: Config,
client: WikiClient,
*,
check: bool = False,
deep_check: bool = False,
) -> list[str]:
"""检查或写入所有显式映射;写入前先完成全部远端读取。"""
if not check:
dirty = dirty_mirror_paths(config)
@@ -392,10 +482,43 @@ def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
)
messages: list[str] = []
pages = client.list_pages()
resolved: list[tuple[Mapping, Path, WikiPage | None, str | None]] = []
for mapping in config.mappings:
page = client.get_page(mapping.page)
metadata = _find_page_metadata(pages, mapping.page)
target = ROOT / PurePosixPath(mapping.path)
remote_revision = _metadata_revision(metadata)
identity = _metadata_identity(config, metadata, mapping.page)
local_revision = (
_local_revision(
target, expected_title=identity[0], expected_url=identity[1]
)
if identity is not None
else None
)
if not deep_check and remote_revision and local_revision == remote_revision:
action = "一致" if check else "无变化"
resolved.append(
(
mapping,
target,
None,
f"{action}:{mapping.path} <- "
f"{mapping.page}@{remote_revision[:12]}",
)
)
continue
page = client.get_page_from_metadata(metadata, mapping.page)
resolved.append((mapping, target, page, None))
messages: list[str] = []
for mapping, target, page, skip_message in resolved:
if skip_message is not None:
messages.append(skip_message)
continue
if page is None: # pragma: no cover - resolved 元组由上面的单一路径构造
raise WikiDocsError(f"Wiki 页面未解析:{mapping.page}")
if check:
errors = check_mirror(mapping, page, target)
if errors:
+8 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 6207da4ffac6b5821eda7869b9ea335b7585826d
synchronized_at: 2026-08-31T16:06:21Z
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T04:06:47Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -21,16 +21,20 @@ synchronized_at: 2026-08-31T16:06:21Z
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 项目治理模式
GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。
## 建设基线
| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 |
|---|---|---|---|
| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 |
| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 |
| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 |
| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 |
| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 |
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
## 交付单元
+22 -3
View File
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1
synchronized_at: 2026-08-27T09:37:51Z
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T04:06:53Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 事实来源
| 信息 | 唯一事实来源 |
@@ -18,7 +24,7 @@ synchronized_at: 2026-08-27T09:37:51Z
| 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git |
| 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 |
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
## 权威源与事实边界
@@ -53,6 +59,19 @@ synchronized_at: 2026-08-27T09:37:51Z
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
## 项目治理模式与不可裁剪底线
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
### 明确授权后的执行
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 工单与设计证据双门禁
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
+26 -11
View File
@@ -2,16 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: de7e400503d6e7b9c335657114893de345bfead5
synchronized_at: 2026-08-31T03:33:36Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
synchronized_at: 2026-08-29T01:39:35Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -56,7 +48,7 @@ Android Portal/Agent
- Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
## 最小业务数据
@@ -318,3 +310,26 @@ PddProductDetailCollector
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
## 蝦皮规格自动匹配批处理(#195)
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
## SYB 异常规格 AI 定时解析(#198)
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
## 定时任务持久化执行历史(#199)
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
+63 -27
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: a6f63cc745cf0e0df7ca94521a70fb6af3adea26
synchronized_at: 2026-09-01T09:55:26Z
wiki_revision: f8a2e64acaf245066d55d4a0f1e928d672712270
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -31,9 +31,9 @@ synchronized_at: 2026-09-01T09:55:26Z
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外,其进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;SYB 商品页批量 `ai_match` 只要 Provider 成功返回规格结果且理由非空、返回值属于当前可选候选即可直接持久化为 `confirmed`,置信度仅记录供审计、不作为放行门槛。若当前 PDD 档案存在完整可售颜色+尺码 SKU 组合证据,结果还必须命中其中同一个组合;人工录入或外部导入导致组合证据缺失时不阻断保存或创建采购。无结果、已有组合证据中的无效组合或 Provider 异常不得改写现有映射。
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
@@ -41,6 +41,8 @@ synchronized_at: 2026-09-01T09:55:26Z
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
- 采购放行不以 uncertain 单独阻断:success 与已提取出至少一个非空颜色或尺码的 uncertain 明细,都可继续进入规格匹配和采购预检;只有 failed 或颜色、尺码均为空的异常明细才要求人工处理。uncertain 状态与解析说明继续保留供审计,不会自动猜测规格。
- 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。
- 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
@@ -62,6 +64,7 @@ synchronized_at: 2026-09-01T09:55:26Z
## SYB 店铺过滤
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
@@ -73,7 +76,7 @@ synchronized_at: 2026-09-01T09:55:26Z
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
- 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。
## 采集规则
@@ -84,12 +87,14 @@ synchronized_at: 2026-09-01T09:55:26Z
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮;评价、评论、晒单、问答区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮。若“请选择”和颜色/尺码语义分散在同一可点击父容器的子节点中,也只能在该容器唯一、位于非底部动作区且同时具备两类语义时作为规格入口;规则别名只能收窄已识别的安全候选,不能按页面原始文字扩张。底部购买入口在通过全部安全过滤后仍存在多个候选时,按最右优先(`centerX` 降序,并列时可点击面积升序)确定性选出唯一入口,不再因候选不唯一直接失败;该情形记为 `bottom_purchase_rightmost`,唯一候选仍记为 `bottom_purchase`。“单独购买”与“发起拼单”打开同一规格面板,但对应不同成交形态与价格,最右优先是经人工确认的取舍。评价、评论、晒单、问答、订单、提交订单、支付和付款区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
## 采集任务
@@ -135,27 +140,27 @@ synchronized_at: 2026-09-01T09:55:26Z
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取和本地确定性识别,禁止调用 AI Provider。自 #215 起,未保存或已失效的长期规格映射不再阻断创建采购;满足商品关联、PDD 状态、价格、规则、设备和订单安全门禁后即可创建任务,每个新 SYB 采购任务都在首个 attempt 以当次 PDD 页面候选重新决策。
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;任一页面不在白名单、恢复动作重复、无法到达订单详情或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;下单成功页仅可点击唯一、精确的“查看订单”或“订单详情”入口一次,进入 PDD 订单详情后才允许有限向上手势下翻读取折叠内容。PDD 页面出现“待付款”“待支付”“订单编号”或“下单时间”等订单结果信号时,该只读信号优先于复用的支付 Activity 名称:允许在唯一主要纵向容器内有限下翻并重新读取,仍不得点击支付;没有任何订单结果信号的支付页或未知页面不得盲目滑动。入口缺失或不唯一、点击后未出现详情证据、任一页面不在白名单、恢复动作重复或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- 自 #215 起,每个新 SYB 采购任务无论是否存在已确认长期映射,都先由 Agent 在当次 PDD 规格面板内有界遍历颜色和尺码并回传原始候选。服务端只以任务冻结的 SYB 目标规格与当次候选先做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算),仍无唯一结果才调用已启用的服务端 AI。AI 必须逐字返回当次候选集中的原始标签;候选不完整、集合外返回、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
- 所有新 SYB 采购任务的规则都必须具有 `purchase.spec-probe.v1`;创建时任务级 `mappedColor` / `mappedSize` 为空且 `spec_source=unresolved`,长期映射和 PDD 档案候选都不能跳过首趟探测。第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟;探测阶段从服务端拒绝进入 `order_submit_started`。Android 不自行匹配或猜测,只在第二趟接收并精确选择服务端固化的当次 PDD 原始标签。
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、目标规格、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。新任务不复用旧任务或长期档案的执行规格,仍必须先完成当次真机规格探测。
- 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。
- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。
@@ -251,16 +256,14 @@ synchronized_at: 2026-09-01T09:55:26Z
## Agent 受控重试采购
- 当前设备只可就地重跑自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”复用原 `purchase_task.id`,不新建任务,不改变商品、虾皮/SYB 身份、目标规格、已映射规格、价格保护、地址后缀与其他业务快照。重试只把任务恢复为 `pending`,清理错误、租约和运行守卫。
- 重试时刷新服务端当前采购规则及其类型、schema 和能力要求。#127 尚未实施前,当前规则仍为服务端内置规则;#127 完成后才切换为数据库单例设置,不能在本工单提前引入第二事实源。
- 新规则下发前必须重新校验原设备在线、空闲且具备全部能力。规则不可用或能力不匹配时拒绝重试,原任务保持失败状态。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝就地重试,并提示走既有“授权重新采购”流程,防止重复下单。
- 每次成功受理就地重试都会为同一任务预建一个新的 `pending` attempt,保存本次规则哈希;正常 Start 复用并转为 `running`。因此尝试次数、每次规则哈希和失败原因均可追溯,且不限制人工重试次数。
- `requestId` 按“任务 + 请求”幂等;相同请求重放不重复修改任务或增加 attempt。
- Agent 详情显示已尝试次数与上次失败原因。确认界面必须说明同一任务使用最新规则重跑、可能创建待付款订单且系统不会支付。
- “继续采购”与普通“重试采购”语义不同:替代商品匹配完成后的“继续采购”继续调用既有 `AgentRetry → BatchRetry → Create`,保留旧任务并按当前替代商品档案创建新任务;Admin 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。
- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -331,12 +334,16 @@ synchronized_at: 2026-09-01T09:55:26Z
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
## 创建采购任务时的异步规格匹配(#148)
## 历史:创建采购任务时的异步规格匹配(#148,#215 后仅兼容存量)
- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。
- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。
- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。
- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。
- #215 起,新建 SYB 采购任务不再根据 PDD 档案创建 `purchase_spec_match_work_item`,也不在首次派发前调用外部 AI;规格决策统一来自首趟真机探测的当次候选。
- 部署前已存在的匹配工作项继续保留审计、恢复、重试和人工处理能力;其 AI 封闭候选、置信度、输入指纹、退避和最长 60 秒调用边界不变,但不能成为新任务跳过当次探测的依据。
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
## Agent 手动采集替代商品(#130)
@@ -386,6 +393,13 @@ synchronized_at: 2026-09-01T09:55:26Z
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
## Android 采购规格动作安全执行(#214)
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
- 一个 PDD 商品可被多个虾皮商品共用。PDD 详情返回全部关联虾皮商品摘要;订单行由独立统一分页接口读取,不按虾皮商品伪造嵌套分页。
@@ -393,3 +407,25 @@ synchronized_at: 2026-09-01T09:55:26Z
- 规格映射摘要使用 `specMappingStatus=complete|incomplete|not_required`、确认数和总数。`not_required` 只表示虾皮商品没有颜色或尺码维度;商品摘要不替代订单行采购资格。
- “关联订单继续采购”只处理已有 SYB 订单;“创建备货采购”不关联 SYB 订单。两者入口和文案必须明确区分。继续采购复用既有批量预检和批量创建,价格、映射、并发、订单和不可逆门禁不变。
- SYB 订单号、目标颜色/尺码、数量和当前采购任务状态只允许管理员与采购员读取;其他角色由服务端拒绝。查询路径不调用 AI Provider。
## 定时与手动蝦皮规格自动匹配(#195)
- 系统只扫描存活且已关联 `active` PDD 商品、两边至少共享颜色或尺码角色、并且至少存在一个未确认或已失效映射的蝦皮商品。
- 每个商品复用详情页“一键匹配颜色和尺码”的规则:保留当前仍有效的 `confirmed` 映射;唯一确定匹配和达到服务端阈值、理由非空、候选仍有效的 AI 结果直接保存为 `confirmed`;低置信度、无结果、Provider 异常或上下文漂移不猜测、不写入错误映射。
- 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。
- Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
- 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。
## SYB 异常采购规格 AI 解析(#198)
- SYB 采购规格采用两阶段流程:先把货运单明细 `productSpec` 解析为目标颜色/尺码,再由 #195 把蝦皮规格匹配到 PDD 规格;两阶段不得混为同一匹配事实。
- 定时任务只处理未人工确认且确定性解析为 `uncertain` / `failed` 的明细。每条先重跑确定性解析;空 `productSpec`、无关联蝦皮商品、无颜色/尺码候选或同一角色存在多个候选维度时不调用 AI,继续人工处理。
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
## 设备身份恢复
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
+28 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 0bb8d63fe2a3c6b1021bfa18800f5062de726868
synchronized_at: 2026-08-31T16:06:55Z
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚
- 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。
- 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。
### PowerShell 语法与外部命令
- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。
- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开:
```powershell
$script = @'
print("保持原文")
'@
$script | python -
```
- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式:
```powershell
$items = @(foreach ($path in $paths) {
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
})
```
- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数:
```powershell
rg -n -g '*.md' 'sync --check' docs
```
### 文件编码与控制台输出
文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码:
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b7d5520da954c544248e979c4ababb86d2784a43
synchronized_at: 2026-08-27T09:38:36Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584
synchronized_at: 2026-08-28T03:35:05Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: 0d9924b4566e567edf167ba678fedde28a41cfd3
synchronized_at: 2026-08-28T02:27:12Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+50 -30
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: c214791a0b055e5b56c0ad05e73829a1f141dd46
synchronized_at: 2026-09-01T08:03:51Z
wiki_revision: 08a65eb49d3d1091f0be30e6f6d1a9372f6e5b37
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
@@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
@@ -493,6 +493,16 @@ POST /api/agent/v1/tasks/{taskId}/fail
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### Android 规格动作执行边界(#214)
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
### 管理端接口
| 方法 | 路径 | 幂等键 / 说明 |
@@ -501,7 +511,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与长期规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务且不调用 AI Provider。自 #215 起,长期映射缺失或失效不再单独使 `eligible=false`;新任务会在首趟当次 PDD 页面探测中决定执行规格 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 |
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
@@ -519,17 +529,17 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -607,12 +617,12 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157)
## Agent 受控采购重试(#95、#157、#217)
普通失败任务的“重试采购”改为就地重跑:
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
POST /api/agent/v1/purchase-tasks/{taskId}/retry
Authorization: Bearer <device-token>
Content-Type: application/json
@@ -624,37 +634,35 @@ Content-Type: application/json
```json
{
"data": {
"taskId": 12,
"taskNo": "CG-12",
"attemptNumber": 2,
"status": "pending",
"sourceTaskId": 12,
"sourceTaskNo": "CG-12",
"taskId": 13,
"taskNo": "CG-13",
"replayed": false
}
}
```
- 原任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得恢复为待执行。
- 服务端在同一事务锁定任务和设备,确认同一 SYB 商品没有更新任务、设备在线且空闲,然后读取当前服务端采购规则,重新校验 schema、动作安全边界与设备能力。
- 成功时复用原 `purchase_task.id`,只刷新 `ruleSnapshot`、`ruleType`、`ruleSchemaVersion`、`requiredCapabilities`,清除错误、租约和运行守卫并恢复 `pending`。商品、Target、Mapped、价格保护、数量、地址后缀及其他业务快照逐字段保持不变。
- 每次受理创建该任务的新 `pending` attempt 并记录规则哈希;Start 复用该 attempt 转为 `running`,不会重复创建执行记录。相同 `requestId` 重放返回相同 attempt 且 `replayed=true`;不同请求可在任务再次失败后继续重试,不限制次数。
- 采购详情新增 `attemptCount`、可选 `lastFailureCode` 和 `lastFailureMessage`,供 Agent 显示已尝试次数与上次失败原因;不返回规则快照、Token、地址、控件树或截图。
- Android 仍只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认文案必须说明任务号不变、使用最新规则重跑、可能产生待付款订单且系统不会支付。
- 规则无效、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
既有新建任务接口保留原语义:
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
POST /api/agent/v1/purchase-tasks/{taskId}/reset
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。
- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。
- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
@@ -845,3 +853,15 @@ file=<JPEG 二进制>
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
## 设备身份恢复(#201)
```http
POST /api/admin/v1/devices/{deviceId}/identity-reset
POST /api/agent/v1/register
X-GoAuto-Device-Recovery-Code: <one-time-code>
```
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: 0eed28dd5dc629ce66071833d5fefbdf24d567d3
synchronized_at: 2026-08-24T02:35:58Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: 5b74a3b915cda6307cd59087bf9ce295f4c5e806
synchronized_at: 2026-08-21T08:08:57Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 一加真机验收记录
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: PDD-Detail-Rule-Migration-Analysis
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
wiki_revision: 1fe9ed083c4fc565a50b4186571233d1dfab58a7
synchronized_at: 2026-08-17T03:28:38Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+20 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: f596931468caf524cbd9d90b3c299b32a8cf8228
synchronized_at: 2026-08-28T02:29:07Z
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -393,19 +393,32 @@ POST /am/stock/detail/listByStock?hist=0
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
### 6.3 `productSpec` 的角色顺序不是固定的
```text
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
均碼,黑色
```
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与
`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。
`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧
恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为
尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或
跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容,
并继续执行原有复杂分隔符和空值校验。
`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入
虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、
映射及历史采购任务保持不变。
`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD
精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或
规格比对不明确时保持存疑,不得跨颜色/尺码猜测。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ SpecKey() ──→ 稳定规格身份键
+30 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
synchronized_at: 2026-08-31T16:07:03Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -39,6 +39,34 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
## 蝦皮规格自动匹配任务运维(#195)
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
## SYB 异常规格 AI 定时任务(#198)
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
## 定时任务执行历史运维(#199)
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a
synchronized_at: 2026-08-31T16:06:14Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:06:45Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: 389f09d292b0aa408f26f1967e33eec82d37cedf
synchronized_at: 2026-08-27T09:39:50Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: 53927b3b69fa26943f323f9be53153f80ed54530
synchronized_at: 2026-08-24T08:23:07Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+2
View File
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
+3
View File
@@ -47,6 +47,9 @@ var AdminAPIs = []APIPermission{
{"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true},
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
+15 -4
View File
@@ -15,10 +15,12 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
@@ -62,3 +64,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
}
t.Fatal("missing purchaser batch spec match permission")
}
func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) {
for _, permission := range PurchaserAPIs() {
if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" {
return
}
}
t.Fatal("missing purchaser shopee auto-match permission")
}
+96 -2
View File
@@ -25,12 +25,28 @@ const (
defaultAutoConfirmMinConfidence = 0.9
)
// MaxProviderTimeout is also the total budget used by composite synchronous
// AI operations. This keeps their HTTP response inside the Admin and API
// transport windows even when an operation needs more than one provider call.
const MaxProviderTimeout = 600 * time.Second
// Service owns the internal AI Provider configuration. The API key exception
// is deliberately narrow: it is plain text only in the dedicated settings
// table and is returned only by the administrator settings handler.
type Service struct {
DB *gorm.DB
HTTPClient *http.Client
DB *gorm.DB
HTTPClient *http.Client
ProviderFailureLogger func(ProviderFailureDiagnostic)
}
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
// candidates, response body or credential. It is safe for operational logs.
type ProviderFailureDiagnostic struct {
CallID string
Operation string
Kind string
StatusCode int
Duration time.Duration
}
func NewService(db *gorm.DB) *Service {
@@ -179,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
return result, nil
}
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
// every non-empty answer must be an exact member of the supplied Shopee set.
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return SYBSpecParseResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
{Role: "user", Content: sybSpecParsePrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
}
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
}
return SYBSpecParseResult{
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
}, nil
}
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
@@ -294,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
return false
}
func validClosedChoice(selected string, candidates []string) bool {
selected = strings.TrimSpace(selected)
if len(candidates) == 0 {
return selected == ""
}
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func safeReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -316,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
return string(raw)
}
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
payload := struct {
ProductSpec string `json:"productSpec"`
Colors []string `json:"shopeeColorCandidates,omitempty"`
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
}{request.ProductSpec, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -2,6 +2,7 @@ package aimatching
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
t.Fatalf("invalid threshold err=%v", err)
}
}
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
var sent map[string]any
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &sent); err != nil {
t.Fatal(err)
}
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
t.Fatalf("result=%+v err=%v", result, err)
}
encoded, _ := json.Marshal(sent)
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
}
}
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
if !strings.Contains(string(encoded), required) {
t.Fatalf("provider payload missing %q: %s", required, encoded)
}
}
}
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
responses := []string{
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
}
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
body := responses[0]
responses = responses[1:]
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
for i := 0; i < 2; i++ {
_, err := service.ResolveSYBSpec(context.Background(), request)
var target *Error
if !errors.As(err, &target) || target.Code != CodeNoMatch {
t.Fatalf("attempt %d err=%v", i, err)
}
}
}
+28 -1
View File
@@ -10,6 +10,9 @@ import (
"net/http"
"strings"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
)
// Suggestion limits are enforced defensively here too, even though callers
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
callID, startedAt := uuid.NewString(), time.Now()
response, err := s.httpClient().Do(httpRequest)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 1<<20)
responseBody, readErr := io.ReadAll(limited)
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if readErr != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
raw, err := parseSuggestChoices(responseBody)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
}
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
}
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
if s.ProviderFailureLogger != nil {
s.ProviderFailureLogger(diagnostic)
return
}
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
}
func providerNetworkErrorKind(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "network_error"
}
func suggestSystemPrompt(dimension string) string {
noun := "颜色或尺码"
switch dimension {
@@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
}
}
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, sensitiveBody, http.StatusBadGateway)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
})
if err == nil {
t.Fatal("expected provider failure")
}
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
}
printed := fmt.Sprintf("%+v", diagnostic)
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
if strings.Contains(printed, secret) {
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
}
}
}
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, "http://provider.invalid")
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
const sensitiveBody = "not-json-with-sensitive-provider-details"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(sensitiveBody))
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2100 * time.Millisecond)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+18
View File
@@ -29,6 +29,24 @@ type MatchRequest struct {
Sizes []string
}
// SYBSpecParseRequest contains the only source text and closed Shopee
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
// never contain the shipment/order, account, address, price or full raw JSON.
type SYBSpecParseRequest struct {
ProductSpec string
Colors []string
Sizes []string
}
type SYBSpecParseResult struct {
Color string
Size string
Provider string
Model string
Reason string
Confidence *float64
}
type CandidateSnapshot struct {
Colors []string `json:"colors,omitempty"`
Sizes []string `json:"sizes,omitempty"`
+4
View File
@@ -34,7 +34,11 @@ func MigratedModels() []any {
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
&models.ShopeeSpecAutoMatchRun{},
&models.ShopeeSpecAutoMatchWorkItem{},
&models.SYBProduct{},
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBSyncRun{},
+9
View File
@@ -533,6 +533,15 @@ type SYBProduct struct {
// recording the parser's own last output for audit even after a manual
// correction; it is not overwritten by the correction itself.
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
// deterministic parser's audit result, while these fields record a closed-
// candidate, high-confidence AI decision. Human correction always clears
// and supersedes this decision.
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
AIConfidence *float64 `json:"aiConfidence,omitempty"`
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
// RawJSON is the untouched `details[]` element as SYB returned it. It is
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -0,0 +1,56 @@
package models
import "time"
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
// ActiveSlot is 1 only while running; its nullable unique index is the
// database-level cross-process mutex shared by both trigger paths.
type ShopeeSpecAutoMatchRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
RequestedBy *uint64 `json:"requestedBy,omitempty"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
// state for each product, preventing unchanged low-confidence inputs from
// repeatedly spending AI calls.
type ShopeeSpecAutoMatchWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
return "shopee_spec_auto_match_work_item"
}
@@ -0,0 +1,48 @@
package models
import "time"
// SYBSpecAIParseRun is one globally serialized scheduled batch.
type SYBSpecAIParseRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
// spending provider calls and owns the per-row recovery lease.
type SYBSpecAIParseWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
@@ -32,7 +32,41 @@ type skuCombinationRow struct {
}
func sybSpecsTrusted(syb models.SYBProduct) bool {
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed || syb.AIConfirmed
}
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
if !syb.AIConfirmed {
return true
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
return false
}
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
foundAny := false
for role, target := range wanted {
if target == "" {
continue
}
foundAny = true
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
found = true
break
}
}
}
if !found {
return false
}
}
return foundAny
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
+4
View File
@@ -406,6 +406,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
+22
View File
@@ -44,6 +44,28 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
}
}
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
if !sybSpecsTrusted(row) {
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
}
if row.ManuallyConfirmed {
t.Fatal("AI confirmation must not be represented as human confirmation")
}
}
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
if aiConfirmedSpecsCurrent(row, product) {
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
}
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
if !aiConfirmedSpecsCurrent(row, product) {
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
}
}
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
+1 -1
View File
@@ -137,7 +137,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
}
if syb.ParseStatus == models.SYBParseStatusFailed {
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
}
if syb.ShopeeProductID == nil {
@@ -0,0 +1,306 @@
package shopeeproduct
import (
"context"
"errors"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AutoMatchRequest struct {
RequestID string `json:"requestId"`
SpecContextVersion string `json:"specContextVersion"`
}
type AutoMatchItem struct {
Dimension string `json:"dimension"`
Role string `json:"role"`
ValueName string `json:"valueName"`
PDDValue string `json:"pddValue,omitempty"`
Status string `json:"status"` // confirmed | preserved | unmatched
Source string `json:"source,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Reason string `json:"reason,omitempty"`
}
type AutoMatchResponse struct {
Product ProductView `json:"product"`
Items []AutoMatchItem `json:"items"`
ConfirmedCount int `json:"confirmedCount"`
PreservedCount int `json:"preservedCount"`
UnmatchedCount int `json:"unmatchedCount"`
Replayed bool `json:"replayed"`
}
type autoMatchRef struct {
dimension string
valueName string
}
// AutoMatchMappings resolves both color and size values, then persists every
// accepted result as confirmed in one transaction. Provider calls happen
// before the transaction; the transaction rechecks the complete spec context
// and PDD candidate set so a stale decision can never be written.
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
if _, err := uuid.Parse(requestID); err != nil {
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
}
requestVersion := strings.TrimSpace(request.SpecContextVersion)
if requestVersion == "" {
return AutoMatchResponse{}, contextVersionStale()
}
db := service.DB.WithContext(ctx)
var replay models.ShopeeProduct
if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil {
return service.autoMatchReplay(ctx, id)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return AutoMatchResponse{}, internalError(err)
}
var shopee models.ShopeeProduct
if err := db.First(&shopee, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return AutoMatchResponse{}, productNotFound()
}
return AutoMatchResponse{}, internalError(err)
}
if shopee.PDDProductID == nil {
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"}
}
var pdd models.PDDProduct
if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"}
}
return AutoMatchResponse{}, internalError(err)
}
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion {
return AutoMatchResponse{}, contextVersionStale()
}
initialSpecs, err := Unmarshal(shopee.SpecsJSON)
if err != nil {
return AutoMatchResponse{}, internalError(err)
}
items := make([]AutoMatchItem, 0)
for _, role := range []string{RoleColor, RoleSize} {
refs := autoMatchRefs(initialSpecs, role)
if len(refs) == 0 {
continue
}
preview, err := service.suggestMappings(ctx, id, requestVersion, role)
if err != nil {
return AutoMatchResponse{}, err
}
if len(preview.Items) != len(refs) {
return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致"))
}
for index, suggestion := range preview.Items {
item := AutoMatchItem{
Dimension: refs[index].dimension,
Role: role,
ValueName: refs[index].valueName,
PDDValue: suggestion.PDDValue,
Confidence: suggestion.Confidence,
Reason: strings.TrimSpace(suggestion.Reason),
Status: "unmatched",
}
switch suggestion.Status {
case "preserved":
item.Status = "preserved"
case "matched":
item.Status = "confirmed"
item.Source = MappingSourceExactMatch
case "ai_matched":
if suggestion.Confidence != nil && item.Reason != "" {
item.Status = "confirmed"
item.Source = MappingSourceAIMatch
}
}
items = append(items, item)
}
}
replayed := false
err = db.Transaction(func(tx *gorm.DB) error {
var current models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return productNotFound()
}
return internalError(err)
}
if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID {
replayed = true
return nil
}
if current.PDDProductID == nil || *current.PDDProductID != pdd.ID {
return contextVersionStale()
}
var currentPDD models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&currentPDD, pdd.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return contextVersionStale()
}
return internalError(err)
}
if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion {
return contextVersionStale()
}
currentSpecs, err := Unmarshal(current.SpecsJSON)
if err != nil {
return internalError(err)
}
candidates := map[string]map[string]bool{}
for _, role := range []string{RoleColor, RoleSize} {
values, err := selectablePDDValues(currentPDD.SpecsJSON, role)
if err != nil {
return internalError(err)
}
candidates[role] = values
}
needsAISettings := false
for _, item := range items {
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch {
needsAISettings = true
break
}
}
if needsAISettings {
settings, err := aimatching.NewService(tx).Settings(ctx)
if err != nil {
return internalError(err)
}
if !settings.Enabled {
return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
}
for index := range items {
item := &items[index]
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch &&
(item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") {
item.Status, item.Source = "unmatched", ""
}
}
}
plans := make(map[string]AutoMatchItem, len(items))
for _, item := range items {
plans[item.Dimension+"\x00"+item.ValueName] = item
}
for dimensionIndex, dimension := range currentSpecs {
if dimension.Role != RoleColor && dimension.Role != RoleSize {
continue
}
for valueIndex, value := range dimension.Values {
item, ok := plans[dimension.Name+"\x00"+value.Name]
if !ok || item.Status != "confirmed" {
continue
}
if !candidates[dimension.Role][item.PDDValue] {
return contextVersionStale()
}
currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{
PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed,
Confidence: item.Confidence, Reason: item.Reason,
}
}
}
if err := Validate(currentSpecs); err != nil {
return invalidRequest(err.Error())
}
specsJSON, err := Marshal(currentSpecs)
if err != nil {
return internalError(err)
}
if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
"specs_json": specsJSON, "last_update_request_id": requestID,
}).Error; err != nil {
return internalError(err)
}
if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil {
return internalError(err)
}
return nil
})
if err != nil {
return AutoMatchResponse{}, err
}
if replayed {
return service.autoMatchReplay(ctx, id)
}
response, err := service.autoMatchResponse(ctx, id, items)
if err != nil {
return AutoMatchResponse{}, err
}
return response, nil
}
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
refs := make([]autoMatchRef, 0)
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name})
}
}
return refs
}
func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) {
detail, err := service.Detail(ctx, id)
if err != nil {
return AutoMatchResponse{}, err
}
response := AutoMatchResponse{Product: detail.Product, Items: items}
for _, item := range items {
switch item.Status {
case "confirmed":
response.ConfirmedCount++
case "preserved":
response.PreservedCount++
default:
response.UnmatchedCount++
}
}
return response, nil
}
func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) {
detail, err := service.Detail(ctx, id)
if err != nil {
return AutoMatchResponse{}, err
}
response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true}
for _, dimension := range detail.Product.Specs {
if dimension.Role != RoleColor && dimension.Role != RoleSize {
continue
}
for _, value := range dimension.Values {
item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"}
if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed {
item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source
item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason
response.PreservedCount++
} else {
response.UnmatchedCount++
}
response.Items = append(response.Items, item)
}
}
return response, nil
}
@@ -0,0 +1,360 @@
package shopeeproduct
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
defaultAutoMatchBatchLimit = 20
autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3
)
type AutoMatchRunView struct {
models.ShopeeSpecAutoMatchRun
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
// StartAutoMatchRun acquires the single database-backed activity slot. A
// repeated requestId is idempotent; a concurrent trigger receives the current
// run instead of starting a second batch.
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
}
if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
}
if batchLimit <= 0 {
batchLimit = defaultAutoMatchBatchLimit
}
if batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
owner := uuid.NewString()
one := uint8(1)
var result models.ShopeeSpecAutoMatchRun
created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
result.ActiveSlot = &one
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
if err := tx.Create(&result).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
// A unique-slot race means another instance won after our read. Return
// its run as the stable, non-error result.
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
}
return AutoMatchRunView{}, false, internalError(err)
}
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
if !created {
view.AlreadyRunning = result.RequestID != requestID
view.Replayed = result.RequestID == requestID
}
return view, created, nil
}
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
var run models.ShopeeSpecAutoMatchRun
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, internalError(err)
}
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
}
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
// HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit
}
var candidates []models.ShopeeProduct
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
if err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL").
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, product := range candidates {
if processed >= limit {
break
}
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !claimed {
continue
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
postFingerprint = next
}
if matchErr != nil {
failed++
if firstError == "" {
firstError = safeBatchError(matchErr)
}
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
continue
}
confirmed += response.ConfirmedCount
unmatched += response.UnmatchedCount
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
if product.PDDProductID == nil {
return "", false, nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
}
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil {
return "", false, err
}
shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil {
return "", false, err
}
if len(pddValues) == 0 {
continue
}
for _, dimension := range shopeeSpecs {
if dimension.Role != role || len(dimension.Values) == 0 {
continue
}
shared = true
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true
}
}
}
}
if !shared || !needsMatch {
return "", false, nil
}
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time }
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input.
return contextVersion + hex.EncodeToString(h[:]), true, nil
}
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
var errWorkNotClaimed = errors.New("auto match work not claimed")
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil {
if unmatched > 0 {
updates["status"] = "unmatched"
} else {
updates["status"] = "completed"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := batchErrorCode(matchErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
next := now.Add(autoMatchRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
}
func batchErrorCode(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return serviceErr.Code
}
return CodeInternal
}
func safeBatchError(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return truncateBatchText(serviceErr.Message)
}
return "服务端处理失败"
}
func truncateBatchText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledAutoMatchArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledAutoMatchJob struct{}
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
return errors.New("规格自动匹配定时任务缺少数据库连接")
}
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
}
service := NewService(db)
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
if err != nil || !created {
return err
}
return service.ProcessAutoMatchRun(context.Background(), run.ID)
}
@@ -0,0 +1,86 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
requestID := uuid.NewString()
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || !created {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
}
}
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
service := NewService(db)
createdProduct, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
})
if err != nil {
t.Fatal(err)
}
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil || !started {
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
}
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
t.Fatal(err)
}
latest, err := service.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
t.Fatalf("latest=%+v", latest)
}
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
if err != nil {
t.Fatal(err)
}
mapping := detail.Product.Specs[0].Values[0].Mapping
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
t.Fatalf("mapping=%+v", mapping)
}
}
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
one := uint8(1)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
}
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
}
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
if err != nil || !claimed {
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
}
}
@@ -0,0 +1,179 @@
package shopeeproduct
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) {
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`))
defer provider.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID,
Specs: []SpecDimension{
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}},
},
})
if err != nil {
t.Fatal(err)
}
requestID := uuid.NewString()
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
if err != nil {
t.Fatalf("auto match: %v", err)
}
if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 {
t.Fatalf("unexpected counts: %+v", response)
}
color := response.Product.Specs[0].Values[0].Mapping
size := response.Product.Specs[1].Values[0].Mapping
if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" {
t.Fatalf("color not auto-confirmed: %+v", color)
}
if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" {
t.Fatalf("size not auto-confirmed: %+v", size)
}
replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 {
t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err)
}
}
func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) {
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`))
defer provider.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID,
Specs: []SpecDimension{
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}},
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}},
},
})
if err != nil {
t.Fatal(err)
}
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
if err != nil {
t.Fatal(err)
}
if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil {
t.Fatalf("low confidence color was persisted: %+v", response)
}
if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed {
t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1])
}
}
func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unavailable", http.StatusBadGateway)
}))
defer provider.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable {
t.Fatalf("expected AI unavailable, got %v", err)
}
detail, err := service.Detail(context.Background(), created.Product.ID)
if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil {
t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err)
}
}
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
t.Fatalf("response is not structured: %s", recorder.Body.String())
}
}
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
call := calls.Add(1)
if call == 2 {
if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}})
}))
defer provider.Close()
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", PDDProductID: &pdd.ID,
Specs: []SpecDimension{
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "小号", Source: ValueSourceImport}}},
},
})
if err != nil {
t.Fatal(err)
}
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale {
t.Fatalf("expected stale context, got %v", err)
}
detail, err := service.Detail(context.Background(), created.Product.ID)
if err != nil {
t.Fatal(err)
}
for _, dimension := range detail.Product.Specs {
for _, value := range dimension.Values {
if value.Mapping != nil {
t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs)
}
}
}
}
@@ -1,6 +1,7 @@
package shopeeproduct
import (
"context"
"encoding/json"
"errors"
"io"
@@ -278,6 +279,67 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) AutoMatchMappings(c *gin.Context) {
id, ok := handler.pathID(c)
if !ok {
return
}
var request AutoMatchRequest
if err := decodeJSON(c, &request); err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
service, ok := handler.service(c)
if !ok {
return
}
response, err := service.AutoMatchMappings(c.Request.Context(), id, request)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
var request struct {
RequestID string `json:"requestId"`
}
if err := decodeJSON(c, &request); err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
service, ok := handler.service(c)
if !ok {
return
}
operator := currentUserID(c)
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
if err != nil {
writeError(c, err)
return
}
if created {
go func(runID uint64, db *gorm.DB) {
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
}(run.ID, service.DB)
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
service, ok := handler.service(c)
if !ok {
return
}
run, err := service.LatestAutoMatchRun(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) BatchDelete(c *gin.Context) {
var request BatchDeleteRequest
if err := decodeJSON(c, &request); err != nil {
@@ -10,6 +10,9 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.POST("/batch-delete", handler.BatchDelete)
@@ -26,4 +29,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches)
admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings)
admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings)
admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings)
}
@@ -0,0 +1,489 @@
package sybimport
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
defaultAIParseBatchLimit = 20
aiParseLeaseDuration = 30 * time.Minute
aiParseRetryDelay = time.Hour
maxAIParseAttempts = 3
)
var (
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
)
type aiParseInput struct {
ProductSpec string
Colors []string
Sizes []string
Fingerprint string
}
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
}
if batchLimit <= 0 {
batchLimit = defaultAIParseBatchLimit
}
if batchLimit > 100 {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
one := uint8(1)
owner := uuid.NewString()
var run models.SYBSpecAIParseRun
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.SYBSpecAIParseRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
run = models.SYBSpecAIParseRun{
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
return run, false, nil
}
return models.SYBSpecAIParseRun{}, false, err
}
return run, created, nil
}
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
var run models.SYBSpecAIParseRun
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAIParseBatchLimit
}
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
var candidates []models.SYBProduct
if err := db.WithContext(ctx).
Where("manually_confirmed = ?", false).
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, candidate := range candidates {
if processed >= limit {
break
}
if candidate.AIConfirmed {
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
if currentErr != nil {
failed++
continue
}
if current {
continue
}
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
failed++
continue
}
candidate.AIConfirmed = false
}
outcome, err := Reparse(ctx, db, candidate.ID, false)
if err != nil {
failed++
if firstError == "" {
firstError = "确定性重新解析失败"
}
continue
}
if outcome.NewStatus == models.SYBParseStatusSuccess {
processed++
confirmed++
continue
}
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
failed++
continue
}
input, ok, err := buildAIParseInput(ctx, db, candidate)
if err != nil {
failed++
if firstError == "" {
firstError = "读取 AI 解析上下文失败"
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
if err != nil {
failed++
continue
}
if !claimed {
continue
}
processed++
renewSpecAIParseRun(db, run)
matcher := aimatching.NewService(db)
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
})
if matchErr == nil {
settings, settingsErr := matcher.Settings(ctx)
if settingsErr != nil {
matchErr = settingsErr
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
} else {
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
}
}
if matchErr != nil {
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
}
failed++
if firstError == "" {
firstError = safeAIParseError(matchErr)
}
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
continue
}
confirmed++
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
return aiParseInput{}, false, nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
return aiParseInput{}, false, err
}
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
if raw.ProductSpec == "" {
return aiParseInput{}, false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return aiParseInput{}, false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
return aiParseInput{}, false, nil
}
var setting models.AIMatchingSetting
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
return aiParseInput{}, false, nil
}
fingerprintPayload := struct {
ProductSpec string
ShopeeProductID uint64
ShopeeSpecsJSON string
SettingUpdatedAt string
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
encoded, _ := json.Marshal(fingerprintPayload)
hash := sha256.Sum256(encoded)
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
}
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
if !record.AIConfirmed || record.ShopeeProductID == nil {
return false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous {
return false, nil
}
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
}
func closedCandidateContains(value string, candidates []string) bool {
value = strings.TrimSpace(value)
if len(candidates) == 0 {
return value == ""
}
for _, candidate := range candidates {
if candidate == value {
return true
}
}
return false
}
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
roleDimensions := map[string]int{}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
continue
}
values := make([]string, 0, len(dimension.Values))
seen := map[string]bool{}
for _, value := range dimension.Values {
name := strings.TrimSpace(value.Name)
if name != "" && !seen[name] {
seen[name] = true
values = append(values, name)
}
}
if len(values) == 0 {
continue
}
roleDimensions[dimension.Role]++
if roleDimensions[dimension.Role] > 1 {
return nil, nil, true
}
if dimension.Role == shopeeproduct.RoleColor {
colors = values
} else {
sizes = values
}
}
return colors, sizes, false
}
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
now := time.Now().UTC()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record models.SYBProduct
if err := tx.First(&record, id).Error; err != nil {
return err
}
input, ok, err := buildAIParseInput(ctx, tx, record)
if err != nil {
return err
}
if !ok || input.Fingerprint != fingerprint {
return errAIParseInputChanged
}
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
return errAIParseInputChanged
}
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
"target_color": result.Color, "target_size": result.Size,
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
})
if write.Error != nil {
return write.Error
}
if write.RowsAffected != 1 {
return errAIParseInputChanged
}
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
})
}
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
var work models.SYBSpecAIParseWorkItem
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errAIParseWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errAIParseWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
if parseErr == nil {
if confirmed {
updates["status"] = "completed"
} else {
updates["status"] = "unmatched"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := aiParseErrorCode(parseErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
next := now.Add(aiParseRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
lease := time.Now().UTC().Add(aiParseLeaseDuration)
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
"error_summary": truncateAIParseText(summary), "finished_at": now,
}).Error
}
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
func aiParseErrorCode(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return target.Code
}
return CodeInternal
}
func safeAIParseError(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return truncateAIParseText(target.Message)
}
return "服务端处理失败"
}
func truncateAIParseText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledSpecAIParseArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledSpecAIParseJob struct{}
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
}
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
}
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
if err != nil || !created {
return err
}
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
}
@@ -0,0 +1,204 @@
package sybimport_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"github.com/google/uuid"
"gorm.io/gorm"
)
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
t.Helper()
detail := realDetailA()
detail.ID = detailID
detail.ProductSpec = productSpec
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
if err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
if err := db.Save(&setting).Error; err != nil {
t.Fatal(err)
}
return applied.SYBProduct
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start run: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
t.Fatalf("unexpected confirmed record: %+v", record)
}
if record.RawJSON != rawBefore {
t.Fatal("AI confirmation must not rewrite RawJSON")
}
var finished models.SYBSpecAIParseRun
if err := db.First(&finished, run.ID).Error; err != nil {
t.Fatal(err)
}
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
t.Fatalf("unexpected run: %+v", finished)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("second start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
}
}
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", i, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || calls.Load() != 1 {
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.Status != "unmatched" {
t.Fatalf("work status=%s", work.Status)
}
}
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
t.Fatal(err)
}
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 0 {
t.Fatalf("ineligible rows called provider %d times", calls.Load())
}
var workCount int64
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
t.Fatal(err)
}
if workCount != 0 {
t.Fatalf("ineligible rows created %d work items", workCount)
}
}
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
db := openTestDB(t)
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("first: created=%v err=%v", created, err)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || created || second.ID != first.ID {
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
}
}
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusBadGateway)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if attempt < 3 {
past := time.Now().UTC().Add(-time.Minute)
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
t.Fatal(err)
}
}
}
if calls.Load() != 3 {
t.Fatalf("provider calls=%d, want 3", calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
t.Fatalf("retry state=%+v", work)
}
}
+33 -2
View File
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
// Human-confirmed target values are authoritative and survive every
// source re-import. ParseStatus/ParseNote below still record what the
// current deterministic parser observed for audit.
if existing.ManuallyConfirmed {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
record.ManuallyConfirmed = true
}
updates := map[string]any{
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
"target_color": record.TargetColor, "target_size": record.TargetSize,
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
}).Error; err != nil {
}
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
if preserveAI {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
} else {
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
}
if existing.ManuallyConfirmed {
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
result.Outcome = OutcomeUpdated
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
return result, nil
}
func sameOptionalID(left, right *uint64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
// a soft-deleted match is revived (keeping its prior mapping), and only when
// neither exists does the import create a minimal archive. On an existing
+18 -3
View File
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
// result page's per-line feedback.
type ReparseOutcome struct {
SYBProductID uint64 `json:"sybProductId"`
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
}
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
const (
ReparseOutcomeReparsed = "reparsed"
ReparseOutcomeSkippedManual = "skipped_manual"
ReparseOutcomeSkippedAI = "skipped_ai"
ReparseOutcomeUnchanged = "unchanged"
)
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
outcome.NewStatus = record.ParseStatus
return nil
}
if record.AIConfirmed && !force {
outcome.Outcome = ReparseOutcomeSkippedAI
outcome.NewStatus = record.ParseStatus
return nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
outcome.Outcome = ReparseOutcomeUnchanged
if force {
record.ManuallyConfirmed = false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
record.ManuallyConfirmed, record.AIConfirmed = false, false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
}
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
updates := map[string]any{
"target_color": parsed.Color, "target_size": parsed.Size,
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
return err
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
return err
}
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
"target_color": color, "target_size": size, "manually_confirmed": true,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
+106
View File
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
}
}
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
}).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
}
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
t.Fatal(err)
}
var record models.SYBProduct
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
t.Fatalf("forced reparse retained AI state: %+v", record)
}
}
// 可勾选强制覆盖.
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
db := openTestDB(t)
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
}
}
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.96
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
}).Error; err != nil {
t.Fatal(err)
}
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
if err != nil {
t.Fatal(err)
}
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
}
}
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
}).Error; err != nil {
t.Fatal(err)
}
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !same.SYBProduct.AIConfirmed {
t.Fatal("identical re-import must preserve AI confirmation")
}
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
t.Fatal("identical re-import must preserve AI-confirmed target values")
}
detail.ProductSpec += " 新备注"
detail.Raw = []byte(`{"productSpec":"changed"}`)
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
}
}
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
detail.ProductSpec = "来源新颜色,来源新尺码"
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
}
}
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
// instead of the camelCase the rest of this API and the admin frontend use.
+87
View File
@@ -0,0 +1,87 @@
package apis
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
jobservice "go-admin/app/jobs/service"
)
func (e SysJob) ListExecutionLogs(c *gin.Context) {
jobID, err := strconv.Atoi(c.Param("id"))
if err != nil || jobID < 1 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "jobId 无效"})
return
}
page, err := positiveQueryInt(c.Query("pageIndex"), 1)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageIndex 必须是正整数"})
return
}
pageSize, err := positiveQueryInt(c.Query("pageSize"), 20)
if err != nil || pageSize > 100 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageSize 必须是 1 到 100 的整数"})
return
}
startedFrom, err := optionalTime(c.Query("startedFrom"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedFrom 必须是 RFC3339 时间"})
return
}
startedTo, err := optionalTime(c.Query("startedTo"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedTo 必须是 RFC3339 时间"})
return
}
e.MakeContext(c)
db, err := e.GetOrm()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
return
}
response, err := jobservice.NewExecutionLogService(db).List(c.Request.Context(), jobID, jobservice.ExecutionLogListRequest{
Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")),
StartedFrom: startedFrom, StartedTo: startedTo,
})
if err != nil {
switch {
case errors.Is(err, jobservice.ErrExecutionLogInvalidRequest):
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": err.Error()})
case errors.Is(err, jobservice.ErrExecutionLogJobNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "定时任务不存在"})
default:
e.GetLogger().Errorf("list scheduled job execution logs failed job_id=%d: %v", jobID, err)
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func positiveQueryInt(value string, fallback int) (int, error) {
if strings.TrimSpace(value) == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return 0, errors.New("invalid positive integer")
}
return parsed, nil
}
func optionalTime(value string) (*time.Time, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
+5 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
)
@@ -12,8 +13,10 @@ import (
// 字典 key 可以配置到 自动任务 调用目标 中;
func InitJob() {
jobList = map[string]JobExec{
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
// ...
}
}
+132
View File
@@ -0,0 +1,132 @@
package jobs
import (
"context"
"errors"
"fmt"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
const (
executionErrorTargetMissing = "JOB_TARGET_NOT_FOUND"
executionErrorExecFailed = "JOB_EXECUTION_FAILED"
executionErrorHTTPFailed = "JOB_HTTP_FAILED"
executionErrorPanicked = "JOB_EXECUTION_PANICKED"
executionErrorInterrupted = "JOB_INTERRUPTED"
)
type executionFailure struct {
code string
message string
cause error
}
func (failure *executionFailure) Error() string {
if failure.cause != nil {
return failure.cause.Error()
}
return failure.message
}
func newExecutionFailure(code, message string, cause error) error {
return &executionFailure{code: code, message: message, cause: cause}
}
func runWithExecutionLog(db *gorm.DB, core JobCore, execute func() error) (executionErr error) {
startedAt := time.Now().UTC()
record := &models.SysJobExecutionLog{
ExecutionID: uuid.NewString(), JobID: core.JobId,
JobNameSnapshot: core.Name, InvokeTargetSnapshot: core.InvokeTarget,
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning,
StartedAt: startedAt,
}
created := false
if db != nil {
if err := db.WithContext(context.Background()).Create(record).Error; err != nil {
log.Errorf("[Job] execution log create failed job_id=%d: %v", core.JobId, err)
} else {
created = true
}
}
defer func() {
if recovered := recover(); recovered != nil {
executionErr = newExecutionFailure(executionErrorPanicked, "任务执行异常中断", nil)
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
return
}
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
}()
executionErr = execute()
return executionErr
}
func finishExecutionLog(db *gorm.DB, jobID int, record *models.SysJobExecutionLog, startedAt time.Time, executionErr error) {
finishedAt := time.Now().UTC()
updates := map[string]any{
"status": models.JobExecutionSucceeded, "finished_at": finishedAt,
"duration_ms": finishedAt.Sub(startedAt).Milliseconds(), "error_code": "", "error_message": "",
}
if executionErr != nil {
code, message := publicExecutionFailure(executionErr)
updates["status"] = models.JobExecutionFailed
updates["error_code"] = code
updates["error_message"] = message
}
if err := db.WithContext(context.Background()).Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(updates).Error; err != nil {
log.Errorf("[Job] execution log finish failed job_id=%d execution_id=%s: %v", jobID, record.ExecutionID, err)
}
}
func publicExecutionFailure(err error) (string, string) {
var failure *executionFailure
if errors.As(err, &failure) {
return failure.code, failure.message
}
return executionErrorExecFailed, "任务执行失败,请查看受控服务日志"
}
// RecoverInterruptedExecutionLogs closes invocations left running by the
// previous process. Production currently runs one scheduler per database.
func RecoverInterruptedExecutionLogs(db *gorm.DB) error {
if db == nil {
return nil
}
now := time.Now().UTC()
var records []models.SysJobExecutionLog
if err := db.WithContext(context.Background()).Where("status = ?", models.JobExecutionRunning).Find(&records).Error; err != nil {
return err
}
return db.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
for _, record := range records {
duration := now.Sub(record.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
if err := tx.Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(map[string]any{
"status": models.JobExecutionInterrupted, "finished_at": now,
"duration_ms": duration, "error_code": executionErrorInterrupted,
"error_message": "服务重启前任务未完成",
}).Error; err != nil {
return err
}
}
return nil
})
}
func missingExecutionTarget(target string) error {
return newExecutionFailure(executionErrorTargetMissing, "任务调用目标未注册", fmt.Errorf("job target %q is not registered", target))
}
+113
View File
@@ -0,0 +1,113 @@
package jobs
import (
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func jobExecutionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestRunWithExecutionLogRecordsSuccessAndSanitizedFailure(t *testing.T) {
db := jobExecutionTestDB(t)
core := JobCore{JobId: 7, Name: "测试任务", InvokeTarget: "TestTarget"}
if err := runWithExecutionLog(db, core, func() error { return nil }); err != nil {
t.Fatal(err)
}
rawSecret := "token=secret-value productSpec=private"
if err := runWithExecutionLog(db, core, func() error { return errors.New(rawSecret) }); err == nil {
t.Fatal("failed execution must return its error")
}
var records []models.SysJobExecutionLog
if err := db.Order("id asc").Find(&records).Error; err != nil {
t.Fatal(err)
}
if len(records) != 2 || records[0].Status != models.JobExecutionSucceeded || records[1].Status != models.JobExecutionFailed {
t.Fatalf("unexpected records: %+v", records)
}
if records[1].ErrorCode != executionErrorExecFailed || records[1].ErrorMessage == rawSecret || records[1].ErrorMessage == "" {
t.Fatalf("failure was not safely summarized: %+v", records[1])
}
if records[0].FinishedAt == nil || records[1].FinishedAt == nil || records[0].ExecutionID == records[1].ExecutionID {
t.Fatal("execution lifecycle or unique IDs were not recorded")
}
}
func TestRecoverInterruptedExecutionLogs(t *testing.T) {
db := jobExecutionTestDB(t)
started := time.Now().UTC().Add(-2 * time.Second)
record := models.SysJobExecutionLog{
ExecutionID: "00000000-0000-4000-8000-000000000010", JobID: 9,
JobNameSnapshot: "中断任务", InvokeTargetSnapshot: "Interrupted",
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, StartedAt: started,
}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
if err := RecoverInterruptedExecutionLogs(db); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionInterrupted || record.FinishedAt == nil || record.ErrorCode != executionErrorInterrupted || record.DurationMS < 1000 {
t.Fatalf("record was not safely interrupted: %+v", record)
}
}
func TestMissingExecutionTargetHasPublicSafeMessage(t *testing.T) {
code, message := publicExecutionFailure(missingExecutionTarget("SecretTarget"))
if code != executionErrorTargetMissing || message != "任务调用目标未注册" {
t.Fatalf("unexpected target failure %s %s", code, message)
}
}
func TestExecJobRecordsMissingTargetAsFailure(t *testing.T) {
db := jobExecutionTestDB(t)
previousJobList := jobList
jobList = map[string]JobExec{}
t.Cleanup(func() { jobList = previousJobList })
job := &ExecJob{JobCore: JobCore{JobId: 10, Name: "未注册任务", InvokeTarget: "MissingTarget"}, DB: db}
job.Run()
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorTargetMissing || record.ErrorMessage != "任务调用目标未注册" {
t.Fatalf("missing target was not safely recorded: %+v", record)
}
}
func TestRunWithExecutionLogSafelyRecordsPanic(t *testing.T) {
db := jobExecutionTestDB(t)
err := runWithExecutionLog(db, JobCore{JobId: 11, Name: "异常任务", InvokeTarget: "Panic"}, func() error {
panic("provider-secret")
})
if code, message := publicExecutionFailure(err); code != executionErrorPanicked || message != "任务执行异常中断" {
t.Fatalf("panic was not returned as a safe failure: %s %s", code, message)
}
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorPanicked || record.ErrorMessage != "任务执行异常中断" {
t.Fatalf("panic was not safely persisted: %+v", record)
}
}
+33 -22
View File
@@ -33,6 +33,7 @@ type JobCore struct {
// HttpJob 任务类型 http
type HttpJob struct {
JobCore
DB *gorm.DB
}
type ExecJob struct {
@@ -42,14 +43,16 @@ type ExecJob struct {
func (e *ExecJob) Run() {
startTime := time.Now()
var obj = jobList[e.InvokeTarget]
if obj == nil {
log.Warn("[Job] ExecJob Run job nil")
return
}
err := CallExecWithDB(obj.(JobExec), e.DB, e.Args)
err := runWithExecutionLog(e.DB, e.JobCore, func() error {
obj := jobList[e.InvokeTarget]
if obj == nil {
return missingExecutionTarget(e.InvokeTarget)
}
return CallExecWithDB(obj, e.DB, e.Args)
})
if err != nil {
log.Errorf("[Job] JobCore %s failed: %v", e.Name, err)
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", e.Name, code)
return
}
// 结束时间
@@ -68,22 +71,26 @@ func (e *ExecJob) Run() {
func (h *HttpJob) Run() {
startTime := time.Now()
var count = 0
var err error
var str string
/* 循环 */
LOOP:
if count < retryCount {
/* 跳过迭代 */
str, err = pkg.Get(h.InvokeTarget)
if err != nil {
// 如果失败暂停一段时间重试
log.Warnf("[Job] mission failed! %v", err)
log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
count = count + 1
goto LOOP
err := runWithExecutionLog(h.DB, h.JobCore, func() error {
var lastErr error
for count := 0; count < retryCount; count++ {
_, requestErr := pkg.Get(h.InvokeTarget)
if requestErr == nil {
return nil
}
lastErr = requestErr
log.Warnf("[Job] HTTP mission failed attempt=%d", count+1)
if count+1 < retryCount {
log.Warnf("[Job] Retry after the task fails %d seconds!\n", (count+1)*5)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
}
}
return newExecutionFailure(executionErrorHTTPFailed, "HTTP 任务请求失败", lastErr)
})
if err != nil {
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", h.Name, code)
return
}
// 结束时间
endTime := time.Now()
@@ -102,6 +109,9 @@ func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
for k, db := range dbs {
if err := RecoverInterruptedExecutionLogs(db); err != nil {
log.Errorf("[Job] recover interrupted execution logs failed: %v", err)
}
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
setup(k, db)
}
@@ -127,6 +137,7 @@ func setup(key string, db *gorm.DB) {
for i := 0; i < len(jobList); i++ {
if jobList[i].JobType == 1 {
j := &HttpJob{}
j.DB = db
j.InvokeTarget = jobList[i].InvokeTarget
j.CronExpression = jobList[i].CronExpression
j.JobId = jobList[i].JobId
@@ -0,0 +1,32 @@
package models
import "time"
const (
JobExecutionRunning = "running"
JobExecutionSucceeded = "succeeded"
JobExecutionFailed = "failed"
JobExecutionInterrupted = "interrupted"
JobTriggerScheduled = "scheduled"
)
// SysJobExecutionLog stores one scheduler invocation. Job arguments and raw
// provider responses are deliberately excluded from this audit record.
type SysJobExecutionLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ExecutionID string `json:"executionId" gorm:"size:36;not null;uniqueIndex:ux_sys_job_execution_id"`
JobID int `json:"jobId" gorm:"not null;index:idx_sys_job_execution_job_started,priority:1"`
JobNameSnapshot string `json:"jobName" gorm:"size:255;not null"`
InvokeTargetSnapshot string `json:"invokeTarget" gorm:"size:255;not null"`
TriggerType string `json:"triggerType" gorm:"size:16;not null"`
Status string `json:"status" gorm:"size:16;not null;index:idx_sys_job_execution_status_started,priority:1"`
StartedAt time.Time `json:"startedAt" gorm:"not null;index:idx_sys_job_execution_job_started,priority:2;index:idx_sys_job_execution_status_started,priority:2"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs" gorm:"not null;default:0"`
ErrorCode string `json:"errorCode,omitempty" gorm:"size:64;not null;default:''"`
ErrorMessage string `json:"errorMessage,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SysJobExecutionLog) TableName() string { return "sys_job_execution_log" }
+2
View File
@@ -24,6 +24,8 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
list := make([]models2.SysJob, 0)
return &list
}))
jobAPI := apis.SysJob{}
r.GET("/:id/execution-logs", actions.PermissionAction(), jobAPI.ListExecutionLogs)
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} {
return &dto2.SysJobItem{}
}))
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
var (
ErrExecutionLogInvalidRequest = errors.New("invalid execution log request")
ErrExecutionLogJobNotFound = errors.New("scheduled job not found")
)
type ExecutionLogListRequest struct {
Page int
PageSize int
Status string
StartedFrom *time.Time
StartedTo *time.Time
}
type ExecutionLogJob struct {
JobID int `json:"jobId"`
JobName string `json:"jobName"`
InvokeTarget string `json:"invokeTarget"`
Deleted bool `json:"deleted"`
}
type ExecutionLogListResponse struct {
Job ExecutionLogJob `json:"job"`
Items []models.SysJobExecutionLog `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type ExecutionLogService struct{ db *gorm.DB }
func NewExecutionLogService(db *gorm.DB) *ExecutionLogService {
return &ExecutionLogService{db: db}
}
func (service *ExecutionLogService) List(ctx context.Context, jobID int, request ExecutionLogListRequest) (ExecutionLogListResponse, error) {
if service.db == nil || jobID < 1 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: jobId 无效", ErrExecutionLogInvalidRequest)
}
if request.Page < 1 {
request.Page = 1
}
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: pageSize 必须是 1 到 100 的整数", ErrExecutionLogInvalidRequest)
}
if request.Status != "" && !validExecutionStatus(request.Status) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: status 无效", ErrExecutionLogInvalidRequest)
}
if request.StartedFrom != nil && request.StartedTo != nil && request.StartedFrom.After(*request.StartedTo) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: 开始时间范围无效", ErrExecutionLogInvalidRequest)
}
var job models.SysJob
if err := service.db.WithContext(ctx).Unscoped().First(&job, jobID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ExecutionLogListResponse{}, ErrExecutionLogJobNotFound
}
return ExecutionLogListResponse{}, err
}
query := service.db.WithContext(ctx).Model(&models.SysJobExecutionLog{}).Where("job_id = ?", jobID)
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
if request.StartedFrom != nil {
query = query.Where("started_at >= ?", request.StartedFrom.UTC())
}
if request.StartedTo != nil {
query = query.Where("started_at <= ?", request.StartedTo.UTC())
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ExecutionLogListResponse{}, err
}
items := make([]models.SysJobExecutionLog, 0, request.PageSize)
if err := query.Order("started_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil {
return ExecutionLogListResponse{}, err
}
return ExecutionLogListResponse{
Job: ExecutionLogJob{JobID: job.JobId, JobName: job.JobName, InvokeTarget: job.InvokeTarget, Deleted: job.DeletedAt.Valid},
Items: items, Total: total, Page: request.Page, PageSize: request.PageSize,
}, nil
}
func validExecutionStatus(status string) bool {
switch status {
case models.JobExecutionRunning, models.JobExecutionSucceeded, models.JobExecutionFailed, models.JobExecutionInterrupted:
return true
default:
return false
}
}
@@ -0,0 +1,87 @@
package service
import (
"context"
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func executionLogTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJob{}, &models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestExecutionLogListFiltersPaginatesAndKeepsDeletedJob(t *testing.T) {
db := executionLogTestDB(t)
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
base := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
records := []models.SysJobExecutionLog{
{ExecutionID: "00000000-0000-4000-8000-000000000001", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionSucceeded, StartedAt: base},
{ExecutionID: "00000000-0000-4000-8000-000000000002", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(time.Hour)},
{ExecutionID: "00000000-0000-4000-8000-000000000003", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(2 * time.Hour)},
}
if err := db.Create(&records).Error; err != nil {
t.Fatal(err)
}
from, to := base.Add(30*time.Minute), base.Add(3*time.Hour)
result, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{
Page: 1, PageSize: 1, Status: models.JobExecutionFailed, StartedFrom: &from, StartedTo: &to,
})
if err != nil {
t.Fatal(err)
}
if result.Total != 2 || len(result.Items) != 1 || result.Items[0].ExecutionID != records[2].ExecutionID {
t.Fatalf("unexpected filtered page: %+v", result)
}
if err := db.Delete(&job).Error; err != nil {
t.Fatal(err)
}
deleted, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{Page: 1, PageSize: 20})
if err != nil {
t.Fatal(err)
}
if !deleted.Job.Deleted || deleted.Job.JobName != job.JobName || deleted.Total != 3 {
t.Fatalf("deleted job history unavailable: %+v", deleted)
}
}
func TestExecutionLogListValidationAndNotFound(t *testing.T) {
db := executionLogTestDB(t)
service := NewExecutionLogService(db)
if _, err := service.List(context.Background(), 0, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid job id error = %v", err)
}
if _, err := service.List(context.Background(), 1, ExecutionLogListRequest{PageSize: 101}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid page size error = %v", err)
}
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{Status: "unknown"}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid status error = %v", err)
}
from, to := time.Now(), time.Now().Add(-time.Hour)
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{StartedFrom: &from, StartedTo: &to}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid range error = %v", err)
}
if _, err := service.List(context.Background(), 999, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogJobNotFound) {
t.Fatalf("not found error = %v", err)
}
}
+1
View File
@@ -62,6 +62,7 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error {
if data.JobType == 1 {
var j = &jobs.HttpJob{}
j.DB = e.Orm.WithContext(context.Background())
j.InvokeTarget = data.InvokeTarget
j.CronExpression = data.CronExpression
j.JobId = data.JobId
+19 -1
View File
@@ -54,6 +54,12 @@ var (
}
)
// The synchronous Admin AI endpoints allow a provider timeout of up to 600
// seconds and the browser waits 610 seconds. Keep the HTTP server alive a
// little longer so it can return the domain response instead of truncating
// the connection and surfacing a proxy-level 502.
const minimumAPIWriteTimeout = 620 * time.Second
var AppRouters = make([]func(), 0)
func init() {
@@ -125,11 +131,15 @@ func run() error {
)
}
writeTimeout, err := validatedAPIWriteTimeout(config.ApplicationConfig.WriterTimeout)
if err != nil {
return err
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
Handler: sdk.Runtime.GetEngine(),
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
WriteTimeout: writeTimeout,
}
go func() {
@@ -193,6 +203,14 @@ func run() error {
return nil
}
func validatedAPIWriteTimeout(seconds int) (time.Duration, error) {
timeout := time.Duration(seconds) * time.Second
if timeout < minimumAPIWriteTimeout {
return 0, fmt.Errorf("application writetimeout must be at least %s for synchronous AI requests", minimumAPIWriteTimeout)
}
return timeout, nil
}
type policyLoader interface {
LoadPolicy() error
}
+19
View File
@@ -0,0 +1,19 @@
package api
import (
"testing"
"time"
)
func TestValidatedAPIWriteTimeoutProtectsSynchronousAIRequests(t *testing.T) {
if _, err := validatedAPIWriteTimeout(2); err == nil {
t.Fatal("two-second write timeout must be rejected")
}
got, err := validatedAPIWriteTimeout(620)
if err != nil {
t.Fatalf("620-second write timeout should be accepted: %v", err)
}
if got != 620*time.Second {
t.Fatalf("write timeout = %s, want 620s", got)
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateShopeeSpecAutoMatch)
}
func migrateShopeeSpecAutoMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureShopeeSpecAutoMatchJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureShopeeSpecAutoMatchJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "蝦皮规格自动匹配", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 15 * * * *", InvokeTarget: shopeeproduct.SpecAutoMatchInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureShopeeSpecAutoMatchJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:shopee-spec-auto-match-job?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 15 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected seed: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 30 * * * *", "args": `{"batchLimit":5}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("count=%d", count)
}
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 30 * * * *" || job.Args != `{"batchLimit":5}` {
t.Fatalf("admin changes overwritten: %+v", job)
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecAIParse)
}
func migrateSYBSpecAIParse(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureSYBSpecAIParseJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSYBSpecAIParseJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected initial job: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("job count=%d, want 1", count)
}
if err := db.First(&job, job.JobId).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` {
t.Fatalf("existing administrator changes were overwritten: %+v", job)
}
}
@@ -0,0 +1,68 @@
package version_local
import (
"fmt"
"runtime"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
const jobExecutionLogsAPIPath = "/api/v1/sysjob/:id/execution-logs"
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSysJobExecutionLog)
}
func migrateSysJobExecutionLog(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureSysJobExecutionLog(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSysJobExecutionLog(db *gorm.DB) error {
if err := db.AutoMigrate(&jobsmodels.SysJobExecutionLog{}); err != nil {
return err
}
var logMenu migrationmodels.SysMenu
if err := db.Where("menu_name = ?", "JobLog").First(&logMenu).Error; err != nil {
return fmt.Errorf("find JobLog menu: %w", err)
}
api := migrationmodels.SysApi{}
if err := db.Where(migrationmodels.SysApi{Path: jobExecutionLogsAPIPath, Action: "GET"}).
Attrs(migrationmodels.SysApi{Title: "查询定时任务执行日志", Type: "BUS"}).FirstOrCreate(&api).Error; err != nil {
return err
}
if err := db.Model(&logMenu).Association("SysApi").Append(&api); err != nil {
return err
}
var roleIDs []int
if err := db.Table("sys_role_menu").Where("menu_id = ?", logMenu.MenuId).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
return err
}
if len(roleIDs) == 0 {
return nil
}
var roles []migrationmodels.SysRole
if err := db.Where("role_id IN ?", roleIDs).Find(&roles).Error; err != nil {
return err
}
for _, role := range roles {
rule := purchaserCasbinRule{Ptype: "p", V0: role.RoleKey, V1: jobExecutionLogsAPIPath, V2: "GET"}
if err := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.Ptype, rule.V0, rule.V1, rule.V2).
FirstOrCreate(&rule).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,78 @@
package version_local
import (
"testing"
jobsmodels "go-admin/app/jobs/models"
migrationmodels "go-admin/cmd/migrate/migration/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSysJobExecutionLogIsIdempotentAndGrantsOnlyBoundRoles(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{},
); err != nil {
t.Fatal(err)
}
menu := migrationmodels.SysMenu{MenuName: "JobLog", Title: "日志", Path: "/schedule/log", Component: "/schedule/log"}
if err := db.Create(&menu).Error; err != nil {
t.Fatal(err)
}
bound := migrationmodels.SysRole{RoleName: "日志管理员", RoleKey: "job_logger"}
unbound := migrationmodels.SysRole{RoleName: "无日志权限", RoleKey: "no_job_logs"}
if err := db.Create(&bound).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unbound).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&bound).Association("SysMenu").Append(&menu); err != nil {
t.Fatal(err)
}
custom := purchaserCasbinRule{Ptype: "p", V0: bound.RoleKey, V1: "/custom", V2: "GET"}
if err := db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatalf("migration helper must be repeatable: %v", err)
}
if !db.Migrator().HasTable(&jobsmodels.SysJobExecutionLog{}) {
t.Fatal("execution log table was not created")
}
var apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy int64
db.Model(&migrationmodels.SysApi{}).Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").Count(&apiCount)
var api migrationmodels.SysApi
if err := db.Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").First(&api).Error; err != nil {
t.Fatal(err)
}
db.Table("sys_menu_api_rule").Where("sys_menu_menu_id = ? AND sys_api_id = ?", menu.MenuId, api.Id).Count(&menuAPI)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", bound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&boundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", unbound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&unboundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ?", bound.RoleKey, "/custom").Count(&customPolicy)
if apiCount != 1 || menuAPI != 1 || boundPolicy != 1 || unboundPolicy != 0 || customPolicy != 1 {
t.Fatalf("unexpected permission state api=%d menu=%d bound=%d unbound=%d custom=%d", apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy)
}
}
func TestEnsureSysJobExecutionLogRequiresExistingMenu(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err == nil {
t.Fatal("missing JobLog menu must fail closed")
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
ssl:
+2 -2
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
logger:
@@ -84,4 +84,4 @@ settings:
# blockingTimeout: 5
# reclaimInterval: 1
locker:
redis:
redis:
+240 -2
View File
@@ -5,8 +5,11 @@ import os
import sys
import tempfile
import unittest
from argparse import Namespace
from io import BytesIO
from pathlib import Path
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
from urllib.error import HTTPError
ROOT = Path(__file__).resolve().parents[1]
@@ -21,6 +24,7 @@ from harness import ( # noqa: E402
existing_task_mirrors,
export_task_archives,
main as harness_main,
run_sync,
safe_title,
task_target,
)
@@ -126,8 +130,215 @@ class MirrorTests(unittest.TestCase):
sync_all(config, client)
client.get_page.assert_not_called()
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
def test_incremental_sync_lists_once_and_skips_unchanged_body(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "docs" / "README.md"
target.parent.mkdir(parents=True)
target.write_text(render_mirror(self.page), encoding="utf-8")
config = Config(
path=root / "wiki-docs.json",
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(Mapping("Home", "docs/README.md"),),
)
client = Mock()
client.list_pages.return_value = [
{
"title": "Home",
"sub_url": "Home",
"last_commit": {"sha": self.page.revision},
}
]
with patch("wiki_docs.ROOT", root):
messages = sync_all(config, client)
self.assertTrue(messages[0].startswith("无变化:"))
client.list_pages.assert_called_once_with()
client.get_page_from_metadata.assert_not_called()
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
def test_incremental_sync_fetches_changed_page(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "docs" / "README.md"
target.parent.mkdir(parents=True)
target.write_text(render_mirror(self.page), encoding="utf-8")
changed = WikiPage(
title="Home",
sub_url="Home",
text="# 新首页\n",
revision="b" * 40,
html_url=self.page.html_url,
)
metadata = {
"title": "Home",
"sub_url": "Home",
"last_commit": {"sha": changed.revision},
}
config = Config(
path=root / "wiki-docs.json",
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(Mapping("Home", "docs/README.md"),),
)
client = Mock()
client.list_pages.return_value = [metadata]
client.get_page_from_metadata.return_value = changed
with patch("wiki_docs.ROOT", root):
messages = sync_all(config, client)
_metadata, body = parse_mirror(target.read_text(encoding="utf-8"))
self.assertTrue(messages[0].startswith("已更新:"))
self.assertEqual(body, changed.text)
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
def test_sync_does_not_write_partial_mirrors_when_later_read_fails(
self, _dirty
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
first_target = root / "docs" / "first.md"
second_target = root / "docs" / "second.md"
first_target.parent.mkdir(parents=True)
first_original = render_mirror(self.page)
first_target.write_text(first_original, encoding="utf-8")
second_target.write_text(first_original, encoding="utf-8")
config = Config(
path=root / "wiki-docs.json",
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(
Mapping("First", "docs/first.md"),
Mapping("Second", "docs/second.md"),
),
)
pages = [
{
"title": name,
"sub_url": name,
"last_commit": {"sha": revision},
}
for name, revision in (("First", "b" * 40), ("Second", "c" * 40))
]
changed = WikiPage(
title="First",
sub_url="First",
text="# Changed\n",
revision="b" * 40,
html_url="http://gitea.example/o/r/wiki/First",
)
client = Mock()
client.list_pages.return_value = pages
client.get_page_from_metadata.side_effect = [
changed,
WikiDocsError("rate limited"),
]
with patch("wiki_docs.ROOT", root):
with self.assertRaises(WikiDocsError):
sync_all(config, client)
self.assertEqual(first_target.read_text(encoding="utf-8"), first_original)
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
def test_sync_fetches_when_remote_revision_is_missing(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "docs" / "README.md"
target.parent.mkdir(parents=True)
target.write_text(render_mirror(self.page), encoding="utf-8")
metadata = {"title": "Home", "sub_url": "Home"}
config = Config(
path=root / "wiki-docs.json",
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(Mapping("Home", "docs/README.md"),),
)
client = Mock()
client.list_pages.return_value = [metadata]
client.get_page_from_metadata.return_value = self.page
with patch("wiki_docs.ROOT", root):
sync_all(config, client)
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
def test_deep_check_fetches_unchanged_page_body(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "docs" / "README.md"
target.parent.mkdir(parents=True)
target.write_text(render_mirror(self.page), encoding="utf-8")
metadata = {
"title": "Home",
"sub_url": "Home",
"last_commit": {"sha": self.page.revision},
}
config = Config(
path=root / "wiki-docs.json",
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(Mapping("Home", "docs/README.md"),),
)
client = Mock()
client.list_pages.return_value = [metadata]
client.get_page_from_metadata.return_value = self.page
with patch("wiki_docs.ROOT", root):
sync_all(config, client, check=True, deep_check=True)
client.list_pages.assert_called_once_with()
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
@patch("harness.run_check", return_value=0)
@patch("harness.sync_all", return_value=["已更新:docs/README.md"])
@patch("harness.WikiClient")
@patch("harness.load_config")
def test_verify_reuses_one_deep_sync(
self, load, client_class, sync, check
) -> None:
config = Mock()
load.return_value = config
result = run_sync(
Namespace(verify=True, check=False, deep_check=False, config="config.json")
)
self.assertEqual(result, 0)
sync.assert_called_once_with(
config, client_class.return_value, check=False, deep_check=True
)
check.assert_called_once()
class WikiClientTests(unittest.TestCase):
@patch("wiki_docs.time.sleep")
@patch("wiki_docs.urlopen")
def test_get_retries_a_rate_limited_request(self, urlopen, sleep) -> None:
config = Config(
path=Path("wiki-docs.json"),
gitea_url="http://gitea.example",
owner="o",
repository="r",
mappings=(),
)
limited = HTTPError(
"http://gitea.example/api/v1/repos/o/r/wiki/pages",
429,
"Too Many Requests",
None,
BytesIO(b"limited"),
)
success = MagicMock()
success.__enter__.return_value.read.return_value = b"[]"
urlopen.side_effect = [limited, success]
result = WikiClient(config, token="token")._request(
"GET", "/repos/o/r/wiki/pages"
)
self.assertEqual(result, [])
self.assertEqual(urlopen.call_count, 2)
sleep.assert_called_once_with(1.0)
def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None:
config = Config(
path=Path("wiki-docs.json"),
@@ -323,7 +534,7 @@ class GovernancePolicyTests(unittest.TestCase):
self.assertEqual(errors, [])
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
self.assertIn("Gitea 工单是单次任务", agents)
self.assertIn("需要工单的任务以 Gitea 工单作为单次", agents)
self.assertIn("标准流程不创建 Wiki 任务归档", agents)
self.assertIn("Wiki 同步由长期事实变化触发,不由任务完成触发", agents)
@@ -340,6 +551,33 @@ class GovernancePolicyTests(unittest.TestCase):
self.assertIn("### 线上原型审核与按需导出", workflow)
self.assertIn("## Windows PowerShell 与 UTF-8", local_development)
def test_goauto_uses_lightweight_governance_and_explicit_authorization(self) -> None:
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
profile = (ROOT / "docs" / "00-project-profile.md").read_text(
encoding="utf-8"
)
workflow = (ROOT / "docs" / "01-workflow.md").read_text(encoding="utf-8")
for text in (agents, profile, workflow):
self.assertIn("轻量治理", text)
for text in (agents, workflow):
self.assertIn("明确授权后的执行", text)
self.assertIn("不能单独构成人工授权", text)
self.assertIn("不得仅因操作不可逆而重复询问或拒绝", text)
self.assertIn("平台自身强制的审批", text)
def test_language_and_windows_command_policy_is_documented(self) -> None:
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
workflow = (ROOT / "docs" / "01-workflow.md").read_text(encoding="utf-8")
local_development = (
ROOT / "docs" / "04-local-development-and-verification.md"
).read_text(encoding="utf-8")
for text in (agents, workflow):
self.assertIn("默认使用中文", text)
self.assertIn("日志和错误原文保持原样", text)
self.assertIn("不得默认套用 Bash", agents)
self.assertIn("### PowerShell 语法与外部命令", local_development)
self.assertIn("rg -n -g '*.md'", local_development)
def test_deployment_template_is_required_and_mapped(self) -> None:
path = "docs/templates/deployment.md"
self.assertIn(path, harness.REQUIRED_FILES)
+12
View File
@@ -66,6 +66,18 @@ export function suggestShopeeSizeMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
}
export function autoMatchShopeeSpecMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
}
export function startShopeeSpecAutoMatchRun(data) {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs', method: 'post', data })
}
export function getLatestShopeeSpecAutoMatchRun() {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs/latest', method: 'get' })
}
export function batchDeleteShopeeProducts(data) {
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
}
+9 -1
View File
@@ -17,6 +17,15 @@ export function getSysJob(jobId) {
})
}
// 查询指定定时任务的持久化执行历史
export function listJobExecutionLogs(jobId, query) {
return request({
url: '/api/v1/sysjob/' + jobId + '/execution-logs',
method: 'get',
params: query
})
}
// 新增SysJob
export function addSysJob(data) {
return request({
@@ -59,4 +68,3 @@ export function startJob(jobId) {
method: 'get'
})
}
@@ -23,13 +23,12 @@
</el-descriptions>
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
<div class="section-heading"><h3>规格匹配</h3><div class="mapping-heading-actions"><span class="mapping-progress">{{ mappingProgressText }}</span><el-button type="primary" :loading="detail.aiMatching" :disabled="!canAutoMatch" :title="hasMappingChanges ? '请先保存或撤销当前人工修改' : ''" @click="autoMatchMappings">一键匹配颜色和尺码</el-button></div></div>
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
<el-tab-pane label="颜色匹配" name="color">
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
@@ -39,7 +38,6 @@
</el-table>
</el-tab-pane>
<el-tab-pane label="尺码匹配" name="size">
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
<el-table v-else :data="specRows('size')" border size="small">
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus'
import {
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
suggestShopeeColorMappings, suggestShopeeSizeMappings
autoMatchShopeeSpecMappings
} from '@/api/goauto/shopee-products'
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
import { createRequestId } from '@/utils/request-id'
@@ -107,8 +105,7 @@ export default {
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
},
watch: {
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
@@ -117,7 +114,7 @@ export default {
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
},
methods: {
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
@@ -164,7 +161,7 @@ export default {
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } },
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
@@ -175,5 +172,5 @@ export default {
</script>
<style lang="scss" scoped>
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-heading-actions{display:flex;align-items:center;gap:12px}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.section-heading,.mapping-heading-actions{align-items:flex-start;flex-direction:column}}
</style>
+12 -6
View File
@@ -1,7 +1,7 @@
<template>
<BasicLayout><template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item></el-form>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
@@ -19,9 +19,9 @@
</template>
<script>
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts, startShopeeSpecAutoMatchRun, getLatestShopeeSpecAutoMatchRun } from '@/api/goauto/shopee-products'
import { listPddProducts } from '@/api/goauto/pdd-products'
import { createRequestId } from '@/utils/request-id'
import ShopeeProductDetailDrawer from './ShopeeProductDetailDrawer.vue'
@@ -29,10 +29,16 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }} },
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail() }, activated() { this.applyRouteDetail() },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
},
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, activated() { this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, beforeUnmount() { if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer) },
methods: {
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
async loadLatestAutoMatchRun() { if (!this.isAdmin) return; const response = await getLatestShopeeSpecAutoMatchRun(); this.autoMatchRun.latest = response.data.run || null; if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer); if (this.autoMatchRun.latest?.status === 'running') this.autoMatchPollTimer = setTimeout(() => this.loadLatestAutoMatchRun(), 2000) },
async startAutoMatchRun() { await ElMessageBox.confirm('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', '执行规格自动匹配', { type: 'warning', confirmButtonText: '开始执行', cancelButtonText: '取消' }); this.autoMatchRun.submitting = true; try { const response = await startShopeeSpecAutoMatchRun({ requestId: createRequestId() }); this.autoMatchRun.latest = response.data.run; ElMessage.success(response.data.run.alreadyRunning ? '已有自动匹配正在运行' : '已开始后台匹配'); await this.loadLatestAutoMatchRun() } finally { this.autoMatchRun.submitting = false } },
applyRouteDetail() { const id = Number(this.$route.query.productId); if (!Number.isInteger(id) || id <= 0) return; this.detail = { open: true, productId: id, targetColor: String(this.$route.query.targetColor || '').trim(), action: String(this.$route.query.action || '') } },
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listShopeeProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() }, handleSelectionChange(rows) { this.selectedProducts = rows },
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` }, mappingMeta(product) { if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping?.status === 'confirmed').length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
@@ -45,5 +51,5 @@ export default {
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
</style>
+24
View File
@@ -0,0 +1,24 @@
export function jobLogRoute(selectedIds) {
if (!Array.isArray(selectedIds) || selectedIds.length !== 1) return null
const jobId = Number(selectedIds[0])
if (!Number.isInteger(jobId) || jobId < 1) return null
return { name: 'JobLog', query: { jobId: String(jobId) }}
}
export function executionStatusMeta(status) {
return {
running: { label: '执行中', type: 'primary' },
succeeded: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
interrupted: { label: '已中断', type: 'warning' }
}[status] || { label: status || '未知', type: 'info' }
}
export function executionLogQuery(query, dateRange) {
const result = { ...query }
if (Array.isArray(dateRange) && dateRange.length === 2) {
result.startedFrom = new Date(dateRange[0]).toISOString()
result.startedTo = new Date(dateRange[1]).toISOString()
}
return result
}
+4 -2
View File
@@ -55,7 +55,7 @@
<el-button v-permisaction="['job:sysJob:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增</el-button>
<el-button v-permisaction="['job:sysJob:edit']" type="primary" size="small" :icon="Edit" :disabled="single" @click="handleUpdate">修改</el-button>
<el-button v-permisaction="['job:sysJob:remove']" type="danger" size="small" :icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" @click="handleLog">日志</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" :disabled="single" @click="handleLog">日志</el-button>
</div>
<el-table v-loading="loading" :data="sysjobList" border stripe @selection-change="handleSelectionChange">
@@ -267,6 +267,7 @@
<script>
import { addSysJob, delSysJob, getSysJob, listSysJob, updateSysJob, removeJob, startJob } from '@/api/job/sys-job'
import { Search, Refresh, Plus, Edit, Delete } from '@element-plus/icons-vue'
import { jobLogRoute } from './execution-log'
export default {
name: 'SysJobManage',
@@ -500,7 +501,8 @@ export default {
}).catch(function() {})
},
handleLog() {
this.$router.push({ name: 'job_log', params: { }})
const route = jobLogRoute(this.ids)
if (route) this.$router.push(route)
},
handleSybSyncRuns() {
this.$router.push('/syb-sync-runs/index')
+86 -71
View File
@@ -1,101 +1,116 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div>
<div class="eyebrow">定时任务 / 执行日志</div>
<h1>{{ job.jobName || '定时任务执行日志' }}<span v-if="job.jobId">(#{{ job.jobId }})</span></h1>
<p>查看该任务每次调度的开始时间、结果、耗时和脱敏错误摘要。</p>
</div>
<el-button :icon="ArrowLeft" @click="backToList">返回任务列表</el-button>
</div>
<el-alert v-if="job.deleted" title="该定时任务已删除,以下为保留的历史执行记录。" type="warning" show-icon :closable="false" class="notice" role="status" />
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部状态" style="width:150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item>
<el-button type="success" size="mini">状态</el-button>
<el-button type="primary" size="mini">清空</el-button>
<el-button type="primary" :icon="Search" :disabled="!jobId" @click="search">查询</el-button>
<el-button :icon="RefreshLeft" :disabled="!jobId" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-row ref="log" :gutter="10" class="mb8">
<el-scrollbar style="height:500px;background-color: black;color: cornflowerblue;">
<ul
style="line-height: 25px;padding-top: 15px;padding-bottom: 15px;min-height: 500px; margin: 0;list-style-type: none;"
>
<li v-for="(item,index) in arrs" :key="index">
{{ item }}
</li>
</ul>
</el-scrollbar>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice" role="alert">
<template #default><el-button v-if="jobId" link type="primary" @click="load">重新加载</el-button></template>
</el-alert>
<el-table v-loading="loading" :data="items" border stripe :empty-text="jobId ? '该任务尚无执行记录' : '请从定时任务列表选择一条任务'">
<el-table-column label="执行标识" prop="executionId" min-width="205" show-overflow-tooltip />
<el-table-column label="开始时间" min-width="175"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
<el-table-column label="结束时间" min-width="175"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatTime(row.finishedAt) }}</template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="耗时" width="105"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatDuration(row.durationMs) }}</template></el-table-column>
<el-table-column label="触发方式" width="110"><template #default="{ row }">{{ row.triggerType === 'scheduled' ? '定时调度' : row.triggerType }}</template></el-table-column>
<el-table-column label="错误摘要" min-width="260"><template #default="{ row }"><span v-if="row.errorCode" class="error-code">{{ row.errorCode }}</span>{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { ArrowLeft, RefreshLeft, Search } from '@element-plus/icons-vue'
import { listJobExecutionLogs } from '@/api/job/sys-job'
import { executionLogQuery, executionStatusMeta } from './execution-log'
import { unWsLogout } from '@/api/ws'
export default {
name: 'SysJobLogManage',
setup() { return { ArrowLeft, RefreshLeft, Search } },
data() {
return {
websock: null,
arrs: [],
id: undefined,
group: undefined
loading: false, loadError: '', job: {}, items: [], total: 0, dateRange: null, pollTimer: null,
query: { pageIndex: 1, pageSize: 20, status: '' },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
computed: {
jobId() {
const value = Number(this.$route.query.jobId)
return Number.isInteger(value) && value > 0 ? value : 0
}
},
created() {
this.id = this.guid()
this.group = 'log'
this.initWebSocket()
},
unmounted() {
console.log('断开websocket连接')
this.websock.close() // 离开路由之后断开websocket连接
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
if (!this.jobId) {
this.loadError = '缺少有效的定时任务编号,请返回列表重新选择。'
return
}
)
this.load()
},
beforeUnmount() { this.stopPolling() },
methods: {
initWebSocket() { // 初始化weosocket
console.log(this.$store.state.user.token)
const wsuri = 'ws://127.0.0.1:8000/ws/' + this.id + '/' + this.group + '?token=' + this.$store.state.user.token
this.websock = new WebSocket(wsuri)
this.websock.onmessage = this.websocketonmessage
this.websock.onopen = this.websocketonopen
this.websock.onerror = this.websocketonerror
this.websock.onclose = this.websocketclose
statusMeta: executionStatusMeta,
formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
formatDuration(value) {
const ms = Number(value) || 0
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
},
websocketonopen() { // 连接建立之后执行send方法发送数据
console.log('连接打开')
// const actions = { 'test': '12345' }
// this.websocketsend(JSON.stringify(actions))
},
websocketonerror() { // 连接建立失败重连
this.initWebSocket()
},
websocketonmessage(e) { // 数据接收
console.log(e.data)
// console.log(this.binaryAgent(e))
// const redata = JSON.parse(e.data)
// console.log(redata)
// this.$refs.log.innerText = e.data + '\n' + this.$refs.log.innerText
this.arrs.unshift(e.data)
},
websocketsend(Data) { // 数据发送
// this.websock.send(Data)
},
websocketclose(e) { // 关闭
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
async load() {
if (!this.jobId) return
this.loading = true
this.loadError = ''
try {
const response = await listJobExecutionLogs(this.jobId, executionLogQuery(this.query, this.dateRange))
this.job = response.data.job
this.items = response.data.items
this.total = response.data.total
this.items.some(item => item.status === 'running') ? this.startPolling() : this.stopPolling()
} catch (error) {
this.loadError = error?.response?.data?.msg || error?.message || '执行日志加载失败'
} finally {
this.loading = false
}
)
console.log('断开连接', e)
},
guid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0; var v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
search() { this.query.pageIndex = 1; this.load() },
reset() { this.dateRange = null; this.query = { pageIndex: 1, pageSize: 20, status: '' }; this.load() },
backToList() { this.$router.push('/schedule/manage') },
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(() => this.load(), 5000) },
stopPolling() { if (this.pollTimer) { window.clearInterval(this.pollTimer); this.pollTimer = null } }
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.page-heading h1{margin:4px 0 6px;color:var(--el-text-color-primary);font-size:24px}.page-heading p{margin:0;color:var(--el-text-color-regular);line-height:1.5}.eyebrow{color:var(--el-text-color-secondary);font-size:13px}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid var(--el-border-color);border-radius:8px;background:var(--el-fill-color-lighter)}.notice{margin-bottom:16px}.error-code{display:inline-block;margin-right:8px;color:var(--el-color-danger);font-family:Consolas,monospace;font-size:12px}@media(max-width:800px){.page-heading{flex-direction:column}.search-form :deep(.el-date-editor){width:100%}}
</style>
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test'
async function authenticate(context: any) {
await context.addCookies([{ name: 'Admin-Token', value: 'job-log-test-token', domain: 'localhost', path: '/' }])
}
test('单选定时任务后进入对应的持久化执行日志', async ({ page, context }) => {
await authenticate(context)
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) {
return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: ['job:sysJob:log'] } } })
}
if (url.pathname.endsWith('/api/v1/menurole')) {
return route.fulfill({ json: { code: 200, data: [{ path: '/schedule', component: 'Layout', visible: '0', menuName: 'Schedule', title: '定时任务', icon: 'time', children: [{ path: 'manage', component: '/schedule/index', visible: '0', menuName: 'ScheduleManage', title: '定时任务' }, { path: 'log', component: '/schedule/log', visible: '1', menuName: 'JobLog', title: '执行日志' }] }] } })
}
if (url.pathname.endsWith('/api/v1/sysjob/5/execution-logs')) {
return route.fulfill({ json: { code: 200, data: { job: { jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', deleted: false }, items: [{ id: 21, executionId: '00000000-0000-4000-8000-000000000021', jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', triggerType: 'scheduled', status: 'failed', startedAt: '2026-09-02T01:00:00Z', finishedAt: '2026-09-02T01:00:03Z', durationMs: 3000, errorCode: 'JOB_EXECUTION_FAILED', errorMessage: '任务执行失败,请查看受控服务日志' }], total: 1, page: 1, pageSize: 20 } } })
}
if (url.pathname.endsWith('/api/v1/sysjob')) {
return route.fulfill({ json: { code: 200, data: { list: [{ jobId: 5, jobName: 'SYB 规格 AI 修复', jobGroup: 'GoAuto', cronExpression: '0 0 * * * *', invokeTarget: 'GoAutoSYBSpecAIParse', status: 2, entry_id: 0 }], count: 1 } } })
}
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/schedule/manage')
const logButton = page.getByRole('button', { name: '日志', exact: true })
await expect(page.getByText('SYB 规格 AI 修复', { exact: true })).toBeVisible()
await expect(logButton).toBeDisabled()
await page.locator('.el-table__body-wrapper .el-checkbox').first().click()
await expect(logButton).toBeEnabled()
await logButton.click()
await expect(page).toHaveURL(/#\/schedule\/log\?jobId=5$/)
await expect(page.getByRole('heading', { name: 'SYB 规格 AI 修复(#5)' })).toBeVisible()
await expect(page.getByText('JOB_EXECUTION_FAILED')).toBeVisible()
await expect(page.getByText('任务执行失败,请查看受控服务日志')).toBeVisible()
})
+17 -9
View File
@@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
})
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let autoMatched = false
let autoMatchBody: Record<string, unknown> | null = null
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) {
autoMatchBody = route.request().postDataJSON()
autoMatched = true
return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}})
}
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}})
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
}
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
return route.fulfill({ json: { code: 200, data: [] }})
})
@@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as
page.on('popup', () => { popupCount += 1 })
await page.getByRole('button', { name: '详情', exact: true }).click()
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1)
await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click()
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
await expect(page.getByText('可采购', { exact: true })).toBeVisible()
expect(autoMatchBody?.specContextVersion).toBe('context-3')
expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
expect(popupCount).toBe(0)
})
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test'
const routes = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/shopee-products', component: '/goauto/shopee-products/index', menuName: 'GoAutoShopeeProducts', title: '虾皮商品', visible: '0' }] }]
test('管理员可确认并手动启动规格自动匹配且查看摘要', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let started = false
let requestBody: Record<string, unknown> | null = null
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}})
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs/latest')) {
const run = started ? { id: 8, status: 'completed', processedCount: 12, confirmedCount: 9, unmatchedCount: 3 } : null
return route.fulfill({ json: { code: 200, data: { run }}})
}
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs')) {
requestBody = route.request().postDataJSON()
started = true
return route.fulfill({ status: 202, json: { code: 200, data: { run: { id: 8, status: 'running', processedCount: 0 }}}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/shopee-products')
await page.getByRole('button', { name: '执行规格自动匹配', exact: true }).click()
await expect(page.getByText('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '开始执行', exact: true }).click()
await expect(page.getByText('最近一次:完成,处理 12,确认 9,未匹配 3', { exact: true })).toBeVisible()
expect(String(requestBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
})
@@ -0,0 +1,26 @@
import { executionLogQuery, executionStatusMeta, jobLogRoute } from '@/views/schedule/execution-log'
describe('scheduled job execution log helpers', () => {
test('only one valid selection can navigate to JobLog', () => {
expect(jobLogRoute([])).toBeNull()
expect(jobLogRoute([1, 2])).toBeNull()
expect(jobLogRoute(['invalid'])).toBeNull()
expect(jobLogRoute([5])).toEqual({ name: 'JobLog', query: { jobId: '5' }})
})
test('builds RFC3339 date filters without mutating paging', () => {
const from = new Date('2026-09-02T10:00:00+08:00')
const to = new Date('2026-09-02T11:00:00+08:00')
expect(executionLogQuery({ pageIndex: 2, pageSize: 20, status: 'failed' }, [from, to])).toEqual({
pageIndex: 2, pageSize: 20, status: 'failed',
startedFrom: from.toISOString(), startedTo: to.toISOString()
})
})
test('maps all persisted statuses', () => {
expect(executionStatusMeta('running').label).toBe('执行中')
expect(executionStatusMeta('succeeded').type).toBe('success')
expect(executionStatusMeta('failed').type).toBe('danger')
expect(executionStatusMeta('interrupted').label).toBe('已中断')
})
})