Explorar el Código

feat(identity): 移动端 App 内微信 webview 授权端点 /auth/wechat/authorize

- wechat.go: BuildQRConnectURL 重构为 scope 参数化 BuildAuthorizeURL + 常量
  ScopeLogin(snsapi_login, PC 扫码, connect/qrconnect + #wechat_redirect) /
  ScopeUserinfo(snsapi_userinfo, 移动 webview, connect/oauth2/authorize 无尾段);
  BuildQRConnectURL 保留薄封装给 PC 链路
- service: 新增 WechatService.Authorize(复用 newState/SetState 写 Redis pending,
  返 AuthorizeResult{state, authorizeUrl, campusId, expiresIn},URL 用 ScopeUserinfo)
- handler: 新增 WechatAuthorize(wechat==nil→4004)
- main.go: 挂公开路由 GET /auth/wechat/authorize
- 单测: TestBuildAuthorizeURL_Userinfo/_Login、TestWechatAuthorize_Success/ReuseCallback、
  TestWechat_NotConfigured 加 authorize;PC 扫码 BuildQRConnectURL 行为不变
- 与 PC 扫码共用同一 state 状态机 / 同一 /auth/wechat/callback 换 openid / 同一套 ② issue
Q hace 3 días
padre
commit
058da50f3f

+ 2 - 1
cmd/api/main.go

@@ -261,11 +261,12 @@ func main() {
 	})
 
 	// ---- 业务域路由挂载(公开路由须在 Auth Group 之前——铁律 8)----
-	// 公开(无需登录态):两步登录①② + refresh 轮换 + 微信扫码(二维码/状态轮询/回调)
+	// 公开(无需登录态):两步登录①② + refresh 轮换 + 微信(PC 扫码二维码/状态轮询、移动 webview 授权、回调)
 	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/authorize", authH.WechatAuthorize)
 	app.Get("/auth/wechat/status", authH.WechatStatus)
 	app.Get("/auth/wechat/callback", authH.WechatCallback)
 

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

@@ -11,7 +11,9 @@
 //   - POST /auth/login    两步登录①:验账密 → 一次性票据 + 用户 + 作用域列表(**公开**,无鉴权)
 //   - POST /auth/issue    两步登录②:消费票据 + 选作用域 → access+refresh(**公开**,凭票据)
 //   - POST /auth/refresh  refresh 轮换:旧 refresh 换新对(**公开**,凭 refresh)
-//   - GET  /auth/wechat/qrcode、GET /auth/wechat/status  微信扫码占位(暂未开放)
+//   - GET  /auth/wechat/qrcode、GET /auth/wechat/status  PC 微信扫码(state 状态机 + 复用两步②票据)
+//   - GET  /auth/wechat/authorize                        移动端 App 内微信 webview 授权(snsapi_userinfo,整页跳)
+//   - GET  /auth/wechat/callback                         微信重定向落点(code 换 openid,两 scope 共用)
 //   - GET  /auth/me       登录态自查(**受保护**,需 Auth 中间件)
 //   - POST /auth/logout   登出(**受保护**,恒 200 幂等;撤销尽力而为)
 //
@@ -249,6 +251,26 @@ func (h *Handler) WechatStatus(c *fiber.Ctx) error {
 	return response.OK(c, res)
 }
 
+// WechatAuthorize 移动端 App 内微信授权——生成 webview 授权 URL(GET /auth/wechat/authorize,**公开**)。
+//
+// 与 WechatQrcode(PC 扫码)共用同一 state 状态机 / 同一 /auth/wechat/callback 换 openid /
+// 同一套 ② /auth/issue 签 token;差异仅在前端拿到的 URL 形态:本端点返 **snsapi_userinfo** 的
+// connect/oauth2/authorize(移动端 H5 跑微信内置浏览器,整页跳转),而 qrcode 返 snsapi_login 的
+// connect/qrconnect(PC 渲染二维码,无 #wechat_redirect 尾段差异见 wechat.BuildAuthorizeURL)。
+//
+// 出参(成功):AuthorizeResult → {state, authorizeUrl, campusId, expiresIn}(前端整页跳 authorizeUrl)。
+// 出参(失败):code 4004(微信未配置——wechat 服务未注入 / appid·回调地址缺失)/ 5002(Redis 写 state 失败)。
+func (h *Handler) WechatAuthorize(c *fiber.Ctx) error {
+	if h.wechat == nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信授权登录未配置"))
+	}
+	res, err := h.wechat.Authorize(c.Context())
+	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;

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

@@ -304,6 +304,7 @@ func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
 	app.Post("/auth/issue", fx.handler.Issue)
 	app.Post("/auth/refresh", fx.handler.Refresh)
 	app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
+	app.Get("/auth/wechat/authorize", fx.handler.WechatAuthorize)
 	app.Get("/auth/wechat/status", fx.handler.WechatStatus)
 	app.Get("/auth/wechat/callback", fx.handler.WechatCallback)
 
@@ -1003,10 +1004,16 @@ func TestWechat_NotConfigured(t *testing.T) {
 	}
 	app := fiber.New(fiber.Config{DisableStartupMessage: true})
 	app.Get("/auth/wechat/qrcode", h.WechatQrcode)
+	app.Get("/auth/wechat/authorize", h.WechatAuthorize)
 	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"} {
+	for _, path := range []string{
+		"/auth/wechat/qrcode",
+		"/auth/wechat/authorize",
+		"/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)
@@ -1014,6 +1021,88 @@ func TestWechat_NotConfigured(t *testing.T) {
 	}
 }
 
+// ---- 移动端 App 内微信授权(webview,snsapi_userinfo)----
+
+// TestWechatAuthorize_Success 生成授权 URL:code 200,data 含 state(32hex) + authorizeUrl(oauth2/authorize + snsapi_userinfo)
+// + campusId + expiresIn。authorizeUrl 与 qrUrl 的差异:endpoint=oauth2/authorize(非 qrconnect)、
+// scope=snsapi_userinfo(非 snsapi_login)、**无** #wechat_redirect 尾段。
+func TestWechatAuthorize_Success(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
+	}
+	var ar struct {
+		State        string `json:"state"`
+		AuthorizeURL string `json:"authorizeUrl"`
+		CampusID     int64  `json:"campusId"`
+		ExpiresIn    int64  `json:"expiresIn"`
+	}
+	if err := json.Unmarshal(env.Data, &ar); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if len(ar.State) != 32 {
+		t.Fatalf("state 长度=%d(期望 32 hex)", len(ar.State))
+	}
+	// endpoint 应为 oauth2/authorize(非 qrconnect)
+	if !strings.Contains(ar.AuthorizeURL, "open.weixin.qq.com/connect/oauth2/authorize") {
+		t.Fatalf("authorizeUrl=%q(期望 connect/oauth2/authorize)", ar.AuthorizeURL)
+	}
+	// scope 应为 snsapi_userinfo(非 snsapi_login)
+	if !strings.Contains(ar.AuthorizeURL, "scope=snsapi_userinfo") {
+		t.Fatalf("authorizeUrl=%q(期望 scope=snsapi_userinfo)", ar.AuthorizeURL)
+	}
+	// 无 #wechat_redirect 尾段(webview 场景不保留)
+	if strings.Contains(ar.AuthorizeURL, "#wechat_redirect") {
+		t.Fatalf("authorizeUrl=%q(webview 场景不应含 #wechat_redirect)", ar.AuthorizeURL)
+	}
+	if !strings.Contains(ar.AuthorizeURL, "state="+ar.State) {
+		t.Fatalf("authorizeUrl=%q(期望携带 state=%q)", ar.AuthorizeURL, ar.State)
+	}
+	if ar.CampusID != 3 {
+		t.Fatalf("campusId=%d(期望 3,配置首个 campus)", ar.CampusID)
+	}
+	if ar.ExpiresIn <= 0 {
+		t.Fatalf("expiresIn=%d(期望 >0 秒)", ar.ExpiresIn)
+	}
+}
+
+// TestWechatAuthorize_ReuseCallback 授权 URL 的 state 可被同一 /auth/wechat/callback 消费
+// (证明 PC 扫码与移动 webview 共用同一 callback 换 openid,无需独立端点)。
+func TestWechatAuthorize_ReuseCallback(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addWechatUser(uid, "openid-mobile")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	fx.wechat.byCode["code-mobile"] = "openid-mobile"
+
+	app := fx.newApp(nil)
+	// ① 生成移动 webview 授权 URL
+	authEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
+	var ar struct {
+		State string `json:"state"`
+	}
+	_ = json.Unmarshal(authEnv.Data, &ar)
+
+	// ② 模拟微信重定向回 callback(同 PC 扫码的同一端点)
+	cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-mobile&state="+ar.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"`
+	}
+	_ = json.Unmarshal(cbEnv.Data, &cb)
+	if cb.Status != "success" || cb.LoginTicket == "" {
+		t.Fatalf("status=%q ticket=%q(期望 success + 票据)", cb.Status, cb.LoginTicket)
+	}
+}
+
 // md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。
 func md5Hex(s string) string {
 	d := md5.Sum([]byte(s))

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

@@ -171,6 +171,53 @@ func (s *WechatService) Qrcode(ctx context.Context) (*QRCodeResult, error) {
 	}, nil
 }
 
+// AuthorizeResult 移动端 App 内微信授权出参(GET /auth/wechat/authorize)。
+//
+// 与 QRCodeResult 字段同形(复用同一 state 状态机 / 同一 callback 换 openid / 同一 ② issue),
+// 仅 URL 形态不同:
+//   - QRCodeResult.QRURL:微信**扫码**授权 URL(connect/qrconnect + snsapi_login + #wechat_redirect),
+//     PC 前端把它渲染成二维码图片;
+//   - AuthorizeResult.AuthorizeURL:微信 **webview** 授权 URL(connect/oauth2/authorize + snsapi_userinfo,
+//     无 #wechat_redirect),移动端 H5 跑在微信内置浏览器里,前端**整页跳转**到该 URL 完成授权。
+//
+// 授权完成后微信重定向回同一 callbackURL,callback 换 openid 的逻辑对两个 scope 通用(同接口)。
+type AuthorizeResult struct {
+	// State 会话标识(16 字节 hex;与扫码链路同一状态机,callback 重定向回来后消费)。
+	State string `json:"state"`
+	// AuthorizeURL 微信 webview 网页授权 URL(snsapi_userinfo;前端整页跳转,授权后微信重定向回 callbackURL)。
+	AuthorizeURL string `json:"authorizeUrl"`
+	// CampusID 本次选定的校区(与 Qrcode 同语义:配置首个 campus)。
+	CampusID int64 `json:"campusId"`
+	// ExpiresIn 会话有效期(秒;授权后须在窗口内回到 callback 才能完成换票)。
+	ExpiresIn int64 `json:"expiresIn"`
+}
+
+// Authorize 生成移动端 App 内微信授权 URL(GET /auth/wechat/authorize 编排)。
+//
+// 行为:与 Qrcode 完全对称——选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) →
+// 返回 state + **webview 授权 URL**(snsapi_userinfo)。
+//
+// 出参:
+//   - *AuthorizeResult:state + 授权 URL。
+//   - error:*AppError(CodeInternal:Redis 写失败——fail-closed,与 Qrcode 同)。
+func (s *WechatService) Authorize(ctx context.Context) (*AuthorizeResult, error) {
+	campus := s.campuses[0] // 与 Qrcode 同:多 campus 选第一个(后续可加 campus 选择入参)
+	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 &AuthorizeResult{
+		State:        state,
+		AuthorizeURL: wechat.BuildAuthorizeURL(campus.AppID, s.callbackURL, state, wechat.ScopeUserinfo),
+		CampusID:     campus.CampusID,
+		ExpiresIn:    int64(s.stateTTL / time.Second),
+	}, nil
+}
+
 // StatusResult 轮询状态出参(前端据此驱动 UI:pending 继续轮 / success 携 ticket 走 ② / 终态停轮)。
 type StatusResult struct {
 	// Status 状态机取值(pending/success/unbound/fail,见 WechatQrcode* 常量)。

+ 34 - 9
internal/identity/wechat/wechat.go

@@ -121,22 +121,47 @@ func (c *HTTPClient) ExchangeCode(ctx context.Context, appID, secret, code, redi
 	return ar.OpenID, nil
 }
 
-// BuildQRConnectURL 构造扫码登录二维码指向的**网页授权 URL**(前端把该 URL 渲染成二维码)。
+// ScopeLogin 网页扫码登录 scope(PC 端扫码用;snsapi_login 仅取 openid,不拉头像昵称)。
+const ScopeLogin = "snsapi_login"
+
+// ScopeUserinfo 网页授权获取用户信息 scope(移动端微信内置浏览器"App 内授权"用;
+// snsapi_userinfo 可拉头像昵称,但本系统登录只需 openid,拉取与否由微信侧决定,scope 仅授权)。
+const ScopeUserinfo = "snsapi_userinfo"
+
+// BuildAuthorizeURL 构造**微信网页授权 URL**(通用版:PC 扫码 + 移动 webview 共用同一 host)。
 //
-// 微信网页扫码登录(snsapi_login)固定走 open.weixin.qq.com/connect/qrconnect,
-// 参数:appid / redirect_uri(回调地址,URL-encoded)/ response_type=code /
-// scope=snsapi_login(仅取 openid,不拉头像昵称)/ state(本系统生成的会话标识,防 CSRF)。
+// host 固定 open.weixin.qq.com,两个场景的差异只在 endpoint + scope:
+//   - PC 扫码(snsapi_login):connect/qrconnect —— 前端把该 URL 渲染成二维码,用户扫后在
+//     微信外浏览器完成授权,#wechat_redirect 尾段微信约定必须保留;
+//   - 移动 webview(snsapi_userinfo):connect/oauth2/authorize —— 页面跑在微信内置浏览器里,
+//     前端整页跳转到该 URL 完成授权,**无** #wechat_redirect 尾段(扫码专用,非扫码场景不带)。
 //
-// 入参:appID / redirectURI / state(见上)。
-// 出参:可直接作为二维码内容的完整 URL(含 #wechat_redirect 尾段——微信约定必须保留)。
-func BuildQRConnectURL(appID, redirectURI, state string) string {
+// 参数(两场景一致):appid / redirect_uri(回调地址,URL-encoded)/ response_type=code /
+// scope(snsapi_login 或 snsapi_userinfo)/ state(本系统生成的会话标识,防 CSRF)。
+//
+// 入参:appID / redirectURI / state / scope(ScopeLogin 或 ScopeUserinfo)。
+// 出参:可直接使用的完整授权 URL(扫码场景带 #wechat_redirect,webview 场景不带)。
+func BuildAuthorizeURL(appID, redirectURI, state, scope 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("scope", scope)
 	q.Set("state", state)
-	return "https://open.weixin.qq.com/connect/qrconnect?" + q.Encode() + "#wechat_redirect"
+	base := "https://open.weixin.qq.com/connect/"
+	switch scope {
+	case ScopeLogin:
+		return base + "qrconnect?" + q.Encode() + "#wechat_redirect"
+	default:
+		// ScopeUserinfo(及未知 scope 兜底):走 oauth2/authorize,无 #wechat_redirect
+		return base + "oauth2/authorize?" + q.Encode()
+	}
+}
+
+// BuildQRConnectURL 构造扫码登录二维码指向的网页授权 URL(snsapi_login,PC 扫码专用)。
+// 薄封装 BuildAuthorizeURL,保留原签名给 PC 扫码链路(service.Qrcode 等)。
+func BuildQRConnectURL(appID, redirectURI, state string) string {
+	return BuildAuthorizeURL(appID, redirectURI, state, ScopeLogin)
 }
 
 // truncate 截断字符串到 n 字节(防微信 errmsg 异常长时污染错误串/日志)。

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

@@ -123,3 +123,41 @@ func TestBuildQRConnectURL(t *testing.T) {
 		t.Fatalf("redirect_uri 未正确 URL-encode:%q", u)
 	}
 }
+
+// TestBuildAuthorizeURL_Userinfo 移动 webview 授权 URL:oauth2/authorize + snsapi_userinfo + 无 #wechat_redirect。
+func TestBuildAuthorizeURL_Userinfo(t *testing.T) {
+	u := BuildAuthorizeURL("appid-a", "https://cb.test/auth/wechat/callback", "state-456", ScopeUserinfo)
+	if !strings.Contains(u, "https://open.weixin.qq.com/connect/oauth2/authorize?") {
+		t.Fatalf("endpoint 应为 oauth2/authorize:%q", u)
+	}
+	if !strings.Contains(u, "appid=appid-a") {
+		t.Fatalf("缺 appid:%q", u)
+	}
+	if !strings.Contains(u, "scope=snsapi_userinfo") {
+		t.Fatalf("scope 应为 snsapi_userinfo:%q", u)
+	}
+	if !strings.Contains(u, "state=state-456") {
+		t.Fatalf("缺 state(防 CSRF):%q", u)
+	}
+	if !strings.Contains(u, "response_type=code") {
+		t.Fatalf("缺 response_type=code:%q", u)
+	}
+	// webview 场景无 #wechat_redirect 尾段
+	if strings.Contains(u, "#wechat_redirect") {
+		t.Fatalf("webview 场景不应含 #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)
+	}
+}
+
+// TestBuildAuthorizeURL_Login PC 扫码走 BuildAuthorizeURL(ScopeLogin) 应与 BuildQRConnectURL 等价。
+func TestBuildAuthorizeURL_Login(t *testing.T) {
+	u1 := BuildAuthorizeURL("appid-a", "https://cb.test/x", "s1", ScopeLogin)
+	u2 := BuildQRConnectURL("appid-a", "https://cb.test/x", "s1")
+	if u1 != u2 {
+		t.Fatalf("BuildAuthorizeURL(ScopeLogin) 应等于 BuildQRConnectURL:\n  A=%q\n  B=%q", u1, u2)
+	}
+}