package gateway import ( "context" "fmt" "net/http" "sort" "strconv" "strings" "time" "unicode/utf8" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) // Notification feed limits (design/10 §3 #16). Compile-time constants so no bare // magic number appears in the run path (project rule §10). const ( defaultNotificationLimit = 50 maxNotificationLimit = 200 // notificationFetchFactor widens the per-source read. The merged page must be // the newest N of the union, which the per-source newest-N always contains, // but with a `before` cursor one source can dominate the newest window and // starve the other — the factor pays for that. notificationFetchFactor = 4 // notificationBodyRunes caps a backthink summary in the feed; the full text // stays available on GET /api/v1/admin/backthink. notificationBodyRunes = 200 ) // The unified feed's families, used both as the item `type` and as the `?type=` // filter. They are derived from the sources the composition root already has // (design/10 §3 #16: 先聚合现有源为统一 feed,避免新建重模型). const ( // NotificationTypeSystem is a system/security operation: backup & restore, // JWT rotation, API-key revocation, plugin install/uninstall, tool policy. NotificationTypeSystem = "system" // NotificationTypeChannel is a channel lifecycle or connection event // (channel.open / close / save / reject / login). NotificationTypeChannel = "channel" // NotificationTypeBackthink is one proactive push the background thinker // decided to send. NotificationTypeBackthink = "backthink" // NotificationTypeAudit is the remaining admin audit trail: personas, memory, // users, RAG, sessions, uploads, audio. NotificationTypeAudit = "audit" ) // Provenance labels carried on an item's `source` field. const ( notificationSourceAudit = "audit" notificationSourceBackthink = "backthink" // notificationIDPrefixAudit / ...Backthink namespace the source's own record id // so the merged feed has stable, collision-free, client-dedupable ids. notificationIDPrefixAudit = "audit:" notificationIDPrefixBackthink = "backthink:" ) // AdminNotification is one item of the unified notification feed (design/10 §3 // #16). It is a read-only projection of an existing record — audit rows and // backthink records — never a new persisted model: `id` is ":<原生 id>", // `title`/`body` are derived from the source record, and `createdAt` is epoch // milliseconds. type AdminNotification struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Body string `json:"body,omitempty"` Source string `json:"source"` CreatedAt int64 `json:"createdAt"` } // adminListNotifications returns the unified notification feed (owner/admin // read): the admin audit trail and the background thinker's proactive pushes, // merged newest-first and filterable by family and by a `before` cursor. // // A source that is not injected contributes no items — the feed reports what // actually exists instead of fabricating a family — so a deployment without the // backthink service simply has no `backthink` items. func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request) { limit := parseNotificationLimit(r) typ := strings.TrimSpace(r.URL.Query().Get("type")) if typ != "" && !validNotificationType(typ) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_type", "type must be one of system|channel|backthink|audit")) return } before, hasBefore := parseNotificationBefore(r) items, err := s.notificationItems(r.Context(), notificationFetchLimit(limit)) if err != nil { s.log.Error("list notifications", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "notifications_failed", "failed to list notifications")) return } out := make([]AdminNotification, 0, limit) for _, it := range items { if typ != "" && it.Type != typ { continue } if hasBefore && it.CreatedAt > before { continue } out = append(out, it) if len(out) == limit { break } } writeJSON(w, http.StatusOK, out) } // notificationItems reads both sources and returns their merge, newest first. A // source failure is returned (never swallowed into a silently shorter feed), so // the endpoint answers a clear error instead of pretending there was no event. func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotification, error) { auditItems, err := s.notificationsFromAudit(ctx, fetch) if err != nil { return nil, err } btItems, err := s.notificationsFromBackthink(ctx, fetch) if err != nil { return nil, err } items := append(auditItems, btItems...) sort.SliceStable(items, func(i, j int) bool { if items[i].CreatedAt != items[j].CreatedAt { return items[i].CreatedAt > items[j].CreatedAt } // Descending id keeps the order deterministic for items sharing a // millisecond (the cursor can split one millisecond across pages). return items[i].ID > items[j].ID }) return items, nil } // notificationsFromAudit projects the admin audit trail (audit_logs, persisted // by NewSQLiteAuditStore) into feed items. The fallback in-memory store holds // only records really appended in this process, so an unwired store yields an // empty family rather than fabricated entries. func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]AdminNotification, error) { if s.obs == nil || s.obs.audit == nil { return nil, nil } records, err := s.obs.audit.List(ctx, fetch) if err != nil { return nil, fmt.Errorf("gateway: notification audit source: %w", err) } out := make([]AdminNotification, 0, len(records)) for _, rec := range records { out = append(out, AdminNotification{ ID: notificationIDPrefixAudit + strconv.FormatInt(rec.ID, 10), Type: notificationTypeOfAudit(rec.What), Title: rec.What, Body: notificationAuditBody(rec), Source: notificationSourceAudit, CreatedAt: notificationAuditAt(rec.When), }) } return out, nil } // notificationsFromBackthink projects the background thinker's records into feed // items. Only records that decided to push are notifications (a plain thinking // record is not an event the owner needs to be told about); each carries the // delivery state so the feed never claims an undelivered push was sent. func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]AdminNotification, error) { if s.backthink == nil { return nil, nil } records, err := s.backthink.List(ctx, fetch) if err != nil { return nil, fmt.Errorf("gateway: notification backthink source: %w", err) } out := make([]AdminNotification, 0, len(records)) for _, rec := range records { if !rec.ShouldPush { continue } out = append(out, AdminNotification{ ID: notificationIDPrefixBackthink + strconv.FormatInt(rec.ID, 10), Type: NotificationTypeBackthink, Title: backthinkNotificationTitle(rec), Body: clipRunes(strings.TrimSpace(rec.Conclusion), notificationBodyRunes), Source: notificationSourceBackthink, CreatedAt: rec.At.UnixMilli(), }) } return out, nil } // notificationTypeOfAudit classifies an audit operation into a feed family: the // channel lifecycle is `channel`, and the system/plugin/policy operations are // `system`; everything else stays `audit`. func notificationTypeOfAudit(what string) string { switch { case strings.HasPrefix(what, "channel."): return NotificationTypeChannel case strings.HasPrefix(what, "system."), strings.HasPrefix(what, "plugin."), strings.HasPrefix(what, "policy."): return NotificationTypeSystem default: return NotificationTypeAudit } } // notificationAuditBody derives an audit item's body from its who/what/result // trail, e.g. "owner:owner · ok". func notificationAuditBody(rec AuditRecord) string { parts := make([]string, 0, 2) if who := strings.TrimSpace(rec.Who); who != "" { parts = append(parts, who) } if result := strings.TrimSpace(rec.Result); result != "" { parts = append(parts, result) } return strings.Join(parts, " · ") } // notificationAuditAt parses an audit record's RFC3339 timestamp into epoch // milliseconds. An unparseable timestamp yields 0: the item still appears (oldest // first) instead of being dropped or stamped with a made-up time. func notificationAuditAt(when string) int64 { ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(when)) if err != nil { return 0 } return ts.UnixMilli() } // backthinkNotificationTitle labels one proactive push with its trigger and // delivery state, so the feed distinguishes "sent" from "decided but not // delivered" (route none / no online client / push failure). func backthinkNotificationTitle(rec backthink.Record) string { state := "未送达" if rec.PushResult.Pushed { state = "已送达" } if trigger := strings.TrimSpace(rec.Trigger); trigger != "" { return "主动推送(" + trigger + " · " + state + ")" } return "主动推送(" + state + ")" } // clipRunes truncates s to at most max runes, appending an ellipsis when it was // cut. It never splits a multi-byte rune. func clipRunes(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s } runes := []rune(s) return string(runes[:max]) + "…" } // parseNotificationLimit parses the ?limit= query param, clamping to // [1, maxNotificationLimit] and selecting defaultNotificationLimit when absent or // invalid. func parseNotificationLimit(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n <= 0 { return defaultNotificationLimit } if n > maxNotificationLimit { return maxNotificationLimit } return n } // parseNotificationBefore parses the ?before= epoch-millisecond cursor. The // cursor is inclusive (items with createdAt <= before are returned): the sources // resolve to milliseconds, so a page boundary can fall inside one millisecond, // and the client deduplicates by id. An absent or malformed cursor means "from // the newest item". func parseNotificationBefore(r *http.Request) (int64, bool) { raw := strings.TrimSpace(r.URL.Query().Get("before")) if raw == "" { return 0, false } ms, err := strconv.ParseInt(raw, 10, 64) if err != nil || ms <= 0 { return 0, false } return ms, true } // notificationFetchLimit widens a page request into the per-source read window, // capped at maxObsLimit (the audit store's own ceiling) and never below the page. func notificationFetchLimit(limit int) int { fetch := limit * notificationFetchFactor if fetch > maxObsLimit { fetch = maxObsLimit } if fetch < limit { fetch = limit } return fetch } // validNotificationType reports whether t names a feed family. An unknown family // is rejected with 400 rather than answered with a silently empty feed. func validNotificationType(t string) bool { switch t { case NotificationTypeSystem, NotificationTypeChannel, NotificationTypeBackthink, NotificationTypeAudit: return true default: return false } }