Files
QTImport/docs/QuickTeam-API导入-调研与设计.md
T
chenshoulin 58850314cf 初始提交:QuickTeam 工单接口导入工具
用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单:
单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。

- 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/
  workflow(阶段编排)/ store(SQLite 台账)/ report / CLI
- 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品
- 命令:plan / apply / report / status,真实写入需 --yes
- tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断
- docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码

实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS,
工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
2026-11-05 19:14:25 +08:00

725 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QuickTeam 工单接口导入 —— 调研与设计
| 项 | 内容 |
| --- | --- |
| 版本 | v1.0(调研 + 设计,未进入编码) |
| 日期 | 2026-11-05 |
| 目标系统 | QuickTeam `http://192.168.1.213` |
| 现有实现 | `D:\Learning\Python\工单自动化\auto_quickteam.py`(Selenium) |
| 结论摘要 | **可行**。QuickTeam 无公开 REST API,但其前端全部业务数据都经一套「WCF + 通用存储过程网关」读写,该网关可被直接调用;已实测登录、项目、工单查询、文件上传四类接口,写入链路 5 个存储过程的参数契约已从前端产物完整还原。 |
---
## 1. 背景与现状
### 1.1 现有做法
`auto_quickteam.py` 用 Selenium 驱动 Chrome,按「项目」逐个完成 5 步动作:
| 步骤 | 函数 | 实际动作 |
| --- | --- | --- |
| 1 | `login` / `open_team` | 打开 `#/Login`,填账号密码,进「当前项目」 |
| 2 | `open_project` | 搜索项目名 → 点项目卡片 → 点「工单」页签(`tab-seven`) |
| 3 | `create_work_order` | 点「新增」→ 填系统名称/所属团队/起止时间/实施内容/三个责任人 → 保存 |
| 4 | `entry_work_order_info` | 点进工单 → 编辑 → 隐藏的 `input[type=file]` 上传 xlsx → 点「确认工单」→ 返回 |
| 5 | `push_work_order_to_tfs` | 点「推送功能模块」→ 选 TFS 项目 `Tools_Dev` → 选迭代 → 全选 → 批量推送 |
进度靠 `done.txt` 记录,只在整条流程成功后追加一行。
### 1.2 慢在哪
代码里的固定 `sleep` 合计约 **128 秒/项目**(`open_project` 17.5s + `create_work_order` 41s + `entry_work_order_info` 14s + `push_work_order_to_tfs` 56s),再加上元素等待、浏览器渲染与网络往返,单项目实际 **2~3 分钟**。一个 Sprint 12~13 个项目就是 **25~35 分钟**,全程需保持前台浏览器与稳定网络。
### 1.3 不稳在哪
| 根因 | 表现 |
| --- | --- |
| 依赖 DOM 结构 | XPath 写死 Element UI 结构,前端一改版即失效(旧版甚至用 `/html/body/section/.../input` 绝对路径) |
| 固定等待 vs 真实加载 | `sleep(10)` 等保存、`sleep(20)` 等推送,加载慢时 `TimeoutException`;快时又白等 |
| 弹层/遮罩时序 | `el-loading-mask`、`el-select-dropdown`(`x-placement`)出现与消失时机不定 |
| 上传控件不可见 | 必须 JS 强改 `display` 才能赋值,任何前端调整都会破坏 |
| 项目名用 `contains` 匹配 | `资管 - 华泰资管` 同时命中 `资管 - 华泰保险资管`,存在点错项目的风险(现有 `open_project_error_*.html` 已记录多起同名/近名项目失败) |
| 单实例线性执行 | 任一步失败整批中断,无重试、无断点续跑 |
| `done.txt` 语义粗糙 | 只在成功后写,中途失败重跑会**重复建工单**;也没有可核对的中间状态 |
---
## 2. 接口调研
### 2.1 核心发现:后端不是 REST,而是「通用存储过程网关」
QuickTeam 前端是 Vue 2 + Element UI 的 SPA(`/QuickTeam/`,hash 路由)。抓取并分析其构建产物后确认,前端所有业务读写都汇聚到少数几个 WCF 端点,其中数据层只有一个入口:
```
POST http://192.168.1.213/Services/CommonService.svc/ExecTable
Content-Type: application/json
{
"connectionName": "QuickTeam", // 数据库连接名:QuickTeam / UserCenter
"param": "%7B%22SPName%22%3A...%7D" // encodeURIComponent(JSON)
}
```
`param` 解码后为:
```json
{
"SPName": "QuickTeam.usp_GetProjectsByTeamId",
"SQLParams": [
{ "Name": "TeamId", "Value": "090F8E57-...", "DBType": "string" },
{ "Name": "IsTemplate", "Value": "1", "DBType": "string" }
]
}
```
配套还有一个 `ExecResult`(标量/返回值语义,如 `Result`、`Column1`),另外两个服务:
| 端点 | 用途 | 本项目是否使用 |
| --- | --- | --- |
| `/Services/CommonService.svc/` | `ExecTable` / `ExecResult`:查项目、查工单、建工单、确认工单 | 是(核心) |
| `/Services/FileOperationService.svc/` | `FileUpload`(上传 xlsx)、`BatchImportFeature`(服务器解析 Excel 写库) | 是(导入) |
| `/Services/TFSOperationService.svc/` | `GetTeamProjects`、`BatchImportWorkItemsToTfs`(推送 TFS) | 是(推送) |
| `/TaskProcessEngine/...` | 任务流程引擎,前端仅用它换 Token | 否 |
**这意味着:所谓「通过接口导入」并不需要官方开放 API —— 前端自己就是一个接口客户端,我们只要复刻它的调用序列,就能绕开浏览器与 DOM。**
### 2.2 实测结果(只读)
在 `192.168.1.213` 上以 `chenshoulin@goldenstand.cn` 实测,全部通过:
| 调用 | 结果 | 耗时 |
| --- | --- | --- |
| `UserCenter.dbo.usp_Authentication`(账号 + MD5 密码) | 返回 `GuidId/UserName/RealName/Token` | 0.50 s |
| `QuickTeam.usp_GetTeamsByUserId` | 返回 2 个团队(当前项目 / 归档项目) | 0.46 s |
| `QuickTeam.usp_GetProjectsByTeamId` | 返回 **215** 个项目 | 1.73 s |
| `QuickTeam.usp_GetWeeklyWorkSheetsByProjectId` | 单项目 3~4 条工单 | 0.28~1.35 s |
| `QuickTeam.usp_GetWorkSheetById` | 返回完整工单字段(见 §2.5) | ≈0.3 s |
| `QuickTeam.usp_GetProjectUsersByProjectId` | 单项目 14 名可指派人员 | 0.34 s |
| `FileOperationService.FileUpload` | 返回服务器路径 `E:\TSSWCFServices\FileBaseFoloder\WorkSheetImport\<ts>.xlsx` | 0.19 s |
| `TFSOperationService.GetTeamProjects` | 9 个 TFS 项目(`Tools_Dev` 等) | ≈1 s |
| `TFSOperationService.GetTeamByProject` | `Tools_Dev` 下 9 个团队,含 `AreaPath` 与 107 条迭代 | ≈2 s |
**没有使用 Cookie/令牌**:`ExecTable` 不校验身份,登录接口只是前端用来取 `UserId/RealName` 做界面门禁与 `Operator` 字段填充。这一点对实现是便利,对安全是隐患(见 §6 R6)。
配套只读探针已落在 `tools/api_probe.py`,一个 Sprint 的现状扫描(12 个项目、含 N+1 次工单详情)实测 **12.63 s,平均 1.05 s/项目**——这已经比 Selenium 完成一个项目的耗时低两个数量级。
### 2.3 调用链还原(与 Selenium 步骤一一对应)
前端产物中可直接读出以下序列,即本项目的目标行为:
```
登录 usp_Authentication(username, md5(password)) -> GuidId, RealName
建工单 usp_InsertWorkSheet() -> [{WorkSheetId}]
usp_UpdateWorkSheetById(...17 个字段, Operation="new") -> [{Result}]
导入明细 FileUpload(file) -> {FileUploadResult: 服务器路径}
BatchImportFeature(connectionName, param, filePath) -> [{Column1: 0}] (0=成功)
确认工单 usp_GetWorkSheetById(WorkSheetId) -> CurrentVersionId / VersionCode
usp_VerifyWorkSheetById(...) -> 0 (成功)
推送 TFS usp_GetWorkFeaturesByWorkSheetIds(WorkSheetIds) -> FeatureId 列表
BatchImportWorkItemsToTfs(projectName, areaPath, iterationPath)
```
### 2.4 接口契约清单
#### 2.4.1 `CommonService.svc/ExecTable`
| 存储过程 | 参数(Name / DBType) | 返回 |
| --- | --- | --- |
| `UserCenter.dbo.usp_Authentication` | `UserName`/string,`Password`/string(**MD5 摘要**) | 用户行 |
| `QuickTeam.usp_GetTeamsByUserId` | `UserId`/string | 团队行(`TeamId`、`TeamName`、`Role`) |
| `QuickTeam.usp_GetProjectsByTeamId` | `TeamId`/string,`IsTemplate`/string(固定 `"1"`) | 项目行(`ProjectId`、`ProjectName`) |
| `QuickTeam.usp_GetWeeklyWorkSheetsByProjectId` | `ProjectId`/string,`Monday`/string,`Sunday`/string | 工单行(`WorkSheetId`、`WorkSheetCode`、`Status`) |
| `QuickTeam.usp_GetWorkSheetById` | `WorkSheetId`/int | 工单详情(单行) |
| `QuickTeam.usp_GetProjectUsersByProjectId` | `ProjectId`/string | 项目人员(`UserId`、`RealName`) |
| `QuickTeam.usp_InsertWorkSheet` | **无参数** | `[{WorkSheetId}]` |
| `QuickTeam.usp_UpdateWorkSheetById` | 见下表 | `[{Result}]`,>0 表示成功 |
| `QuickTeam.usp_VerifyWorkSheetById` | `WorkSheetId`/int,`VersionId`/int,`NextOperator`/string,`NextVersionCode`/string,`Percent`/int,`Operator`/string | `0` = 成功(走 `ExecResult`) |
| `QuickTeam.usp_GetWorkFeaturesByWorkSheetIds` | `WorkSheetIds`/string(逗号分隔) | 功能模块行(含 `FeatureId`、`FeatureCode`) |
| `QuickTeam.usp_DeleteWorkSheetAllById` | `WorkSheetId`/int | 删除整单(**回滚用**) |
`usp_UpdateWorkSheetById` 的 17 个参数(前端 `chunk-0cadb1de` 中原样提取):
| 参数 | 类型 | 取值来源 |
| --- | --- | --- |
| `WorkSheetId` | string | 上一步返回值 |
| `ProjectId` | string | 项目 GUID |
| `ProjectName` | string | 项目名 |
| `SystemName` | string | **Sprint 名称**(如 `2026 Sprint 10`) |
| `Evaluator` | string | 工单评估人**真实姓名** |
| `TechnicalLeader` | string | 技术负责人真实姓名 |
| `CustomerLeader` | string | 客户负责人真实姓名 |
| `StartTime` / `EndTime` | string | `yyyy-MM-dd` |
| `Status` | string | `00`~`04`(见 §2.6) |
| `ServiceType` | string | 默认 `DevelopService` |
| `ImplementContent` | string | 实施内容,默认 `需求开发测试` |
| `OtherInstructions` | string | 其它说明(可空) |
| `Operator` | string | 操作人真实姓名 |
| `DeleteFlag` | string | 固定 `"0"` |
| `Operation` | string | 新建固定 `"new"`,修改 `"update"` |
| `BelongTeam` | string | 所属团队(如 `资管投资人事业部`) |
#### 2.4.2 `FileOperationService.svc`
| 操作 | 请求 | 返回 |
| --- | --- | --- |
| `FileUpload?fileName=<ts>.xlsx&fileFolder=WorkSheetImport` | `multipart/form-data`,文件字段名不限(实测 `file`/`upload` 均可) | `{"FileUploadResult":"E:\\TSSWCFServices\\FileBaseFoloder\\WorkSheetImport\\<ts>.xlsx"}` |
| `BatchImportFeature` | `{connectionName:"QuickTeam", param:<同 ExecTable>, filePath:"<上一步路径>"}`,其中 `SPName = QuickTeam.usp_BatchImportFeatureFromExcel`,`SQLParams = [WorkSheetId/int, VersionId/int]` | `[{Column1: 0}]`,**0 为成功** |
#### 2.4.3 `TFSOperationService.svc`
| 操作 | 请求 | 返回 |
| --- | --- | --- |
| `GetTeamProjects` | 空 body | TFS 项目列表(实测 9 个:`BeiJing_Dev`、`Tools_Dev`、`ShangHai_Dev`…,含 `ProjectId`) |
| `GetTeamByProject` | `{projectName:"Tools_Dev", projectId:"adbb5ee9-..."}` | 团队列表,每项含 `TeamName`、`AreaPath: ["Tools_Dev\\DataTechnologies"]`、`Iterations: [{Id, Name, Path}]` |
| `BatchImportWorkItemsToTfs` | `{connectionName:"QuickTeam", param:<SPName=[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds], FeatureIds:"1,2,3">, projectName, areaPath, iterationPath}` | 推送结果 |
**推送所需的两个路径都能自动取到**(这点对稳定性很关键):
```
GetTeamProjects() -> Tools_Dev 的 ProjectId
GetTeamByProject("Tools_Dev", pid) -> teams[];team.AreaPath[0] = "Tools_Dev\DataTechnologies"
team.Iterations[].Path 形如 "Tools_Dev\2026 Sprint\2026 Sprint 10"
```
按 Sprint 名称(`2026 Sprint 10`)在 `Iterations` 里精确匹配即可得到 `iterationPath`,`areaPath` 取所选团队的 `AreaPath[0]`——**无需在配置里手工维护迭代路径**。前端 `GetIterationPaths` 在当前部署上返回 404(服务端未暴露该端点),但走 `GetTeamByProject` 已完全覆盖其用途。
### 2.5 工单数据模型(实测)
```json
{
"WorkSheetId": 225072,
"WorkSheetCode": "信托 - 上海信托_20260830_225072",
"ProjectId": "0f52024c-...",
"ProjectName": "信托 - 上海信托",
"SystemName": "2026 Sprint 8",
"Evaluator": "陈首林",
"TechnicalLeader": "陈首林",
"CustomerLeader": "陈首林",
"StartTime": "/Date(1785081600000)/",
"EndTime": "/Date(1788019200000)/",
"Status": "01",
"ServiceType": "DevelopService",
"ImplementContent": "需求开发测试",
"CurrentVersionId": 260113,
"VersionCode": "技术责任人确认版",
"BelongTeam": "资管投资人事业部",
"Finished": "00",
"DeleteFlag": "0"
}
```
要点:
- 日期是 **.NET `/Date(ms)/`** 格式,需转换;
- 工单标识 = `WorkSheetCode`(`项目名_日期_WorkSheetId`),业务唯一键实际是 **(ProjectId, SystemName)**;
- `SystemName` 就是 Sprint 名称,是幂等判定的关键字段。
### 2.6 工单状态与版本机
前端 `EffortListVersionInfo` 是硬编码常量,即审批链:
| 版本 | 版本名(VersionCode) | 确认后流转到 | NextOperator | NNextOperator | Percent |
| --- | --- | --- | --- | --- | --- |
| 01 | 技术责任人确认版 | 客户责任人确认版 | TechnicalLeader | CustomerLeader | 20 |
| 02 | 客户责任人确认版 | 甲方确认版 | CustomerLeader | CustomerLeader | — |
| 03 | 甲方确认版 | 流程结束 | CustomerLeader | — | — |
状态码:
| Status | 含义 |
| --- | --- |
| 00 | 等待技术责任人确认 |
| 01 | 等待客户责任人确认 |
| 02 | 等待甲方确认 |
| 03 / 04 | 甲方已确认 |
**「确认工单」= 推进版本**:调用 `usp_VerifyWorkSheetById` 时必须传当前版本的 `NNextOperator` 作为 `NextOperator`、`NextVersionName` 作为 `NextVersionCode`;技术责任人确认版这一步 `Percent=20`。这些值不要写死,应从 `usp_GetWorkSheetById` 返回的 `VersionCode` 映射得到(本表即映射依据)。
### 2.7 Excel 模板
上传的 xlsx 是「工作量评估表」模板(`Sheet1`),由**服务器端** `usp_BatchImportFeatureFromExcel` 按固定单元格位置解析——基本信息区(项目名称/起止时间/系统名称/三个责任人/实施内容)+ 明细表(服务模块代码、服务模块、服务内容、详细说明、需求/开发/测试/实施分项、小计)。
因此:
- **导入阶段必须沿用现有模板**,不能改结构;`D:\Work\QuickTeam\*` 下的文件已是该模板;
- 客户端**不需要**解析 Excel(这是原方案的一个隐含优点,本项目保留);
- 解析有副作用(直接写库),所以每次导入都必须是幂等的(见 §4.5)。
---
## 3. 可行性结论
### 3.1 技术可行(已验证部分)
| 验证项 | 状态 |
| --- | --- |
| 登录 / 取用户身份 | ✅ 实测通过 |
| 团队、项目、人员、工单查询 | ✅ 实测通过(215 项目 / 单项目 3~4 工单) |
| 文件上传通道 | ✅ 实测通过,返回服务器绝对路径 |
| 读取链路端到端 | ✅ `tools/api_probe.py` 已跑通一个 Sprint 的现状扫描 |
| 写链路(建单/导入/确认/推送) | ⚠️ 契约已完整还原,**尚未实写**(避免污染生产数据),需在测试项目上做一次小批量联调 |
### 3.2 收益
| 指标 | 现状(Selenium) | 接口方案(估算) | 依据 |
| --- | --- | --- | --- |
| 单项目耗时 | 2~3 min | 10~25 s | 实测读 1.05 s/项目;写操作 4~5 次 + 上传 0.19 s + TFS 推送(未知,按 5~15 s 估) |
| 一个 Sprint(12 项目) | 25~35 min | 3~6 min | 线性外推 |
| 人工介入 | 必须人在前台看着 | 可无人值守 + 微信/邮件告警 | 无浏览器 |
| 失败恢复 | 整批中断、可能重复建单 | 台账驱动,按阶段续跑 | §4.5 |
| 环境依赖 | Chrome + Driver + 分辨率 + DOM 版本 | 只要能访问 HTTP 服务 | — |
| 前端改版影响 | 直接失效 | 无影响(契约是存储过程) | — |
### 3.3 风险可控但需正视
接口方案把「UI 不确定性」换成了「服务端契约不确定性」:不动的是存储过程名与参数,动的是**我们无法从代码里看到的分支逻辑**(例如 `usp_InsertWorkSheet` 无参却要返回合法 WorkSheetId、Excel 解析的隐含校验、TFS 重复推送的行为)。因此设计上必须把「可重入 + 可回滚 + 可审计」做成第一等公民,而不是事后补。
### 3.4 被否决的方案
| 方案 | 否决原因 |
| --- | --- |
| 继续优化 Selenium(显式等待、并发多实例) | 只是把不稳定的边界往后推,前端一改版仍然全废;并发浏览器成本高 |
| 直连数据库改表 | 绕过业务逻辑与审批链,无审计,风险远高于收益 |
| 抓包录制重放(HAR → 固定请求) | 与直接调接口等价,但可维护性更差 |
---
## 4. 系统设计
### 4.1 目标与非目标
**目标**
- 用接口完成「建工单 → 导入 Excel 明细 → 确认工单 → 推送 TFS」全流程;
- 一个 Sprint 批量执行,支持 `plan`(只读预演)/ `apply`(执行)/ `resume`(续跑)/ `report`;
- 幂等:重复执行不产生重复工单、不重复推送;
- 可观测:每一步有结构化日志与报告,失败可定位到具体阶段与接口。
**非目标**
- 不替代 QuickTeam 的审批流程(只做「技术责任人」这一跳);
- 不改服务器端、不改 Excel 模板;
- 不做 Web 界面(CLI 优先,稳定后再考虑)。
### 4.2 技术选型
| 项 | 选择 | 理由 |
| --- | --- | --- |
| 语言 | Python 3.12 | 与现有脚本同栈,团队可直接接手;生产环境已有 3.12 |
| HTTP | 标准库 `urllib`(可选 `requests`) | 零依赖,便于离线部署;只有 3 个服务、8 个操作,不需要框架 |
| 状态存储 | SQLite(单文件) | 无需服务,天然支持事务与断点;可直接用 DB 工具排查 |
| 日志 | JSONL + 控制台摘要 | 机器可读,便于后续接告警 |
| 测试 | pytest + 录制的响应 fixtures | 契约回放可在无网环境跑 |
| 打包 | 单目录 + `python -m qtimport` | 内网机器直接拷贝运行 |
### 4.3 分层架构
```
┌──────────────────────────────────────────────────────┐
│ cli plan / apply / resume / report │
├──────────────────────────────────────────────────────┤
│ workflow 单项目编排、阶段推进、补偿、并发调度 │
├──────────────────────────────────────────────────────┤
│ domain 工单聚合:查重、建单、填写、确认、推送 │
├──────────────────────────────────────────────────────┤
│ api SP 语义封装(login/projects/worksheets…) │
├──────────────────────────────────────────────────────┤
│ transport WCF 网关:ExecTable/ExecResult/Upload │
│ 双层 JSON 解码、错误解析、重试、耗时埋点 │
├──────────────────────────────────────────────────────┤
│ store SQLite 台账 + 运行报告 │
└──────────────────────────────────────────────────────┘
```
分层原则:**transport 不含业务,domain 不含 HTTP**。这样存储过程改名、端点迁移只影响一层;而幂等判定全部集中在 workflow/domain,不散落在 HTTP 调用里。
### 4.4 单项目时序
```
plan ─┬─ 登录 → 取 TeamId → 取项目列表(1 次,缓存)
├─ 读 xlsx 目录 → 项目名规范化匹配(精确优先,模糊仅提示)
└─ 查该项目历史工单 → 比对 SystemName → 输出计划(create / skip / resume)
apply ─┬─ [create] InsertWorkSheet() → UpdateWorkSheetById(Operation="new")
├─ [detail] GetWorkSheetById → CurrentVersionId / VersionCode
├─ [import] FileUpload(xlsx) → BatchImportFeature(WorkSheetId, VersionId)
├─ [verify] VerifyWorkSheetById(NextOperator, NextVersionCode, Percent)
└─ [push] GetTeamProjects → GetTeamByProject(解析 area/iteration)
GetWorkFeaturesByWorkSheetIds → BatchImportWorkItemsToTfs
```
每一步落台账后才进入下一步(write-ahead),因此任何时刻中断都能从记录点续跑。
### 4.5 幂等与断点(核心设计)
**业务唯一键**:`(ProjectId, SystemName)` —— 同一项目同一 Sprint 只应有一张工单。
**台账表**:
```sql
CREATE TABLE import_job (
run_id TEXT NOT NULL,
project_id TEXT NOT NULL,
project_name TEXT NOT NULL,
system_name TEXT NOT NULL, -- Sprint
source_file TEXT NOT NULL,
worksheet_id INTEGER, -- 建单后回填
version_id INTEGER,
stage TEXT NOT NULL, -- 见下
tfs_project TEXT,
iteration TEXT,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (run_id, project_id)
);
CREATE TABLE import_event ( -- 追加型审计流水
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
project_id TEXT,
stage TEXT NOT NULL,
action TEXT NOT NULL, -- call/ok/fail/skip
detail TEXT,
elapsed_ms INTEGER,
created_at TEXT NOT NULL
);
```
**阶段机**:
```
PLANNED → WS_CREATED → WS_FILLED → EXCEL_IMPORTED → WS_VERIFIED → TFS_PUSHED
└──────────┴────────────┴──────────────┴──→ FAILED(stage, error)
```
**重入规则**(`apply` 前先跑一次判定,而不是无脑建单):
| 现场状态 | 动作 |
| --- | --- |
| 无该 Sprint 的工单 | 新建(create) |
| 有工单,`Status` ∈ {00,01},且台账显示是本工具创建 | **resume**:从台账记录的下一阶段继续,不重复建单 |
| 有工单,`Status` ∈ {00,01},但台账无记录(人工建的/上次工具崩了) | 默认 **skip 并告警**;加 `--adopt` 才接管续跑(保守优先,避免误改别人的单) |
| 有工单,`Status` ∈ {03,04} | **skip**(已闭环) |
| 有工单且已推送 TFS | **skip**(推送不可逆) |
**补偿(回滚)**:仅当「本次创建 + 尚未推送 TFS + 在导入/确认阶段失败」时,`--rollback-on-fail` 会调 `usp_DeleteWorkSheetAllById` 清掉半成品。已推送的一律不删——TFS 侧工作项已产生,必须人工处理,这是硬边界。
**并发**:`--workers 2` 默认(写路径串行度受服务端 SP 与 TFS 限流约束),读路径可 4。并发下每个项目独立事务,互不影响;工单编号由服务端生成,不存在本地竞争。
### 4.6 配置与凭据
```json
{
"base_url": "http://192.168.1.213",
"username": "chenshoulin@goldenstand.cn",
"password": "(不入库,见下)",
"team_name": "当前项目",
"work_dir": "D:/Work/QuickTeam/2026 Sprint 10",
"sprint": "2026 Sprint 10",
"start_time": "2026-10-01",
"end_time": "2026-10-31",
"content_desc": "需求开发测试",
"tfs_project": "Tools_Dev",
"tfs_team": "DataTechnologies",
"area_path": "",
"iteration_path": ""
}
```
`area_path` / `iteration_path` 留空即自动解析(`GetTeamByProject` → 按 Sprint 名匹配),填了则作为覆盖值使用。`tfs_team` 决定 `AreaPath`,缺省取该 TFS 项目的第一个团队。
凭据优先级:`QT_PASSWORD` 环境变量 → Windows DPAPI 加密文件 → 交互式输入。**明文密码不进仓库、不进日志**(`config.json` 已在 `.gitignore`)。
### 4.7 CLI
```bash
# 只读预演:打印将要做什么,不写任何数据
qtimport plan --config config/config.json
# 执行(默认幂等,已完成的跳过)
qtimport apply --config config/config.json --workers 2
# 指定项目重跑(失败重试的常用姿势)
qtimport apply --config config/config.json --project "信托 - 上海信托" --from-stage EXCEL_IMPORTED
# 出报告
qtimport report --run-id 20261105-1030
```
### 4.8 错误处理
WCF 的错误是 HTML 页面,真正的信息在 `The exception message is '...'` 里,必须解析出来再分类,否则只会看到 `HTTP 400`:
| 分类 | 判定特征 | 策略 |
| --- | --- | --- |
| `NET` | `URLError` / timeout | 重试 2 次(指数退避),仍失败则标记该阶段 |
| `SP_MISSING` | `Could not find stored procedure` | 不重试(配置/版本问题),整批中止并告警 |
| `PARAM` | `missing parameter` / `conversion` | 不重试,记到项目级错误,继续下一个项目 |
| `EXCEL` | `BatchImportFeature` 返回 `Column1 != 0` | 不重试,标记该文件模板不符,继续 |
| `TFS` | 推送异常 | 不重试,标记并保留工单(可人工补推) |
| `AUTH` | 登录返回空 | 立即中止(避免锁号) |
另有两个必须处理的协议细节:
1. **双层 JSON**:返回值常是「JSON 字符串里再套一层 JSON」,需循环解码 ≤3 次;
2. **BOM**:错误页带 UTF-8 BOM,需 `utf-8-sig` 解码。
### 4.9 报告
每次 run 产出:
- `reports/<run_id>/summary.md`:项目 / 文件 / 工单号 / 阶段 / 耗时 / 结论(create|skip|resume|fail);
- `reports/<run_id>/detail.csv`:逐阶段明细,便于 Excel 复核;
- `logs/<run_id>.jsonl`:结构化调用日志(含每次 HTTP 的 `sp`、`elapsed_ms`、`result`)。
### 4.10 目录结构(拟)
```
QTImport/
├── README.md
├── config/
│ ├── config.example.json
│ └── config.json # gitignore
├── docs/
│ └── QuickTeam-API导入-调研与设计.md
├── tools/
│ └── api_probe.py # 只读探针(已完成,可独立交付)
├── qtimport/
│ ├── __main__.py # CLI 入口
│ ├── transport.py # WCF 网关
│ ├── api.py # SP 语义封装
│ ├── domain.py # 工单聚合与状态机
│ ├── workflow.py # 编排 / 并发 / 补偿
│ ├── store.py # SQLite 台账
│ ├── report.py # 报告
│ └── config.py
├── tests/
│ ├── fixtures/ # 录制的真实响应
│ └── test_*.py
└── pyproject.toml
```
### 4.11 测试策略
| 层级 | 做法 |
| --- | --- |
| 单元 | payload 构造(`encodeURIComponent`+双层 JSON)、`/Date(ms)/` 转换、错误页解析、项目名规范化、状态机流转 |
| 契约回放 | 把本次实测的真实响应存 `tests/fixtures/`,离线断言字段与解码层数(防止服务端升级后静默失败) |
| 集成 | 在一个**专用测试项目**上跑 1 张工单:建 → 导 → 确认,人工核对 UI 与 DB,再删单 |
| 灰度 | 一个真实 Sprint 中挑 2 个项目跑接口,其余照旧;逐字段 diff(明细行数、小计、三个责任人、TFS 工作项数) |
| 回归 | 全量切换后,用 `plan` 的只读结果与 UI 页面勾兑一次 |
### 4.12 实施计划
| 里程碑 | 内容 | 交付 | 预估 |
| --- | --- | --- | --- |
| M0 | 调研 + 只读探针(**已完成**) | 本文档 + `tools/api_probe.py` | — |
| M1 | 写链路代码:transport/api/domain + `plan`/`apply`(**已实现**,未真实写入) | `qtimport/` 包 | — |
| M2 | 幂等/断点/补偿/并发/报告(**已完成**) | SQLite 台账 + 报告 | — |
| M3 | 真实联调:一个测试项目跑通建单→导入→确认(**待执行**) | 联调记录 | 0.5 d |
| M4 | 灰度一个 Sprint(2 个项目)+ 全量切换 | 切换说明 | 0.5 d |
| M5 | 退役 Selenium 脚本,保留为应急兜底 | — | — |
M3 是唯一剩下的卡点:**写链路一旦真跑通,后面都是工程量**。
---
## 5. 与现有脚本的对应关系
| 现有能力 | 新实现 | 备注 |
| --- | --- | --- |
| `get_project_name_list` + `done.txt` | 读目录 + SQLite 台账 | 台账可查、可重入,替代文本文件 |
| `login` | `usp_Authentication` | 无浏览器 |
| `open_team` / `open_project` | `usp_GetTeamsByUserId` / `usp_GetProjectsByTeamId` | 项目名做规范化精确匹配,避免 `contains` 误命中 |
| `create_work_order` | `InsertWorkSheet` + `UpdateWorkSheetById` | 字段一一对应 |
| `entry_work_order_info`(上传 xlsx) | `FileUpload` + `BatchImportFeature` | 服务器解析逻辑不变 |
| 「确认工单」点击 | `VerifyWorkSheetById` | 需按版本表推导 NextOperator/Percent |
| `push_work_order_to_tfs` | `GetWorkFeaturesByWorkSheetIds` + `BatchImportWorkItemsToTfs` | 全量勾选 = 拼全部 FeatureId |
| 错误页 dump HTML | 解析 `exception message` + 结构化日志 | 从「事后看截图」变「当场有原因」 |
---
## 6. 风险与开放问题
| 编号 | 风险/问题 | 影响 | 对策 | 优先级 |
| --- | --- | --- | --- | --- |
| R1 | 写链路未实测(`InsertWorkSheet` 无参、`UpdateWorkSheetById` 的 `Result` 语义、Excel 解析副作用) | 阻塞实施 | M1 在测试项目上小批量验证;先建后删,验证补偿可用 | **P0** |
| R2 | `GetIterationPaths` 404(服务端未暴露) | 原本以为迭代路径不可取 | 已解决:改走 `GetTeamByProject`,返回团队 `AreaPath` 与 `Iterations[].Path`,按 Sprint 名精确匹配 | — |
| R3 | 迭代/区域路径匹配失败(Sprint 名与 TFS 迭代命名不一致,如 `2026 Sprint 06` 补零) | 推送会失败 | 匹配时做归一化(去空格、去前导零、忽略大小写);匹配不到则**拒绝推送并报错**,不猜 | P1 |
| R4 | TFS 重复推送可能产生重复工作项(服务端无幂等标记) | 数据脏 | 台账记录 `TFS_PUSHED`,推过即不再推;报告里显式列出 | P1 |
| R5 | 服务器端 Excel 解析耗时未知(明细多时可能数十秒) | 影响单项目耗时估算与超时设置 | 首次联调即埋点计时;超时按实测值 × 2 设定 | P2 |
| R6 | `ExecTable` 无鉴权(实测无 Cookie 可读写任意 SP);密码 MD5 明文传输 | 安全 | 仅在内网机器执行、不使用管理员账号;不新增对外暴露;建议后续接入 SSO / 网络层限制 | P2 |
| R7 | 服务器 `WorkSheetImport` 目录只增不删(`FileBaseFoloder`) | 磁盘堆积 | 上传文件名带 run_id 前缀;提供清理脚本;联系运维确认保留策略 | P3 |
| R8 | 存储过程改名/加参(前端同步改) | 契约失效 | 契约回放测试会在字段缺失时立刻报错;版本化 `api.py` 的 SP 映射表 | P3 |
| R9 | 凭据落盘 | 泄露 | `config.json` 已 gitignore;支持环境变量/DPAPI;日志脱敏 | P3 |
---
## 7. 附录
### A. 实测请求样例(脱敏)
```http
POST /Services/CommonService.svc/ExecTable HTTP/1.1
Content-Type: application/json
{"connectionName":"QuickTeam","param":"%7B%22SPName%22%3A%22QuickTeam.usp_GetProjectsByTeamId%22%2C%22SQLParams%22%3A%5B%7B%22Name%22%3A%22TeamId%22%2C%22Value%22%3A%22090F8E57-...%22%2C%22DBType%22%3A%22string%22%7D%2C%7B%22Name%22%3A%22IsTemplate%22%2C%22Value%22%3A%221%22%2C%22DBType%22%3A%22string%22%7D%5D%7D"}
```
响应(注意外层是字符串,需二次解码):
```
"[{\"ProjectId\":\"0f52024c-...\",\"ProjectName\":\"信托 - 上海信托\",...}, ...]"
```
### B. 前端产物中的存储过程清单
共 210 个(`QuickTeam.*` + `UserCenter.*`),本项目涉及 13 个存储过程与 6 个服务操作。完整清单可通过以下命令复现:
```bash
# 抓取并统计(在任一可访问 QuickTeam 的机器上)
curl -s http://192.168.1.213/QuickTeam/ -o index.html
grep -oE '/QuickTeam/js/[A-Za-z0-9._-]+\.js' index.html | sort -u | \
while read p; do curl -s "http://192.168.1.213$p"; done | \
grep -oh '[A-Za-z][A-Za-z0-9_]*\.usp_[A-Za-z0-9_]*' | sort -u
```
### C. Selenium 耗时拆解(按代码 sleep 累加)
| 函数 | 固定 sleep | 主要来源 |
| --- | --- | --- |
| `open_project` | 17.5 s | 搜索 0.5+1+1+2、点击 3、页签 10 |
| `create_work_order` | 41 s | 新增 10、保存 10、三个责任人各 3、其余 5 |
| `entry_work_order_info` | 14 s | 三次点击各 3、返回 5 |
| `push_work_order_to_tfs` | 56 s | 开头 20、选项目后 20、其余 16 |
| **合计** | **≈128 s** | 不含元素等待/渲染/网络 |
### D. 探针工具
```bash
# 现状扫描(只读)
python tools/api_probe.py --config config/config.json
# 额外验证上传通道(会在服务器留一个临时文件,不解析、不写库)
python tools/api_probe.py --config config/config.json --upload-test
# 输出 JSON 供后续程序消费
python tools/api_probe.py --config config/config.json --json-out reports/probe.json
```
实测输出(2026 Sprint 10,12 个文件):
```
[登录] 陈首林 <chenshoulin@goldenstand.cn> UserId=86334126-... 0.50s
[团队] 当前项目 (090F8E57-...) 0.46s
[项目] 共 215 个 1.73s
信托 - 上海信托 4 否 待创建
...
合计 12 个文件:待创建 12,已存在 0,异常 0
[耗时] 扫描 12.63s 平均 1.05s/项目
```
---
## 8. 实现说明(2026-11-05)
代码已落地,与设计的对应关系:
| 设计章节 | 实现 |
| --- | --- |
| §4.3 分层 | `qtimport/transport.py`(HTTP+协议)、`api.py`(SP 语义)、`domain.py`(纯规则)、`workflow.py`(编排)、`store.py`(台账)、`report.py`(报告)、`__main__.py`(CLI) |
| §4.4 时序 | `workflow.Importer._run_project`,阶段常量 `STAGES` |
| §4.5 幂等 | `store.latest_job` 跨 run 取最近阶段;dry-run 的 run 不参与判定(避免演练被当成已完成) |
| §4.5 补偿 | `--rollback-on-fail`,仅在「本次创建 + 未到确认阶段」时调 `usp_DeleteWorkSheetAllById` |
| §4.7 CLI | `plan` / `apply` / `report` / `status`,真实写入需 `--yes` |
| §4.8 错误 | `errors.py` 把 WCF 错误页分类为 NET/SP_MISSING/PARAM/EXCEL/TFS/AUTH,决定重试或中止 |
| §4.11 测试 | `tests/` 69 个用例(unittest,无需网络),覆盖解码、错误分类、项目匹配、动作判定、版本推进、参数构造、回滚、续跑、工单列表去重、待确认版本选择、TFS 状态码 |
实现期新增的两个设计约束(原方案没写细):
1. **查询窗口与业务窗口分离**:`StartTime/EndTime` 是写进工单的业务数据,必须精确;查「是否已有工单」时用前后各放宽 30 天的窗口,避免边界漏查。
2. **Excel 上传前预检**:读 `xl/sharedStrings.xml` 确认含「工作量评估表」「服务模块」,避免把一个错文件送到服务器解析(`Column1 != 0` 的失败代价更高)。
实测(`--dry-run`,12 个项目):19.2 s,0 失败,TFS 上下文自动解析为
`area=Tools_Dev\DataTechnologies`、`iteration=Tools_Dev\2026 Sprint\2026 Sprint 10`。
### 8.1 真实联调(2026-11-05,Sprint 2026 Sprint 10)
单项目「资管 - 华泰保险资管」跑通全链路:
| 阶段 | 结果 |
| --- | --- |
| 建单 + 填单 | `usp_InsertWorkSheet` → 225134,`usp_UpdateWorkSheetById` 后 `Status=00`、`WorkSheetCode=资管 - 华泰保险资管_20261031_225134` |
| 导入明细 | `FileUpload` → `usp_BatchImportFeatureFromExcel` → `Column1=0`,2 个功能模块(测试工时 12 / 10.5,与 Excel 一致) |
| 确认 | `usp_VerifyWorkSheetById` → `Status=01`、`CurrentVersionId=260220`、`VersionCode=技术责任人确认版` |
| 推送 TFS | `BatchImportWorkItemsToTfs` 返回创建数;`area=Tools_Dev\DataTechnologies`、`iteration=Tools_Dev\2026 Sprint\2026 Sprint 10` |
耗时 3.5 s(建单→确认),推送 1.5 s;含登录与规划整轮 20 s,Selenium 同流程按代码 sleep 累加约 15 分钟。
### 8.2 联调暴露的五个真问题(都已修)
**① `FileUpload` 必须发裸字节,不能套 multipart。**
前端是 `data: e`(原生 `File` 对象)→ XHR 直接发送文件字节,WCF 按 `Stream` 收下后原样落盘。
发 multipart 时服务器把整段包体写进 `.xlsx`,文件损坏 → 服务端解析出空 `DataTable` →
`SqlBulkCopyToDB` 抛 `Object reference not set to an instance of an object`。
这个错误极有误导性:它同时对「不存在的路径」「非 xlsx 文件」也报同样的错,看上去像参数问题。
**② 导入要用「待确认版本」,不能用 `CurrentVersionId`。**
`usp_UpdateWorkSheetById` 只在 `WorkSheet.Status` 长度为 0(第一次保存)时插入版本行
(技术责任人确认版,`VersionStatus='N'`),但**不回填** `CurrentVersionId` —— 那要等
`usp_VerifyWorkSheetById` 才写。所以首次导入时 `CurrentVersionId` 是 `NULL`,拿它当
`VersionId` 会插到不存在的版本上,报 `FK_WorkSheetTask_WorkSheetVersion` 冲突。
版本号要从 `usp_GetWorkSheetsById`(列表页那个 SP,一行一个版本)里取 `VersionStatus='N'` 的那一行。
**③ `usp_GetWeeklyWorkSheetsByProjectId` 名为按周,实际按季度过滤。**
SP 里 `@Sunday` 完全没用到,只用 `@Monday` 推出所在季度的起止:
`(StartTime between 季初 and 季末) or (EndTime between 季初 and 季末)`。
业务窗口 10-08~10-31 前后各放宽 30 天后起点落在 9 月(Q3),于是 Q4 的工单被整段漏掉 ——
`plan` 会误判「无工单」并重复建单(这条差点在批量执行时踩到)。
最终不再用它:改用 `usp_GetWorkSheetsById` 一次取回项目全部工单(含 SystemName / Status /
CurrentVersionId),既不漏也不受季度影响,还顺带消掉了「每张工单再查一次详情」的 N+1
(plan 从 17 s 降到 10.6 s)。
**④ `BatchImportWorkItemsToTfs` 返回的是状态码,不是数量。**
实测:正常推送返回 `0`,把 `projectName` 换成不存在的值返回 `-1`。最初把 `0` 误判成
「一件都没建」而报失败,结果 11 个项目的推送被判错(实际都已建出工作项),只能改台账而不是重跑 ——
因为该 SP 只是 `select` 出待建清单,本身不记录「已推送」状态,**重复推送会产生重复工作项**,
`TFS_PUSHED` 阶段完全靠台账防重。
**⑤ 跳过行不能覆盖台账阶段。**
`apply` 对不执行的行也写了一条台账(`stage='SKIPPED'`)用于报告,但下一轮 `latest_job`
取到它之后,`decide` 会把「已推送 TFS」当成「台账停在 SKIPPED」判成 `resume` —— 接着就是重复推送。
修法:`latest_job` 排除 `SKIPPED`,且已有真实记录时不再写跳过行(只记事件)。
### 8.3 新增工具
| 工具 | 用途 |
| --- | --- |
| `tools/api_probe.py` | 零依赖只读探针,扫描一个 Sprint 的工单现状 |
| `tools/sp_source.py` | 只读导出存储过程源码。网关 `ExecTable` 能直接执行 `sys.sp_helptext`,不需要数据库账号 —— 排查参数语义比猜快得多(`docs/sp/` 下已存 6 个关键 SP) |
| `tools/diag_import.py` | 建草稿→填单→上传→试各种 `VersionId`→删单,用于定位导入阶段问题 |
> 顺带发现:网关允许把任意 SP 名塞进 `ExecTable`,包括 `sys.sp_helptext`、`sys.sp_columns`、
> `sys.sp_executesql`。也就是说**无凭证即可执行任意只读甚至写 SQL**,这是服务端的安全问题,
> 不在本项目范围内,但值得单独提给 QuickTeam 维护方。
### 8.4 全量执行结果(Sprint 2026 Sprint 10,2026-11-05)
12 个项目全部执行完成(`apply --yes --rollback-on-fail`,并发 2,整轮 175 s):
| 项目 | 工单 | 功能模块 |
| --- | --- | --- |
| 资管 - 华泰保险资管 | 225134 | 2 |
| 内部研发 - QuickDealService | 225135 | 1 |
| 信托 - 上海信托 | 225136 | 9 |
| 汽金 - 长安汽车 | 225137 | 1 |
| 汽金 - 吉致汽车 | 225138 | 1 |
| 消金 - 平安消金 | 225139 | 1 |
| 消金 - 滴滴金融 | 225140 | 2 |
| 消金 - 美团金融 | 225141 | 1 |
| 清算 - 海南清算所 | 225142 | 4 |
| 资管 - 华泰资管 | 225143 | 7 |
| 资管 - 招商信诺 | 225144 | 1 |
| 资管 - 粤海资本 | 225145 | 5 |
核对口径:每张工单 `SystemName=2026 Sprint 10`、`Status=01`(等待客户责任人确认)、
功能模块数与对应 xlsx 明细行数逐文件相等(12 张工单合计 35 个,其中本轮新建 11 张 33 个)。复跑 `plan` 12 个项目全为 `skip-pushed`。
旧方案(Selenium)同流程约 25~35 分钟,且失败重跑可能重复建单。