فهرست منبع

fix(identity): B4 打通 phone+密码 登录链路(Handler 解锁 + e2e 合并门禁)

根因:Handler.Login 硬编码 IdentityTypeUsername,phone 身份进不了登录入口
(Service 层 AuthService.Login 早已支持 phone:isValidPhone + (phone,号,"") 唯一键命中)。

改动:
- LoginReq 增 Phone 字段;username/phone 用 required_without 交叉约束(至少一个非空),
  phone 类型 max=11 兜底(权威 11 位校验在 Service isValidPhone)
- Handler.Login 按 phone 存在与否选 identityType(同传时 phone 优先),
  与 username 共用同一套两步①②协议 + 统一 RS256 token
- 新增 seedPhoneUser + TestPhoneLoginE2E(全链路 login①→issue②→me→重放 1001)
  + TestPhoneLoginE2E_BadParam(非 11 位 → 2001)

验收:go build/vet ./... 全绿;go test ./... -short 全绿;真库 e2e_test 跑
TestPhoneLoginE2E(_BadParam) PASS + identity/org 各包 E2E 全 PASS(无回归)。
注:identity 与 org 两包 e2e 共享 e2e_test 库的同名表,并行跑会 DDL 互撞
(既有现象,非本次引入),逐包单独跑全绿。

合并门禁含 phone 登录 e2e(B4 拍板:合并前打通)。
Q 2 روز پیش
والد
کامیت
5afb640731
2فایلهای تغییر یافته به همراه143 افزوده شده و 5 حذف شده
  1. 17 5
      internal/identity/handler/handler.go
  2. 126 0
      internal/identity/handler/login_e2e_test.go

+ 17 - 5
internal/identity/handler/handler.go

@@ -83,13 +83,17 @@ func New(cfg Config) (*Handler, error) {
 
 // ---- 请求体 DTO(入参;camelCase 与前端契约严格对齐,见 README 命名规范)----
 
-// LoginReq 两步登录① 请求体(PC 账号密码登录)。
+// LoginReq 两步登录① 请求体(账号密码登录,账号 = 用户名或手机号,二选一)。
 //
 // 字段:
-//   - Username:登录账号(用户名;归一/校验在 Service,此处仅校验非空与长度上限)。
+//   - Username:用户名(与 Phone 二选一;归一/校验在 Service,此处仅校验长度上限)。
+//   - Phone:手机号(与 Username 二选一;11 位,Service 层 isValidPhone 做权威位数校验,此处 max=11 兜底)。
 //   - Password:明文密码(6~128 位;**仅内存调用栈,禁止进日志/响应**)。
+//
+// 校验:Username 与 Phone 至少一个非空(required_without 交叉约束);两者都给时 Handler 优先走 phone。
 type LoginReq struct {
-	Username string `json:"username" validate:"required,max=64"`
+	Username string `json:"username" validate:"required_without=Phone,max=64"`
+	Phone    string `json:"phone" validate:"required_without=Username,max=11"`
 	Password string `json:"password" validate:"required,min=6,max=128"`
 }
 
@@ -122,14 +126,22 @@ type LogoutReq struct {
 
 // Login 两步登录**第一步**(POST /auth/login,公开)。
 //
+// 账号 = 用户名或手机号二选一(LoginReq 用 required_without 交叉约束);两者都给时优先 phone。
+// phone 走 model.IdentityTypePhone(Service 层 isValidPhone 权威校验 + 唯一键 (phone, 号, "") 命中),
+// username 走 model.IdentityTypeUsername——两者共用同一套两步①②协议 + 统一 RS256 token。
+//
 // 出参(成功):LoginStep1 → {loginTicket, user, memberships[]}(**不签 token**)。
-// 出参(失败):code 1001(统一文案防枚举)/ 2001(参数)/ 5002(DB·票据写失败)。
+// 出参(失败):code 1001(统一文案防枚举)/ 2001(参数,含手机号位数不合法)/ 5002(DB·票据写失败)。
 func (h *Handler) Login(c *fiber.Ctx) error {
 	var req LoginReq
 	if !validator.Validate(c, &req) { // 校验失败:body code=2001 已写出
 		return nil
 	}
-	res, err := h.svc.Login(c.Context(), model.IdentityTypeUsername, req.Username, req.Password)
+	identityType, identifier := model.IdentityTypeUsername, req.Username
+	if req.Phone != "" { // phone 优先(与 username 同传时按 phone 走)
+		identityType, identifier = model.IdentityTypePhone, req.Phone
+	}
+	res, err := h.svc.Login(c.Context(), identityType, identifier, req.Password)
 	if err != nil {
 		return response.Fail(c, err)
 	}

+ 126 - 0
internal/identity/handler/login_e2e_test.go

@@ -335,6 +335,132 @@ func TestLoginE2E_BadPassword(t *testing.T) {
 	}
 }
 
+// seedPhoneUser 落一个 argon2 用户 + phone 身份(type=phone, identifier=11 位手机号)+ 一条 membership。
+// 与 seedUser 的差异:身份类型为 phone(identifier=手机号;phone 类型 AppID 恒 "",见 DDL v1.2)。
+// 手机号须 11 位数字(Service 层 isValidPhone 权威校验;e2e 用固定合规号 13800001234)。
+func (a *e2eApp) seedPhoneUser(phone, password string, inst, campus int64, role string) string {
+	a.t.Helper()
+	uid, err := idpkg.NewCanonicalUserID()
+	if err != nil {
+		a.t.Fatalf("生成 canonical user id: %v", err)
+	}
+	hash, err := credential.HashArgon2(password)
+	if err != nil {
+		a.t.Fatalf("HashArgon2: %v", err)
+	}
+	ctx := context.Background()
+	now := time.Now()
+	if err := a.gdb.WithContext(ctx).Create(&model.User{
+		CanonicalUserID: uid, PersonType: model.PersonTypeStaff,
+		PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
+		Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error; err != nil {
+		a.t.Fatalf("插入 t_user: %v", err)
+	}
+	if err := a.gdb.WithContext(ctx).Create(&model.Identity{
+		CanonicalUserID: uid, Type: model.IdentityTypePhone, Identifier: phone,
+		// phone 类型 AppID 恒 ""(DDL v1.2:phone/email/username 均空串,仅 wechat 必填)
+		Verified: true, CreatedAt: now,
+	}).Error; err != nil {
+		a.t.Fatalf("插入 t_identity(phone): %v", err)
+	}
+	if err := a.gdb.WithContext(ctx).Exec(
+		`INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
+		 VALUES (?, ?, ?, ?, 'active', ?, ?)`,
+		uid, inst, campus, role, now, now,
+	).Error; err != nil {
+		a.t.Fatalf("插入 t_membership: %v", err)
+	}
+	return uid
+}
+
+// TestPhoneLoginE2E 手机号+密码 登录全链路(B4 / T4 合并门禁):
+// phone 作为与 username 对称的第三种账号标识,走同一套两步①②协议 + 统一 RS256 token。
+// ① POST /auth/login {"phone","password"} → 真查 t_identity(phone)+argon2 验密 → 200 + loginTicket
+// ② POST /auth/issue → 200 + accessToken(本公钥可验签)
+// ③ GET  /auth/me(验签 + scope 注入)→ 200 + 正确用户
+// ④ 重放票据 → 1001(防重放)
+func TestPhoneLoginE2E(t *testing.T) {
+	a := e2eSetup(t)
+	uid := a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
+
+	// ① login:phone 走 IdentityTypePhone 真查 t_identity + argon2 验密 → 200 + loginTicket
+	step1 := a.post(`/auth/login`, `{"phone":"13800001234","password":"e2e-pass-123"}`)
+	if step1.Code != 200 {
+		t.Fatalf("phone login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
+	}
+	var l1 struct {
+		LoginTicket string `json:"loginTicket"`
+		User        struct {
+			ID string `json:"id"`
+		} `json:"user"`
+	}
+	if err := json.Unmarshal(step1.Data, &l1); err != nil {
+		t.Fatalf("解析 login data: %v", err)
+	}
+	if l1.User.ID != uid {
+		t.Fatalf("phone login user.id=%q(期望 %q)", l1.User.ID, uid)
+	}
+	if l1.LoginTicket == "" {
+		t.Fatal("loginTicket 缺失")
+	}
+
+	// ② issue:真查 t_membership + 签 token → 200 + accessToken
+	issueRes := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`)
+	if issueRes.Code != 200 {
+		t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
+	}
+	var pair struct {
+		AccessToken string   `json:"accessToken"`
+		Roles         []string `json:"roles"`
+	}
+	if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
+		t.Fatalf("解析 issue data: %v", err)
+	}
+	if pair.AccessToken == "" || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
+		t.Fatalf("issue pair=%+v(期望 accessToken + roles=[teacher])", pair)
+	}
+
+	// access 可被本公钥验签 + claims 正确
+	claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
+	if err != nil {
+		t.Fatalf("验签 access 失败: %v", err)
+	}
+	if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
+		t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
+	}
+
+	// ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
+	meRes := a.getBearer(`/auth/me`, pair.AccessToken)
+	if meRes.Code != 200 {
+		t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
+	}
+	var me struct{ ID string `json:"id"` }
+	if err := json.Unmarshal(meRes.Data, &me); err != nil {
+		t.Fatalf("解析 me data: %v", err)
+	}
+	if me.ID != uid {
+		t.Fatalf("me.id=%q(期望 %q)", me.ID, uid)
+	}
+
+	// ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
+	if replay := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`); replay.Code != 1001 {
+		t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
+	}
+}
+
+// TestPhoneLoginE2E_BadParam 非法手机号(非 11 位):Service 层 isValidPhone 拦截 → 1001/2001 统一处理。
+// 注意:11 位校验在 Service(ErrBadParam→2001);handler 层 max=11 兜底拦更长串。这里用 10 位号验证 Service 路径。
+func TestPhoneLoginE2E_BadParam(t *testing.T) {
+	a := e2eSetup(t)
+	a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
+	// 10 位手机号:过 handler max=11,但 Service isValidPhone 判非 11 位 → ErrBadParam(2001)
+	res := a.post(`/auth/login`, `{"phone":"1380000123","password":"e2e-pass-123"}`)
+	if res.Code != 2001 {
+		t.Fatalf("code=%d msg=%s(期望 2001 手机号位数不合法)", res.Code, res.Message)
+	}
+}
+
 // seedWechatUser 落一个用户 + wechat 身份(type=wechat, identifier=openid)+ 一条 membership(raw SQL)。
 // 与 seedUser 的差异:无密码哈希(微信登录不验密)、身份类型为 wechat(扫码绑定的 openid)。
 func (a *e2eApp) seedWechatUser(openid string, inst, campus int64, role string) string {