# 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\.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=.xlsx&fileFolder=WorkSheetImport` | `multipart/form-data`,文件字段名不限(实测 `file`/`upload` 均可) | `{"FileUploadResult":"E:\\TSSWCFServices\\FileBaseFoloder\\WorkSheetImport\\.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:, 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//summary.md`:项目 / 文件 / 工单号 / 阶段 / 耗时 / 结论(create|skip|resume|fail); - `reports//detail.csv`:逐阶段明细,便于 Excel 复核; - `logs/.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 个文件): ``` [登录] 陈首林 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 分钟,且失败重跑可能重复建单。