2 Achegas 460da46dc8 ... e2a00041b3

Autor SHA1 Mensaxe Data
  Q e2a00041b3 feat(identity): 微信扫码 success 回显 memberships(复用两步②分流) hai 3 días
  Q a5fae3ac12 feat(identity): 微信扫码登录端点(Redis 状态机 + 复用两步②票据) hai 3 días

+ 35 - 2
cmd/api/main.go

@@ -43,6 +43,7 @@ import (
 	"yuansiwei/unified-backend/internal/identity/repository"
 	"yuansiwei/unified-backend/internal/identity/service"
 	"yuansiwei/unified-backend/internal/identity/token"
+	"yuansiwei/unified-backend/internal/identity/wechat"
 	"yuansiwei/unified-backend/internal/infra/config"
 	"yuansiwei/unified-backend/internal/infra/db"
 	"yuansiwei/unified-backend/internal/infra/redis"
@@ -126,6 +127,8 @@ func main() {
 	refreshStore := redis.NewRefreshStore(rc)
 	revocation := redis.NewRevocationStore(rc, cfg.Auth.RevocationKeyPrefix)
 	tickets := redis.NewLoginTicketStore(rc)
+	// 微信扫码登录状态机存储(per-state 会话,双实例共享;与 tickets 同一 Redis)
+	wxStates := redis.NewWechatQrcodeStore(rc)
 
 	accessTTL := time.Duration(cfg.Auth.AccessTokenTTLMin) * time.Minute
 	if accessTTL <= 0 {
@@ -172,7 +175,36 @@ func main() {
 		logger.Error("AuthService 装配失败", zap.Error(err))
 		os.Exit(1)
 	}
-	authH, err := handler.New(handler.Config{Svc: authSvc, Log: logger})
+	// 微信扫码登录服务(可选装配:仅当配齐 per-campus appid + 对外回调地址时注入;
+	// 未配齐时 Handler.wechat 为 nil,/auth/wechat/* 端点返回 4004"未配置"——不阻断其他登录链路)。
+	var wechatSvc *service.WechatService
+	if len(cfg.WeChat.Campuses) > 0 && cfg.WeChat.CallbackURL != "" {
+		campusCfgs := make([]service.WechatCampus, 0, len(cfg.WeChat.Campuses))
+		for _, cp := range cfg.WeChat.Campuses {
+			campusCfgs = append(campusCfgs, service.WechatCampus{
+				CampusID: cp.CampusID, AppID: cp.AppID, Secret: cp.Secret,
+			})
+		}
+		ws, werr := service.NewWechatService(service.WechatConfig{
+			Client:      wechat.NewHTTPClient(),
+			States:      wxStates,
+			Tickets:     tickets,
+			Identities:  repository.NewIdentityRepo(gdb),
+			Memberships: repository.NewMembershipRepo(gdb),
+			Campuses:    campusCfgs,
+			CallbackURL: cfg.WeChat.CallbackURL,
+		})
+		if werr != nil {
+			logger.Error("WechatService 装配失败(微信扫码登录将不可用,不影响账密登录)", zap.Error(werr))
+		} else {
+			wechatSvc = ws
+			logger.Info("微信扫码登录已启用", zap.Int("campuses", len(campusCfgs)))
+		}
+	} else {
+		logger.Warn("微信扫码登录未配置(缺 per-campus appid 或回调地址),/auth/wechat/* 端点将返回 4004")
+	}
+
+	authH, err := handler.New(handler.Config{Svc: authSvc, Wechat: wechatSvc, Log: logger})
 	if err != nil {
 		logger.Error("Handler 装配失败", zap.Error(err))
 		os.Exit(1)
@@ -229,12 +261,13 @@ func main() {
 	})
 
 	// ---- 业务域路由挂载(公开路由须在 Auth Group 之前——铁律 8)----
-	// 公开(无需登录态):两步登录①② + refresh 轮换 + 微信扫码占位
+	// 公开(无需登录态):两步登录①② + refresh 轮换 + 微信扫码(二维码/状态轮询/回调)
 	app.Post("/auth/login", authH.Login)
 	app.Post("/auth/issue", authH.Issue)
 	app.Post("/auth/refresh", authH.Refresh)
 	app.Get("/auth/wechat/qrcode", authH.WechatQrcode)
 	app.Get("/auth/wechat/status", authH.WechatStatus)
+	app.Get("/auth/wechat/callback", authH.WechatCallback)
 
 	// 受保护(前置验签中间件:验签 + 作用域注入)
 	auth := app.Group("/auth")

+ 55 - 12
internal/identity/handler/handler.go

@@ -45,18 +45,23 @@ const ClaimsCtxKey = "authClaims"
 
 // Handler 身份域 HTTP 处理器(启动装配一次,全进程共享)。
 //
-// 字段(装配入口 New 注入,均必填):
-//   - svc:身份域业务服务(两步登录 / me / refresh / logout 编排,T4-3)。
+// 字段(装配入口 New 注入):
+//   - svc:身份域业务服务(两步登录 / me / refresh / logout 编排,T4-3)——**必填**。
+//   - wechat:微信扫码登录服务(二维码/状态轮询/回调编排)——可选(nil 时 /auth/wechat/* 端点
+//     返回 4004"微信扫码未配置";main 仅在 WeChatCfg 配齐 appid+回调地址时注入)。
 //   - log:结构化日志器(logout 撤销失败的告警出口;nil 时静默不记,不阻断)。
 type Handler struct {
-	svc *service.AuthService
-	log *zap.Logger
+	svc    *service.AuthService
+	wechat *service.WechatService
+	log    *zap.Logger
 }
 
 // Config Handler 装配参数(T4-6 main 按此构造)。
 type Config struct {
 	// Svc 身份域业务服务(必填)。
 	Svc *service.AuthService
+	// Wechat 微信扫码登录服务(可选;nil 时微信端点返回 4004 未配置)。
+	Wechat *service.WechatService
 	// Log 结构化日志器(可选;nil 时登出撤销失败不记日志,仍恒 200)。
 	Log *zap.Logger
 }
@@ -71,7 +76,7 @@ func New(cfg Config) (*Handler, error) {
 	if cfg.Svc == nil {
 		return nil, apperrors.New(apperrors.CodeInternal, "handler: AuthService 未注入")
 	}
-	return &Handler{svc: cfg.Svc, log: cfg.Log}, nil
+	return &Handler{svc: cfg.Svc, wechat: cfg.Wechat, log: cfg.Log}, nil
 }
 
 // ---- 请求体 DTO(入参;camelCase 与前端契约严格对齐,见 README 命名规范)----
@@ -210,19 +215,57 @@ func (h *Handler) Logout(c *fiber.Ctx) error {
 	return response.OK(c, nil)
 }
 
-// WechatQrcode 微信扫码登录——获取二维码(GET /auth/wechat/qrcode,占位)。
+// WechatQrcode 微信扫码登录——获取二维码(GET /auth/wechat/qrcode,**公开**)。
 //
-// 现状:**后端微信扫码端点未就绪**(per-campus 授权链路待实现),返回"暂未开放"占位,
-// PC 前端微信 tab 据此显示"暂未开放"。真实实现落地后替换本方法(见 PC 前端契约)。
+// 出参(成功):QRCodeResult → {state, qrUrl, campusId, expiresIn}(前端渲染二维码 + 携 state 轮询)。
+// 出参(失败):code 4004(微信未配置——wechat 服务未注入 / appid·回调地址缺失)/ 5002(Redis 写 state 失败)。
 func (h *Handler) WechatQrcode(c *fiber.Ctx) error {
-	return response.Fail(c, apperrors.New(apperrors.CodeInternal, "微信扫码登录暂未开放"))
+	if h.wechat == nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
+	}
+	res, err := h.wechat.Qrcode(c.Context())
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
 }
 
-// WechatStatus 微信扫码登录——轮询扫码状态(GET /auth/wechat/status?state=xxx,占位)。
+// WechatStatus 微信扫码登录——轮询扫码状态(GET /auth/wechat/status?state=xxx,**公开**)。
 //
-// 现状:同 WechatQrcode,**后端未就绪**,返回"暂未开放"占位。
+// 出参(成功):StatusResult → {status, campusId, loginTicket?, userId?, expired?}。
+// 出参(失败):code 4004(微信未配置)/ 5002(Redis 读 state 失败,fail-closed)。
 func (h *Handler) WechatStatus(c *fiber.Ctx) error {
-	return response.Fail(c, apperrors.New(apperrors.CodeInternal, "微信扫码登录暂未开放"))
+	if h.wechat == nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
+	}
+	state := c.Query("state")
+	if state == "" {
+		return response.Fail(c, apperrors.ErrBadParam)
+	}
+	res, err := h.wechat.Status(c.Context(), state)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// WechatCallback 微信扫码登录——微信重定向落点(GET /auth/wechat/callback?code=&state=,**公开**)。
+//
+// 行为:code 换 openid → 查 t_identity(wechat) → 已绑定发 loginTicket 写回 state=success;
+// 未绑定写回 unbound;第三方失败写回 fail。前端拿到 success 的 loginTicket 后走**同一套** ② /auth/issue。
+//
+// 出参(成功):StatusResult → {status, campusId, loginTicket?, userId?}。
+// 出参(失败):code 4004(微信未配置)/ 1001(state 失效/已终态,防重放)/ 2001(缺 code/state)/
+//   4003(微信换 openid 失败)/ 5002(Redis/DB 故障)。
+func (h *Handler) WechatCallback(c *fiber.Ctx) error {
+	if h.wechat == nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
+	}
+	res, err := h.wechat.Callback(c.Context(), c.Query("code"), c.Query("state"))
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
 }
 
 // claimsFromCtx 从 fiber.Ctx 局部值读取已验签 claims(T4-5 中间件写入 *token.Claims)。

+ 295 - 12
internal/identity/handler/handler_test.go

@@ -120,11 +120,36 @@ var (
 
 // ---- fixture ----
 
+// fakeWechat 微信客户端打桩:code→openid 映射(默认返回 "openid-fixed";可注入失败)。
+type fakeWechat struct {
+	byCode map[string]string
+	fail   bool
+	called int
+}
+
+func (f *fakeWechat) ExchangeCode(_ context.Context, _, _, code, _ string) (string, error) {
+	f.called++
+	if f.fail {
+		return "", &wechatErr{}
+	}
+	if oid, ok := f.byCode[code]; ok {
+		return oid, nil
+	}
+	return "", &wechatErr{}
+}
+
+// wechatErr 实现 wechat 包的"换 openid 失败"错误形态(IsTokenError 兼容:此处仅需非 nil error)。
+type wechatErr struct{}
+
+func (*wechatErr) Error() string { return "wechat: 换 openid 失败(测试打桩)" }
+
 type fixture struct {
 	t           *testing.T
 	users       *mockUsers
 	identities  *mockIdentities
 	memberships *mockMemberships
+	wechat      *fakeWechat
+	wxSvc       *service.WechatService
 	handler     *Handler
 	pubKeys     []string
 	privPEM     string
@@ -152,6 +177,7 @@ func newFixture(t *testing.T) *fixture {
 	refreshStore := appredis.NewRefreshStore(rc)
 	revocation := appredis.NewRevocationStore(rc, "revoked")
 	tickets := appredis.NewLoginTicketStore(rc)
+	wxStates := appredis.NewWechatQrcodeStore(rc)
 
 	issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
 	if err != nil {
@@ -169,12 +195,21 @@ func newFixture(t *testing.T) *fixture {
 	if err != nil {
 		t.Fatalf("NewAuthService: %v", err)
 	}
-	h, err := New(Config{Svc: svc})
+	fw := &fakeWechat{byCode: map[string]string{}}
+	wxSvc, err := service.NewWechatService(service.WechatConfig{
+		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",
+	})
+	if err != nil {
+		t.Fatalf("NewWechatService: %v", err)
+	}
+	h, err := New(Config{Svc: svc, Wechat: wxSvc})
 	if err != nil {
 		t.Fatalf("New Handler: %v", err)
 	}
 	return &fixture{
-		t: t, users: users, identities: ids, memberships: mergs, handler: h,
+		t: t, users: users, identities: ids, memberships: mergs, wechat: fw, wxSvc: wxSvc, handler: h,
 		pubKeys: pubKeys, privPEM: privPEM, issuer: issuer, revoke: revocation, refresh: refreshStore,
 	}
 }
@@ -195,6 +230,16 @@ func genRSAKey(t *testing.T) (string, []string) {
 	return privPEM, []string{pubPEM}
 }
 
+// addWechatUser 造一个用户 + wechat 身份(type=wechat, identifier=openid;扫码登录绑定用)。
+func (fx *fixture) addWechatUser(uid, openid string) {
+	fx.t.Helper()
+	if fx.users.byUID[uid] == nil {
+		fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive}
+	}
+	fx.identities.add(model.IdentityTypeWechat, openid,
+		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid, Verified: true})
+}
+
 // addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。
 func (fx *fixture) addArgon2User(uid, username, password string) {
 	fx.t.Helper()
@@ -260,6 +305,7 @@ func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
 	app.Post("/auth/refresh", fx.handler.Refresh)
 	app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
 	app.Get("/auth/wechat/status", fx.handler.WechatStatus)
+	app.Get("/auth/wechat/callback", fx.handler.WechatCallback)
 
 	// 受保护路由(模拟 T4-5 中间件:把 claims 写入 c.Locals(ClaimsCtxKey))
 	auth := app.Group("/auth")
@@ -709,25 +755,262 @@ func TestLogout_NoClaims(t *testing.T) {
 	}
 }
 
-// ---- 微信占位 ----
+// ---- 微信扫码登录(状态机 + 复用两步票据)----
 
-// TestWechatQrcode_Placeholder 微信扫码占位:code 5001(暂未开放)。
-func TestWechatQrcode_Placeholder(t *testing.T) {
+// TestWechatQrcode_Success 生成二维码:code 200,data 含 state(32hex) + qrUrl(含 state) + campusId + expiresIn。
+func TestWechatQrcode_Success(t *testing.T) {
 	fx := newFixture(t)
 	app := fx.newApp(nil)
 	env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
-	if env.Code != apperrors.CodeInternal {
-		t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
+	}
+	var qr struct {
+		State     string `json:"state"`
+		QRURL     string `json:"qrUrl"`
+		CampusID  int64  `json:"campusId"`
+		ExpiresIn int64  `json:"expiresIn"`
+	}
+	if err := json.Unmarshal(env.Data, &qr); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if len(qr.State) != 32 {
+		t.Fatalf("state 长度=%d(期望 32 hex)", len(qr.State))
+	}
+	if !strings.Contains(qr.QRURL, "open.weixin.qq.com/connect/qrconnect") {
+		t.Fatalf("qrUrl=%q(期望微信网页授权 URL)", qr.QRURL)
+	}
+	if !strings.Contains(qr.QRURL, "state="+qr.State) {
+		t.Fatalf("qrUrl=%q(期望携带 state=%q)", qr.QRURL, qr.State)
+	}
+	if qr.CampusID != 3 {
+		t.Fatalf("campusId=%d(期望 3,配置首个 campus)", qr.CampusID)
+	}
+	if qr.ExpiresIn <= 0 {
+		t.Fatalf("expiresIn=%d(期望 >0 秒)", qr.ExpiresIn)
+	}
+}
+
+// TestWechatStatus_Pending 新 state 轮询:status=pending(继续轮询)。
+func TestWechatStatus_Pending(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	qr := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	var q struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(qr.Data, &q)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+q.State, "", nil)
+	if env.Code != 200 {
+		t.Fatalf("code=%d msg=%s(期望 200)", env.Code, env.Message)
+	}
+	var st struct {
+		Status string `json:"status"`
+	}
+	if err := json.Unmarshal(env.Data, &st); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if st.Status != "pending" {
+		t.Fatalf("status=%q(期望 pending)", st.Status)
+	}
+}
+
+// TestWechatStatus_Expired 未知 state 轮询:expired=true(前端停轮询,提示刷新)。
+func TestWechatStatus_Expired(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=doesnotexist00000000000000000000", "", nil)
+	if env.Code != 200 {
+		t.Fatalf("code=%d msg=%s(期望 200,expired 语义非故障)", env.Code, env.Message)
+	}
+	var st struct {
+		Expired bool `json:"expired"`
+	}
+	if err := json.Unmarshal(env.Data, &st); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if !st.Expired {
+		t.Fatal("未知 state 应 expired=true")
+	}
+}
+
+// TestWechatCallback_Success 扫码成功(已绑定):code 换 openid → 发 loginTicket → status=success。
+// 校验:callback 返回 success + loginTicket + userId;state 内票据可被 ② issue 消费(复用两步②)。
+func TestWechatCallback_Success(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addWechatUser(uid, "openid-bound")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	fx.wechat.byCode["code-ok"] = "openid-bound"
+
+	app := fx.newApp(nil)
+	// ① 生成二维码
+	qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	var qr struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(qrEnv.Data, &qr)
+
+	// ② 微信回调:code=code-ok, state=<state>
+	cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil)
+	if cbEnv.Code != 200 {
+		t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
+	}
+	var cb struct {
+		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)
+	}
+	if cb.Status != "success" {
+		t.Fatalf("status=%q(期望 success)", cb.Status)
+	}
+	if cb.UserID != uid {
+		t.Fatalf("userId=%q(期望 %q)", cb.UserID, uid)
+	}
+	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)
+	var st struct {
+		Status      string `json:"status"`
+		LoginTicket string `json:"loginTicket"`
+	}
+	_ = json.Unmarshal(stEnv.Data, &st)
+	if st.Status != "success" || st.LoginTicket != cb.LoginTicket {
+		t.Fatalf("轮询 status=%q ticket=%q(期望 success + 同票据)", st.Status, st.LoginTicket)
+	}
+
+	// ④ 复用两步②:携 loginTicket 签 token(证明微信登录与账密登录走同一套 ②)
+	issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+cb.LoginTicket+`","institutionId":11,"campusId":3}`, nil)
+	if issueEnv.Code != 200 {
+		t.Fatalf("issue code=%d msg=%s(期望 200:微信票据应可走同一套 ②)", issueEnv.Code, issueEnv.Message)
+	}
+}
+
+// TestWechatCallback_Unbound 扫码成功但 openid 未绑定:status=unbound(前端提示"未绑定账号")。
+func TestWechatCallback_Unbound(t *testing.T) {
+	fx := newFixture(t)
+	fx.wechat.byCode["code-x"] = "openid-not-bound" // 不造该 openid 的身份记录
+
+	app := fx.newApp(nil)
+	qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	var qr struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(qrEnv.Data, &qr)
+
+	cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-x&state="+qr.State, "", nil)
+	if cbEnv.Code != 200 {
+		t.Fatalf("callback code=%d msg=%s(期望 200,unbound 语义)", cbEnv.Code, cbEnv.Message)
+	}
+	var cb struct {
+		Status string `json:"status"`
+	}
+	_ = json.Unmarshal(cbEnv.Data, &cb)
+	if cb.Status != "unbound" {
+		t.Fatalf("status=%q(期望 unbound)", cb.Status)
+	}
+}
+
+// TestWechatCallback_ThirdPartyFail 微信换 openid 失败:status=fail + code 4003(第三方超时/失败)。
+func TestWechatCallback_ThirdPartyFail(t *testing.T) {
+	fx := newFixture(t)
+	fx.wechat.fail = true // 打桩:换 openid 失败
+
+	app := fx.newApp(nil)
+	qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	var qr struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(qrEnv.Data, &qr)
+
+	cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=bad&state="+qr.State, "", nil)
+	if cbEnv.Code != apperrors.CodeThirdTimeout {
+		t.Fatalf("callback code=%d(期望 %d 第三方失败)", cbEnv.Code, apperrors.CodeThirdTimeout)
+	}
+	// state 应被置 fail(前端后续轮询读到 fail)
+	stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil)
+	var st struct {
+		Status string `json:"status"`
+	}
+	_ = json.Unmarshal(stEnv.Data, &st)
+	if st.Status != "fail" {
+		t.Fatalf("state 应置 fail,实际 %q", st.Status)
+	}
+}
+
+// TestWechatCallback_Replay 已终态 state 二次回调:code 1001(防重放)。
+func TestWechatCallback_Replay(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addWechatUser(uid, "openid-bound")
+	fx.wechat.byCode["code-ok"] = "openid-bound"
+
+	app := fx.newApp(nil)
+	qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	var qr struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(qrEnv.Data, &qr)
+
+	// 首次回调 → success(终态)
+	if env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil); env.Code != 200 {
+		t.Fatalf("首次回调 code=%d(期望 200)", env.Code)
+	}
+	// 二次回同样 state → 1001(已终态,防重放)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("重放 callback code=%d(期望 %d 已终态)", env.Code, apperrors.CodeUnauth)
 	}
 }
 
-// TestWechatStatus_Placeholder 微信扫码状态占位:code 5001(暂未开放)。
-func TestWechatStatus_Placeholder(t *testing.T) {
+// TestWechatCallback_MissingParam 缺 code 或 state:code 2001(参数校验)。
+func TestWechatCallback_MissingParam(t *testing.T) {
 	fx := newFixture(t)
 	app := fx.newApp(nil)
-	env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=abc", "", nil)
-	if env.Code != apperrors.CodeInternal {
-		t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=only-code", "", nil)
+	if env.Code != apperrors.CodeBadParam {
+		t.Fatalf("code=%d(期望 %d 缺 state 参数)", env.Code, apperrors.CodeBadParam)
+	}
+}
+
+// TestWechat_NotConfigured wechat 服务未注入:qrcode/status/callback 均 code 4004(未配置)。
+func TestWechat_NotConfigured(t *testing.T) {
+	fx := newFixture(t)
+	// 构造一个 wechat 为 nil 的 handler(复用 fx.svc 之外的 AuthService,此处直接重造 Handler)
+	svc := fx.handler.svc // 复用现有 svc
+	h, err := New(Config{Svc: svc}) // Wechat 缺省 nil
+	if err != nil {
+		t.Fatalf("New Handler: %v", err)
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Get("/auth/wechat/qrcode", h.WechatQrcode)
+	app.Get("/auth/wechat/status", h.WechatStatus)
+	app.Get("/auth/wechat/callback", h.WechatCallback)
+
+	for _, path := range []string{"/auth/wechat/qrcode", "/auth/wechat/status?state=x", "/auth/wechat/callback?code=x&state=x"} {
+		env := do(fx.t, app, http.MethodGet, path, "", nil)
+		if env.Code != apperrors.CodeThirdNotConfigured {
+			t.Fatalf("%s code=%d(期望 %d 未配置)", path, env.Code, apperrors.CodeThirdNotConfigured)
+		}
 	}
 }
 

+ 161 - 2
internal/identity/handler/login_e2e_test.go

@@ -57,6 +57,7 @@ type e2eApp struct {
 	pubKeys    []string
 	revocation *appredis.RevocationStore
 	gdb        *gorm.DB
+	wechat     *fakeWechat // 微信扫码打桩(code→openid 映射;供 TestWechatE2E 配置)
 }
 
 // e2eSetup 建真库 + AutoMigrate 4 表 + 真仓储 + miniredis + 真 Issuer + AuthService + Handler + 路由应用。
@@ -92,6 +93,7 @@ func e2eSetup(t *testing.T) *e2eApp {
 	refreshStore := appredis.NewRefreshStore(rc)
 	revocation := appredis.NewRevocationStore(rc, "revoked")
 	tickets := appredis.NewLoginTicketStore(rc)
+	wxStates := appredis.NewWechatQrcodeStore(rc)
 
 	privPEM, pubKeys := genRSAKey(t)
 	issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
@@ -107,7 +109,17 @@ func e2eSetup(t *testing.T) *e2eApp {
 	if err != nil {
 		t.Fatalf("NewAuthService: %v", err)
 	}
-	h, err := New(Config{Svc: svc})
+	// 微信扫码登录服务(真库 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, Memberships: memberships,
+		Campuses:    []service.WechatCampus{{CampusID: 3, AppID: "appid-e2e", Secret: "secret-e2e"}},
+		CallbackURL: "https://unified.test/auth/wechat/callback",
+	})
+	if err != nil {
+		t.Fatalf("NewWechatService: %v", err)
+	}
+	h, err := New(Config{Svc: svc, Wechat: ws})
 	if err != nil {
 		t.Fatalf("New Handler: %v", err)
 	}
@@ -120,12 +132,15 @@ func e2eSetup(t *testing.T) *e2eApp {
 	app.Post("/auth/login", h.Login)
 	app.Post("/auth/issue", h.Issue)
 	app.Post("/auth/refresh", h.Refresh)
+	app.Get("/auth/wechat/qrcode", h.WechatQrcode)
+	app.Get("/auth/wechat/status", h.WechatStatus)
+	app.Get("/auth/wechat/callback", h.WechatCallback)
 	auth := app.Group("/auth")
 	auth.Use(mw)
 	auth.Get("/me", h.Me)
 	auth.Post("/logout", h.Logout)
 
-	return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb}
+	return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, wechat: fw}
 }
 
 // seedUser 落一个 argon2 用户 + username 身份 + 一条 membership(raw SQL 保证 created_at 确定)。
@@ -179,6 +194,21 @@ func (a *e2eApp) post(path, body string) *envelope {
 	return &env
 }
 
+// get 发 GET 请求(无 body、无鉴权头;微信扫码公开端点用)并解出统一信封。
+func (a *e2eApp) get(path string) *envelope {
+	a.t.Helper()
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	resp, err := a.app.Test(req, -1)
+	if err != nil {
+		a.t.Fatalf("GET %s 请求失败: %v", path, err)
+	}
+	var env envelope
+	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+		a.t.Fatalf("GET %s 解析响应: %v", path, err)
+	}
+	return &env
+}
+
 // getBearer 带 Bearer 头请求并解出统一信封。
 func (a *e2eApp) getBearer(path, bearer string) *envelope {
 	a.t.Helper()
@@ -302,3 +332,132 @@ func TestLoginE2E_BadPassword(t *testing.T) {
 		t.Fatalf("code=%d(期望 1001 统一文案)", res.Code)
 	}
 }
+
+// seedWechatUser 落一个用户 + wechat 身份(type=wechat, identifier=openid)+ 一条 membership(raw SQL)。
+// 与 seedUser 的差异:无密码哈希(微信登录不验密)、身份类型为 wechat(扫码绑定的 openid)。
+func (a *e2eApp) seedWechatUser(openid 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)
+	}
+	ctx := context.Background()
+	now := time.Now()
+	if err := a.gdb.WithContext(ctx).Create(&model.User{
+		CanonicalUserID: uid, PersonType: model.PersonTypeStaff,
+		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.IdentityTypeWechat, Identifier: openid,
+		Verified: true, CreatedAt: now,
+	}).Error; err != nil {
+		a.t.Fatalf("插入 t_identity(wechat): %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
+}
+
+// TestWechatE2E 微信扫码登录全链路(真库):qrcode → callback(code→openid 真查 t_identity) → success+loginTicket
+// → 复用同一套 ② issue 签 token。验证"微信扫码与账密登录共用两步②"的核心契约。
+func TestWechatE2E(t *testing.T) {
+	a := e2eSetup(t)
+	uid := a.seedWechatUser("openid-e2e", 11, 3, "teacher")
+	a.wechat.byCode["code-e2e"] = "openid-e2e" // 打桩:code→openid 映射
+
+	// ① qrcode:200 + state
+	qrEnv := a.get(`/auth/wechat/qrcode`)
+	if qrEnv.Code != 200 {
+		t.Fatalf("qrcode code=%d msg=%s(期望 200)", qrEnv.Code, qrEnv.Message)
+	}
+	var qr struct {
+		State string `json:"state"`
+	}
+	if err := json.Unmarshal(qrEnv.Data, &qr); err != nil {
+		t.Fatalf("解析 qrcode data: %v", err)
+	}
+	if qr.State == "" {
+		t.Fatal("state 缺失")
+	}
+
+	// ② callback:code 换 openid(打桩)→ 真查 t_identity(wechat) → 已绑定 → success + loginTicket
+	cbEnv := a.get(`/auth/wechat/callback?code=code-e2e&state=` + qr.State)
+	if cbEnv.Code != 200 {
+		t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
+	}
+	var cb struct {
+		Status      string `json:"status"`
+		LoginTicket string `json:"loginTicket"`
+		UserID      string `json:"userId"`
+	}
+	if err := json.Unmarshal(cbEnv.Data, &cb); err != nil {
+		t.Fatalf("解析 callback data: %v", err)
+	}
+	if cb.Status != "success" || cb.UserID != uid || cb.LoginTicket == "" {
+		t.Fatalf("callback=%+v(期望 success + userId=%q + loginTicket)", cb, uid)
+	}
+
+	// ③ 复用两步②:携 loginTicket 签 token(证明微信登录与账密登录走同一套 ②)
+	issueRes := a.post(`/auth/issue`, `{"loginTicket":"`+cb.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 == "" {
+		t.Fatal("accessToken 缺失")
+	}
+	if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
+		t.Fatalf("roles=%v(期望 [teacher])", pair.Roles)
+	}
+
+	// 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)
+	}
+
+	// ④ 重放 callback(已终态)→ 1001(防重放)
+	if replay := a.get(`/auth/wechat/callback?code=code-e2e&state=` + qr.State); replay.Code != 1001 {
+		t.Fatalf("重放 callback code=%d(期望 1001 已终态)", replay.Code)
+	}
+}
+
+// TestWechatE2E_Unbound 真库未绑定 openid:callback → unbound(引导去账密登录后绑定)。
+func TestWechatE2E_Unbound(t *testing.T) {
+	a := e2eSetup(t)
+	a.seedWechatUser("openid-other", 11, 3, "teacher") // 造一个已绑定用户(保证表/数据存在)
+	a.wechat.byCode["code-x"] = "openid-not-bound"     // 该 openid 无身份记录
+
+	qrEnv := a.get(`/auth/wechat/qrcode`)
+	var qr struct{ State string `json:"state"` }
+	_ = json.Unmarshal(qrEnv.Data, &qr)
+
+	cbEnv := a.get(`/auth/wechat/callback?code=code-x&state=` + qr.State)
+	if cbEnv.Code != 200 {
+		t.Fatalf("callback code=%d(期望 200 unbound 语义)", cbEnv.Code)
+	}
+	var cb struct {
+		Status string `json:"status"`
+	}
+	_ = json.Unmarshal(cbEnv.Data, &cb)
+	if cb.Status != "unbound" {
+		t.Fatalf("status=%q(期望 unbound)", cb.Status)
+	}
+}
+

+ 361 - 0
internal/identity/service/wechat_service.go

@@ -0,0 +1,361 @@
+// 微信扫码登录服务(PC 端扫码登录链路编排,复用两步登录②票据机制)。
+//
+// 与 AuthService(账密两步登录)的关系:
+//   - 本服务**复用**两步登录的"② 签 token"机制——扫码成功后生成一张 loginTicket 存入 Redis,
+//     前端拿到 status=success + loginTicket 后,与账密登录**完全同一套** ② /auth/issue 签 token。
+//     不在此处直接签 token(不重造 ②,避免"作用域校验/菜单下发"逻辑两处漂移)。
+//   - 本服务只做微信扫码特有的三件事:① 生成二维码(state 落 Redis);② 微信回调 code 换 openid →
+//     查 t_identity(wechat) → 发 loginTicket 写回 state;③ 前端轮询读 state 返回进度。
+//
+// 依赖方向:service → wechat.Client(第三方)+ redis.WeChatQrcodeStore/LoginTicketStore +
+// repository.IdentityRepository + model;不依赖 handler/main(纯业务,可纯单测:
+// fake wechat.Client + miniredis 双 store + mock identity repo)。
+package service
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	stderrors "errors"
+	"time"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/repository"
+	"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 字段;前端据状态决定是否继续轮询)。
+const (
+	// WechatQrcodePending 已生成二维码、等待用户扫码(前端继续轮询)。
+	WechatQrcodePending = "pending"
+	// WechatQrcodeSuccess 扫码成功、已绑定用户、已发 loginTicket(前端停轮询,携 ticket 走 ② issue)。
+	WechatQrcodeSuccess = "success"
+	// WechatQrcodeUnbound 扫码成功但 openid 未绑定本平台账号(前端停轮询,提示"该微信号未绑定账号")。
+	WechatQrcodeUnbound = "unbound"
+	// WechatQrcodeFail 微信换 openid 失败(第三方错误)(前端停轮询,提示"扫码失败,请重试")。
+	WechatQrcodeFail = "fail"
+)
+
+// defaultWechatStateTTL 扫码会话有效期(生成二维码 → 扫码 → 轮询到 success 的窗口;
+// 与 loginTicketTTL 对齐 5min,给前端留足操作时间)。
+const defaultWechatStateTTL = 5 * time.Minute
+
+// WechatService 微信扫码登录业务服务(状态机 + 复用两步票据)。
+//
+// 字段(装配入口 NewWechatService 注入,均必填):
+//   - client:微信网页授权客户端(code 换 openid;生产 *wechat.HTTPClient,单测 fake)。
+//   - states:扫码状态机存储(per-state 会话,Redis;双实例共享)。
+//   - tickets:登录票据存储(扫码成功后签发 loginTicket,复用 AuthService 同一存储)。
+//   - identities:登录身份仓储(查 (type=wechat, identifier=open_id) → canonical_user_id)。
+//   - campuses:per-campus 微信应用配置(来自 config.WeChatCfg.Campuses,决定 appid/secret)。
+//   - callbackURL:对外回调地址(微信重定向落点;未配置时 New 报错,不允许"无回调地址生成二维码")。
+type WechatService struct {
+	client      wechat.Client
+	states      *appredis.WechatQrcodeStore
+	tickets     *appredis.LoginTicketStore
+	identities  repository.IdentityRepository
+	memberships repository.MembershipRepository
+	campuses    []WechatCampus
+	callbackURL string
+	stateTTL    time.Duration
+	ticketTTL   time.Duration
+}
+
+// WechatCampus per-campus 微信应用(从 config.WeChatCampus 转换;Service 自持避免 infra/config 泄漏进单测)。
+type WechatCampus struct {
+	CampusID int64
+	AppID    string
+	Secret   string
+}
+
+// WechatConfig WechatService 装配参数(main 按此构造;全部必填,缺则 New 报错)。
+type WechatConfig struct {
+	Client      wechat.Client
+	States      *appredis.WechatQrcodeStore
+	Tickets     *appredis.LoginTicketStore
+	Identities  repository.IdentityRepository
+	Memberships repository.MembershipRepository
+	Campuses    []WechatCampus
+	CallbackURL string
+	// StateTTL / TicketTTL 可选:0/缺省用 5min。
+	StateTTL  time.Duration
+	TicketTTL time.Duration
+}
+
+// NewWechatService 构造微信扫码登录服务(启动装配一次,全进程共享)。
+//
+// 入参:cfg WechatConfig:见 WechatConfig 字段说明。
+// 出参:
+//   - *WechatService:就绪服务。
+//   - error:必填依赖缺失(nil 检查,fail-fast)/ 无 campus 配置 / callbackURL 为空。
+func NewWechatService(cfg WechatConfig) (*WechatService, error) {
+	if cfg.Client == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 微信客户端未注入")
+	}
+	if cfg.States == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 扫码状态机存储未注入")
+	}
+	if cfg.Tickets == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录票据存储未注入")
+	}
+	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 条)")
+	}
+	if cfg.CallbackURL == "" {
+		return nil, apperrors.New(apperrors.CodeInternal, "wechat: 回调地址为空(CallbackURL 必填,且须为已登记的 https 网页授权回调域名)")
+	}
+	stTTL := cfg.StateTTL
+	if stTTL <= 0 {
+		stTTL = defaultWechatStateTTL
+	}
+	tkTTL := cfg.TicketTTL
+	if tkTTL <= 0 {
+		tkTTL = defaultWechatStateTTL
+	}
+	return &WechatService{
+		client:      cfg.Client,
+		states:      cfg.States,
+		tickets:     cfg.Tickets,
+		identities:  cfg.Identities,
+		memberships: cfg.Memberships,
+		campuses:    cfg.Campuses,
+		callbackURL: cfg.CallbackURL,
+		stateTTL:    stTTL,
+		ticketTTL:   tkTTL,
+	}, nil
+}
+
+// QRCodeResult 生成二维码出参(前端渲染 + 轮询凭据)。
+type QRCodeResult struct {
+	// State 会话标识(16 字节 hex;前端据此轮询 /auth/wechat/status?state=)。
+	State string `json:"state"`
+	// QRURL 二维码内容(指向微信网页授权 URL,含 state;前端生成二维码图片)。
+	QRURL string `json:"qrUrl"`
+	// CampusID 本次选定的校区(提示前端"正在登录 X 校区")。
+	CampusID int64 `json:"campusId"`
+	// ExpiresIn 会话有效期(秒;前端据此展示倒计时/停止轮询)。
+	ExpiresIn int64 `json:"expiresIn"`
+}
+
+// Qrcode 生成扫码登录二维码(GET /auth/wechat/qrcode 编排)。
+//
+// 行为:选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) → 返回 state + QR URL。
+//
+// 出参:
+//   - *QRCodeResult:state + 二维码 URL。
+//   - error:*AppError(CodeInternal:Redis 写失败——fail-closed,不放行"无 state 的二维码")。
+func (s *WechatService) Qrcode(ctx context.Context) (*QRCodeResult, error) {
+	campus := s.campuses[0] // 多 campus 选第一个(后续可加 campus 选择入参,本期 PC 统一)
+	state, err := s.newState()
+	if err != nil {
+		return nil, apperrors.WithCause(apperrors.ErrInternal, err)
+	}
+	st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID}
+	if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil {
+		return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
+	}
+	return &QRCodeResult{
+		State:     state,
+		QRURL:     wechat.BuildQRConnectURL(campus.AppID, s.callbackURL, state),
+		CampusID:  campus.CampusID,
+		ExpiresIn: int64(s.stateTTL / time.Second),
+	}, nil
+}
+
+// StatusResult 轮询状态出参(前端据此驱动 UI:pending 继续轮 / success 携 ticket 走 ② / 终态停轮)。
+type StatusResult struct {
+	// Status 状态机取值(pending/success/unbound/fail,见 WechatQrcode* 常量)。
+	Status string `json:"status"`
+	// CampusID 本次会话的校区(success/unbound 时回显,供前端提示)。
+	CampusID int64 `json:"campusId"`
+	// LoginTicket success 时携带的登录票据(前端用同一套 ② /auth/issue 签 token)。
+	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"`
+}
+
+// Status 轮询扫码状态(GET /auth/wechat/status?state= 编排;只读快照,不消费)。
+//
+// 出参:
+//   - *StatusResult:当前状态(见 StatusResult 字段)。
+//   - error:*AppError(CodeInternal/DBError:Redis 读失败——fail-closed,不放行"读不到当已失效")。
+func (s *WechatService) Status(ctx context.Context, state string) (*StatusResult, error) {
+	st, err := s.states.GetState(ctx, state)
+	if err != nil {
+		return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
+	}
+	if st.Status == "" {
+		// 键不存在/已过期:返回"已失效"(前端停轮询,提示刷新)——这是正常的"过期"语义,非故障。
+		return &StatusResult{Expired: true}, nil
+	}
+	res := &StatusResult{Status: st.Status, CampusID: st.CampusID}
+	if st.Status == WechatQrcodeSuccess {
+		res.LoginTicket = st.Ticket
+		res.UserID = st.UserID
+		res.Memberships = toMembershipViewsFromState(st.Memberships)
+	}
+	return res, nil
+}
+
+// Callback 微信重定向落点(GET /auth/wechat/callback?code=&state= 编排)。
+//
+// 行为:读 state 会话 → 校验仍 pending(防重放)→ code 换 openid → 查 t_identity(wechat) →
+//   - 命中:发 loginTicket(复用 ② 机制)写回 state=success;
+//   - 未命中:state=unbound(提示"该微信号未绑定账号")。
+//
+// 出参:
+//   - *StatusResult:回调后的状态(success/unbound/fail,前端据此决定跳登录或提示)。
+//   - error:*AppError:
+//     - ErrDBError(5002):Redis 读写故障(fail-closed);
+//     - CodeThirdTimeout(4003):微信换 openid 失败(state 置 fail);
+//     - ErrUnauth(1001):state 不存在/已过期/已终态(防重放,前端提示"二维码已失效")。
+func (s *WechatService) Callback(ctx context.Context, code, state string) (*StatusResult, error) {
+	if code == "" || state == "" {
+		return nil, apperrors.ErrBadParam
+	}
+	st, err := s.states.GetState(ctx, state)
+	if err != nil {
+		return nil, err // Redis 故障 fail-closed
+	}
+	if st.Status == "" {
+		// state 不存在/已过期:拒绝(防重放 + 防任意 state 注入)
+		return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已失效,请重新获取二维码")
+	}
+	if st.Status != WechatQrcodePending {
+		// 已终态(success/unbound/fail):拒绝二次回调(防重放)
+		return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已结束,请重新获取二维码")
+	}
+
+	campus := s.campusByID(st.CampusID)
+	if campus == nil {
+		// 会话 campusId 对不上配置(配置热变更/脏数据):置 fail
+		_ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
+		return &StatusResult{Status: WechatQrcodeFail, CampusID: st.CampusID}, nil
+	}
+
+	// code 换 openid(第三方;失败置 fail)
+	openid, err := s.client.ExchangeCode(ctx, campus.AppID, campus.Secret, code, s.callbackURL)
+	if err != nil {
+		_ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
+		return nil, apperrors.New(apperrors.CodeThirdTimeout, "微信扫码失败,请重试") // 4003 第三方
+	}
+
+	// 查绑定:(type=wechat, identifier=open_id) → canonical_user_id
+	it, err := s.identities.GetByTypeIdentifier(ctx, model.IdentityTypeWechat, openid)
+	if err != nil {
+		if stderrors.Is(err, apperrors.ErrNotFound) {
+			// 未绑定:置 unbound(前端提示"该微信号未绑定账号",引导去账密登录后绑定)
+			_ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, s.stateTTL)
+			return &StatusResult{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, nil
+		}
+		return nil, err // *AppError(ErrDBError)
+	}
+
+	// 取作用域列表(供前端 ② 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)
+	}
+	if setErr := s.tickets.SetTicket(ctx, ticket, it.CanonicalUserID, s.ticketTTL); setErr != nil {
+		return nil, setErr // *AppError(ErrDBError)——票据写失败阻断(不放行半套)
+	}
+	final := appredis.WechatState{
+		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,
+		Memberships: toMembershipViews(mergs), // org 域来源(auth_service.go 的 toMembershipViews)
+	}, nil
+}
+
+// ---- 私有工具 ----
+
+// campusByID 按 campusId 取配置(找不到返回 nil——调用方按"脏数据"置 fail)。
+func (s *WechatService) campusByID(campusID int64) *WechatCampus {
+	for i := range s.campuses {
+		if s.campuses[i].CampusID == campusID {
+			return &s.campuses[i]
+		}
+	}
+	return nil
+}
+
+// newState 生成扫码会话 state(16 字节随机 → 32 hex)。
+func (s *WechatService) newState() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+// newTicket 生成登录票据(16 字节随机 → 32 hex,与 AuthService.newTicket 同语义)。
+func (s *WechatService) newTicket() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	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
+}

+ 148 - 0
internal/identity/wechat/wechat.go

@@ -0,0 +1,148 @@
+// Package wechat 微信公众号"网页授权扫码登录"第三方客户端封装(PC 端扫码登录用)。
+//
+// 职责边界(架构规格 §3.2 三层之"依赖倒置"的第三方消费点):
+//   - 本包只做**与微信服务端通信**这一件事:拿授权 code 换 openid(网页授权 snsapi_login);
+//     不做登录票据、不做作用域、不碰 DB/Redis(那些归 identity/service 的 WechatService 编排)。
+//   - 消费方 identity/service 依赖本包定义的 Client 接口(生产注入 *HTTPClient,单测注入 fake),
+//     使"换 openid"这一外部 I/O 可被替换/打桩——纯单测不连微信。
+//
+// 为什么用 GET 而非 SDK:微信网页授权换 token 是单一 GET 调用(无签名/无长连接),
+// 标准库 net/http 足够,不引入重量级 wechat SDK(减少依赖面 + 升级面)。
+//
+// 安全纪律:
+//   - secret 只出现在对微信服务端的请求参数里(内存调用栈),**禁止**进日志/响应体/二维码 URL;
+//   - BaseURL 可注入(测试用 httptest 打桩,不真连微信);生产默认 api.weixin.qq.com。
+package wechat
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// errToken 换 openid 失败(微信返回 errcode!=0 / 网络故障 / 响应非 JSON)。
+// 归一出口:调用方(WechatService)按"第三方失败"处理(state 置 fail,前端提示"扫码失败")。
+type errToken struct{ msg string }
+
+func (e *errToken) Error() string { return "wechat: " + e.msg }
+
+// IsTokenError 判断是否为本包"换 openid 失败"错误(区别于"openid 未绑定"等业务分支)。
+func IsTokenError(err error) bool {
+	_, ok := err.(*errToken)
+	return ok
+}
+
+// Client 网页授权"code 换 openid"抽象(依赖倒置接口,消费方 identity/service 持有)。
+type Client interface {
+	// ExchangeCode 用扫码授权 code 换 openid(网页扫码登录 snsapi_login,仅取 openid 不做用户信息拉取)。
+	//
+	// 入参:
+	//   - ctx context.Context:请求上下文(超时/取消由 http.Client 接管)。
+	//   - appID / secret string:该校区公众号 appid/appSecret(来自 config.WeChatCfg.Campuses)。
+	//   - code string:微信重定向回带的授权 code(一次性,数秒~分钟级有效)。
+	//   - redirectURI string:回调地址(须与发起授权时一致,微信校验)。
+	//
+	// 出参:
+	//   - openid string:该用户在此公众号下的 openid(identity 层 (type=wechat, appid, openid) 唯一定位)。
+	//   - error:*errToken(微信 errcode!=0 / 网络故障 / 响应非法),IsTokenError 可判。
+	ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (openid string, err error)
+}
+
+// HTTPClient 真实实现(调 api.weixin.qq.com/sns/oauth2/access_token)。
+type HTTPClient struct {
+	// HTTP 底层客户端(带超时;生产默认 8s,测试可注入)。
+	HTTP *http.Client
+	// BaseURL 便于测试注入 mock(生产默认 https://api.weixin.qq.com,含协议头、无尾斜杠)。
+	BaseURL string
+}
+
+// NewHTTPClient 构造真实微信客户端(生产装配用;BaseURL 用默认值)。
+func NewHTTPClient() *HTTPClient {
+	return &HTTPClient{
+		HTTP:    &http.Client{Timeout: 8 * time.Second},
+		BaseURL: "https://api.weixin.qq.com",
+	}
+}
+
+// accessTokenResp 微信换 token 接口出参(成功/失败共用结构,errcode==0 才成功)。
+type accessTokenResp struct {
+	AccessToken string `json:"access_token"`
+	OpenID      string `json:"openid"`
+	ErrCode     int    `json:"errcode"`
+	ErrMsg      string `json:"errmsg"`
+}
+
+// ExchangeCode 实现 Client:GET 换 openid(见接口文档)。
+func (c *HTTPClient) ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (string, error) {
+	q := url.Values{}
+	q.Set("appid", appID)
+	q.Set("secret", secret)
+	q.Set("code", code)
+	q.Set("grant_type", "authorization_code")
+	// 注:网页授权 snsapi_login 的 access_token 接口**不强制** redirect_uri(区别于 snsapi_userinfo),
+	// 但微信文档建议带以防 code 与回调地址不匹配误用。这里带上,与 BuildQRConnectURL 的 redirect_uri 一致。
+	if redirectURI != "" {
+		q.Set("redirect_uri", redirectURI)
+	}
+	u := c.BaseURL + "/sns/oauth2/access_token?" + q.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+	if err != nil {
+		return "", &errToken{msg: "构造请求失败: " + err.Error()}
+	}
+	resp, err := c.HTTP.Do(req)
+	if err != nil {
+		return "", &errToken{msg: "请求微信换 openid 失败: " + err.Error()}
+	}
+	defer func() { _ = resp.Body.Close() }()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) // 64KB 上限防异常大响应
+	if err != nil {
+		return "", &errToken{msg: "读取微信响应失败: " + err.Error()}
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", &errToken{msg: fmt.Sprintf("微信换 openid 返回 HTTP %d", resp.StatusCode)}
+	}
+	var ar accessTokenResp
+	if err := json.Unmarshal(body, &ar); err != nil {
+		return "", &errToken{msg: "解析微信响应失败(非 JSON): " + err.Error()}
+	}
+	if ar.ErrCode != 0 {
+		// errcode/errmsg 是微信给的(脱敏安全),但拼接前做截断防异常长串
+		return "", &errToken{msg: fmt.Sprintf("微信拒绝换 openid:errcode=%d %s", ar.ErrCode, truncate(ar.ErrMsg, 128))}
+	}
+	if ar.OpenID == "" {
+		return "", &errToken{msg: "微信响应缺少 openid(errcode=0 但 openid 为空)"}
+	}
+	return ar.OpenID, nil
+}
+
+// BuildQRConnectURL 构造扫码登录二维码指向的**网页授权 URL**(前端把该 URL 渲染成二维码)。
+//
+// 微信网页扫码登录(snsapi_login)固定走 open.weixin.qq.com/connect/qrconnect,
+// 参数:appid / redirect_uri(回调地址,URL-encoded)/ response_type=code /
+// scope=snsapi_login(仅取 openid,不拉头像昵称)/ state(本系统生成的会话标识,防 CSRF)。
+//
+// 入参:appID / redirectURI / state(见上)。
+// 出参:可直接作为二维码内容的完整 URL(含 #wechat_redirect 尾段——微信约定必须保留)。
+func BuildQRConnectURL(appID, redirectURI, state string) string {
+	q := url.Values{}
+	q.Set("appid", appID)
+	q.Set("redirect_uri", redirectURI)
+	q.Set("response_type", "code")
+	q.Set("scope", "snsapi_login")
+	q.Set("state", state)
+	return "https://open.weixin.qq.com/connect/qrconnect?" + q.Encode() + "#wechat_redirect"
+}
+
+// truncate 截断字符串到 n 字节(防微信 errmsg 异常长时污染错误串/日志)。
+func truncate(s string, n int) string {
+	if len(s) <= n {
+		return s
+	}
+	return strings.TrimSpace(s[:n]) + "…"
+}

+ 125 - 0
internal/identity/wechat/wechat_test.go

@@ -0,0 +1,125 @@
+// wechat 客户端单测:code 换 openid(httptest 打桩微信服务端)+ 扫码授权 URL 构造。
+//
+// 不连真微信:BaseURL 注入 httptest.Server,覆盖成功 / errcode!=0 / 网络故障 / 响应非法 JSON,
+// 以及 BuildQRConnectURL 的参数完整性(appid/redirect_uri/scope= snsapi_login/state + #wechat_redirect)。
+package wechat
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"regexp"
+	"strings"
+	"testing"
+	"time"
+)
+
+// newStub 起一个 httptest 微信服务替身,按 handler 返回预置响应。
+func newStub(t *testing.T, handler http.HandlerFunc) *HTTPClient {
+	t.Helper()
+	srv := httptest.NewServer(handler)
+	t.Cleanup(srv.Close)
+	return &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL}
+}
+
+// TestExchangeCode_Success 微信返回 openid:ExchangeCode 应取回原值。
+func TestExchangeCode_Success(t *testing.T) {
+	var gotAppID, gotSecret, gotCode, gotRedirect string
+	c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
+		q := r.URL.Query()
+		gotAppID = q.Get("appid")
+		gotSecret = q.Get("secret")
+		gotCode = q.Get("code")
+		gotRedirect = q.Get("redirect_uri")
+		_, _ = w.Write([]byte(`{"access_token":"at-1","openid":"openid-x","errcode":0,"errmsg":"ok"}`))
+	})
+	oid, err := c.ExchangeCode(context.Background(), "appid-a", "secret-b", "code-1", "https://cb.test/auth/wechat/callback")
+	if err != nil {
+		t.Fatalf("ExchangeCode: %v", err)
+	}
+	if oid != "openid-x" {
+		t.Fatalf("openid=%q(期望 openid-x)", oid)
+	}
+	if gotAppID != "appid-a" || gotSecret != "secret-b" || gotCode != "code-1" {
+		t.Fatalf("请求参数不符:appid=%q secret=%q code=%q", gotAppID, gotSecret, gotCode)
+	}
+	if gotRedirect != "https://cb.test/auth/wechat/callback" {
+		t.Fatalf("redirect_uri=%q(期望回调地址原样透传)", gotRedirect)
+	}
+}
+
+// TestExchangeCode_WechatReject errcode!=0:返回 *errToken,IsTokenError 可判。
+func TestExchangeCode_WechatReject(t *testing.T) {
+	c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(`{"errcode":40029,"errmsg":"invalid code"}`))
+	})
+	_, err := c.ExchangeCode(context.Background(), "a", "s", "bad", "")
+	if err == nil {
+		t.Fatal("errcode!=0 应返回 error")
+	}
+	if !IsTokenError(err) {
+		t.Fatalf("应为 *errToken,实际 %T: %v", err, err)
+	}
+}
+
+// TestExchangeCode_EmptyOpenID errcode=0 但 openid 缺失:按失败处理(防半成功态)。
+func TestExchangeCode_EmptyOpenID(t *testing.T) {
+	c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(`{"access_token":"at","errcode":0,"errmsg":"ok"}`))
+	})
+	_, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
+	if err == nil {
+		t.Fatal("errcode=0 但 openid 空应返回 error(防半成功态)")
+	}
+}
+
+// TestExchangeCode_NonJSON 响应非 JSON:返回 error(fail-closed,不误判成功)。
+func TestExchangeCode_NonJSON(t *testing.T) {
+	c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("<html>bad gateway</html>"))
+	})
+	_, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
+	if err == nil {
+		t.Fatal("非 JSON 响应应返回 error")
+	}
+}
+
+// TestExchangeCode_NetworkFail 网络故障(关 server):返回 *errToken。
+func TestExchangeCode_NetworkFail(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+	c := &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL}
+	srv.Close() // 关服务 → 连接失败
+	_, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
+	if err == nil {
+		t.Fatal("网络故障应返回 error")
+	}
+	if !IsTokenError(err) {
+		t.Fatalf("应为 *errToken,实际 %T", err)
+	}
+}
+
+// TestBuildQRConnectURL 扫码授权 URL 参数完整 + 尾段 #wechat_redirect(微信约定必须保留)。
+func TestBuildQRConnectURL(t *testing.T) {
+	u := BuildQRConnectURL("appid-a", "https://cb.test/auth/wechat/callback", "state-123")
+	// 必含关键参数(redirect_uri 已 URL-encode,故用解码后断言)
+	if !strings.Contains(u, "appid=appid-a") {
+		t.Fatalf("缺 appid:%q", u)
+	}
+	if !strings.Contains(u, "response_type=code") {
+		t.Fatalf("缺 response_type=code:%q", u)
+	}
+	if !strings.Contains(u, "scope=snsapi_login") {
+		t.Fatalf("缺 scope=snsapi_login(网页扫码登录固定):%q", u)
+	}
+	if !strings.Contains(u, "state=state-123") {
+		t.Fatalf("缺 state(防 CSRF):%q", u)
+	}
+	if !strings.HasSuffix(u, "#wechat_redirect") {
+		t.Fatalf("缺 #wechat_redirect 尾段(微信约定必须保留):%q", u)
+	}
+	// redirect_uri 应出现(URL-encoded 形式)
+	re := regexp.MustCompile(`redirect_uri=https%3A%2F%2Fcb\.test%2Fauth%2Fwechat%2Fcallback`)
+	if !re.MatchString(u) {
+		t.Fatalf("redirect_uri 未正确 URL-encode:%q", u)
+	}
+}

+ 8 - 1
internal/infra/config/config.go

@@ -136,8 +136,14 @@ type OSSCfg struct {
 // 字段:
 //   - Campuses []WeChatCampus:appid 与校区的绑定关系(appid → campus_id 映射,
 //     身份绑定的依据:同一手机号在不同校区 appid 下是不同身份实例)。
+//   - CallbackURL string:微信扫码登录的**对外可达**回调地址(微信重定向落点,
+//     须为 https 且已在微信公众号后台登记为"网页授权回调域名";形如
+//     "https://www.yuansiwei.com/api/auth/wechat/callback",**敏感无关**但环境相关,
+//     走环境变量 CONFIG_WECHAT__CALLBACKURL,代码库模板中置空)。
+//     未配置时 /auth/wechat/qrcode 返回 4004(第三方未配置),不允许"无回调地址生成二维码"。
 type WeChatCfg struct {
-	Campuses []WeChatCampus `json:"campuses"`
+	Campuses    []WeChatCampus `json:"campuses"`
+	CallbackURL string         `json:"callbackUrl"`
 }
 
 // WeChatCampus 单个校区的微信应用配置。
@@ -236,6 +242,7 @@ func Load(env string) (*Config, error) {
 	setStr("CONFIG_AGENT__TOKEN", &cfg.Agent.Token)
 	setStr("CONFIG_OSS__ACCESSKEYID", &cfg.OSS.AccessKeyID)
 	setStr("CONFIG_OSS__ACCESSKEYSECRET", &cfg.OSS.AccessKeySecret)
+	setStr("CONFIG_WECHAT__CALLBACKURL", &cfg.WeChat.CallbackURL)
 
 	// 必填校验(fail-fast:配置不完整不允许启动,双实例任一实例配置错都不该带病上线)
 	var missing []string

+ 1 - 0
internal/infra/errors/errors.go

@@ -146,6 +146,7 @@ const (
 	CodeAgentDown     = 4001 // Agent 不可达
 	CodePayCallback   = 4002 // 支付回调失败
 	CodeThirdTimeout  = 4003 // 第三方超时
+	CodeThirdNotConfigured = 4004 // 第三方服务未配置(如微信 per-campus appid/secret 缺失)
 	CodeInternal      = 5001 // 内部错误
 	CodeDBError       = 5002 // 数据库异常
 	CodeThrottled     = 5003 // 限流

+ 113 - 0
internal/infra/redis/store_test.go

@@ -315,3 +315,116 @@ func TestLoginTicketStore_FailClosed(t *testing.T) {
 		t.Fatal("Redis 故障后 GetAndDeleteTicket 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
 	}
 }
+
+// ---- WechatQrcodeStore(扫码状态机)----
+
+// TestWechatQrcodeStore_RoundTrip 写-读往返:SetState 后 GetState 应取回原载荷(含 campusId/ticket/userId)。
+func TestWechatQrcodeStore_RoundTrip(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	st := WechatState{Status: "success", CampusID: 3, Ticket: "tk-abc", UserID: "01GAQF0Z6C1V9Y3E5H7K2M8B4T"}
+	if err := wq.SetState(ctx, "state-rt", st, time.Minute); err != nil {
+		t.Fatalf("SetState: %v", err)
+	}
+	got, err := wq.GetState(ctx, "state-rt")
+	if err != nil {
+		t.Fatalf("GetState: %v", err)
+	}
+	if got.Status != "success" || got.CampusID != 3 || got.Ticket != "tk-abc" || got.UserID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
+		t.Fatalf("往返载荷不符:%+v(期望 %+v)", got, st)
+	}
+}
+
+// TestWechatQrcodeStore_Miss 键不存在/已过期:GetState 返回 (zero, nil)(非故障,调用方按"已失效"处理)。
+func TestWechatQrcodeStore_Miss(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	got, err := wq.GetState(ctx, "state-never")
+	if err != nil {
+		t.Fatalf("未命中时不应报错: %v", err)
+	}
+	if got.Status != "" || got.CampusID != 0 || got.Ticket != "" || got.UserID != "" {
+		t.Fatalf("未命中应返回 zero 值,实际 %+v", got)
+	}
+}
+
+// TestWechatQrcodeStore_TTL TTL 生效:写入后 FastForward 超过 TTL,GetState 应返回 zero(键过期自动删)。
+func TestWechatQrcodeStore_TTL(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	if err := wq.SetState(ctx, "state-ttl", WechatState{Status: "pending"}, 5*time.Second); err != nil {
+		t.Fatalf("SetState: %v", err)
+	}
+	mr.FastForward(6 * time.Second) // 超过 TTL
+	got, err := wq.GetState(ctx, "state-ttl")
+	if err != nil {
+		t.Fatalf("GetState: %v", err)
+	}
+	if got.Status != "" || got.CampusID != 0 || got.Ticket != "" || got.UserID != "" {
+		t.Fatalf("TTL 过期后 GetState 应返回 zero,实际 %+v", got)
+	}
+}
+
+// TestWechatQrcodeStore_Update 终态写入:pending 状态可被 SetState 覆盖为 success(callback 落终态)。
+func TestWechatQrcodeStore_Update(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	if err := wq.SetState(ctx, "state-up", WechatState{Status: "pending", CampusID: 3}, time.Minute); err != nil {
+		t.Fatalf("SetState pending: %v", err)
+	}
+	if err := wq.SetState(ctx, "state-up", WechatState{Status: "success", CampusID: 3, Ticket: "tk-1", UserID: "01GA"}, time.Minute); err != nil {
+		t.Fatalf("SetState success: %v", err)
+	}
+	got, _ := wq.GetState(ctx, "state-up")
+	if got.Status != "success" || got.Ticket != "tk-1" || got.UserID != "01GA" {
+		t.Fatalf("终态写入后应取到 success,实际 %+v", got)
+	}
+}
+
+// TestWechatQrcodeStore_Clear ClearState 幂等删除:删后 GetState 返回 zero;重复删不报错。
+func TestWechatQrcodeStore_Clear(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	if err := wq.SetState(ctx, "state-cl", WechatState{Status: "success"}, time.Minute); err != nil {
+		t.Fatalf("SetState: %v", err)
+	}
+	if err := wq.ClearState(ctx, "state-cl"); err != nil {
+		t.Fatalf("ClearState: %v", err)
+	}
+	got, _ := wq.GetState(ctx, "state-cl")
+	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 {
+		t.Fatalf("重复 ClearState 应幂等(不报错),实际: %v", err)
+	}
+}
+
+// TestWechatQrcodeStore_FailClosed 关键安全语义:Redis 故障时 GetState 必须返回 error(fail-closed),
+// 不可把"读不到"当作"已失效"静默放行(= 前端误判扫码状态,安全红线)。
+func TestWechatQrcodeStore_FailClosed(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	wq := NewWechatQrcodeStore(rc)
+	ctx := context.Background()
+
+	mr.Close() // 模拟连接故障
+	if _, err := wq.GetState(ctx, "state-fc"); err == nil {
+		t.Fatal("Redis 故障后 GetState 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
+	}
+}

+ 134 - 0
internal/infra/redis/wechat_qrcode_store.go

@@ -0,0 +1,134 @@
+// 微信扫码登录状态机存储(Redis,per-state 一次性会话)。
+//
+// 键空间:wxqrcode:{state} → 序列化后的 WechatState(JSON)。
+// TTL 由调用方传入(Service 层固定 5min,覆盖"生成二维码 → 用户扫码 → 前端轮询到 success"的窗口)。
+//
+// 设计要点:
+//   - state 是 16 字节随机 hex(32 字符,不含 ":",无需转义),由 Service 生成后经 Set 写入;
+//   - 双实例(ADR-015)共享同一 Redis:state 落共享键空间,实例 A 生成的二维码在实例 B 轮询/
+//     回调时也能命中——无需 per-instance 命名空间(与登录票据 loginticket: 前缀对称设计);
+//   - state 记录**可变**(pending → success/unbound),故用 GET/SET 而非 GETDEL:
+//     前端轮询是"只读快照"(不消费),只有 callback 写入终态(success/unbound);
+//   - 终态后不再变更:success 的 ticket 由 ② /auth/issue 经 LoginTicketStore.GETDEL 原子消费,
+//     本 store 只负责"承载 ticket + 作用域提示",不承担一次性语义(一次性在票据层保证)。
+//
+// 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。
+package redis
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	gredis "github.com/redis/go-redis/v9"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// wechatStateKeyPrefix 键前缀(硬编码,与 loginticket: / refresh: 对称设计)。
+const wechatStateKeyPrefix = "wxqrcode:"
+
+// WechatState 扫码状态机的持久化载荷(一个 state 对应的一次扫码会话)。
+//
+// 字段:
+//   - Status:状态机取值(见 WechatQrcodeStatus* 常量)。
+//   - 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"`
+	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 直接实现,装配时构造)。
+type WechatQrcodeStore struct {
+	*Client
+}
+
+// NewWechatQrcodeStore 构造扫码状态机存储。
+//
+// 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
+// 出参:*WechatQrcodeStore:就绪存储。
+func NewWechatQrcodeStore(rc *Client) *WechatQrcodeStore {
+	return &WechatQrcodeStore{Client: rc}
+}
+
+// stateKey 拼键:wxqrcode:{state}。
+func (s *WechatQrcodeStore) stateKey(state string) string {
+	return wechatStateKeyPrefix + state
+}
+
+// SetState 写入/更新一个 state 的载荷(callback 写终态、qrcode 写初态)。
+//
+// 入参:
+//   - ctx    context.Context:请求上下文。
+//   - state  string:会话标识(16 字节 hex)。
+//   - st     WechatState:要持久化的载荷。
+//   - ttl    time.Duration:有效期(过期自动删除,未扫码的会话自然失效)。
+//
+// 出参:error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
+func (s *WechatQrcodeStore) SetState(ctx context.Context, state string, st WechatState, ttl time.Duration) error {
+	raw, err := json.Marshal(st)
+	if err != nil {
+		// WechatState 全是基础类型,Marshal 实际不会失败;防御性兜底(fail-closed)
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 序列化扫码状态 state=%s 失败: %w", state, err))
+	}
+	if err := s.Client.Set(ctx, s.stateKey(state), raw, ttl).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写扫码状态 state=%s 失败: %w", state, err))
+	}
+	return nil
+}
+
+// GetState 读取一个 state 的载荷(前端轮询只读快照;不消费、不改写)。
+//
+// 入参:
+//   - ctx   context.Context:请求上下文。
+//   - state string:会话标识。
+//
+// 出参:
+//   - WechatState:当前载荷;state 不存在/已过期时返回 zero 值 + err=nil(调用方按"已失效"处理)。
+//   - error:Redis 故障(连接失败/超时)——**调用方必须阻断**,fail-closed(不把存储故障降级为"状态未知")。
+func (s *WechatQrcodeStore) GetState(ctx context.Context, state string) (WechatState, error) {
+	var out WechatState
+	raw, err := s.Client.Get(ctx, s.stateKey(state)).Result()
+	switch {
+	case err == nil:
+		if uerr := json.Unmarshal([]byte(raw), &out); uerr != nil {
+			return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 反序列化扫码状态 state=%s 失败: %w", state, uerr))
+		}
+		return out, nil
+	case err == gredis.Nil:
+		return out, nil // 未命中:state 不存在/已过期(调用方按"扫码已失效,请重新获取"处理)
+	default:
+		return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 读扫码状态 state=%s 失败: %w", state, err))
+	}
+}
+
+// ClearState 删除一个 state(登出/失效时主动清理;幂等)。
+//
+// 入参:ctx context.Context;state string:会话标识。
+// 出参:error:Redis DEL 失败时返回 errors.ErrDBError(调用方按需告警,不阻断主链路)。
+func (s *WechatQrcodeStore) ClearState(ctx context.Context, state string) error {
+	if err := s.Client.Del(ctx, s.stateKey(state)).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 删扫码状态 state=%s 失败: %w", state, err))
+	}
+	return nil
+}