Browse Source

feat(identity): 微信扫码 success 回显 memberships(复用两步②分流)

status/callback success 出参增 membersips(机构/校区/角色),与账密登录
LoginStep1 同源——前端拿到 loginTicket + memberships 后即可走**同一套**
② /auth/issue 分流(单条自动②、多条进作用域选择页),不新增"微信专属
直发 token"链路。

- redis: WechatState 载荷增 memberships(冗余自 t_membership,使轮询与
  callback 两条读法一致、5min 窗口内免回查 DB);新增 MembershipInfo
  (infra 自持最小 DTO,不反向依赖 identity)
- service: WechatService 增 memberships 仓储依赖;Callback 已绑定分支
  取 ListByCanonicalUserID 写入 state;Status 回显转 MembershipView;
  新增 toMembershipInfos / toMembershipViewsFromState 两个映射(后者
  与 auth_service 的 toMembershipViews 区分,避免同包重名)
- main/测试装配注入 Memberships 仓储;handler 测试 success 断言 memberships
Q 3 days ago
parent
commit
e2a00041b3

+ 1 - 0
cmd/api/main.go

@@ -190,6 +190,7 @@ func main() {
 			States:      wxStates,
 			Tickets:     tickets,
 			Identities:  repository.NewIdentityRepo(gdb),
+			Memberships: repository.NewMembershipRepo(gdb),
 			Campuses:    campusCfgs,
 			CallbackURL: cfg.WeChat.CallbackURL,
 		})

+ 10 - 1
internal/identity/handler/handler_test.go

@@ -197,7 +197,7 @@ func newFixture(t *testing.T) *fixture {
 	}
 	fw := &fakeWechat{byCode: map[string]string{}}
 	wxSvc, err := service.NewWechatService(service.WechatConfig{
-		Client: fw, States: wxStates, Tickets: tickets, Identities: ids,
+		Client: fw, States: wxStates, Tickets: tickets, Identities: ids, Memberships: mergs,
 		Campuses:    []service.WechatCampus{{CampusID: 3, AppID: "appid-test", Secret: "secret-test"}},
 		CallbackURL: "https://unified.test/auth/wechat/callback",
 	})
@@ -863,6 +863,11 @@ func TestWechatCallback_Success(t *testing.T) {
 		Status      string `json:"status"`
 		LoginTicket string `json:"loginTicket"`
 		UserID      string `json:"userId"`
+		Memberships []struct {
+			InstitutionID int64  `json:"institutionId"`
+			CampusID      int64  `json:"campusId"`
+			Role          string `json:"role"`
+		} `json:"memberships"`
 	}
 	if err := json.Unmarshal(cbEnv.Data, &cb); err != nil {
 		t.Fatalf("解析 callback data 失败: %v", err)
@@ -876,6 +881,10 @@ func TestWechatCallback_Success(t *testing.T) {
 	if cb.LoginTicket == "" || len(cb.LoginTicket) != 32 {
 		t.Fatalf("loginTicket=%q(期望 32 hex)", cb.LoginTicket)
 	}
+	// success 应回显可选作用域列表(与账密登录 LoginStep1 同源;前端据此走同一套 ② 分流)
+	if len(cb.Memberships) != 1 || cb.Memberships[0].Role != "teacher" || cb.Memberships[0].InstitutionID != 11 {
+		t.Fatalf("memberships=%+v(期望 1 条 inst=11 role=teacher)", cb.Memberships)
+	}
 
 	// ③ 轮询确认 state=success 且携带同一 loginTicket
 	stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil)

+ 1 - 1
internal/identity/handler/login_e2e_test.go

@@ -112,7 +112,7 @@ func e2eSetup(t *testing.T) *e2eApp {
 	// 微信扫码登录服务(真库 identities 仓储 + miniredis 双 store + 打桩 wechat.Client)
 	fw := &fakeWechat{byCode: map[string]string{}}
 	ws, err := service.NewWechatService(service.WechatConfig{
-		Client: fw, States: wxStates, Tickets: tickets, Identities: identities,
+		Client: fw, States: wxStates, Tickets: tickets, Identities: identities, Memberships: memberships,
 		Campuses:    []service.WechatCampus{{CampusID: 3, AppID: "appid-e2e", Secret: "secret-e2e"}},
 		CallbackURL: "https://unified.test/auth/wechat/callback",
 	})

+ 61 - 6
internal/identity/service/wechat_service.go

@@ -24,6 +24,7 @@ import (
 	"yuansiwei/unified-backend/internal/identity/wechat"
 	apperrors "yuansiwei/unified-backend/internal/infra/errors"
 	appredis "yuansiwei/unified-backend/internal/infra/redis"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
 )
 
 // WeChat 扫码状态机取值(state.Status 字段;前端据状态决定是否继续轮询)。
@@ -56,6 +57,7 @@ type WechatService struct {
 	states      *appredis.WechatQrcodeStore
 	tickets     *appredis.LoginTicketStore
 	identities  repository.IdentityRepository
+	memberships repository.MembershipRepository
 	campuses    []WechatCampus
 	callbackURL string
 	stateTTL    time.Duration
@@ -75,6 +77,7 @@ type WechatConfig struct {
 	States      *appredis.WechatQrcodeStore
 	Tickets     *appredis.LoginTicketStore
 	Identities  repository.IdentityRepository
+	Memberships repository.MembershipRepository
 	Campuses    []WechatCampus
 	CallbackURL string
 	// StateTTL / TicketTTL 可选:0/缺省用 5min。
@@ -101,6 +104,9 @@ func NewWechatService(cfg WechatConfig) (*WechatService, error) {
 	if cfg.Identities == nil {
 		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录身份仓储未注入")
 	}
+	if cfg.Memberships == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 成员关系仓储未注入")
+	}
 	if len(cfg.Campuses) == 0 {
 		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 无 per-campus 微信应用配置(Campuses 至少 1 条)")
 	}
@@ -120,6 +126,7 @@ func NewWechatService(cfg WechatConfig) (*WechatService, error) {
 		states:      cfg.States,
 		tickets:     cfg.Tickets,
 		identities:  cfg.Identities,
+		memberships: cfg.Memberships,
 		campuses:    cfg.Campuses,
 		callbackURL: cfg.CallbackURL,
 		stateTTL:    stTTL,
@@ -174,6 +181,9 @@ type StatusResult struct {
 	LoginTicket string `json:"loginTicket,omitempty"`
 	// UserID success 时绑定的用户 ULID(前端可据此展示"已登录 X")。
 	UserID string `json:"userId,omitempty"`
+	// Memberships success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录
+	// 同一套 ② issue 分流:单条自动②、多条进作用域选择页;空 = 该用户暂无机构归属)。
+	Memberships []MembershipView `json:"memberships,omitempty"`
 	// Expired true = state 不存在/已过期(前端停止轮询,提示"二维码已失效,请刷新")。
 	// 与 Status 互斥:Expired=true 时 Status 为空串(调用方按 Expired 优先判断)。
 	Expired bool `json:"expired,omitempty"`
@@ -197,6 +207,7 @@ func (s *WechatService) Status(ctx context.Context, state string) (*StatusResult
 	if st.Status == WechatQrcodeSuccess {
 		res.LoginTicket = st.Ticket
 		res.UserID = st.UserID
+		res.Memberships = toMembershipViewsFromState(st.Memberships)
 	}
 	return res, nil
 }
@@ -255,7 +266,13 @@ func (s *WechatService) Callback(ctx context.Context, code, state string) (*Stat
 		return nil, err // *AppError(ErrDBError)
 	}
 
-	// 已绑定:发 loginTicket(复用 ② 机制)写回 state=success
+	// 取作用域列表(供前端 ② issue 分流:单条自动②、多条进作用域选择页;与账密登录同源)
+	mergs, merr := s.memberships.ListByCanonicalUserID(ctx, it.CanonicalUserID)
+	if merr != nil {
+		return nil, merr // *AppError(ErrDBError)
+	}
+
+	// 已绑定:发 loginTicket(复用 ② 机制)写回 state=success(载荷含 memberships,两条读法一致)
 	ticket, terr := s.newTicket()
 	if terr != nil {
 		return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
@@ -264,15 +281,20 @@ func (s *WechatService) Callback(ctx context.Context, code, state string) (*Stat
 		return nil, setErr // *AppError(ErrDBError)——票据写失败阻断(不放行半套)
 	}
 	final := appredis.WechatState{
-		Status:   WechatQrcodeSuccess,
-		CampusID: st.CampusID,
-		Ticket:   ticket,
-		UserID:   it.CanonicalUserID,
+		Status:      WechatQrcodeSuccess,
+		CampusID:    st.CampusID,
+		Ticket:      ticket,
+		UserID:      it.CanonicalUserID,
+		Memberships: toMembershipInfos(mergs),
 	}
 	if setErr := s.states.SetState(ctx, state, final, s.stateTTL); setErr != nil {
 		return nil, setErr
 	}
-	return &StatusResult{Status: WechatQrcodeSuccess, CampusID: st.CampusID, LoginTicket: ticket, UserID: it.CanonicalUserID}, nil
+	return &StatusResult{
+		Status: WechatQrcodeSuccess, CampusID: st.CampusID,
+		LoginTicket: ticket, UserID: it.CanonicalUserID,
+		Memberships: toMembershipViews(mergs), // org 域来源(auth_service.go 的 toMembershipViews)
+	}, nil
 }
 
 // ---- 私有工具 ----
@@ -304,3 +326,36 @@ func (s *WechatService) newTicket() (string, error) {
 	}
 	return hex.EncodeToString(buf), nil
 }
+
+// toMembershipInfos org 域成员关系切片 → redis 状态机载荷切片(callback 落终态时写入 state)。
+// 空切片保持非 nil(JSON 序列化为 [] 而非 null;无机构归属时前端据此提示"联系管理员")。
+func toMembershipInfos(list []orgmodel.Membership) []appredis.MembershipInfo {
+	out := make([]appredis.MembershipInfo, 0, len(list))
+	for _, m := range list {
+		out = append(out, appredis.MembershipInfo{
+			MembershipID:  m.MembershipID,
+			InstitutionID: m.InstitutionID,
+			CampusID:      m.CampusID,
+			Role:          m.Role,
+			Status:        m.Status,
+		})
+	}
+	return out
+}
+
+// toMembershipViewsFromState redis 状态机载荷切片 → 对外 MembershipView(Status 轮询/callback 回显用)。
+// 与 AuthService.toMembershipViews(org 域来源,同名异签名)区分:本函数源类型为 redis 载荷,
+// 避免同包内重名冲突(auth_service.go 已有 toMembershipViews(orgmodel.Membership))。
+func toMembershipViewsFromState(list []appredis.MembershipInfo) []MembershipView {
+	out := make([]MembershipView, 0, len(list))
+	for _, m := range list {
+		out = append(out, MembershipView{
+			MembershipID:  m.MembershipID,
+			InstitutionID: m.InstitutionID,
+			CampusID:      m.CampusID,
+			Role:          m.Role,
+			Status:        m.Status,
+		})
+	}
+	return out
+}

+ 3 - 3
internal/infra/redis/store_test.go

@@ -349,7 +349,7 @@ func TestWechatQrcodeStore_Miss(t *testing.T) {
 	if err != nil {
 		t.Fatalf("未命中时不应报错: %v", err)
 	}
-	if got != (WechatState{}) {
+	if got.Status != "" || got.CampusID != 0 || got.Ticket != "" || got.UserID != "" {
 		t.Fatalf("未命中应返回 zero 值,实际 %+v", got)
 	}
 }
@@ -369,7 +369,7 @@ func TestWechatQrcodeStore_TTL(t *testing.T) {
 	if err != nil {
 		t.Fatalf("GetState: %v", err)
 	}
-	if got != (WechatState{}) {
+	if got.Status != "" || got.CampusID != 0 || got.Ticket != "" || got.UserID != "" {
 		t.Fatalf("TTL 过期后 GetState 应返回 zero,实际 %+v", got)
 	}
 }
@@ -407,7 +407,7 @@ func TestWechatQrcodeStore_Clear(t *testing.T) {
 		t.Fatalf("ClearState: %v", err)
 	}
 	got, _ := wq.GetState(ctx, "state-cl")
-	if got != (WechatState{}) {
+	if got.Status != "" || got.CampusID != 0 || got.Ticket != "" || got.UserID != "" {
 		t.Fatalf("ClearState 后 GetState 应返回 zero,实际 %+v", got)
 	}
 	if err := wq.ClearState(ctx, "state-cl"); err != nil {

+ 19 - 4
internal/infra/redis/wechat_qrcode_store.go

@@ -36,11 +36,26 @@ const wechatStateKeyPrefix = "wxqrcode:"
 //   - CampusID:本次扫码选定的校区(决定用哪个 appid 换 openid + 提示前端作用域)。
 //   - Ticket:success 时的登录票据(② /auth/issue 消费;pending 时空)。
 //   - UserID:success 时绑定用户的 ULID(前端轮询到 success 后用于展示"已登录 X 的会员")。
+//   - Memberships:success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录
+//     **同一套** ② issue 分流:单条自动②、多条进作用域选择页)。载荷冗余自 t_membership,
+//     使"轮询 status"与"callback 落终态"两条读法都无需回查 DB(扫码会话一次性,作用域在 5min 窗口内不变)。
 type WechatState struct {
-	Status   string `json:"status"`
-	CampusID int64  `json:"campusId"`
-	Ticket   string `json:"ticket,omitempty"`
-	UserID   string `json:"userId,omitempty"`
+	Status      string `json:"status"`
+	CampusID    int64  `json:"campusId"`
+	Ticket      string `json:"ticket,omitempty"`
+	UserID      string `json:"userId,omitempty"`
+	Memberships []MembershipInfo `json:"memberships,omitempty"`
+}
+
+// MembershipInfo 可选作用域的最小载荷(机构/校区/角色/状态;与 identity 的 MembershipView 字段对齐)。
+// 在 infra/redis 定义(而非引用 identity/model),避免 infra 反向依赖 identity(依赖方向铁律);
+// 消费方(identity/service WechatService)负责 orgmodel.Membership → MembershipInfo 的映射。
+type MembershipInfo struct {
+	MembershipID  int64  `json:"membershipId"`
+	InstitutionID int64  `json:"institutionId"`
+	CampusID      int64  `json:"campusId"`
+	Role          string `json:"role"`
+	Status        string `json:"status"`
 }
 
 // WechatQrcodeStore 扫码状态机存储(*Client 直接实现,装配时构造)。