Pārlūkot izejas kodu

feat(academic): D6 新增 3143 重复报名码 + D4 端点53 realName 跨域只读端口

Q 10 stundas atpakaļ
vecāks
revīzija
a293009816

+ 6 - 1
cmd/api/wiring_academic.go

@@ -30,6 +30,7 @@ import (
 	academicrepo "yuansiwei/unified-backend/internal/academic/repository"
 	academicservice "yuansiwei/unified-backend/internal/academic/service"
 	identityhandler "yuansiwei/unified-backend/internal/identity/handler"
+	identityrepo "yuansiwei/unified-backend/internal/identity/repository"
 )
 
 // 编译期契约断言(签名漂移即编译失败,杜绝"注入了但方法对不上"的隐蔽错误):
@@ -39,6 +40,7 @@ import (
 var (
 	_ academicservice.FirstChargedRecordReader = (*academicrepo.RecordRepo)(nil)
 	_ academicservice.DivideLogGranter         = (*academicservice.DivideService)(nil)
+	_ academicport.UserNameReader              = (*identityrepo.UserNameRepo)(nil)
 )
 
 // academicServices academic 域已装配的服务集合(AC-14 接线时在此继续追加 Handler/路由)。
@@ -97,7 +99,10 @@ func registerAcademicRoutes(app *fiber.App, gdb *gorm.DB, authMw fiber.Handler,
 		// 成员校验器(Service 层对象级越权预检):academic 域经 port 只读 t_membership,
 		// 不去 import org 的 repository(依赖方向铁律,见 academic/port/orgadapter.go)。
 		Member: academicport.NewOrgMembershipChecker(gdb),
-		Log:    log,
+		// 用户显示名只读端口(端点 53 的 `realName`,D4 裁决):接口在 academic/port,
+		// 实现放 identity(`t_user` 的属主),此处注入。**academic 对 identity 零 import**。
+		Users: identityrepo.NewUserNameRepo(gdb),
+		Log:   log,
 	})
 	if err != nil {
 		return err

+ 9 - 0
internal/academic/apperr/codes.go

@@ -57,6 +57,12 @@ const (
 	CodeBusinessParam = 3140
 	// CodeActivityFull 报名人数已满(signed_add:number_sign >= number_max)。
 	CodeActivityFull = 3141
+	// CodeEnrollmentDuplicate 重复报名(同一学生在本课程已有在读报课行)。
+	//
+	// 为什么单独立码而不复用 3140:§10.1 v0.1.19 明令 3140 只收"**参数之间**互相矛盾"
+	// (句式「A 与 B 矛盾」)。"该学生已报过名"是**业务状态冲突**,不是参数矛盾;
+	// 塞进 3140 会把 3140 变成垃圾桶(该禁令见 §10.1)。
+	CodeEnrollmentDuplicate = 3143
 )
 
 // 金额/申请/活动/分账/课酬/课程 业务错误的预置 sentinel(业务域直接复用,禁止散落 magic message)。
@@ -101,6 +107,8 @@ var (
 	ErrBusinessParam = infraerrors.New(CodeBusinessParam, "业务参数错误")
 	// ErrActivityFull 报名人数已满(3141)。
 	ErrActivityFull = infraerrors.New(CodeActivityFull, "报名人数已满")
+	// ErrEnrollmentDuplicate 重复报名(3143)。
+	ErrEnrollmentDuplicate = infraerrors.New(CodeEnrollmentDuplicate, "该学生已在本课程在读,请勿重复报名")
 )
 
 // CodeLowerBound / CodeUpperBound academic 业务码段位上下界(含端点,单测与门禁断言用)。
@@ -132,6 +140,7 @@ var all = []*infraerrors.AppError{
 	ErrScheduleConflict,
 	ErrBusinessParam,
 	ErrActivityFull,
+	ErrEnrollmentDuplicate,
 }
 
 // All 返回本包定义的 academic 业务错误全集(供单测断言段位/唯一性、供 Handler 生成错误码文档)。

+ 1 - 0
internal/academic/apperr/codes_test.go

@@ -56,6 +56,7 @@ func TestKnownCodes(t *testing.T) {
 		{"3122 排班冲突", ErrScheduleConflict.Code, 3122},
 		{"3140 业务参数错", ErrBusinessParam.Code, 3140},
 		{"3141 报名人数已满", ErrActivityFull.Code, 3141},
+		{"3143 重复报名", ErrEnrollmentDuplicate.Code, 3143},
 	}
 	for _, p := range pairs {
 		if p.got != p.want {

+ 42 - 1
internal/academic/handler/course_handler.go

@@ -62,10 +62,12 @@
 package handler
 
 import (
+	"context"
 	"strings"
 	"time"
 
 	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
 
 	"yuansiwei/unified-backend/internal/academic/money"
 	"yuansiwei/unified-backend/internal/academic/service"
@@ -669,11 +671,19 @@ func (h *Handler) ListCourseStudents(c *fiber.Ctx) error {
 	if err != nil {
 		return response.Fail(c, err)
 	}
+	// 姓名跨域只读(identity 域 `t_user.display_name`):一次批量取,**查不到 → null**。
+	names := h.displayNames(c.Context(), rows)
 	views := make([]studentRowView, 0, len(rows))
 	for i := range rows {
 		row := &rows[i]
+		var realName *string
+		if name, ok := names[row.UserID]; ok && name != "" {
+			v := name
+			realName = &v
+		}
 		views = append(views, studentRowView{
 			UserID:              row.UserID,
+			RealName:            realName,
 			Pay:                 row.Pay.String(),
 			Cost:                row.Cost.String(),
 			GiveCost:            row.GiveCost.String(),
@@ -691,7 +701,7 @@ func (h *Handler) ListCourseStudents(c *fiber.Ctx) error {
 // 入参(body):见 `EnrollReq`(`userId`/`pay`/`lessonCost` 必填)。
 // 出参(成功):`{enrolled:true, courseId, userId}`。
 // 出参(失败):1001/2001/2002/**3001**(课程不存在/越权)/**1004**(campusId 跨校区)/
-// **3140**(该学生已在本课程在读)/5002。
+// **3143**(该学生已在本课程在读)/5002。
 //
 // 三条资金红线(见 `service.EnrollInput` 注释):不写流水、不动余额;
 // `confirm_discount_user_id` 写操作人(否则该行永远进不了欠费核销候选);重复报名拒绝。
@@ -1004,6 +1014,37 @@ func assertScopeMatches(sc *appscope.Scope, institutionID, campusID int64, op st
 	return nil
 }
 
+// displayNames 批量解析学生显示名(端点 53 的 `realName`)。
+//
+// 入参:ctx;rows 报课学生行(取 `UserID` 去重后批量查)。出参:map[ULID]显示名(查不到的不在 map)。
+//
+// 三条降级策略(都不影响其它字段):
+//   - 端口未注入(`h.users == nil`,如单测/双跑脚本)→ **返回空 map**,`realName` 全部 null;
+//   - 端口报错 → **不冒泡**(列表本身是只读展示,为"少一列姓名"让整页 5002 不划算):
+//     记 warn 级日志(含条数,不含姓名内容——姓名属个人信息,不进日志);
+//   - 查不到某个 userID → 该行 `realName=null`(**不填 `""`**,前端要能区分两种"没名字")。
+func (h *Handler) displayNames(ctx context.Context, rows []service.StudentRow) map[string]string {
+	if h.users == nil || len(rows) == 0 {
+		return map[string]string{}
+	}
+	ids := make([]string, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].UserID)
+	}
+	names, err := h.users.DisplayNames(ctx, ids)
+	if err != nil {
+		if h.log != nil {
+			h.log.Warn("academic: 学生姓名解析失败(realName 降级为 null)",
+				zap.Int("count", len(ids)), zap.Error(err))
+		}
+		return map[string]string{}
+	}
+	if names == nil {
+		return map[string]string{}
+	}
+	return names
+}
+
 // optString 空串 → nil(落库 NULL),非空 → 去空白后的指针。
 //
 // 入参:raw 原始字符串。出参:*string。

+ 136 - 5
internal/academic/handler/course_handler_test.go

@@ -27,7 +27,9 @@
 package handler
 
 import (
+	"context"
 	"encoding/json"
+	"errors"
 	"strings"
 	"testing"
 	"time"
@@ -38,6 +40,7 @@ import (
 	"yuansiwei/unified-backend/internal/academic/apperr"
 	"yuansiwei/unified-backend/internal/academic/model"
 	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
@@ -118,7 +121,15 @@ func newCourseTestDB(t *testing.T) *gorm.DB {
 // `teacher` = 老师、缺省 = 财务(机构 1 / 校区 10)。
 func newCourseTestApp(t *testing.T, db *gorm.DB) *fiber.App {
 	t.Helper()
-	h, err := New(Config{DB: db})
+	return newCourseTestAppWithUsers(t, db, nil)
+}
+
+// newCourseTestAppWithUsers 同 `newCourseTestApp`,但可注入用户姓名端口(D4 用)。
+//
+// 入参:users `port.UserNameReader`(nil = 未注入,端点 53 的 realName 应全部为 null)。
+func newCourseTestAppWithUsers(t *testing.T, db *gorm.DB, users port.UserNameReader) *fiber.App {
+	t.Helper()
+	h, err := New(Config{DB: db, Users: users})
 	if err != nil {
 		t.Fatalf("handler.New 失败: %v", err)
 	}
@@ -715,8 +726,128 @@ func TestListCourseStudentsUsesRemainderReceivable(t *testing.T) {
 	if _, hasUnpaid := row["unpaid"]; hasUnpaid {
 		t.Fatalf("响应下发了 unpaid(%s):废弃列禁下发(设计 §3.8-E4)", string(data))
 	}
-	if _, hasName := row["realName"]; hasName {
-		t.Fatalf("响应下发了 realName(%s):本域无 t_user,不得凭空编姓名", string(data))
+	// 未注入姓名端口时 `realName` 必须是 **null**(键在、值为 nil):
+	// - 不下发该键 → 前端 `row.realName` undefined 与 "" 混在一起,无法区分;
+	// - 下发 "" → 把"没有这个人"和"姓名为空"混在一起。
+	nameValue, hasName := row["realName"]
+	if !hasName {
+		t.Fatalf("响应缺少 realName 键(%s):未注入端口时应下发 null,而不是省掉该键", string(data))
+	}
+	if nameValue != nil {
+		t.Fatalf("realName = %v,期望 null(未注入姓名端口)", nameValue)
+	}
+}
+
+// fakeUserNameReader 姓名端口测试替身(**只实现端口契约**:查不到的不进 map)。
+type fakeUserNameReader struct {
+	names map[string]string
+	err   error
+	// calls 记录调用次数(断言"批量一次"而不是逐行 N 次)
+	calls int
+	last  []string
+}
+
+// DisplayNames 实现 `port.UserNameReader`。
+func (f *fakeUserNameReader) DisplayNames(_ context.Context, userIDs []string) (map[string]string, error) {
+	f.calls++
+	f.last = append([]string{}, userIDs...)
+	if f.err != nil {
+		return nil, f.err
+	}
+	out := make(map[string]string, len(userIDs))
+	for _, id := range userIDs {
+		if name, ok := f.names[id]; ok {
+			out[id] = name
+		}
+	}
+	return out, nil
+}
+
+// TestListCourseStudentsResolvesRealName 端点 53 的 `realName`(D4:跨域只读 port)。
+//
+// 三行数据覆盖三种情形:
+//
+//	A:端口能查到 → `realName` = 姓名
+//	B:端口查不到 → `realName` = **null**(不是 "")
+//	C:端口返回空串 → 同样按"没名字"→ null(空串不该出现在响应里)
+//
+// 变红条件:
+//   - 端口没接(仍是 null)→ A 红;
+//   - 用空串兜底 → B/C 红;
+//   - 逐行查询 → `calls != 1` 红(N+1)。
+func TestListCourseStudentsResolvesRealName(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "姓名测试课", 10)
+	const studentB = "01HSTUDENT0000000000000B"
+	const studentC = "01HSTUDENT0000000000000C"
+	seedEnrollment(t, db, course.ID, courseTestStudent, "NAME-1", "100.00", "300.00", "300.00", "0.00", "0.00")
+	seedEnrollment(t, db, course.ID, studentB, "NAME-2", "100.00", "300.00", "300.00", "0.00", "0.00")
+	seedEnrollment(t, db, course.ID, studentC, "NAME-3", "100.00", "300.00", "300.00", "0.00", "0.00")
+	reader := &fakeUserNameReader{names: map[string]string{
+		courseTestStudent: "张三",
+		studentC:          "", // 端口返回空串:调用方必须当成"没名字"
+	}}
+	app := newCourseTestAppWithUsers(t, db, reader)
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		"/admin/courses/"+itoa(course.ID)+"/students?pageSize=50", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("报课学生列表 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 3 {
+		t.Fatalf("行数 = %d,期望 3", len(items))
+	}
+	byUser := make(map[string]map[string]any, len(items))
+	for _, item := range items {
+		row, _ := item.(map[string]any)
+		uid, _ := row["userId"].(string)
+		byUser[uid] = row
+	}
+	if got, _ := byUser[courseTestStudent]["realName"].(string); got != "张三" {
+		t.Fatalf("A 行 realName = %v,期望 张三(端口已查到,不得为 null)",
+			byUser[courseTestStudent]["realName"])
+	}
+	if v, ok := byUser[studentB]["realName"]; !ok || v != nil {
+		t.Fatalf("B 行 realName = %v,期望 null(端口查不到;空串会让前端无法区分两种没名字)", v)
+	}
+	if v, ok := byUser[studentC]["realName"]; !ok || v != nil {
+		t.Fatalf("C 行 realName = %v,期望 null(端口返回空串 ⇒ 等同于没名字)", v)
+	}
+	// 批量:一页 3 行只应调用端口 1 次(N+1 会变成 3 次)
+	if reader.calls != 1 {
+		t.Fatalf("姓名端口调用 %d 次,期望 1 次(批量)—— 多次说明逐行查", reader.calls)
+	}
+	if len(reader.last) != 3 {
+		t.Fatalf("端口收到 %d 个 ID,期望 3(一页全部学生一次传入)", len(reader.last))
+	}
+}
+
+// TestListCourseStudentsNameReaderFailureDegradesToNull 端口报错时**降级为 null**、列表仍 200。
+//
+// 为什么不让它 5002:列表页因"少一列姓名"整页失败,用户连报课/欠费都看不到——
+// 展示类依赖必须可降级(与 `MembershipChecker` 那种安全依赖相反)。
+func TestListCourseStudentsNameReaderFailureDegradesToNull(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "降级测试课", 10)
+	seedEnrollment(t, db, course.ID, courseTestStudent, "DEGRADE-1", "100.00", "300.00", "300.00", "0.00", "0.00")
+	reader := &fakeUserNameReader{err: errors.New("identity 库不可用")}
+	app := newCourseTestAppWithUsers(t, db, reader)
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		"/admin/courses/"+itoa(course.ID)+"/students", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("姓名端口报错时列表 = %d(%s),期望 200(展示类依赖可降级)", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("行数 = %d,期望 1(降级不影响数据行)", len(items))
+	}
+	row, _ := items[0].(map[string]any)
+	if v, ok := row["realName"]; !ok || v != nil {
+		t.Fatalf("realName = %v,期望 null(端口报错时降级)", v)
+	}
+	if got, _ := row["remainderReceivable"].(string); got != "0.00" {
+		t.Fatalf("remainderReceivable = %v,期望 0.00(降级不得影响其它字段)", row["remainderReceivable"])
 	}
 }
 
@@ -767,8 +898,8 @@ func TestEnrollCreatesSnapshotAndRejectsDuplicate(t *testing.T) {
 
 	// 重复报名 → 3140,且库里仍只有一行
 	code, msg, _ = doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
-	if code != apperr.CodeBusinessParam {
-		t.Fatalf("重复报名 = %d(%s),期望 3140(已在读报课)", code, msg)
+	if code != apperr.CodeEnrollmentDuplicate {
+		t.Fatalf("重复报名 = %d(%s),期望 3143(重复报名)", code, msg)
 	}
 	var count int64
 	db.Model(&model.CoursesStudent{}).Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).Count(&count)

+ 5 - 2
internal/academic/handler/dto_course.go

@@ -428,11 +428,14 @@ type finishRecordResp struct {
 
 // studentRowView 端点 53 的行(契约 §3.4 C 行 53)。
 //
-// ⚠ **无 `realName`**:姓名在 identity 域(`t_user`),academic 域不持有、跨域只读要经 port,
-// 本批未注入姓名解析器 ⇒ 不下发(空字符串会让前端显示空白且无法区分"未填"与"查不到")。
+// `realName` 来源:`t_user.display_name`,经 `port.UserNameReader` 跨域只读(D4 裁决)。
+// ⚠ **查不到时下发 `null` 而不是空串**:空串会让前端无法区分"没有这个人"与"姓名为空";
+// 端口未注入(nil)时同样是 `null`(页面少一列,不影响其它字段)。
 type studentRowView struct {
 	// UserID 学生 ULID(源列 `student_id`)。
 	UserID string `json:"userId"`
+	// RealName 学生显示名(源列 `t_user.display_name`;查不到 → null)。
+	RealName *string `json:"realName"`
 	// Pay 应收(源列 `receivable`)。
 	Pay string `json:"pay"`
 	// Cost 实收(源列 `net_receipts`)。

+ 5 - 1
internal/academic/handler/handler.go

@@ -113,6 +113,9 @@ type Config struct {
 	DB *gorm.DB
 	// Member 成员校验器(可 nil)。
 	Member port.MembershipChecker
+	// Users 用户显示名只读端口(**可 nil**:nil 时端点 53 的 `realName` 下发 null,
+	// 页面少一列姓名,不影响任何判据)。实现放 identity 侧,装配层注入(D4 裁决)。
+	Users port.UserNameReader
 	// Log 日志器(可 nil)。
 	Log *zap.Logger
 }
@@ -126,6 +129,7 @@ type Config struct {
 type Handler struct {
 	db     *gorm.DB
 	member port.MembershipChecker
+	users  port.UserNameReader
 	log    *zap.Logger
 }
 
@@ -139,7 +143,7 @@ func New(cfg Config) (*Handler, error) {
 	if cfg.DB == nil {
 		return nil, infraerrors.New(infraerrors.CodeInternal, "academic handler: DB 未注入")
 	}
-	return &Handler{db: cfg.DB, member: cfg.Member, log: cfg.Log}, nil
+	return &Handler{db: cfg.DB, member: cfg.Member, users: cfg.Users, log: cfg.Log}, nil
 }
 
 // scopedServices 一次请求内装配的服务集合(按作用域现场构造,请求结束即丢弃)。

+ 43 - 0
internal/academic/port/user_adapter.go

@@ -0,0 +1,43 @@
+// 用户姓名只读端口(academic → identity 单向只读,D4 裁决)——**接口在此,实现在 identity 侧**。
+//
+// ---------------------------------------------------------------------------
+// 为什么需要它
+// ---------------------------------------------------------------------------
+//
+// 端点 53(报课学生列表)契约要求 `realName`。姓名列在 `t_user.display_name`(identity 域),
+// academic 域**不持有** `t_user`,而"报课学生列表没有姓名"等于页面不可用 ⇒ 必须跨域只读。
+//
+// ---------------------------------------------------------------------------
+// 依赖方向(设计 §1.3 铁律)
+// ---------------------------------------------------------------------------
+//
+//	academic/handler → academic/port(**接口**) ← identity/repository(**实现**)
+//	                       ↑
+//	                   装配层注入(cmd/api/wiring_academic.go)
+//
+// 三条硬约束(评审逐条检查):
+//
+//  1. **academic 不 import identity**(本文件只定义接口与文档,零业务 import);
+//  2. 实现放 identity 侧(那里才是 `t_user` 的属主),本包不写任何 `t_user` SQL;
+//  3. 端口**只读**(`SELECT`),绝不跨域写。
+//
+// 与 `MembershipChecker` / `CampusReader` 的区别:那两个是**越权判定**依赖(缺失会放宽安全),
+// 本端口只是**展示**依赖 —— 未注入时页面少一列姓名,不影响任何判据(因此可以是 nil)。
+package port
+
+import "context"
+
+// UserNameReader 用户显示名只读端口(端点 53 的 `realName`)。
+//
+// 契约(实现方必须遵守):
+//   - **批量**取(一页最多 200 个学生 ⇒ 禁止逐条查询);
+//   - 返回 `map[userID]displayName`;**查不到的 userID 不要放进 map**(不要塞空串),
+//     调用方据此区分"没有这个人 / 姓名为空"并下发 `null`;
+//   - 任一 DB 错误如实返回,调用方决定是降级(下发 null)还是整体失败。
+type UserNameReader interface {
+	// DisplayNames 批量取显示名。
+	//
+	// 入参:ctx 请求上下文;userIDs 用户 ULID 列表(可重复、可空)。
+	// 出参:map[ULID]显示名(仅含查到的);error 非 nil 表示查询失败。
+	DisplayNames(ctx context.Context, userIDs []string) (map[string]string, error)
+}

+ 2 - 2
internal/academic/service/course_query_service.go

@@ -512,7 +512,7 @@ type EnrollInput struct {
 //
 // 入参:ctx;in 入参。
 // 出参:*model.CoursesStudent(含自增 ID);2001/2002(金额/课次非法)/
-// 3001(课程不存在或不在作用域)/1004(跨校区)/3140(该学生已在本课程在读)/
+// 3001(课程不存在或不在作用域)/1004(跨校区)/**3143**(该学生已在本课程在读)/
 // 5002(DB)。
 //
 // 三条与"资金"相关的强制约束:
@@ -617,7 +617,7 @@ func (s *CourseService) Enroll(ctx context.Context, in EnrollInput) (*model.Cour
 			return cerr
 		}
 		if existing > 0 {
-			return infraerrors.WithCause(apperr.ErrBusinessParam,
+			return infraerrors.WithCause(apperr.ErrEnrollmentDuplicate,
 				errString("service: 该学生已在本课程在读报课,请勿重复报名"))
 		}
 		return s.courses.CreateCoursesStudent(txCtx, tx, row)

+ 113 - 0
internal/identity/repository/user_name_repo.go

@@ -0,0 +1,113 @@
+// 用户显示名批量只读仓储(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
+}

+ 122 - 0
internal/identity/repository/user_name_repo_test.go

@@ -0,0 +1,122 @@
+// 用户显示名批量只读仓储单测(D4:academic 端点 53 的 `realName` 数据源)。
+//
+// 为什么自带 sqlite 库而不走 `setup(t)`:本包的 `setup` 依赖 `UNIFIED_TEST_DSN`(MySQL 集成测试),
+// 未设置时整包 Skip;本用例要证明的是**三条纯逻辑口径**(NULL → 不返回、空白 → 不返回、批量去重),
+// 与方言无关,用 sqlite 才能在默认 CI 环境真跑起来。
+package repository
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+)
+
+// newUserNameTestDB 建只含 `t_user` 的 sqlite 库。
+func newUserNameTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "user_name_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(显示名测试库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+	if err := db.AutoMigrate(&model.User{}); err != nil {
+		t.Fatalf("AutoMigrate(t_user) 失败: %v", err)
+	}
+	return db
+}
+
+// insertUser 落一个用户(display_name 可 NULL)。
+func insertUser(t *testing.T, db *gorm.DB, uid string, displayName *string) {
+	t.Helper()
+	row := &model.User{CanonicalUserID: uid, DisplayName: displayName}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("插入用户 %s 失败: %v", uid, err)
+	}
+	_ = time.Now()
+}
+
+// TestDisplayNamesContracts 三条口径 + 批量去重(**任一条写错都会让端点 53 的姓名列出现脏值**)。
+//
+//	A 有名字          → 返回 "张三"
+//	B display_name 为 NULL → **不进 map**(调用方下发 null)
+//	C display_name 为空白串 → **不进 map**(不能把 " " 当姓名)
+//	D 有名字但重复传两次  → 只查一次、结果一次
+//	E 库里不存在         → 不进 map
+func TestDisplayNamesContracts(t *testing.T) {
+	db := newUserNameTestDB(t)
+	const (
+		userA = "01HUSERA0000000000000000A"
+		userB = "01HUSERB0000000000000000B"
+		userC = "01HUSERC0000000000000000C"
+		userD = "01HUSERD0000000000000000D"
+		userE = "01HUSERE0000000000000000E"
+	)
+	insertUser(t, db, userA, strPtr("张三"))
+	insertUser(t, db, userB, nil)
+	insertUser(t, db, userC, strPtr("   "))
+	insertUser(t, db, userD, strPtr("李四"))
+
+	repo := NewUserNameRepo(db)
+	names, err := repo.DisplayNames(context.Background(),
+		[]string{userA, userB, userC, userD, userD, userE, "  "})
+	if err != nil {
+		t.Fatalf("DisplayNames 失败: %v", err)
+	}
+
+	if got, ok := names[userA]; !ok || got != "张三" {
+		t.Fatalf("A = %q(在 map 内=%v),期望 张三", got, ok)
+	}
+	if _, ok := names[userB]; ok {
+		t.Fatalf("B 在 map 内:display_name 为 NULL 时**不得**返回(调用方要据此下发 null)")
+	}
+	if _, ok := names[userC]; ok {
+		t.Fatalf("C 在 map 内:display_name 为空白串时**不得**返回(空白不是姓名)")
+	}
+	if got, ok := names[userD]; !ok || got != "李四" {
+		t.Fatalf("D = %q(在 map 内=%v),期望 李四", got, ok)
+	}
+	if _, ok := names[userE]; ok {
+		t.Fatalf("E 在 map 内:库中不存在该用户,不得返回")
+	}
+	if len(names) != 2 {
+		t.Fatalf("返回条数 = %d,期望 2(只有 A/D 有真名字):%v", len(names), names)
+	}
+}
+
+// TestDisplayNamesEmptyInput 空入参不发 SQL、返回空 map(不是 nil,避免调用方 nil 解引用)。
+func TestDisplayNamesEmptyInput(t *testing.T) {
+	db := newUserNameTestDB(t)
+	repo := NewUserNameRepo(db)
+	for _, ids := range [][]string{nil, {}, {"", "  "}} {
+		names, err := repo.DisplayNames(context.Background(), ids)
+		if err != nil {
+			t.Fatalf("空入参不该报错: %v", err)
+		}
+		if names == nil {
+			t.Fatalf("空入参应返回空 map 而非 nil(入参 %v)", ids)
+		}
+		if len(names) != 0 {
+			t.Fatalf("空入参应返回 0 条,实际 %d", len(names))
+		}
+	}
+}
+
+// TestDisplayNamesNilDB 未装配 DB 时返回错误(不得 panic)。
+func TestDisplayNamesNilDB(t *testing.T) {
+	repo := NewUserNameRepo(nil)
+	if _, err := repo.DisplayNames(context.Background(), []string{"01HUSERA0000000000000000A"}); err == nil {
+		t.Fatal("DB 为 nil 时应返回错误,而不是静默返回空 map(那会让\"没名字\"与\"库连不上\"混为一谈)")
+	}
+}