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";
|
||||
|
||||
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 = {
|
||||
/**
|
||||
* 登录接口
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @returns 返回 token
|
||||
* 账号密码登录
|
||||
* @param data 登录表单数据
|
||||
* @returns 登录结果
|
||||
*/
|
||||
login(data: LoginFormData): Promise<LoginResult> {
|
||||
login(data: LoginData): Promise<LoginResult> {
|
||||
return request<LoginResult>({
|
||||
url: "/api/v1/auth/login",
|
||||
url: `${AUTH_BASE_URL}/login`,
|
||||
method: "POST",
|
||||
data: data,
|
||||
header: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 微信登录接口
|
||||
*
|
||||
* @param code 微信登录code
|
||||
* @returns 返回 token
|
||||
* 微信登录 (基础版)
|
||||
* @param code 微信登录凭证
|
||||
* @returns 登录结果
|
||||
*/
|
||||
wechatLogin(code: string): Promise<LoginResult> {
|
||||
return request<LoginResult>({
|
||||
url: "/api/v1/auth/wechat-login",
|
||||
url: `${AUTH_BASE_URL}/wechat/login`,
|
||||
method: "POST",
|
||||
data: { code },
|
||||
header: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 微信小程序登录接口(增强版)
|
||||
*
|
||||
* @param data 微信登录数据
|
||||
* @returns 返回 token 和用户信息
|
||||
* 微信小程序增强登录 (获取手机号)
|
||||
* @param data 包含code, encryptedData, iv等的登录数据
|
||||
* @returns 登录结果
|
||||
*/
|
||||
wechatMiniLogin(data: WechatMiniLoginData): Promise<WechatLoginResult> {
|
||||
return request<WechatLoginResult>({
|
||||
url: "/api/v1/auth/wechat-mini-login",
|
||||
wechatMiniLogin(data: WxLoginData): Promise<LoginResult> {
|
||||
return request<LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/wechat/mini-login`,
|
||||
method: "POST",
|
||||
data: data,
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出接口
|
||||
* 微信一键登录 (通过手机号)
|
||||
* @param data 包含code和phoneCode的登录数据
|
||||
* @returns 登录结果
|
||||
*/
|
||||
logout(): Promise<void> {
|
||||
return request({
|
||||
url: "/api/v1/auth/logout",
|
||||
method: "DELETE",
|
||||
wechatPhoneLogin(data: WxLoginData): Promise<LoginResult> {
|
||||
return request<LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/wechat/phone-login`,
|
||||
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 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" />
|
||||
|
||||
<!-- Logo和标题区域 -->
|
||||
<view class="header">
|
||||
<image src="/static/logo.png" class="logo" />
|
||||
<text class="title">有来开源</text>
|
||||
<text class="subtitle">专注于构建高效开发的应用解决方案</text>
|
||||
<image src="/static/images/logo.png" mode="aspectFit" class="logo" />
|
||||
<text class="title">您好,欢迎回来</text>
|
||||
<text class="subtitle">登录您的账号,开始愉快的旅程</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录表单区域 -->
|
||||
<view class="login-card">
|
||||
<view class="form-wrap">
|
||||
<wd-form ref="loginFormRef" :model="loginFormData">
|
||||
<!-- 账号密码登录表单 -->
|
||||
<wd-form :model="LoginData" v-if="loginType === 'account'" ref="loginFormRef">
|
||||
<!-- 用户名输入框 -->
|
||||
<view class="form-item">
|
||||
<wd-icon name="user" size="22" color="#165DFF" class="input-icon" />
|
||||
<input v-model="loginFormData.username" class="form-input" placeholder="请输入用户名" />
|
||||
<wd-icon name="user" size="20" class="input-icon" />
|
||||
<input
|
||||
v-model="LoginData.username"
|
||||
class="form-input"
|
||||
placeholder="请输入用户名"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<wd-icon
|
||||
v-if="loginFormData.username"
|
||||
name="close-fill"
|
||||
size="18"
|
||||
color="#9ca3af"
|
||||
v-if="LoginData.username"
|
||||
name="error-fill"
|
||||
size="14"
|
||||
class="clear-icon"
|
||||
@click="loginFormData.username = ''"
|
||||
@click="LoginData.username = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<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
|
||||
v-model="loginFormData.password"
|
||||
v-model="LoginData.password"
|
||||
class="form-input"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
placeholder-style="color: #9ca3af; font-weight: normal;"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<wd-icon
|
||||
:name="showPassword ? 'eye-open' : 'eye-close'"
|
||||
size="18"
|
||||
color="#9ca3af"
|
||||
:name="showPassword ? 'view' : 'view-off'"
|
||||
size="14"
|
||||
class="eye-icon"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
@@ -54,13 +56,41 @@
|
||||
class="login-btn"
|
||||
:disabled="loading"
|
||||
:style="loading ? 'opacity: 0.7;' : ''"
|
||||
@click="handleLogin"
|
||||
@click="handleAccountLogin"
|
||||
>
|
||||
登录
|
||||
{{ loading ? "登录中..." : "账号登录" }}
|
||||
</button>
|
||||
|
||||
<!-- 切换登录方式 -->
|
||||
<view class="switch-login-type" @click="loginType = 'phone'">
|
||||
<text>使用手机号一键登录</text>
|
||||
<wd-icon name="arrow-right" size="12" />
|
||||
</view>
|
||||
</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-title">
|
||||
<view class="line"></view>
|
||||
@@ -91,21 +121,24 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { 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 toast = useToast();
|
||||
const loading = ref(false);
|
||||
const userStore = useUserStore();
|
||||
const showPassword = ref(false);
|
||||
const loginType = ref<"account" | "phone">("account");
|
||||
const { theme } = useTheme();
|
||||
|
||||
// 登录表单数据
|
||||
const loginFormData = ref<LoginFormData>({
|
||||
username: "admin",
|
||||
password: "123456",
|
||||
const LoginData = ref<LoginData>({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
// 获取重定向参数
|
||||
@@ -116,15 +149,50 @@ onLoad((options) => {
|
||||
} else {
|
||||
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 (!LoginData.value.username) {
|
||||
toast.error("请输入用户名");
|
||||
return;
|
||||
}
|
||||
if (!LoginData.value.password) {
|
||||
toast.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
userStore
|
||||
.login(loginFormData.value)
|
||||
.login(LoginData.value)
|
||||
.then(() => userStore.getInfo())
|
||||
.then(() => {
|
||||
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 () => {
|
||||
if (loading.value) return;
|
||||
@@ -162,78 +274,37 @@ const handleWechatLogin = async () => {
|
||||
try {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 获取微信登录的临时 code
|
||||
const { code } = await uni.login({
|
||||
provider: "weixin",
|
||||
});
|
||||
const code = await getWxLoginCode();
|
||||
|
||||
// 尝试使用增强的微信登录接口
|
||||
try {
|
||||
const result = await userStore.loginByWechatMini({
|
||||
code: code,
|
||||
});
|
||||
// 尝试使用微信登录接口
|
||||
const result = await userStore.loginByWechat(code);
|
||||
|
||||
if (result) {
|
||||
// 获取用户信息
|
||||
await userStore.getInfo();
|
||||
toast.success("登录成功");
|
||||
// 获取用户信息
|
||||
await userStore.getInfo();
|
||||
toast.success("登录成功");
|
||||
|
||||
// 检查是否为新用户或信息不完整
|
||||
const wechatResult = result as any; // 类型断言
|
||||
if (
|
||||
wechatResult.isNewUser ||
|
||||
!wechatResult.isProfileComplete ||
|
||||
!userStore.isUserInfoComplete()
|
||||
) {
|
||||
// 如果信息不完整,跳转到完善信息页面
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则直接跳转到重定向页面
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 检查用户信息是否完整
|
||||
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);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
toast.error("当前环境不支持微信登录");
|
||||
// #endif
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
toast.error(error?.message || "微信登录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -243,14 +314,14 @@ const handleWechatLogin = async () => {
|
||||
// 跳转到用户协议页面
|
||||
const navigateToUserAgreement = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/user-agreement/index",
|
||||
url: "/pages/mine/settings/agreement/index",
|
||||
});
|
||||
};
|
||||
|
||||
// 跳转到隐私政策页面
|
||||
const navigateToPrivacy = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/privacy/index",
|
||||
url: "/pages/mine/settings/privacy/index",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -263,6 +334,7 @@ const navigateToPrivacy = () => {
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--wot-color-bg-container);
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
@@ -313,6 +385,11 @@ const navigateToPrivacy = () => {
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 24rpx;
|
||||
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 {
|
||||
@@ -336,6 +413,10 @@ const navigateToPrivacy = () => {
|
||||
font-size: 28rpx;
|
||||
line-height: 60rpx;
|
||||
color: #333;
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon,
|
||||
@@ -347,25 +428,78 @@ const navigateToPrivacy = () => {
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.wot-theme-dark & {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 90rpx;
|
||||
height: 88rpx;
|
||||
margin-top: 60rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 90rpx;
|
||||
font-weight: 500;
|
||||
line-height: 88rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #165dff, #4080ff);
|
||||
text-align: center;
|
||||
background-color: var(--wot-color-theme);
|
||||
border: none;
|
||||
border-radius: 45rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(22, 93, 255, 0.3);
|
||||
transition: all 0.3s;
|
||||
border-radius: 44rpx;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
box-shadow: 0 4rpx 10rpx rgba(22, 93, 255, 0.2);
|
||||
transform: translateY(2rpx);
|
||||
.switch-login-type {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -375,56 +509,62 @@ const navigateToPrivacy = () => {
|
||||
.other-login-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
width: 80rpx;
|
||||
height: 1rpx;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0 30rpx;
|
||||
margin: 0 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #9ca3af;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.wechat-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
background-color: #fff;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background-color: #07c160;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.agreement {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 30rpx;
|
||||
margin-top: 60rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.agreement .text {
|
||||
padding: 0 4rpx;
|
||||
color: #9ca3af;
|
||||
.link {
|
||||
color: var(--wot-color-theme);
|
||||
}
|
||||
|
||||
.agreement .link {
|
||||
color: #165dff;
|
||||
.input-placeholder {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,30 +2,27 @@
|
||||
<!-- 背景渐变 -->
|
||||
<defs>
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#94BFFF" />
|
||||
<stop offset="100%" stop-color="#165DFF" />
|
||||
<stop offset="0%" stop-color="#7AC5FF" />
|
||||
<stop offset="100%" stop-color="#0062E8" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- 图形渐变 -->
|
||||
<linearGradient id="shapeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3" />
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.15" />
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.2" />
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.08" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 透明背景,不完全填充 -->
|
||||
<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="190" y="90" width="80" height="80" rx="10" fill="url(#shapeGradient)" transform="rotate(15, 230, 130)" opacity="0.6" />
|
||||
<rect x="60" y="250" width="100" height="100" rx="10" fill="url(#shapeGradient)" transform="rotate(-5, 110, 300)" opacity="0.5" />
|
||||
<!-- 左侧方块装饰 - 向上移动,避开文字区域 -->
|
||||
<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="40" width="80" height="80" rx="10" fill="url(#shapeGradient)" transform="rotate(15, 230, 80)" opacity="0.4" />
|
||||
<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="820" cy="230" r="90" fill="url(#shapeGradient)" opacity="0.5" />
|
||||
<circle cx="690" cy="250" r="40" fill="url(#shapeGradient)" opacity="0.6" />
|
||||
|
||||
<!-- 底部波浪 -->
|
||||
<path d="M0,900 C200,800 350,950 550,870 C750,790 850,900 1000,850 L1000,1000 L0,1000 Z" fill="#ffffff" />
|
||||
<!-- 右侧圆形装饰 - 向上并向右移动,避开中央区域 -->
|
||||
<circle cx="800" cy="100" r="60" fill="url(#shapeGradient)" opacity="0.5" />
|
||||
<circle cx="870" cy="180" r="90" fill="url(#shapeGradient)" opacity="0.3" />
|
||||
<circle cx="740" cy="200" r="40" fill="url(#shapeGradient)" opacity="0.4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,5 @@
|
||||
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 { setAccessToken, clearTokens } from "@/utils/auth";
|
||||
import { getUserInfo, setUserInfo } from "@/utils/storage";
|
||||
@@ -10,7 +10,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
const userInfo = ref<UserInfo | undefined>(getUserInfo());
|
||||
|
||||
// 登录
|
||||
const login = (data: LoginFormData) => {
|
||||
const login = (data: LoginData) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
AuthAPI.login(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) => {
|
||||
AuthAPI.wechatMiniLogin(data)
|
||||
.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 = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -95,8 +123,10 @@ export const useUserStore = defineStore("user", () => {
|
||||
login,
|
||||
loginByWechat,
|
||||
loginByWechatMini,
|
||||
loginByWechatPhone,
|
||||
logout,
|
||||
getInfo,
|
||||
checkSession,
|
||||
isUserInfoComplete,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -184,3 +184,7 @@ textarea {
|
||||
:deep(.wd-tabbar) {
|
||||
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 { ApiCode } from "@/enums/api-code.enum";
|
||||
import { getAccessToken, getRefreshToken, isTokenExpired, setAccessToken } from "./auth";
|
||||
|
||||
export default function request<T>(options: UniApp.RequestOptions): Promise<T> {
|
||||
// H5 使用 VITE_APP_BASE_API 作为代理路径,其他平台使用 VITE_APP_API_URL 作为请求路径
|
||||
let baseApi = import.meta.env.VITE_APP_API_URL;
|
||||
// #ifdef H5
|
||||
baseApi = import.meta.env.VITE_APP_BASE_API;
|
||||
// #endif
|
||||
// 刷新令牌的锁,防止多个请求同时刷新令牌
|
||||
let isRefreshing = false;
|
||||
// 请求队列,存储需要等待令牌刷新的请求
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${baseApi}${options.url}`,
|
||||
// 添加订阅者
|
||||
const subscribeTokenRefresh = (callback: (token: string) => void) => {
|
||||
refreshSubscribers.push(callback);
|
||||
};
|
||||
|
||||
// 执行所有订阅者
|
||||
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: {
|
||||
...options.header,
|
||||
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,
|
||||
});
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
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