feat(workflow): Phase 5 智能测试经理工作流、前置强依赖校验与文档血缘级联失效
This commit is contained in:
@@ -113,6 +113,10 @@ func NewService(cfg *config.Config) *Service {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pwSvc != nil && doc.TaskKey == "system_reqs" {
|
||||
_, _ = pwSvc.CheckAndPropagateStale(ctx, doc.DocID, doc.ContentSHA256, fmt.Sprintf("上游系统需求规格书 V%d 已正式批准,已标记关联下游验证方案为待复核(stale)", doc.Version))
|
||||
}
|
||||
})
|
||||
|
||||
return svc
|
||||
@@ -280,6 +284,38 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
if docID == "" {
|
||||
docID = fmt.Sprintf("doc_proj%d_%s", req.ProjectID, req.TaskKey)
|
||||
}
|
||||
// 智能测试经理前置检查:必须依赖同项目已批准的系统需求规格书 (system_reqs)
|
||||
var upstreamSRDoc *docgov.DocumentRecord
|
||||
if req.TaskKey == "test_manager" {
|
||||
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||||
if s.docGov != nil && req.ProjectID > 0 {
|
||||
doc, err := s.docGov.GetDocument(ctx, upstreamDocID)
|
||||
if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
|
||||
upstreamSRDoc = doc
|
||||
}
|
||||
}
|
||||
if upstreamSRDoc == nil {
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
ArtifactTitle: artifactTitle,
|
||||
WorkDir: req.WorkDir,
|
||||
Success: false,
|
||||
ErrorReason: "智能测试经理工作流前置条件未满足:必须先完成同项目的《系统需求规格书》并经批准 (approved) 后方可启动系统验证编制",
|
||||
}, projectworkflow.ErrPrerequisiteNotMet
|
||||
}
|
||||
|
||||
// 自动将已批准系统需求规格书注入为测试方案编制的依据输入
|
||||
if len(materials) == 0 && upstreamSRDoc.WorkDir != "" {
|
||||
contentPath := filepath.Join(upstreamSRDoc.WorkDir, "output.md")
|
||||
if b, err := os.ReadFile(contentPath); err == nil && len(b) > 0 {
|
||||
materials = append(materials, squad.MaterialItem{
|
||||
Filename: "upstream_system_requirements.md",
|
||||
Content: b,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authorID := req.AuthorID
|
||||
if authorID == 0 {
|
||||
authorID = req.UserID
|
||||
@@ -561,6 +597,23 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
}
|
||||
}
|
||||
|
||||
if res.Success && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||||
_ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{
|
||||
ProjectID: req.ProjectID,
|
||||
DownstreamDocID: docID,
|
||||
DownstreamVersion: 1,
|
||||
UpstreamDocID: upstreamSRDoc.DocID,
|
||||
UpstreamVersion: upstreamSRDoc.Version,
|
||||
UpstreamSHA256: upstreamSRDoc.ContentSHA256,
|
||||
IsStale: false,
|
||||
})
|
||||
}
|
||||
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||||
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||
shaStr := hex.EncodeToString(sha[:])
|
||||
_, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核")
|
||||
}
|
||||
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey,
|
||||
ArtifactTitle: res.ArtifactTitle,
|
||||
|
||||
@@ -267,6 +267,8 @@ func writeTaskError(w http.ResponseWriter, err error, payload any) {
|
||||
status = http.StatusConflict
|
||||
} else if errors.Is(err, docgov.ErrDocumentLocked) || errors.Is(err, docgov.ErrForceEditNotAllowed) {
|
||||
status = http.StatusConflict
|
||||
} else if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if payload != nil {
|
||||
writeJSONResponse(w, status, payload)
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
)
|
||||
@@ -228,3 +230,29 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// handleGetProjectLineage 查询项目文档血缘关系及失效状态
|
||||
func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) {
|
||||
docID := strings.TrimSpace(r.URL.Query().Get("doc_id"))
|
||||
if docID == "" {
|
||||
idStr := r.PathValue("id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if id > 0 {
|
||||
docID = fmt.Sprintf("doc_proj%d_test_manager", id)
|
||||
}
|
||||
}
|
||||
if docID == "" {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"doc_id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if lineages == nil {
|
||||
lineages = make([]*projectworkflow.DocumentLineage, 0)
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, lineages)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,3 +156,90 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) {
|
||||
assert.NotEmpty(t, runsList)
|
||||
assert.Equal(t, "completed", runsList[0].Status)
|
||||
}
|
||||
|
||||
func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) {
|
||||
srv, handler := setupTestServerForProjectWorkflow(t)
|
||||
|
||||
// 1. 创建测试项目
|
||||
bodyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||||
Name: "智能肺功能仪",
|
||||
DeviceType: "呼吸诊疗设备",
|
||||
IntendedUse: "肺通气功能评测",
|
||||
TargetMarket: "NMPA Class II",
|
||||
})
|
||||
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyCreate))
|
||||
reqCreate.Header.Set("X-User-ID", "2")
|
||||
recCreate := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recCreate, reqCreate)
|
||||
require.Equal(t, http.StatusCreated, recCreate.Code)
|
||||
var proj projectworkflow.Project
|
||||
_ = json.Unmarshal(recCreate.Body.Bytes(), &proj)
|
||||
|
||||
// 2. 在未批准系统需求规格书时,直接触发 test_manager -> 应触发前置阻断 400 / ERR_PRECONDITION_FAILED
|
||||
bodyRunTestFail, _ := json.Marshal(map[string]any{
|
||||
"task_key": "test_manager",
|
||||
"project_name": proj.Name,
|
||||
})
|
||||
reqRunTestFail := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1001/workflows/test_manager/run", bytes.NewReader(bodyRunTestFail))
|
||||
reqRunTestFail.Header.Set("X-User-ID", "2")
|
||||
recRunTestFail := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recRunTestFail, reqRunTestFail)
|
||||
assert.Equal(t, http.StatusBadRequest, recRunTestFail.Code)
|
||||
assert.Contains(t, recRunTestFail.Body.String(), "前置条件未满足")
|
||||
|
||||
// 3. 在受控治理中创建并批准系统需求规格书
|
||||
srDocID := "doc_proj1001_system_reqs"
|
||||
srDoc, err := srv.BizdevService().DocGov().CreateOrGetDocument(
|
||||
nil, srDocID, 1001, "system_reqs", "系统需求规格书",
|
||||
"V0.5_feasibility", "",
|
||||
2, "张工", 3, "李主管", 4, "王总监",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_ = srv.BizdevService().DocGov().MarkDocumentContentUpdated(nil, srDocID, "hash_v1", false)
|
||||
_, _ = srv.BizdevService().DocGov().SubmitForReview(nil, srDocID, 2, "author", "张工")
|
||||
_, _ = srv.BizdevService().DocGov().ReviewApprove(nil, srDocID, 3, "reviewer", "李主管", "同意")
|
||||
_, err = srv.BizdevService().DocGov().ApproverApprove(nil, srDocID, 4, "approver", "王总监", "批准生效")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "approved", string(srDoc.CurrentState))
|
||||
|
||||
// 4. 前置已批准后,触发 test_manager 运行 -> 成功 200
|
||||
bodyRunTestSuccess, _ := json.Marshal(map[string]any{
|
||||
"task_key": "test_manager",
|
||||
"project_name": proj.Name,
|
||||
"materials": []map[string]string{
|
||||
{
|
||||
"filename": "upstream_system_requirements.md",
|
||||
"content": "# 系统需求\n| REQ-01 | 测量流量误差 <= 3% | 测试 | 高 |\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reqRunTestSuccess := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1001/workflows/test_manager/run", bytes.NewReader(bodyRunTestSuccess))
|
||||
reqRunTestSuccess.Header.Set("X-User-ID", "2")
|
||||
recRunTestSuccess := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recRunTestSuccess, reqRunTestSuccess)
|
||||
assert.Equal(t, http.StatusOK, recRunTestSuccess.Code)
|
||||
|
||||
// 5. 查询文档血缘 -> 存在且 is_stale == false
|
||||
reqLineage := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1001/lineage?doc_id=doc_proj1001_test_manager", nil)
|
||||
recLineage := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recLineage, reqLineage)
|
||||
assert.Equal(t, http.StatusOK, recLineage.Code)
|
||||
var lineages []*projectworkflow.DocumentLineage
|
||||
_ = json.Unmarshal(recLineage.Body.Bytes(), &lineages)
|
||||
require.NotEmpty(t, lineages)
|
||||
assert.Equal(t, srDocID, lineages[0].UpstreamDocID)
|
||||
assert.False(t, lineages[0].IsStale)
|
||||
|
||||
// 6. 上游 system_reqs 发生更新(模拟触发 CheckAndPropagateStale)
|
||||
_, err = srv.ProjectWorkflowService().CheckAndPropagateStale(nil, srDocID, "hash_v2_changed", "需求发生重大变更")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 再次查询下游血缘 -> is_stale 应变为 true
|
||||
recLineage2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recLineage2, reqLineage)
|
||||
var lineagesAfter []*projectworkflow.DocumentLineage
|
||||
_ = json.Unmarshal(recLineage2.Body.Bytes(), &lineagesAfter)
|
||||
require.NotEmpty(t, lineagesAfter)
|
||||
assert.True(t, lineagesAfter[0].IsStale)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,16 @@ func (s *Server) AuthService() *auth.Service {
|
||||
return s.authSvc
|
||||
}
|
||||
|
||||
// BizdevService 获取业务交付服务
|
||||
func (s *Server) BizdevService() *bizdev.Service {
|
||||
return s.svc
|
||||
}
|
||||
|
||||
// ProjectWorkflowService 获取项目多工作流控制服务
|
||||
func (s *Server) ProjectWorkflowService() *projectworkflow.Service {
|
||||
return s.pwSvc
|
||||
}
|
||||
|
||||
// Routes 构建 HTTP 路由分发器
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -87,6 +97,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage)
|
||||
|
||||
// 3. 对话协同服务 (标准 JSON 阻塞)
|
||||
mux.HandleFunc("POST /api/v1/chat", s.handleChat)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 系统验证方案及计划(SVP)
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
- 文件编号:RD-SVP
|
||||
- 记录归属:DHF
|
||||
- 版本:A
|
||||
- 阶段:04 设计验证
|
||||
|
||||
## 结论
|
||||
|
||||
{{conclusion}}
|
||||
|
||||
## 验证范围与策略
|
||||
|
||||
- 产品名称:{{project_name}}
|
||||
- 验证类型:系统级功能、性能、电气安全、环境适应性与电磁兼容测试
|
||||
- 上游依据:系统需求规格书 (RD-SR)
|
||||
- 验证原则:需求 100% 全覆盖,关键风险与基本安全项目双重验证
|
||||
|
||||
## 测试环境与工具
|
||||
|
||||
| 仪器设备/软件工具 | 规格型号/版本 | 校验状态/有效期限 | 用途说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 模拟生理信号源 | 标准多参源 V2.1 | 计量校准合格 | 产生心率/血压模拟基准信号 |
|
||||
| 医用示波器 | 100MHz 数字存储 | 检定合格 | 接口电平及波形质量测量 |
|
||||
| 自动化测试平台 | AutoTest Runner 1.0 | 已确认验证 | 协议与长周期稳定性测试 |
|
||||
|
||||
## 系统测试用例及判定准则
|
||||
|
||||
| 用例 ID | 追溯需求 ID | 测试项与方法说明 | 判定准则与接受限值 | 测试结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| TC-01 | REQ-01 | 按规程输入标准心率模拟信号,采样并计算误差 | 心率测量误差不超过 ±2bpm 或 ±2% | 符合 |
|
||||
| TC-02 | REQ-02 | 连续工作 72 小时无故障与数据漂移测试 | 72 小时内无死机、内存泄漏与通信中断 | 符合 |
|
||||
{{test_cases_table}}
|
||||
|
||||
## 需求覆盖率与追踪矩阵
|
||||
|
||||
| 系统需求 ID | 需求陈述摘要 | 对应测试用例 ID | 覆盖结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-01 | 心率与生理参数监测精度 | TC-01 | 100% 已覆盖 |
|
||||
| REQ-02 | 系统长周期运行稳定性与连续性 | TC-02 | 100% 已覆盖 |
|
||||
{{traceability_matrix_table}}
|
||||
|
||||
## 待确认清单
|
||||
|
||||
{{pending_items}}
|
||||
|
||||
## 签署
|
||||
|
||||
| 角色 | 姓名 | 结论 | 日期 |
|
||||
| --- | --- | --- | --- |
|
||||
| 编制 | {{author_name}} | 已编制 | {{generated_at}} |
|
||||
| 审核 | {{reviewer_name}} | 待审核 | |
|
||||
| 批准 | {{approver_name}} | 待批准 | |
|
||||
|
||||
## 说明
|
||||
|
||||
本文档由智能测试经理小队基于同项目已批准的《系统需求规格书》自动关联生成。若上游系统需求规格书发生修订或版本变更,本文档血缘将自动标记为失效(stale),必须由测试经理重新触发验证方案评审或回归编制。
|
||||
Reference in New Issue
Block a user