package docnotify import ( "context" "fmt" "strings" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/notification" ) // LifecycleAction identifies a governance transition for routing. type LifecycleAction string const ( LifecycleSubmitReview LifecycleAction = "submit_review" LifecycleReviewApprove LifecycleAction = "review_approve" LifecycleReviewReject LifecycleAction = "review_reject" LifecycleApproverApprove LifecycleAction = "approver_approve" LifecycleApproverReject LifecycleAction = "approver_reject" LifecycleReopen LifecycleAction = "reopen" ) // RecipientForDocEvent resolves the duty-holder inbox recipient for a document event. func RecipientForDocEvent(doc *docgov.DocumentRecord, eventType string) int64 { if doc == nil { return 0 } switch eventType { case notification.EventDocReviewRequested: if doc.ReviewerID > 0 { return doc.ReviewerID } case notification.EventDocApprovalRequested: if doc.ApproverID > 0 { return doc.ApproverID } case notification.EventDocRejected, notification.EventDocReopened, notification.EventDocPendingSupplements: if doc.AuthorID > 0 { return doc.AuthorID } case notification.EventDocApproved: if doc.AuthorID > 0 { return doc.AuthorID } } if doc.AuthorID > 0 { return doc.AuthorID } return 0 } // EmitLifecycleParams carries optional chat side channels. type EmitLifecycleParams struct { Notify notificationEmitter Digest digestUpserter Chat *ctxmgr.ContextManager Action LifecycleAction Doc *docgov.DocumentRecord ActorID int64 ActorName string Detail string } type digestUpserter interface { UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error } // EmitLifecycle notifies the duty holder for a governance transition. func EmitLifecycle(ctx context.Context, p EmitLifecycleParams) { if p.Doc == nil || p.Notify == nil || !p.Notify.Available() { return } eventType, notifType, title, body, severity := lifecycleCopy(p.Action, p.Doc, p.ActorName, p.Detail) if eventType == "" { return } recipient := RecipientForDocEvent(p.Doc, eventType) if recipient <= 0 { return } payload := notification.DocumentEventPayload{ ProjectID: p.Doc.ProjectID, DocID: p.Doc.DocID, DocTitle: p.Doc.Title, Summary: truncateLifecycle(p.Detail, 80), } dedupe := fmt.Sprintf("doc_lifecycle:%s:%s:%d:%s", p.Action, p.Doc.DocID, recipient, p.Doc.CurrentState) _, _ = p.Notify.EmitDocumentEvent(ctx, eventType, notifType, title, body, dedupe, recipient, payload) if p.Digest != nil && severity == notification.SeverityActionRequired { kind := digestKindForEvent(eventType) if kind != "" { _ = p.Digest.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{ UserID: recipient, ProjectID: p.Doc.ProjectID, DigestKind: kind, DocID: p.Doc.DocID, DocTitle: p.Doc.Title, Severity: severity, ActorID: p.ActorID, ActorName: p.ActorName, Summary: truncateLifecycle(p.Detail, 60), }) } } if p.Chat != nil { _ = PostProjectNotice(ctx, p.Chat, p.Doc.ProjectID, recipient, body, map[string]any{ "doc_id": p.Doc.DocID, "action": string(p.Action), "event": eventType, }) } } func digestKindForEvent(eventType string) string { switch eventType { case notification.EventDocReviewRequested: return notification.DigestKindReviewRequested case notification.EventDocApprovalRequested: return notification.DigestKindApprovalRequested case notification.EventDocPendingSupplements: return notification.DigestKindPendingSupplements default: return "" } } func lifecycleCopy(action LifecycleAction, doc *docgov.DocumentRecord, actorName, detail string) (eventType, notifType, title, body, severity string) { titleDoc := strings.TrimSpace(doc.Title) if titleDoc == "" { titleDoc = doc.DocID } actor := strings.TrimSpace(actorName) if actor == "" { actor = "成员" } reason := strings.TrimSpace(detail) switch action { case LifecycleSubmitReview: return notification.EventDocReviewRequested, notification.TypeDocReviewRequested, fmt.Sprintf("《%s》待您审核", titleDoc), fmt.Sprintf("%s 已送审,请审核本文档。", actor), notification.SeverityActionRequired case LifecycleReviewApprove: return notification.EventDocApprovalRequested, notification.TypeDocApprovalRequested, fmt.Sprintf("《%s》待您批准", titleDoc), fmt.Sprintf("%s 审核已通过,请批准本文档。", actor), notification.SeverityActionRequired case LifecycleReviewReject, LifecycleApproverReject: msg := fmt.Sprintf("%s 驳回了《%s》。", actor, titleDoc) if reason != "" { msg += "原因:" + reason } return notification.EventDocRejected, notification.TypeDocRejected, fmt.Sprintf("《%s》已被驳回", titleDoc), msg, notification.SeverityActionRequired case LifecycleApproverApprove: return notification.EventDocApproved, notification.TypeDocApproved, fmt.Sprintf("《%s》已批准归档", titleDoc), fmt.Sprintf("%s 已终审批准,《%s》已归档锁定。", actor, titleDoc), notification.SeverityInfo case LifecycleReopen: return notification.EventDocReopened, notification.TypeDocReopened, fmt.Sprintf("《%s》已重开修订", titleDoc), fmt.Sprintf("%s 批准重开修订,《%s》已回编制期。", actor, titleDoc), notification.SeverityActionRequired default: return "", "", "", "", "" } } func truncateLifecycle(s string, n int) string { r := []rune(strings.TrimSpace(s)) if len(r) <= n { return string(r) } return string(r[:n]) + "…" }