7.11 微信公众号实现用户登录:扫二维码、回调、保存用户状态..retrofi使用

This commit is contained in:
zhangsan 2025-07-11 19:43:13 +08:00
parent f1119a54a2
commit 95318d5686
26 changed files with 620 additions and 29 deletions

View File

@ -0,0 +1,11 @@
package edu.whut.api;
import edu.whut.api.response.Response;
public interface IAuthService {
Response<String> weixinQrCodeTicket();
Response<String> checkLogin(String ticket);
}

View File

@ -10,10 +10,19 @@ import java.util.concurrent.TimeUnit;
@Configuration
public class GuavaConfig {
@Bean(name = "cache")
public Cache<String, String> cache() {
@Bean(name = "weixinAccessToken") // 注册名为 weixinAccessToken 的缓存 Bean
public Cache<String, String> weixinAccessToken() {
// 创建一个 Guava 本地缓存写入后 2 小时过期
return CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.expireAfterWrite(2, TimeUnit.HOURS) // 设置缓存条目在写入后 2 小时自动失效
.build();
}
@Bean(name = "openidToken") // 注册名为 openidToken 的缓存 Bean
public Cache<String, String> openidToken() {
// 创建一个 Guava 本地缓存写入后 1 小时过期
return CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS) // 设置缓存条目在写入后 1 小时自动失效
.build();
}

View File

@ -0,0 +1,38 @@
package edu.whut.config;
import edu.whut.infrastructure.gateway.IWeixinApiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;
@Slf4j
@Configuration
public class Retrofit2Config {
// 微信开放平台的基础 URL后续所有接口都会在这个前缀下拼接路径
private static final String BASE_URL = "https://api.weixin.qq.com/";
/**
* 创建一个 Retrofit 对象并注册为 Spring Bean
* - baseUrl设置所有请求的公共前缀
* - addConverterFactory添加 Jackson 转换器用于 JSON <-> Java 对象的自动映射
*/
@Bean
public Retrofit retrofit() {
return new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(JacksonConverterFactory.create()).build();
}
/**
* 通过 Retrofit 动态生成 IWeixinApiService 接口的实现
* 并注册为 Spring 容器中的 Bean方便业务层直接注入使用
*/
@Bean
public IWeixinApiService weixinApiService(Retrofit retrofit) {
return retrofit.create(IWeixinApiService.class);
}
}

View File

@ -39,7 +39,10 @@ spring:
weixin:
config:
originalid: gh_b748269e1f4c
token: b8b6
token: asdf
app-id: wx7cc74be9b340b26e
app-secret: d4e73551512c6dc7a2e8f746c26b7f2c
template_id: ARDqdKXuGvASjsDqXzeunq0P8chMQ7tXk_4-BPULJ6U
# 日志
logging:

View File

@ -0,0 +1,11 @@
package edu.whut.domain.auth.adapter.port;
import java.io.IOException;
public interface ILoginPort {
String createQrCodeTicket() throws IOException;
void sendLoginTemplate(String openid) throws IOException;
}

View File

@ -0,0 +1 @@
package edu.whut.domain.auth.adapter.repository;

View File

@ -0,0 +1,14 @@
package edu.whut.domain.auth.service;
import java.io.IOException;
public interface ILoginService {
String createQrCodeTicket() throws Exception;
String checkLogin(String ticket);
void saveLoginState(String ticket, String openid) throws IOException;
}

View File

@ -0,0 +1,51 @@
package edu.whut.domain.auth.service;
import com.google.common.cache.Cache;
import edu.whut.domain.auth.adapter.port.ILoginPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.IOException;
@Slf4j
@Service
public class WeixinLoginService implements ILoginService {
@Resource
private ILoginPort loginPort;
@Resource
private Cache<String, String> openidToken;
/**
* 生成登录二维码的 ticket
* 根据业务需求调用微信 API 或其他服务生成一个可用于前端展示的二维码凭证ticket
*/
@Override
public String createQrCodeTicket() throws Exception {
return loginPort.createQrCodeTicket();
}
/**
* 检查扫码登录状态
* 根据前端传回的 ticket轮询或查询登录结果
* 如果用户已扫描并确认则返回对应的 openid否则返回 null 或空串
*/
@Override
public String checkLogin(String ticket) {
return openidToken.getIfPresent(ticket);
}
/**
* 保存登录状态
* 将用户的登录态ticket openid 的映射持久化或缓存
* 以便后续业务逻辑如会话校验权限验证等使用
*/
@Override
public void saveLoginState(String ticket, String openid) throws IOException {
// 保存登录信息
openidToken.put(ticket, openid);
// 发送模板消息
loginPort.sendLoginTemplate(openid);
}
}

View File

@ -1,7 +0,0 @@
/**
* 聚合对象
* 1. 聚合实体和值对象
* 2. 聚合是聚合的对象和提供基础处理对象的方法但不建议在聚合中引入仓储和接口来做过大的逻辑而这些复杂的操作应该放到service中处理
* 3. 对象名称 XxxAggregate
*/
package edu.whut.domain.yyy.model.aggregate;

View File

@ -1,7 +0,0 @@
/**
* 实体对象
* 1. 一般和数据库持久化对象1v1的关系但因各自开发系统的不同也有1vn的可能
* 2. 如果是老系统改造那么旧的库表冗余了太多的字段可能会有nv1的情况
* 3. 对象名称 XxxEntity
*/
package edu.whut.domain.yyy.model.entity;

View File

@ -1,6 +0,0 @@
/**
* 值对象
* 1. 用于描述对象属性的值如一个库表中有json后者一个字段多个属性信息的枚举对象
* 2. 对象名称如XxxVO
*/
package edu.whut.domain.yyy.model.valobj;

View File

@ -1 +0,0 @@
package edu.whut.domain.yyy.service;

View File

@ -18,6 +18,18 @@
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>adapter-rxjava2</artifactId>
</dependency>
<!-- 系统模块 -->
<dependency>
<groupId>edu.whut</groupId>

View File

@ -0,0 +1,98 @@
package edu.whut.infrastructure.adapter.port;
import com.google.common.cache.Cache;
import edu.whut.domain.auth.adapter.port.ILoginPort;
import edu.whut.infrastructure.gateway.IWeixinApiService;
import edu.whut.infrastructure.gateway.dto.WeixinQrCodeRequestDTO;
import edu.whut.infrastructure.gateway.dto.WeixinQrCodeResponseDTO;
import edu.whut.infrastructure.gateway.dto.WeixinTemplateMessageDTO;
import edu.whut.infrastructure.gateway.dto.WeixinTokenResponseDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import retrofit2.Call;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
public class LoginPort implements ILoginPort {
@Value("${weixin.config.app-id}")
private String appid;
@Value("${weixin.config.app-secret}")
private String appSecret;
@Value("${weixin.config.template_id}")
private String template_id;
@Resource
private Cache<String, String> weixinAccessToken;
@Resource
private IWeixinApiService weixinApiService;
/**
* 生成二维码登录凭证 ticket
* 获取或刷新 access_token 调用微信接口创建带参数的二维码返回 ticket
*/
@Override
public String createQrCodeTicket() throws IOException {
// 1. 获取 access_token优先从缓存读取缓存失效时调用微信接口获取并更新缓存
String accessToken = weixinAccessToken.getIfPresent(appid);
if (null == accessToken) {
Call<WeixinTokenResponseDTO> call = weixinApiService.getToken("client_credential", appid, appSecret);
WeixinTokenResponseDTO weixinTokenRes = call.execute().body();
assert weixinTokenRes != null;
accessToken = weixinTokenRes.getAccess_token();
weixinAccessToken.put(appid, accessToken);
}
// 2. 构造二维码请求对象设置过期时间及业务参数
WeixinQrCodeRequestDTO weixinQrCodeReq = WeixinQrCodeRequestDTO.builder()
.expire_seconds(2592000)
.action_name(WeixinQrCodeRequestDTO.ActionNameTypeVO.QR_SCENE.getCode())
.action_info(WeixinQrCodeRequestDTO.ActionInfo.builder()
.scene(WeixinQrCodeRequestDTO.ActionInfo.Scene.builder()
.scene_id(100601)
.build())
.build())
.build();
// 3. 调用微信接口生成二维码 ticket
Call<WeixinQrCodeResponseDTO> call = weixinApiService.createQrCode(accessToken, weixinQrCodeReq);
WeixinQrCodeResponseDTO weixinQrCodeRes = call.execute().body();
assert null != weixinQrCodeRes;
return weixinQrCodeRes.getTicket();
}
/**
* 发送登录成功模板消息
* 获取或刷新 access_token 封装模板数据调用微信接口发送模板消息
*/
@Override
public void sendLoginTemplate(String openid) throws IOException {
// 1. 获取 accessToken 实际业务场景按需处理下异常
String accessToken = weixinAccessToken.getIfPresent(appid);
if (null == accessToken){
Call<WeixinTokenResponseDTO> call = weixinApiService.getToken("client_credential", appid, appSecret);
WeixinTokenResponseDTO weixinTokenRes = call.execute().body();
assert weixinTokenRes != null;
accessToken = weixinTokenRes.getAccess_token();
weixinAccessToken.put(appid, accessToken);
}
// 2. 构造模板消息的 data 数据结构
Map<String, Map<String, String>> data = new HashMap<>();
WeixinTemplateMessageDTO.put(data, WeixinTemplateMessageDTO.TemplateKey.USER, openid);
// 3. 构造模板消息对象
WeixinTemplateMessageDTO templateMessageDTO = new WeixinTemplateMessageDTO(openid, template_id);
templateMessageDTO.setUrl("https://blog.bitday.top");
templateMessageDTO.setData(data);
// 4. 调用微信接口发送模板消息
Call<Void> call = weixinApiService.sendMessage(accessToken, templateMessageDTO);
call.execute();
}
}

View File

@ -0,0 +1,54 @@
package edu.whut.infrastructure.gateway;
import edu.whut.infrastructure.gateway.dto.WeixinQrCodeRequestDTO;
import edu.whut.infrastructure.gateway.dto.WeixinQrCodeResponseDTO;
import edu.whut.infrastructure.gateway.dto.WeixinTemplateMessageDTO;
import edu.whut.infrastructure.gateway.dto.WeixinTokenResponseDTO;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Query;
/**
* 微信API服务 retrofit2
*/
public interface IWeixinApiService {
/**
* 获取 Access token
* 文档<a href="https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html">Get_access_token</a>
*
* @param grantType 获取access_token填写client_credential
* @param appId 第三方用户唯一凭证
* @param appSecret 第三方用户唯一凭证密钥即appsecret
* @return 响应结果
*/
@GET("cgi-bin/token")
Call<WeixinTokenResponseDTO> getToken(@Query("grant_type") String grantType,
@Query("appid") String appId,
@Query("secret") String appSecret);
/**
* 获取凭据 ticket
* 文档<a href="https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html">Generating_a_Parametric_QR_Code</a>
* <a href="https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=TICKET">前端根据凭证展示二维码</a>
*
* @param accessToken getToken 获取的 token 信息
* @param weixinQrCodeRequestDTO 入参对象
* @return 应答结果
*/
@POST("cgi-bin/qrcode/create")
Call<WeixinQrCodeResponseDTO> createQrCode(@Query("access_token") String accessToken, @Body WeixinQrCodeRequestDTO weixinQrCodeRequestDTO);
/**
* 发送微信公众号模板消息
* 文档https://mp.weixin.qq.com/debug/cgi-bin/readtmpl?t=tmplmsg/faq_tmpl
*
* @param accessToken getToken 获取的 token 信息
* @param weixinTemplateMessageDTO 入参对象
* @return 应答结果
*/
@POST("cgi-bin/message/template/send")
Call<Void> sendMessage(@Query("access_token") String accessToken, @Body WeixinTemplateMessageDTO weixinTemplateMessageDTO);
}

View File

@ -0,0 +1,48 @@
package edu.whut.infrastructure.gateway.dto;
import lombok.*;
/**
* @description 获取微信登录二维码请求对象
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class WeixinQrCodeRequestDTO {
private int expire_seconds;
private String action_name;
private ActionInfo action_info;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class ActionInfo {
Scene scene;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class Scene {
int scene_id;
String scene_str;
}
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public enum ActionNameTypeVO {
QR_SCENE("QR_SCENE", "临时的整型参数值"),
QR_STR_SCENE("QR_STR_SCENE", "临时的字符串参数值"),
QR_LIMIT_SCENE("QR_LIMIT_SCENE", "永久的整型参数值"),
QR_LIMIT_STR_SCENE("QR_LIMIT_STR_SCENE", "永久的字符串参数值");
private String code;
private String info;
}
}

View File

@ -0,0 +1,15 @@
package edu.whut.infrastructure.gateway.dto;
import lombok.Data;
/**
* 获取微信登录二维码响应对象
*/
@Data
public class WeixinQrCodeResponseDTO {
private String ticket;
private Long expire_seconds;
private String url;
}

View File

@ -0,0 +1,104 @@
package edu.whut.infrastructure.gateway.dto;
import java.util.HashMap;
import java.util.Map;
/**
* 微信模板消息
*/
public class WeixinTemplateMessageDTO {
private String touser = "or0Ab6ivwmypESVp_bYuk92T6SvU";
private String template_id = "GLlAM-Q4jdgsktdNd35hnEbHVam2mwsW2YWuxDhpQkU";
private String url = "https://weixin.qq.com";
private Map<String, Map<String, String>> data = new HashMap<>();
public WeixinTemplateMessageDTO(String touser, String template_id) {
this.touser = touser;
this.template_id = template_id;
}
public void put(TemplateKey key, String value) {
data.put(key.getCode(), new HashMap<String, String>() {
private static final long serialVersionUID = 7092338402387318563L;
{
put("value", value);
}
});
}
public static void put(Map<String, Map<String, String>> data, TemplateKey key, String value) {
data.put(key.getCode(), new HashMap<String, String>() {
private static final long serialVersionUID = 7092338402387318563L;
{
put("value", value);
}
});
}
public enum TemplateKey {
USER("user","用户ID")
;
private String code;
private String desc;
TemplateKey(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}
public String getTouser() {
return touser;
}
public void setTouser(String touser) {
this.touser = touser;
}
public String getTemplate_id() {
return template_id;
}
public void setTemplate_id(String template_id) {
this.template_id = template_id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Map<String, Map<String, String>> getData() {
return data;
}
public void setData(Map<String, Map<String, String>> data) {
this.data = data;
}
}

View File

@ -0,0 +1,16 @@
package edu.whut.infrastructure.gateway.dto;
import lombok.Data;
/**
* 获取 Access token DTO 对象
*/
@Data
public class WeixinTokenResponseDTO {
private String access_token;
private int expires_in;
private String errcode;
private String errmsg;
}

View File

@ -0,0 +1,77 @@
package edu.whut.trigger.http;
import edu.whut.api.IAuthService;
import edu.whut.api.response.Response;
import edu.whut.domain.auth.service.ILoginService;
import edu.whut.types.common.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/login")
public class LoginController implements IAuthService {
@Resource
private ILoginService loginService;
/**
* 生成并返回一个微信扫码登录的凭证ticket
* 前端拿到 ticket 会用它来换取二维码图片并展示给用户
*/
@GetMapping("/weixin_qrcode_ticket")
@Override
public Response<String> weixinQrCodeTicket() {
try {
String qrCodeTicket = loginService.createQrCodeTicket();
log.info("生成微信扫码登录 ticket:{}", qrCodeTicket);
return Response.<String>builder()
.code(Constants.ResponseCode.SUCCESS.getCode())
.info(Constants.ResponseCode.SUCCESS.getInfo())
.data(qrCodeTicket)
.build();
} catch (Exception e) {
log.error("生成微信扫码登录 ticket 失败", e);
return Response.<String>builder()
.code(Constants.ResponseCode.UN_ERROR.getCode())
.info(Constants.ResponseCode.UN_ERROR.getInfo())
.build();
}
}
/**
* 检测指定 ticket 的登录状态
* 如果用户已扫码后端已收到 openid 回调就返回对应的登录令牌 openidToken JWT
* 否则返回 未登录 的状态码前端可以继续轮询
*/
@GetMapping("/check_login")
@Override
public Response<String> checkLogin(String ticket) {
try {
String openidToken = loginService.checkLogin(ticket);
log.info("扫码检测登录结果 ticket:{} openidToken:{}", ticket, openidToken);
if (StringUtils.isNotBlank(openidToken)) {
return Response.<String>builder()
.code(Constants.ResponseCode.SUCCESS.getCode())
.info(Constants.ResponseCode.SUCCESS.getInfo())
.data(openidToken)
.build();
} else {
return Response.<String>builder()
.code(Constants.ResponseCode.NO_LOGIN.getCode())
.info(Constants.ResponseCode.NO_LOGIN.getInfo())
.build();
}
} catch (Exception e) {
log.error("扫码检测登录结果失败 ticket:{}", ticket, e);
return Response.<String>builder()
.code(Constants.ResponseCode.UN_ERROR.getCode())
.info(Constants.ResponseCode.UN_ERROR.getInfo())
.build();
}
}
}

View File

@ -1,7 +1,9 @@
package edu.whut.trigger.http;
import edu.whut.domain.auth.service.ILoginService;
import edu.whut.types.weixin.MessageTextEntity;
import edu.whut.types.weixin.SignatureUtil;
import edu.whut.types.weixin.XmlUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
@ -9,10 +11,12 @@ import org.springframework.web.bind.annotation.*;
/**
* https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index 平台地址
* https://pay.bitday.top/api/v1/weixin/portal/receive 内网穿透
*/
@Slf4j
@RestController()
@CrossOrigin("*")
@RequiredArgsConstructor
@RequestMapping("/api/v1/weixin/portal/")
public class WeixinPortalController {
@ -21,6 +25,15 @@ public class WeixinPortalController {
@Value("${weixin.config.token}")
private String token;
private final ILoginService loginService;
//测试内网穿透
@GetMapping(value = "test", produces = "text/plain;charset=UTF-8")
public String testFrp() {
log.info("内网穿透测试接口被调用");
return "内网穿透测试成功";
}
@GetMapping(value = "receive", produces = "text/plain;charset=utf-8")
public String validate(@RequestParam(value = "signature", required = false) String signature,
@RequestParam(value = "timestamp", required = false) String timestamp,
@ -43,6 +56,9 @@ public class WeixinPortalController {
}
}
/**
* 用户扫码登录会触发该函数
*/
@PostMapping(value = "receive", produces = "application/xml; charset=UTF-8")
public String post(@RequestBody String requestBody,
@RequestParam("signature") String signature,
@ -55,6 +71,12 @@ public class WeixinPortalController {
log.info("接收微信公众号信息请求{}开始 {}", openid, requestBody);
// 消息转换
MessageTextEntity message = XmlUtil.xmlToBean(requestBody, MessageTextEntity.class);
if ("event".equals(message.getMsgType()) && "SCAN".equals(message.getEvent())) {
loginService.saveLoginState(message.getTicket(), openid);
return buildMessageTextEntity(openid, "登录成功");
}
return buildMessageTextEntity(openid, "你好," + message.getContent());
} catch (Exception e) {
log.error("接收微信公众号信息请求{}失败 {}", openid, requestBody, e);

View File

@ -1,4 +0,0 @@
/**
* HTTP 接口服务
*/
package edu.whut.trigger.http;

View File

@ -1,7 +1,24 @@
package edu.whut.types.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
public class Constants {
public final static String SPLIT = ",";
@AllArgsConstructor
@NoArgsConstructor
@Getter
public enum ResponseCode {
SUCCESS("0000", "调用成功"),
UN_ERROR("0001", "调用失败"),
ILLEGAL_PARAMETER("0002", "非法参数"),
NO_LOGIN("0003", "未登录"),
;
private String code;
private String info;
}
}

15
pom.xml
View File

@ -115,6 +115,21 @@
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>adapter-rxjava2</artifactId>
<version>2.9.0</version>
</dependency>
<!-- 工程模块 -->
<dependency>