diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index cc8c55cc..9e650e44 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "strings" "sync" "time" @@ -13,6 +14,15 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// nullIfZero maps unset duty holders (0) to SQL NULL so nullable FK columns +// (author_id/reviewer_id/approver_id → users.id) accept optional duties. +func nullIfZero(id int64) any { + if id <= 0 { + return nil + } + return id +} + // ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果 type ModificationCheckResult struct { AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改 @@ -153,12 +163,15 @@ func (e *GovernanceEngine) CreateOrGetDocument( } ensureUser := func(uid int64, uname, urole string) { - if uid > 0 { - _, _ = e.pool.Exec(ctx, ` - INSERT INTO users (id, account_id, name, role) - VALUES ($1, $2, $3, $4) - ON CONFLICT (id) DO NOTHING - `, uid, fmt.Sprintf("acc_%d", uid), uname, urole) + if uid <= 0 { + return + } + if _, err := e.pool.Exec(ctx, ` + INSERT INTO users (id, account_id, name, role) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING + `, uid, fmt.Sprintf("acc_%d", uid), uname, urole); err != nil { + log.Printf("[docgov] ensureUser id=%d role=%s failed: %v", uid, urole, err) } } ensureUser(doc.AuthorID, doc.AuthorName, "engineer") @@ -185,7 +198,8 @@ func (e *GovernanceEngine) CreateOrGetDocument( err := e.pool.QueryRow(ctx, query, doc.DocID, doc.ProjectID, doc.TaskKey, doc.Title, doc.Stage, doc.Version, string(doc.CurrentState), doc.IsLocked, doc.IsTemplateOnly, doc.WorkDir, - doc.AuthorID, doc.AuthorName, doc.ReviewerID, doc.ReviewerName, doc.ApproverID, doc.ApproverName, + nullIfZero(doc.AuthorID), doc.AuthorName, nullIfZero(doc.ReviewerID), doc.ReviewerName, + nullIfZero(doc.ApproverID), doc.ApproverName, cfgJSON, doc.CreatedAt, doc.UpdatedAt, ).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim) if err != nil { diff --git a/internal/docgov/engine_db_test.go b/internal/docgov/engine_db_test.go index 2bed3a44..1fbf869f 100644 --- a/internal/docgov/engine_db_test.go +++ b/internal/docgov/engine_db_test.go @@ -97,3 +97,64 @@ func TestDocGov_DatabaseIntegration(t *testing.T) { t.Fatalf("expected doc locked in reviewing") } } + +// TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK ensures reviewer/approver=0 +// persist as SQL NULL (optional DutyMode) instead of FK-violating 0. +func TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK(t *testing.T) { + cfg, err := config.Load(".env") + if err != nil || cfg == nil || cfg.ContextDbHost == "" { + t.Skip("Skipping DB integration test: context DB not configured") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, cfg.ContextDSN()) + if err != nil || pool.Ping(ctx) != nil { + t.Skipf("Skipping DB integration test: cannot connect to %s: %v", cfg.ContextDSN(), err) + } + defer pool.Close() + + engine := docgov.NewGovernanceEngine(pool) + testDocID := fmt.Sprintf("doc_opt_duty_%d", time.Now().UnixNano()) + projectID := time.Now().UnixNano()%900000 + 100000 + + doc, err := engine.CreateOrGetDocument( + ctx, + testDocID, + projectID, + "intake", + "可选职责空审核人", + "V0.5_feasibility", + "", + 9, "系统管理员", + 0, "", + 0, "", + ) + if err != nil { + t.Fatalf("CreateOrGetDocument with reviewer/approver=0 failed: %v", err) + } + if doc.DocID != testDocID { + t.Fatalf("unexpected docID: %s", doc.DocID) + } + + var reviewerNull, approverNull bool + var authorID int64 + err = pool.QueryRow(ctx, ` + SELECT author_id, + reviewer_id IS NULL, + approver_id IS NULL + FROM document_records WHERE doc_id = $1 + `, testDocID).Scan(&authorID, &reviewerNull, &approverNull) + if err != nil { + t.Fatalf("query document_records: %v", err) + } + if authorID != 9 { + t.Fatalf("author_id=%d want 9", authorID) + } + if !reviewerNull || !approverNull { + t.Fatalf("expected NULL reviewer/approver, got reviewerNull=%v approverNull=%v", reviewerNull, approverNull) + } + + _, _ = pool.Exec(ctx, `DELETE FROM document_records WHERE doc_id = $1`, testDocID) +} diff --git a/internal/docgov/null_if_zero_test.go b/internal/docgov/null_if_zero_test.go new file mode 100644 index 00000000..a769ec0e --- /dev/null +++ b/internal/docgov/null_if_zero_test.go @@ -0,0 +1,16 @@ +package docgov + +import "testing" + +func TestNullIfZero(t *testing.T) { + t.Parallel() + if got := nullIfZero(0); got != nil { + t.Fatalf("nullIfZero(0)=%v want nil", got) + } + if got := nullIfZero(-1); got != nil { + t.Fatalf("nullIfZero(-1)=%v want nil", got) + } + if got := nullIfZero(9); got != int64(9) { + t.Fatalf("nullIfZero(9)=%v want 9", got) + } +}