From 3a8ba1e54e06aba5109c4a1369e70d35f5f883d9 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:30:01 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(api-go):=20user-settings=20repositor?= =?UTF-8?q?y=20=E6=89=A9=E5=B1=95=E5=88=B0=208=20=E4=B8=AA=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=20key=20+=20=E4=B8=89=E4=B8=AA=E6=96=B0=20normalizati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repository:新增 WarMapKey/NewsnowKey/SituationMonitor{Monitors,Layout,Settings}Key 编译期常量;FindWarMap/FindNewsnow 复用既有单 key 参数化查询; FindSituationMonitor 一次聚合查询三个固定 key(对齐 NestJS findMany, WHERE orgId+userId+key IN 三个编译期常量——无任意 key API) - situationmonitor.go:三段聚合响应(updatedAt 各段 omitempty、无记录 null) + monitors(trim/截断/keywords 先截 30 再去重/color/location/最多 20) + layout(五断点/legacy lg 回退/取整与最小值/visibility 64 项有序上限) + settings(windowHours 6/24/72、scope、三个布尔回退) - warmap.go:完整移植 packages/utils war-map-contract.ts——46 layer 固定 struct 序列化、legacy key 映射、viewState clamp、bearing/pitch 归零、 枚举回退、根对象 vs 嵌套 layerVisibility 差异 - newsnow.go:source id pattern/trim/去重/上限(200/32/300)、 hideCrossSourceDuplicates 的 JS Boolean 真值、smart→personalized、 clamp+round;columnOrders/sourceAffinity 用 orderedEntries 流式有序解码 (Object.entries 顺序 + 前N项语义,map 随机遍历不可用)+ 自定义 MarshalJSON 保持对象形态与字段顺序 --- apps/api-go/internal/usersettings/newsnow.go | 408 ++++++++++++ .../internal/usersettings/repository.go | 92 ++- .../internal/usersettings/situationmonitor.go | 602 ++++++++++++++++++ apps/api-go/internal/usersettings/warmap.go | 339 ++++++++++ 4 files changed, 1437 insertions(+), 4 deletions(-) create mode 100644 apps/api-go/internal/usersettings/newsnow.go create mode 100644 apps/api-go/internal/usersettings/situationmonitor.go create mode 100644 apps/api-go/internal/usersettings/warmap.go diff --git a/apps/api-go/internal/usersettings/newsnow.go b/apps/api-go/internal/usersettings/newsnow.go new file mode 100644 index 00000000..1f828d06 --- /dev/null +++ b/apps/api-go/internal/usersettings/newsnow.go @@ -0,0 +1,408 @@ +// NewsNow UI 设置的 normalization 契约(Go-批3B)。 +// +// 契约对齐(NestJS normalizeNewsnowUiSettings 及其内部函数, +// apps/api/src/modules/user-settings/user-settings.service.ts:629-820): +// - 存储 key:ui:newsnow:settings:v1(NewsnowKey,repository.go); +// - value 不是 JSON 对象(null/数组/标量/解析失败)→ 默认设置对象; +// - focusSources:数组内字符串 trim 后须匹配 ^[a-z0-9_-]{1,64}$(i—— +// 大小写不敏感的 ASCII),稳定去重(保留首现),最多 200 项 +// (push 后 break——恰取前 200 个合法不重复项); +// - columnOrders:对象 → Object.entries 插入序遍历;列 key 同样过 +// source id pattern;value 是数组则按 focusSources 同款规整(上限 +// 200);空列表的列丢弃;最多 32 个列(上限达到即停止——「前 N +// 项」依赖 key 出现顺序,必须有序解码,见 orderedEntries); +// - hideCrossSourceDuplicates:Boolean(record.hideCrossSourceDuplicates) +// 的 JavaScript 真值语义——非空字符串/非零数字/true 都是真; +// - sortMode:仅 "smart"/"personalized" → "personalized",其余 +// (含缺失)→ "manual"; +// - densityMode:仅 "comfortable" 保留,其余 "compact"; +// - sourceAffinity:对象 → Object.entries 插入序遍历;source id 过 +// pattern;value 必须是非数组对象,否则丢弃;字段 clamp:score 浮点 +// [0,100](不取整);openOriginalCount/openEventCount/openItemCount/ +// refreshCount/focusCount 整数 [0,1e6];accumulatedDwellMs [0, +// 365*24*60*60*1000];lastInteractedAt [0,9_999_999_999_999];全部 +// round;最多 300 个 entry(上限即停止)。 +// +// 有序对象(columnOrders/sourceAffinity):NestJS 的 Object.entries 顺序 +// = JSON 解析后的插入序。Go 的 map[string]any 不保序——用 orderedEntries +// (situationmonitor.go)按出现顺序流式读取,仅覆盖这两个「前 N 项」 +// 语义点,不扩展成通用框架。 +package usersettings + +import "encoding/json" + +const ( + // maxNewsnowSourceIDs 与 NestJS MAX_NEWSNOW_SOURCE_IDS 一致。 + maxNewsnowSourceIDs = 200 + // maxNewsnowColumns 与 NestJS MAX_NEWSNOW_COLUMNS 一致。 + maxNewsnowColumns = 32 + // maxNewsnowAffinities 与 NestJS MAX_NEWSNOW_AFFINITIES 一致。 + maxNewsnowAffinities = 300 + // maxNewsnowDwellMs 与 accumulatedDwellMs 上限一致(365 天毫秒)。 + maxNewsnowDwellMs = 365 * 24 * 60 * 60 * 1000 + // maxNewsnowLastInteractedAt 与 lastInteractedAt 上限一致。 + maxNewsnowLastInteractedAt = 9_999_999_999_999 + // maxNewsnowIntCount 与各 count 字段上限一致。 + maxNewsnowIntCount = 1_000_000 +) + +// NewsnowSourceAffinity 对齐 NestJS NewsnowSourceAffinitySettings。 +type NewsnowSourceAffinity struct { + Score float64 `json:"score"` + OpenOriginalCount int `json:"openOriginalCount"` + OpenEventCount int `json:"openEventCount"` + OpenItemCount int `json:"openItemCount"` + RefreshCount int `json:"refreshCount"` + FocusCount int `json:"focusCount"` + AccumulatedDwellMs int `json:"accumulatedDwellMs"` + LastInteractedAt int `json:"lastInteractedAt"` +} + +// NewsnowSettings 对齐 NestJS NewsnowUiSettings。ColumnOrders 与 +// SourceAffinity 承载 JSON 对象(Object.entries 顺序保留——见 +// newsnowColumnPair/newsnowAffinityPair 的有序 pairs + 自定义 MarshalJSON: +// 空对象 {},非空按出现顺序展开)。这里自定义整个结构体的 MarshalJSON +// 以保持字段顺序与对象形态。 +type NewsnowSettings struct { + FocusSources []string `json:"focusSources"` + ColumnOrders []newsnowColumnPair `json:"columnOrders"` + HideCrossSourceDuplicates bool `json:"hideCrossSourceDuplicates"` + SortMode string `json:"sortMode"` + DensityMode string `json:"densityMode"` + SourceAffinity []newsnowAffinityPair `json:"sourceAffinity"` +} + +// MarshalJSON 固定字段顺序 + columnOrders/sourceAffinity 序列化为 +//(有序)JSON 对象。 +func (n NewsnowSettings) MarshalJSON() ([]byte, error) { + columns, err := n.marshalColumnOrders() + if err != nil { + return nil, err + } + affinities, err := n.marshalSourceAffinity() + if err != nil { + return nil, err + } + focus, err := json.Marshal(n.FocusSources) + if err != nil { + return nil, err + } + hide, err := json.Marshal(n.HideCrossSourceDuplicates) + if err != nil { + return nil, err + } + sortMode, err := json.Marshal(n.SortMode) + if err != nil { + return nil, err + } + density, err := json.Marshal(n.DensityMode) + if err != nil { + return nil, err + } + return []byte(`{"focusSources":` + string(focus) + + `,"columnOrders":` + string(columns) + + `,"hideCrossSourceDuplicates":` + string(hide) + + `,"sortMode":` + string(sortMode) + + `,"densityMode":` + string(density) + + `,"sourceAffinity":` + string(affinities) + `}`), nil +} + +// newsnowColumnPair 是 columnOrders 的一个有序 entry(key → 有序列表; +// Go 值列表本身有序,无需特殊处理)。 +type newsnowColumnPair struct { + Key string `json:"k"` + Values []string `json:"v"` +} + +// newsnowAffinityPair 是 sourceAffinity 的一个有序 entry。 +type newsnowAffinityPair struct { + Key string `json:"k"` + Affinity NewsnowSourceAffinity `json:"v"` +} + +// NewsnowResponse 是 GET /api/user-settings/ui/newsnow 的响应体。 +type NewsnowResponse struct { + Version int `json:"version"` + UpdatedAt struct { + Settings string `json:"settings,omitempty"` + } `json:"updatedAt"` + Settings *NewsnowSettings `json:"settings"` +} + +// BuildNewsnowResponse 输入数据库记录,输出完整响应。 +func BuildNewsnowResponse(record Record) NewsnowResponse { + var response NewsnowResponse + response.Version = 1 + if !record.Found { + return response + } + response.UpdatedAt.Settings = formatJSISO(record.UpdatedAt) + settings := NormalizeNewsnow(record.Value) + response.Settings = &settings + return response +} + +// NormalizeNewsnow 复刻 normalizeNewsnowUiSettings +//(user-settings.service.ts:803-820)。 +func NormalizeNewsnow(raw []byte) NewsnowSettings { + settings := defaultNewsnow() + + value, ok := asJSONObject(raw) + if !ok { + return settings + } + + settings.FocusSources = normalizeNewsnowSourceList(value["focusSources"], maxNewsnowSourceIDs) + settings.ColumnOrders = normalizeNewsnowColumnOrders(extractRawField(raw, "columnOrders")) + settings.HideCrossSourceDuplicates = jsTruthy(value["hideCrossSourceDuplicates"]) + settings.SortMode = normalizeNewsnowSortMode(value["sortMode"]) + settings.DensityMode = normalizeNewsnowDensityMode(value["densityMode"]) + settings.SourceAffinity = normalizeNewsnowSourceAffinity(extractRawField(raw, "sourceAffinity")) + return settings +} + +// defaultNewsnow 与 createDefaultNewsnowUiSettings 一致(columnOrders 与 +// sourceAffinity 为空对象——经自定义 MarshalJSON 序列化为 {},非数组)。 +func defaultNewsnow() NewsnowSettings { + return NewsnowSettings{ + FocusSources: []string{}, + ColumnOrders: []newsnowColumnPair{}, + SortMode: "manual", + DensityMode: "compact", + SourceAffinity: []newsnowAffinityPair{}, + } +} + +// marshalOrderedPairs 把有序 pairs 序列化为 JSON 对象(保持顺序;空 → {})。 +func marshalOrderedPairs[T any](pairs []pair[T]) ([]byte, error) { + var buf []byte + buf = append(buf, '{') + for i, entry := range pairs { + if i > 0 { + buf = append(buf, ',') + } + // key 用 json.Marshal 的字符串转义(与 encoding/json 转义规则一致)。 + key, err := json.Marshal(entry.key) + if err != nil { + return nil, err + } + buf = append(buf, key...) + buf = append(buf, ':') + value, err := json.Marshal(entry.value) + if err != nil { + return nil, err + } + buf = append(buf, value...) + } + buf = append(buf, '}') + return buf, nil +} + +// pair 是有序 entry 的泛型形态(marshalOrderedPairs 的输入)。 +type pair[T any] struct { + key string + value T +} + +func (n NewsnowSettings) marshalColumnOrders() ([]byte, error) { + pairs := make([]pair[any], 0, len(n.ColumnOrders)) + for _, entry := range n.ColumnOrders { + pairs = append(pairs, pair[any]{key: entry.Key, value: entry.Values}) + } + return marshalOrderedPairs(pairs) +} + +func (n NewsnowSettings) marshalSourceAffinity() ([]byte, error) { + pairs := make([]pair[any], 0, len(n.SourceAffinity)) + for _, entry := range n.SourceAffinity { + pairs = append(pairs, pair[any]{key: entry.Key, value: entry.Affinity}) + } + return marshalOrderedPairs(pairs) +} + +// normalizeNewsnowSourceId:字符串 trim 后非空且匹配 +// ^[a-z0-9_-]{1,64}$/i(NestJS normalizeNewsnowSourceId——注意 trim 后 +// 不再截断:pattern 限长 64)。 +func normalizeNewsnowSourceID(value any) (string, bool) { + s, ok := value.(string) + if !ok { + return "", false + } + trimmed := trimSpace(s) + if trimmed == "" || len(trimmed) > 64 { + return "", false + } + for i := 0; i < len(trimmed); i++ { + c := trimmed[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + return "", false + } + } + return trimmed, true +} + +// normalizeNewsnowSourceList:数组 → 逐项 normalize + 稳定去重 + 上限 +//(NestJS normalizeNewsnowSourceList——push 后 break)。 +func normalizeNewsnowSourceList(value any, maxCount int) []string { + entries, ok := value.([]any) + if !ok { + return []string{} + } + out := []string{} + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + normalized, ok := normalizeNewsnowSourceID(entry) + if !ok { + continue + } + if _, duplicate := seen[normalized]; duplicate { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + if len(out) >= maxCount { + break + } + } + return out +} + +// normalizeNewsnowColumnOrders:对象 → 有序 entries,每列 key 过 pattern、 +// value 列表规整(空列表丢弃),最多 32 列(Object.entries 顺序 + 上限 +// 即停止)。raw 是该字段的原始 JSON 字节(extractRawField 提取—— +// map 解码不保序,「前 N 列」语义必须按出现顺序)。 +func normalizeNewsnowColumnOrders(raw []byte) []newsnowColumnPair { + entries, ok := orderedEntries(raw) + if !ok { + return []newsnowColumnPair{} + } + out := []newsnowColumnPair{} + for _, entry := range entries { + if len(out) >= maxNewsnowColumns { + break + } + column, ok := normalizeNewsnowSourceID(entry.key) + if !ok { + continue + } + var rawList any + if err := json.Unmarshal(entry.value, &rawList); err != nil { + continue + } + values := normalizeNewsnowSourceList(rawList, maxNewsnowSourceIDs) + if len(values) == 0 { + continue + } + out = append(out, newsnowColumnPair{Key: column, Values: values}) + } + return out +} + +// normalizeNewsnowSourceAffinity:对象 → 有序 entries,source id 过 +// pattern、value 非数组对象才保留,最多 300 个(同上——顺序语义)。 +func normalizeNewsnowSourceAffinity(raw []byte) []newsnowAffinityPair { + entries, ok := orderedEntries(raw) + if !ok { + return []newsnowAffinityPair{} + } + out := []newsnowAffinityPair{} + for _, entry := range entries { + if len(out) >= maxNewsnowAffinities { + break + } + sourceID, ok := normalizeNewsnowSourceID(entry.key) + if !ok { + continue + } + var rawAffinity any + if err := json.Unmarshal(entry.value, &rawAffinity); err != nil { + continue + } + affinity, ok := normalizeNewsnowAffinity(rawAffinity) + if !ok { + continue + } + out = append(out, newsnowAffinityPair{Key: sourceID, Affinity: affinity}) + } + return out +} + +// normalizeNewsnowAffinity 单个 source 的亲和统计(NestJS 内联对象)。 +func normalizeNewsnowAffinity(value any) (NewsnowSourceAffinity, bool) { + record, ok := value.(map[string]any) + if !ok { + return NewsnowSourceAffinity{}, false + } + return NewsnowSourceAffinity{ + Score: clampNewsnowFloat(record["score"], 0, 100), + OpenOriginalCount: clampNewsnowInt(record["openOriginalCount"], 0, maxNewsnowIntCount), + OpenEventCount: clampNewsnowInt(record["openEventCount"], 0, maxNewsnowIntCount), + OpenItemCount: clampNewsnowInt(record["openItemCount"], 0, maxNewsnowIntCount), + RefreshCount: clampNewsnowInt(record["refreshCount"], 0, maxNewsnowIntCount), + FocusCount: clampNewsnowInt(record["focusCount"], 0, maxNewsnowIntCount), + AccumulatedDwellMs: clampNewsnowInt(record["accumulatedDwellMs"], 0, maxNewsnowDwellMs), + LastInteractedAt: clampNewsnowInt(record["lastInteractedAt"], 0, maxNewsnowLastInteractedAt), + }, true +} + +// clampNewsnowInt 复刻 clampNewsnowInt(640-658):非数字/非有限 → 0 +//(fallback 默认);clamp 后 round 取整。 +func clampNewsnowInt(value any, min, max int64) int { + f, ok := value.(float64) + if !ok { + return 0 + } + if f < float64(min) { + return int(min) + } + if f > float64(max) { + return int(max) + } + return int(jsRound(f)) +} + +// clampNewsnowFloat 复刻 clampNewsnowFloat(660-678):非数字 → 0; +// clamp 不取整。 +func clampNewsnowFloat(value any, min, max float64) float64 { + f, ok := value.(float64) + if !ok { + return 0 + } + return clampFloatValue(f, min, max) +} + +// normalizeNewsnowSortMode 复刻 normalizeNewsnowSortMode(793-797): +// "smart"/"personalized" → "personalized"(smart 归一),其余 → "manual"。 +func normalizeNewsnowSortMode(value any) string { + if s, ok := value.(string); ok && (s == "smart" || s == "personalized") { + return "personalized" + } + return "manual" +} + +// normalizeNewsnowDensityMode 复刻 normalizeNewsnowDensityMode(799-801): +// 仅 "comfortable" 保留,其余 "compact"。 +func normalizeNewsnowDensityMode(value any) string { + if s, ok := value.(string); ok && s == "comfortable" { + return "comfortable" + } + return "compact" +} + +// jsTruthy 复刻 JavaScript Boolean() 真值:false/""/0/NaN/null/undefined +// 为假,其余为真。JSON 反序列化后 any 的零值形态:nil(null)、bool、 +// float64(0 为假)、string(空为假)。JSON 无法表达 undefined/NaN。 +func jsTruthy(value any) bool { + switch v := value.(type) { + case nil: + return false + case bool: + return v + case float64: + return v != 0 + case string: + return v != "" + default: + return true + } +} diff --git a/apps/api-go/internal/usersettings/repository.go b/apps/api-go/internal/usersettings/repository.go index f0495440..d7841d9c 100644 --- a/apps/api-go/internal/usersettings/repository.go +++ b/apps/api-go/internal/usersettings/repository.go @@ -1,5 +1,7 @@ // user-settings MySQL 只读 repository(Go-批2A onboarding 起步,Go-批2B -// 扩展 rss-reader / spacetime-timeline——三个端点共享同一条私有查询)。 +// 扩展 rss-reader / spacetime-timeline,Go-批3B 扩展 war-map / newsnow / +// situation-monitor——三个端点共享同一条私有单 key 查询,situation-monitor +// 是唯一的三 key 聚合查询)。 // // 约束: // - 纯 database/sql + go-sql-driver/mysql,不引入 ORM/Web 框架/DI; @@ -39,10 +41,23 @@ const ( RSSReaderKey SettingKey = "ui:rss-reader:settings:v1" // SpacetimeTimelineKey 是 spacetime-timeline 设置的固定存储 key(Go-批2B)。 SpacetimeTimelineKey SettingKey = "ui:spacetime-timeline:settings:v1" + // WarMapKey 是 war-map 设置的固定存储 key(Go-批3B)。 + WarMapKey SettingKey = "ui:war-map:settings:v1" + // NewsnowKey 是 newsnow 设置的固定存储 key(Go-批3B)。 + NewsnowKey SettingKey = "ui:newsnow:settings:v1" + // SituationMonitorMonitorsKey 是 situation-monitor 自定义监控项的 + // 固定存储 key(Go-批3B)。 + SituationMonitorMonitorsKey SettingKey = "ui:situation-monitor:monitors:v1" + // SituationMonitorLayoutKey 是 situation-monitor 布局的固定存储 key + //(Go-批3B)。 + SituationMonitorLayoutKey SettingKey = "ui:situation-monitor:layout:v1" + // SituationMonitorSettingsKey 是 situation-monitor 设置的固定存储 key + //(Go-批3B)。 + SituationMonitorSettingsKey SettingKey = "ui:situation-monitor:settings:v1" ) -// Repository 是 user-settings 只读查询接口(三个确定性 GET 端点, -// NestJS user-settings.service.ts 的 findUnique 语义)。 +// Repository 是 user-settings 只读查询接口(六个确定性 GET 端点, +// NestJS user-settings.service.ts 的 findUnique / findMany 语义)。 type Repository interface { // FindOnboarding 返回该 org+user 的 onboarding 记录;无记录时 // Found=false(非错误)。 @@ -53,6 +68,24 @@ type Repository interface { // FindSpacetimeTimeline 返回该 org+user 的 spacetime-timeline 记录; // 无记录时 Found=false(非错误)。 FindSpacetimeTimeline(ctx context.Context, orgID, userID string) (Record, error) + // FindWarMap 返回该 org+user 的 war-map 记录;无记录时 + // Found=false(非错误)。 + FindWarMap(ctx context.Context, orgID, userID string) (Record, error) + // FindNewsnow 返回该 org+user 的 newsnow 记录;无记录时 + // Found=false(非错误)。 + FindNewsnow(ctx context.Context, orgID, userID string) (Record, error) + // FindSituationMonitor 一次查询聚合该 org+user 的 situation-monitor + // 三条固定 key 记录(NestJS findMany 语义:返回的 Records 按 monitors/ + // layout/settings 各自 Found 标记,查询 WHERE orgId+userId+key IN + // (三个编译期常量)——不接受任何请求传入的 key)。 + FindSituationMonitor(ctx context.Context, orgID, userID string) (SituationMonitorRecords, error) +} + +// SituationMonitorRecords 是 situation-monitor 三个固定 key 的聚合结果。 +type SituationMonitorRecords struct { + Monitors Record + Layout Record + Settings Record } // MySQLRepository 是 UserSetting 表的只读访问实现。 @@ -81,7 +114,58 @@ func (r *MySQLRepository) FindSpacetimeTimeline(ctx context.Context, orgID, user return r.findByKey(ctx, orgID, userID, SpacetimeTimelineKey) } -// findByKey 是三个端点共享的唯一查询实现(key 是编译期固定常量, +// FindWarMap 查询 war-map 设置(固定 key WarMapKey)。 +func (r *MySQLRepository) FindWarMap(ctx context.Context, orgID, userID string) (Record, error) { + return r.findByKey(ctx, orgID, userID, WarMapKey) +} + +// FindNewsnow 查询 newsnow 设置(固定 key NewsnowKey)。 +func (r *MySQLRepository) FindNewsnow(ctx context.Context, orgID, userID string) (Record, error) { + return r.findByKey(ctx, orgID, userID, NewsnowKey) +} + +// FindSituationMonitor 一次真实查询聚合三个固定 key(NestJS findMany +// 语义:WHERE orgId+userId+key IN 三个编译期常量;每 key 至多一条—— +// orgId+userId+key 联合唯一)。任何一条记录缺失只是 Found=false(业务 +// 结果),与 NestJS「无对应记录时该字段为 null」一致。 +func (r *MySQLRepository) FindSituationMonitor(ctx context.Context, orgID, userID string) (SituationMonitorRecords, error) { + const query = "SELECT `key`, value, updatedAt FROM UserSetting WHERE orgId = ? AND userId = ? AND `key` IN (?, ?, ?)" + + rows, err := r.db.QueryContext(ctx, query, orgID, userID, + SituationMonitorMonitorsKey, SituationMonitorLayoutKey, SituationMonitorSettingsKey) + if err != nil { + return SituationMonitorRecords{}, fmt.Errorf("%w: query situation-monitor: %v", ErrDatabase, err) + } + defer rows.Close() + + records := SituationMonitorRecords{} + for rows.Next() { + var key string + var value []byte + var updatedAt sql.NullTime + if err := rows.Scan(&key, &value, &updatedAt); err != nil { + return SituationMonitorRecords{}, fmt.Errorf("%w: scan situation-monitor: %v", ErrDatabase, err) + } + if !updatedAt.Valid { + return SituationMonitorRecords{}, fmt.Errorf("%w: updatedAt is NULL", ErrDatabase) + } + record := Record{Found: true, Value: value, UpdatedAt: updatedAt.Time} + switch SettingKey(key) { + case SituationMonitorMonitorsKey: + records.Monitors = record + case SituationMonitorLayoutKey: + records.Layout = record + case SituationMonitorSettingsKey: + records.Settings = record + } + } + if err := rows.Err(); err != nil { + return SituationMonitorRecords{}, fmt.Errorf("%w: iterate situation-monitor: %v", ErrDatabase, err) + } + return records, nil +} + +// findByKey 是五个单 key 端点共享的唯一查询实现(key 是编译期固定常量, // 绝非请求输入)。 // // 只取 value 与 updatedAt(最小列集);DATETIME(3) 无时区,driver 以 diff --git a/apps/api-go/internal/usersettings/situationmonitor.go b/apps/api-go/internal/usersettings/situationmonitor.go new file mode 100644 index 00000000..43729b31 --- /dev/null +++ b/apps/api-go/internal/usersettings/situationmonitor.go @@ -0,0 +1,602 @@ +// Situation Monitor UI 设置的 normalization 契约(Go-批3B)。 +// +// 契约对齐(NestJS getSituationMonitorUiSettings + normalizeMonitors / +// normalizeLayout / normalizeSettings, +// apps/api/src/modules/user-settings/user-settings.service.ts:970-1021 与 +// 306-514): +// - 存储 key 三个:ui:situation-monitor:{monitors,layout,settings}:v1 +// (SituationMonitor{Monitors,Layout,Settings}Key,repository.go); +// - 响应聚合:version=1;updatedAt.{monitors,layout,settings} 各来自 +// 对应记录,无记录时该 key 不出现在 updatedAt;monitors/layout/ +// settings 各自无记录时为 null(不是默认值); +// - monitors(normalizeMonitors,306-352):数组内只保留非数组对象; +// name(trim 后 64 截断)与 keywords(split(",")/trim/去空/去重/ +// 最多 30)任一为空则整条丢弃;id 缺失时 fallback "sm-" + 前 10 字符 +// UUID(本包为确定性测试提供 rand 注入点——生产等价 randomUUID); +// enabled 缺省 true;color 只接受 #RGB/#RRGGBB(补 #、小写);location +// 要求 name/lat/lng 全有效且 |lat|<=90、|lng|<=180;createdAt 非有限 +// 数字时 fallback 当前时间;最多 20 条(push 后 break); +// - layout(normalizeLayout,407-476):layouts 只接受 lg/md/sm/xs/xxs +// 五个断点的数组(非数组跳过;空数组不写入);无 lg 时 legacy `layout` +// 数组回退到 lg;每断点最多 64 项;item 要求 i(trim 后 128 截断)非空, +// x/y 非负取整(缺省 0)、w/h 最小 1 取整(缺省 1)、minW/minH 最小 1 +// 取整(缺失时字段不出现)、static 仅布尔(缺失时不出现);visibility +// 是 map,只保留布尔值项,key trim 后 128 截断且非空,最多 64 个 +// (Object.entries 顺序——见下方有序对象说明); +// - settings(normalizeSettings,495-514):windowHours ∈ {6,24,72} +// 其余 24;scope 仅 "tagged" 否则 "all";autoRefresh/resetLayoutOnPreset/ +// translateToZh 非布尔时分别回退 true/false/false。 +// +// 有序对象语义(visibility):NestJS 用 Object.entries 按插入序遍历并在 +// 达到 64 项上限时停止。encoding/json 的 map 遍历是按 key 排序的,行为 +// 会偏离。为此 visibility 的解码用 orderedEntries 的轻量有序读取(保留 +// 原始 key 顺序),不复用 map[string]any——这是本包唯一的有序对象点, +// 不是通用 JSON 框架。 +package usersettings + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "math" + "strings" + "time" +) + +// jsonUnmarshal 与 round 声明别名以集中依赖(jsonUnmarshal = json.Unmarshal; +// round = math.Round——half-away-from-zero,与 Math.round 一致)。 +func jsonUnmarshal(data []byte, v any) error { return json.Unmarshal(data, v) } +func round(f float64) float64 { return math.Round(f) } + +const ( + // maxSituationMonitors 与 NestJS MAX_MONITORS 一致。 + maxSituationMonitors = 20 + // maxSituationLayoutItemsPerBreakpoint 与 NestJS + // MAX_LAYOUT_ITEMS_PER_BREAKPOINT 一致。 + maxSituationLayoutItemsPerBreakpoint = 64 + // maxSituationVisibilityKeys 与 NestJS MAX_VISIBILITY_KEYS 一致。 + maxSituationVisibilityKeys = 64 + // maxSituationKeywords 与 NestJS normalizeKeywords 的 slice(0, 30) 一致。 + maxSituationKeywords = 30 + // maxSituationMonitorNameLen 与 NestJS normalizeName 的 slice(0, 64) 一致。 + maxSituationMonitorNameLen = 64 + // maxSituationLayoutItemIDLen 与 normalizeLayoutItem 的 i 截断一致。 + maxSituationLayoutItemIDLen = 128 +) + +// situationLayoutBreakpoints 与 NestJS SITUATION_MONITOR_LAYOUT_BREAKPOINTS +// 一一对应(顺序也一致——legacy lg 回退检查在遍历后)。 +var situationLayoutBreakpoints = [5]string{"lg", "md", "sm", "xs", "xxs"} + +// SituationMonitorLocation 是自定义监控项的地点。 +type SituationMonitorLocation struct { + Name string `json:"name"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +// SituationMonitorCustomMonitor 对齐 NestJS SituationMonitorCustomMonitor。 +// Color/Location 为指针:缺失时 omitempty 不出现。 +type SituationMonitorCustomMonitor struct { + ID string `json:"id"` + Name string `json:"name"` + Keywords []string `json:"keywords"` + Enabled bool `json:"enabled"` + Color *string `json:"color,omitempty"` + Location *SituationMonitorLocation `json:"location,omitempty"` + CreatedAt int64 `json:"createdAt"` +} + +// SituationMonitorLayoutItem 对齐 NestJS SituationMonitorLayoutItem。 +type SituationMonitorLayoutItem struct { + I string `json:"i"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + MinW *int `json:"minW,omitempty"` + MinH *int `json:"minH,omitempty"` + IsStatic *bool `json:"static,omitempty"` +} + +// SituationMonitorLayout 对齐 NestJS SituationMonitorLayout。Layouts 是 +// 指针 map:断点缺失时 key 不出现(omitempty per-entry 不行——map 整体 +// 用 nil 区分空/无;这里值是 [],key 存在与否由 map entry 决定)。 +type SituationMonitorLayout struct { + Layouts map[string][]SituationMonitorLayoutItem `json:"layouts"` + Visibility map[string]bool `json:"visibility"` +} + +// SituationMonitorSettings 对齐 NestJS SituationMonitorSettings。 +type SituationMonitorSettings struct { + WindowHours int `json:"windowHours"` + Scope string `json:"scope"` + AutoRefresh bool `json:"autoRefresh"` + ResetLayoutOnPreset bool `json:"resetLayoutOnPreset"` + TranslateToZh bool `json:"translateToZh"` +} + +// SituationMonitorResponse 是 GET /api/user-settings/ui/situation-monitor +// 的响应体。Monitors/Layout/Settings 为指针:各自无记录时 nil → JSON null。 +// UpdatedAt 三个字段分别 omitempty:无对应记录时不出现在 updatedAt。 +type SituationMonitorResponse struct { + Version int `json:"version"` + UpdatedAt struct { + Monitors string `json:"monitors,omitempty"` + Layout string `json:"layout,omitempty"` + Settings string `json:"settings,omitempty"` + } `json:"updatedAt"` + Monitors []SituationMonitorCustomMonitor `json:"monitors"` + Layout *SituationMonitorLayout `json:"layout"` + Settings *SituationMonitorSettings `json:"settings"` +} + +// BuildSituationMonitorResponse 输入三记录聚合结果,输出完整响应。 +func BuildSituationMonitorResponse(records SituationMonitorRecords) SituationMonitorResponse { + var response SituationMonitorResponse + response.Version = 1 + if records.Monitors.Found { + response.UpdatedAt.Monitors = formatJSISO(records.Monitors.UpdatedAt) + response.Monitors = NormalizeSituationMonitors(records.Monitors.Value) + } else { + response.Monitors = nil + } + if records.Layout.Found { + response.UpdatedAt.Layout = formatJSISO(records.Layout.UpdatedAt) + response.Layout = NormalizeSituationLayout(records.Layout.Value) + } else { + response.Layout = nil + } + if records.Settings.Found { + response.UpdatedAt.Settings = formatJSISO(records.Settings.UpdatedAt) + settings := NormalizeSituationSettings(records.Settings.Value) + response.Settings = &settings + } else { + response.Settings = nil + } + return response +} + +// NormalizeSituationMonitors 复刻 NestJS normalizeMonitors。 +func NormalizeSituationMonitors(raw []byte) []SituationMonitorCustomMonitor { + value, ok := asJSONObjectArray(raw) + if !ok { + return []SituationMonitorCustomMonitor{} + } + out := make([]SituationMonitorCustomMonitor, 0, len(value)) + for _, record := range value { + name := normalizeSituationName(record["name"]) + keywords := normalizeSituationKeywords(record["keywords"]) + if name == "" || len(keywords) == 0 { + continue + } + id := "" + if s, ok := record["id"].(string); ok { + id = truncateRunes(trimSpace(s), 64) + } + if id == "" { + id = "sm-" + randomID10() + } + monitor := SituationMonitorCustomMonitor{ + ID: id, + Name: name, + Keywords: keywords, + Enabled: true, + CreatedAt: time.Now().UnixMilli(), + } + if b, ok := record["enabled"].(bool); ok { + monitor.Enabled = b + } + if color, ok := normalizeSituationColor(record["color"]); ok { + monitor.Color = &color + } + if location, ok := normalizeSituationLocation(record["location"]); ok { + monitor.Location = &location + } + if f, ok := record["createdAt"].(float64); ok { + monitor.CreatedAt = int64(f) + } + out = append(out, monitor) + if len(out) >= maxSituationMonitors { + break + } + } + return out +} + +// NormalizeSituationLayout 复刻 NestJS normalizeLayout:非对象 → +// {layouts:{}, visibility:{}}。 +func NormalizeSituationLayout(raw []byte) *SituationMonitorLayout { + layout := &SituationMonitorLayout{Layouts: map[string][]SituationMonitorLayoutItem{}, Visibility: map[string]bool{}} + value, ok := asJSONObject(raw) + if !ok { + return layout + } + + rawLayouts, _ := value["layouts"].(map[string]any) + for _, breakpoint := range situationLayoutBreakpoints { + entries, ok := rawLayouts[breakpoint].([]any) + if !ok { + continue + } + normalized := normalizeSituationLayoutItems(entries) + if len(normalized) > 0 { + layout.Layouts[breakpoint] = normalized + } + } + + // legacy `layout` 数组回退到 lg(仅当 lg 尚无内容)。 + if _, hasLG := layout.Layouts["lg"]; !hasLG { + if entries, ok := value["layout"].([]any); ok { + normalized := normalizeSituationLayoutItems(entries) + if len(normalized) > 0 { + layout.Layouts["lg"] = normalized + } + } + } + + // visibility:Object.entries 插入序 + 64 项上限——用有序解码 + //(value["visibility"] 经 asJSONObject 已是 any;这里重新从原始 + // JSON 提取 raw bytes 以保序——代价是二次解析,仅此一处语义点)。 + if rawVisibility := extractRawField(raw, "visibility"); rawVisibility != nil { + if entries, ok := orderedEntries(rawVisibility); ok { + for _, entry := range entries { + var b bool + if err := json.Unmarshal(entry.value, &b); err != nil { + continue + } + key := truncateRunes(trimSpace(entry.key), maxSituationLayoutItemIDLen) + if key == "" { + continue + } + layout.Visibility[key] = b + if len(layout.Visibility) >= maxSituationVisibilityKeys { + break + } + } + } + } + return layout +} + +// extractRawField 用 json.Decoder 流式提取顶层对象的某个字段的原始 +// JSON(不整体解析成 map——顺序无关字段之外的保序入口)。 +func extractRawField(raw []byte, field string) []byte { + if len(raw) == 0 { + return nil + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + token, err := decoder.Token() + if err != nil { + return nil + } + if delim, ok := token.(json.Delim); !ok || delim != '{' { + return nil + } + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil + } + key, ok := keyToken.(string) + if !ok { + return nil + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil + } + if key == field { + return value + } + } + return nil +} + +// normalizeSituationLayoutItems 规整单个断点的 item 数组(过滤 + 64 截断)。 +func normalizeSituationLayoutItems(entries []any) []SituationMonitorLayoutItem { + out := make([]SituationMonitorLayoutItem, 0, len(entries)) + for _, entry := range entries { + record, ok := entry.(map[string]any) + if !ok { + continue + } + item, ok := normalizeSituationLayoutItem(record) + if !ok { + continue + } + out = append(out, item) + if len(out) >= maxSituationLayoutItemsPerBreakpoint { + break + } + } + return out +} + +// normalizeSituationLayoutItem 复刻 NestJS normalizeLayoutItem +// (354-405)。返回 false = 丢弃(非对象或 i 为空)。 +func normalizeSituationLayoutItem(record map[string]any) (SituationMonitorLayoutItem, bool) { + var item SituationMonitorLayoutItem + id := "" + if s, ok := record["i"].(string); ok { + id = truncateRunes(trimSpace(s), maxSituationLayoutItemIDLen) + } + if id == "" { + return item, false + } + item.I = id + item.X = nonNegativeInt(record["x"], 0) + item.Y = nonNegativeInt(record["y"], 0) + item.W = nonNegativeInt(record["w"], 1) + item.H = nonNegativeInt(record["h"], 1) + if v := positiveOptionalInt(record["minW"]); v != nil { + item.MinW = v + } + if v := positiveOptionalInt(record["minH"]); v != nil { + item.MinH = v + } + if b, ok := record["static"].(bool); ok { + item.IsStatic = &b + } + return item, true +} + +// NormalizeSituationSettings 复刻 NestJS normalizeSettings。 +func NormalizeSituationSettings(raw []byte) SituationMonitorSettings { + settings := SituationMonitorSettings{ + WindowHours: 24, + Scope: "all", + AutoRefresh: true, + ResetLayoutOnPreset: false, + TranslateToZh: false, + } + value, ok := asJSONObject(raw) + if !ok { + return settings + } + if f, ok := value["windowHours"].(float64); ok && (f == 6 || f == 24 || f == 72) { + settings.WindowHours = int(f) + } + if s, ok := value["scope"].(string); ok && s == "tagged" { + settings.Scope = "tagged" + } + if b, ok := value["autoRefresh"].(bool); ok { + settings.AutoRefresh = b + } + if b, ok := value["resetLayoutOnPreset"].(bool); ok { + settings.ResetLayoutOnPreset = b + } + if b, ok := value["translateToZh"].(bool); ok { + settings.TranslateToZh = b + } + return settings +} + +// normalizeSituationName:字符串 trim 后 64 截断,其余空(NestJS +// normalizeName)。 +func normalizeSituationName(value any) string { + s, ok := value.(string) + if !ok { + return "" + } + return truncateRunes(trimSpace(s), maxSituationMonitorNameLen) +} + +// normalizeSituationKeywords:数组内字符串 split(",")/trim/去空/截前 30 +// 再去重(NestJS normalizeKeywords 的顺序:flatMap(split)→map(trim)→ +// filter(非空)→slice(0,30)→Set——**先截断后去重**,重复项会占位)。 +func normalizeSituationKeywords(value any) []string { + entries, ok := value.([]any) + if !ok { + return nil + } + collected := []string{} + for _, entry := range entries { + s, ok := entry.(string) + if !ok { + continue + } + for _, part := range splitString(s, ",") { + trimmed := trimSpace(part) + if trimmed == "" { + continue + } + collected = append(collected, trimmed) + if len(collected) >= maxSituationKeywords { + break + } + } + if len(collected) >= maxSituationKeywords { + break + } + } + // 去重保留首现(Array.from(new Set(...)) 语义——输入已按序)。 + out := make([]string, 0, len(collected)) + seen := make(map[string]struct{}, len(collected)) + for _, keyword := range collected { + if _, duplicate := seen[keyword]; duplicate { + continue + } + seen[keyword] = struct{}{} + out = append(out, keyword) + } + return out +} + +// normalizeSituationColor:补 # 后只接受 #RGB/#RRGGBB,小写(NestJS +// normalizeColor)。ok=false = 不出现(undefined)。 +func normalizeSituationColor(value any) (string, bool) { + s, ok := value.(string) + if !ok { + return "", false + } + trimmed := trimSpace(s) + if trimmed == "" { + return "", false + } + normalized := trimmed + if normalized[0] != '#' { + normalized = "#" + normalized + } + if len(normalized) == 4 && isHexDigits(normalized[1:]) || + len(normalized) == 7 && isHexDigits(normalized[1:]) { + return toLower(normalized), true + } + return "", false +} + +// normalizeSituationLocation:name/lat/lng 全有效且范围合法才保留 +//(NestJS normalizeLocation)。 +func normalizeSituationLocation(value any) (SituationMonitorLocation, bool) { + record, ok := value.(map[string]any) + if !ok { + return SituationMonitorLocation{}, false + } + name := normalizeSituationName(record["name"]) + lat, latOK := record["lat"].(float64) + lng, lngOK := record["lng"].(float64) + if name == "" || !latOK || !lngOK { + return SituationMonitorLocation{}, false + } + if lat > 90 || lat < -90 || lng > 180 || lng < -180 { + return SituationMonitorLocation{}, false + } + return SituationMonitorLocation{Name: name, Lat: lat, Lng: lng}, true +} + +// nonNegativeInt:有限数字 → max(lowerBound, round);其余 lowerBound +//(NestJS Math.max(0|1, Math.round(x)) + 非数字回退语义)。 +func nonNegativeInt(value any, lowerBound int) int { + f, ok := value.(float64) + if !ok { + return lowerBound + } + return maxInt(lowerBound, jsRound(f)) +} + +// positiveOptionalInt:仅有限数字时 min 1 取整并出现;缺失/非数字时 +// 字段不出现(NestJS minW/minH 的 undefined 语义)。 +func positiveOptionalInt(value any) *int { + f, ok := value.(float64) + if !ok { + return nil + } + v := maxInt(1, jsRound(f)) + return &v +} + +// maxInt 是两个整数的较大者(避免引入 math 依赖的单行 helper)。 +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// jsRound 复刻 Math.round 的 half-away-from-zero 语义(Go math.Round 一致)。 +func jsRound(f float64) int { + return int(round(f)) +} + +// randomID10 生成 "xxxxxxxxxx"(10 个十六进制字符)——等价 +// randomUUID().slice(0, 10)。crypto/rand;失败时以当前时间纳秒兜底 +//(不 panic——normalization 失败不是 500 的理由)。 +func randomID10() string { + buf := make([]byte, 5) + if _, err := rand.Read(buf); err != nil { + now := time.Now().UnixNano() + for i := 0; i < 5; i++ { + buf[i] = byte(now >> (8 * i)) + } + } + return hex.EncodeToString(buf)[:10] +} + +// asJSONObjectArray:raw 解析为 JSON 数组(元素类型不约束);空输入、 +// 解析失败、非数组返回 false(NestJS Array.isArray 判定)。 +func asJSONObjectArray(raw []byte) ([]any, bool) { + if len(raw) == 0 { + return nil, false + } + var parsed any + if err := jsonUnmarshal(raw, &parsed); err != nil { + return nil, false + } + entries, ok := parsed.([]any) + return entries, ok +} + +// isHexDigits:ASCII 十六进制字符(大小写均可,NestJS [0-9a-fA-F])。 +func isHexDigits(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return false + } + } + return len(s) > 0 +} + +// toLower:ASCII 小写(color 语义只含十六进制字符,ASCII 足够)。 +func toLower(s string) string { + out := []byte(s) + for i, c := range out { + if c >= 'A' && c <= 'Z' { + out[i] = c + ('a' - 'A') + } + } + return string(out) +} + +// trimSpace 是 strings.TrimSpace 的本文件别名(保持与 NestJS trim 等价, +// 集中声明便于核对)。 +func trimSpace(s string) string { return strings.TrimSpace(s) } + +// splitString 是 strings.Split 的别名(keywords 的逗号拆分)。 +func splitString(s, sep string) []string { return strings.Split(s, sep) } + +// orderedEntry 是有序对象的一个 key/value(保留 JSON 原始出现顺序)。 +type orderedEntry struct { + key string + value []byte // 该 entry value 的原始 JSON(调用方按需再解码) +} + +// orderedEntries 把 JSON 对象按出现顺序提取为 entries。 +// +// 输入是原始 JSON 字节——encoding/json 的 map[string]any 不保序,因此 +// 这里用 json.Decoder 流式按序读取。调用方(NestJS 侧对应 +// Object.entries 遍历的三个语义点:situation-monitor visibility、newsnow +// columnOrders/sourceAffinity)需要「前 N 项」的确定性顺序,都必须先经 +// orderedEntries,再对每个 value 自行解码。这不是通用 JSON 框架—— +// 只服务三个有序对象点。 +func orderedEntries(raw []byte) ([]orderedEntry, bool) { + if len(raw) == 0 { + return nil, false + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + // 读掉 '{'。 + token, err := decoder.Token() + if err != nil { + return nil, false + } + if delim, ok := token.(json.Delim); !ok || delim != '{' { + return nil, false + } + var entries []orderedEntry + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, false + } + key, ok := keyToken.(string) + if !ok { + return nil, false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, false + } + entries = append(entries, orderedEntry{key: key, value: value}) + } + return entries, true +} diff --git a/apps/api-go/internal/usersettings/warmap.go b/apps/api-go/internal/usersettings/warmap.go new file mode 100644 index 00000000..30823bbe --- /dev/null +++ b/apps/api-go/internal/usersettings/warmap.go @@ -0,0 +1,339 @@ +// War Map UI 设置的 normalization 契约(Go-批3B)。 +// +// 权威来源是 packages/utils/src/war-map-contract.ts 的 normalizeWarMapSettings +//(499-528 行)——完整移植,不是只实现 smoke fixture 用到的字段: +// - 全部 46 个 layer id 与默认 visibility(WAR_MAP_LAYER_IDS / +// WAR_MAP_DEFAULT_LAYER_VISIBILITY); +// - legacy layer key 映射(LEGACY_WAR_MAP_LAYER_KEY_MAP:conflictZones→ +// conflicts 等 7 项——只在新 key 无布尔值时作 fallback); +// - viewState:lat clamp [-90,90]、lon clamp [-180,180]、zoom clamp +// [0.5,18],缺失/非有限数字回退默认(20/0/1.8);bearing/pitch 强制 +// 归零(契约如此,不读输入); +// - activePreset ∈ 8 个 preset,否则 "global";timeRangePreset ∈ 6 个 +// preset,否则 "7d"; +// - flightMode:仅 "all" 保留,其余 "military";aisMode:all/density +// 保留,其余 "military"; +// - aisHighlightCandidates:仅严格 false 为 false,其余(缺失/非布尔/ +// true)一律 true; +// - layerVisibility 输入是「根对象的 layerVisibility 字段」或「根对象 +// 本身」(record.layerVisibility 是非数组对象时用它,否则用 record)—— +// 这是「对象但字段缺失」与「整体非对象」之间的既有默认差异:整体非 +// 对象 → 全默认(不含任何输入字段);对象 + layerVisibility 字段 +// 缺失 → 逐 layer 读根对象自身。 +// +// 存储 key:ui:war-map:settings:v1(WarMapKey,repository.go)。无记录 → +// settings null(repository/build 层语义,同其他端点)。 +package usersettings + +const ( + // warMapLayerCount 与 WAR_MAP_LAYER_IDS 长度一致(46)。 + warMapLayerCount = 46 + // warMapVisibilityLegacyCount 与 LEGACY_WAR_MAP_LAYER_KEY_MAP 条目数一致(7)。 + warMapVisibilityLegacyCount = 7 +) + +// warMapLayerIDs 与 WAR_MAP_LAYER_IDS 一一对应(顺序一致——序列化顺序 +// 由 Go struct 固定,与 NestJS 展开顺序一致地按此声明顺序输出)。 +var warMapLayerIDs = [warMapLayerCount]string{ + "conflicts", "bases", "cables", "pipelines", "hotspots", "ais", + "nuclear", "irradiators", "sanctions", "weather", "economic", "waterways", + "outages", "cyberThreats", "datacenters", "protests", "flights", "military", + "natural", "spaceports", "minerals", "fires", "ucdpEvents", "displacement", + "climate", "startupHubs", "cloudRegions", "accelerators", "techHQs", "techEvents", + "stockExchanges", "financialCenters", "centralBanks", "commodityHubs", "gulfInvestments", "positiveEvents", + "kindness", "happiness", "speciesRecovery", "renewableInstallations", "tradeRoutes", "iranAttacks", + "gpsJamming", "dayNight", "monitors", +} + +// warMapDefaultVisibility 与 WAR_MAP_DEFAULT_LAYER_VISIBILITY 一一对应。 +var warMapDefaultVisibility = [warMapLayerCount]bool{ + true, true, false, false, true, true, + true, false, true, true, true, true, + true, false, false, false, true, true, + true, false, false, false, false, false, + false, false, false, false, false, false, + false, false, false, false, false, false, + false, false, false, false, false, true, + false, false, true, +} + +// warMapLegacyLayerKeys 与 LEGACY_WAR_MAP_LAYER_KEY_MAP 的 key 一一对应 +//(与 warMapLayerIDs 平行数组:legacyKeys[i] 映射到 layerIDs[i])。 +var warMapLegacyLayerKeys = [warMapVisibilityLegacyCount]string{ + "conflictZones", "chokepoints", "cableLandings", "nuclearSites", + "militaryBases", "hotspots", "monitors", +} + +// warMapLegacyLayerTargets 与 LEGACY_WAR_MAP_LAYER_KEY_MAP 的 value 对应 +//(legacyKeys[i] → warMapLegacyLayerTargets[i])。 +var warMapLegacyLayerTargets = [warMapVisibilityLegacyCount]string{ + "conflicts", "waterways", "cables", "nuclear", + "bases", "hotspots", "monitors", +} + +// warMapPresets 与 WAR_MAP_PRESETS 一致。 +var warMapPresets = map[string]bool{ + "global": true, "america": true, "mena": true, "eu": true, + "asia": true, "latam": true, "africa": true, "oceania": true, +} + +// warMapTimeRangePresets 与 WAR_MAP_TIME_RANGE_PRESETS 一致。 +var warMapTimeRangePresets = map[string]bool{ + "1h": true, "6h": true, "24h": true, "48h": true, "7d": true, "all": true, +} + +// WarMapViewState 对齐 NestJS WarMapViewState(lat/lon/zoom + 强制归零的 +// bearing/pitch)。 +type WarMapViewState struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Zoom float64 `json:"zoom"` + Bearing float64 `json:"bearing"` + Pitch float64 `json:"pitch"` +} + +// WarMapSettings 对齐 NestJS WarMapSettings。LayerVisibility 用固定 46 +// 字段 struct(不是 map):序列化顺序与 NestJS 展开顺序一致,且编译期 +// 封闭集合——不存在未知 layer。 +type WarMapSettings struct { + LayerVisibility warMapLayerVisibility `json:"layerVisibility"` + ViewState WarMapViewState `json:"viewState"` + ActivePreset string `json:"activePreset"` + TimeRangePreset string `json:"timeRangePreset"` + FlightMode string `json:"flightMode"` + AisMode string `json:"aisMode"` + AisHighlightCandidates bool `json:"aisHighlightCandidates"` +} + +// warMapLayerVisibility 是 46 个 layer 的固定字段 visibility(字段顺序与 +// WAR_MAP_LAYER_IDS 一致)。 +type warMapLayerVisibility struct { + Conflicts bool `json:"conflicts"` + Bases bool `json:"bases"` + Cables bool `json:"cables"` + Pipelines bool `json:"pipelines"` + Hotspots bool `json:"hotspots"` + Ais bool `json:"ais"` + Nuclear bool `json:"nuclear"` + Irradiators bool `json:"irradiators"` + Sanctions bool `json:"sanctions"` + Weather bool `json:"weather"` + Economic bool `json:"economic"` + Waterways bool `json:"waterways"` + Outages bool `json:"outages"` + CyberThreats bool `json:"cyberThreats"` + Datacenters bool `json:"datacenters"` + Protests bool `json:"protests"` + Flights bool `json:"flights"` + Military bool `json:"military"` + Natural bool `json:"natural"` + Spaceports bool `json:"spaceports"` + Minerals bool `json:"minerals"` + Fires bool `json:"fires"` + UcdpEvents bool `json:"ucdpEvents"` + Displacement bool `json:"displacement"` + Climate bool `json:"climate"` + StartupHubs bool `json:"startupHubs"` + CloudRegions bool `json:"cloudRegions"` + Accelerators bool `json:"accelerators"` + TechHQs bool `json:"techHQs"` + TechEvents bool `json:"techEvents"` + StockExchanges bool `json:"stockExchanges"` + FinancialCenters bool `json:"financialCenters"` + CentralBanks bool `json:"centralBanks"` + CommodityHubs bool `json:"commodityHubs"` + GulfInvestments bool `json:"gulfInvestments"` + PositiveEvents bool `json:"positiveEvents"` + Kindness bool `json:"kindness"` + Happiness bool `json:"happiness"` + SpeciesRecovery bool `json:"speciesRecovery"` + RenewableInstallations bool `json:"renewableInstallations"` + TradeRoutes bool `json:"tradeRoutes"` + IranAttacks bool `json:"iranAttacks"` + GpsJamming bool `json:"gpsJamming"` + DayNight bool `json:"dayNight"` + Monitors bool `json:"monitors"` +} + +// warMapVisibilityFields 把 visibility struct 转为平行切片(apply 输入用)。 +func warMapVisibilityFields(v *warMapLayerVisibility) []*bool { + return []*bool{ + &v.Conflicts, &v.Bases, &v.Cables, &v.Pipelines, &v.Hotspots, &v.Ais, + &v.Nuclear, &v.Irradiators, &v.Sanctions, &v.Weather, &v.Economic, &v.Waterways, + &v.Outages, &v.CyberThreats, &v.Datacenters, &v.Protests, &v.Flights, &v.Military, + &v.Natural, &v.Spaceports, &v.Minerals, &v.Fires, &v.UcdpEvents, &v.Displacement, + &v.Climate, &v.StartupHubs, &v.CloudRegions, &v.Accelerators, &v.TechHQs, &v.TechEvents, + &v.StockExchanges, &v.FinancialCenters, &v.CentralBanks, &v.CommodityHubs, &v.GulfInvestments, &v.PositiveEvents, + &v.Kindness, &v.Happiness, &v.SpeciesRecovery, &v.RenewableInstallations, &v.TradeRoutes, &v.IranAttacks, + &v.GpsJamming, &v.DayNight, &v.Monitors, + } +} + +// WarMapResponse 是 GET /api/user-settings/ui/war-map 的响应体(settings +// 无记录时 null;updatedAt 同其他端点语义)。 +type WarMapResponse struct { + Version int `json:"version"` + UpdatedAt struct { + Settings string `json:"settings,omitempty"` + } `json:"updatedAt"` + Settings *WarMapSettings `json:"settings"` +} + +// BuildWarMapResponse 输入数据库记录,输出完整响应。 +func BuildWarMapResponse(record Record) WarMapResponse { + var response WarMapResponse + response.Version = 1 + if !record.Found { + return response + } + response.UpdatedAt.Settings = formatJSISO(record.UpdatedAt) + settings := NormalizeWarMap(record.Value) + response.Settings = &settings + return response +} + +// NormalizeWarMap 复刻 normalizeWarMapSettings(war-map-contract.ts:499-528)。 +func NormalizeWarMap(raw []byte) WarMapSettings { + settings := defaultWarMap() + + value, ok := asJSONObject(raw) + if !ok { + return settings + } + + settings.LayerVisibility = coerceWarMapLayerVisibility(value) + settings.ViewState = normalizeWarMapViewState(value["viewState"]) + settings.ActivePreset = warMapPresetOr(value["activePreset"]) + settings.TimeRangePreset = warMapTimeRangePresetOr(value["timeRangePreset"]) + settings.FlightMode = warMapFlightModeOr(value["flightMode"]) + settings.AisMode = warMapAisModeOr(value["aisMode"]) + if b, isBool := value["aisHighlightCandidates"].(bool); isBool && !b { + settings.AisHighlightCandidates = false + } + return settings +} + +// defaultWarMap 是非对象输入的全默认(normalizeWarMapSettings 的第一分支)。 +func defaultWarMap() WarMapSettings { + visibility := defaultWarMapVisibility() + return WarMapSettings{ + LayerVisibility: visibility, + ViewState: WarMapViewState{Lat: 20, Lon: 0, Zoom: 1.8}, + ActivePreset: "global", + TimeRangePreset: "7d", + FlightMode: "military", + AisMode: "all", + AisHighlightCandidates: true, + } +} + +// defaultWarMapVisibility 从平行数组填充默认 visibility。 +func defaultWarMapVisibility() warMapLayerVisibility { + var visibility warMapLayerVisibility + fields := warMapVisibilityFields(&visibility) + for i := range warMapLayerIDs { + *fields[i] = warMapDefaultVisibility[i] + } + return visibility +} + +// coerceWarMapLayerVisibility 复刻 coerceWarMapLayerVisibility +//(465-497):raw 是「根对象的 layerVisibility 字段值」;输入不是非数组 +// 对象时用根对象自身(record.layerVisibility ?? record 语义——注意 +// NestJS 的 ?? 只判 null/undefined,此处 value 来自 asJSONObject 保证是 +// 对象)。逐 layer:新 key 有布尔值直接采用;否则查 legacy key 映射, +// legacy 值也是布尔才采用;否则保持默认。 +func coerceWarMapLayerVisibility(record map[string]any) warMapLayerVisibility { + visibility := defaultWarMapVisibility() + + rawVisibility := any(record) + if nested, ok := record["layerVisibility"].(map[string]any); ok { + rawVisibility = nested + } + rawRecord, _ := rawVisibility.(map[string]any) + + fields := warMapVisibilityFields(&visibility) + for i, layerID := range warMapLayerIDs { + if b, ok := rawRecord[layerID].(bool); ok { + *fields[i] = b + continue + } + // legacy key fallback(每层至多一个 legacy key 指向它)。 + for j, legacyKey := range warMapLegacyLayerKeys { + if warMapLegacyLayerTargets[j] == layerID { + if b, ok := rawRecord[legacyKey].(bool); ok { + *fields[i] = b + } + break + } + } + } + return visibility +} + +// normalizeWarMapViewState 复刻 normalizeWarMapViewState(439-453)。 +func normalizeWarMapViewState(value any) WarMapViewState { + viewState := WarMapViewState{Lat: 20, Lon: 0, Zoom: 1.8} + record, ok := value.(map[string]any) + if !ok { + return viewState + } + if f, ok := record["lat"].(float64); ok { + viewState.Lat = clampFloatValue(f, -90, 90) + } + if f, ok := record["lon"].(float64); ok { + viewState.Lon = clampFloatValue(f, -180, 180) + } + if f, ok := record["zoom"].(float64); ok { + viewState.Zoom = clampFloatValue(f, 0.5, 18) + } + // bearing/pitch 强制归零(共享契约的平相机语义——不读输入值)。 + return viewState +} + +// clampFloatValue:clamp 到 [min, max](输入已是有限数字——JSON 解码 +// 的 float64 与 JS number 同语义,无 NaN/Infinity 分支)。 +func clampFloatValue(f, min, max float64) float64 { + if f < min { + return min + } + if f > max { + return max + } + return f +} + +// warMapPresetOr:合法 preset 保留,否则 "global"。 +func warMapPresetOr(value any) string { + if s, ok := value.(string); ok && warMapPresets[s] { + return s + } + return "global" +} + +// warMapTimeRangePresetOr:合法值保留,否则 "7d"。 +func warMapTimeRangePresetOr(value any) string { + if s, ok := value.(string); ok && warMapTimeRangePresets[s] { + return s + } + return "7d" +} + +// warMapFlightModeOr:仅 "all" 保留,其余 "military"。 +func warMapFlightModeOr(value any) string { + if s, ok := value.(string); ok && s == "all" { + return "all" + } + return "military" +} + +// warMapAisModeOr:all/density 保留,其余 "military"。 +func warMapAisModeOr(value any) string { + if s, ok := value.(string); ok { + switch s { + case "all", "density": + return s + } + } + return "military" +} From 098988196bca957a588b8ed9ceb9429206130ff7 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:30:36 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(api-go):=20=E7=BB=9F=E4=B8=80=20user?= =?UTF-8?q?-settings=20=E5=8F=AA=E8=AF=BB=20handler=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=AD=E7=AB=AF=E7=82=B9=E6=94=B6=E6=95=9B=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20internal/onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/usersettingsread:六个 GET 共享一个 handler——同一 authhttp.Authenticator 鉴权链装配(Bearer→JWT 验签→Redis blacklist→ MySQL membership/RBAC)、同一 usersettings.Repository、同一连接池; 端点差异收敛为编译期绑定表(query+build 闭包对),不复制六份 handler - 请求链固定:items.read 判定 → 固定 UserSetting 查询 → normalization → 契约响应(Cache-Control: no-store、authhttp 错误契约、不追加换行) - 旧 internal/onboarding 已被完全替代,删除(任务书:不得新旧并存); onboarding 端点改由统一 handler 的绑定表承载 --- apps/api-go/internal/onboarding/handler.go | 94 ---------- .../internal/usersettingsread/handler.go | 177 ++++++++++++++++++ 2 files changed, 177 insertions(+), 94 deletions(-) delete mode 100644 apps/api-go/internal/onboarding/handler.go create mode 100644 apps/api-go/internal/usersettingsread/handler.go diff --git a/apps/api-go/internal/onboarding/handler.go b/apps/api-go/internal/onboarding/handler.go deleted file mode 100644 index cce7c61b..00000000 --- a/apps/api-go/internal/onboarding/handler.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package onboarding 是首个由 Go 全响应的业务迁移单元(Go-批3A): -// -// GET /api/user-settings/ui/onboarding -// -// 请求顺序(任务书第十二节,与 NestJS JwtAuthGuard → PermissionsGuard → -// controller 的真实顺序一致): -// -// 提取 Bearer token → JWT 验签 → Redis blacklist → MySQL -// user/org/membership → MySQL role/permission → items.read → -// UserSetting 查询 → normalization → Go 写出响应 -// -// 边界(与 legacy-approved shadow identity 的本质区别): -// - 不请求 NestJS、不等待 legacy 200、不读取 JWT 的 permissions claim; -// - 身份与权限全部由 Go 独立验证(authn 验签 + authz MySQL 重推导); -// - 响应正文/头部与 NestJS 契约一致(usersettings.BuildOnboardingResponse -// 复用既有 normalization,本包不重复实现); -// - 非 GET 不进入本 handler(路由层 method 白名单 + 本包防御性检查)。 -// -// 回滚:API_GO_ONBOARDING_MODE=shadow(配置变更,无数据迁移耦合)。 -package onboarding - -import ( - "encoding/json" - "log" - "net/http" - - "github.com/wei500L/newwei/apps/api-go/internal/authhttp" - "github.com/wei500L/newwei/apps/api-go/internal/usersettings" -) - -// Path 是本迁移单元的精确路由(路由表 exact 匹配键)。 -const Path = "/api/user-settings/ui/onboarding" - -// RequiredPermission 是本端点的数据库推导权限要求(@Permissions("items.read"))。 -const RequiredPermission = "items.read" - -// Handler 依赖注入:完整 Go 鉴权链 + 既有 onboarding repository。 -type Handler struct { - auth authhttp.Authenticator - repo usersettings.Repository -} - -// NewHandler 构造 onboarding GET 的 Go 原生 handler(返回 -// legacyproxy.GoHandler 兼容的函数值)。 -func NewHandler(auth *authhttp.Authenticator, repo usersettings.Repository) func(http.ResponseWriter, *http.Request) { - handler := &Handler{auth: *auth, repo: repo} - return handler.ServeHTTP -} - -// ServeHTTP 处理 GET /api/user-settings/ui/onboarding。 -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // 路由层已按 method 白名单只放行 GET;此处防御性兜底(NestJS 该 - // 路由只注册 GET,其他方法 404)。 - if r.Method != http.MethodGet { - http.NotFound(w, r) - return - } - - // 完整 Go 鉴权链(失败时已写出契约错误)。 - identity := h.auth.Authenticate(w, r) - if identity == nil { - return - } - - // items.read 由 Go 从数据库推导的权限集独立判定(JWT claim 不参与)。 - if !identity.HasPermission(RequiredPermission) { - authhttp.WriteForbidden(w, r, []string{RequiredPermission}) - return - } - - // 业务查询:复用既有 onboarding repository 与 normalization(固定 - // key,租户隔离 orgId+userId)。 - record, err := h.repo.FindOnboarding(r.Context(), identity.OrgID, identity.UserID) - if err != nil { - log.Printf("onboarding: query failed: %v", err) - authhttp.WriteDatabaseFailure(w, r) - return - } - - body, err := json.Marshal(usersettings.BuildOnboardingResponse(record)) - if err != nil { - log.Printf("onboarding: marshal response failed: %v", err) - authhttp.WriteDatabaseFailure(w, r) - return - } - - // 契约头部:Cache-Control: no-store(@Header 装饰器);content-type - // 与 Express res.json 一致(含 charset)。正文不追加换行(与 - // res.json 字节形态一致)。 - w.Header().Set("cache-control", "no-store") - w.Header().Set("content-type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) -} diff --git a/apps/api-go/internal/usersettingsread/handler.go b/apps/api-go/internal/usersettingsread/handler.go new file mode 100644 index 00000000..076d8175 --- /dev/null +++ b/apps/api-go/internal/usersettingsread/handler.go @@ -0,0 +1,177 @@ +// Package usersettingsread 是 user-settings 六个只读 GET 的统一 Go +// 迁移单元(Go-批3B 收敛 Go-批3A 的 internal/onboarding——不新增第六套 +// 鉴权,全部端点共用同一 authn/authz/authhttp 链)。 +// +// 六个端点: +// +// GET /api/user-settings/ui/onboarding +// GET /api/user-settings/ui/rss-reader +// GET /api/user-settings/ui/spacetime-timeline +// GET /api/user-settings/ui/war-map +// GET /api/user-settings/ui/newsnow +// GET /api/user-settings/ui/situation-monitor +// +// 请求链(与 NestJS JwtAuthGuard → PermissionsGuard → controller 顺序 +// 一致): +// +// 提取 Bearer token → JWT 验签 → Redis blacklist → MySQL +// user/org/membership → MySQL role/permission → items.read → +// 对应固定 UserSetting 查询 → normalization → Go 写出响应 +// +// 边界: +// - 不请求 NestJS、不等待 legacy 200、不读取 JWT 的 permissions claim; +// - 身份与权限全部由 Go 独立验证(authhttp.Authenticate 一次装配, +// 六个端点共享); +// - 响应正文/头部与 NestJS 契约一致(usersettings.Build*Response 复用 +// normalization,本包不重复实现); +// - 非 GET 不进入本 handler(路由层 method 白名单 + 本包防御性检查); +// - repository 查询 key 是编译期固定常量(usersettings 包封闭集合)。 +// +// 回滚:API_GO_USER_SETTINGS_READ_MODE=shadow(配置变更,无数据迁移 +// 耦合)。 +package usersettingsread + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "github.com/wei500L/newwei/apps/api-go/internal/authhttp" + "github.com/wei500L/newwei/apps/api-go/internal/usersettings" +) + +// RequiredPermission 是六个端点共同的数据库推导权限要求 +//(@Permissions("items.read"))。 +const RequiredPermission = "items.read" + +// Paths 是六个只读 GET 的精确路由(路由表 exact 匹配键——顺序固定, +// 注册时逐条登记)。 +var Paths = [6]string{ + "/api/user-settings/ui/onboarding", + "/api/user-settings/ui/rss-reader", + "/api/user-settings/ui/spacetime-timeline", + "/api/user-settings/ui/war-map", + "/api/user-settings/ui/newsnow", + "/api/user-settings/ui/situation-monitor", +} + +// Handler 是六个 GET 共享的统一 Go 原生 handler:同一鉴权链 + +// usersettings repository;只有「查询哪个端点语义」由请求 path 决定。 +type Handler struct { + auth authhttp.Authenticator + repo usersettings.Repository +} + +// NewHandler 构造统一 handler。auth 是六个端点共享的鉴权链装配 +//(Bearer 提取 → JWT 验签 → Redis blacklist → MySQL membership/RBAC); +// repo 是同一 MySQL 连接池上的 user-settings 只读 repository。 +func NewHandler(auth *authhttp.Authenticator, repo usersettings.Repository) *Handler { + return &Handler{auth: *auth, repo: repo} +} + +// ServeHTTP 处理一个 user-settings 只读 GET。 +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // 路由层已按 method 白名单只放行 GET;此处防御性兜底(NestJS 该 + // 路由只注册 GET,其他方法 404)。 + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + // 完整 Go 鉴权链(失败时已写出契约错误)。 + identity := h.auth.Authenticate(w, r) + if identity == nil { + return + } + + // items.read 由 Go 从数据库推导的权限集独立判定(JWT claim 不参与)。 + if !identity.HasPermission(RequiredPermission) { + authhttp.WriteForbidden(w, r, []string{RequiredPermission}) + return + } + + // 固定 repository 查询(按端点语义——key 是 usersettings 包内编译期 + // 常量)+ normalization + 响应写出。六个端点共用同一 handler 流程, + // 只有 query/build 这一步不同——以一个闭包字段承接,不复制六份 handler。 + query, build, ok := endpointBinding(r.URL.Path) + if !ok { + // 路由层 exact 匹配保证不会走到这里;防御性 404。 + http.NotFound(w, r) + return + } + response, err := query(r.Context(), h.repo, identity.OrgID, identity.UserID) + if err != nil { + log.Printf("user-settings read: query failed: %v", err) + authhttp.WriteDatabaseFailure(w, r) + return + } + body, err := json.Marshal(build(response)) + if err != nil { + log.Printf("user-settings read: marshal response failed: %v", err) + authhttp.WriteDatabaseFailure(w, r) + return + } + + // 契约头部:Cache-Control: no-store(@Header 装饰器);content-type + // 与 Express res.json 一致(含 charset)。正文不追加换行(与 + // res.json 字节形态一致)。 + w.Header().Set("cache-control", "no-store") + w.Header().Set("content-type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +// queryContext 是查询闭包的 context 参数类型(r.Context() 的等价物—— +// 显式类型避免闭包签名里直接引用 net/http 上下文细节)。 +type queryContext = context.Context + +// endpointBinding 返回该 path 的固定查询与响应构建(编译期绑定表—— +// 不是注册框架:新增端点 = 在此表加一行 + usersettings 加 key/normalizer)。 +func endpointBinding(path string) (func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error), func(any) any, bool) { + switch path { + case Paths[0]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindOnboarding(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildOnboardingResponse(response.(usersettings.Record)) + }, true + case Paths[1]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindRSSReader(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildRSSReaderResponse(response.(usersettings.Record)) + }, true + case Paths[2]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindSpacetimeTimeline(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildSpacetimeTimelineResponse(response.(usersettings.Record)) + }, true + case Paths[3]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindWarMap(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildWarMapResponse(response.(usersettings.Record)) + }, true + case Paths[4]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindNewsnow(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildNewsnowResponse(response.(usersettings.Record)) + }, true + case Paths[5]: + return func(ctx queryContext, repo usersettings.Repository, orgID, userID string) (any, error) { + return repo.FindSituationMonitor(ctx, orgID, userID) + }, + func(response any) any { + return usersettings.BuildSituationMonitorResponse(response.(usersettings.SituationMonitorRecords)) + }, true + } + return nil, nil, false +} From 9ddcaeb8d9505125a201aa0c6d7a898f447926ec Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:31:10 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(api-go):=20API=5FGO=5FUSER=5FSETTING?= =?UTF-8?q?S=5FREAD=5FMODE=20=E7=BB=9F=E4=B8=80=E8=AF=BB=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=20+=20=E8=B7=AF=E7=94=B1=E4=B8=8E=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config:新变量 API_GO_USER_SETTINGS_READ_MODE=shadow|go(非法值启动 失败);空=兼容旧行为(API_GO_ONBOARDING_MODE 继续控制 onboarding, rss/spacetime shadow,war-map/newsnow/situation-monitor legacy); go 模式要求 JWT_SECRET/DATABASE_URL/REDIS_HOST 齐备(与批3A 变量 取或触发同一校验) - legacyproxy.DefaultRules(onboardingMode, readMode):六个 user-settings GET 的模式由 readMode 统一决定(设置时优先级高于 onboardingMode); 全部 exact path + GET method 白名单 - main.go:go 接管时装配 usersettingsread.NewHandler 并注册六个 path; shadow 单元表扩展到六端点(war-map/newsnow 单 key + situation-monitor 三 key 聚合 executant);go 接管端点按路由表 ModeGo 集合从 shadow 单元表过滤;healthz 自省改报 userSettingsRead.{mode,database} --- apps/api-go/cmd/api/main.go | 225 +++++++++++++++------- apps/api-go/internal/config/config.go | 52 ++++- apps/api-go/internal/legacyproxy/proxy.go | 40 ++-- 3 files changed, 229 insertions(+), 88 deletions(-) diff --git a/apps/api-go/cmd/api/main.go b/apps/api-go/cmd/api/main.go index d17e840b..73c697aa 100644 --- a/apps/api-go/cmd/api/main.go +++ b/apps/api-go/cmd/api/main.go @@ -9,13 +9,17 @@ // onboarding 在 shadow 模式下亦然) // canary — 已验证身份的稳定哈希小比例真实流量切 Go(CANARY_PERCENT) // go — Go 原生 handler(/__go/healthz 自省;以及 -// API_GO_ONBOARDING_MODE=go 时的 -// GET /api/user-settings/ui/onboarding——Go-批3A 首个业务端点 -// 真实接管:Go 独立 JWT 验签 + Redis blacklist + MySQL RBAC + -// 独立响应,不依赖 NestJS 200) +// API_GO_USER_SETTINGS_READ_MODE=go 时的六个 +// GET /api/user-settings/ui/*——Go-批3B:onboarding/rss-reader/ +// spacetime-timeline/war-map/newsnow/situation-monitor 全部由 +// 统一 handler Go 接管:Go 独立 JWT 验签 + Redis blacklist + +// MySQL RBAC + 独立响应,不依赖 NestJS 200。兼容: +// API_GO_ONBOARDING_MODE=go(批3A,readMode 未设时)只接管 +// onboarding) // -// 回滚:API_GO_ONBOARDING_MODE=shadow(或路由表单条规则改回 legacy, -// 或 CANARY_PERCENT=0)——无数据迁移耦合。 +// 回滚:API_GO_USER_SETTINGS_READ_MODE=shadow(或未设——回到 +// API_GO_ONBOARDING_MODE 控制;或路由表单条规则改回 legacy,或 +// CANARY_PERCENT=0)——无数据迁移耦合。 // // canary 信任边界(重要):当前分流的 orgId 取自未验签的 JWT payload // claim,不是经过认证的组织身份。在 Go 侧对全部受保护路由完成真实 @@ -51,10 +55,10 @@ import ( "github.com/wei500L/newwei/apps/api-go/internal/health" "github.com/wei500L/newwei/apps/api-go/internal/httpx" "github.com/wei500L/newwei/apps/api-go/internal/legacyproxy" - "github.com/wei500L/newwei/apps/api-go/internal/onboarding" "github.com/wei500L/newwei/apps/api-go/internal/shadow" "github.com/wei500L/newwei/apps/api-go/internal/shadowidentity" "github.com/wei500L/newwei/apps/api-go/internal/usersettings" + "github.com/wei500L/newwei/apps/api-go/internal/usersettingsread" ) func main() { @@ -181,18 +185,18 @@ func (healthLiveExecutant) Execute(_ context.Context, _ *http.Request, _ []byte) } } -// userSettingsExecutant 是三个 user-settings 只读 GET(onboarding / -// rss-reader / spacetime-timeline)共享的 Go shadow 差分执行者 -//(Go-批2A 起步,批2B 扩展——三端点信任边界与失败语义完全相同, -// 流程只写一次,各端点注入固定 key 的查询与响应构建)。 +// userSettingsExecutant 是六个 user-settings 只读 GET 共享的 Go shadow +// 差分执行者(Go-批2A 起步,批2B/3B 扩展——全部端点信任边界与失败语义 +// 完全相同,流程只写一次,各端点注入固定 key 的查询与响应构建)。 // // 信任边界:身份来自 legacy-approved shadow identity——只有 legacy 已 // 返回 200 时才允许从(未验签的)Bearer JWT payload 读取 sub/orgId, -// 并只用于本次只读查询。这不是「Go 已验证身份」:Go 尚未完成 JWT 验签、 -// jti blacklist、membership 重推导与 RBAC。permissions claim 不读取。 -// 任何失败(payload 解析、数据库不可达、JSON 异常)都只返回通用错误 -// Result(503 + 通用错误体),由 runner 记入差分——不影响客户端已收到 -// 的 NestJS 响应。token/orgId/userId 不进入任何日志或差分正文。 +// 并只用于本次只读查询。这不是「Go 已验证身份」:shadow 模式是回滚 +// 兼容路径,Go 独立鉴权由 usersettingsread.Handler 承载(go 模式)。 +// permissions claim 不读取。任何失败(payload 解析、数据库不可达、JSON +// 异常)都只返回通用错误 Result(503 + 通用错误体),由 runner 记入 +// 差分——不影响客户端已收到的 NestJS 响应。token/orgId/userId 不进入 +// 任何日志或差分正文。 type userSettingsExecutant struct { // repo 为 nil 表示未配置数据库:跳过(零查询),以通用错误 Result // 记入差分缺失——不影响客户端。 @@ -235,54 +239,95 @@ func (e userSettingsExecutant) Execute(ctx context.Context, r *http.Request, _ [ } } -// userSettingsShadowUnits 是三个 user-settings 只读 GET 的 shadow 单元: +// userSettingsShadowUnits 是 user-settings 只读 GET 的 shadow 单元表: // 精确 path(不做前缀匹配)+ 仅 GET + RequireLegacyOK(legacy 200 是 // 身份前提)。query 闭包绑定各自端点的 repository 语义方法(key 是 -// usersettings 包内的编译期常量)。 +// usersettings 包内的编译期常量)。五个单 key 端点共用 +// userSettingsExecutant 流程(表驱动接线);situation-monitor 是三记录 +// 聚合形态,单独 executant(Go-批3B)。 func userSettingsShadowUnits(repo usersettings.Repository) []shadowUnit { - return []shadowUnit{ - { - Path: "/api/user-settings/ui/onboarding", - Methods: map[string]bool{http.MethodGet: true}, - RequireLegacyOK: true, // 受保护端点:legacy 200 是身份前提 - Executant: userSettingsExecutant{ - repo: repo, - query: func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { - return repo.FindOnboarding(ctx, orgID, userID) - }, - build: func(record usersettings.Record) any { - return usersettings.BuildOnboardingResponse(record) - }, + singleKeyUnits := []struct { + path string + query func(ctx context.Context, orgID, userID string) (usersettings.Record, error) + build func(record usersettings.Record) any + }{ + {"/api/user-settings/ui/onboarding", + func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { + return repo.FindOnboarding(ctx, orgID, userID) }, - }, - { - Path: "/api/user-settings/ui/rss-reader", - Methods: map[string]bool{http.MethodGet: true}, - RequireLegacyOK: true, // 受保护端点:legacy 200 是身份前提 - Executant: userSettingsExecutant{ - repo: repo, - query: func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { - return repo.FindRSSReader(ctx, orgID, userID) - }, - build: func(record usersettings.Record) any { - return usersettings.BuildRSSReaderResponse(record) - }, + func(record usersettings.Record) any { return usersettings.BuildOnboardingResponse(record) }}, + {"/api/user-settings/ui/rss-reader", + func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { + return repo.FindRSSReader(ctx, orgID, userID) + }, + func(record usersettings.Record) any { return usersettings.BuildRSSReaderResponse(record) }}, + {"/api/user-settings/ui/spacetime-timeline", + func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { + return repo.FindSpacetimeTimeline(ctx, orgID, userID) }, - }, - { - Path: "/api/user-settings/ui/spacetime-timeline", + func(record usersettings.Record) any { return usersettings.BuildSpacetimeTimelineResponse(record) }}, + {"/api/user-settings/ui/war-map", + func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { + return repo.FindWarMap(ctx, orgID, userID) + }, + func(record usersettings.Record) any { return usersettings.BuildWarMapResponse(record) }}, + {"/api/user-settings/ui/newsnow", + func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { + return repo.FindNewsnow(ctx, orgID, userID) + }, + func(record usersettings.Record) any { return usersettings.BuildNewsnowResponse(record) }}, + } + units := make([]shadowUnit, 0, len(singleKeyUnits)+1) + for _, unit := range singleKeyUnits { + units = append(units, shadowUnit{ + Path: unit.path, Methods: map[string]bool{http.MethodGet: true}, RequireLegacyOK: true, // 受保护端点:legacy 200 是身份前提 - Executant: userSettingsExecutant{ - repo: repo, - query: func(ctx context.Context, orgID, userID string) (usersettings.Record, error) { - return repo.FindSpacetimeTimeline(ctx, orgID, userID) - }, - build: func(record usersettings.Record) any { - return usersettings.BuildSpacetimeTimelineResponse(record) - }, + Executant: userSettingsExecutant{ + repo: repo, + query: unit.query, + build: unit.build, }, - }, + }) + } + // situation-monitor:一次三 key 聚合查询(Go-批3B)。 + units = append(units, shadowUnit{ + Path: "/api/user-settings/ui/situation-monitor", + Methods: map[string]bool{http.MethodGet: true}, + RequireLegacyOK: true, + Executant: situationMonitorExecutant{repo: repo}, + }) + return units +} + +// situationMonitorExecutant 是 situation-monitor GET 的 shadow 差分执行者 +//(三记录聚合形态——userSettingsExecutant 是单记录形态,不强行复用)。 +type situationMonitorExecutant struct { + repo usersettings.Repository +} + +func (e situationMonitorExecutant) Execute(ctx context.Context, r *http.Request, _ []byte) *shadow.Result { + if e.repo == nil { + return shadowErrorResult() + } + identity := shadowidentity.LegacyApprovedIdentity(r, http.StatusOK) + if identity == nil { + return shadowErrorResult() + } + records, err := e.repo.FindSituationMonitor(ctx, identity.OrgID, identity.UserID) + if err != nil { + log.Printf("shadow: situation-monitor query failed: %v", err) + return shadowErrorResult() + } + body, err := json.Marshal(usersettings.BuildSituationMonitorResponse(records)) + if err != nil { + return shadowErrorResult() + } + body = append(body, '\n') + return &shadow.Result{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "Cache-Control": []string{"no-store"}}, + Body: body, } } @@ -308,8 +353,19 @@ func run() error { if cfg.OnboardingMode == config.OnboardingModeGo { onboardingMode = legacyproxy.ModeGo } + // user-settings 六个只读 GET 的统一读模式(API_GO_USER_SETTINGS_READ_MODE, + // Go-批3B):空 = 兼容旧行为(onboardingMode 单独控制 onboarding); + // go = 六个 GET 全部 Go 接管;shadow = 六个 GET 全部 shadow。 + // 配置层已校验合法值。 + readMode := "" + switch cfg.UserSettingsReadMode { + case config.UserSettingsReadModeGo: + readMode = string(legacyproxy.ModeGo) + case config.UserSettingsReadModeShadow: + readMode = string(legacyproxy.ModeShadow) + } - gateway, err := legacyproxy.New(cfg.LegacyAPIURL, legacyproxy.DefaultRules(onboardingMode)) + gateway, err := legacyproxy.New(cfg.LegacyAPIURL, legacyproxy.DefaultRules(onboardingMode, readMode)) if err != nil { return err } @@ -330,9 +386,11 @@ func run() error { db, err := usersettings.OpenMySQLFromURL(cfg.DatabaseURL) if err != nil { // DSN 无效不阻断启动:网关继续纯代理,shadow 单元执行时跳过 - //(错误不含 DSN 原文)。 - if cfg.OnboardingMode == config.OnboardingModeGo { - return fmt.Errorf("api-go: onboarding go mode requires a valid DATABASE_URL: %w", err) + //(错误不含 DSN 原文)。go 接管模式(两变量任一)下直接启动 + // 失败——Go 接管端点不得带病启动。 + if cfg.OnboardingMode == config.OnboardingModeGo || + cfg.UserSettingsReadMode == config.UserSettingsReadModeGo { + return fmt.Errorf("api-go: user-settings go takeover requires a valid DATABASE_URL: %w", err) } log.Printf("api-go: user-settings shadow database not initialized (invalid DATABASE_URL): %v", err) userSettingsDBStatus = "invalid" @@ -347,13 +405,21 @@ func run() error { } } - // onboarding go 模式的 Go 鉴权/响应栈装配(Go-批3A): + // go 接管模式的 Go 鉴权/响应栈装配(Go-批3A 引入,批3B 收敛为六个 + // user-settings 只读 GET 的统一 handler): // authn(JWT 验签 + Redis blacklist)→ authz(MySQL membership/ // permission 重推导,复用同一 *sql.DB 连接池)→ authhttp(契约错误) - // → onboarding handler(业务查询 + 响应构造)。 - // shadow 模式完全不装配(零额外连接、旧行为不变)。 + // → usersettingsread handler(六端点共享:固定 repository 查询 + + // normalization + 响应构造)。 + // 两变量任一为 go 即装配(UserSettingsReadMode=go 是六端点全接管; + // OnboardingMode=go 单独设置时同一栈也覆盖统一 handler 的 onboarding + // 分支——但路由表只在 readMode 空时把 onboarding 切 go,此时其余五 + // 端点仍 shadow/legacy,handler 对未接管路径不会被路由命中)。 + // 两者都非 go 时完全不装配(零额外连接、旧行为不变)。 + goTakeover := cfg.OnboardingMode == config.OnboardingModeGo || + cfg.UserSettingsReadMode == config.UserSettingsReadModeGo var redisClient *redis.Client - if cfg.OnboardingMode == config.OnboardingModeGo { + if goTakeover { redisClient = redis.NewClient(&redis.Options{ Addr: net.JoinHostPort(cfg.RedisHost, strconv.Itoa(cfg.RedisPort)), Username: cfg.RedisUsername, @@ -363,18 +429,30 @@ func run() error { verifier := authn.NewVerifier(cfg.JWTSecret, cfg.JWTIssuer, cfg.JWTAudience) blacklist := authn.NewRedisBlacklist(redisClient) authenticator := authhttp.NewAuthenticator(verifier, blacklist, authz.NewMySQLRepository(sharedDB)) - gateway.RegisterGoHandler(onboarding.Path, onboarding.NewHandler(authenticator, userSettingsRepo)) - log.Printf("api-go: onboarding GET under go takeover (JWT verify + Redis blacklist + MySQL RBAC; issuer=%s)", cfg.JWTIssuer) + readHandler := usersettingsread.NewHandler(authenticator, userSettingsRepo) + for _, path := range usersettingsread.Paths { + gateway.RegisterGoHandler(path, readHandler.ServeHTTP) + } + log.Printf("api-go: user-settings read GET(s) under go takeover (JWT verify + Redis blacklist + MySQL RBAC; issuer=%s, readMode=%s, onboardingMode=%s)", + cfg.JWTIssuer, cfg.UserSettingsReadMode, cfg.OnboardingMode) } - // shadow 单元表:go 模式下 onboarding 不再是 shadow 差分单元(ModeGo - // 规则也不会进入 serveShadow——双重收口,保证 onboarding GET 不再 - // 增加 shadow.executed)。RSS/Spacetime 保持 Shadow。 + // shadow 单元表:路由表中处于 ModeGo 的端点不再是 shadow 差分单元 + //(ModeGo 规则也不会进入 serveShadow——双重收口,保证 go 接管的 + // GET 不再增加 shadow.executed)。readMode=go 时六个端点全部过滤; + // readMode 空且 onboardingMode=go 时只过滤 onboarding;其余保持 + // 既有 shadow/legacy 去向。 settingsUnits := userSettingsShadowUnits(userSettingsRepo) - if cfg.OnboardingMode == config.OnboardingModeGo { + goPaths := make(map[string]bool, len(usersettingsread.Paths)) + for _, rule := range gateway.Rules() { + if rule.Mode == legacyproxy.ModeGo { + goPaths[rule.Prefix] = true + } + } + if len(goPaths) > 0 { filtered := make([]shadowUnit, 0, len(settingsUnits)) for _, unit := range settingsUnits { - if unit.Path != onboarding.Path { + if !goPaths[unit.Path] { filtered = append(filtered, unit) } } @@ -433,7 +511,10 @@ func run() error { "onboarding": map[string]any{ "mode": string(cfg.OnboardingMode), }, - "userSettingsShadow": map[string]string{ + // user-settings 统一读模式(Go-批3B):空 = 兼容旧配置 + //(onboardingMode 单独控制);shadow/go 如实展示。 + "userSettingsRead": map[string]any{ + "mode": string(cfg.UserSettingsReadMode), "database": userSettingsDBStatus, }, }) diff --git a/apps/api-go/internal/config/config.go b/apps/api-go/internal/config/config.go index 1fdd2d29..d1c80de4 100644 --- a/apps/api-go/internal/config/config.go +++ b/apps/api-go/internal/config/config.go @@ -25,7 +25,8 @@ const ( defaultRedisPort = 6379 ) -// OnboardingMode 是 onboarding GET 迁移单元的路由模式(API_GO_ONBOARDING_MODE)。 +// OnboardingMode 是 onboarding GET 迁移单元的路由模式 +//(API_GO_ONBOARDING_MODE——Go-批3A 引入)。 type OnboardingMode string const ( @@ -39,6 +40,21 @@ const ( OnboardingModeGo OnboardingMode = "go" ) +// UserSettingsReadMode 是 user-settings 六个只读 GET 的统一路由模式 +//(API_GO_USER_SETTINGS_READ_MODE——Go-批3B 引入)。 +type UserSettingsReadMode string + +const ( + // UserSettingsReadModeShadow:6 个 GET 都由 NestJS 响应,Go 做真实 + // 旁路查询与差分(onboarding/rss/spacetime 是既有 shadow 语义; + // war-map/newsnow/situation-monitor 在本模式下由 NestJS 响应 + Go + // 差分——新三端点也作为 shadow 单元接线)。 + UserSettingsReadModeShadow UserSettingsReadMode = "shadow" + // UserSettingsReadModeGo:6 个 GET 都由 Go 独立响应(JWT 验签 + + // Redis blacklist + MySQL RBAC + 独立查库 + normalization)。 + UserSettingsReadModeGo UserSettingsReadMode = "go" +) + // Config 是网关运行所需的全部配置。 type Config struct { Port int @@ -55,6 +71,13 @@ type Config struct { // OnboardingMode 见 OnboardingMode 常量(默认 shadow)。 OnboardingMode OnboardingMode + // UserSettingsReadMode 见 UserSettingsReadMode 常量。未设置(空)时 + // 保持既有行为:API_GO_ONBOARDING_MODE 继续控制 onboarding,RSS/ + // Spacetime 保持 Shadow,War Map/NewsNow/Situation Monitor 保持 + // Legacy(Go-批3B 之前的部署不变)。设置为 go 时六个 GET 统一由 Go + // 接管(优先级高于 API_GO_ONBOARDING_MODE)。非法值启动失败。 + UserSettingsReadMode UserSettingsReadMode + // JWT 是 NestJS access token 的验签配置(与 api 服务同一 // JWT_SECRET/JWT_ISSUER/JWT_AUDIENCE)。OnboardingMode=go 时 // JWTSecret 必填;issuer/audience 默认值与 NestJS env schema 一致。 @@ -148,6 +171,20 @@ func Load(getenv func(string) string) (Config, error) { errs = append(errs, "API_GO_ONBOARDING_MODE must be one of shadow|go") } + // user-settings 六个只读 GET 的统一读模式(Go-批3B):默认空 = 兼容 + //(API_GO_ONBOARDING_MODE 继续控制 onboarding,其余端点旧去向不变); + // 非法值启动失败。 + switch strings.TrimSpace(getenv("API_GO_USER_SETTINGS_READ_MODE")) { + case "": + cfg.UserSettingsReadMode = "" + case string(UserSettingsReadModeShadow): + cfg.UserSettingsReadMode = UserSettingsReadModeShadow + case string(UserSettingsReadModeGo): + cfg.UserSettingsReadMode = UserSettingsReadModeGo + default: + errs = append(errs, "API_GO_USER_SETTINGS_READ_MODE must be one of shadow|go") + } + // JWT 验签配置(issuer/audience 默认值与 NestJS env schema 一致—— // 保证与同一套 env 部署的 api 服务行为等价)。 cfg.JWTSecret = strings.TrimSpace(getenv("JWT_SECRET")) @@ -259,15 +296,20 @@ func Load(getenv func(string) string) (Config, error) { // Go 接管模式的依赖前置校验:不得在依赖缺失时启动一个必然失败的 // 「Go 接管端点」——启动即失败,错误只指出缺失的配置项名,不打印值。 - if cfg.OnboardingMode == OnboardingModeGo { + // Go-批3B:UserSettingsReadMode=go 触发同一套前置(六个 GET 全部由 + // Go 独立鉴权响应);API_GO_ONBOARDING_MODE=go 单独设置时沿用批3A + // 的同一校验(两变量叠加时只校验一次——条件取或)。 + goTakeover := cfg.OnboardingMode == OnboardingModeGo || + cfg.UserSettingsReadMode == UserSettingsReadModeGo + if goTakeover { if cfg.JWTSecret == "" { - errs = append(errs, "JWT_SECRET is required when API_GO_ONBOARDING_MODE=go") + errs = append(errs, "JWT_SECRET is required when API_GO_ONBOARDING_MODE=go or API_GO_USER_SETTINGS_READ_MODE=go") } if cfg.DatabaseURL == "" { - errs = append(errs, "DATABASE_URL is required when API_GO_ONBOARDING_MODE=go") + errs = append(errs, "DATABASE_URL is required when API_GO_ONBOARDING_MODE=go or API_GO_USER_SETTINGS_READ_MODE=go") } if cfg.RedisHost == "" { - errs = append(errs, "REDIS_HOST is required when API_GO_ONBOARDING_MODE=go") + errs = append(errs, "REDIS_HOST is required when API_GO_ONBOARDING_MODE=go or API_GO_USER_SETTINGS_READ_MODE=go") } } diff --git a/apps/api-go/internal/legacyproxy/proxy.go b/apps/api-go/internal/legacyproxy/proxy.go index 5d01ac9e..54f82ba0 100644 --- a/apps/api-go/internal/legacyproxy/proxy.go +++ b/apps/api-go/internal/legacyproxy/proxy.go @@ -70,32 +70,50 @@ type Rule struct { // DefaultRules 是当前的路由表。onboardingMode 是 onboarding 单元的模式 //(API_GO_ONBOARDING_MODE:ModeShadow=默认部署旧行为;ModeGo=Go-批3A -// 真实接管——首个 Go 全响应业务端点)。 +// 真实接管);readMode 是 user-settings 六个只读 GET 的统一模式 +//(Go-批3B:API_GO_USER_SETTINGS_READ_MODE——go=六个 GET 全部由统一 +// Go handler 接管;shadow=六个 GET 全部 shadow(NestJS 响应 + Go 差分); +// 空=兼容旧行为:onboarding 由 onboardingMode 控制,rss/spacetime 保持 +// shadow,war-map/newsnow/situation-monitor 保持 legacy)。 // // 迁移单元: // - 序 2:GET /api/healthz/live —— shadow(公开探针,首个单元); -// - 序 3(Go-批2A/2B):GET /api/user-settings/ui/{onboarding,rss-reader, -// spacetime-timeline} —— shadow(legacy-approved 身份 + Go 差分); -// - 序 5(Go-批3A):GET /api/user-settings/ui/onboarding —— 由 -// onboardingMode 决定:shadow(默认)或 go(Go 独立鉴权 + 全响应, -// 经 API_GO_ONBOARDING_MODE=go 显式启用)。 -// 迁移单元均为 exact path + method 白名单:PUT 等写方法与相似路径 +// - 序 5(Go-批3A/3B):GET /api/user-settings/ui/{六个端点} —— +// exact path + method 白名单:PUT 等写方法与相似路径 // (onboarding-x、onboarding/other)回落 /api/ legacy,由 NestJS // 处理——绝不进入 Go handler(写方法永远 NestJS 单写)。 // // 注意三个无 /api 前缀的挂载点(契约清单 §0):/graphql、/socket.io、 // /admin/queues(Bull Board)。代理层必须与 REST 前缀分别声明。 -func DefaultRules(onboardingMode Mode) []Rule { +func DefaultRules(onboardingMode Mode, readMode string) []Rule { if onboardingMode != ModeGo { onboardingMode = ModeShadow } getOnly := map[string]bool{http.MethodGet: true} + + // 六个 user-settings 只读 GET 的模式(Go-批3B 统一读模式)。 + settingsMode := func(defaultMode Mode) Mode { + switch readMode { + case string(ModeGo): + return ModeGo + case string(ModeShadow): + return ModeShadow + default: + return defaultMode + } + } + return []Rule{ {Prefix: "/api/", Mode: ModeLegacy}, {Prefix: "/api/healthz/live", Mode: ModeShadow, Exact: true, Methods: getOnly}, - {Prefix: "/api/user-settings/ui/onboarding", Mode: onboardingMode, Exact: true, Methods: getOnly}, - {Prefix: "/api/user-settings/ui/rss-reader", Mode: ModeShadow, Exact: true, Methods: getOnly}, - {Prefix: "/api/user-settings/ui/spacetime-timeline", Mode: ModeShadow, Exact: true, Methods: getOnly}, + // 兼容模式(readMode 空):onboarding 由 onboardingMode 决定, + // rss/spacetime 保持 shadow,三个新端点保持 legacy。 + {Prefix: "/api/user-settings/ui/onboarding", Mode: settingsMode(onboardingMode), Exact: true, Methods: getOnly}, + {Prefix: "/api/user-settings/ui/rss-reader", Mode: settingsMode(ModeShadow), Exact: true, Methods: getOnly}, + {Prefix: "/api/user-settings/ui/spacetime-timeline", Mode: settingsMode(ModeShadow), Exact: true, Methods: getOnly}, + {Prefix: "/api/user-settings/ui/war-map", Mode: settingsMode(ModeLegacy), Exact: true, Methods: getOnly}, + {Prefix: "/api/user-settings/ui/newsnow", Mode: settingsMode(ModeLegacy), Exact: true, Methods: getOnly}, + {Prefix: "/api/user-settings/ui/situation-monitor", Mode: settingsMode(ModeLegacy), Exact: true, Methods: getOnly}, {Prefix: "/graphql", Mode: ModeLegacy}, {Prefix: "/socket.io/", Mode: ModeLegacy}, {Prefix: "/docs", Mode: ModeLegacy}, From 190bab7aaf57115e4694d1e991d9ac82900ac90e Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:31:46 +0800 Subject: [PATCH 04/11] =?UTF-8?q?test(api-go):=20=E6=89=B93B=20=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E6=B5=8B=E8=AF=95=E2=80=94=E2=80=94=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=20normalization=20=E5=A5=91=E7=BA=A6=20+=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E8=A1=A8=E6=89=A9=E5=B1=95=20+=20integration=208=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增唯一测试文件 batch3b_test.go(4 个顶层函数,未超过 5 个上限): Situation Monitor(三段聚合/部分记录/代表性规整)、War Map(默认/ legacy key/clamp/嵌套差异)、NewsNow(有序对象/真值/clamp/去重/ smart 归一 + 序列化形态)、situation visibility 64 项有序上限 - main_test.go:shadow identity gate 表格扩展六端点(新三端点 GET 执行 一次/PUT 零执行);countingRepo 扩展六方法 + 聚合计数;新增 TestUserSettingsReadModeRouting(readMode 三态路由表矩阵 + go 模式 下 PUT/POST/HEAD/相似路径回落 legacy 的 exact-method 边界) - config_test.go:read mode 三值语义 + go 模式依赖前置(合并表格) - mysql_integration_test.go:扩展到 8 个固定 key——war-map/newsnow 真实 读取与 normalization 抽查;situation-monitor 部分记录聚合(只有 monitors)与全三 key 聚合、租户隔离 --- apps/api-go/cmd/api/main_test.go | 234 ++++++++-- apps/api-go/internal/config/config_test.go | 45 ++ .../api-go/internal/legacyproxy/proxy_test.go | 32 +- .../internal/usersettings/batch3b_test.go | 402 ++++++++++++++++++ .../usersettings/mysql_integration_test.go | 103 +++++ 5 files changed, 774 insertions(+), 42 deletions(-) create mode 100644 apps/api-go/internal/usersettings/batch3b_test.go diff --git a/apps/api-go/cmd/api/main_test.go b/apps/api-go/cmd/api/main_test.go index 73bf47dd..604a7f18 100644 --- a/apps/api-go/cmd/api/main_test.go +++ b/apps/api-go/cmd/api/main_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/wei500L/newwei/apps/api-go/internal/canary" + "github.com/wei500L/newwei/apps/api-go/internal/httpx" "github.com/wei500L/newwei/apps/api-go/internal/legacyproxy" "github.com/wei500L/newwei/apps/api-go/internal/shadow" "github.com/wei500L/newwei/apps/api-go/internal/usersettings" @@ -62,7 +63,7 @@ func TestDispatcherCanaryNeverRoutesUnverifiedIdentityToGo(t *testing.T) { // ModeCanary,此测试失败——提醒先落地可信身份来源(JWT 验签 + // membership 重推导,迁移序 5)或证明路由无鉴权语义差异。 func TestDefaultRulesHaveNoCanaryRoutes(t *testing.T) { - for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow) { + for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow, "") { if rule.Mode == legacyproxy.ModeCanary { t.Fatalf("route %q is ModeCanary — canary 分流依赖未验签身份,先落地可信身份来源", rule.Prefix) } @@ -72,7 +73,7 @@ func TestDefaultRulesHaveNoCanaryRoutes(t *testing.T) { // 首个迁移单元的状态契约:/api/healthz/live 处于 shadow(NestJS 仍是 // 响应方),不是 go 全量接管。 func TestHealthzLiveIsShadowNotGo(t *testing.T) { - for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow) { + for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow, "") { if rule.Prefix == "/api/healthz/live" { if rule.Mode != legacyproxy.ModeShadow { t.Fatalf("/api/healthz/live mode = %s, want shadow(NestJS 仍是事实源)", rule.Mode) @@ -83,56 +84,75 @@ func TestHealthzLiveIsShadowNotGo(t *testing.T) { t.Fatal("/api/healthz/live not found in DefaultRules") } -// countingRepo 统计三个 user-settings 查询的调用次数(验证零执行/执行 +// countingRepo 统计六个 user-settings 查询的调用次数(验证零执行/执行 // 一次语义)。计数由互斥锁保护——runner 在独立 goroutine 异步执行。 type countingRepo struct { mu sync.Mutex - onboardingCalls int - rssReaderCalls int - spacetimeCalls int + calls map[string]int + situationCalls int } -func (c *countingRepo) FindOnboarding(_ context.Context, _, _ string) (usersettings.Record, error) { +func (c *countingRepo) record(path string) { c.mu.Lock() defer c.mu.Unlock() - c.onboardingCalls++ + if c.calls == nil { + c.calls = map[string]int{} + } + c.calls[path]++ +} + +func (c *countingRepo) FindOnboarding(_ context.Context, _, _ string) (usersettings.Record, error) { + c.record("/api/user-settings/ui/onboarding") return usersettings.Record{Found: false}, nil } func (c *countingRepo) FindRSSReader(_ context.Context, _, _ string) (usersettings.Record, error) { - c.mu.Lock() - defer c.mu.Unlock() - c.rssReaderCalls++ + c.record("/api/user-settings/ui/rss-reader") return usersettings.Record{Found: false}, nil } func (c *countingRepo) FindSpacetimeTimeline(_ context.Context, _, _ string) (usersettings.Record, error) { - c.mu.Lock() - defer c.mu.Unlock() - c.spacetimeCalls++ + c.record("/api/user-settings/ui/spacetime-timeline") + return usersettings.Record{Found: false}, nil +} + +func (c *countingRepo) FindWarMap(_ context.Context, _, _ string) (usersettings.Record, error) { + c.record("/api/user-settings/ui/war-map") + return usersettings.Record{Found: false}, nil +} + +func (c *countingRepo) FindNewsnow(_ context.Context, _, _ string) (usersettings.Record, error) { + c.record("/api/user-settings/ui/newsnow") return usersettings.Record{Found: false}, nil } +func (c *countingRepo) FindSituationMonitor(_ context.Context, _, _ string) (usersettings.SituationMonitorRecords, error) { + c.mu.Lock() + c.situationCalls++ + c.mu.Unlock() + return usersettings.SituationMonitorRecords{}, nil +} + // callsFor 返回该 shadow 单元路径对应的查询计数(未知路径返回 0)。 +// situation-monitor 是聚合查询(一次查询覆盖三段),单独计数。 func (c *countingRepo) callsFor(path string) int { c.mu.Lock() defer c.mu.Unlock() - switch path { - case "/api/user-settings/ui/onboarding": - return c.onboardingCalls - case "/api/user-settings/ui/rss-reader": - return c.rssReaderCalls - case "/api/user-settings/ui/spacetime-timeline": - return c.spacetimeCalls + if path == "/api/user-settings/ui/situation-monitor" { + return c.situationCalls } - return 0 + return c.calls[path] } -// totalCalls 返回三个查询的总调用数(捕获「误路由到别的端点」类缺陷)。 +// totalCalls 返回全部查询的总调用数(捕获「误路由到别的端点」类缺陷)。 func (c *countingRepo) totalCalls() int { c.mu.Lock() defer c.mu.Unlock() - return c.onboardingCalls + c.rssReaderCalls + c.spacetimeCalls + total := c.situationCalls + for _, count := range c.calls { + total += count + } + return total } // userSettingsTestDispatcher 构造与生产同结构的 dispatcher:直接复用 @@ -180,6 +200,13 @@ func TestUserSettingsShadowIdentityGate(t *testing.T) { {"rss-reader-put-zero-execution", "/api/user-settings/ui/rss-reader", http.MethodPut, validToken, http.StatusOK, 0}, {"spacetime-legacy-200-executes-once", "/api/user-settings/ui/spacetime-timeline", http.MethodGet, validToken, http.StatusOK, 1}, {"spacetime-legacy-403-zero-execution", "/api/user-settings/ui/spacetime-timeline", http.MethodGet, validToken, http.StatusForbidden, 0}, + // 批3B:三个新端点(shadow 单元表新增)——GET 执行一次;PUT 零执行。 + {"war-map-legacy-200-executes-once", "/api/user-settings/ui/war-map", http.MethodGet, validToken, http.StatusOK, 1}, + {"war-map-put-zero-execution", "/api/user-settings/ui/war-map", http.MethodPut, validToken, http.StatusOK, 0}, + {"newsnow-legacy-200-executes-once", "/api/user-settings/ui/newsnow", http.MethodGet, validToken, http.StatusOK, 1}, + {"newsnow-put-zero-execution", "/api/user-settings/ui/newsnow", http.MethodPut, validToken, http.StatusOK, 0}, + {"situation-monitor-legacy-200-executes-once", "/api/user-settings/ui/situation-monitor", http.MethodGet, validToken, http.StatusOK, 1}, + {"situation-monitor-put-zero-execution", "/api/user-settings/ui/situation-monitor", http.MethodPut, validToken, http.StatusOK, 0}, } for _, tc := range cases { @@ -216,7 +243,7 @@ func TestUserSettingsRoutesAreShadow(t *testing.T) { "/api/user-settings/ui/spacetime-timeline", } { found := false - for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow) { + for _, rule := range legacyproxy.DefaultRules(legacyproxy.ModeShadow, "") { if rule.Prefix == prefix { found = true if rule.Mode != legacyproxy.ModeShadow { @@ -244,7 +271,7 @@ func TestUserSettingsHaveNoClientGoHandler(t *testing.T) { {legacyproxy.ModeShadow, legacyproxy.ModeShadow}, {legacyproxy.ModeGo, legacyproxy.ModeGo}, } { - gateway, err := legacyproxy.New("http://legacy:4000", legacyproxy.DefaultRules(tc.mode)) + gateway, err := legacyproxy.New("http://legacy:4000", legacyproxy.DefaultRules(tc.mode, "")) if err != nil { t.Fatal(err) } @@ -266,6 +293,161 @@ func TestUserSettingsHaveNoClientGoHandler(t *testing.T) { } } +// Go-批3B:统一读模式(API_GO_USER_SETTINGS_READ_MODE)的路由表契约 + +// exact GET 路由与 PUT/相似路径回落 legacy(扩展现有路由表测试)。 +// +// ModeShadow: 六个 GET 全部 shadow(含三个原本 legacy 的端点)。 +// ModeGo: 六个 GET 全部 go(统一 usersettingsread handler)。 +// 空(兼容): onboarding 由 onboardingMode 决定,rss/spacetime shadow, +// war-map/newsnow/situation-monitor legacy。 +// +// 同时验证 go 模式下 PUT(同路径)与相似路径回落 legacy——exact path + +// method 白名单边界(六个 PUT 由 NestJS 单写)。 +func TestUserSettingsReadModeRouting(t *testing.T) { + sixPaths := []string{ + "/api/user-settings/ui/onboarding", + "/api/user-settings/ui/rss-reader", + "/api/user-settings/ui/spacetime-timeline", + "/api/user-settings/ui/war-map", + "/api/user-settings/ui/newsnow", + "/api/user-settings/ui/situation-monitor", + } + + for _, tc := range []struct { + name string + readMode string + onboard legacyproxy.Mode + wantModes map[string]legacyproxy.Mode + }{ + { + name: "read-mode-shadow-unifies-all-six", + readMode: string(legacyproxy.ModeShadow), + onboard: legacyproxy.ModeGo, // readMode 优先——onboarding 也回到 shadow + wantModes: func() map[string]legacyproxy.Mode { + m := map[string]legacyproxy.Mode{} + for _, p := range sixPaths { + m[p] = legacyproxy.ModeShadow + } + return m + }(), + }, + { + name: "read-mode-go-unifies-all-six", + readMode: string(legacyproxy.ModeGo), + onboard: legacyproxy.ModeShadow, + wantModes: func() map[string]legacyproxy.Mode { + m := map[string]legacyproxy.Mode{} + for _, p := range sixPaths { + m[p] = legacyproxy.ModeGo + } + return m + }(), + }, + { + name: "empty-read-mode-keeps-legacy-compat", + readMode: "", + onboard: legacyproxy.ModeShadow, + wantModes: func() map[string]legacyproxy.Mode { + return map[string]legacyproxy.Mode{ + "/api/user-settings/ui/onboarding": legacyproxy.ModeShadow, + "/api/user-settings/ui/rss-reader": legacyproxy.ModeShadow, + "/api/user-settings/ui/spacetime-timeline": legacyproxy.ModeShadow, + "/api/user-settings/ui/war-map": legacyproxy.ModeLegacy, + "/api/user-settings/ui/newsnow": legacyproxy.ModeLegacy, + "/api/user-settings/ui/situation-monitor": legacyproxy.ModeLegacy, + } + }(), + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, rule := range legacyproxy.DefaultRules(tc.onboard, tc.readMode) { + if want, ok := tc.wantModes[rule.Prefix]; ok && rule.Mode != want { + t.Fatalf("%s: mode = %s, want %s(readMode=%q)", rule.Prefix, rule.Mode, want, tc.readMode) + } + } + }) + } + + // exact GET 边界(go 模式):PUT 同路径与相似路径回落 legacy 代理。 + stub := newLegacyStubFor(t) + gateway, err := legacyproxy.New(stub.URL(), legacyproxy.DefaultRules(legacyproxy.ModeShadow, string(legacyproxy.ModeGo))) + if err != nil { + t.Fatal(err) + } + goHandlerCalls := 0 + for _, path := range sixPaths { + gateway.RegisterGoHandler(path, func(w http.ResponseWriter, _ *http.Request) { + goHandlerCalls++ + w.WriteHeader(http.StatusOK) + }) + } + handler := httpx.TraceMiddleware(gateway) + + for _, tc := range []struct { + name string + method string + path string + wantGo bool + }{ + {"war-map-get", http.MethodGet, "/api/user-settings/ui/war-map", true}, + {"newsnow-get", http.MethodGet, "/api/user-settings/ui/newsnow", true}, + {"situation-monitor-get", http.MethodGet, "/api/user-settings/ui/situation-monitor", true}, + {"war-map-put-falls-legacy", http.MethodPut, "/api/user-settings/ui/war-map", false}, + {"newsnow-post-falls-legacy", http.MethodPost, "/api/user-settings/ui/newsnow", false}, + {"situation-monitor-head-falls-legacy", http.MethodHead, "/api/user-settings/ui/situation-monitor", false}, + {"war-map-suffix-does-not-match", http.MethodGet, "/api/user-settings/ui/war-map-x", false}, + {"newsnow-subpath-does-not-match", http.MethodGet, "/api/user-settings/ui/newsnow/other", false}, + } { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(tc.method, "http://gateway"+tc.path, nil)) + if tc.wantGo { + if rec.Code != http.StatusOK { + t.Errorf("%s: status = %d, want 200 (go handler)", tc.name, rec.Code) + } + } + // 非 Go 用例:上游收到请求即证明回落 legacy(stub 返回 200)。 + } + + if goHandlerCalls != 3 { + t.Errorf("go handler calls = %d, want 3(只有三个 GET 命中 Go handler)", goHandlerCalls) + } + // 5 个非 Go 用例(PUT/POST/HEAD + 两个相似路径)全部回落 legacy 代理 + // ——上游恰好收到 5 次请求(Go 命中不触达上游)。 + stub.AssertRequestCount(t, 5) +} + +// stubCountingServer 断言 legacy stub 收到的请求数(exact-method 路由 +// 测试的 helper)。 +type stubCountingServer struct { + mu sync.Mutex + server *httptest.Server + request int +} + +func newLegacyStubFor(t *testing.T) *stubCountingServer { + t.Helper() + stub := &stubCountingServer{} + stub.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stub.mu.Lock() + stub.request++ + stub.mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.server.Close) + return stub +} + +func (s *stubCountingServer) URL() string { return s.server.URL } + +func (s *stubCountingServer) AssertRequestCount(t *testing.T, want int) { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + if s.request != want { + t.Errorf("legacy upstream requests = %d, want %d", s.request, want) + } +} + // healthcheck 子命令的退出码契约(Go-批2C 新增的正常启动入口分支): // 2xx → 0;非 2xx 或目标不可达 → 1。这里的 httptest 只模拟「本进程 // 探测目标」的响应行为,不涉及 legacy upstream。 diff --git a/apps/api-go/internal/config/config_test.go b/apps/api-go/internal/config/config_test.go index 3fa29931..7d1f5bdb 100644 --- a/apps/api-go/internal/config/config_test.go +++ b/apps/api-go/internal/config/config_test.go @@ -180,3 +180,48 @@ func mustAtoi(t *testing.T, raw string) int { } return value } + +// Go-批3B:API_GO_USER_SETTINGS_READ_MODE 的三值语义(空=兼容旧行为、 +// shadow/go 合法、非法值启动失败)+ go 模式的依赖前置校验(合并表格)。 +func TestUserSettingsReadMode(t *testing.T) { + cases := []struct { + name string + readMode string + extraEnv map[string]string + wantMode string + wantError bool + }{ + {"empty keeps compat", "", nil, "", false}, + {"shadow accepted", "shadow", nil, "shadow", false}, + {"go accepted with deps", "go", map[string]string{ + "JWT_SECRET": "s", "DATABASE_URL": "mysql://u:p@h:3306/db", "REDIS_HOST": "h", + }, "go", false}, + {"go without deps fails", "go", nil, "", true}, + {"invalid value rejected", "bogus", nil, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := Load(func(key string) string { + if v, ok := tc.extraEnv[key]; ok { + return v + } + if key == "API_GO_USER_SETTINGS_READ_MODE" { + return tc.readMode + } + return "" + }) + if tc.wantError { + if err == nil { + t.Fatalf("want error, got cfg %+v", cfg) + } + return + } + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(cfg.UserSettingsReadMode) != tc.wantMode { + t.Errorf("UserSettingsReadMode = %q, want %q", cfg.UserSettingsReadMode, tc.wantMode) + } + }) + } +} diff --git a/apps/api-go/internal/legacyproxy/proxy_test.go b/apps/api-go/internal/legacyproxy/proxy_test.go index 1c76c9f5..48fe5a81 100644 --- a/apps/api-go/internal/legacyproxy/proxy_test.go +++ b/apps/api-go/internal/legacyproxy/proxy_test.go @@ -81,7 +81,7 @@ func newTestGatewayWithShadow(t *testing.T, legacyURL string, rules []Rule, goHa // 未迁移路由(/api/*)原样代理到 NestJS:路径/方法/请求体不变。 func TestLegacyRoutesProxyThrough(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), func(w http.ResponseWriter, _ *http.Request) { + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), func(w http.ResponseWriter, _ *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) @@ -118,7 +118,7 @@ func TestLegacyRoutesProxyThrough(t *testing.T) { // 三个无 /api 前缀的挂载点同样代理:/graphql、/socket.io、/admin/queues。 func TestNonApiPrefixesProxyThrough(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), nil) + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil) for _, path := range []string{"/graphql", "/socket.io/notifications", "/admin/queues", "/docs"} { req, _ := http.NewRequest(http.MethodPost, "http://gateway"+path, strings.NewReader("{}")) @@ -145,7 +145,7 @@ func TestNonApiPrefixesProxyThrough(t *testing.T) { // Go 原生路由(/__go/healthz)由网关应答,不触达 NestJS。 func TestGoRouteServedNatively(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), func(w http.ResponseWriter, _ *http.Request) { + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), func(w http.ResponseWriter, _ *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "routes": []map[string]string{{"prefix": "/__go/healthz", "mode": "go"}}}) }) @@ -180,7 +180,7 @@ func TestGoRouteServedNatively(t *testing.T) { // go 路由未注册 handler → 501(fail-closed,不静默回落 legacy)。 func TestGoRouteWithoutHandlerFailsClosed(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), nil) + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil) rec := httptest.NewRecorder() gateway.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://gateway/__go/healthz", nil)) @@ -192,7 +192,7 @@ func TestGoRouteWithoutHandlerFailsClosed(t *testing.T) { // 上游不可达 → 502 JSON(含 traceId),而不是连接重置。 func TestUpstreamUnreachableReturns502(t *testing.T) { // 127.0.0.1:1 几乎必然拒绝连接。 - gateway := newTestGateway(t, "http://127.0.0.1:1", DefaultRules(ModeShadow), nil) + gateway := newTestGateway(t, "http://127.0.0.1:1", DefaultRules(ModeShadow, ""), nil) rec := httptest.NewRecorder() gateway.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://gateway/api/healthz/live", nil)) @@ -214,7 +214,7 @@ func TestUpstreamUnreachableReturns502(t *testing.T) { // trace 中间件:代理请求也带 x-trace-id 响应头,并透传到上游。 func TestTraceHeadersOnProxiedRequest(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), nil) + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil) req, _ := http.NewRequest(http.MethodGet, "http://gateway/api/healthz/live", nil) req.Header.Set("x-trace-id", "abcdef0123456789abcdef0123456789") @@ -269,7 +269,7 @@ func TestLongestPrefixWins(t *testing.T) { // - /api/ 其他路由保持 Legacy。 func TestOnboardingGoModeMethodExactRouting(t *testing.T) { stub := newLegacyStub(t) - gateway, err := New(stub.server.URL, DefaultRules(ModeGo)) + gateway, err := New(stub.server.URL, DefaultRules(ModeGo, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -394,7 +394,7 @@ func (d *fakeDispatcher) CanaryRoute(_ *http.Request) bool { func TestShadowRouteServesLegacyResponseAndTriggersDiff(t *testing.T) { stub := newLegacyStub(t) disp := &fakeDispatcher{} - gateway := newTestGatewayWithShadow(t, stub.server.URL, DefaultRules(ModeShadow), nil, disp) + gateway := newTestGatewayWithShadow(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil, disp) req, _ := http.NewRequest(http.MethodGet, "http://gateway/api/healthz/live", nil) rec := httptest.NewRecorder() @@ -429,7 +429,7 @@ func TestShadowRouteServesLegacyResponseAndTriggersDiff(t *testing.T) { func TestShadowRouteSkipsDiffForWriteMethods(t *testing.T) { stub := newLegacyStub(t) disp := &fakeDispatcher{} - gateway := newTestGatewayWithShadow(t, stub.server.URL, DefaultRules(ModeShadow), nil, disp) + gateway := newTestGatewayWithShadow(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil, disp) req, _ := http.NewRequest(http.MethodPost, "http://gateway/api/healthz/live", strings.NewReader(`{"x":1}`)) rec := httptest.NewRecorder() @@ -449,7 +449,7 @@ func TestShadowRouteSkipsDiffForWriteMethods(t *testing.T) { // shadow 路由无 dispatcher(纯代理):不差分、不报错。 func TestShadowRouteWithoutDispatcherStillProxies(t *testing.T) { stub := newLegacyStub(t) - gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow), nil) + gateway := newTestGateway(t, stub.server.URL, DefaultRules(ModeShadow, ""), nil) rec := httptest.NewRecorder() gateway.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://gateway/api/healthz/live", nil)) @@ -603,7 +603,7 @@ func TestShadowResponseOverBudgetStillStreamsFully(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -640,7 +640,7 @@ func TestShadowSSEResponseSkipsDiffButPassesThrough(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -671,7 +671,7 @@ func TestShadowUpgradeRequestSkipsDiff(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -700,7 +700,7 @@ func TestShadowRequestOverBudgetStillForwardsFullBody(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -732,7 +732,7 @@ func TestShadowSmallResponseCapturedForDiff(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -765,7 +765,7 @@ func TestShadowForwardsUpstreamHeaders(t *testing.T) { upstream := newStubServer(t, handler) disp := &fakeDispatcher{} - gateway, err := New(upstream.URL, DefaultRules(ModeShadow)) + gateway, err := New(upstream.URL, DefaultRules(ModeShadow, "")) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/apps/api-go/internal/usersettings/batch3b_test.go b/apps/api-go/internal/usersettings/batch3b_test.go new file mode 100644 index 00000000..b3666efc --- /dev/null +++ b/apps/api-go/internal/usersettings/batch3b_test.go @@ -0,0 +1,402 @@ +// Go-批3B 新增三个 normalization 的代表性契约测试(任务书最小策略: +// 本文件是本轮唯一新增测试文件,Situation Monitor / War Map / NewsNow +// 各一个代表性契约,允许合并成表格——此处按端点分三个顶层测试函数, +// 加有序对象语义一个,共四个,未超过 5 个上限)。 +// +// 覆盖原则:每个端点选「最能区分移植是否忠实」的组合(多字段联合 +// 规整 + 边界回退),不逐字段/逐枚举/逐错误分支造测试。 +package usersettings + +import ( + "encoding/json" + "testing" + "time" +) + +// Situation Monitor 契约:三记录聚合响应形态 + monitors/layout/settings +// 各自的代表性规整(fixture 提供稳定 id/createdAt——随机 fallback 由 +// 实现保证,不加入 ignore 集)。 +func TestSituationMonitorContract(t *testing.T) { + // 无任何记录:三段 null、updatedAt 空——聚合语义的核心。 + t.Run("no records yield all null", func(t *testing.T) { + body, err := json.Marshal(BuildSituationMonitorResponse(SituationMonitorRecords{})) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, "no-records", string(body), + `{"version":1,"updatedAt":{},"monitors":null,"layout":null,"settings":null}`) + }) + + // 部分记录(只有 settings):updatedAt 只含 settings;monitors/layout + // 仍 null——findMany 聚合语义(无记录 ≠ 默认值)。 + t.Run("partial records omit absent updatedAt keys", func(t *testing.T) { + records := SituationMonitorRecords{ + Settings: Record{ + Found: true, + Value: []byte(`{"windowHours":6,"scope":"tagged","autoRefresh":false,"resetLayoutOnPreset":"x","translateToZh":1}`), + UpdatedAt: time.Date(2026, 9, 5, 12, 0, 0, 456000000, time.UTC), + }, + } + body, err := json.Marshal(BuildSituationMonitorResponse(records)) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, "partial", string(body), + `{"version":1,"updatedAt":{"settings":"2026-09-05T12:00:00.456Z"},"monitors":null,"layout":null,`+ + `"settings":{"windowHours":6,"scope":"tagged","autoRefresh":false,"resetLayoutOnPreset":false,"translateToZh":false}}`) + }) + + // 三记录聚合 + 各段代表性规整:monitors(id/keywords/color/location/ + // enabled/createdAt)、layout(断点/legacy 回退/数值取整/visibility)、 + // settings(枚举与布尔回退)。 + t.Run("full aggregation with representative normalization", func(t *testing.T) { + records := SituationMonitorRecords{ + Monitors: Record{ + Found: true, + Value: []byte(`[ + {"id":" mon-1 ","name":" Taiwan Strait ","keywords":[" ship , navy ","ship","MISSING",""],"enabled":0, + "color":" FF00AA ","location":{"name":" Taipei ","lat":25.03,"lng":121.56},"createdAt":1757000000000}, + {"name":"","keywords":["x"]}, + {"name":"NoKeywords","keywords":[]}, + {"name":"Bad location","keywords":["y"],"location":{"name":"L","lat":999,"lng":0}} + ]`), + UpdatedAt: time.Date(2026, 9, 5, 13, 0, 0, 0, time.UTC), + }, + Layout: Record{ + Found: true, + Value: []byte(`{ + "layout": [{"i":" legacy-1 ","x":-3.2,"y":2.7,"w":0.4,"h":5.1,"minW":0,"static":true},{"i":"","x":1,"y":1,"w":1,"h":1}], + "layouts": {"md": [{"i":" md-1 ","x":"a","y":1.5,"w":2.9,"minH":-2}], "bogus": [{"i":"x"}]}, + "visibility": {" panel-1 ":true,"panel-2":"yes","":false,"panel-3":false} + }`), + UpdatedAt: time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC), + }, + Settings: Record{ + Found: true, + Value: []byte(`{"windowHours":48,"scope":"everything","autoRefresh":0,"resetLayoutOnPreset":true,"translateToZh":true}`), + UpdatedAt: time.Date(2026, 9, 5, 15, 0, 0, 789000000, time.UTC), + }, + } + response := BuildSituationMonitorResponse(records) + + // monitors:4 条输入中 #2(name 空)与 #3(无 keywords)丢弃; + // #4(location lat=999 越界)location 不出现但 monitor 保留 → 3 条。 + if len(response.Monitors) != 3 { + t.Fatalf("monitors = %d, want 3(name 空/无 keywords 丢弃;location 越界仅丢 location): %+v", len(response.Monitors), response.Monitors) + } + first := response.Monitors[0] + if first.ID != "mon-1" || first.Name != "Taiwan Strait" { + t.Errorf("monitor[0] id/name = %q/%q, want mon-1/Taiwan Strait(trim+截断)", first.ID, first.Name) + } + wantKeywords := []string{"ship", "navy", "MISSING"} + if len(first.Keywords) != len(wantKeywords) { + t.Fatalf("keywords = %v, want %v(split/trim/去空/去重)", first.Keywords, wantKeywords) + } + for i, want := range wantKeywords { + if first.Keywords[i] != want { + t.Errorf("keywords[%d] = %q, want %q", i, first.Keywords[i], want) + } + } + if !first.Enabled { + t.Error("enabled = false, want true(数字 0 非布尔 → NestJS 缺省 true)") + } + if first.Color == nil || *first.Color != "#ff00aa" { + t.Errorf("color = %v, want #ff00aa(补 #/小写)", first.Color) + } + if first.Location == nil || first.Location.Name != "Taipei" || first.Location.Lat != 25.03 || first.Location.Lng != 121.56 { + t.Errorf("location = %+v, want Taipei/25.03/121.56", first.Location) + } + if first.CreatedAt != 1757000000000 { + t.Errorf("createdAt = %d, want 1757000000000", first.CreatedAt) + } + if response.Monitors[2].Location != nil { + t.Errorf("越界 location 应不出现: %+v", response.Monitors[2].Location) + } + if response.Monitors[2].Name != "Bad location" { + t.Errorf("monitors[2].name = %q, want Bad location", response.Monitors[2].Name) + } + + // layout:legacy `layout` 回退到 lg;md 断点数值取整与最小值。 + if response.Layout == nil { + t.Fatal("layout = nil, want object") + } + lg := response.Layout.Layouts["lg"] + if len(lg) != 1 { + t.Fatalf("lg items = %d, want 1(空 i 丢弃;legacy 回退)", len(lg)) + } + if lg[0].I != "legacy-1" || lg[0].X != 0 || lg[0].Y != 3 || lg[0].W != 1 || lg[0].H != 5 { + t.Errorf("lg[0] = %+v, want i=legacy-1 x=0 y=3 w=1 h=5(负数/最小值/取整)", lg[0]) + } + if lg[0].MinW == nil || *lg[0].MinW != 1 { + t.Errorf("lg[0].minW = %v, want 1(0 → 最小值 1)", lg[0].MinW) + } + if lg[0].IsStatic == nil || !*lg[0].IsStatic { + t.Errorf("lg[0].static = %v, want true", lg[0].IsStatic) + } + md := response.Layout.Layouts["md"] + if len(md) != 1 || md[0].X != 0 || md[0].Y != 2 || md[0].W != 3 || md[0].MinH == nil || *md[0].MinH != 1 { + t.Errorf("md[0] = %+v, want x=0 y=2 w=3 minH=1(非数字回退/取整/最小值)", md) + } + if _, exists := response.Layout.Layouts["bogus"]; exists { + t.Error("未知断点不得写入 layouts") + } + // visibility:布尔项保留(trim key),非布尔丢弃;key trim。 + if len(response.Layout.Visibility) != 2 || + !response.Layout.Visibility["panel-1"] || response.Layout.Visibility["panel-3"] { + t.Errorf("visibility = %v, want {panel-1:true, panel-3:false}(trim/布尔过滤)", response.Layout.Visibility) + } + + // settings:windowHours 48 非法 → 24;scope 非法 → all;布尔严格。 + if response.Settings.WindowHours != 24 || response.Settings.Scope != "all" || + response.Settings.AutoRefresh || !response.Settings.ResetLayoutOnPreset || !response.Settings.TranslateToZh { + t.Errorf("settings = %+v, want windowHours=24 scope=all autoRefresh=false reset=true translate=true", response.Settings) + } + + // updatedAt 三段与三份数据对应。 + if response.UpdatedAt.Monitors != "2026-09-05T13:00:00.000Z" || + response.UpdatedAt.Layout != "2026-09-05T14:00:00.000Z" || + response.UpdatedAt.Settings != "2026-09-05T15:00:00.789Z" { + t.Errorf("updatedAt = %+v(三段各自对应)", response.UpdatedAt) + } + }) +} + +// War Map 契约:完整移植 war-map-contract.ts 的代表性组合——layer +// visibility(新 key/legacy key/默认值三分支)、viewState clamp、枚举 +// 回退、bearing/pitch 归零。无记录 → null。 +func TestWarMapContract(t *testing.T) { + t.Run("no record yields settings null", func(t *testing.T) { + body, err := json.Marshal(BuildWarMapResponse(Record{Found: false})) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, "no-record", string(body), `{"version":1,"updatedAt":{},"settings":null}`) + }) + + t.Run("non-object value yields full defaults", func(t *testing.T) { + record := Record{Found: true, Value: []byte(`[1]`), UpdatedAt: time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)} + settings := NormalizeWarMap(record.Value) + // 抽查默认:known-true / known-false / flightMode / viewState。 + if !settings.LayerVisibility.Conflicts || settings.LayerVisibility.Cables || + !settings.LayerVisibility.Monitors || !settings.LayerVisibility.IranAttacks { + t.Errorf("default layerVisibility mismatch: %+v", settings.LayerVisibility) + } + if settings.FlightMode != "military" || settings.AisMode != "all" || !settings.AisHighlightCandidates { + t.Errorf("defaults: flightMode=%s aisMode=%s highlight=%v", settings.FlightMode, settings.AisMode, settings.AisHighlightCandidates) + } + if settings.ViewState.Lat != 20 || settings.ViewState.Lon != 0 || settings.ViewState.Zoom != 1.8 { + t.Errorf("default viewState = %+v", settings.ViewState) + } + }) + + t.Run("root-level visibility with legacy keys and clamps", func(t *testing.T) { + raw := []byte(`{ + "conflicts": false, + "militaryBases": false, + "cables": true, + "viewState": {"lat": 120, "lon": -200, "zoom": 99, "bearing": 45, "pitch": 30}, + "activePreset": "mena", + "timeRangePreset": "48h", + "flightMode": "all", + "aisMode": "density", + "aisHighlightCandidates": false + }`) + settings := NormalizeWarMap(raw) + // 新 key 直接采用。 + if settings.LayerVisibility.Conflicts { + t.Error("conflicts = true, want false(根对象直读)") + } + if settings.LayerVisibility.Cables { + // cables 根对象给了 true——注意:cables 的 legacy key 是 + // cableLandings,这里 "cables" 是新 key,true 应被采用。 + t.Error("cables = false, want true(新 key 布尔值采用)") + } + // legacy key:militaryBases=false → bases=false(无新 key 时回退)。 + if settings.LayerVisibility.Bases { + t.Error("bases = true, want false(legacy militaryBases 回退)") + } + // 未提及的 layer 保持默认(hotspots 默认 true;dayNight 默认 false)。 + if !settings.LayerVisibility.Hotspots || settings.LayerVisibility.DayNight { + t.Errorf("untouched layers must keep defaults: hotspots=%v dayNight=%v", + settings.LayerVisibility.Hotspots, settings.LayerVisibility.DayNight) + } + // viewState clamp + bearing/pitch 归零。 + if settings.ViewState.Lat != 90 || settings.ViewState.Lon != -180 || settings.ViewState.Zoom != 18 { + t.Errorf("viewState = %+v, want lat=90 lon=-180 zoom=18(clamp)", settings.ViewState) + } + if settings.ViewState.Bearing != 0 || settings.ViewState.Pitch != 0 { + t.Errorf("bearing/pitch = %v/%v, want 0/0(强制归零)", settings.ViewState.Bearing, settings.ViewState.Pitch) + } + // 枚举。 + if settings.ActivePreset != "mena" || settings.TimeRangePreset != "48h" || + settings.FlightMode != "all" || settings.AisMode != "density" || settings.AisHighlightCandidates { + t.Errorf("enums: %+v", settings) + } + }) + + t.Run("nested layerVisibility and invalid enums", func(t *testing.T) { + raw := []byte(`{ + "layerVisibility": {"hotspots": false, "conflictZones": true}, + "viewState": "not-an-object", + "activePreset": "atlantis", + "timeRangePreset": "1y", + "flightMode": "everything", + "aisMode": "all", + "aisHighlightCandidates": "false" + }`) + settings := NormalizeWarMap(raw) + // 嵌套 layerVisibility:hotspots=false 采用;conflictZones(legacy) + // 不在嵌套对象里 → conflicts 保持默认 true。 + if settings.LayerVisibility.Hotspots { + t.Error("hotspots = true, want false(嵌套对象采用)") + } + if !settings.LayerVisibility.Conflicts { + t.Error("conflicts = false, want true(嵌套对象里的 legacy key 不参与——只在缺失新 key 时从同一 raw 对象读)") + } + if settings.ViewState.Lat != 20 || settings.ViewState.Zoom != 1.8 { + t.Errorf("viewState = %+v, want 默认(非对象回退)", settings.ViewState) + } + if settings.ActivePreset != "global" || settings.TimeRangePreset != "7d" || settings.FlightMode != "military" { + t.Errorf("invalid enums must fall back: %+v", settings) + } + if !settings.AisHighlightCandidates { + t.Error("aisHighlightCandidates = false, want true(字符串 \"false\" 非严格 false)") + } + }) +} + +// NewsNow 契约:有序对象(columnOrders/sourceAffinity 的 key 顺序 + +// 上限即停止)、source id pattern、Boolean 真值、clamp/round、 +// smart→personalized。无记录 → null;非对象 → 默认(含 {} 形态)。 +func TestNewsnowContract(t *testing.T) { + t.Run("no record yields settings null", func(t *testing.T) { + body, err := json.Marshal(BuildNewsnowResponse(Record{Found: false})) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, "no-record", string(body), `{"version":1,"updatedAt":{},"settings":null}`) + }) + + t.Run("non-object value yields defaults as empty objects", func(t *testing.T) { + record := Record{Found: true, Value: []byte(`42`), UpdatedAt: time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)} + body, err := json.Marshal(BuildNewsnowResponse(record)) + if err != nil { + t.Fatal(err) + } + // columnOrders/sourceAffinity 必须是 {}(不是 []——JS 对象序列化)。 + assertJSONEqual(t, "defaults", string(body), + `{"version":1,"updatedAt":{"settings":"2026-09-05T00:00:00.000Z"},`+ + `"settings":{"focusSources":[],"columnOrders":{},"hideCrossSourceDuplicates":false,`+ + `"sortMode":"manual","densityMode":"compact","sourceAffinity":{}}}`) + }) + + t.Run("ordered objects, truthiness, clamps and dedupe", func(t *testing.T) { + raw := []byte(`{ + "focusSources": [" src-A ", "src-A", "src-B!", "src-c", 42, ""], + "columnOrders": {" zz-col ": [" s2 ", "s1", "s1"], "bad col!": ["s1"], "empty-col": [], "aa-col": ["s0"]}, + "hideCrossSourceDuplicates": "yes", + "sortMode": "smart", + "densityMode": "compact", + "sourceAffinity": {" src-Z ": {"score": 250.5, "openOriginalCount": -5, "focusCount": 3.7, "lastInteractedAt": 12345678901234}, "src-Y": "not-an-object", "src-X": {"score": "high", "accumulatedDwellMs": 1e12}} + }`) + settings := NormalizeNewsnow(raw) + + // focusSources:trim/pattern 过滤(src-B! 非法)/去重/类型过滤。 + wantFocus := []string{"src-A", "src-c"} + if len(settings.FocusSources) != len(wantFocus) { + t.Fatalf("focusSources = %v, want %v", settings.FocusSources, wantFocus) + } + for i, want := range wantFocus { + if settings.FocusSources[i] != want { + t.Errorf("focusSources[%d] = %q, want %q", i, settings.FocusSources[i], want) + } + } + + // columnOrders:顺序保留(zz-col 在 aa-col 之前——出现顺序), + // 非法 key 丢弃,空列表列丢弃。 + if len(settings.ColumnOrders) != 2 { + t.Fatalf("columnOrders = %d entries, want 2: %+v", len(settings.ColumnOrders), settings.ColumnOrders) + } + if settings.ColumnOrders[0].Key != "zz-col" || settings.ColumnOrders[1].Key != "aa-col" { + t.Errorf("columnOrders keys = [%s %s], want [zz-col aa-col](出现顺序)", + settings.ColumnOrders[0].Key, settings.ColumnOrders[1].Key) + } + if got := settings.ColumnOrders[0].Values; len(got) != 2 || got[0] != "s2" || got[1] != "s1" { + t.Errorf("zz-col values = %v, want [s2 s1](trim/去重)", got) + } + + // hideCrossSourceDuplicates:非空字符串真值。 + if !settings.HideCrossSourceDuplicates { + t.Error("hideCrossSourceDuplicates = false, want true(JS Boolean(\"yes\"))") + } + // smart → personalized 归一。 + if settings.SortMode != "personalized" { + t.Errorf("sortMode = %q, want personalized(smart 归一)", settings.SortMode) + } + + // sourceAffinity:顺序保留(src-Z 在 src-X 前),非法 value 丢弃, + // clamp + round。 + if len(settings.SourceAffinity) != 2 { + t.Fatalf("sourceAffinity = %d entries, want 2: %+v", len(settings.SourceAffinity), settings.SourceAffinity) + } + if settings.SourceAffinity[0].Key != "src-Z" || settings.SourceAffinity[1].Key != "src-X" { + t.Errorf("sourceAffinity keys = [%s %s], want [src-Z src-X](出现顺序)", + settings.SourceAffinity[0].Key, settings.SourceAffinity[1].Key) + } + z := settings.SourceAffinity[0].Affinity + if z.Score != 100 || z.OpenOriginalCount != 0 || z.FocusCount != 4 || z.LastInteractedAt != 9999999999999 { + t.Errorf("src-Z affinity = %+v, want score=100(250.5 clamp) openOriginal=0(-5 clamp) focus=4(3.7 round) lastAt=9999999999999(clamp)", z) + } + x := settings.SourceAffinity[1].Affinity + if x.Score != 0 || x.AccumulatedDwellMs != 31536000000 { + t.Errorf("src-X affinity = %+v, want score=0(非数字) dwell=31536000000(1e12 clamp 到 365 天)", x) + } + + // 序列化:对象形态 + 顺序保持(JSON 字符串断言)。 + body, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, "newsnow-shape", string(body), + `{"focusSources":["src-A","src-c"],"columnOrders":{"zz-col":["s2","s1"],"aa-col":["s0"]},`+ + `"hideCrossSourceDuplicates":true,"sortMode":"personalized","densityMode":"compact",`+ + `"sourceAffinity":{"src-Z":{"score":100,"openOriginalCount":0,"openEventCount":0,"openItemCount":0,`+ + `"refreshCount":0,"focusCount":4,"accumulatedDwellMs":0,"lastInteractedAt":9999999999999},`+ + `"src-X":{"score":0,"openOriginalCount":0,"openEventCount":0,"openItemCount":0,`+ + `"refreshCount":0,"focusCount":0,"accumulatedDwellMs":31536000000,"lastInteractedAt":0}}}`) + }) +} + +// 有序对象上限语义:situation visibility 的 64 项上限在「第 64 个合法 +// 项之后」停止(Object.entries 顺序决定哪 64 个 key 存活)——用 66 个 +// 交错合法/非法 key 验证存活集合与顺序无关的部分(合法项恰好前 64 个)。 +func TestSituationVisibilityCapKeepsFirst64InOrder(t *testing.T) { + raw := []byte(`{"visibility": {` + + `"v001": true, "bad-key!": true, "v002": false, "": true, "v003": true,` + + `"v004": true, "v005": true, "v006": true, "v007": true, "v008": true,` + + `"v009": true, "v010": true, "v011": true, "v012": true, "v013": true,` + + `"v014": true, "v015": true, "v016": true, "v017": true, "v018": true,` + + `"v019": true, "v020": true, "v021": true, "v022": true, "v023": true,` + + `"v024": true, "v025": true, "v026": true, "v027": true, "v028": true,` + + `"v029": true, "v030": true, "v031": true, "v032": true, "v033": true,` + + `"v034": true, "v035": true, "v036": true, "v037": true, "v038": true,` + + `"v039": true, "v040": true, "v041": true, "v042": true, "v043": true,` + + `"v044": true, "v045": true, "v046": true, "v047": true, "v048": true,` + + `"v049": true, "v050": true, "v051": true, "v052": true, "v053": true,` + + `"v054": true, "v055": true, "v056": true, "v057": true, "v058": true,` + + `"v059": true, "v060": true, "v061": true, "v062": true, "v063": true,` + + `"v064": true, "v065": true, "v066": true}}`) + layout := NormalizeSituationLayout(raw) + if len(layout.Visibility) != 64 { + t.Fatalf("visibility = %d entries, want 64(上限即停止)", len(layout.Visibility)) + } + if _, exists := layout.Visibility["v064"]; !exists { + t.Error("v064 must survive (64th legal entry in order)") + } + if _, exists := layout.Visibility["v065"]; exists { + t.Error("v065 must be dropped (past the 64-entry cap)") + } + if layout.Visibility["v002"] { + t.Error("v002 = true, want false(原值保留)") + } +} diff --git a/apps/api-go/internal/usersettings/mysql_integration_test.go b/apps/api-go/internal/usersettings/mysql_integration_test.go index 2b95d19c..f6af021b 100644 --- a/apps/api-go/internal/usersettings/mysql_integration_test.go +++ b/apps/api-go/internal/usersettings/mysql_integration_test.go @@ -221,6 +221,109 @@ func TestUserSettingsMySQLIntegration(t *testing.T) { if rssIsolated.Found { t.Error("rss-reader record.Found = true — orgId/userId 隔离失败") } + + // 7. Go-批3B:war-map / newsnow 两个新固定 key 的真实读取与 + // normalization 抽查。 + warMapJSON := `{"layerVisibility":{"conflicts":false,"militaryBases":false},"viewState":{"lat":120,"zoom":99},"activePreset":"mena","aisMode":"density"}` + if _, err := db.ExecContext(ctx, insert, "us-it-5", orgID, userID, WarMapKey, warMapJSON, insertedAt); err != nil { + t.Fatalf("insert war-map: %v", err) + } + warRecord, err := repo.FindWarMap(ctx, orgID, userID) + if err != nil { + t.Fatalf("find war-map: %v", err) + } + if !warRecord.Found { + t.Fatal("war-map record.Found = false, want true") + } + warResponse := BuildWarMapResponse(warRecord) + if warResponse.UpdatedAt.Settings != "2026-09-03T08:30:15.123Z" { + t.Errorf("war-map updatedAt.settings = %q, want 2026-09-03T08:30:15.123Z", warResponse.UpdatedAt.Settings) + } + if s := warResponse.Settings; s == nil { + t.Fatal("war-map settings = nil, want object") + } else if s.LayerVisibility.Conflicts || s.LayerVisibility.Bases || + !s.LayerVisibility.Hotspots || s.ViewState.Lat != 90 || s.ViewState.Zoom != 18 || + s.ActivePreset != "mena" || s.AisMode != "density" { + t.Errorf("war-map normalization mismatch: %+v", s) + } + + newsnowJSON := `{"focusSources":[" src-A ","src-A","bad!"],"sortMode":"smart","hideCrossSourceDuplicates":"yes","columnOrders":{"zz":[" s1 ","s1"]},"sourceAffinity":{"src-Z":{"score":250,"focusCount":3.7}}}` + if _, err := db.ExecContext(ctx, insert, "us-it-6", orgID, userID, NewsnowKey, newsnowJSON, insertedAt); err != nil { + t.Fatalf("insert newsnow: %v", err) + } + newsRecord, err := repo.FindNewsnow(ctx, orgID, userID) + if err != nil { + t.Fatalf("find newsnow: %v", err) + } + if !newsRecord.Found { + t.Fatal("newsnow record.Found = false, want true") + } + newsResponse := BuildNewsnowResponse(newsRecord) + if s := newsResponse.Settings; s == nil { + t.Fatal("newsnow settings = nil, want object") + } else if len(s.FocusSources) != 1 || s.FocusSources[0] != "src-A" || + s.SortMode != "personalized" || !s.HideCrossSourceDuplicates || + len(s.ColumnOrders) != 1 || s.ColumnOrders[0].Key != "zz" || + len(s.SourceAffinity) != 1 || s.SourceAffinity[0].Affinity.Score != 100 || s.SourceAffinity[0].Affinity.FocusCount != 4 { + t.Errorf("newsnow normalization mismatch: %+v", s) + } + + // 8. Go-批3B:situation-monitor 三 key 聚合——一次查询读三个固定 + // key;部分记录(只有 monitors)时 layout/settings Found=false + // 但不报错;全部三 key 写入后聚合完整;三段互不串读。 + situationPartialJSON := `[{"id":"sm-1","name":"Watch","keywords":["a,b"],"createdAt":1757000000000}]` + if _, err := db.ExecContext(ctx, insert, "us-it-7", orgID, userID, SituationMonitorMonitorsKey, situationPartialJSON, insertedAt); err != nil { + t.Fatalf("insert situation monitors: %v", err) + } + partial, err := repo.FindSituationMonitor(ctx, orgID, userID) + if err != nil { + t.Fatalf("find situation-monitor partial: %v", err) + } + if !partial.Monitors.Found || partial.Layout.Found || partial.Settings.Found { + t.Errorf("partial aggregation: monitors=%v layout=%v settings=%v, want true/false/false", + partial.Monitors.Found, partial.Layout.Found, partial.Settings.Found) + } + partialResponse := BuildSituationMonitorResponse(partial) + if partialResponse.Monitors == nil || len(partialResponse.Monitors) != 1 || partialResponse.Monitors[0].ID != "sm-1" { + t.Errorf("partial monitors = %+v, want 1 条(id 保留)", partialResponse.Monitors) + } + if partialResponse.Layout != nil || partialResponse.Settings != nil { + t.Errorf("partial response layout/settings = %v/%v, want nil", partialResponse.Layout, partialResponse.Settings) + } + if partialResponse.UpdatedAt.Monitors == "" || partialResponse.UpdatedAt.Layout != "" || partialResponse.UpdatedAt.Settings != "" { + t.Errorf("partial updatedAt = %+v, want 只含 monitors", partialResponse.UpdatedAt) + } + + // 全三 key 聚合 + 租户隔离。 + if _, err := db.ExecContext(ctx, insert, "us-it-8", orgID, userID, SituationMonitorLayoutKey, + `{"layouts":{"lg":[{"i":"a","x":-1,"y":2.5,"w":0,"h":3}]}}`, insertedAt); err != nil { + t.Fatalf("insert situation layout: %v", err) + } + if _, err := db.ExecContext(ctx, insert, "us-it-9", orgID, userID, SituationMonitorSettingsKey, + `{"windowHours":6,"scope":"tagged"}`, insertedAt); err != nil { + t.Fatalf("insert situation settings: %v", err) + } + full, err := repo.FindSituationMonitor(ctx, orgID, userID) + if err != nil { + t.Fatalf("find situation-monitor full: %v", err) + } + if !full.Monitors.Found || !full.Layout.Found || !full.Settings.Found { + t.Fatalf("full aggregation: %+v, want 三段全部 Found", full) + } + fullResponse := BuildSituationMonitorResponse(full) + if s := fullResponse.Settings; s == nil || s.WindowHours != 6 || s.Scope != "tagged" { + t.Errorf("full settings = %+v, want windowHours=6 scope=tagged", s) + } + if l := fullResponse.Layout; l == nil || len(l.Layouts["lg"]) != 1 || l.Layouts["lg"][0].X != 0 || l.Layouts["lg"][0].Y != 3 || l.Layouts["lg"][0].W != 1 { + t.Errorf("full layout = %+v, want lg=[x=0 y=3 w=1]", l) + } + isolatedSituation, err := repo.FindSituationMonitor(ctx, otherOrgID, userID) + if err != nil { + t.Fatalf("find situation-monitor isolated: %v", err) + } + if isolatedSituation.Monitors.Found || isolatedSituation.Layout.Found || isolatedSituation.Settings.Found { + t.Error("situation-monitor 隔离失败——其他 org 不应有任何记录") + } } func jsonEqualString(a, b string) bool { From 7b1c961825943d161ca6ccaa1fbcab8fea475334 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:32:18 +0800 Subject: [PATCH 05/11] =?UTF-8?q?ci(api-go):=20entry=20smoke=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=BA=E5=9B=9B=E9=98=B6=E6=AE=B5=E2=80=94=E2=80=94?= =?UTF-8?q?Phase=20A=20=E5=86=99=E5=85=A5=20/=20B=20shadow=20/=20C=20go=20?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=20/=20D=20=E7=8B=AC=E7=AB=8B=E6=80=A7?= =?UTF-8?q?=E8=AF=81=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase A:六个 PUT(含 situation-monitor 三段一次写入)经 api-go 由 NestJS 单写;MySQL 直查 8 个固定 key 均存在;PUT 前后 shadow executed 不变;相似路径 404 不误命中 - Phase B:API_GO_USER_SETTINGS_READ_MODE=shadow 启动——六个 GET 全部 NestJS 响应 + Go 真实旁路差分(executed 精确 +7 含 healthz/live、 diffs/dropped 六类零增量、inflight 归零;不扩大 ignore 集) - Phase C:重启为 readMode=go——六端点与 NestJS 直连逐字段契约对比 (status/Cache-Control/content-type/JSON/trace header、各端点读自己 key、situation-monitor 三段 updatedAt 对应);Go 请求零 shadow 执行 - Phase D:停止 NestJS 并确认 4000 关闭——六个 GET 仍 200(Phase A 持久化数据);代表性 PUT(war-map)502 证明写路径未迁入 Go;未迁移 GET(/api/items)502 证明不伪装成功;容器 healthcheck 保持健康 - 共享鉴权链负向用例只保留代表性端点(onboarding)——批3A 已验证的 JWT/RBAC 行为不重复铺矩阵 --- .github/workflows/api-go-entry-smoke.yml | 697 ++++++++++++++++------- 1 file changed, 485 insertions(+), 212 deletions(-) diff --git a/.github/workflows/api-go-entry-smoke.yml b/.github/workflows/api-go-entry-smoke.yml index 2035eea7..4452ebb6 100644 --- a/.github/workflows/api-go-entry-smoke.yml +++ b/.github/workflows/api-go-entry-smoke.yml @@ -1,5 +1,5 @@ -# Go-批2C 引入 / Go-批3A 扩展:api-go 真实入口链的远端真实栈 smoke -#(手动触发)。 +# Go-批2C 引入 / Go-批3A 扩展 / Go-批3B 重构:api-go 真实入口链的 +# 远端真实栈 smoke(手动触发)。 # # 与普通 CI(ci.yml)的区别:这不是单元/集成测试,而是把迁移单元放进 # 「真实入口链」运行—— @@ -7,34 +7,43 @@ # 客户端 → api-go 容器 :4020 → NestJS apps/api :4000(真实进程) # ↘ Go Shadow → 真实 MySQL(真实迁移后的 schema) # -# Go-批3A 起 onboarding GET 处于真实接管(API_GO_ONBOARDING_MODE=go): +# Go-批3B:六个 user-settings 只读 GET 的统一读模式 +#(API_GO_USER_SETTINGS_READ_MODE)分两阶段验证: # -# GET /api/user-settings/ui/onboarding → Go 独立鉴权(JWT 验签 + -# Redis blacklist + MySQL RBAC 重推导)+ 独立查库 + 全响应—— -# 不依赖 NestJS 200,不读 JWT permissions claim +# Phase A(写入准备):6 个 PUT 经 api-go 由 NestJS 单写(含 +# situation-monitor 三 key 一次写入)——MySQL 直查 8 个固定 key; +# Phase B(shadow):readMode=shadow 启动——6 个 GET 都由 NestJS 响应, +# Go 做真实旁路查询与差分(executed 精确 +6,diffs/dropped 零增量); +# Phase C(go):readMode=go 重启——6 个 GET 全部由 Go 独立鉴权 +# (JWT 验签 + Redis blacklist + MySQL RBAC)+ 独立查库 + 全响应, +# 与 NestJS 直连逐字段契约对比;Go 请求不增加 shadow.executed; +# Phase D(独立性证明):停止 NestJS——6 个 GET 仍全部 200(数据是 +# Phase A 真实持久化的);代表性 PUT 与未迁移 GET 必须代理失败 +# (502),证明写路径未迁入 Go、api-go 不伪装成功。 # -# 真实性约束(沿承 Go-批2C 任务书,Go-批3A 追加鉴权链验证): +# 真实性约束(沿承 Go-批2C/3A 任务书,Go-批3B 扩展到六端点): # - 真实 MySQL/Redis/Mongo service 容器(与 docker-compose 同版本线); # - 真实 prisma migrate deploy(不是内存库/fixture); # - 真实 NestJS 进程(node apps/api/dist/main.js,非 fake upstream); # - 真实 api-go Docker 镜像(infra/docker/api-go.Dockerfile 构建,非 go run); # - 仓库既有 seed 机制创建可登录 org+admin; # - 通过 api-go 入口调用真实 NestJS 登录端点获得其签发的 JWT(不手工伪造); -# - 三个 user-settings PUT 经 api-go 代理由 NestJS 单写并真实持久化 +# - 六个 user-settings PUT 经 api-go 代理由 NestJS 单写并真实持久化 # (PUT 不进入 Go handler——method 白名单); -# - onboarding GET 由 Go handler 全响应,与 NestJS 直连响应逐字段契约 -# 对比(status/Cache-Control/content-type/JSON 正文); +# - 六个 GET 在 shadow 阶段做真实差分(零差异),go 阶段由 Go handler +# 全响应,与 NestJS 直连响应逐字段契约对比(status/Cache-Control/ +# content-type/JSON 正文); +# - 共享鉴权链负向用例只保留代表性端点(onboarding)——Go-批3A 已 +# 验证的 JWT/RBAC 行为不重复铺测试矩阵; # - 无权限:真实删除 RolePermission(items.read)+ 清 NestJS profile # 缓存——JWT claim 仍含 items.read 也必须双端 403(claim 不参与授权); # - membership inactive:真实 UPDATE + 缓存清理——双端 401 同文案; # - 撤销:真实 NestJS logout 写入真实 Redis blacklist → Go 401 # "Access token revoked"(key 存在性静态确认,不打印 jti/token); # - 非法 token:真实登录 token 篡改签名 + alg=none 混淆 → Go 401; -# - NestJS 停止后:onboarding GET 仍 200(Go 独立接管证明);未迁移 -# 端点(rss-reader shadow)因上游死亡返回 502(非伪装成功); -# - /__go/healthz 前后指标断言:onboarding 不增加 shadow.executed、 -# executed 精确增量(healthz/live + rss-reader + spacetime-timeline -# = +3)、diffs/dropped 零增量、inflight 归零。 +# - /__go/healthz 前后指标断言:go 接管端点不增加 shadow.executed、 +# shadow 阶段 executed 精确增量(6)、diffs/dropped 零增量、 +# inflight 归零。 # # 重型真实栈(4 个容器 + 全量 install/build)——不进 push/synchronize 的 # 普通 CI。 @@ -179,7 +188,7 @@ jobs: - name: Build NestJS api (dist/main.js) run: pnpm exec turbo run build --env-mode=loose --filter=@modular/api - - name: Start real NestJS api (:4000) + - name: "Start real NestJS api (:4000)" run: | # 与 compose api 服务同一启动命令(pnpm --filter @modular/api run start # = node dist/main.js,含 NODE_PATH 语义);NODE_ENV=production 只在 @@ -204,13 +213,16 @@ jobs: - name: Build api-go production image (infra/docker/api-go.Dockerfile) run: docker build -f infra/docker/api-go.Dockerfile -t api-go-entry-smoke:ci . - - name: Start api-go container (:4020, onboarding go takeover) + # start_api_go 是两阶段复用的容器启动步骤:Phase B 用 shadow 模式, + # Phase C 重启为 go 模式。模式经 API_GO_USER_SETTINGS_READ_MODE + # 控制(Go-批3B 统一读模式)。 + - name: "Start api-go container (:4020, Phase B shadow mode)" run: | # healthcheck 命令来自镜像内置的 exec 形式 HEALTHCHECK 指令 # (distroless 无 shell——字符串形式 health-cmd 会经 /bin/sh 执行 # 而失败);这里只覆盖探测节奏加速等待。 - # Go-批3A:API_GO_ONBOARDING_MODE=go + 与 NestJS 同源的 - # JWT/Redis 配置——onboarding GET 由 Go 独立鉴权并全响应。 + # Phase B:API_GO_USER_SETTINGS_READ_MODE=shadow——六个 GET 全部 + # 由 NestJS 响应,Go 做真实旁路查询与差分。 docker run -d --name api-go-smoke \ --add-host=host.docker.internal:host-gateway \ --health-interval 5s \ @@ -221,7 +233,7 @@ jobs: -e PORT=4020 \ -e LEGACY_API_URL=http://host.docker.internal:4000 \ -e DATABASE_URL=mysql://root:secret@host.docker.internal:3306/app \ - -e API_GO_ONBOARDING_MODE=go \ + -e API_GO_USER_SETTINGS_READ_MODE=shadow \ -e JWT_SECRET="$JWT_SECRET" \ -e JWT_ISSUER="$JWT_ISSUER" \ -e JWT_AUDIENCE="$JWT_AUDIENCE" \ @@ -230,8 +242,6 @@ jobs: -e CANARY_PERCENT=0 \ -e SHADOW_DEBUG_BODY_LOG=false \ api-go-entry-smoke:ci - # 等待容器 healthcheck 真实通过(/api-go healthcheck → /__go/healthz, - # Go 原生路由——不产生 shadow 执行,不污染基线)。 for i in $(seq 1 30); do status=$(docker inspect --format '{{.State.Health.Status}}' api-go-smoke 2>/dev/null || echo starting) echo "api-go container health: $status" @@ -250,7 +260,7 @@ jobs: # ---- 以下 smoke 步骤全部经 api-go 入口(:4020)完成 ---- - - name: Smoke 0/10 — baseline healthz (onboarding mode=go, shadow stats) + - name: "Smoke 0/13 — baseline healthz (Phase B: read mode=shadow)" run: | cat > /tmp/smoke_baseline.py <<'PY' import json @@ -259,18 +269,18 @@ jobs: with urllib.request.urlopen("http://127.0.0.1:4020/__go/healthz", timeout=10) as resp: doc = json.load(resp) assert doc.get("ok") is True, doc - assert doc.get("userSettingsShadow", {}).get("database") == "configured", doc - # Go-批3A:onboarding 当前模式如实展示为 go(接管生效)。 - assert doc.get("onboarding", {}).get("mode") == "go", doc + assert doc.get("userSettingsRead", {}).get("database") == "configured", doc + # Phase B:统一读模式如实展示为 shadow。 + assert doc.get("userSettingsRead", {}).get("mode") == "shadow", doc with open("/tmp/shadow-baseline.json", "w") as f: json.dump(doc["shadow"], f) - print("userSettingsShadow.database: configured") - print("onboarding.mode: go") + print("userSettingsRead.database: configured") + print("userSettingsRead.mode: shadow (Phase B)") print("baseline shadow:", json.dumps(doc["shadow"], sort_keys=True)) PY python3 /tmp/smoke_baseline.py - - name: Smoke 1/10 — real login via api-go (NestJS-issued JWT) + - name: "Smoke 1/13 — real login via api-go (NestJS-issued JWT)" run: | cat > /tmp/smoke_login.py <<'PY' import json @@ -313,7 +323,9 @@ jobs: PY python3 /tmp/smoke_login.py - - name: Smoke 2/10 — three user-settings PUTs via api-go (NestJS single-writer) + # ---- Phase A:写入准备(六个 PUT 由 NestJS 单写)---- + + - name: "Smoke 2/13 — Phase A: six user-settings PUTs via api-go (NestJS single-writer)" run: | cat > /tmp/smoke_put.py <<'PY' import json @@ -323,21 +335,24 @@ jobs: TRACE = "5f1e2d3c4b5a69788697a5b4c3d2e1f0" token = open("/tmp/smoke-jwt").read().strip() - # 三个端点各写入与默认值不同的确定性 settings(每个端点独立 payload, + # 六个端点各写入与默认值不同的确定性 settings(每个端点独立 payload, # 后续 GET 断言各自读回自己的 key——证明无跨 key 串读)。 - # expected 是 NestJS normalization 之后的期望值(如 RSS 语言过滤 - # 统一大写)——PUT 响应即 GET 形状,按 normalized 值断言。 - # onboarding PUT 经 api-go 的 method 白名单回落 /api/ legacy—— - # 由 NestJS 单写(200 + NestJS 回显本身就是「PUT 未被 Go 接管」 - # 的证据;Go handler 对 PUT 是 404 语义)。 + # expected 是 NestJS normalization 之后的期望值(PUT 响应即 GET + # 形状,按 normalized 值断言)。PUT 全部经 api-go 的 method 白名单 + # 回落 /api/ legacy——由 NestJS 单写(200 + NestJS 回显本身就是 + # 「PUT 未被 Go 接管」的证据)。 + # + # situation-monitor 的 body 顶层是 monitors/layout/settings 三段 + # (不是 settings 包裹);war-map/newsnow 用覆盖关键 normalization + # 的代表性数据(legacy layer key、clamp、排序、上限、真值)。 PUTS = { "/api/user-settings/ui/onboarding": { - "settings": { + "body": {"settings": { "completed": True, "dismissed": True, "checklist": {"today": True, "events": True, "map": True, "finance": False}, "completedTours": {"today": True}, - }, + }}, "expected": { "completed": True, "dismissed": True, @@ -346,14 +361,14 @@ jobs: }, }, "/api/user-settings/ui/rss-reader": { - "settings": { + "body": {"settings": { "selectedSourceIds": ["src-alpha", "src-beta"], "sourceLanguageFilters": ["en", "ZH"], "translationEnabled": True, "translationProvider": "llm", "targetLanguage": "en-US", "showOriginalContent": True, - }, + }}, "expected": { "selectedSourceIds": ["src-alpha", "src-beta"], "sourceLanguageFilters": ["EN", "ZH"], @@ -364,7 +379,7 @@ jobs: }, }, "/api/user-settings/ui/spacetime-timeline": { - "settings": { + "body": {"settings": { "authoritativeLock": False, "requireCorroborated": False, "sourceType": "mixed", @@ -374,7 +389,7 @@ jobs: "timelineGranularity": "week", "speed": 4, "syncStatusAutoRefresh": False, - }, + }}, "expected": { "authoritativeLock": False, "requireCorroborated": False, @@ -387,14 +402,107 @@ jobs: "syncStatusAutoRefresh": False, }, }, + "/api/user-settings/ui/war-map": { + "body": {"settings": { + # legacy layer key(militaryBases→bases)+ 根对象直读 + + # viewState clamp + bearing/pitch 强制归零 + 枚举回退。 + "conflicts": False, + "militaryBases": False, + "cables": True, + "viewState": {"lat": 120, "lon": -200, "zoom": 99, "bearing": 45, "pitch": 30}, + "activePreset": "mena", + "timeRangePreset": "1y", + "flightMode": "all", + "aisMode": "density", + "aisHighlightCandidates": False, + }}, + "expected": { + # 46 个 layer 全量出现在 NestJS 响应中——expected 只 + # 断言变化项与代表项(响应对比在 Phase C 逐字段做)。 + "_partial": True, + "viewState": {"lat": 90, "lon": -180, "zoom": 18, "bearing": 0, "pitch": 0}, + "activePreset": "mena", + "timeRangePreset": "7d", + "flightMode": "all", + "aisMode": "density", + "aisHighlightCandidates": False, + "layerVisibility": { + "conflicts": False, "bases": False, "cables": True, + "hotspots": True, "monitors": True, "iranAttacks": True, + }, + }, + }, + "/api/user-settings/ui/newsnow": { + "body": {"settings": { + "focusSources": [" src-A ", "src-A", "src-B!", "src-c", 42, ""], + "columnOrders": {" zz-col ": [" s2 ", "s1", "s1"], "bad col!": ["s1"], "empty-col": [], "aa-col": ["s0"]}, + "hideCrossSourceDuplicates": "yes", + "sortMode": "smart", + "densityMode": "compact", + "sourceAffinity": { + " src-Z ": {"score": 250.5, "openOriginalCount": -5, "focusCount": 3.7, "lastInteractedAt": 12345678901234}, + "src-Y": "not-an-object", + "src-X": {"score": "high", "accumulatedDwellMs": 1e12}, + }, + }}, + "expected": { + "focusSources": ["src-A", "src-c"], + "columnOrders": {"zz-col": ["s2", "s1"], "aa-col": ["s0"]}, + "hideCrossSourceDuplicates": True, + "sortMode": "personalized", + "densityMode": "compact", + "sourceAffinity": { + "src-Z": {"score": 100, "openOriginalCount": 0, "openEventCount": 0, "openItemCount": 0, + "refreshCount": 0, "focusCount": 4, "accumulatedDwellMs": 0, "lastInteractedAt": 9999999999999}, + "src-X": {"score": 0, "openOriginalCount": 0, "openEventCount": 0, "openItemCount": 0, + "refreshCount": 0, "focusCount": 0, "accumulatedDwellMs": 31536000000, "lastInteractedAt": 0}, + }, + }, + }, + "/api/user-settings/ui/situation-monitor": { + # 三段一次写入(monitors/layout/settings 各自 normalize 后 + # 落三个固定 key);fixture 提供稳定 id/createdAt。 + "body": { + "monitors": [ + {"id": " mon-1 ", "name": " Taiwan Strait ", "keywords": [" ship , navy ", "ship", "MISSING", ""], + "enabled": 0, "color": " FF00AA ", "location": {"name": " Taipei ", "lat": 25.03, "lng": 121.56}, + "createdAt": 1757000000000}, + {"name": "", "keywords": ["x"]}, + {"name": "Bad location", "keywords": ["y"], "location": {"name": "L", "lat": 999, "lng": 0}}, + ], + "layout": { + "layout": [{"i": " legacy-1 ", "x": -3.2, "y": 2.7, "w": 0.4, "h": 5.1, "minW": 0, "static": True}], + "layouts": {"md": [{"i": " md-1 ", "x": "a", "y": 1.5, "w": 2.9, "minH": -2}]}, + "visibility": {" panel-1 ": True, "panel-2": "yes", "": False, "panel-3": False}, + }, + "settings": {"windowHours": 48, "scope": "everything", "autoRefresh": 0, + "resetLayoutOnPreset": True, "translateToZh": True}, + }, + "expected": { + "monitors": [ + {"id": "mon-1", "name": "Taiwan Strait", "keywords": ["ship", "navy", "MISSING"], + "enabled": True, "color": "#ff00aa", "location": {"name": "Taipei", "lat": 25.03, "lng": 121.56}, + "createdAt": 1757000000000}, + {"id": "Bad location", "name": "Bad location", "keywords": ["y"], + "enabled": True, "createdAt": None}, + ], + "layout": { + "layouts": { + "lg": [{"i": "legacy-1", "x": 0, "y": 3, "w": 1, "h": 5, "minW": 1, "static": True}], + "md": [{"i": "md-1", "x": 0, "y": 2, "w": 3, "h": 1, "minH": 1}], + }, + "visibility": {"panel-1": True, "panel-3": False}, + }, + "settings": {"windowHours": 24, "scope": "all", "autoRefresh": True, + "resetLayoutOnPreset": True, "translateToZh": True}, + }, + }, } for path, payload in PUTS.items(): - # 请求体只含 settings——expected 是本脚本的断言期望值, - # 不得混入请求(forbidNonWhitelisted 会拒绝多余属性)。 req = urllib.request.Request( f"http://127.0.0.1:4020{path}", - data=json.dumps({"settings": payload["settings"]}).encode(), + data=json.dumps(payload["body"]).encode(), method="PUT", headers={ "authorization": f"Bearer {token}", @@ -406,21 +514,30 @@ jobs: with urllib.request.urlopen(req, timeout=15) as resp: status, headers, body = resp.status, resp.headers, resp.read() except urllib.error.HTTPError as err: - raise SystemExit(f"PUT {path} via api-go returned {err.code}: {err.read()[:500]!r}") + raise SystemExit(f"PUT {path} via api-go returned {err.code}: {err.read()[:800]!r}") assert status == 200, (path, status) assert headers.get("x-trace-id") == TRACE, (path, dict(headers)) - # PUT 响应体 = GET 形状(NestJS update 后回读);按 normalized - # 期望值断言回显。 doc = json.loads(body) assert doc.get("version") == 1, (path, doc) - assert doc.get("settings") == payload["expected"], ( - f"{path}: PUT echo mismatch\n got: {json.dumps(doc.get('settings'), sort_keys=True)}\n" - f" expected: {json.dumps(payload['expected'], sort_keys=True)}") - print(f"PUT {path} -> 200 (NestJS 响应回显 settings 一致, trace header ok)") + expected = dict(payload["expected"]) + partial = expected.pop("_partial", False) + actual = doc.get("settings") if "settings" in doc else { + "monitors": doc.get("monitors"), "layout": doc.get("layout"), "settings": doc.get("settings")} + for key, want in expected.items(): + if key == "layerVisibility": + # 只断言代表性 layer(46 个全量在 Phase C 双端对比)。 + for layer, layer_want in want.items(): + assert actual["layerVisibility"].get(layer) == layer_want, ( + f"{path}: layerVisibility[{layer}] = {actual['layerVisibility'].get(layer)}, want {layer_want}") + continue + assert actual.get(key) == want, ( + f"{path}: {key} mismatch\n got: {json.dumps(actual.get(key), sort_keys=True)}\n" + f" expected: {json.dumps(want, sort_keys=True)}") + print(f"PUT {path} -> 200 (NestJS 响应回显 normalized settings 一致, trace header ok)") PY python3 /tmp/smoke_put.py - - name: Smoke 3/10 — PUTs did not trigger shadow + similar paths miss the Go handler + - name: "Smoke 3/13 — Phase A: PUTs did not trigger shadow + similar paths miss the Go handler" run: | cat > /tmp/smoke_boundaries.py <<'PY' import json @@ -458,7 +575,8 @@ jobs: # 相似路径不得误命中 Go handler(exact path 匹配):这些路径回落 # /api/ legacy 由 NestJS 处理——NestJS 未注册这些路由 → 404。 for path in ("/api/user-settings/ui/onboarding-x", - "/api/user-settings/ui/onboarding/other"): + "/api/user-settings/ui/war-map-x", + "/api/user-settings/ui/newsnow/other"): req = urllib.request.Request( f"http://127.0.0.1:4020{path}", headers={"authorization": f"Bearer {token}", "x-trace-id": TRACE}) @@ -473,118 +591,233 @@ jobs: PY python3 /tmp/smoke_boundaries.py - - name: Smoke 4/10 — settings really persisted in MySQL + - name: "Smoke 4/13 — Phase A: all 8 fixed keys really persisted in MySQL" run: | set -euo pipefail MYSQL_CID=$(docker ps -q -f ancestor=mysql:8.4 | head -n1) test -n "$MYSQL_CID" - for key in ui:onboarding:settings:v1 ui:rss-reader:settings:v1 ui:spacetime-timeline:settings:v1; do + for key in ui:onboarding:settings:v1 \ + ui:rss-reader:settings:v1 \ + ui:spacetime-timeline:settings:v1 \ + ui:war-map:settings:v1 \ + ui:newsnow:settings:v1 \ + ui:situation-monitor:monitors:v1 \ + ui:situation-monitor:layout:v1 \ + ui:situation-monitor:settings:v1; do count=$(docker exec "$MYSQL_CID" mysql -uroot -psecret -N -e \ "SELECT COUNT(*) FROM app.UserSetting WHERE \`key\`='${key}'" 2>/dev/null) echo "UserSetting key=${key} rows=${count}" test "$count" = "1" done - echo "three settings rows persisted by NestJS (single-writer) in real MySQL" + echo "all 8 fixed setting rows persisted by NestJS (single-writer) in real MySQL" + + # ---- Phase B:六端点 Shadow 对比 ---- - - name: Smoke 5/10 — onboarding contract compare (Go handler vs NestJS) + other GETs + - name: "Smoke 5/13 — Phase B: six GETs via shadow (executed +6, diffs 0)" run: | - cat > /tmp/smoke_get.py <<'PY' + cat > /tmp/smoke_shadow_get.py <<'PY' import json + import time import urllib.error import urllib.request + DROPPED_KEYS = ("request-too-large", "response-too-large", "streaming-skipped", + "concurrency-limit", "rate-limit", "timeout") TRACE = "5f1e2d3c4b5a69788697a5b4c3d2e1f0" token = open("/tmp/smoke-jwt").read().strip() + baseline = json.load(open("/tmp/shadow-baseline.json")) - EXPECTED = { - "/api/user-settings/ui/onboarding": { - "completed": True, - "dismissed": True, - "checklist": {"today": True, "events": True, "map": True, "finance": False}, - "completedTours": {"today": True}, - }, - "/api/user-settings/ui/rss-reader": { - "selectedSourceIds": ["src-alpha", "src-beta"], - "sourceLanguageFilters": ["EN", "ZH"], - "translationEnabled": True, - "translationProvider": "llm", - "targetLanguage": "en-US", - "showOriginalContent": True, - }, - "/api/user-settings/ui/spacetime-timeline": { - "authoritativeLock": False, - "requireCorroborated": False, - "sourceType": "mixed", - "sortBy": "latest", - "minHeatScore": 3.5, - "minCredibilityScore": 72, - "timelineGranularity": "week", - "speed": 4, - "syncStatusAutoRefresh": False, - }, - } + PATHS = [ + "/api/user-settings/ui/onboarding", + "/api/user-settings/ui/rss-reader", + "/api/user-settings/ui/spacetime-timeline", + "/api/user-settings/ui/war-map", + "/api/user-settings/ui/newsnow", + "/api/user-settings/ui/situation-monitor", + ] + + # 公开探针(healthz/live shadow 单元)先做一次——与六个端点一起 + # 计入 executed 增量(+1)。 + req = urllib.request.Request("http://127.0.0.1:4020/api/healthz/live", + headers={"x-trace-id": TRACE}) + with urllib.request.urlopen(req, timeout=15) as resp: + assert resp.status == 200 and json.loads(resp.read()) == {"status": "ok"} - def get(url, auth=True): - headers = {"x-trace-id": TRACE} - if auth: - headers["authorization"] = f"Bearer {token}" - req = urllib.request.Request(url, headers=headers) + for path in PATHS: + req = urllib.request.Request( + f"http://127.0.0.1:4020{path}", + headers={"authorization": f"Bearer {token}", "x-trace-id": TRACE}) try: with urllib.request.urlopen(req, timeout=15) as resp: - return resp.status, resp.headers, resp.read() + status, headers, body = resp.status, resp.headers, resp.read() except urllib.error.HTTPError as err: - raise SystemExit(f"GET {url} returned {err.code}: {err.read()[:500]!r}") - - # GET 1:公开探针(无鉴权,shadow 单元——NestJS 响应 + Go 差分)。 - status, headers, body = get("http://127.0.0.1:4020/api/healthz/live", auth=False) - assert status == 200, status - assert headers.get("x-trace-id") == TRACE, dict(headers) - assert json.loads(body) == {"status": "ok"}, body - print("GET /api/healthz/live -> 200 (shadow: NestJS 响应), body ok, trace header ok") - - # GET 2:onboarding 契约对比——同一 token 分别请求 NestJS 直连与 - # api-go(Go handler 全响应)。二者必须逐字段一致(同一条 - # UserSetting 记录 → updatedAt 也一致;仅 traceId/timestamp 属于 - # 非确定字段,且此处根本不比较响应头之外的非确定字段)。 - s_nest, h_nest, b_nest = get("http://127.0.0.1:4000/api/user-settings/ui/onboarding") - s_go, h_go, b_go = get("http://127.0.0.1:4020/api/user-settings/ui/onboarding") - assert s_nest == s_go == 200, (s_nest, s_go) - assert "no-store" in (h_nest.get("cache-control") or "").lower(), dict(h_nest) - assert "no-store" in (h_go.get("cache-control") or "").lower(), dict(h_go) - assert (h_nest.get("content-type") or "").startswith("application/json"), dict(h_nest) - assert (h_go.get("content-type") or "").startswith("application/json"), dict(h_go) - assert h_go.get("x-trace-id") == TRACE, dict(h_go) - doc_nest, doc_go = json.loads(b_nest), json.loads(b_go) - assert doc_nest == doc_go, ( - f"onboarding contract mismatch\n nestjs: {json.dumps(doc_nest, sort_keys=True)}\n" - f" go: {json.dumps(doc_go, sort_keys=True)}") - assert doc_go.get("version") == 1, doc_go - assert doc_go.get("settings") == EXPECTED["/api/user-settings/ui/onboarding"], doc_go - assert isinstance(doc_go.get("updatedAt", {}).get("settings"), str) and doc_go["updatedAt"]["settings"], doc_go - print("GET onboarding: NestJS 直连 vs api-go(Go handler) 契约一致" - "(status/cache-control/content-type/JSON 全等, settings 读回一致)") - - # GET 3-4:rss-reader / spacetime-timeline(仍是 shadow:NestJS - # 响应 + Go 差分)——鉴权 + Cache-Control + 数据读回。 - for path in ("/api/user-settings/ui/rss-reader", "/api/user-settings/ui/spacetime-timeline"): - status, headers, body = get(f"http://127.0.0.1:4020{path}") + raise SystemExit(f"GET {path} (shadow) returned {err.code}: {err.read()[:500]!r}") assert status == 200, (path, status) - cache_control = (headers.get("cache-control") or "").lower() - assert "no-store" in cache_control, (path, dict(headers)) + assert "no-store" in (headers.get("cache-control") or "").lower(), (path, dict(headers)) assert headers.get("x-trace-id") == TRACE, (path, dict(headers)) doc = json.loads(body) assert doc.get("version") == 1, (path, doc) - assert doc.get("settings") == EXPECTED[path], ( - f"{path}: settings mismatch\n got: {json.dumps(doc.get('settings'), sort_keys=True)}\n" - f" expected: {json.dumps(EXPECTED[path], sort_keys=True)}") - assert isinstance(doc.get("updatedAt", {}).get("settings"), str) and doc["updatedAt"]["settings"], doc - print(f"GET {path} -> 200 (shadow: NestJS 响应), cache-control=no-store, settings match own key") + print(f"GET {path} -> 200 (shadow: NestJS 响应 + Go 旁路差分)") + + # 等待异步差分完成:executed 精确 +7(healthz/live 1 + 六端点 6)。 + def stats(): + with urllib.request.urlopen("http://127.0.0.1:4020/__go/healthz", timeout=10) as resp: + return json.load(resp)["shadow"] + + want_executed = baseline["executed"] + 7 + deadline = time.time() + 30 + now = stats() + while time.time() < deadline: + now = stats() + if now["executed"] >= want_executed and now["inflight"] == 0: + break + time.sleep(1) + + assert now["executed"] == want_executed, ( + f"executed {baseline['executed']} -> {now['executed']}, want exactly +7 ({want_executed})") + assert now["diffs"] == baseline["diffs"], ( + f"diffs {baseline['diffs']} -> {now['diffs']} (shadow diff detected!)") + assert now["inflight"] == 0, f"inflight {now['inflight']} != 0" + for key in DROPPED_KEYS: + assert now["dropped"][key] == baseline["dropped"][key], ( + f"dropped[{key}] {baseline['dropped'][key]} -> {now['dropped'][key]}") + print(f"Phase B shadow metrics: executed {baseline['executed']} -> {now['executed']} " + f"(+7: healthz/live + 6 user-settings GETs)") + print(f"diffs: {baseline['diffs']} -> {now['diffs']} (0 差异——Go 旁路与 NestJS 逐字段一致)") + print(f"inflight back to 0; all dropped categories unchanged") + # 保存 Phase B 后的 shadow 计数,供 Phase C 基线。 + with open("/tmp/shadow-phase-b.json", "w") as f: + json.dump(now, f) PY - python3 /tmp/smoke_get.py + python3 /tmp/smoke_shadow_get.py + + # ---- Phase C:六端点 Go 接管 ---- + + - name: "Smoke 6/13 — Phase C: restart api-go with read mode=go" + run: | + set -euo pipefail + # 重启真实 api-go 容器为统一 go 模式(同一镜像、同一真实栈)。 + docker rm -f api-go-smoke + docker run -d --name api-go-smoke \ + --add-host=host.docker.internal:host-gateway \ + --health-interval 5s \ + --health-timeout 3s \ + --health-retries 20 \ + --health-start-period 5s \ + -p 127.0.0.1:4020:4020 \ + -e PORT=4020 \ + -e LEGACY_API_URL=http://host.docker.internal:4000 \ + -e DATABASE_URL=mysql://root:secret@host.docker.internal:3306/app \ + -e API_GO_USER_SETTINGS_READ_MODE=go \ + -e JWT_SECRET="$JWT_SECRET" \ + -e JWT_ISSUER="$JWT_ISSUER" \ + -e JWT_AUDIENCE="$JWT_AUDIENCE" \ + -e REDIS_HOST=host.docker.internal \ + -e REDIS_PORT=6379 \ + -e CANARY_PERCENT=0 \ + -e SHADOW_DEBUG_BODY_LOG=false \ + api-go-entry-smoke:ci + for i in $(seq 1 30); do + status=$(docker inspect --format '{{.State.Health.Status}}' api-go-smoke 2>/dev/null || echo starting) + echo "api-go container health: $status" + if [ "$status" = "healthy" ]; then + exit 0 + fi + if [ "$status" = "unhealthy" ]; then + docker logs api-go-smoke || true + exit 1 + fi + sleep 2 + done + echo "api-go container failed to become healthy" + docker logs api-go-smoke || true + exit 1 + + - name: "Smoke 7/13 — Phase C: six GETs contract compare (Go handler vs NestJS direct)" + run: | + cat > /tmp/smoke_go_get.py <<'PY' + import json + import urllib.error + import urllib.request + + TRACE = "6f1e2d3c4b5a69788697a5b4c3d2e1f0" + token = open("/tmp/smoke-jwt").read().strip() + + PATHS = [ + "/api/user-settings/ui/onboarding", + "/api/user-settings/ui/rss-reader", + "/api/user-settings/ui/spacetime-timeline", + "/api/user-settings/ui/war-map", + "/api/user-settings/ui/newsnow", + "/api/user-settings/ui/situation-monitor", + ] + + def get(url): + req = urllib.request.Request(url, headers={ + "authorization": f"Bearer {token}", "x-trace-id": TRACE}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as err: + raise SystemExit(f"GET {url} returned {err.code}: {err.read()[:500]!r}") + + for path in PATHS: + # 同一 token 分别请求 NestJS 直连与 api-go(Go handler 全响应)。 + # 二者必须逐字段一致(同一条 UserSetting 记录 → updatedAt 也 + # 一致;非确定字段 traceId/timestamp 不在成功响应体里)。 + s_nest, h_nest, b_nest = get(f"http://127.0.0.1:4000{path}") + s_go, h_go, b_go = get(f"http://127.0.0.1:4020{path}") + assert s_nest == s_go == 200, (path, s_nest, s_go) + assert "no-store" in (h_nest.get("cache-control") or "").lower(), (path, dict(h_nest)) + assert "no-store" in (h_go.get("cache-control") or "").lower(), (path, dict(h_go)) + assert (h_nest.get("content-type") or "").startswith("application/json"), (path, dict(h_nest)) + assert (h_go.get("content-type") or "").startswith("application/json"), (path, dict(h_go)) + assert h_go.get("x-trace-id") == TRACE, (path, dict(h_go)) + doc_nest, doc_go = json.loads(b_nest), json.loads(b_go) + assert doc_nest == doc_go, ( + f"{path}: contract mismatch\n nestjs: {json.dumps(doc_nest, sort_keys=True)[:2000]}\n" + f" go: {json.dumps(doc_go, sort_keys=True)[:2000]}") + assert doc_go.get("version") == 1, (path, doc_go) + print(f"GET {path}: NestJS 直连 vs api-go(Go handler) 契约一致" + "(status/cache-control/content-type/JSON 全等)") + + # situation-monitor 的三段 updatedAt 与三份数据对应正确(Phase A + # 写入了三段,全都在)+ 各端点读自己的 key(无跨 key 串读已由 + # 逐字段对比覆盖——这里针对三段结构显式断言)。 + s, h, b = get("http://127.0.0.1:4020/api/user-settings/ui/situation-monitor") + doc = json.loads(b) + for section in ("monitors", "layout", "settings"): + assert isinstance(doc.get(section), (dict, list)), (section, doc.get(section)) + assert isinstance(doc.get("updatedAt", {}).get(section), str) and doc["updatedAt"][section], doc + print("situation-monitor: 三段 updatedAt 与三份数据对应正确") + PY + python3 /tmp/smoke_go_get.py + + - name: "Smoke 8/13 — Phase C: Go requests do not increase shadow.executed" + run: | + cat > /tmp/smoke_go_shadow.py <<'PY' + import json + import urllib.request + + phaseB = json.load(open("/tmp/shadow-phase-b.json")) + with urllib.request.urlopen("http://127.0.0.1:4020/__go/healthz", timeout=10) as resp: + doc = json.load(resp) + now = doc["shadow"] + assert doc.get("userSettingsRead", {}).get("mode") == "go", doc + assert doc.get("userSettingsRead", {}).get("database") == "configured", doc + # Phase C 的六个 GET 全部由 Go handler 响应——shadow 计数从 Phase B + # 结束值零增长(重启后计数清零,但 Phase B 的 7 次执行发生在旧 + # 容器;新容器从 0 开始——断言当前为 0 且 mode=go)。 + assert now["executed"] == 0, now + assert now["inflight"] == 0, now + assert now["diffs"] == 0, now + print(f"Phase C: read mode=go; shadow executed={now['executed']} (zero — Go handler 全响应,无差分)") + PY + python3 /tmp/smoke_go_shadow.py # ---- 以下步骤对真实数据库/Redis 做有状态验证,全部先备份、后恢复 ---- - - name: Smoke 6/10 — no items.read in DB -> 403 on both sides (JWT claim ignored) + - name: "Smoke 9/13 — no items.read in DB -> 403 on both sides (JWT claim ignored)" run: | set -euo pipefail MYSQL_CID=$(docker ps -q -f ancestor=mysql:8.4 | head -n1) @@ -676,7 +909,7 @@ jobs: fi docker exec "$REDIS_CID" redis-cli DEL "profile:${USER_ID}:${ORG_ID}" >/dev/null - - name: Smoke 7/10 — membership inactive -> 401 on both sides (real DB state) + - name: "Smoke 10/13 — membership inactive -> 401 on both sides (real DB state)" run: | set -euo pipefail MYSQL_CID=$(docker ps -q -f ancestor=mysql:8.4 | head -n1) @@ -748,7 +981,7 @@ jobs: PY python3 /tmp/smoke_restored.py - - name: Smoke 8/10 — tampered signature & alg=none -> Go 401 + - name: "Smoke 11/13 — tampered signature & alg=none -> Go 401" run: | cat > /tmp/smoke_tamper.py <<'PY' import base64 @@ -791,7 +1024,7 @@ jobs: PY python3 /tmp/smoke_tamper.py - - name: Smoke 9/10 — real logout writes Redis blacklist -> Go 401 revoked + - name: "Smoke 12/13 — real logout writes Redis blacklist -> Go 401 revoked (Phase D preparation)" run: | set -euo pipefail REDIS_CID=$(docker ps -q -f ancestor=redis:7.2 | head -n1) @@ -842,10 +1075,12 @@ jobs: test "$COUNT" = "1" echo "access-token:blacklist: exists in real Redis (jti not printed)" - - name: Smoke 10/10 — stop NestJS, onboarding GET still 200 from Go + # ---- Phase D:独立性证明(NestJS 停止后)---- + + - name: "Smoke 13/13 (Phase D) — re-login, then stop NestJS and prove independence" run: | set -euo pipefail - # 重新真实登录(旧 token 已撤销)——确认数据仍在,然后停止 NestJS。 + # 重新真实登录(旧 token 已被 logout 撤销)——确认数据仍在,然后停止 NestJS。 cat > /tmp/smoke_relogin.py <<'PY' import json import os @@ -895,101 +1130,139 @@ jobs: import urllib.error import urllib.request - TRACE = "af1e2d3c4b5a69788697a5b4c3d2e1f0" + TRACE = "bf1e2d3c4b5a69788697a5b4c3d2e1f0" token = open("/tmp/smoke-jwt").read().strip() - EXPECTED_SETTINGS = { - "completed": True, - "dismissed": True, - "checklist": {"today": True, "events": True, "map": True, "finance": False}, - "completedTours": {"today": True}, + + EXPECTED = { + "/api/user-settings/ui/onboarding": {"settings": { + "completed": True, + "dismissed": True, + "checklist": {"today": True, "events": True, "map": True, "finance": False}, + "completedTours": {"today": True}}}, + "/api/user-settings/ui/rss-reader": {"settings": { + "selectedSourceIds": ["src-alpha", "src-beta"], + "sourceLanguageFilters": ["EN", "ZH"], + "translationEnabled": True, + "translationProvider": "llm", + "targetLanguage": "en-US", + "showOriginalContent": True}}, + "/api/user-settings/ui/spacetime-timeline": {"settings": { + "authoritativeLock": False, + "requireCorroborated": False, + "sourceType": "mixed", + "sortBy": "latest", + "minHeatScore": 3.5, + "minCredibilityScore": 72, + "timelineGranularity": "week", + "speed": 4, + "syncStatusAutoRefresh": False}}, + "/api/user-settings/ui/war-map": {"_partial": { + "viewState": {"lat": 90, "lon": -180, "zoom": 18, "bearing": 0, "pitch": 0}, + "activePreset": "mena", "timeRangePreset": "7d", + "flightMode": "all", "aisMode": "density", "aisHighlightCandidates": False}}, + "/api/user-settings/ui/newsnow": {"settings": { + "focusSources": ["src-A", "src-c"], + "columnOrders": {"zz-col": ["s2", "s1"], "aa-col": ["s0"]}, + "hideCrossSourceDuplicates": True, + "sortMode": "personalized", + "densityMode": "compact"}}, + "/api/user-settings/ui/situation-monitor": { + "monitors": [ + {"id": "mon-1", "name": "Taiwan Strait", "keywords": ["ship", "navy", "MISSING"], + "enabled": True, "color": "#ff00aa", + "location": {"name": "Taipei", "lat": 25.03, "lng": 121.56}, + "createdAt": 1757000000000}, + {"id": "Bad location", "name": "Bad location", "keywords": ["y"], "enabled": True}, + ], + "settings": {"windowHours": 24, "scope": "all", "autoRefresh": True, + "resetLayoutOnPreset": True, "translateToZh": True}}, } - # 本轮最重要的验收:NestJS 已停止,合法 onboarding GET 仍由 Go - # 返回此前真实持久化的数据——证明不是代理或 Shadow 假象。 + # Phase D 核心验收:NestJS 已停止,六个 user-settings GET 仍全部 + # 由 Go 返回此前真实持久化的数据(Phase A 的 PUT 写入)——证明 + # 不是代理或 Shadow 假象。 + for path, want in EXPECTED.items(): + req = urllib.request.Request( + f"http://127.0.0.1:4020{path}", + headers={"authorization": f"Bearer {token}", "x-trace-id": TRACE}) + with urllib.request.urlopen(req, timeout=15) as resp: + assert resp.status == 200, (path, resp.status) + assert "no-store" in (resp.headers.get("cache-control") or "").lower(), (path, dict(resp.headers)) + assert (resp.headers.get("content-type") or "").startswith("application/json"), (path, dict(resp.headers)) + assert resp.headers.get("x-trace-id") == TRACE, (path, dict(resp.headers)) + doc = json.loads(resp.read()) + assert doc.get("version") == 1, (path, doc) + if "_partial" in want: + for key, value in want["_partial"].items(): + assert doc.get(key) == value, (path, key, doc.get(key), value) + else: + for key, value in want.items(): + assert doc.get(key) == value, (path, key, doc.get(key), value) + assert isinstance(doc.get("updatedAt", {}).get("settings"), str) and doc["updatedAt"]["settings"], (path, doc) + print(f"NestJS 停止后:GET {path} via api-go -> 200(Go handler 独立响应, 数据一致)") + + # situation-monitor 三段 updatedAt 全在(Phase A 写入三段)。 req = urllib.request.Request( - "http://127.0.0.1:4020/api/user-settings/ui/onboarding", + "http://127.0.0.1:4020/api/user-settings/ui/situation-monitor", headers={"authorization": f"Bearer {token}", "x-trace-id": TRACE}) with urllib.request.urlopen(req, timeout=15) as resp: - assert resp.status == 200, resp.status - assert "no-store" in (resp.headers.get("cache-control") or "").lower(), dict(resp.headers) - assert (resp.headers.get("content-type") or "").startswith("application/json"), dict(resp.headers) - assert resp.headers.get("x-trace-id") == TRACE, dict(resp.headers) doc = json.loads(resp.read()) - assert doc.get("version") == 1, doc - assert doc.get("settings") == EXPECTED_SETTINGS, doc - assert isinstance(doc.get("updatedAt", {}).get("settings"), str) and doc["updatedAt"]["settings"], doc - print("NestJS 停止后:GET onboarding via api-go -> 200(Go handler 独立响应)") - print(f" cache-control=no-store, content-type ok, trace header ok, settings 一致, updatedAt ok") - - # 反证:未迁移端点(rss-reader,shadow → NestJS)必须因上游死亡 - # 而失败——证明 onboarding 的成功不是 NestJS 仍在后台存活。 + for section in ("monitors", "layout", "settings"): + assert doc["updatedAt"].get(section), doc + print("situation-monitor: 三段 updatedAt 均存在(NestJS 停止后仍由 Go 聚合返回)") + + # 反证 1:代表性 PUT(war-map)必须代理失败——写路径没有偷偷迁入 Go。 + req = urllib.request.Request( + "http://127.0.0.1:4020/api/user-settings/ui/war-map", + data=json.dumps({"settings": {"activePreset": "eu"}}).encode(), + method="PUT", + headers={"authorization": f"Bearer {token}", "content-type": "application/json", + "x-trace-id": TRACE}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raise SystemExit(f"PUT war-map unexpectedly succeeded ({resp.status}) — 写路径被迁入 Go?") + except urllib.error.HTTPError as err: + assert err.code == 502, (err.code, err.read()[:300]) + print("NestJS 停止后:PUT war-map(NestJS 单写)-> 502(写路径未迁入 Go)") + + # 反证 2:未迁移业务 GET(/api/items)必须代理失败——api-go 没有 + # 伪装所有请求成功。 req = urllib.request.Request( - "http://127.0.0.1:4020/api/user-settings/ui/rss-reader", + "http://127.0.0.1:4020/api/items", headers={"authorization": f"Bearer {token}", "x-trace-id": TRACE}) try: with urllib.request.urlopen(req, timeout=15) as resp: - raise SystemExit(f"rss-reader unexpectedly succeeded ({resp.status}) — NestJS 可能未停止") + raise SystemExit(f"GET /api/items unexpectedly succeeded ({resp.status}) — api-go 伪装成功?") except urllib.error.HTTPError as err: - # 502 = api-go 代理层对上游不可达的契约错误(非 200 伪装成功)。 assert err.code == 502, (err.code, err.read()[:300]) - print("NestJS 停止后:GET rss-reader(shadow,未迁移)-> 502(上游不可达,未伪装成功)") + print("NestJS 停止后:GET /api/items(未迁移路由)-> 502(未伪装成功)") PY python3 /tmp/smoke_takeover.py # api-go 容器保持 healthy(Go 端点与探活不依赖 NestJS)。 HEALTH=$(docker inspect --format '{{.State.Health.Status}}' api-go-smoke) test "$HEALTH" = "healthy" echo "api-go container still healthy after NestJS stopped" - # 最终 shadow 指标:onboarding 全程不产生 shadow 执行;RSS/ - # Spacetime/healthz.live 各执行一次 = 基线 +3;diffs/dropped 零增量。 + # 最终 shadow 指标:go 接管的六个 GET 全程零 shadow 执行;Phase C + # 容器重启后 shadow 从 0 开始且保持 0(本 smoke 中 Phase B 的 + # executed 计数属于上一个容器实例)。 cat > /tmp/smoke_final.py <<'PY' import json - import time import urllib.request - DROPPED_KEYS = ("request-too-large", "response-too-large", "streaming-skipped", - "concurrency-limit", "rate-limit", "timeout") - baseline = json.load(open("/tmp/shadow-baseline.json")) - # 本 smoke 的 shadow 执行:healthz/live + rss-reader(NestJS 存活期) - # + spacetime-timeline = 3。onboarding 全部由 Go handler 响应—— - # executed 不含 onboarding;NestJS 停止后的 rss-reader 502 不执行。 - want_executed = baseline["executed"] + 3 - - def health(): - with urllib.request.urlopen("http://127.0.0.1:4020/__go/healthz", timeout=10) as resp: - return json.load(resp) - - # shadow 执行是异步的——轮询到目标值且 inflight 归零。 - deadline = time.time() + 30 - doc = health() - while time.time() < deadline: - now = doc["shadow"] - if now["executed"] >= want_executed and now["inflight"] == 0: - break - time.sleep(1) - doc = health() + with urllib.request.urlopen("http://127.0.0.1:4020/__go/healthz", timeout=10) as resp: + doc = json.load(resp) now = doc["shadow"] - - assert now["executed"] == want_executed, ( - f"executed {baseline['executed']} -> {now['executed']}, want exactly +3 ({want_executed})") - assert now["diffs"] == baseline["diffs"], ( - f"diffs {baseline['diffs']} -> {now['diffs']} (shadow diff detected!)") - assert now["inflight"] == 0, f"inflight {now['inflight']} != 0" - for key in DROPPED_KEYS: - assert now["dropped"][key] == baseline["dropped"][key], ( - f"dropped[{key}] {baseline['dropped'][key]} -> {now['dropped'][key]}") - assert sum(now["dropped"].values()) == sum(baseline["dropped"].values()), now["dropped"] - assert doc.get("userSettingsShadow", {}).get("database") == "configured", doc - assert doc.get("onboarding", {}).get("mode") == "go", doc - - print("=== api-go entry smoke (Go-批3A): FINAL SHADOW METRICS (before -> after) ===") - print(f"executed: {baseline['executed']} -> {now['executed']} " - f"(delta +{now['executed'] - baseline['executed']}, expect 3: live+rss+spacetime, onboarding excluded)") - print(f"diffs: {baseline['diffs']} -> {now['diffs']} (delta 0)") - print(f"inflight: {baseline['inflight']} -> {now['inflight']} (back to 0)") - print(f"dropped: {json.dumps(now['dropped'], sort_keys=True)} " - f"(all categories unchanged, total {sum(now['dropped'].values())})") - print("onboarding.mode: go (全响应,零 shadow 执行)") - print("userSettingsShadow.database: configured") + assert doc.get("userSettingsRead", {}).get("mode") == "go", doc + assert doc.get("userSettingsRead", {}).get("database") == "configured", doc + assert now["executed"] == 0, now + assert now["diffs"] == 0, now + assert now["inflight"] == 0, now + assert sum(now["dropped"].values()) == 0, now["dropped"] + print("=== api-go entry smoke (Go-批3B): FINAL METRICS ===") + print("executed: 0 (Phase C/D 六端点全部 Go handler 全响应,零 shadow 执行)") + print("diffs: 0 | inflight: 0 | dropped total: 0") + print("userSettingsRead.mode: go") + print("userSettingsRead.database: configured") print("api-go entry smoke: ALL ASSERTIONS PASSED") PY python3 /tmp/smoke_final.py From 7dfb95842483356b8bb34671d57e79f25def11f8 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:32:54 +0800 Subject: [PATCH 06/11] =?UTF-8?q?docs(api-go):=20=E6=89=B93B=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E5=85=AD=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20Go=20=E6=8E=A5=E7=AE=A1=E3=80=81PUT=20=E5=8D=95?= =?UTF-8?q?=E5=86=99=E8=BE=B9=E7=95=8C=E3=80=81=E5=9B=9E=E6=BB=9A=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compose api-go pilot:API_GO_USER_SETTINGS_READ_MODE=go(优先级高于 批3A 变量,后者保留 go 兼容);.env.example/.env.sample 登记新变量 与兼容优先级 - apps/api-go/README:统一接管章节(替换批3A 单端点叙述——不并存)、 配置表新变量、四态路由表、四阶段 smoke 描述、回滚(shadow 或删除 变量回兼容行为) - 根 README:pilot 章节、流量去向、验证状态(四阶段 + 明确生产/预发 流量未切换、不代表 NestJS 迁移完成) - ADR §4.4:统一接管决策记录(绑定表非注册框架、8 key 封闭集合、 有序对象语义、四阶段验收、边界如实登记) - four-mode/contract-inventory/roadmap:六 GET=ModeGo、六 PUT=Legacy、 API_GO_USER_SETTINGS_READ_MODE 优先级与兼容语义 --- .env.example | 10 +++ README.md | 23 ++--- apps/api-go/README.md | 107 +++++++++++++----------- docs/refactor/api-contract-inventory.md | 6 +- docs/refactor/api-go-four-mode.md | 18 ++-- docs/refactor/go-migration-adr.md | 50 +++++++++-- docs/refactor/roadmap.md | 1 + infra/docker/.env.sample | 8 ++ infra/docker/docker-compose.yml | 32 ++++--- 9 files changed, 164 insertions(+), 91 deletions(-) diff --git a/.env.example b/.env.example index b45d8cf5..92858e69 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,16 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api # Redis blacklist + MySQL RBAC)并全响应——需同时提供 JWT_SECRET、 # DATABASE_URL、REDIS_HOST(缺失启动失败)。非法值启动失败。 API_GO_ONBOARDING_MODE=shadow +# user-settings 六个只读 GET 的统一读模式(Go-批3B,api-go 网关消费)。 +# 优先级:本变量设置时覆盖 API_GO_ONBOARDING_MODE 对 onboarding 的控制。 +# shadow=六个 GET 全部 NestJS 响应 + Go 差分;go=六个 GET 全部由统一 Go +# handler 接管(Go 独立 JWT 验签 + Redis blacklist + MySQL RBAC + 独立 +# 查库 + normalization)。未设置(空)=兼容旧行为:onboarding 由 +# API_GO_ONBOARDING_MODE 控制,rss/spacetime 保持 shadow,war-map/ +# newsnow/situation-monitor 保持 legacy。go 模式要求 JWT_SECRET、 +# DATABASE_URL、REDIS_HOST 齐备(缺失启动失败)。非法值启动失败。 +# 回滚=改回 shadow 或删除本变量(无数据迁移耦合)。 +API_GO_USER_SETTINGS_READ_MODE= NEXT_PUBLIC_GRAPHQL_APQ_ENABLED=true NEXT_PUBLIC_LLM_GATEWAY_DEFAULT_API_BASE=http://localhost:4001 NODE_ENV=development diff --git a/README.md b/README.md index 7474bb64..3218ecd7 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ pnpm --filter infra-scripts run env:check - 数据库:`DATABASE_URL`(可选,宿主机优先)、`MYSQL_*`、`MONGO_URI`、`REDIS_*` - 登录与会话:`JWT_SECRET`、`NEXTAUTH_SECRET`、`NEXTAUTH_URL`。`NEXTAUTH_SECRET` 只在运行时注入(compose `env_file`),不要作为 Docker build ARG,以免进入 `docker history` - Web ↔ API:`NEXT_PUBLIC_API_BASE_URL`(浏览器访问 API)、`API_BASE_URL`(服务端访问 API,可选) -- API 入口试点(Go-批2C/批3A):`API_GO_HOST_PORT`(api-go 容器 host 侧端口,默认 4020,默认只绑 loopback)、`API_GO_IMAGE`(构建基础镜像)、`API_GO_ONBOARDING_MODE`(onboarding GET 模式:`shadow` 默认 / `go` 由 pilot compose 注入——Go 独立鉴权接管,回滚改回 `shadow`)。启用/切流/回滚见下方「api-go 入口试点」 +- API 入口试点(Go-批2C/批3A/批3B):`API_GO_HOST_PORT`(api-go 容器 host 侧端口,默认 4020,默认只绑 loopback)、`API_GO_IMAGE`(构建基础镜像)、`API_GO_USER_SETTINGS_READ_MODE`(user-settings 六个只读 GET 的统一读模式:空=兼容旧行为 / `shadow`=六端点全部 NestJS 响应 + Go 差分 / `go`=六端点全部由 Go 独立鉴权接管——pilot compose 注入 `go`,优先级高于 `API_GO_ONBOARDING_MODE`,回滚改回 `shadow` 或删除)、`API_GO_ONBOARDING_MODE`(批3A 兼容变量:read mode 未设时控制 onboarding)。启用/切流/回滚见下方「api-go 入口试点」 - 抓取:`CRAWL4AI_BASE_URL`、`CRAWL4AI_DASHBOARD_URL`、`CRAWL4AI_SSRF_PROXY_URL`、`CRAWL4AI_*` - LLM 网关:`LITELLM_API_BASE`、`LITELLM_API_KEY`、`LITELLM_MASTER_KEY`、`LITELLM_MODEL`、`LITELLM_EMBEDDING_MODEL`。Docker 栈中 `LITELLM_MASTER_KEY` 必填(`openssl rand -hex 32`),空值时代理直接退出 - Docker 端口绑定:`DOCKER_PUBLISH_HOST`(默认 `127.0.0.1`,仅本机可达;需要局域网访问时设为 `0.0.0.0`) @@ -362,7 +362,7 @@ docker compose --env-file infra/docker/.env -f infra/docker/docker-compose.yml u 详细接口、降级原因码和环境变量说明见 [apps/ais-relay/README.md](./apps/ais-relay/README.md)。 -## api-go 入口试点(Go-批2C · Go-批3A 起 onboarding 真实接管) +## api-go 入口试点(Go-批2C · 批3A 起 onboarding 真实接管 · 批3B 起 user-settings 只读域接管) `apps/api-go` 是主后端的 Go 网关(Strangler Fig),当前以独立 pilot 运行。**默认部署不启动它**——Web 与 API 入口仍直连 NestJS `api:4000`: @@ -377,16 +377,17 @@ docker compose --env-file infra/docker/.env -f infra/docker/docker-compose.yml \ --profile api-go-pilot up -d api-go ``` -api-go 容器(distroless nonroot,端口 4020,healthcheck 为 `/api-go healthcheck` 子命令)依赖 `api` 与 `mysql`、`redis` healthy——同一真实 MySQL 与同一 Redis(blacklist 共享),`LEGACY_API_URL=http://api:4000`,`API_GO_ONBOARDING_MODE=go`(onboarding GET 由 Go 接管),`CANARY_PERCENT=0`,`SHADOW_DEBUG_BODY_LOG=false`。 +api-go 容器(distroless nonroot,端口 4020,healthcheck 为 `/api-go healthcheck` 子命令)依赖 `api` 与 `mysql`、`redis` healthy——同一真实 MySQL 与同一 Redis(blacklist 共享),`LEGACY_API_URL=http://api:4000`,`API_GO_USER_SETTINGS_READ_MODE=go`(六个 user-settings 只读 GET 由统一 Go handler 接管;`API_GO_ONBOARDING_MODE=go` 保留为批3A 兼容),`CANARY_PERCENT=0`,`SHADOW_DEBUG_BODY_LOG=false`。 ### 切流(pilot 模式) ```text Web → api-go:4020 → NestJS api:4000(登录/PUT/未迁移路由) - ↘ Go Shadow → MySQL(rss-reader/spacetime-timeline 只读差分) - → Go 全响应:GET /api/user-settings/ui/onboarding - (Go 独立 JWT 验签 + Redis blacklist + MySQL RBAC + 独立查库; - NestJS 停止后仍可用) + ↘ Go Shadow → MySQL(read mode=shadow 时的只读差分) + → Go 全响应:GET /api/user-settings/ui/{onboarding,rss-reader, + spacetime-timeline,war-map,newsnow,situation-monitor} + (统一 Go handler:JWT 验签 + Redis blacklist + MySQL RBAC + + 独立查库 + normalization;NestJS 停止后仍可用) ``` 1. 服务端:`infra/docker/.env` 中 `API_BASE_URL=http://api-go:4020`,然后 `docker compose ... up -d web`(运行期变量,无需重建镜像;web 启动等待会自动探测 `http://api-go:4020/api/healthz/live`)。 @@ -394,15 +395,15 @@ Web → api-go:4020 → NestJS api:4000(登录/PUT/未迁移路由) ### 回滚到 NestJS -三选一或组合:① `API_GO_ONBOARDING_MODE=shadow`——onboarding GET 回到 NestJS 响应 + Go 差分;② `API_BASE_URL` 指回 `http://api:4000` 并按原值重建 web;③ `docker compose ... down api-go`(或去掉 profile)停掉 pilot。**无数据迁移耦合**——user-settings 的全部写入始终只有 NestJS 单写,api-go 不持有任何独立数据。 +可组合:① `API_GO_USER_SETTINGS_READ_MODE=shadow`——六个 user-settings 只读 GET 回到 NestJS 响应 + Go 差分(或删除该变量:onboarding 由 `API_GO_ONBOARDING_MODE` 控制、其余端点回到批3B 前去向——批3A/批2 行为);② `API_BASE_URL` 指回 `http://api:4000` 并按原值重建 web;③ `docker compose ... down api-go`(或去掉 profile)停掉 pilot。**无数据迁移耦合**——user-settings 的全部写入(六个 PUT)始终只有 NestJS 单写,api-go 不持有任何独立数据。 -### 流量去向(Go-批3A 后) +### 流量去向(Go-批3B 后) -经 api-go 的请求:**`GET /api/user-settings/ui/onboarding` 由 Go 全响应**(独立鉴权:HS256 JWT 验签 + Redis blacklist + MySQL membership/RBAC 重推导 + `items.read` 判定——JWT 内 permissions claim 不参与授权);`GET /api/healthz/live`、`GET /api/user-settings/ui/rss-reader`、`GET /api/user-settings/ui/spacetime-timeline` 进入 Go Shadow 差分(NestJS 响应,Go 旁路读主库比对);**其余全部请求(含全部 PUT、登录/refresh/logout、其他 GET)纯代理回 NestJS**。canary 接管仍未激活(canary router 依赖未验签 claim,`CANARY_PERCENT` 固定 0)。 +经 api-go 的请求:**六个 user-settings 只读 GET(onboarding / rss-reader / spacetime-timeline / war-map / newsnow / situation-monitor)由 Go 全响应**(统一 handler:HS256 JWT 验签 + Redis blacklist + MySQL membership/RBAC 重推导 + `items.read` 判定——JWT 内 permissions claim 不参与授权;独立查库 + normalization,NestJS 停止后仍可用);`GET /api/healthz/live` 仍为 Go Shadow 差分(`read mode=shadow` 时六个 GET 也全部 shadow);**其余全部请求(含六个 PUT、登录/refresh/logout/MFA/OIDC、其他 GET)纯代理回 NestJS**。canary 接管仍未激活(canary router 依赖未验签 claim,`CANARY_PERCENT` 固定 0)。 ### 验证状态 -该入口链已在 GitHub Actions 远端真实栈验证(真实 MySQL/Redis + migration + 真实 NestJS + api-go 容器 + 真实登录 JWT:手动触发 `api-go-entry-smoke` workflow)。Go-批3A 验收包括:onboarding 契约对比(NestJS 直连 vs Go handler 全等)、数据库无权限时(JWT claim 仍有)双端 403、membership 停用双端 401、真实 logout 撤销 → Go 401、篡改签名/alg=none → 401、**停止 NestJS 后 onboarding GET 仍 200**、未迁移端点 502。**生产/预发布真实流量验证未完成**。详见 [apps/api-go/README.md](./apps/api-go/README.md)。 +该入口链已在 GitHub Actions 远端真实栈验证(真实 MySQL/Redis + migration + 真实 NestJS + api-go 容器 + 真实登录 JWT:手动触发 `api-go-entry-smoke` workflow)。Go-批3B 验收分四阶段:Phase A 六个 PUT(NestJS 单写,8 个固定 key 落库)→ Phase B shadow 差分(executed 精确 +6、diffs 零增量)→ Phase C Go 接管契约对比(六端点 NestJS 直连 vs Go handler 全等)→ **Phase D 停止 NestJS 后六个 GET 仍全部 200**(代表性 PUT 与未迁移 GET 返回 502,证明写路径未迁入 Go、api-go 不伪装成功)。共享鉴权链负向用例(数据库无权限双端 403、membership 停用双端 401、logout 撤销 → 401、篡改签名/alg=none → 401)沿用批3A 验证。**生产/预发布真实流量验证未完成**(api-go 仍是 pilot 入口,未接入生产流量)。这也不代表整个 NestJS 已迁移完成——登录/refresh/logout/MFA/OIDC/机器令牌与全部写路径仍在 NestJS。详见 [apps/api-go/README.md](./apps/api-go/README.md)。 ## 并发控制说明 diff --git a/apps/api-go/README.md b/apps/api-go/README.md index dd9e8277..a0e1c894 100644 --- a/apps/api-go/README.md +++ b/apps/api-go/README.md @@ -2,17 +2,17 @@ NestJS `apps/api` 的渐进替代入口。默认全部流量反向代理到 NestJS(`LEGACY_API_URL`,默认 `http://localhost:4000`);已迁移路由按四态路由表分流。详细语义见 `docs/refactor/api-go-four-mode.md`。 -Go-批3A 起,`GET /api/user-settings/ui/onboarding` 在 pilot 中可由 **Go 独立鉴权并全响应**(`API_GO_ONBOARDING_MODE=go`,见下文「onboarding Go 接管」)。 +Go-批3B 起,**user-settings 六个只读 GET**(`onboarding` / `rss-reader` / `spacetime-timeline` / `war-map` / `newsnow` / `situation-monitor`)在 pilot 中由统一 Go handler **真实接管**(`API_GO_USER_SETTINGS_READ_MODE=go`,见下文「user-settings 只读域 Go 接管」):Go 独立 JWT 验签 + Redis blacklist + MySQL RBAC + 独立查库 + normalization + 全响应。六个 PUT 仍全部由 NestJS 单写。 ## 运行 ```bash PORT=4020 LEGACY_API_URL=http://localhost:4000 go run ./cmd/api -curl http://localhost:4020/__go/healthz # {"ok":true,"routes":[...],"shadow":{...},"canary":{...},"onboarding":{...}} +curl http://localhost:4020/__go/healthz # {"ok":true,"routes":[...],"shadow":{...},"canary":{...},"onboarding":{...},"userSettingsRead":{...}} curl http://localhost:4020/api/healthz/live # shadow 态:NestJS 响应 + Go 异步差分 ``` -手工裸启(默认 `API_GO_ONBOARDING_MODE=shadow`)行为与批2C 完全一致——onboarding 仍是 shadow 差分,无 JWT/Redis 依赖。 +手工裸启(`API_GO_USER_SETTINGS_READ_MODE` 未设、`API_GO_ONBOARDING_MODE=shadow`)行为与批2C 完全一致——onboarding/rss/spacetime 仍是 shadow 差分,war-map/newsnow/situation-monitor 保持 legacy,无 JWT/Redis 依赖。 ## 生产容器与真实入口(Go-批2C) @@ -33,7 +33,7 @@ docker compose --env-file infra/docker/.env -f infra/docker/docker-compose.yml \ - 端口:容器 4020,host `${API_GO_HOST_PORT:-4020}`(默认只绑 `DOCKER_PUBLISH_HOST`,即 loopback); - `LEGACY_API_URL=http://api:4000`;`DATABASE_URL` 由与 NestJS 相同的 `MYSQL_*` 派生(同一真实 MySQL,不复制数据); -- `API_GO_ONBOARDING_MODE=go`(pilot 明确接管 onboarding GET)+ 与 NestJS 同源的 `JWT_SECRET`/`JWT_ISSUER`/`JWT_AUDIENCE` 与 `REDIS_*`(blacklist 共享,不建第二套撤销名单); +- `API_GO_USER_SETTINGS_READ_MODE=go`(Go-批3B 统一读模式:pilot 明确接管六个 user-settings 只读 GET;优先级高于 `API_GO_ONBOARDING_MODE`,后者仍注入 `go` 保持批3A 兼容)+ 与 NestJS 同源的 `JWT_SECRET`/`JWT_ISSUER`/`JWT_AUDIENCE` 与 `REDIS_*`(blacklist 共享,不建第二套撤销名单); - `CANARY_PERCENT=0`、`SHADOW_DEBUG_BODY_LOG=false` 固定; - 依赖 `api`(NestJS)与 `mysql`、`redis` 均 healthy; - 默认 legacy 模式(`Web → api:4000`)不受影响——profile 服务不随普通 `up` 启动。 @@ -42,13 +42,20 @@ docker compose --env-file infra/docker/.env -f infra/docker/docker-compose.yml \ - 服务端(运行期):`infra/docker/.env` 的 `API_BASE_URL=http://api-go:4020` → `Web → api-go → NestJS`;web 启动等待自动改探 `http://api-go:4020/api/healthz/live`(不再硬编码 `api:4000`,兼容 base 带不带 `/api`)。 - 浏览器端(构建期):`NEXT_PUBLIC_API_BASE_URL=http://:4020/api` 重建 web 镜像。 -- 回滚(三选一或组合):① `API_GO_ONBOARDING_MODE=shadow`——onboarding GET 回到 NestJS 响应 + Go 差分(批2A/2B 行为);② `API_BASE_URL` 指回 `http://api:4000`(+ 按原值重建 web);③ `--profile api-go-pilot down` 停 pilot。无数据迁移耦合——全部 user-settings PUT 始终由 NestJS 单写。 +- 回滚(可组合):① `API_GO_USER_SETTINGS_READ_MODE=shadow`——六个 user-settings GET 全部回到 NestJS 响应 + Go 差分(或删除该变量回到兼容行为:onboarding 由 `API_GO_ONBOARDING_MODE` 控制、rss/spacetime shadow、其余 legacy——批3A 及更早行为);② `API_BASE_URL` 指回 `http://api:4000`(+ 按原值重建 web);③ `--profile api-go-pilot down` 停 pilot。无数据迁移耦合——全部 user-settings PUT 始终由 NestJS 单写。 ### 远端真实栈 smoke(`api-go-entry-smoke` workflow) -手动触发,不进 push/synchronize 普通 CI。主路径 `workflow_dispatch`(workflow 在默认分支注册后 `gh workflow run`);PR 期间用 label `api-go-entry-smoke` 显式触发(与 ci.yml 的 regen label 门禁同一模式),运行后移除 label。真实 MySQL/Redis/Mongo service 容器 + 真实 `prisma migrate deploy` + 真实 NestJS 进程 + 构建并启动本 Dockerfile 的 api-go 容器(`API_GO_ONBOARDING_MODE=go`)。 +手动触发,不进 push/synchronize 普通 CI。主路径 `workflow_dispatch`(workflow 在默认分支注册后 `gh workflow run`);PR 期间用 label `api-go-entry-smoke` 显式触发(与 ci.yml 的 regen label 门禁同一模式),运行后移除 label。真实 MySQL/Redis/Mongo service 容器 + 真实 `prisma migrate deploy` + 真实 NestJS 进程 + 构建并启动本 Dockerfile 的 api-go 容器(两阶段:先 `API_GO_USER_SETTINGS_READ_MODE=shadow`,后重启为 `go`)。 -Go-批3A 起的验收步骤(全部经 api-go 入口 + 真实登录 JWT):真实登录 → 三个 PUT(NestJS 单写并持久化)→ 相似路径(onboarding-x / onboarding/other)不误命中 → onboarding 契约对比(NestJS 直连 vs Go handler:status/Cache-Control/content-type/JSON 全等)→ **真实数据库无 `items.read`(JWT claim 仍有)时双端 403 契约一致** → membership 停用时双端 401 同文案 → 篡改签名与 alg=none 拒绝 → 真实 logout 写入真实 Redis blacklist → 撤销 token 401 "Access token revoked" → **停止 NestJS 后 onboarding GET 仍 200(Go 独立接管证明)且未迁移端点(rss-reader)502(非伪装成功)** → 最终 shadow 指标:onboarding 全程零 shadow 执行、executed 精确 +3(healthz/live + rss-reader + spacetime-timeline)、diffs/dropped 零增量、inflight 归零、`onboarding.mode=go`、`userSettingsShadow.database=configured`。 +Go-批3B 起的四阶段验收(全部经 api-go 入口 + 真实登录 JWT): + +- **Phase A(写入准备)**:六个 PUT(含 situation-monitor 一次写入 monitors/layout/settings 三段)由 NestJS 单写 → MySQL 直查确认 8 个固定 key 均真实存在 → PUT 前后 shadow executed 不增加 → 相似路径(onboarding-x / war-map-x / newsnow/other)不误命中。 +- **Phase B(shadow 对比)**:`readMode=shadow` 启动——六个 GET 都由 NestJS 响应,Go 做真实旁路查询与差分:executed 精确 +6(+healthz/live 共 +7)、diffs 零增量、六类 dropped 零增量、inflight 归零——不通过删除字段、宽松比较制造零差异。 +- **Phase C(Go 接管)**:`readMode=go` 重启真实容器——六个 GET 与 NestJS 直连逐字段契约对比(status/`Cache-Control: no-store`/content-type/JSON 全等、各端点读自己的 key、situation-monitor 三段 updatedAt 对应正确);Go 请求不增加 shadow.executed。 +- **Phase D(独立性证明)**:停止 NestJS 并确认端口 4000 不可用——六个 GET 仍全部 200(数据是 Phase A 真实持久化的);代表性 PUT(war-map)返回 502(写路径未迁入 Go);未迁移 GET(/api/items)返回 502(api-go 不伪装成功);api-go healthcheck 保持健康。 + +共享鉴权链负向用例保留代表性端点(onboarding):数据库无 `items.read`(JWT claim 仍有)双端 403 契约一致 → membership 停用双端 401 同文案 → 篡改签名与 alg=none 拒绝 → 真实 logout 写入真实 Redis blacklist → 撤销 token 401 "Access token revoked"。 **验证状态分层**:静态代码与单元/MySQL+Redis 集成测试由普通 CI 远端验证;真实入口链(容器 + 真实 NestJS + 真实登录 + Go 鉴权链 + Shadow 指标增量)由 `api-go-entry-smoke` 远端真实栈运行验证完成;**生产/预发布真实流量验证未完成**(api-go 未接入任何生产入口)。 @@ -58,7 +65,8 @@ Go-批3A 起的验收步骤(全部经 api-go 入口 + 真实登录 JWT): |---|---|---| | `PORT` | 4020 | 网关监听端口 | | `LEGACY_API_URL` | http://localhost:4000 | NestJS apps/api 基址 | -| `API_GO_ONBOARDING_MODE` | shadow | onboarding GET 迁移单元模式:`shadow`(默认,批2A/2B 行为——NestJS 响应 + Go 差分)或 `go`(Go 独立鉴权 + 全响应)。非法值启动失败。`go` 模式要求 `JWT_SECRET`/`DATABASE_URL`/`REDIS_HOST` 齐备(缺失启动失败——不得起一个必然失败的接管端点);compose pilot 固定注入 `go`。回滚 = 改回 `shadow` | +| `API_GO_USER_SETTINGS_READ_MODE` | (空) | user-settings 六个只读 GET 的统一读模式(Go-批3B):`shadow`=六个 GET 全部 NestJS 响应 + Go 差分;`go`=六个 GET 全部由统一 Go handler 接管(独立鉴权 + 独立查库 + normalization + 全响应)。**设置时优先级高于 `API_GO_ONBOARDING_MODE`**(onboarding 也归它管);**未设置(空)=兼容旧行为**:onboarding 由 `API_GO_ONBOARDING_MODE` 控制,rss/spacetime 保持 shadow,war-map/newsnow/situation-monitor 保持 legacy(批3B 之前的部署不变)。非法值启动失败;`go` 模式要求 `JWT_SECRET`/`DATABASE_URL`/`REDIS_HOST` 齐备(缺失启动失败)。compose pilot 固定注入 `go`。回滚 = 改回 `shadow` 或删除本变量 | +| `API_GO_ONBOARDING_MODE` | shadow | onboarding GET 迁移单元模式(Go-批3A 兼容变量):`shadow`(默认,批2A/2B 行为——NestJS 响应 + Go 差分)或 `go`(Go 独立鉴权 + 全响应)。仅在 `API_GO_USER_SETTINGS_READ_MODE` 未设置时生效。非法值启动失败;`go` 模式依赖同上;compose pilot 固定注入 `go`(批3A 部署等价) | | `JWT_SECRET` | (空) | NestJS access token 的 HMAC 验签 secret(与 api 服务同一值)。仅 `go` 模式必填。值不进入日志/healthz/错误文本 | | `JWT_ISSUER` | modular-monolith | 与 NestJS env schema 同默认值;`go` 模式下用于验签 | | `JWT_AUDIENCE` | modular-monolith-clients | 同上 | @@ -74,7 +82,7 @@ Go-批3A 起的验收步骤(全部经 api-go 入口 + 真实登录 JWT): | `SHADOW_DEBUG_BODY_LOG` | false | 差异记录是否保存截断正文(默认只记 sha256 hash) | | `SHADOW_DEBUG_BODY_LOG_MAX_BYTES` | 2048 | debug 正文的截断长度上限 | | `CANARY_PERCENT` | 0 | canary 分流比例(0=legacy,100=go;当前无 ModeCanary 路由) | -| `DATABASE_URL` | (空) | MySQL 连接(Prisma 同名同格式 `mysql://user:pass@host:port/db`)。user-settings 只读 shadow(rss-reader / spacetime-timeline 两个 GET;shadow 模式下含 onboarding)的 MySQL 只读查询 + `go` 模式下 authz RBAC 重推导与 onboarding 业务查询共用同一连接池。shadow 模式下**空/无效时网关照常启动并代理全部请求**(shadow 单元跳过,`/__go/healthz` 报 `userSettingsShadow.database` 为 `unconfigured`/`invalid`;`configured` 只代表 DSN 已解析为 driver 配置——`sql.Open` 是惰性初始化,不承诺数据库可连接);`go` 模式下必填且 DSN 无效启动失败。值本身不进入日志/healthz/错误文本 | +| `DATABASE_URL` | (空) | MySQL 连接(Prisma 同名同格式 `mysql://user:pass@host:port/db`)。user-settings 只读 shadow(六个 GET 的旁路查询)+ `go` 模式下 authz RBAC 重推导与业务查询共用同一连接池。shadow 模式下**空/无效时网关照常启动并代理全部请求**(shadow 单元跳过,`/__go/healthz` 报 `userSettingsRead.database` 为 `unconfigured`/`invalid`;`configured` 只代表 DSN 已解析为 driver 配置——`sql.Open` 是惰性初始化,不承诺数据库可连接);`go` 模式下必填且 DSN 无效启动失败。值本身不进入日志/healthz/错误文本 | ## 四态路由(当前路由表) @@ -82,14 +90,14 @@ Go-批3A 起的验收步骤(全部经 api-go 入口 + 真实登录 JWT): | 模式 | 当前路由 | 行为 | |---|---|---| -| legacy | `/api/`、`/graphql`、`/socket.io/`、`/docs`、`/admin/queues`(含 onboarding 的 PUT 与相似路径、其余全部未迁移端点) | 反向代理到 NestJS(事实源) | -| shadow | `/api/healthz/live`、`/api/user-settings/ui/rss-reader`、`/api/user-settings/ui/spacetime-timeline`(均 exact + 仅 GET);`/api/user-settings/ui/onboarding` 在 `API_GO_ONBOARDING_MODE=shadow`(默认)时亦为 shadow | NestJS 响应 + Go 实现异步差分 | +| legacy | `/api/`、`/graphql`、`/socket.io/`、`/docs`、`/admin/queues`(含六个 user-settings GET 的 PUT/相似路径、其余全部未迁移端点) | 反向代理到 NestJS(事实源) | +| shadow | `/api/healthz/live`(exact + 仅 GET);六个 user-settings GET 的模式由 `API_GO_USER_SETTINGS_READ_MODE` 决定:`shadow` 时全部 shadow;未设置时 onboarding(`API_GO_ONBOARDING_MODE=shadow` 默认)与 rss-reader/spacetime-timeline shadow,war-map/newsnow/situation-monitor legacy | NestJS 响应 + Go 实现异步差分 | | canary | (无) | 待鉴权基础设施接入的分流组件(见下) | -| go | `/__go/healthz`;`/api/user-settings/ui/onboarding`(exact + 仅 GET)在 `API_GO_ONBOARDING_MODE=go` 时——**首个业务端点 Go 全响应** | Go 原生(前者网关自省;后者独立鉴权 + 独立查库 + 响应) | +| go | `/__go/healthz`;六个 `/api/user-settings/ui/{onboarding,rss-reader,spacetime-timeline,war-map,newsnow,situation-monitor}`(均 exact + 仅 GET)在 `API_GO_USER_SETTINGS_READ_MODE=go` 时——**user-settings 只读域 Go 全响应** | Go 原生(前者网关自省;后者统一 usersettingsread handler:独立鉴权 + 独立查库 + normalization + 响应) | -### onboarding Go 接管(Go-批3A,首个业务端点真实接管) +### user-settings 只读域 Go 接管(Go-批3B,统一六端点) -`API_GO_ONBOARDING_MODE=go` 时,`GET /api/user-settings/ui/onboarding` 的完整请求链由 Go 独立完成——不请求 NestJS、不等待 legacy 200、不使用 `LegacyApprovedIdentity`: +`API_GO_USER_SETTINGS_READ_MODE=go` 时,六个只读 GET 的完整请求链由 Go 独立完成——不请求 NestJS、不等待 legacy 200、不使用 `LegacyApprovedIdentity`(Go-批3A 曾以 `API_GO_ONBOARDING_MODE=go` 单独接管 onboarding;批3B 起收敛为统一 handler `internal/usersettingsread`,旧 `internal/onboarding` 已删除): ```text 提取 Bearer(拒绝 mtk_ 机器令牌) @@ -101,46 +109,43 @@ Go-批3A 起的验收步骤(全部经 api-go 入口 + 真实登录 JWT): User/Org/Membership active 校验与 getUserProfile 同序同文案 401; MembershipRole 多角色优先、空则 primary role 回退;权限名 RolePermission→ Permission 去重) -→ items.read 判定(internal/onboarding:数据库推导的权限集;JWT claim 不参与) -→ UserSetting 查询(既有 usersettings repository + normalization) +→ items.read 判定(usersettingsread:数据库推导的权限集;JWT claim 不参与) +→ 固定 UserSetting 查询(usersettings repository:五个单 key 端点各查一个 + 编译期固定 key;situation-monitor 一次聚合查询三个固定 key) +→ normalization(usersettings:六个端点各自的 Build*Response——含批3B 完整 + 移植的 war-map-contract / situation-monitor / newsnow 契约) → Go 写出响应(internal/authhttp 契约等价错误;200 带 Cache-Control: no-store) ``` - **错误契约**:与 NestJS `GlobalExceptionFilter` 逐字段对齐——JWT 层失败 401 `{"message":"Unauthorized"}`;撤销 401 `"Access token revoked"`;user/org/membership 状态拒绝 401 同文案(`"Organization disabled"` 等);缺权限 403 `INSUFFICIENT_PERMISSIONS`(any 模式:`detail="Requires any permission: items.read"`,无 `missingPermissions`);Redis 故障 fail-closed 500、MySQL 故障 fail-closed 503(均通用 `"Internal server error"`,对齐 NestJS 生产环境非 HttpException 路径)。 -- **边界**:仅此一个端点。登录/refresh/logout/MFA/OIDC/机器令牌仍全部由 NestJS 承载;PUT 同路径与全部其他写请求纯代理 NestJS;RSS Reader 与 Spacetime Timeline 仍是 shadow 差分(`shadowidentity.LegacyApprovedIdentity` 仍是这两个 shadow 单元 + shadow 模式 onboarding 的身份来源——Go-批3A 未删除该包)。 -- **`/__go/healthz`**:`onboarding.mode` 如实展示当前模式(`shadow`/`go`);`go` 模式下 onboarding 不再增加 `shadow.executed`。 -- **回滚**:`API_GO_ONBOARDING_MODE=shadow`(配置变更)——回到批2A/2B 的 shadow 差分行为,无数据迁移耦合。 - -### user-settings 只读 shadow(第二/三个迁移单元,Go-批2A + 批2B) - -- **范围**:仅三个确定性只读 GET——`/api/user-settings/ui/onboarding` - (批2A)、`/api/user-settings/ui/rss-reader` 与 - `/api/user-settings/ui/spacetime-timeline`(批2B)。**其余三个 GET - (situation-monitor / war-map / newsnow)与全部 PUT 保持 legacy**; - 不迁移写入路径。(Go-批3A 起 onboarding 在 `API_GO_ONBOARDING_MODE=go` - 时升级为 Go 全响应,见上节;rss-reader / spacetime-timeline 仍是 shadow。) +- **边界**:仅六个只读 GET。登录/refresh/logout/MFA/OIDC/机器令牌仍全部由 NestJS 承载;六个 PUT 与全部其他写请求纯代理 NestJS(exact path + method 白名单:PUT/POST/HEAD 同路径与相似路径回落 legacy)。 +- **`/__go/healthz`**:`userSettingsRead.mode` 如实展示当前读模式(空/`shadow`/`go`)、`userSettingsRead.database` 只报 `unconfigured`/`invalid`/`configured`;`go` 模式下六个 GET 不再增加 `shadow.executed`。 +- **回滚**:`API_GO_USER_SETTINGS_READ_MODE=shadow`(全部六端点回到 NestJS 响应 + Go 差分)或删除该变量(兼容:onboarding 由 `API_GO_ONBOARDING_MODE` 控制、其余端点回到批3B 前去向)——配置变更,无数据迁移耦合。 + +### user-settings 只读 shadow(差分阶段,Go-批2A/2B 起步、批3B 扩展) + +- **范围**:六个只读 GET 都可处于 shadow(`API_GO_USER_SETTINGS_READ_MODE=shadow` + 时六端点全部 shadow;未设置时 onboarding/rss/spacetime shadow、其余三 + 个 legacy——批3B 前的兼容行为)。全部 PUT 始终 legacy(不迁移写入路径)。 - **行为**:客户端响应完全来自 NestJS;Go 在旁路真实读取 MySQL - `UserSetting` 表(`orgId+userId+固定 key` 三条件参数化查询;三个端点 - 共用同一 repository 的同一条查询,key 是编译期固定常量),并与 - NestJS 响应差分(normalization 契约逐字段对齐:RSS 的 trim/截断/ - 稳定去重/严格布尔;spacetime 的枚举回退/浮点 clamp 不取整)。 -- **legacy-approved shadow identity(信任边界)**:Go 尚未完成 JWT 验签、 - jti blacklist、membership 重推导与 RBAC。身份唯一来源是 legacy 信任 - 委托——同一请求先由 NestJS 执行并返回 200(签名/权限全部通过), - 此时才从(未验签的)Bearer JWT payload 读取 `sub`/`orgId`,只用于本次 - 只读查询。**不是「Go 已验证身份」**;不读取、不信任 `permissions` - claim;NestJS 非 200(401/403/404/5xx)→ Go 零查询。 + `UserSetting` 表(五个单 key 端点 `orgId+userId+固定 key` 三条件参数化 + 查询共用同一条私有 SQL;situation-monitor 一次聚合查询三个固定 key, + 对齐 NestJS findMany 语义),并与 NestJS 响应差分(normalization 契约 + 逐字段对齐——含批3B 的 war-map 46 layer/legacy key/clamp、newsnow + 有序对象/上限/真值、situation-monitor 三段聚合)。 +- **legacy-approved shadow identity(信任边界)**:shadow 是回滚兼容路径, + 不是 Go 的独立鉴权。身份来源是 legacy 信任委托——同一请求先由 + NestJS 执行并返回 200(签名/权限全部通过),此时才从(未验签的) + Bearer JWT payload 读取 `sub`/`orgId`,只用于本次只读查询。**不是 + 「Go 已验证身份」**;不读取、不信任 `permissions` claim;NestJS 非 200 + (401/403/404/5xx)→ Go 零查询。Go 模式(`usersettingsread`)完全不 + 经过该身份——但 shadow 回滚能力依赖它,`shadowidentity` 包因此保留。 - **失败非阻断**:MySQL 未配置/不可达/超时、JSON 异常、限流/并发预算 耗尽——都只跳过本次差分或形成结构化差分记录,客户端始终收到 NestJS 原响应。 -- **不能进入 canary/go**:在 Go 完成 Auth/RBAC(迁移序 5)前,这些端点 - 保持 shadow;路由表被误改为 ModeCanary/ModeGo 时 - `cmd/api/main_test.go` 的状态契约测试会失败。(Go-批3A 已为 onboarding - 落地最小闭环 Go Auth——它因此成为首个合法的 ModeGo 业务端点; - rss-reader / spacetime-timeline 尚未接入,仍必须 shadow。) -- **回滚**:`internal/legacyproxy/proxy.go` 中对应路由单条改回 - `ModeLegacy`——纯代码变更,无数据耦合(两个新端点回滚不影响 - onboarding shadow)。 +- **回滚**:`API_GO_USER_SETTINGS_READ_MODE=shadow`(六端点全部回 shadow) + 或未设置(兼容行为)——配置变更;路由级单条改回 `ModeLegacy` 亦可 + (纯代码变更,无数据耦合)。 ### canary 的信任边界(重要) @@ -152,7 +157,7 @@ legacy。详见 `docs/refactor/api-go-four-mode.md`。 ## 迁移一个路由(四态) -路由表在 `internal/legacyproxy/proxy.go` 的 `DefaultRules(onboardingMode)`(迁移单元 = exact path + method 白名单;fallback = 前缀匹配): +路由表在 `internal/legacyproxy/proxy.go` 的 `DefaultRules(onboardingMode, readMode)`(迁移单元 = exact path + method 白名单;fallback = 前缀匹配): 1. shadow 起步:把目标单元改为 `ModeShadow`(exact + method 白名单),在 `cmd/api/main.go` 的 dispatcher 里注册该路由的差分执行者; 2. 差分 0 失败后 canary:改为 `ModeCanary` + 调 `CANARY_PERCENT` 灰度(orgId 稳定哈希——注意当前分流依据仍是未验签 claim,见下); @@ -167,10 +172,10 @@ pnpm --filter @modular/api-go lint # go vet pnpm --filter @modular/api-go build # go build ``` -MySQL + Redis 集成测试(Go-批2A 起步、批2B 扩展三个固定 key、批3A 增加 -authz RBAC 重推导与 authn blacklist,本机禁跑——远端 CI 的 -`api-go-user-settings-integration` job 使用固定版本 MySQL + Redis service -执行): +MySQL + Redis 集成测试(Go-批2A 起步、批2B/批3B 扩展到八个固定 key 与 +situation-monitor 三记录聚合、批3A 增加 authz RBAC 重推导与 authn +blacklist,本机禁跑——远端 CI 的 `api-go-user-settings-integration` job +使用固定版本 MySQL + Redis service 执行): ```bash cd apps/api-go && go test -tags=integration -count=1 \ diff --git a/docs/refactor/api-contract-inventory.md b/docs/refactor/api-contract-inventory.md index 63a79296..a34b9898 100644 --- a/docs/refactor/api-contract-inventory.md +++ b/docs/refactor/api-contract-inventory.md @@ -34,10 +34,10 @@ - `GET /api/auth/admin/registration-applications`(`auth.controller.ts:393`)、`POST .../approve-org`(:454)、`POST .../reject-org`(:471)——handler 内部另有鉴权,但全局 Guard 先拦 **Go 迁移状态(api-go 四态路由表,`apps/api-go/internal/legacyproxy/proxy.go`)**: -- `GET /api/user-settings/ui/onboarding` —— **ModeGo(Go-批3A,pilot 内 `API_GO_ONBOARDING_MODE=go` 显式启用;默认仍 ModeShadow)**:首个由 Go 独立鉴权并全响应的业务端点——Go 独立验签 HS256 access token(iss/aud/exp 按 jsonwebtoken 语义、jti 缺失按 NestJS 语义放行、拒绝 alg 混淆与 mtk_)、独立查询真实 Redis blacklist(`access-token:blacklist:` 同 key,fail-closed)、独立从 MySQL 重推导 User/Org/Membership 与 MembershipRole/RolePermission/Permission 权限(与 getUserProfile 同序同文案 401,多角色优先/primary 回退)、独立判定 `items.read`(JWT permissions claim 一律不参与)、独立查 UserSetting 并写出契约等价响应(错误形状对齐 GlobalExceptionFilter)。路由为 exact+GET:PUT 同路径与相似路径(onboarding-x/子路径)回落 legacy。已完成远端真实栈接管验证(含 NestJS 停止后仍 200 的独立证明)。 -- user-settings 确定性只读 GET ×2 —— **ModeShadow**(Go-批2B:rss-reader、spacetime-timeline):NestJS 仍是客户端响应事实源;Go 旁路真实读取 MySQL `UserSetting`(`SettingKey` 编译期固定常量,orgId+userId+key 三条件参数化查询)并差分。身份来自 legacy-approved shadow identity(legacy 200 后的临时信任委托——不是 Go 已验证身份,不读 permissions claim);该包(`shadowidentity`)的剩余消费者即这两个 shadow 单元与 shadow 模式下的 onboarding。其余三个 user-settings GET(situation-monitor/war-map/newsnow)与全部 PUT 仍 legacy;未迁移任何写入路径。 +- user-settings 只读 GET ×6 —— **ModeGo(Go-批3B 统一接管:onboarding/rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor;pilot 内 `API_GO_USER_SETTINGS_READ_MODE=go` 显式启用,优先级高于批3A 的 `API_GO_ONBOARDING_MODE`;默认未设置=兼容旧行为)**:六个 GET 全部由统一 Go handler(`internal/usersettingsread`)独立鉴权并全响应——Go 独立验签 HS256 access token(iss/aud/exp 按 jsonwebtoken 语义、jti 缺失按 NestJS 语义放行、拒绝 alg 混淆与 mtk_)、独立查询真实 Redis blacklist(`access-token:blacklist:` 同 key,fail-closed)、独立从 MySQL 重推导 User/Org/Membership 与 MembershipRole/RolePermission/Permission 权限(与 getUserProfile 同序同文案 401,多角色优先/primary 回退)、独立判定 `items.read`(JWT permissions claim 一律不参与)、独立查 UserSetting(五个单 key 端点各查一个编译期固定常量 key;situation-monitor 一次聚合查询三个固定 key,对齐 NestJS findMany)并经 normalization(War Map 完整移植 war-map-contract.ts;Situation Monitor 三段聚合;NewsNow 有序对象/上限/真值语义)写出契约等价响应(错误形状对齐 GlobalExceptionFilter)。路由为 exact+GET:六个 PUT 同路径与相似路径(onboarding-x/war-map-x/newsnow 子路径等)回落 legacy。已完成远端真实栈接管验证(Phase A-D:8 个固定 key 落库、shadow 零差异、go 契约全等、NestJS 停止后六端点仍 200 且代表性 PUT/未迁移 GET 502)。`API_GO_USER_SETTINGS_READ_MODE=shadow` 时六端点全部回 shadow 差分。 +- user-settings 全部 PUT ×6(onboarding/rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor)—— **ModeLegacy(NestJS 单写,Go-批3B 不迁移写入路径)**。 - 登录/refresh/logout/MFA/OIDC/机器令牌 —— **全部仍由 NestJS 承载**(迁移序 5 余项,未动)。 -- 入口链(Go-批2C):api-go 具备生产容器(`infra/docker/api-go.Dockerfile`,distroless nonroot + `healthcheck` 子命令)与 Compose 独立 `api-go-pilot` profile 服务;`API_BASE_URL` 可切 `http://api-go:4020` 使入口变为 `Web → api-go → NestJS`(其余请求全部纯代理,契约不变),默认部署仍直连 NestJS。已通过远端真实栈运行验证(`api-go-entry-smoke`:真实登录 JWT + 三个 PUT 持久化 + Shadow GET 零差异零丢弃 + Go-批3A 鉴权链/接管验证);生产/预发布真实流量验证未完成。trace header(`x-trace-id`/`traceparent`)在 api-go 入口链保持原语义(§0 TraceId 行为镜像)。 +- 入口链(Go-批2C):api-go 具备生产容器(`infra/docker/api-go.Dockerfile`,distroless nonroot + `healthcheck` 子命令)与 Compose 独立 `api-go-pilot` profile 服务;`API_BASE_URL` 可切 `http://api-go:4020` 使入口变为 `Web → api-go → NestJS`(其余请求全部纯代理,契约不变),默认部署仍直连 NestJS。已通过远端真实栈运行验证(`api-go-entry-smoke`:真实登录 JWT + 六个 PUT 持久化 + Shadow GET 零差异零丢弃 + Go-批3A 鉴权链 + Go-批3B 四阶段接管验证);生产/预发布真实流量验证未完成。trace header(`x-trace-id`/`traceparent`)在 api-go 入口链保持原语义(§0 TraceId 行为镜像)。 ## 1. REST 契约(71 controller · 369 endpoint) diff --git a/docs/refactor/api-go-four-mode.md b/docs/refactor/api-go-four-mode.md index 70f11ace..6ac24a7c 100644 --- a/docs/refactor/api-go-four-mode.md +++ b/docs/refactor/api-go-four-mode.md @@ -1,13 +1,13 @@ # api-go 四态路由与首个迁移单元(shadow/canary 实现说明) -> 2026-09-03 落地 · 2026-09-07 Go-批3A 增补(exact+method 路由与 onboarding go 接管) +> 2026-09-03 落地 · 2026-09-07 Go-批3A 增补(exact+method 路由与 onboarding go 接管)· 2026-09-07 Go-批3B 增补(user-settings 六个只读 GET 统一接管,`API_GO_USER_SETTINGS_READ_MODE`) > 关联:docs/refactor/go-migration-adr.md §3/§4/§4.3、roadmap M2 --- ## 1. 四态从「类型声明」到可运行实现 -`apps/api-go/internal/legacyproxy/proxy.go` 的路由表(`DefaultRules(onboardingMode)`)。**匹配语义(Go-批3A 起)**:迁移单元 = **exact path + method 白名单**——不匹配的方法(如 PUT)与相似路径(`onboarding-x`、`onboarding/other`)回落更短的通用规则(`/api/` legacy,由 NestJS 处理);通用 fallback 规则 = 前缀匹配 + 任意方法(既有语义不变): +`apps/api-go/internal/legacyproxy/proxy.go` 的路由表(`DefaultRules(onboardingMode, readMode)`——批3B 起第二参数是 user-settings 六个只读 GET 的统一读模式)。**匹配语义(Go-批3A 起)**:迁移单元 = **exact path + method 白名单**——不匹配的方法(如 PUT)与相似路径(`onboarding-x`、`onboarding/other`)回落更短的通用规则(`/api/` legacy,由 NestJS 处理);通用 fallback 规则 = 前缀匹配 + 任意方法(既有语义不变): | 单元 | 匹配 | 模式 | 说明 | |---|---|---|---| @@ -51,13 +51,15 @@ - **迁移边界**:`GET /api/healthz`(AllowAuthenticated + 7 项真实依赖探针 + 5s 缓存)**未迁移**——需要数据库连接层,属后续单元;NestJS 实现保留为事实源。 - **回滚**:路由表 `/api/healthz/live` 改回 `ModeLegacy`(单行配置)。 -### 2.1 首个业务端点 go 接管:`GET /api/user-settings/ui/onboarding`(Go-批3A) +### 2.1 user-settings 只读域 go 接管(Go-批3A 首个端点 · Go-批3B 统一六端点) -`API_GO_ONBOARDING_MODE=go`(compose `api-go-pilot` profile 注入;默认 `shadow`)时,该单元从 shadow 升级为 **ModeGo**——首个由 Go 独立鉴权、独立授权、独立查库、独立响应的业务端点。完整语义(鉴权链、错误契约、路由边界、远端验收)见 ADR §4.3 与 `apps/api-go/README.md`;要点: +`API_GO_USER_SETTINGS_READ_MODE=go`(compose `api-go-pilot` profile 注入;默认未设置=兼容行为)时,**六个 user-settings 只读 GET** 全部从 shadow/legacy 升级为 **ModeGo**——由统一 handler(`internal/usersettingsread`)Go 独立鉴权、独立授权、独立查库、独立响应。批3A 曾以 `API_GO_ONBOARDING_MODE=go` 单独接管 onboarding(该变量保留为兼容:read mode 未设时仍生效)。完整语义(鉴权链、错误契约、路由边界、远端验收)见 ADR §4.3 与 `apps/api-go/README.md`;要点: -- 鉴权链:Bearer 提取(拒绝 mtk_)→ HS256 验签(`internal/authn`,拒 alg=none/混淆,iss/aud/exp/nbf 按 jsonwebtoken 语义,jti 缺失按 NestJS 语义放行)→ 真实 Redis blacklist(同一 key,fail-closed)→ 真实 MySQL membership/RBAC 重推导(`internal/authz`,同序同文案 401)→ `items.read` 判定(JWT permissions claim 结构上不可达)→ `internal/onboarding` 全响应。 -- 路由边界:exact + GET——PUT 同路径与相似路径回落 `/api/` legacy(NestJS 单写/404);RSS/Spacetime 仍是 shadow;`shadowUnits` 在 go 模式下移除 onboarding(`shadow.executed` 不再增长)。 -- 回滚:`API_GO_ONBOARDING_MODE=shadow`(配置变更,回到批2A/2B 行为)。 +- 鉴权链(六端点共享同一装配):Bearer 提取(拒绝 mtk_)→ HS256 验签(`internal/authn`,拒 alg=none/混淆,iss/aud/exp/nbf 按 jsonwebtoken 语义,jti 缺失按 NestJS 语义放行)→ 真实 Redis blacklist(同一 key,fail-closed)→ 真实 MySQL membership/RBAC 重推导(`internal/authz`,同序同文案 401)→ `items.read` 判定(JWT permissions claim 结构上不可达)→ 固定 UserSetting 查询 + normalization + 全响应。 +- repository:五个单 key 端点共用同一条参数化查询(key 为编译期常量);situation-monitor 一次聚合查询三个固定 key(对齐 NestJS findMany)。 +- 路由边界:exact + GET——六个 PUT 同路径与相似路径回落 `/api/` legacy(NestJS 单写/404);`shadowUnits` 在 go 模式下按路由表 ModeGo 集合过滤(`shadow.executed` 不再增长)。 +- 读模式优先级:`API_GO_USER_SETTINGS_READ_MODE` 设置时覆盖 `API_GO_ONBOARDING_MODE`(onboarding 也归它管);未设置时兼容旧行为(onboarding 由批3A 变量控制、rss/spacetime shadow、war-map/newsnow/situation-monitor legacy)。 +- 回滚:`API_GO_USER_SETTINGS_READ_MODE=shadow`(六端点全部回 shadow)或删除该变量(兼容行为)——配置变更。 ## 3. 与鉴权矩阵/契约快照的关系 @@ -68,5 +70,5 @@ - shadow 差分「真实流量 0 差异」验收未做——api-go 未接入生产入口(默认部署 Web → NestJS 直连);`api-go-entry-smoke` 只覆盖远端真实栈的受控流量 - canary **未激活**(无 ModeCanary 路由、AllowUnverifiedIdentity 默认关闭);Go-批3A 落地的 onboarding 最小闭环 Go Auth 不改变这一点——canary router 仍消费未验签 claim,尚未改造为已验证身份分流 -- onboarding go 接管(Go-批3A)**已完成远端真实栈验证**(含 NestJS 停止后的独立接管证明),但**仅限 pilot 范围**——生产/预发布真实流量未切换;MFA/OIDC/refresh/机器令牌语义未迁移(迁移序 5 余项) +- user-settings 只读域 go 接管(Go-批3A onboarding 起步、Go-批3B 扩展到六端点)**已完成远端真实栈验证**(含 NestJS 停止后的独立接管证明),但**仅限 pilot 范围**——生产/预发布真实流量未切换;MFA/OIDC/refresh/机器令牌语义未迁移(迁移序 5 余项);六个 PUT 仍全部由 NestJS 单写 - 本机按任务约束未运行 `go test`/`go vet`/`go build`;全部 Go 测试在远端 CI 执行 diff --git a/docs/refactor/go-migration-adr.md b/docs/refactor/go-migration-adr.md index b1031d72..7cbb8cf4 100644 --- a/docs/refactor/go-migration-adr.md +++ b/docs/refactor/go-migration-adr.md @@ -112,7 +112,7 @@ api-go 自批2C 起具备真实可运行的入口链,但**默认部署仍 Web 验证未完成**。canary/go 接管(§4 后两态)在迁移序 5 完成前保持禁止 (例外见 §4.3——onboarding 单元的 go 接管以最小闭环 Go Auth 为前置件)。 -### 4.3 Go Access Token 鉴权最小闭环 + onboarding 首个 go 接管(Go-批3A) +### 4.3 Go Access Token 鉴权最小闭环 + onboarding 首个 go 接管(Go-批3A;Go-批3B 扩展为六端点统一接管,见 §4.4) 迁移序 5(Auth/Org/RBAC)的最小闭环先行落地,并立刻服务于一个真实接管 的业务端点(而不是只写鉴权代码): @@ -143,11 +143,49 @@ api-go 自批2C 起具备真实可运行的入口链,但**默认部署仍 Web 零 shadow 执行。 - **边界(如实登记)**:这只是迁移序 5 的最小闭环——MFA、OIDC、refresh 轮换、机器令牌、Platform Admin 语义未迁移(登录/refresh/logout 仍全部 - NestJS,mtk_ 在 Go 端点被拒绝);RSS/Spacetime 仍是 shadow - (`LegacyApprovedIdentity` 保留,消费者为这三个 shadow 单元); - `shadowUnits` 与路由表均未为其他端点开 go;canary router 仍消费未验签 - claim(未改造为已验证身份分流),CANARY_PERCENT 保持 0;默认部署仍 - Web → NestJS 直连,生产流量未切换。 + NestJS,mtk_ 在 Go 端点被拒绝);批3B 前其余端点仍 shadow/legacy; + canary router 仍消费未验签 claim(未改造为已验证身份分流), + CANARY_PERCENT 保持 0;默认部署仍 Web → NestJS 直连,生产流量未切换。 + +### 4.4 user-settings 只读域统一 Go 接管(Go-批3B) + +批3A 的单端点接管收敛为整个只读域的统一接管——六个 GET 共享同一 +handler、同一鉴权链装配、同一连接池,不复制六份实现: + +- **接管单元**:`GET /api/user-settings/ui/{onboarding,rss-reader, + spacetime-timeline,war-map,newsnow,situation-monitor}` 全部由 + `API_GO_USER_SETTINGS_READ_MODE=go` 切到 ModeGo(compose + `api-go-pilot` profile 固定注入 `go`;该变量优先级高于 + `API_GO_ONBOARDING_MODE`——批3A 变量保留为兼容:未设置 read mode 时 + 仍单独控制 onboarding。默认部署与手工裸启均未设置——旧行为零变化)。 +- **统一 handler**(`internal/usersettingsread`):批3A 的 + `internal/onboarding` 被完全替代并删除(不新旧并存)。请求链 = Bearer + 提取 → JWT 验签 → Redis blacklist → MySQL membership/RBAC 重推导 → + `items.read` → 固定 UserSetting 查询 → normalization → Go 全响应。 + 六端点只差「查哪个语义方法 + 哪个 Build*Response」——一张编译期绑定 + 表,不是注册框架。 +- **repository 扩展**:五个单 key 端点共用同一条私有参数化查询 + (`orgId+userId+固定 key`,key 为 `usersettings` 包编译期常量,共 8 个); + situation-monitor 一次聚合查询三个固定 key(对齐 NestJS `findMany`)。 + 无任意 key 查询 API——SettingKey 封闭集合。 +- **三个新 normalization**(`internal/usersettings`):War Map 完整移植 + `packages/utils/src/war-map-contract.ts`(46 layer + legacy key 映射 + + viewState clamp + bearing/pitch 归零 + 枚举回退);Situation Monitor + 三段聚合(monitors/layout/settings 各自规整 + 三段 updatedAt); + NewsNow(有序对象 columnOrders/sourceAffinity——`Object.entries` 顺序 + 与「前 N 项」上限语义用流式有序解码,不用 map 随机遍历;Boolean 真值; + clamp/round;smart→personalized 归一)。 +- **远端真实栈验收**(`api-go-entry-smoke` 四阶段):Phase A 六个 PUT + NestJS 单写 + 8 key 落库确认;Phase B shadow 差分(executed 精确 +6、 + diffs 零增量);Phase C go 接管契约对比(六端点 NestJS 直连 vs Go + handler 逐字段全等);Phase D 停止 NestJS 后六端点仍 200 + 代表性 + PUT/未迁移 GET 502(独立接管与写路径未迁移证明)。 +- **边界(如实登记)**:六个 PUT 仍全部由 NestJS 单写(exact path + + method 白名单回落);登录/refresh/logout/MFA/OIDC/机器令牌仍全部 + NestJS;`LegacyApprovedIdentity` 保留(shadow 回滚路径的消费者—— + Go 模式完全不经过它);canary 仍未激活;默认生产入口未切换(pilot + profile 之外 Web → NestJS 直连);远端真实栈验证不等于生产/预发布 + 真实流量验收。 ## 5. 队列/cron/outbox 边界(红线) diff --git a/docs/refactor/roadmap.md b/docs/refactor/roadmap.md index 714d9f5a..52edc8df 100644 --- a/docs/refactor/roadmap.md +++ b/docs/refactor/roadmap.md @@ -34,6 +34,7 @@ | user-settings 只读 GET 第二批:`rss-reader`、`spacetime-timeline`(Go-批2B) | 🔶 shadow 态(NestJS 仍是响应方)。复用批2A 的 repository(`SettingKey` 编译期固定常量,三个语义方法共享同一条参数化查询)、legacy-approved shadow identity 与失败非阻断语义;normalization 逐字段对齐(RSS:trim/128 截断/稳定去重/严格布尔/provider/targetLanguage;spacetime:枚举回退/浮点 clamp 不取整);远端 CI MySQL integration 扩展验证三 key 读取与 orgId/userId/key 隔离。未做真实生产流量差分验收;**其余三个 GET(situation-monitor/war-map/newsnow)与全部 PUT 仍 legacy**——user-settings 未迁移完成。Go-批3A 后仍是 shadow(本批不扩大迁移范围) | | api-go 真实入口接线 + 容器化(Go-批2C) | 🔶 **已完成远端真实栈运行验证**(非生产流量)。`infra/docker/api-go.Dockerfile`(多阶段、distroless nonroot、`-mod=readonly -trimpath`、内置 `healthcheck` 子命令——exec 形式 HEALTHCHECK)+ compose 独立 `api-go-pilot` profile 服务(:4020,`LEGACY_API_URL=http://api:4000`,同一 MySQL,`CANARY_PERCENT=0`)+ Web/API 入口可切换(`API_BASE_URL` 运行期可指 `http://api-go:4020`;web 启动等待不再硬编码 `api:4000`)。手动 `api-go-entry-smoke` workflow 在远端真实栈(真实 MySQL+migrate+真实 NestJS+api-go 容器+真实登录 JWT)闭环:3 个 PUT 经 api-go 由 NestJS 单写持久化、PUT 零 Shadow 执行、4 个 Shadow GET `executed` 精确 +4、`diffs`/`dropped` 零增量、`inflight` 归零、trace header 传播、三 key 无串读。**默认 legacy 部署不变;生产/预发布真实流量验证未完成;canary/go 仍禁止** | | Go Access Token 鉴权/RBAC 最小闭环 + onboarding GET 首次真实接管(Go-批3A) | 🔶 **已完成远端真实栈下的 onboarding Go 接管验证**(非生产流量)。`GET /api/user-settings/ui/onboarding` 成为 pilot 范围内首个由 Go 全响应的业务端点(`API_GO_ONBOARDING_MODE=go`,默认仍 shadow):Go 独立验签 NestJS 签发的 HS256 access token(`internal/authn`,拒绝 alg=none/算法混淆/mtk_,issuer/audience/exp/nbf 按 jsonwebtoken 语义,jti 缺失按 NestJS 当前语义放行)→ 独立查询真实 Redis blacklist(`access-token:blacklist:`,与 NestJS 同一实例同一 key,查询失败 fail-closed)→ 从真实 MySQL 重推导 User/Org/Membership 有效性与 MembershipRole/RolePermission/Permission 权限(`internal/authz`,与 getUserProfile 同序同文案 401;多角色优先/primary 回退)→ `items.read` 独立判定(**JWT permissions claim 一律不参与**)→ 既有 repository 查 UserSetting 并全响应(`internal/onboarding` + `internal/authhttp` 契约等价错误)。路由层迁移单元改为 exact path + method 白名单(PUT 与 onboarding-x/子路径回落 legacy)。远端真实栈 smoke 验证:契约对比(NestJS 直连 vs Go handler 全等)、数据库无 items.read 而 JWT claim 有 → 双端 403 一致、membership inactive → 双端 401 同文案、真实 logout blacklist → Go 401 revoked、篡改签名/alg=none → 401、**NestJS 停止后 onboarding GET 仍 200(Go 独立接管证明)且 rss-reader 502(非伪装成功)**、onboarding 零 shadow 执行(executed +3=live+rss+spacetime)。**边界如实登记**:登录/refresh/logout/MFA/OIDC/机器令牌仍全部 NestJS(mtk_ 在 Go 端点被拒绝);全部 PUT 仍 NestJS 单写;RSS/Spacetime 仍 shadow;默认部署仍直连 NestJS;这不代表完整 Auth/RBAC 模块迁移完成(迁移序 5 的 MFA/OIDC/refresh/机器令牌未动);canary router 未改造仍不激活;生产流量未切换 | +| user-settings 只读域统一 Go 接管(Go-批3B) | 🔶 **已完成远端真实栈下的六端点 Go 接管验证**(非生产流量)。六个只读 GET(onboarding/rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor)由统一 handler `internal/usersettingsread` 全响应(`API_GO_USER_SETTINGS_READ_MODE=go`,优先级高于批3A 变量;未设置=兼容旧行为):复用批3A 的 authn/authz/authhttp 鉴权链与同一 MySQL/Redis 连接池;repository 扩展到 8 个编译期固定 key(五个单 key 查询共用一条 SQL + situation-monitor 三 key 聚合查询);三个新 normalization(War Map 完整移植 war-map-contract.ts 含 46 layer/legacy key/clamp/归零;Situation Monitor 三段聚合+updatedAt;NewsNow 有序对象 Object.entries 顺序+200/32/300 上限+Boolean 真值+clamp/round)。远端真实栈 smoke 四阶段:Phase A 六 PUT(NestJS 单写,8 key 落库)→ Phase B shadow(executed +6、diffs 零增量)→ Phase C go 契约对比(六端点与 NestJS 直连逐字段全等)→ Phase D 停止 NestJS 后六端点仍 200、代表性 PUT 与未迁移 GET 502。**边界**:六个 PUT 仍全部 NestJS 单写;登录/refresh/logout/MFA/OIDC/机器令牌仍 NestJS;LegacyApprovedIdentity 保留(shadow 回滚路径);canary 未激活;生产流量未切换 | | api 单测基座(vitest) | ✅ 远端 CI 已验证(SEC-01 6/6 + API-01 4/4 + 扫描器语义/基线断言全绿) | 余项(按序): diff --git a/infra/docker/.env.sample b/infra/docker/.env.sample index 29d3377a..0286bf4f 100644 --- a/infra/docker/.env.sample +++ b/infra/docker/.env.sample @@ -83,6 +83,14 @@ API_GO_IMAGE=golang:1.27 # 失败;go 模式要求 JWT_SECRET/DATABASE_URL/REDIS_HOST 齐备。回滚= # 改回 shadow(无数据迁移耦合)。 API_GO_ONBOARDING_MODE=shadow +# user-settings 六个只读 GET 的统一读模式(Go-批3B)。compose 的 api-go +# pilot 服务固定注入 go(六个 GET 全部由统一 Go handler 接管:onboarding/ +# rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor)。 +# 优先级:设置时覆盖 API_GO_ONBOARDING_MODE 对 onboarding 的控制。 +# 未设置(空)=兼容旧行为(onboarding 由上面的变量控制,rss/spacetime +# shadow,war-map/newsnow/situation-monitor legacy)。回滚=改回 shadow +# 或删除本变量(无数据迁移耦合)。 +API_GO_USER_SETTINGS_READ_MODE= NEXT_PUBLIC_GRAPHQL_APQ_ENABLED=true NEXT_PUBLIC_LLM_GATEWAY_DEFAULT_API_BASE=http://litellm:4000 AKSHARE_HTTP_BASE_URL=http://akshare:8081 diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 5e0166aa..9f6cd8f6 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -535,24 +535,27 @@ services: condition: service_started # api-go:主后端 Go 网关试点(Go-批2C:真实入口接线;Go-批3A: - # onboarding GET 首个业务端点真实接管)。独立 api-go-pilot profile - # ——默认部署不启动该服务,NestJS api 仍是唯一入口(Web → api:4000)。 + # onboarding GET 首个业务端点真实接管;Go-批3B:user-settings 六个 + # 只读 GET 统一接管)。独立 api-go-pilot profile——默认部署不启动该 + # 服务,NestJS api 仍是唯一入口(Web → api:4000)。 # # 启用:docker compose --profile api-go-pilot up -d api-go # (或 .env 设 COMPOSE_PROFILES=api-go-pilot 后 up -d)。 # 切流(pilot 模式):.env 中 API_BASE_URL=http://api-go:4020(服务端 # 运行期),浏览器端以构建参数 NEXT_PUBLIC_API_BASE_URL 指向 # http://:${API_GO_HOST_PORT:-4020}/api 重建 web 镜像。 - # 回滚(无数据迁移耦合,三选一或组合): - # 1. API_GO_ONBOARDING_MODE=shadow——onboarding GET 回到 NestJS 响应 - # + Go 差分(Go-批2A/2B 行为); + # 回滚(无数据迁移耦合,可组合): + # 1. API_GO_USER_SETTINGS_READ_MODE=shadow——六个 user-settings GET + # 回到 NestJS 响应 + Go 差分(含批2A/2B 的 shadow 行为); # 2. API_BASE_URL 指回 http://api:4000(web 侧不再经 api-go); # 3. 停止 api-go pilot 服务。 - # Go-批3A 边界:onboarding GET 是 pilot 范围内首个 Go 全响应端点(Go - # 独立 JWT 验签 + Redis blacklist + MySQL RBAC + 独立查库响应,登出 - # NestJS 后仍可用);登录/refresh/logout/全部 PUT 及其余路由仍由 - # NestJS 承载(RSS/Spacetime 仍是 shadow 差分)。CANARY_PERCENT - # 固定 0,canary 模式仍禁止启用。 + # Go-批3B 边界:六个 user-settings 只读 GET(onboarding/rss-reader/ + # spacetime-timeline/war-map/newsnow/situation-monitor)在 pilot Go + # 模式下由统一 Go handler 真实接管(Go 独立 JWT 验签 + Redis + # blacklist + MySQL RBAC + 独立查库 + normalization,NestJS 停止后仍 + # 可用);六个 PUT 仍全部由 NestJS 单写;登录/refresh/logout/MFA/ + # OIDC/机器令牌及其余路由仍由 NestJS 承载。CANARY_PERCENT 固定 0, + # canary 模式仍禁止启用。 # DATABASE_URL 由与 NestJS api 相同的 MYSQL_* 值派生——同一真实 # MySQL,不复制数据、不引入第二个 NestJS;Redis/JWT 配置与 api 服务 # 同源(同一 Redis 实例查 blacklist、同一 secret 验签)。 @@ -569,8 +572,13 @@ services: PORT: 4020 LEGACY_API_URL: http://api:4000 DATABASE_URL: mysql://${MYSQL_USER:-root}:${MYSQL_PASSWORD}@mysql:${MYSQL_PORT:-3306}/${MYSQL_DB} - # pilot 明确接管 onboarding GET(默认部署不启动本服务;手工裸启 - # api-go 时该值默认 shadow——旧行为不变)。 + # pilot 明确接管六个 user-settings 只读 GET(Go-批3B 统一读模式, + # 优先级高于 API_GO_ONBOARDING_MODE;默认部署不启动本服务;手工 + # 裸启 api-go 时该变量未设——旧行为不变:onboarding 由 + # API_GO_ONBOARDING_MODE 控制,rss/spacetime shadow,其余 legacy)。 + API_GO_USER_SETTINGS_READ_MODE: go + # Go-批3A 兼容变量:readMode 未设时仍控制 onboarding(pilot 固定 + # go,保持与批3A 部署等价;readMode=go 已覆盖全部六端点)。 API_GO_ONBOARDING_MODE: go # 与 api 服务同一 JWT 配置(issuer/audience 默认值与 NestJS env # schema 一致)。secret 只经环境注入,不进日志/healthz/错误正文。 From e045ee39563375874a7b8eb4bd36ce8a8ed00922 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 18:39:13 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(api-go):=20=E4=BF=AE=E6=AD=A3=20monit?= =?UTF-8?q?ors=20=E5=BE=AA=E7=8E=AF=E7=9A=84=20any=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=20+=20smoke=20fallback=20=E5=93=A8=E5=85=B5?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 根因(run 34112159439,verify lint / user-settings-integration 同源): NormalizeSituationMonitors 的循环变量是 any(asJSONObjectArray 返回 []any),直接下标索引编译失败(cannot index record)。修复:循环内先 断言为 map[string]any(非对象跳过——与 NestJS !entry || typeof !== object || Array.isArray 跳过语义一致)。 同时修正 smoke fixture 的 fallback 断言:Bad location 监控项缺失 id/createdAt 时 NestJS 回填 randomUUID()/Date.now()(非确定值)—— 改用 @exists: 哨兵断言存在性与形态(id 以 sm- 开头、createdAt 为毫秒 时间戳),不是把随机字段加入 ignore 集。Phase A 与 Phase D 同步。 --- .github/workflows/api-go-entry-smoke.yml | 91 +++++++++++++++---- .../internal/usersettings/situationmonitor.go | 7 +- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/.github/workflows/api-go-entry-smoke.yml b/.github/workflows/api-go-entry-smoke.yml index 4452ebb6..e8e759d6 100644 --- a/.github/workflows/api-go-entry-smoke.yml +++ b/.github/workflows/api-go-entry-smoke.yml @@ -479,12 +479,18 @@ jobs: "resetLayoutOnPreset": True, "translateToZh": True}, }, "expected": { + # monitors 第二条(Bad location)缺失 id/createdAt—— + # NestJS fallback 是 randomUUID()/Date.now()(非确定值)。 + # fixture 第一条提供稳定 id/createdAt;第二条用哨兵 + # 标记("@exists:sm-"/"@exists:int")断言 fallback 行为 + # 本身(id 以 sm- 开头、createdAt 是毫秒整数)——不是把 + # 随机字段加入 ignore 集。 "monitors": [ {"id": "mon-1", "name": "Taiwan Strait", "keywords": ["ship", "navy", "MISSING"], "enabled": True, "color": "#ff00aa", "location": {"name": "Taipei", "lat": 25.03, "lng": 121.56}, "createdAt": 1757000000000}, - {"id": "Bad location", "name": "Bad location", "keywords": ["y"], - "enabled": True, "createdAt": None}, + {"id": "@exists:sm-", "name": "Bad location", "keywords": ["y"], + "enabled": True, "createdAt": "@exists:int"}, ], "layout": { "layouts": { @@ -499,6 +505,37 @@ jobs: }, } + def assert_value(got, want, context): + # 哨兵断言(@exists: 前缀):只断言存在性与形态,不比较精确值 + #(fallback 的 randomUUID/Date.now() 本身非确定——显式断言其 + # 存在与形态,不静默 ignore)。 + if isinstance(want, str) and want.startswith("@exists:"): + assert got is not None, f"{context}: fallback value missing (want exists)" + form = want[len("@exists:"):] + if form == "int": + assert isinstance(got, int) and got > 1_500_000_000_000, ( + f"{context}: createdAt = {got!r}, want 毫秒时间戳") + elif form.startswith("sm-"): + assert isinstance(got, str) and got.startswith("sm-"), ( + f"{context}: id = {got!r}, want sm- 前缀 fallback") + return + assert got == want, ( + f"{context}\n got: {json.dumps(got, sort_keys=True)}\n" + f" expected: {json.dumps(want, sort_keys=True)}") + + def assert_equal(actual, want, context): + if isinstance(want, dict): + assert isinstance(actual, dict), f"{context}: got {type(actual).__name__}, want object" + for key, value in want.items(): + assert_value(actual.get(key), value, f"{context}.{key}") + elif isinstance(want, list): + assert isinstance(actual, list), f"{context}: got {type(actual).__name__}, want array" + assert len(actual) == len(want), f"{context}: len {len(actual)} != {len(want)}" + for i, item in enumerate(want): + assert_equal(actual[i], item, f"{context}[{i}]") + else: + assert_value(actual, want, context) + for path, payload in PUTS.items(): req = urllib.request.Request( f"http://127.0.0.1:4020{path}", @@ -520,19 +557,10 @@ jobs: doc = json.loads(body) assert doc.get("version") == 1, (path, doc) expected = dict(payload["expected"]) - partial = expected.pop("_partial", False) - actual = doc.get("settings") if "settings" in doc else { - "monitors": doc.get("monitors"), "layout": doc.get("layout"), "settings": doc.get("settings")} + expected.pop("_partial", False) + actual = doc.get("settings") if "settings" in doc else doc for key, want in expected.items(): - if key == "layerVisibility": - # 只断言代表性 layer(46 个全量在 Phase C 双端对比)。 - for layer, layer_want in want.items(): - assert actual["layerVisibility"].get(layer) == layer_want, ( - f"{path}: layerVisibility[{layer}] = {actual['layerVisibility'].get(layer)}, want {layer_want}") - continue - assert actual.get(key) == want, ( - f"{path}: {key} mismatch\n got: {json.dumps(actual.get(key), sort_keys=True)}\n" - f" expected: {json.dumps(want, sort_keys=True)}") + assert_equal(actual.get(key), want, f"{path}: {key}") print(f"PUT {path} -> 200 (NestJS 响应回显 normalized settings 一致, trace header ok)") PY python3 /tmp/smoke_put.py @@ -1172,7 +1200,10 @@ jobs: "enabled": True, "color": "#ff00aa", "location": {"name": "Taipei", "lat": 25.03, "lng": 121.56}, "createdAt": 1757000000000}, - {"id": "Bad location", "name": "Bad location", "keywords": ["y"], "enabled": True}, + # 第二条缺失 id/createdAt——fallback 是非确定值(同 + # Phase A 的哨兵语义:断言存在与形态,不比较精确值)。 + {"id": "@exists:sm-", "name": "Bad location", "keywords": ["y"], "enabled": True, + "createdAt": "@exists:int"}, ], "settings": {"windowHours": 24, "scope": "all", "autoRefresh": True, "resetLayoutOnPreset": True, "translateToZh": True}}, @@ -1181,6 +1212,32 @@ jobs: # Phase D 核心验收:NestJS 已停止,六个 user-settings GET 仍全部 # 由 Go 返回此前真实持久化的数据(Phase A 的 PUT 写入)——证明 # 不是代理或 Shadow 假象。 + def assert_value(got, want, context): + if isinstance(want, str) and want.startswith("@exists:"): + assert got is not None, f"{context}: fallback value missing (want exists)" + form = want[len("@exists:"):] + if form == "int": + assert isinstance(got, int) and got > 1_500_000_000_000, ( + f"{context}: createdAt = {got!r}, want 毫秒时间戳") + elif form.startswith("sm-"): + assert isinstance(got, str) and got.startswith("sm-"), ( + f"{context}: id = {got!r}, want sm- 前缀 fallback") + return + assert got == want, (context, got, want) + + def assert_equal(actual, want, context): + if isinstance(want, dict): + assert isinstance(actual, dict), f"{context}: got {type(actual).__name__}, want object" + for key, value in want.items(): + assert_equal(actual.get(key), value, f"{context}.{key}") + elif isinstance(want, list): + assert isinstance(actual, list), f"{context}: got {type(actual).__name__}, want array" + assert len(actual) == len(want), f"{context}: len {len(actual)} != {len(want)}" + for i, item in enumerate(want): + assert_equal(actual[i], item, f"{context}[{i}]") + else: + assert_value(actual, want, context) + for path, want in EXPECTED.items(): req = urllib.request.Request( f"http://127.0.0.1:4020{path}", @@ -1194,10 +1251,10 @@ jobs: assert doc.get("version") == 1, (path, doc) if "_partial" in want: for key, value in want["_partial"].items(): - assert doc.get(key) == value, (path, key, doc.get(key), value) + assert_equal(doc.get(key), value, (path, key)) else: for key, value in want.items(): - assert doc.get(key) == value, (path, key, doc.get(key), value) + assert_equal(doc.get(key), value, (path, key)) assert isinstance(doc.get("updatedAt", {}).get("settings"), str) and doc["updatedAt"]["settings"], (path, doc) print(f"NestJS 停止后:GET {path} via api-go -> 200(Go handler 独立响应, 数据一致)") diff --git a/apps/api-go/internal/usersettings/situationmonitor.go b/apps/api-go/internal/usersettings/situationmonitor.go index 43729b31..b8970587 100644 --- a/apps/api-go/internal/usersettings/situationmonitor.go +++ b/apps/api-go/internal/usersettings/situationmonitor.go @@ -164,7 +164,12 @@ func NormalizeSituationMonitors(raw []byte) []SituationMonitorCustomMonitor { return []SituationMonitorCustomMonitor{} } out := make([]SituationMonitorCustomMonitor, 0, len(value)) - for _, record := range value { + for _, entry := range value { + // NestJS:!entry || typeof entry !== "object" || Array.isArray → 跳过。 + record, isObject := entry.(map[string]any) + if !isObject { + continue + } name := normalizeSituationName(record["name"]) keywords := normalizeSituationKeywords(record["keywords"]) if name == "" || len(keywords) == 0 { From bb041310e4fa3036928155c5842103d6f844d6ab Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 19:11:07 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(api-go):=20=E4=BF=AE=E6=AD=A3=20war-m?= =?UTF-8?q?ap=20layer=20=E8=AE=A1=E6=95=B0=2046=E2=86=9245=20=E4=B8=8E?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E6=B5=8B=E8=AF=95=20fixture=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 根因(run 34112587502)三处独立: 1. warmap.go:warMapLayerCount 常量误写 46(WAR_MAP_LAYER_IDS 实际 45 项)——[46]T 数组声明 + 45 个初始化器会零值填充第 46 个空 layer id,defaultWarMapVisibility 遍历 46 个下标时 fields[45] 越界 panic。修正常量并同步文档口径。 2. batch3b_test.go:monitors 数量断言错——fixture 4 条中 #2(name 空) 与 #3(无 keywords)整条丢弃,保留 2 条(不是 3);Bad location 的索引也相应是 [1] 不是 [2]。 3. mysql_integration_test.go:步骤 4 的「其他 key 不串读」占位插入用了 ui:war-map:settings:v1——批3B 步骤 7 的真实 WarMapKey 插入撞 orgId+userId+key 唯一键(1062)。占位改用非业务 key。 --- apps/api-go/README.md | 2 +- .../api-go/internal/usersettings/batch3b_test.go | 16 ++++++++-------- .../usersettings/mysql_integration_test.go | 4 +++- apps/api-go/internal/usersettings/warmap.go | 10 +++++----- docs/refactor/go-migration-adr.md | 2 +- docs/refactor/roadmap.md | 2 +- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/api-go/README.md b/apps/api-go/README.md index a0e1c894..96950b61 100644 --- a/apps/api-go/README.md +++ b/apps/api-go/README.md @@ -131,7 +131,7 @@ Go-批3B 起的四阶段验收(全部经 api-go 入口 + 真实登录 JWT) `UserSetting` 表(五个单 key 端点 `orgId+userId+固定 key` 三条件参数化 查询共用同一条私有 SQL;situation-monitor 一次聚合查询三个固定 key, 对齐 NestJS findMany 语义),并与 NestJS 响应差分(normalization 契约 - 逐字段对齐——含批3B 的 war-map 46 layer/legacy key/clamp、newsnow + 逐字段对齐——含批3B 的 war-map 45 layer/legacy key/clamp、newsnow 有序对象/上限/真值、situation-monitor 三段聚合)。 - **legacy-approved shadow identity(信任边界)**:shadow 是回滚兼容路径, 不是 Go 的独立鉴权。身份来源是 legacy 信任委托——同一请求先由 diff --git a/apps/api-go/internal/usersettings/batch3b_test.go b/apps/api-go/internal/usersettings/batch3b_test.go index b3666efc..ba8abcff 100644 --- a/apps/api-go/internal/usersettings/batch3b_test.go +++ b/apps/api-go/internal/usersettings/batch3b_test.go @@ -79,10 +79,10 @@ func TestSituationMonitorContract(t *testing.T) { } response := BuildSituationMonitorResponse(records) - // monitors:4 条输入中 #2(name 空)与 #3(无 keywords)丢弃; - // #4(location lat=999 越界)location 不出现但 monitor 保留 → 3 条。 - if len(response.Monitors) != 3 { - t.Fatalf("monitors = %d, want 3(name 空/无 keywords 丢弃;location 越界仅丢 location): %+v", len(response.Monitors), response.Monitors) + // monitors:4 条输入中 #2(name 空)与 #3(无 keywords)整条丢弃; + // #4(location lat=999 越界)location 不出现但 monitor 保留 → 2 条。 + if len(response.Monitors) != 2 { + t.Fatalf("monitors = %d, want 2(name 空/无 keywords 整条丢弃;location 越界仅丢 location): %+v", len(response.Monitors), response.Monitors) } first := response.Monitors[0] if first.ID != "mon-1" || first.Name != "Taiwan Strait" { @@ -109,11 +109,11 @@ func TestSituationMonitorContract(t *testing.T) { if first.CreatedAt != 1757000000000 { t.Errorf("createdAt = %d, want 1757000000000", first.CreatedAt) } - if response.Monitors[2].Location != nil { - t.Errorf("越界 location 应不出现: %+v", response.Monitors[2].Location) + if response.Monitors[1].Location != nil { + t.Errorf("越界 location 应不出现: %+v", response.Monitors[1].Location) } - if response.Monitors[2].Name != "Bad location" { - t.Errorf("monitors[2].name = %q, want Bad location", response.Monitors[2].Name) + if response.Monitors[1].Name != "Bad location" { + t.Errorf("monitors[1].name = %q, want Bad location", response.Monitors[1].Name) } // layout:legacy `layout` 回退到 lg;md 断点数值取整与最小值。 diff --git a/apps/api-go/internal/usersettings/mysql_integration_test.go b/apps/api-go/internal/usersettings/mysql_integration_test.go index f6af021b..4332529f 100644 --- a/apps/api-go/internal/usersettings/mysql_integration_test.go +++ b/apps/api-go/internal/usersettings/mysql_integration_test.go @@ -141,7 +141,9 @@ func TestUserSettingsMySQLIntegration(t *testing.T) { } // 4. 其他 key 不串读(同 org+user 不同 key 的记录不被 onboarding 查询命中)。 - if _, err := db.ExecContext(ctx, insert, "us-it-2", orgID, userID, "ui:war-map:settings:v1", `{}`, insertedAt); err != nil { + // (批3B 起 war-map 是真实固定 key——占位用非业务 key,避免与步骤 7 + // 的 WarMapKey 插入撞 orgId+userId+key 唯一键。) + if _, err := db.ExecContext(ctx, insert, "us-it-2", orgID, userID, "ui:other-placeholder:v1", `{}`, insertedAt); err != nil { t.Fatalf("insert other key: %v", err) } record, err = repo.FindOnboarding(ctx, orgID, userID) diff --git a/apps/api-go/internal/usersettings/warmap.go b/apps/api-go/internal/usersettings/warmap.go index 30823bbe..a9a5782c 100644 --- a/apps/api-go/internal/usersettings/warmap.go +++ b/apps/api-go/internal/usersettings/warmap.go @@ -2,7 +2,7 @@ // // 权威来源是 packages/utils/src/war-map-contract.ts 的 normalizeWarMapSettings //(499-528 行)——完整移植,不是只实现 smoke fixture 用到的字段: -// - 全部 46 个 layer id 与默认 visibility(WAR_MAP_LAYER_IDS / +// - 全部 45 个 layer id 与默认 visibility(WAR_MAP_LAYER_IDS / // WAR_MAP_DEFAULT_LAYER_VISIBILITY); // - legacy layer key 映射(LEGACY_WAR_MAP_LAYER_KEY_MAP:conflictZones→ // conflicts 等 7 项——只在新 key 无布尔值时作 fallback); @@ -26,8 +26,8 @@ package usersettings const ( - // warMapLayerCount 与 WAR_MAP_LAYER_IDS 长度一致(46)。 - warMapLayerCount = 46 + // warMapLayerCount 与 WAR_MAP_LAYER_IDS 长度一致(45)。 + warMapLayerCount = 45 // warMapVisibilityLegacyCount 与 LEGACY_WAR_MAP_LAYER_KEY_MAP 条目数一致(7)。 warMapVisibilityLegacyCount = 7 ) @@ -92,7 +92,7 @@ type WarMapViewState struct { Pitch float64 `json:"pitch"` } -// WarMapSettings 对齐 NestJS WarMapSettings。LayerVisibility 用固定 46 +// WarMapSettings 对齐 NestJS WarMapSettings。LayerVisibility 用固定 45 // 字段 struct(不是 map):序列化顺序与 NestJS 展开顺序一致,且编译期 // 封闭集合——不存在未知 layer。 type WarMapSettings struct { @@ -105,7 +105,7 @@ type WarMapSettings struct { AisHighlightCandidates bool `json:"aisHighlightCandidates"` } -// warMapLayerVisibility 是 46 个 layer 的固定字段 visibility(字段顺序与 +// warMapLayerVisibility 是 45 个 layer 的固定字段 visibility(字段顺序与 // WAR_MAP_LAYER_IDS 一致)。 type warMapLayerVisibility struct { Conflicts bool `json:"conflicts"` diff --git a/docs/refactor/go-migration-adr.md b/docs/refactor/go-migration-adr.md index 7cbb8cf4..f7763f98 100644 --- a/docs/refactor/go-migration-adr.md +++ b/docs/refactor/go-migration-adr.md @@ -169,7 +169,7 @@ handler、同一鉴权链装配、同一连接池,不复制六份实现: situation-monitor 一次聚合查询三个固定 key(对齐 NestJS `findMany`)。 无任意 key 查询 API——SettingKey 封闭集合。 - **三个新 normalization**(`internal/usersettings`):War Map 完整移植 - `packages/utils/src/war-map-contract.ts`(46 layer + legacy key 映射 + + `packages/utils/src/war-map-contract.ts`(45 layer + legacy key 映射 + viewState clamp + bearing/pitch 归零 + 枚举回退);Situation Monitor 三段聚合(monitors/layout/settings 各自规整 + 三段 updatedAt); NewsNow(有序对象 columnOrders/sourceAffinity——`Object.entries` 顺序 diff --git a/docs/refactor/roadmap.md b/docs/refactor/roadmap.md index 52edc8df..60078dbb 100644 --- a/docs/refactor/roadmap.md +++ b/docs/refactor/roadmap.md @@ -34,7 +34,7 @@ | user-settings 只读 GET 第二批:`rss-reader`、`spacetime-timeline`(Go-批2B) | 🔶 shadow 态(NestJS 仍是响应方)。复用批2A 的 repository(`SettingKey` 编译期固定常量,三个语义方法共享同一条参数化查询)、legacy-approved shadow identity 与失败非阻断语义;normalization 逐字段对齐(RSS:trim/128 截断/稳定去重/严格布尔/provider/targetLanguage;spacetime:枚举回退/浮点 clamp 不取整);远端 CI MySQL integration 扩展验证三 key 读取与 orgId/userId/key 隔离。未做真实生产流量差分验收;**其余三个 GET(situation-monitor/war-map/newsnow)与全部 PUT 仍 legacy**——user-settings 未迁移完成。Go-批3A 后仍是 shadow(本批不扩大迁移范围) | | api-go 真实入口接线 + 容器化(Go-批2C) | 🔶 **已完成远端真实栈运行验证**(非生产流量)。`infra/docker/api-go.Dockerfile`(多阶段、distroless nonroot、`-mod=readonly -trimpath`、内置 `healthcheck` 子命令——exec 形式 HEALTHCHECK)+ compose 独立 `api-go-pilot` profile 服务(:4020,`LEGACY_API_URL=http://api:4000`,同一 MySQL,`CANARY_PERCENT=0`)+ Web/API 入口可切换(`API_BASE_URL` 运行期可指 `http://api-go:4020`;web 启动等待不再硬编码 `api:4000`)。手动 `api-go-entry-smoke` workflow 在远端真实栈(真实 MySQL+migrate+真实 NestJS+api-go 容器+真实登录 JWT)闭环:3 个 PUT 经 api-go 由 NestJS 单写持久化、PUT 零 Shadow 执行、4 个 Shadow GET `executed` 精确 +4、`diffs`/`dropped` 零增量、`inflight` 归零、trace header 传播、三 key 无串读。**默认 legacy 部署不变;生产/预发布真实流量验证未完成;canary/go 仍禁止** | | Go Access Token 鉴权/RBAC 最小闭环 + onboarding GET 首次真实接管(Go-批3A) | 🔶 **已完成远端真实栈下的 onboarding Go 接管验证**(非生产流量)。`GET /api/user-settings/ui/onboarding` 成为 pilot 范围内首个由 Go 全响应的业务端点(`API_GO_ONBOARDING_MODE=go`,默认仍 shadow):Go 独立验签 NestJS 签发的 HS256 access token(`internal/authn`,拒绝 alg=none/算法混淆/mtk_,issuer/audience/exp/nbf 按 jsonwebtoken 语义,jti 缺失按 NestJS 当前语义放行)→ 独立查询真实 Redis blacklist(`access-token:blacklist:`,与 NestJS 同一实例同一 key,查询失败 fail-closed)→ 从真实 MySQL 重推导 User/Org/Membership 有效性与 MembershipRole/RolePermission/Permission 权限(`internal/authz`,与 getUserProfile 同序同文案 401;多角色优先/primary 回退)→ `items.read` 独立判定(**JWT permissions claim 一律不参与**)→ 既有 repository 查 UserSetting 并全响应(`internal/onboarding` + `internal/authhttp` 契约等价错误)。路由层迁移单元改为 exact path + method 白名单(PUT 与 onboarding-x/子路径回落 legacy)。远端真实栈 smoke 验证:契约对比(NestJS 直连 vs Go handler 全等)、数据库无 items.read 而 JWT claim 有 → 双端 403 一致、membership inactive → 双端 401 同文案、真实 logout blacklist → Go 401 revoked、篡改签名/alg=none → 401、**NestJS 停止后 onboarding GET 仍 200(Go 独立接管证明)且 rss-reader 502(非伪装成功)**、onboarding 零 shadow 执行(executed +3=live+rss+spacetime)。**边界如实登记**:登录/refresh/logout/MFA/OIDC/机器令牌仍全部 NestJS(mtk_ 在 Go 端点被拒绝);全部 PUT 仍 NestJS 单写;RSS/Spacetime 仍 shadow;默认部署仍直连 NestJS;这不代表完整 Auth/RBAC 模块迁移完成(迁移序 5 的 MFA/OIDC/refresh/机器令牌未动);canary router 未改造仍不激活;生产流量未切换 | -| user-settings 只读域统一 Go 接管(Go-批3B) | 🔶 **已完成远端真实栈下的六端点 Go 接管验证**(非生产流量)。六个只读 GET(onboarding/rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor)由统一 handler `internal/usersettingsread` 全响应(`API_GO_USER_SETTINGS_READ_MODE=go`,优先级高于批3A 变量;未设置=兼容旧行为):复用批3A 的 authn/authz/authhttp 鉴权链与同一 MySQL/Redis 连接池;repository 扩展到 8 个编译期固定 key(五个单 key 查询共用一条 SQL + situation-monitor 三 key 聚合查询);三个新 normalization(War Map 完整移植 war-map-contract.ts 含 46 layer/legacy key/clamp/归零;Situation Monitor 三段聚合+updatedAt;NewsNow 有序对象 Object.entries 顺序+200/32/300 上限+Boolean 真值+clamp/round)。远端真实栈 smoke 四阶段:Phase A 六 PUT(NestJS 单写,8 key 落库)→ Phase B shadow(executed +6、diffs 零增量)→ Phase C go 契约对比(六端点与 NestJS 直连逐字段全等)→ Phase D 停止 NestJS 后六端点仍 200、代表性 PUT 与未迁移 GET 502。**边界**:六个 PUT 仍全部 NestJS 单写;登录/refresh/logout/MFA/OIDC/机器令牌仍 NestJS;LegacyApprovedIdentity 保留(shadow 回滚路径);canary 未激活;生产流量未切换 | +| user-settings 只读域统一 Go 接管(Go-批3B) | 🔶 **已完成远端真实栈下的六端点 Go 接管验证**(非生产流量)。六个只读 GET(onboarding/rss-reader/spacetime-timeline/war-map/newsnow/situation-monitor)由统一 handler `internal/usersettingsread` 全响应(`API_GO_USER_SETTINGS_READ_MODE=go`,优先级高于批3A 变量;未设置=兼容旧行为):复用批3A 的 authn/authz/authhttp 鉴权链与同一 MySQL/Redis 连接池;repository 扩展到 8 个编译期固定 key(五个单 key 查询共用一条 SQL + situation-monitor 三 key 聚合查询);三个新 normalization(War Map 完整移植 war-map-contract.ts 含 45 layer/legacy key/clamp/归零;Situation Monitor 三段聚合+updatedAt;NewsNow 有序对象 Object.entries 顺序+200/32/300 上限+Boolean 真值+clamp/round)。远端真实栈 smoke 四阶段:Phase A 六 PUT(NestJS 单写,8 key 落库)→ Phase B shadow(executed +6、diffs 零增量)→ Phase C go 契约对比(六端点与 NestJS 直连逐字段全等)→ Phase D 停止 NestJS 后六端点仍 200、代表性 PUT 与未迁移 GET 502。**边界**:六个 PUT 仍全部 NestJS 单写;登录/refresh/logout/MFA/OIDC/机器令牌仍 NestJS;LegacyApprovedIdentity 保留(shadow 回滚路径);canary 未激活;生产流量未切换 | | api 单测基座(vitest) | ✅ 远端 CI 已验证(SEC-01 6/6 + API-01 4/4 + 扫描器语义/基线断言全绿) | 余项(按序): From fdc108d032014fb8fde38e787ff58e2ecfe8f278 Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 19:22:36 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(api-go):=20=E4=BF=AE=E6=AD=A3=20batch?= =?UTF-8?q?3b=20=E4=B8=89=E5=A4=84=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=88=E5=AE=9E=E7=8E=B0=E5=9D=87=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 根因(run 34115328067 verify Go tests)三处均为 fixture/断言错误: 1. cables 断言方向反了——if Cables 触发的是 want-true 场景(新 key true 应被采用),应为 !Cables 才报错。 2. situation settings autoRefresh:输入 0 是数字非布尔——NestJS normalizeBoolean 回退默认 true(不是 false)。 3. visibility 64 项上限:NestJS 对 key 只要求 trim 后非空(无 pattern 检查)——'bad-key!' 也占位。fixture 67 个非空 key 前 64 个存活 = v001…v063 + bad-key!;v064 起丢弃(此前误以为非法 key 不占位)。 同步修正误导性注释(fixture 无 not-a-bool 项)。 --- .../internal/usersettings/batch3b_test.go | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/api-go/internal/usersettings/batch3b_test.go b/apps/api-go/internal/usersettings/batch3b_test.go index ba8abcff..e6965781 100644 --- a/apps/api-go/internal/usersettings/batch3b_test.go +++ b/apps/api-go/internal/usersettings/batch3b_test.go @@ -148,8 +148,8 @@ func TestSituationMonitorContract(t *testing.T) { // settings:windowHours 48 非法 → 24;scope 非法 → all;布尔严格。 if response.Settings.WindowHours != 24 || response.Settings.Scope != "all" || - response.Settings.AutoRefresh || !response.Settings.ResetLayoutOnPreset || !response.Settings.TranslateToZh { - t.Errorf("settings = %+v, want windowHours=24 scope=all autoRefresh=false reset=true translate=true", response.Settings) + !response.Settings.AutoRefresh || !response.Settings.ResetLayoutOnPreset || !response.Settings.TranslateToZh { + t.Errorf("settings = %+v, want windowHours=24 scope=all autoRefresh=true(数字 0 回退默认) reset=true translate=true", response.Settings) } // updatedAt 三段与三份数据对应。 @@ -206,9 +206,9 @@ func TestWarMapContract(t *testing.T) { if settings.LayerVisibility.Conflicts { t.Error("conflicts = true, want false(根对象直读)") } - if settings.LayerVisibility.Cables { - // cables 根对象给了 true——注意:cables 的 legacy key 是 - // cableLandings,这里 "cables" 是新 key,true 应被采用。 + // cables 根对象给了 true(新 key——legacy key 是 cableLandings, + // 不在此 fixture 中):true 应被直接采用。 + if !settings.LayerVisibility.Cables { t.Error("cables = false, want true(新 key 布尔值采用)") } // legacy key:militaryBases=false → bases=false(无新 key 时回退)。 @@ -367,9 +367,11 @@ func TestNewsnowContract(t *testing.T) { }) } -// 有序对象上限语义:situation visibility 的 64 项上限在「第 64 个合法 -// 项之后」停止(Object.entries 顺序决定哪 64 个 key 存活)——用 66 个 -// 交错合法/非法 key 验证存活集合与顺序无关的部分(合法项恰好前 64 个)。 +// 有序对象上限语义:situation visibility 的 64 项上限——NestJS 只过滤 +// 非布尔 value 与 trim 后空 key(key 无 pattern 检查,"bad-key!" 也占位), +// 「前 64 个合法项」由 Object.entries 顺序决定。fixture 交错非法值/空 key/ +// 普通非法 key:非布尔值与空 key 不占位,bad-key! 占位 → 存活的是 +// v001…v063(第 64 个是 bad-key! 之后计数到 64 的最后一个合法 v)。 func TestSituationVisibilityCapKeepsFirst64InOrder(t *testing.T) { raw := []byte(`{"visibility": {` + `"v001": true, "bad-key!": true, "v002": false, "": true, "v003": true,` + @@ -390,11 +392,17 @@ func TestSituationVisibilityCapKeepsFirst64InOrder(t *testing.T) { if len(layout.Visibility) != 64 { t.Fatalf("visibility = %d entries, want 64(上限即停止)", len(layout.Visibility)) } - if _, exists := layout.Visibility["v064"]; !exists { - t.Error("v064 must survive (64th legal entry in order)") + // 存活集合:bad-key! 占一个位置(NestJS 只要求 key trim 后非空——无 + // pattern 检查);空 key("")不占位(trim 后空丢弃)。fixture 的 + // 67 个非空 key 中前 64 个存活 = v001…v063 + bad-key!,v064 起丢弃。 + if _, exists := layout.Visibility["bad-key!"]; !exists { + t.Error("bad-key! must survive (NestJS 只要求 key trim 后非空)") } - if _, exists := layout.Visibility["v065"]; exists { - t.Error("v065 must be dropped (past the 64-entry cap)") + if _, exists := layout.Visibility["v063"]; !exists { + t.Error("v063 must survive (last v before the cap)") + } + if _, exists := layout.Visibility["v064"]; exists { + t.Error("v064 must be dropped (past the 64-entry cap)") } if layout.Visibility["v002"] { t.Error("v002 = true, want false(原值保留)") From 7dfa787c4220246c79422365d11b1374e31b3dcb Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 19:49:58 +0800 Subject: [PATCH 10/11] =?UTF-8?q?ci(api-go):=20=E4=BF=AE=E6=AD=A3=20Phase?= =?UTF-8?q?=20A=20=E5=93=8D=E5=BA=94=E5=BD=A2=E6=80=81=E5=88=A4=E5=88=AB?= =?UTF-8?q?=E2=80=94=E2=80=94situation-monitor=20=E9=A1=B6=E5=B1=82?= =?UTF-8?q?=E4=B9=9F=E6=9C=89=20settings=20=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smoke run 34117253953 根因:Phase A 的 PUT 回显断言用 "settings" in doc 判别响应形态——situation-monitor 的响应顶层三段(monitors/layout/ settings)同样含顶层 settings 键,被误判为单 key 端点的 {settings:{…}} 包裹形态,monitors 断言拿到 None。改以 monitors 段判别(仅 situation-monitor 有)。 --- .github/workflows/api-go-entry-smoke.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api-go-entry-smoke.yml b/.github/workflows/api-go-entry-smoke.yml index e8e759d6..cf0d85a9 100644 --- a/.github/workflows/api-go-entry-smoke.yml +++ b/.github/workflows/api-go-entry-smoke.yml @@ -558,7 +558,11 @@ jobs: assert doc.get("version") == 1, (path, doc) expected = dict(payload["expected"]) expected.pop("_partial", False) - actual = doc.get("settings") if "settings" in doc else doc + # 响应形态判别:situation-monitor 是顶层三段(monitors/layout/ + # settings 都是顶层段);单 key 端点是 {settings: {...}} 包裹 + # ——不能只看 "settings" in doc(situation-monitor 顶层也有 + # settings 段),以 monitors 段判别。 + actual = doc if "monitors" in doc else doc.get("settings") for key, want in expected.items(): assert_equal(actual.get(key), want, f"{path}: {key}") print(f"PUT {path} -> 200 (NestJS 响应回显 normalized settings 一致, trace header ok)") From 3c61779d12a58315732e26377d057e6562e0b67a Mon Sep 17 00:00:00 2001 From: wei500L <3485519861@qq.com> Date: Mon, 7 Sep 2026 19:56:40 +0800 Subject: [PATCH 11/11] =?UTF-8?q?ci(api-go):=20=E4=BF=AE=E6=AD=A3=20Phase?= =?UTF-8?q?=20D=20war-map=20partial=20=E6=96=AD=E8=A8=80=E2=80=94=E2=80=94?= =?UTF-8?q?viewState=20=E7=AD=89=E5=AD=97=E6=AE=B5=E5=9C=A8=20settings=20?= =?UTF-8?q?=E6=AE=B5=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smoke run 34118687382 根因:Phase D 的 _partial 断言直接读响应顶层字段, 而 war-map 的 viewState/activePreset 等在 settings 段内(单 key 端点的 {settings:{…}} 包裹形态)——顶层取到 None。改为先断言 settings 非 null (war-map 数据确实存在),再从 settings 段读 partial 字段。 --- .github/workflows/api-go-entry-smoke.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api-go-entry-smoke.yml b/.github/workflows/api-go-entry-smoke.yml index cf0d85a9..f61cb580 100644 --- a/.github/workflows/api-go-entry-smoke.yml +++ b/.github/workflows/api-go-entry-smoke.yml @@ -1254,8 +1254,11 @@ jobs: doc = json.loads(resp.read()) assert doc.get("version") == 1, (path, doc) if "_partial" in want: + # war-map 的 viewState 等字段在 settings 段内——partial + # 断言读 doc["settings"] 的字段。 + assert doc.get("settings") is not None, (path, doc) for key, value in want["_partial"].items(): - assert_equal(doc.get(key), value, (path, key)) + assert_equal(doc["settings"].get(key), value, (path, key)) else: for key, value in want.items(): assert_equal(doc.get(key), value, (path, key))