package workbench import ( "context" "encoding/json" "errors" "net/url" "strconv" "strings" "time" "unicode/utf8" "github.com/jackc/pgx/v5" "github.com/redis/go-redis/v9" ) // InboxMessage 是站内消息的一行:一条消息对应一个收件人(admin 或 portal)。 // 管理员广播时按目标用户逐行落库,因此每行独立维护 read_at 已读回执。 type InboxMessage struct { ID string `json:"id"` RecipientKind string `json:"recipient_kind"` RecipientUserID string `json:"recipient_user_id"` SenderType string `json:"sender_type"` Category string `json:"category"` Title string `json:"title"` Body string `json:"body"` Link string `json:"link"` Payload json.RawMessage `json:"payload,omitempty"` ReadAt *time.Time `json:"read_at,omitempty"` CreatedAt time.Time `json:"created_at"` } type InboxInput struct { RecipientKind string `json:"recipient_kind"` Category string `json:"category"` Title string `json:"title"` Body string `json:"body"` Link string `json:"link"` Payload json.RawMessage `json:"payload,omitempty"` } // InboxService 负责将 outbox 事件物化为站内消息,并对外提供收件箱读写。 // 未读数以 PostgreSQL 为权威源(部分索引 COUNT 快速),Redis 仅作实时 PUBLISH // 提示(为未来 SSE 预留);因此未读计数不会因 Redis 抖动或消息漂移而失真。 type InboxService struct { assets *Service redis *redis.Client channel string } func NewInboxService(assets *Service, client *redis.Client, channel string) *InboxService { return &InboxService{assets: assets, redis: client, channel: channel} } const inboxSelect = `SELECT id::text,recipient_kind,recipient_user_id::text,sender_type,category,title,body,link,payload,read_at,created_at FROM gateway.inbox_messages` func scanInboxMessage(row pgx.Row) (InboxMessage, error) { var m InboxMessage err := row.Scan(&m.ID, &m.RecipientKind, &m.RecipientUserID, &m.SenderType, &m.Category, &m.Title, &m.Body, &m.Link, &m.Payload, &m.ReadAt, &m.CreatedAt) return m, err } // inboxDraft 描述一个 outbox 事件要落成的一条站内消息(收件人解析方式不同)。 type inboxDraft struct { RecipientKind string // admin | portal Category string Title string Body string Link string UserID string // 直接收件人(从 payload 取),空串表示需额外解析 AllAdmins bool // 收件人 = 全部启用管理员 RequestUser bool // 收件人 = model_access_requests.portal_user_id(payload.request_id) NotifyPref bool // 收件人 = payload.portal_user_id,且其登录通知偏好开启 } func payloadValue(payload json.RawMessage, key string) string { var values map[string]any if err := json.Unmarshal(payload, &values); err != nil { return "" } value, ok := values[key] if !ok { return "" } switch v := value.(type) { case string: return v case float64: return strconv.FormatFloat(v, 'f', -1, 64) } encoded, _ := json.Marshal(value) return string(encoded) } // inboxPlan 把事件类型映射成站内消息草稿(纯函数,便于单测)。未知事件返回 nil, // 不构成错误——并非所有 outbox 事件都需要站内信。 func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft { switch eventType { case "model_access.requested": return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "新的模型访问申请", Body: "用户申请访问模型 " + payloadValue(payload, "model"), Link: "/security/governance", AllAdmins: true}} case "model_access.decided": text := "已批准" if payloadValue(payload, "status") == "rejected" { text = "已驳回" } return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "模型申请已处理", Body: "您的模型访问申请已被" + text, Link: "/portal/access", RequestUser: true}} case "marketplace.installed": return []inboxDraft{{RecipientKind: "portal", Category: "resource", Title: "资源已安装", Body: "资源 " + payloadValue(payload, "code") + " 已安装到您的工作区", Link: "/portal/marketplace", UserID: payloadValue(payload, "portal_user_id")}} case "knowledge_document.ready": return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档已入库", Body: "文档已分块入库(" + payloadValue(payload, "chunk_count") + " 切片)", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}} case "knowledge_document.reprocessed": return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档已重新处理", Body: "文档已重新分块入库", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}} case "knowledge_document.embedding_failed": return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档向量化失败", Body: "文档已入库但向量化失败,检索将回退全文检索;请检查 Ollama 后重新处理", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}} case "scheduled_task.completed": return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务已执行", Body: "定时任务 " + payloadValue(payload, "task_code") + " 已完成", Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}} case "scheduled_task.failed": return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务执行失败", Body: "定时任务 " + payloadValue(payload, "task_code") + " 执行失败: " + payloadValue(payload, "error"), Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}} case "security.login_detected": ip := payloadValue(payload, "ip") if ip == "" { ip = "未知地址" } return []inboxDraft{{RecipientKind: "portal", Category: "security", Title: "新设备登录提醒", Body: "你的账号刚刚从 " + ip + " 登录,如非本人操作请立即修改密码", Link: "/portal/security", UserID: payloadValue(payload, "portal_user_id"), NotifyPref: true}} } return nil } func (s *InboxService) resolveRecipients(ctx context.Context, draft inboxDraft, payload json.RawMessage) ([]string, error) { switch { case draft.UserID != "" && draft.NotifyPref: // 登录提醒:尊重账号的安全偏好(默认开启)。 var notify bool if err := s.assets.pool.QueryRow(ctx, `SELECT COALESCE((SELECT login_notify FROM gateway.portal_security_prefs WHERE portal_user_id=$1),true)`, draft.UserID).Scan(¬ify); err != nil { return nil, err } if !notify { return nil, nil } return []string{draft.UserID}, nil case draft.UserID != "": return []string{draft.UserID}, nil case draft.RequestUser: var userID string if err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.model_access_requests WHERE id=$1`, payloadValue(payload, "request_id")).Scan(&userID); err != nil { return nil, err } return []string{userID}, nil case draft.AllAdmins: rows, err := s.assets.pool.Query(ctx, `SELECT id::text FROM gateway.admin_accounts WHERE active`) if err != nil { return nil, err } defer rows.Close() ids := []string{} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } return nil, nil } // Materialize 把一条 outbox 事件落成站内消息。以 (source_event_id, 收件人) 幂等: // 事件重放时 ON CONFLICT DO NOTHING,不产生重复消息,也不报错。 func (s *InboxService) Materialize(ctx context.Context, eventID, eventType string, payload json.RawMessage) error { for _, draft := range inboxPlan(eventType, payload) { recipients, err := s.resolveRecipients(ctx, draft, payload) if err != nil { return err } for _, userID := range recipients { if err := s.notify(ctx, eventID, draft, userID, payload); err != nil { return err } } } return nil } func (s *InboxService) notify(ctx context.Context, sourceEventID string, draft inboxDraft, userID string, payload json.RawMessage) error { id, err := newUUID() if err != nil { return err } // 数据库 CHECK 按字符数(length)校验,Go 的 len() 是字节数;多字节文本下 // 字节校验通过但字符数超限,INSERT 会失败并把事件永远卡在 pending。 // 落库前按 rune 数截断,保证任何语言正文都能入库。 title := runeTruncate(draft.Title, 256) body := runeTruncate(draft.Body, 4000) link := runeTruncate(draft.Link, 512) tag, err := s.assets.pool.Exec(ctx, `INSERT INTO gateway.inbox_messages(id,source_event_id,recipient_kind,recipient_user_id,sender_type,category,title,body,link,payload) VALUES($1,$2,$3,$4,'system',$5,$6,$7,$8,$9) ON CONFLICT (source_event_id, recipient_kind, recipient_user_id) WHERE source_event_id IS NOT NULL DO NOTHING`, id, sourceEventID, draft.RecipientKind, userID, draft.Category, title, body, link, payload) if err != nil { return err } if tag.RowsAffected() == 0 { return nil // 同一来源事件对同一收件人的重放,跳过 } s.publish(draft.RecipientKind, userID) return nil } // runeTruncate 按 rune(字符)数截断;超出 limit 个字符时截到第 limit 个 // 完整 rune,绝不产生无效 UTF-8。 func runeTruncate(value string, limit int) string { if utf8.RuneCountInString(value) <= limit { return value } runes := []rune(value) return string(runes[:limit]) } // publish 仅作实时提示(未来 SSE 可订阅);收件箱未读数以 DB 为准。 func (s *InboxService) publish(kind, userID string) { if s.redis == nil { return } _ = s.redis.Publish(context.WithoutCancel(context.Background()), s.channel, kind+":"+userID).Err() } // List 返回某个收件人的收件箱(倒序)。 func (s *InboxService) List(ctx context.Context, kind, userID string, limit int) ([]InboxMessage, error) { if limit < 1 { limit = 50 } if limit > 200 { limit = 200 } rows, err := s.assets.pool.Query(ctx, inboxSelect+` WHERE recipient_kind=$1 AND recipient_user_id=$2 ORDER BY created_at DESC LIMIT $3`, kind, userID, limit) if err != nil { return nil, err } defer rows.Close() items := []InboxMessage{} for rows.Next() { m, err := scanInboxMessage(rows) if err != nil { return nil, err } items = append(items, m) } return items, rows.Err() } // UnreadCount 以 PostgreSQL 为权威源统计未读消息(部分索引快速扫描)。 func (s *InboxService) UnreadCount(ctx context.Context, kind, userID string) (int, error) { var count int err := s.assets.pool.QueryRow(ctx, `SELECT count(*) FROM gateway.inbox_messages WHERE recipient_kind=$1 AND recipient_user_id=$2 AND read_at IS NULL`, kind, userID).Scan(&count) return count, err } // MarkRead 把某条消息标记为已读;只允许收件人本人操作。 func (s *InboxService) MarkRead(ctx context.Context, id, kind, userID string) (bool, error) { tag, err := s.assets.pool.Exec(ctx, `UPDATE gateway.inbox_messages SET read_at=coalesce(read_at,clock_timestamp()) WHERE id=$1 AND recipient_kind=$2 AND recipient_user_id=$3`, id, kind, userID) if err != nil { return false, err } return tag.RowsAffected() > 0, nil } // MarkAllRead 把某收件人的全部未读标记为已读,返回标记条数。 func (s *InboxService) MarkAllRead(ctx context.Context, kind, userID string) (int, error) { tag, err := s.assets.pool.Exec(ctx, `UPDATE gateway.inbox_messages SET read_at=clock_timestamp() WHERE recipient_kind=$1 AND recipient_user_id=$2 AND read_at IS NULL`, kind, userID) if err != nil { return 0, err } return int(tag.RowsAffected()), nil } // AdminList 返回管理端消息中心列表。scope=mine 看发给自己(admin)的消息; // scope=broadcasts 看管理员发起的广播(portal 收件)。其余 scope 全部返回。 func (s *InboxService) AdminList(ctx context.Context, adminID, scope string, limit int) ([]InboxMessage, error) { if limit < 1 { limit = 50 } if limit > 500 { limit = 500 } query, args := inboxSelect+` WHERE`, []any{} switch scope { case "mine": query += ` recipient_kind='admin' AND recipient_user_id=$1` args = append(args, adminID) case "broadcasts": query += ` sender_type='admin' AND recipient_kind='portal'` default: query += ` recipient_kind='admin' AND recipient_user_id=$1` args = append(args, adminID) } args = append(args, limit) query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)) rows, err := s.assets.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() items := []InboxMessage{} for rows.Next() { m, err := scanInboxMessage(rows) if err != nil { return nil, err } items = append(items, m) } return items, rows.Err() } // Broadcast 由管理员向 portal(可按部门过滤)或全部 admin 发广播;逐收件人落库, // 返回实际送达用户数。sender_type='admin',无 source_event_id(不与事件幂等键冲突)。 func (s *InboxService) Broadcast(ctx context.Context, input InboxInput, departmentIDs []string, actorID string) (int, error) { input.RecipientKind = strings.TrimSpace(input.RecipientKind) input.Category = strings.TrimSpace(input.Category) input.Title = strings.TrimSpace(input.Title) input.Body = strings.TrimSpace(input.Body) input.Link = strings.TrimSpace(input.Link) if input.RecipientKind != "portal" && input.RecipientKind != "admin" { return 0, errors.New("广播对象必须是 admin 或 portal") } if input.Title == "" || utf8.RuneCountInString(input.Title) > 256 || utf8.RuneCountInString(input.Body) > 4000 || utf8.RuneCountInString(input.Link) > 512 { return 0, errors.New("广播标题或正文格式无效") } if !validInboxLink(input.Link) { return 0, errors.New("跳转链接仅支持站内绝对路径或 http(s) 地址") } if input.Category != "system" && input.Category != "approval" && input.Category != "task_result" && input.Category != "resource" { input.Category = "system" } var query string var args []any if input.RecipientKind == "admin" { query = `SELECT id::text FROM gateway.admin_accounts WHERE active` } else { query = `SELECT id::text FROM gateway.portal_users WHERE active AND (array_length($1::uuid[],1) IS NULL OR department_id = ANY($1::uuid[]))` args = append(args, departmentIDs) } rows, err := s.assets.pool.Query(ctx, query, args...) if err != nil { return 0, err } defer rows.Close() recipients := []string{} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return 0, err } recipients = append(recipients, id) } if err := rows.Err(); err != nil { return 0, err } // 广播整体包在单个事务里:中途失败不留半套消息,收件人数目与落库一致。 tx, err := s.assets.pool.Begin(ctx) if err != nil { return 0, err } defer rollback(ctx, tx) count := 0 for _, userID := range recipients { id, idErr := newUUID() if idErr != nil { return 0, idErr } tag, insertErr := tx.Exec(ctx, `INSERT INTO gateway.inbox_messages(id,recipient_kind,recipient_user_id,sender_type,category,title,body,link,payload) VALUES($1,$2,$3,'admin',$4,$5,$6,$7,$8)`, id, input.RecipientKind, userID, input.Category, input.Title, input.Body, input.Link, input.Payload) if insertErr != nil { return 0, insertErr } count += int(tag.RowsAffected()) s.publish(input.RecipientKind, userID) } if err := tx.Commit(ctx); err != nil { return 0, err } return count, nil } // validInboxLink blocks executable and browser-special schemes. Empty links are allowed; // internal links must be root-relative, while external links are limited to HTTP(S). func validInboxLink(link string) bool { if link == "" { return true } if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") { return true } parsed, err := url.ParseRequestURI(link) return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" }