// 用户显示名批量只读仓储(identity 侧,实现 academic 的 `port.UserNameReader`)。 // // 为什么实现放在这里而不是 academic:`t_user` 的属主是 identity 域;academic 只声明**接口** // (`internal/academic/port/user_adapter.go`),实现与 SQL 留在本包,依赖方向保持 // `academic → port ← identity`(装配层注入,academic 对 identity 零 import)。 // // 契约细节(与 `port.UserNameReader` 逐条对齐): // - **批量**:一页最多 200 个学生,逐条查会 200 次往返; // - 查不到的 userID **不进 map**(不塞空串)——调用方据此下发 `null` 而不是 `""`, // 前端才能区分"没有这个人"与"姓名为空"; // - 只读:本文件只有 SELECT,没有任何写路径。 package repository import ( "context" "strings" "gorm.io/gorm" "yuansiwei/unified-backend/internal/identity/model" ) // userNameBatchLimit 单次批量查询的 userID 上限(防一条 IN 列表过长打爆 SQL)。 // // 200 = academic 分页上限(`repository.MaxPageSize`),与调用方一页行数一致; // 超过则分批查询(本方法内部循环,调用方无感)。 const userNameBatchLimit = 200 // UserNameRepo 用户显示名只读仓储。 type UserNameRepo struct { // db 连接池句柄(只读用途;不持有事务,展示类查询不需要快照一致性)。 db *gorm.DB } // NewUserNameRepo 构造显示名仓储。 // // 入参:db *gorm.DB(必填)。 // 出参:*UserNameRepo —— 结构化满足 `academic/port.UserNameReader`(装配层做编译期断言)。 func NewUserNameRepo(db *gorm.DB) *UserNameRepo { return &UserNameRepo{db: db} } // DisplayNames 批量取显示名(`t_user.display_name`)。 // // 入参:ctx;userIDs 用户 ULID 列表(可重复/可空)。 // 出参:map[ULID]显示名(仅含"查得到且非空"的项);error 为 DB 错误。 // // 取数口径(**这是 D4 的唯一口径点**): // - 列 = `display_name`(identity 域用户表**唯一**的姓名列;不存在 `real_name` 列); // - 值为 NULL 或全空白 ⇒ **不进 map**(让调用方下发 `null`,与"空字符串"区分); // - 不做任何格式化/拼接(拼接出来的"姓名"无法反查,属伪造数据)。 func (r *UserNameRepo) DisplayNames(ctx context.Context, userIDs []string) (map[string]string, error) { out := make(map[string]string, len(userIDs)) ids := dedupNonEmpty(userIDs) if len(ids) == 0 { return out, nil } if r == nil || r.db == nil { return nil, gorm.ErrInvalidDB } db := r.db if ctx != nil { db = db.WithContext(ctx) } for start := 0; start < len(ids); start += userNameBatchLimit { end := start + userNameBatchLimit if end > len(ids) { end = len(ids) } rows := make([]nameRow, 0, end-start) if err := db.Model(&model.User{}). Select("canonical_user_id", "display_name"). Where("canonical_user_id IN ?", ids[start:end]). Find(&rows).Error; err != nil { return nil, err } for i := range rows { if rows[i].DisplayName == nil { continue // NULL:不放进 map(调用方下发 null) } name := strings.TrimSpace(*rows[i].DisplayName) if name == "" { continue // 空白:同样按"没有名字"处理 } out[strings.TrimSpace(rows[i].CanonicalUserID)] = name } } return out, nil } // nameRow `t_user` 的两列投影(不 SELECT *:用户表可能有凭据类列,越少出库越好)。 type nameRow struct { CanonicalUserID string `gorm:"column:canonical_user_id"` DisplayName *string `gorm:"column:display_name"` } // dedupNonEmpty 去重并剔除空白(IN 列表净化,保持首次出现顺序)。 func dedupNonEmpty(ids []string) []string { out := make([]string, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, id := range ids { trimmed := strings.TrimSpace(id) if trimmed == "" { continue } if _, dup := seen[trimmed]; dup { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } return out }