wip: 临时提交
This commit is contained in:
609
README-wechat-login.md
Normal file
609
README-wechat-login.md
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
# 微信登录实现指南
|
||||||
|
|
||||||
|
本文档提供基于 wx-java-sdk 的微信授权登录后端实现方案,包括会话管理和接口设计。
|
||||||
|
|
||||||
|
## 技术方案概述
|
||||||
|
|
||||||
|
微信登录流程采用基于会话管理的方式,避免每次都从微信服务端获取授权:
|
||||||
|
|
||||||
|
1. 前端通过微信 SDK 获取登录凭证(code)和手机号加密数据
|
||||||
|
2. 后端接收凭证和加密数据,与微信服务器交互获取用户信息
|
||||||
|
3. 后端创建或更新用户信息,并生成会话令牌(token)返回给前端
|
||||||
|
4. 前端存储令牌,后续请求时携带令牌进行身份验证
|
||||||
|
5. 令牌过期时,后端自动使用刷新令牌获取新的访问令牌
|
||||||
|
|
||||||
|
## 后端依赖
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- wx-java SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.binarywang</groupId>
|
||||||
|
<artifactId>weixin-java-miniapp</artifactId>
|
||||||
|
<version>4.5.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT 依赖 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后端配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
wx:
|
||||||
|
miniapp:
|
||||||
|
appid: ${WX_MINIAPP_APPID} # 微信小程序 appId
|
||||||
|
secret: ${WX_MINIAPP_SECRET} # 微信小程序 appSecret
|
||||||
|
token: ${WX_MINIAPP_TOKEN} # 微信小程序消息服务器配置的 token
|
||||||
|
aesKey: ${WX_MINIAPP_AES_KEY} # 微信小程序消息服务器配置的 EncodingAESKey
|
||||||
|
msgDataFormat: JSON # 消息格式,XML 或者 JSON
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET_KEY} # JWT 密钥
|
||||||
|
access-token-expiration: 86400 # 访问令牌过期时间(秒),默认1天
|
||||||
|
refresh-token-expiration: 604800 # 刷新令牌过期时间(秒),默认7天
|
||||||
|
```
|
||||||
|
|
||||||
|
## 微信服务配置类
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(WxMaProperties.class)
|
||||||
|
public class WxMaConfiguration {
|
||||||
|
private final WxMaProperties properties;
|
||||||
|
private static final Map<String, WxMaService> maServices = new HashMap<>();
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public WxMaConfiguration(WxMaProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WxMaService wxMaService() {
|
||||||
|
WxMaService service = new WxMaServiceImpl();
|
||||||
|
WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
|
||||||
|
config.setAppid(properties.getAppid());
|
||||||
|
config.setSecret(properties.getSecret());
|
||||||
|
config.setToken(properties.getToken());
|
||||||
|
config.setAesKey(properties.getAesKey());
|
||||||
|
config.setMsgDataFormat(properties.getMsgDataFormat());
|
||||||
|
service.setWxMaConfig(config);
|
||||||
|
maServices.put(properties.getAppid(), service);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WxMaService getMaService(String appid) {
|
||||||
|
return maServices.get(appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 微信用户表设计
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `wx_user` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`open_id` varchar(128) NOT NULL COMMENT '微信开放ID',
|
||||||
|
`union_id` varchar(128) DEFAULT NULL COMMENT '微信开放平台unionid',
|
||||||
|
`session_key` varchar(128) DEFAULT NULL COMMENT '会话密钥',
|
||||||
|
`nickname` varchar(64) DEFAULT NULL COMMENT '昵称',
|
||||||
|
`avatar_url` varchar(256) DEFAULT NULL COMMENT '头像',
|
||||||
|
`phone` varchar(32) DEFAULT NULL COMMENT '手机号',
|
||||||
|
`gender` tinyint(1) DEFAULT NULL COMMENT '性别(0:未知 1:男 2:女)',
|
||||||
|
`country` varchar(64) DEFAULT NULL COMMENT '国家',
|
||||||
|
`province` varchar(64) DEFAULT NULL COMMENT '省份',
|
||||||
|
`city` varchar(64) DEFAULT NULL COMMENT '城市',
|
||||||
|
`language` varchar(64) DEFAULT NULL COMMENT '语言',
|
||||||
|
`is_new_user` tinyint(1) DEFAULT '1' COMMENT '是否新用户(0:否 1:是)',
|
||||||
|
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
|
||||||
|
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_open_id` (`open_id`),
|
||||||
|
KEY `idx_union_id` (`union_id`),
|
||||||
|
KEY `idx_phone` (`phone`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信用户表';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 控制器层实现
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final WxMaService wxMaService;
|
||||||
|
private final UserService userService;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录(简单版)
|
||||||
|
*/
|
||||||
|
@PostMapping("/wechat/login")
|
||||||
|
public R<LoginResult> wechatLogin(@RequestBody WechatLoginRequest request) {
|
||||||
|
try {
|
||||||
|
// 获取微信用户信息
|
||||||
|
WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode());
|
||||||
|
String openid = sessionResult.getOpenid();
|
||||||
|
String sessionKey = sessionResult.getSessionKey();
|
||||||
|
|
||||||
|
// 查询或创建用户
|
||||||
|
User user = userService.getOrCreateWxUser(openid, sessionKey);
|
||||||
|
|
||||||
|
// 生成令牌
|
||||||
|
String accessToken = jwtService.generateAccessToken(user.getId());
|
||||||
|
String refreshToken = jwtService.generateRefreshToken(user.getId());
|
||||||
|
|
||||||
|
// 构建返回结果
|
||||||
|
LoginResult result = new LoginResult();
|
||||||
|
result.setAccessToken(accessToken);
|
||||||
|
result.setRefreshToken(refreshToken);
|
||||||
|
result.setTokenType("Bearer");
|
||||||
|
result.setExpiresIn(jwtService.getAccessTokenExpiration());
|
||||||
|
result.setNewUser(user.getIsNewUser());
|
||||||
|
result.setProfileComplete(userService.isUserProfileComplete(user));
|
||||||
|
|
||||||
|
return R.ok(result);
|
||||||
|
} catch (WxErrorException e) {
|
||||||
|
log.error("微信登录失败", e);
|
||||||
|
return R.fail("微信登录失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序手机号登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/wechat/phone-login")
|
||||||
|
public R<LoginResult> wechatPhoneLogin(@RequestBody WechatPhoneLoginRequest request) {
|
||||||
|
try {
|
||||||
|
// 获取微信用户信息
|
||||||
|
WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode());
|
||||||
|
String openid = sessionResult.getOpenid();
|
||||||
|
String sessionKey = sessionResult.getSessionKey();
|
||||||
|
|
||||||
|
// 解密手机号
|
||||||
|
String phoneNumber = null;
|
||||||
|
|
||||||
|
// 新版本获取手机号
|
||||||
|
if (request.getPhoneCode() != null) {
|
||||||
|
WxMaPhoneNumberInfo phoneInfo = wxMaService.getPhoneNoInfo(request.getPhoneCode());
|
||||||
|
phoneNumber = phoneInfo.getPhoneNumber();
|
||||||
|
}
|
||||||
|
// 旧版本获取手机号
|
||||||
|
else if (request.getEncryptedData() != null && request.getIv() != null) {
|
||||||
|
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService()
|
||||||
|
.getPhoneNoInfo(sessionKey, request.getEncryptedData(), request.getIv());
|
||||||
|
phoneNumber = phoneInfo.getPhoneNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phoneNumber == null) {
|
||||||
|
return R.fail("获取手机号失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询或创建用户并更新手机号
|
||||||
|
User user = userService.getOrCreateWxUserWithPhone(openid, sessionKey, phoneNumber);
|
||||||
|
|
||||||
|
// 生成令牌
|
||||||
|
String accessToken = jwtService.generateAccessToken(user.getId());
|
||||||
|
String refreshToken = jwtService.generateRefreshToken(user.getId());
|
||||||
|
|
||||||
|
// 构建返回结果
|
||||||
|
LoginResult result = new LoginResult();
|
||||||
|
result.setAccessToken(accessToken);
|
||||||
|
result.setRefreshToken(refreshToken);
|
||||||
|
result.setTokenType("Bearer");
|
||||||
|
result.setExpiresIn(jwtService.getAccessTokenExpiration());
|
||||||
|
result.setNewUser(user.getIsNewUser());
|
||||||
|
result.setProfileComplete(userService.isUserProfileComplete(user));
|
||||||
|
|
||||||
|
return R.ok(result);
|
||||||
|
} catch (WxErrorException e) {
|
||||||
|
log.error("微信手机号登录失败", e);
|
||||||
|
return R.fail("微信手机号登录失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查会话有效性
|
||||||
|
*/
|
||||||
|
@GetMapping("/check-session")
|
||||||
|
public R<SessionValidResult> checkSession(HttpServletRequest request) {
|
||||||
|
String token = jwtService.getTokenFromRequest(request);
|
||||||
|
boolean isValid = jwtService.validateToken(token);
|
||||||
|
|
||||||
|
SessionValidResult result = new SessionValidResult();
|
||||||
|
result.setValid(isValid);
|
||||||
|
|
||||||
|
return R.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh-token")
|
||||||
|
public R<RefreshTokenResult> refreshToken(@RequestBody RefreshTokenRequest request) {
|
||||||
|
try {
|
||||||
|
String refreshToken = request.getRefreshToken();
|
||||||
|
if (!jwtService.validateRefreshToken(refreshToken)) {
|
||||||
|
return R.fail("刷新令牌无效或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = jwtService.getUserIdFromToken(refreshToken);
|
||||||
|
String newAccessToken = jwtService.generateAccessToken(userId);
|
||||||
|
|
||||||
|
RefreshTokenResult result = new RefreshTokenResult();
|
||||||
|
result.setAccessToken(newAccessToken);
|
||||||
|
result.setExpiresIn(jwtService.getAccessTokenExpiration());
|
||||||
|
|
||||||
|
return R.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("刷新令牌失败", e);
|
||||||
|
return R.fail("刷新令牌失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public R<Void> logout(HttpServletRequest request) {
|
||||||
|
// 此处可以将token加入黑名单
|
||||||
|
// 在真实场景中,可以将token存入Redis黑名单,并设置过期时间
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JWT服务实现
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
@Value("${jwt.secret}")
|
||||||
|
private String jwtSecret;
|
||||||
|
|
||||||
|
@Value("${jwt.access-token-expiration}")
|
||||||
|
private long accessTokenExpiration;
|
||||||
|
|
||||||
|
@Value("${jwt.refresh-token-expiration}")
|
||||||
|
private long refreshTokenExpiration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成访问令牌
|
||||||
|
*/
|
||||||
|
public String generateAccessToken(Long userId) {
|
||||||
|
return generateToken(userId, accessTokenExpiration, "access");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成刷新令牌
|
||||||
|
*/
|
||||||
|
public String generateRefreshToken(Long userId) {
|
||||||
|
return generateToken(userId, refreshTokenExpiration, "refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成令牌
|
||||||
|
*/
|
||||||
|
private String generateToken(Long userId, long expiration, String type) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + expiration * 1000);
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(userId.toString())
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.claim("type", type)
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中获取令牌
|
||||||
|
*/
|
||||||
|
public String getTokenFromRequest(HttpServletRequest request) {
|
||||||
|
String bearerToken = request.getHeader("Authorization");
|
||||||
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||||
|
return bearerToken.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证令牌
|
||||||
|
*/
|
||||||
|
public boolean validateToken(String token) {
|
||||||
|
try {
|
||||||
|
Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证刷新令牌
|
||||||
|
*/
|
||||||
|
public boolean validateRefreshToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
|
||||||
|
return "refresh".equals(claims.get("type"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从令牌中获取用户ID
|
||||||
|
*/
|
||||||
|
public Long getUserIdFromToken(String token) {
|
||||||
|
Claims claims = Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名密钥
|
||||||
|
*/
|
||||||
|
private Key getSigningKey() {
|
||||||
|
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取访问令牌过期时间(秒)
|
||||||
|
*/
|
||||||
|
public long getAccessTokenExpiration() {
|
||||||
|
return accessTokenExpiration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用户服务实现
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
|
private final WxUserMapper wxUserMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建微信用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public User getOrCreateWxUser(String openid, String sessionKey) {
|
||||||
|
// 查询用户
|
||||||
|
WxUser wxUser = wxUserMapper.selectByOpenId(openid);
|
||||||
|
|
||||||
|
// 如果用户不存在,创建新用户
|
||||||
|
if (wxUser == null) {
|
||||||
|
wxUser = new WxUser();
|
||||||
|
wxUser.setOpenId(openid);
|
||||||
|
wxUser.setSessionKey(sessionKey);
|
||||||
|
wxUser.setIsNewUser(true);
|
||||||
|
wxUser.setLastLoginTime(new Date());
|
||||||
|
wxUserMapper.insert(wxUser);
|
||||||
|
} else {
|
||||||
|
// 更新会话密钥和登录时间
|
||||||
|
wxUser.setSessionKey(sessionKey);
|
||||||
|
wxUser.setLastLoginTime(new Date());
|
||||||
|
wxUserMapper.updateById(wxUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 WxUser 转换为 User
|
||||||
|
return convertToUser(wxUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建带手机号的微信用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public User getOrCreateWxUserWithPhone(String openid, String sessionKey, String phone) {
|
||||||
|
// 先检查是否有该手机号的用户
|
||||||
|
WxUser wxUserByPhone = wxUserMapper.selectByPhone(phone);
|
||||||
|
|
||||||
|
// 如果存在该手机号用户但openid不同,可能是用户换了微信号,更新openid
|
||||||
|
if (wxUserByPhone != null && !openid.equals(wxUserByPhone.getOpenId())) {
|
||||||
|
wxUserByPhone.setOpenId(openid);
|
||||||
|
wxUserByPhone.setSessionKey(sessionKey);
|
||||||
|
wxUserByPhone.setLastLoginTime(new Date());
|
||||||
|
wxUserByPhone.setIsNewUser(false);
|
||||||
|
wxUserMapper.updateById(wxUserByPhone);
|
||||||
|
return convertToUser(wxUserByPhone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户
|
||||||
|
WxUser wxUser = wxUserMapper.selectByOpenId(openid);
|
||||||
|
|
||||||
|
// 如果用户不存在,创建新用户
|
||||||
|
if (wxUser == null) {
|
||||||
|
wxUser = new WxUser();
|
||||||
|
wxUser.setOpenId(openid);
|
||||||
|
wxUser.setSessionKey(sessionKey);
|
||||||
|
wxUser.setPhone(phone);
|
||||||
|
wxUser.setIsNewUser(true);
|
||||||
|
wxUser.setLastLoginTime(new Date());
|
||||||
|
wxUserMapper.insert(wxUser);
|
||||||
|
} else {
|
||||||
|
// 更新会话密钥、手机号和登录时间
|
||||||
|
wxUser.setSessionKey(sessionKey);
|
||||||
|
wxUser.setPhone(phone);
|
||||||
|
wxUser.setLastLoginTime(new Date());
|
||||||
|
wxUserMapper.updateById(wxUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 WxUser 转换为 User
|
||||||
|
return convertToUser(wxUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户信息是否完整
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isUserProfileComplete(User user) {
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StringUtils.hasText(user.getNickname())
|
||||||
|
&& StringUtils.hasText(user.getAvatar())
|
||||||
|
&& StringUtils.hasText(user.getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 WxUser 转换为 User
|
||||||
|
*/
|
||||||
|
private User convertToUser(WxUser wxUser) {
|
||||||
|
if (wxUser == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(wxUser.getId());
|
||||||
|
user.setOpenId(wxUser.getOpenId());
|
||||||
|
user.setUnionId(wxUser.getUnionId());
|
||||||
|
user.setNickname(wxUser.getNickname());
|
||||||
|
user.setAvatar(wxUser.getAvatarUrl());
|
||||||
|
user.setPhone(wxUser.getPhone());
|
||||||
|
user.setGender(wxUser.getGender());
|
||||||
|
user.setIsNewUser(wxUser.getIsNewUser());
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 网关过滤器实现 (可选)
|
||||||
|
|
||||||
|
如果使用了Spring Cloud Gateway,可以添加JWT验证过滤器:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter implements GlobalFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||||
|
ServerHttpRequest request = exchange.getRequest();
|
||||||
|
|
||||||
|
// 白名单路径,不需要token验证
|
||||||
|
if (isWhiteListPath(request.getPath().toString())) {
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取token
|
||||||
|
String token = getTokenFromRequest(request);
|
||||||
|
if (token == null) {
|
||||||
|
return onError(exchange, "未授权", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证token
|
||||||
|
if (!jwtService.validateToken(token)) {
|
||||||
|
return onError(exchange, "token无效或已过期", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID并设置到请求头
|
||||||
|
Long userId = jwtService.getUserIdFromToken(token);
|
||||||
|
ServerHttpRequest mutatedRequest = request.mutate()
|
||||||
|
.header("X-User-ID", userId.toString())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return chain.filter(exchange.mutate().request(mutatedRequest).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isWhiteListPath(String path) {
|
||||||
|
List<String> whiteList = Arrays.asList(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/wechat/login",
|
||||||
|
"/api/v1/auth/wechat/mini-login",
|
||||||
|
"/api/v1/auth/wechat/phone-login",
|
||||||
|
"/api/v1/auth/refresh-token"
|
||||||
|
);
|
||||||
|
|
||||||
|
return whiteList.stream().anyMatch(path::startsWith);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getTokenFromRequest(ServerHttpRequest request) {
|
||||||
|
List<String> authHeaders = request.getHeaders().get("Authorization");
|
||||||
|
if (authHeaders != null && !authHeaders.isEmpty()) {
|
||||||
|
String auth = authHeaders.get(0);
|
||||||
|
if (auth.startsWith("Bearer ")) {
|
||||||
|
return auth.substring(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
|
||||||
|
ServerHttpResponse response = exchange.getResponse();
|
||||||
|
response.setStatusCode(status);
|
||||||
|
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", status.value());
|
||||||
|
result.put("message", message);
|
||||||
|
|
||||||
|
byte[] bytes = new ObjectMapper().writeValueAsBytes(result);
|
||||||
|
DataBuffer buffer = response.bufferFactory().wrap(bytes);
|
||||||
|
return response.writeWith(Mono.just(buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. 生产环境中使用HTTPS保护API通信
|
||||||
|
2. 敏感信息(如SessionKey)不要存储在前端
|
||||||
|
3. 为JWT密钥使用足够强度的随机字符串
|
||||||
|
4. 实现令牌黑名单机制处理注销和令牌泄露情况
|
||||||
|
5. 考虑实现令牌自动续期机制
|
||||||
|
6. 定期清理过期的会话记录
|
||||||
|
7. 记录关键操作的审计日志
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
使用Postman或其他API测试工具测试以下接口:
|
||||||
|
|
||||||
|
1. `/api/v1/auth/wechat/login` - 微信简单登录
|
||||||
|
2. `/api/v1/auth/wechat/phone-login` - 微信手机号登录
|
||||||
|
3. `/api/v1/auth/check-session` - 检查会话有效性
|
||||||
|
4. `/api/v1/auth/refresh-token` - 刷新令牌
|
||||||
|
5. `/api/v1/auth/logout` - 登出
|
||||||
177
src/api/auth.ts
177
src/api/auth.ts
@@ -1,126 +1,115 @@
|
|||||||
import request from "@/utils/request";
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
const AUTH_BASE_URL = "/api/v1/auth";
|
||||||
|
|
||||||
|
export interface LoginData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxLoginData {
|
||||||
|
code: string;
|
||||||
|
encryptedData?: string;
|
||||||
|
iv?: string;
|
||||||
|
phoneCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenType: string;
|
||||||
|
expiresIn: number;
|
||||||
|
isNewUser?: boolean;
|
||||||
|
isProfileComplete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const AuthAPI = {
|
const AuthAPI = {
|
||||||
/**
|
/**
|
||||||
* 登录接口
|
* 账号密码登录
|
||||||
*
|
* @param data 登录表单数据
|
||||||
* @param username 用户名
|
* @returns 登录结果
|
||||||
* @param password 密码
|
|
||||||
* @returns 返回 token
|
|
||||||
*/
|
*/
|
||||||
login(data: LoginFormData): Promise<LoginResult> {
|
login(data: LoginData): Promise<LoginResult> {
|
||||||
return request<LoginResult>({
|
return request<LoginResult>({
|
||||||
url: "/api/v1/auth/login",
|
url: `${AUTH_BASE_URL}/login`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: data,
|
data,
|
||||||
header: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信登录接口
|
* 微信登录 (基础版)
|
||||||
*
|
* @param code 微信登录凭证
|
||||||
* @param code 微信登录code
|
* @returns 登录结果
|
||||||
* @returns 返回 token
|
|
||||||
*/
|
*/
|
||||||
wechatLogin(code: string): Promise<LoginResult> {
|
wechatLogin(code: string): Promise<LoginResult> {
|
||||||
return request<LoginResult>({
|
return request<LoginResult>({
|
||||||
url: "/api/v1/auth/wechat-login",
|
url: `${AUTH_BASE_URL}/wechat/login`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { code },
|
data: { code },
|
||||||
header: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信小程序登录接口(增强版)
|
* 微信小程序增强登录 (获取手机号)
|
||||||
*
|
* @param data 包含code, encryptedData, iv等的登录数据
|
||||||
* @param data 微信登录数据
|
* @returns 登录结果
|
||||||
* @returns 返回 token 和用户信息
|
|
||||||
*/
|
*/
|
||||||
wechatMiniLogin(data: WechatMiniLoginData): Promise<WechatLoginResult> {
|
wechatMiniLogin(data: WxLoginData): Promise<LoginResult> {
|
||||||
return request<WechatLoginResult>({
|
return request<LoginResult>({
|
||||||
url: "/api/v1/auth/wechat-mini-login",
|
url: `${AUTH_BASE_URL}/wechat/mini-login`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: data,
|
data,
|
||||||
header: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登出接口
|
* 微信一键登录 (通过手机号)
|
||||||
|
* @param data 包含code和phoneCode的登录数据
|
||||||
|
* @returns 登录结果
|
||||||
*/
|
*/
|
||||||
logout(): Promise<void> {
|
wechatPhoneLogin(data: WxLoginData): Promise<LoginResult> {
|
||||||
return request({
|
return request<LoginResult>({
|
||||||
url: "/api/v1/auth/logout",
|
url: `${AUTH_BASE_URL}/wechat/phone-login`,
|
||||||
method: "DELETE",
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查会话有效性
|
||||||
|
* @returns 会话是否有效
|
||||||
|
*/
|
||||||
|
checkSession(): Promise<{ valid: boolean }> {
|
||||||
|
return request<{ valid: boolean }>({
|
||||||
|
url: `${AUTH_BASE_URL}/check-session`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
* @returns 登出结果
|
||||||
|
*/
|
||||||
|
logout(): Promise<any> {
|
||||||
|
return request<any>({
|
||||||
|
url: `${AUTH_BASE_URL}/logout`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌
|
||||||
|
* @param refreshToken 刷新令牌
|
||||||
|
* @returns 新的访问令牌
|
||||||
|
*/
|
||||||
|
refreshToken(refreshToken: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||||
|
return request<{ accessToken: string; expiresIn: number }>({
|
||||||
|
url: `${AUTH_BASE_URL}/refresh-token`,
|
||||||
|
method: "POST",
|
||||||
|
data: { refreshToken },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthAPI;
|
export default AuthAPI;
|
||||||
|
|
||||||
/** 登录响应 */
|
|
||||||
export interface LoginResult {
|
|
||||||
/** 访问token */
|
|
||||||
accessToken: string;
|
|
||||||
/** token 类型 */
|
|
||||||
tokenType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginFormData {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 微信小程序登录数据 */
|
|
||||||
export interface WechatMiniLoginData {
|
|
||||||
/** 微信登录code */
|
|
||||||
code: string;
|
|
||||||
/** 用户信息(可选) */
|
|
||||||
userInfo?: {
|
|
||||||
/** 昵称 */
|
|
||||||
nickName?: string;
|
|
||||||
/** 头像URL */
|
|
||||||
avatarUrl?: string;
|
|
||||||
/** 性别 */
|
|
||||||
gender?: number;
|
|
||||||
/** 国家 */
|
|
||||||
country?: string;
|
|
||||||
/** 省份 */
|
|
||||||
province?: string;
|
|
||||||
/** 城市 */
|
|
||||||
city?: string;
|
|
||||||
};
|
|
||||||
/** 手机号授权数据(可选) */
|
|
||||||
phoneData?: {
|
|
||||||
/** 手机号授权code */
|
|
||||||
code: string;
|
|
||||||
/** 加密数据 */
|
|
||||||
encryptedData?: string;
|
|
||||||
/** 初始向量 */
|
|
||||||
iv?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 微信登录结果 */
|
|
||||||
export interface WechatLoginResult extends LoginResult {
|
|
||||||
/** 是否为新用户 */
|
|
||||||
isNewUser?: boolean;
|
|
||||||
/** 用户信息是否完整 */
|
|
||||||
isProfileComplete?: boolean;
|
|
||||||
/** 用户基本信息 */
|
|
||||||
userInfo?: {
|
|
||||||
userId?: number;
|
|
||||||
username?: string;
|
|
||||||
nickname?: string;
|
|
||||||
avatar?: string;
|
|
||||||
mobile?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,46 +3,48 @@
|
|||||||
<!-- 背景图 -->
|
<!-- 背景图 -->
|
||||||
<image src="/static/images/login-bg.svg" mode="aspectFill" class="login-bg" />
|
<image src="/static/images/login-bg.svg" mode="aspectFill" class="login-bg" />
|
||||||
|
|
||||||
<!-- Logo和标题区域 -->
|
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<image src="/static/logo.png" class="logo" />
|
<image src="/static/images/logo.png" mode="aspectFit" class="logo" />
|
||||||
<text class="title">有来开源</text>
|
<text class="title">您好,欢迎回来</text>
|
||||||
<text class="subtitle">专注于构建高效开发的应用解决方案</text>
|
<text class="subtitle">登录您的账号,开始愉快的旅程</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 登录表单区域 -->
|
|
||||||
<view class="login-card">
|
<view class="login-card">
|
||||||
<view class="form-wrap">
|
<view class="form-wrap">
|
||||||
<wd-form ref="loginFormRef" :model="loginFormData">
|
<!-- 账号密码登录表单 -->
|
||||||
|
<wd-form :model="LoginData" v-if="loginType === 'account'" ref="loginFormRef">
|
||||||
<!-- 用户名输入框 -->
|
<!-- 用户名输入框 -->
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<wd-icon name="user" size="22" color="#165DFF" class="input-icon" />
|
<wd-icon name="user" size="20" class="input-icon" />
|
||||||
<input v-model="loginFormData.username" class="form-input" placeholder="请输入用户名" />
|
<input
|
||||||
|
v-model="LoginData.username"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
placeholder-class="input-placeholder"
|
||||||
|
/>
|
||||||
<wd-icon
|
<wd-icon
|
||||||
v-if="loginFormData.username"
|
v-if="LoginData.username"
|
||||||
name="close-fill"
|
name="error-fill"
|
||||||
size="18"
|
size="14"
|
||||||
color="#9ca3af"
|
|
||||||
class="clear-icon"
|
class="clear-icon"
|
||||||
@click="loginFormData.username = ''"
|
@click="LoginData.username = ''"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="divider"></view>
|
<view class="divider"></view>
|
||||||
|
|
||||||
<!-- 密码输入框 -->
|
<!-- 密码输入框 -->
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<wd-icon name="lock-on" size="22" color="#165DFF" class="input-icon" />
|
<wd-icon name="lock" size="20" class="input-icon" />
|
||||||
<input
|
<input
|
||||||
v-model="loginFormData.password"
|
v-model="LoginData.password"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
placeholder-style="color: #9ca3af; font-weight: normal;"
|
placeholder-class="input-placeholder"
|
||||||
/>
|
/>
|
||||||
<wd-icon
|
<wd-icon
|
||||||
:name="showPassword ? 'eye-open' : 'eye-close'"
|
:name="showPassword ? 'view' : 'view-off'"
|
||||||
size="18"
|
size="14"
|
||||||
color="#9ca3af"
|
|
||||||
class="eye-icon"
|
class="eye-icon"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
/>
|
/>
|
||||||
@@ -54,13 +56,41 @@
|
|||||||
class="login-btn"
|
class="login-btn"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:style="loading ? 'opacity: 0.7;' : ''"
|
:style="loading ? 'opacity: 0.7;' : ''"
|
||||||
@click="handleLogin"
|
@click="handleAccountLogin"
|
||||||
>
|
>
|
||||||
登录
|
{{ loading ? "登录中..." : "账号登录" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 切换登录方式 -->
|
||||||
|
<view class="switch-login-type" @click="loginType = 'phone'">
|
||||||
|
<text>使用手机号一键登录</text>
|
||||||
|
<wd-icon name="arrow-right" size="12" />
|
||||||
|
</view>
|
||||||
</wd-form>
|
</wd-form>
|
||||||
|
|
||||||
<!-- 微信登录 -->
|
<!-- 手机号登录 -->
|
||||||
|
<view v-else class="phone-login-form">
|
||||||
|
<view class="phone-login-title">微信一键登录</view>
|
||||||
|
<view class="phone-login-subtitle">授权后将获取您的手机号</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="wechat-phone-btn"
|
||||||
|
:disabled="loading"
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
@getphonenumber="handleWechatPhoneLogin"
|
||||||
|
>
|
||||||
|
<wd-icon name="weixin" size="24" color="#ffffff" />
|
||||||
|
<text>微信一键登录</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 切换登录方式 -->
|
||||||
|
<view class="switch-login-type" @click="loginType = 'account'">
|
||||||
|
<text>使用账号密码登录</text>
|
||||||
|
<wd-icon name="arrow-right" size="12" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 其他登录方式 -->
|
||||||
<view class="other-login">
|
<view class="other-login">
|
||||||
<view class="other-login-title">
|
<view class="other-login-title">
|
||||||
<view class="line"></view>
|
<view class="line"></view>
|
||||||
@@ -91,21 +121,24 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import { type LoginFormData } from "@/api/auth";
|
import { type LoginData } from "@/api/auth";
|
||||||
import { useUserStore } from "@/store/modules/user.store";
|
import { useUserStore } from "@/store/modules/user.store";
|
||||||
import { useToast } from "wot-design-uni";
|
import { useToast } from "wot-design-uni";
|
||||||
import { ref } from "vue";
|
import { getWxLoginCode, getWxPhoneNumber, wxAuthState } from "@/services/wechat.service";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
|
||||||
const loginFormRef = ref();
|
const loginFormRef = ref();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
|
const loginType = ref<"account" | "phone">("account");
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// 登录表单数据
|
// 登录表单数据
|
||||||
const loginFormData = ref<LoginFormData>({
|
const LoginData = ref<LoginData>({
|
||||||
username: "admin",
|
username: "",
|
||||||
password: "123456",
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取重定向参数
|
// 获取重定向参数
|
||||||
@@ -116,15 +149,50 @@ onLoad((options) => {
|
|||||||
} else {
|
} else {
|
||||||
redirect.value = "/pages/index/index";
|
redirect.value = "/pages/index/index";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
checkLoginStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 登录处理
|
// 检查登录状态
|
||||||
const handleLogin = () => {
|
const checkLoginStatus = async () => {
|
||||||
|
try {
|
||||||
|
const token = uni.getStorageSync("app_token");
|
||||||
|
if (token) {
|
||||||
|
// 验证token有效性
|
||||||
|
const isValid = await userStore.checkSession();
|
||||||
|
if (isValid) {
|
||||||
|
// 已登录,获取用户信息
|
||||||
|
await userStore.getInfo();
|
||||||
|
// 重定向到首页或指定页面
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({ url: redirect.value });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("检查登录状态失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 账号密码登录处理
|
||||||
|
const handleAccountLogin = () => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
if (!LoginData.value.username) {
|
||||||
|
toast.error("请输入用户名");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!LoginData.value.password) {
|
||||||
|
toast.error("请输入密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
userStore
|
userStore
|
||||||
.login(loginFormData.value)
|
.login(LoginData.value)
|
||||||
.then(() => userStore.getInfo())
|
.then(() => userStore.getInfo())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("登录成功");
|
toast.success("登录成功");
|
||||||
@@ -154,6 +222,50 @@ const handleLogin = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 微信一键登录(通过手机号)
|
||||||
|
const handleWechatPhoneLogin = async (e) => {
|
||||||
|
if (loading.value || wxAuthState.value.isLogining) return;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取手机号加密数据
|
||||||
|
const phoneData = await getWxPhoneNumber(e);
|
||||||
|
|
||||||
|
// 调用登录接口
|
||||||
|
const result = await userStore.loginByWechatPhone(phoneData);
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
await userStore.getInfo();
|
||||||
|
toast.success("登录成功");
|
||||||
|
|
||||||
|
// 检查是否为新用户或信息不完整
|
||||||
|
if (result.isNewUser || !userStore.isUserInfoComplete()) {
|
||||||
|
// 跳转到完善信息页面
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// 跳转到重定向页面
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: redirect.value,
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === "用户拒绝授权") {
|
||||||
|
toast.error("您已拒绝授权获取手机号");
|
||||||
|
} else {
|
||||||
|
toast.error(error?.message || "登录失败");
|
||||||
|
}
|
||||||
|
console.error("微信手机号登录失败:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 微信登录处理
|
// 微信登录处理
|
||||||
const handleWechatLogin = async () => {
|
const handleWechatLogin = async () => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@@ -162,78 +274,37 @@ const handleWechatLogin = async () => {
|
|||||||
try {
|
try {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
// 获取微信登录的临时 code
|
// 获取微信登录的临时 code
|
||||||
const { code } = await uni.login({
|
const code = await getWxLoginCode();
|
||||||
provider: "weixin",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 尝试使用增强的微信登录接口
|
// 尝试使用微信登录接口
|
||||||
try {
|
const result = await userStore.loginByWechat(code);
|
||||||
const result = await userStore.loginByWechatMini({
|
|
||||||
code: code,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
// 获取用户信息
|
||||||
// 获取用户信息
|
await userStore.getInfo();
|
||||||
await userStore.getInfo();
|
toast.success("登录成功");
|
||||||
toast.success("登录成功");
|
|
||||||
|
|
||||||
// 检查是否为新用户或信息不完整
|
// 检查用户信息是否完整
|
||||||
const wechatResult = result as any; // 类型断言
|
if (result.isNewUser || !userStore.isUserInfoComplete()) {
|
||||||
if (
|
// 如果信息不完整,跳转到完善信息页面
|
||||||
wechatResult.isNewUser ||
|
setTimeout(() => {
|
||||||
!wechatResult.isProfileComplete ||
|
uni.navigateTo({
|
||||||
!userStore.isUserInfoComplete()
|
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
|
||||||
) {
|
});
|
||||||
// 如果信息不完整,跳转到完善信息页面
|
}, 1000);
|
||||||
setTimeout(() => {
|
} else {
|
||||||
uni.navigateTo({
|
// 否则直接跳转到重定向页面
|
||||||
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
|
setTimeout(() => {
|
||||||
});
|
uni.reLaunch({
|
||||||
}, 1000);
|
url: redirect.value,
|
||||||
} else {
|
});
|
||||||
// 否则直接跳转到重定向页面
|
}, 1000);
|
||||||
setTimeout(() => {
|
|
||||||
uni.reLaunch({
|
|
||||||
url: redirect.value,
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (enhancedError) {
|
|
||||||
// 如果增强接口失败,回退到原始接口
|
|
||||||
console.log("增强微信登录失败,回退到原始接口:", enhancedError);
|
|
||||||
|
|
||||||
const result = await userStore.loginByWechat(code);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// 获取用户信息
|
|
||||||
await userStore.getInfo();
|
|
||||||
toast.success("登录成功");
|
|
||||||
|
|
||||||
// 检查用户信息是否完整
|
|
||||||
if (!userStore.isUserInfoComplete()) {
|
|
||||||
// 如果信息不完整,跳转到完善信息页面
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
// 否则直接跳转到重定向页面
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.reLaunch({
|
|
||||||
url: redirect.value,
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifndef MP-WEIXIN
|
// #ifndef MP-WEIXIN
|
||||||
toast.error("当前环境不支持微信登录");
|
toast.error("当前环境不支持微信登录");
|
||||||
// #endif
|
// #endif
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast.error(error?.message || "微信登录失败");
|
toast.error(error?.message || "微信登录失败");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -243,14 +314,14 @@ const handleWechatLogin = async () => {
|
|||||||
// 跳转到用户协议页面
|
// 跳转到用户协议页面
|
||||||
const navigateToUserAgreement = () => {
|
const navigateToUserAgreement = () => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/mine/user-agreement/index",
|
url: "/pages/mine/settings/agreement/index",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转到隐私政策页面
|
// 跳转到隐私政策页面
|
||||||
const navigateToPrivacy = () => {
|
const navigateToPrivacy = () => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/mine/privacy/index",
|
url: "/pages/mine/settings/privacy/index",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -263,6 +334,7 @@ const navigateToPrivacy = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--wot-color-bg-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-bg {
|
.login-bg {
|
||||||
@@ -313,6 +385,11 @@ const navigateToPrivacy = () => {
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
background-color: rgba(31, 31, 31, 0.9);
|
||||||
|
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-wrap {
|
.form-wrap {
|
||||||
@@ -336,6 +413,10 @@ const navigateToPrivacy = () => {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
line-height: 60rpx;
|
line-height: 60rpx;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-icon,
|
.clear-icon,
|
||||||
@@ -347,25 +428,78 @@ const navigateToPrivacy = () => {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 90rpx;
|
height: 88rpx;
|
||||||
margin-top: 60rpx;
|
margin-top: 60rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
line-height: 90rpx;
|
font-weight: 500;
|
||||||
|
line-height: 88rpx;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(90deg, #165dff, #4080ff);
|
text-align: center;
|
||||||
|
background-color: var(--wot-color-theme);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 45rpx;
|
border-radius: 44rpx;
|
||||||
box-shadow: 0 8rpx 20rpx rgba(22, 93, 255, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn:active {
|
.switch-login-type {
|
||||||
box-shadow: 0 4rpx 10rpx rgba(22, 93, 255, 0.2);
|
display: flex;
|
||||||
transform: translateY(2rpx);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--wot-color-theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-login-title {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-login-subtitle {
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
color: #aaaaaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-phone-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #07c160;
|
||||||
|
border: none;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
text {
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.other-login {
|
.other-login {
|
||||||
@@ -375,56 +509,62 @@ const navigateToPrivacy = () => {
|
|||||||
.other-login-title {
|
.other-login-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin-bottom: 40rpx;
|
margin-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
flex: 1;
|
width: 80rpx;
|
||||||
height: 1px;
|
height: 1rpx;
|
||||||
background-color: rgba(0, 0, 0, 0.08);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
padding: 0 30rpx;
|
margin: 0 20rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #9ca3af;
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-login {
|
.wechat-login {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 30rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-icon-wrapper {
|
.wechat-icon-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 90rpx;
|
width: 80rpx;
|
||||||
height: 90rpx;
|
height: 80rpx;
|
||||||
background-color: #fff;
|
background-color: #07c160;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-icon {
|
.wechat-icon {
|
||||||
width: 60rpx;
|
width: 40rpx;
|
||||||
height: 60rpx;
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agreement {
|
.agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 30rpx;
|
margin-top: 60rpx;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agreement .text {
|
.link {
|
||||||
padding: 0 4rpx;
|
color: var(--wot-color-theme);
|
||||||
color: #9ca3af;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agreement .link {
|
.input-placeholder {
|
||||||
color: #165dff;
|
color: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
.wot-theme-dark & {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,30 +2,27 @@
|
|||||||
<!-- 背景渐变 -->
|
<!-- 背景渐变 -->
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#94BFFF" />
|
<stop offset="0%" stop-color="#7AC5FF" />
|
||||||
<stop offset="100%" stop-color="#165DFF" />
|
<stop offset="100%" stop-color="#0062E8" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
<!-- 图形渐变 -->
|
<!-- 图形渐变 -->
|
||||||
<linearGradient id="shapeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="shapeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3" />
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.2" />
|
||||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.15" />
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.08" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 透明背景,不完全填充 -->
|
<!-- 透明背景,不完全填充 -->
|
||||||
<rect width="100%" height="50%" fill="url(#bgGradient)" />
|
<rect width="100%" height="50%" fill="url(#bgGradient)" />
|
||||||
|
|
||||||
<!-- 左侧方块装饰 -->
|
<!-- 左侧方块装饰 - 向上移动,避开文字区域 -->
|
||||||
<rect x="100" y="150" width="120" height="120" rx="15" fill="url(#shapeGradient)" transform="rotate(-10, 160, 210)" opacity="0.7" />
|
<rect x="100" y="100" width="120" height="120" rx="15" fill="url(#shapeGradient)" transform="rotate(-10, 160, 160)" opacity="0.5" />
|
||||||
<rect x="190" y="90" width="80" height="80" rx="10" fill="url(#shapeGradient)" transform="rotate(15, 230, 130)" opacity="0.6" />
|
<rect x="190" y="40" width="80" height="80" rx="10" fill="url(#shapeGradient)" transform="rotate(15, 230, 80)" opacity="0.4" />
|
||||||
<rect x="60" y="250" width="100" height="100" rx="10" fill="url(#shapeGradient)" transform="rotate(-5, 110, 300)" opacity="0.5" />
|
<rect x="60" y="180" width="100" height="100" rx="10" fill="url(#shapeGradient)" transform="rotate(-5, 110, 230)" opacity="0.3" />
|
||||||
|
|
||||||
<!-- 右侧圆形装饰 -->
|
<!-- 右侧圆形装饰 - 向上并向右移动,避开中央区域 -->
|
||||||
<circle cx="750" cy="150" r="60" fill="url(#shapeGradient)" opacity="0.7" />
|
<circle cx="800" cy="100" r="60" fill="url(#shapeGradient)" opacity="0.5" />
|
||||||
<circle cx="820" cy="230" r="90" fill="url(#shapeGradient)" opacity="0.5" />
|
<circle cx="870" cy="180" r="90" fill="url(#shapeGradient)" opacity="0.3" />
|
||||||
<circle cx="690" cy="250" r="40" fill="url(#shapeGradient)" opacity="0.6" />
|
<circle cx="740" cy="200" r="40" fill="url(#shapeGradient)" opacity="0.4" />
|
||||||
|
|
||||||
<!-- 底部波浪 -->
|
|
||||||
<path d="M0,900 C200,800 350,950 550,870 C750,790 850,900 1000,850 L1000,1000 L0,1000 Z" fill="#ffffff" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import AuthAPI, { type LoginFormData } from "@/api/auth";
|
import AuthAPI, { type LoginData, type WxLoginData } from "@/api/auth";
|
||||||
import UserAPI, { type UserInfo } from "@/api/user";
|
import UserAPI, { type UserInfo } from "@/api/user";
|
||||||
import { setAccessToken, clearTokens } from "@/utils/auth";
|
import { setAccessToken, clearTokens } from "@/utils/auth";
|
||||||
import { getUserInfo, setUserInfo } from "@/utils/storage";
|
import { getUserInfo, setUserInfo } from "@/utils/storage";
|
||||||
@@ -10,7 +10,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const userInfo = ref<UserInfo | undefined>(getUserInfo());
|
const userInfo = ref<UserInfo | undefined>(getUserInfo());
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const login = (data: LoginFormData) => {
|
const login = (data: LoginData) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
AuthAPI.login(data)
|
AuthAPI.login(data)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -40,7 +40,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 微信小程序增强登录
|
// 微信小程序增强登录
|
||||||
const loginByWechatMini = (data: any): Promise<any> => {
|
const loginByWechatMini = (data: WxLoginData): Promise<any> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
AuthAPI.wechatMiniLogin(data)
|
AuthAPI.wechatMiniLogin(data)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -54,6 +54,34 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 微信手机号一键登录
|
||||||
|
const loginByWechatPhone = (data: WxLoginData): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
AuthAPI.wechatPhoneLogin(data)
|
||||||
|
.then((result) => {
|
||||||
|
setAccessToken(result.accessToken);
|
||||||
|
resolve(result);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("微信手机号登录失败", error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查会话状态
|
||||||
|
const checkSession = (): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
AuthAPI.checkSession()
|
||||||
|
.then((result) => {
|
||||||
|
resolve(result.valid);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
const getInfo = () => {
|
const getInfo = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -95,8 +123,10 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
login,
|
login,
|
||||||
loginByWechat,
|
loginByWechat,
|
||||||
loginByWechatMini,
|
loginByWechatMini,
|
||||||
|
loginByWechatPhone,
|
||||||
logout,
|
logout,
|
||||||
getInfo,
|
getInfo,
|
||||||
|
checkSession,
|
||||||
isUserInfoComplete,
|
isUserInfoComplete,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -184,3 +184,7 @@ textarea {
|
|||||||
:deep(.wd-tabbar) {
|
:deep(.wd-tabbar) {
|
||||||
background-color: var(--wot-tabbar-bg-color) !important;
|
background-color: var(--wot-tabbar-bg-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.wd-icon) {
|
||||||
|
color: var(--wot-color-theme) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,3 +140,33 @@ export function requireLogin(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查令牌是否过期
|
||||||
|
* 这是一个简单实现,如果需要更精确的检查,应该解析JWT的payload
|
||||||
|
* @returns 是否过期
|
||||||
|
*/
|
||||||
|
export function isTokenExpired(token: string): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 简单解析JWT payload (不验证签名)
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const payload = JSON.parse(
|
||||||
|
decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split("")
|
||||||
|
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join("")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查过期时间
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return payload.exp < now;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("解析token失败", e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,61 +1,183 @@
|
|||||||
import { getAccessToken, clearTokens } from "@/utils/auth";
|
import { getAccessToken, getRefreshToken, isTokenExpired, setAccessToken } from "./auth";
|
||||||
import { ApiCode } from "@/enums/api-code.enum";
|
|
||||||
|
|
||||||
export default function request<T>(options: UniApp.RequestOptions): Promise<T> {
|
// 刷新令牌的锁,防止多个请求同时刷新令牌
|
||||||
// H5 使用 VITE_APP_BASE_API 作为代理路径,其他平台使用 VITE_APP_API_URL 作为请求路径
|
let isRefreshing = false;
|
||||||
let baseApi = import.meta.env.VITE_APP_API_URL;
|
// 请求队列,存储需要等待令牌刷新的请求
|
||||||
// #ifdef H5
|
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||||
baseApi = import.meta.env.VITE_APP_BASE_API;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// 添加订阅者
|
||||||
uni.request({
|
const subscribeTokenRefresh = (callback: (token: string) => void) => {
|
||||||
...options,
|
refreshSubscribers.push(callback);
|
||||||
url: `${baseApi}${options.url}`,
|
};
|
||||||
|
|
||||||
|
// 执行所有订阅者
|
||||||
|
const onRefreshed = (token: string) => {
|
||||||
|
refreshSubscribers.forEach((callback) => callback(token));
|
||||||
|
refreshSubscribers = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌
|
||||||
|
* @returns 新的访问令牌
|
||||||
|
*/
|
||||||
|
const refreshToken = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("刷新令牌不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await uni.request({
|
||||||
|
url: "/api/v1/auth/refresh-token",
|
||||||
|
method: "POST",
|
||||||
|
data: { refreshToken },
|
||||||
header: {
|
header: {
|
||||||
...options.header,
|
"Content-Type": "application/json",
|
||||||
Authorization: getAccessToken() ? `Bearer ${getAccessToken()}` : "",
|
|
||||||
},
|
|
||||||
success: (response) => {
|
|
||||||
console.log("success response", response);
|
|
||||||
const resData = response.data as ResponseData<T>;
|
|
||||||
|
|
||||||
// 业务状态码 00000 表示成功
|
|
||||||
if (resData.code === ApiCode.SUCCESS) {
|
|
||||||
resolve(resData.data);
|
|
||||||
}
|
|
||||||
// 令牌失效或过期处理
|
|
||||||
else if (resData.code === ApiCode.TOKEN_INVALID) {
|
|
||||||
console.log("令牌失效或过期处理");
|
|
||||||
clearTokens();
|
|
||||||
// 跳转到登录页
|
|
||||||
uni.reLaunch({
|
|
||||||
url: "/pages/login/index",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 其他业务处理失败
|
|
||||||
uni.showToast({
|
|
||||||
title: resData.msg || "业务处理失败",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
reject({
|
|
||||||
message: resData.msg || "业务处理失败",
|
|
||||||
code: resData.code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
console.log("fail error", error);
|
|
||||||
uni.showToast({
|
|
||||||
title: "网络请求失败",
|
|
||||||
icon: "none",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
reject({
|
|
||||||
message: "网络请求失败",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = response.data as any;
|
||||||
|
if (data.code !== 200 || !data.data.accessToken) {
|
||||||
|
throw new Error("刷新令牌失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = data.data.accessToken;
|
||||||
|
setAccessToken(newToken);
|
||||||
|
return newToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("刷新令牌失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 请求配置
|
||||||
|
interface RequestOptions<T = any> {
|
||||||
|
url: string;
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
data?: T;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
responseType?: "text" | "arraybuffer";
|
||||||
|
// 是否跳过令牌刷新 (用于刷新令牌接口本身)
|
||||||
|
skipTokenRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求函数
|
||||||
|
function request<T = any>(options: RequestOptions): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
// 添加授权头
|
||||||
|
const token = getAccessToken();
|
||||||
|
const header = Object.assign({}, options.header || {});
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
header["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一处理请求
|
||||||
|
const handleRequest = () => {
|
||||||
|
uni.request({
|
||||||
|
url: options.url,
|
||||||
|
method: options.method,
|
||||||
|
data: options.data,
|
||||||
|
header,
|
||||||
|
timeout: options.timeout || 30000,
|
||||||
|
responseType: options.responseType,
|
||||||
|
success: (res: any) => {
|
||||||
|
// 请求成功
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(res.data.data);
|
||||||
|
}
|
||||||
|
// 未授权错误
|
||||||
|
else if (res.statusCode === 401) {
|
||||||
|
// 跳过令牌刷新的请求直接返回错误
|
||||||
|
if (options.skipTokenRefresh) {
|
||||||
|
reject(new Error("未授权"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试刷新令牌
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
refreshToken()
|
||||||
|
.then((newToken) => {
|
||||||
|
// 令牌刷新成功,通知所有等待的请求
|
||||||
|
onRefreshed(newToken);
|
||||||
|
// 重新发起请求
|
||||||
|
header["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
handleRequest();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("令牌刷新失败:", err);
|
||||||
|
// 刷新失败,清除订阅者
|
||||||
|
refreshSubscribers = [];
|
||||||
|
// 重定向到登录页
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/login/index",
|
||||||
|
});
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 当前已有刷新令牌的请求,将此请求加入队列
|
||||||
|
subscribeTokenRefresh((newToken) => {
|
||||||
|
header["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
handleRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 其他错误
|
||||||
|
else {
|
||||||
|
const errorMsg = res.data.message || `请求失败: ${res.statusCode}`;
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(new Error(err.errMsg || "网络请求失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查令牌是否过期
|
||||||
|
if (token && !options.skipTokenRefresh && isTokenExpired(token)) {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
refreshToken()
|
||||||
|
.then((newToken) => {
|
||||||
|
// 令牌刷新成功,通知所有等待的请求
|
||||||
|
onRefreshed(newToken);
|
||||||
|
// 重新发起请求
|
||||||
|
header["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
handleRequest();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("令牌刷新失败:", err);
|
||||||
|
// 刷新失败,清除订阅者
|
||||||
|
refreshSubscribers = [];
|
||||||
|
// 重定向到登录页
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/login/index",
|
||||||
|
});
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 当前已有刷新令牌的请求,将此请求加入队列
|
||||||
|
subscribeTokenRefresh((newToken) => {
|
||||||
|
header["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
handleRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 令牌有效或无需令牌,直接发起请求
|
||||||
|
handleRequest();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default request;
|
||||||
|
|||||||
Reference in New Issue
Block a user