diff --git a/README-wechat-login.md b/README-wechat-login.md
new file mode 100644
index 0000000..71f1465
--- /dev/null
+++ b/README-wechat-login.md
@@ -0,0 +1,609 @@
+# 微信登录实现指南
+
+本文档提供基于 wx-java-sdk 的微信授权登录后端实现方案,包括会话管理和接口设计。
+
+## 技术方案概述
+
+微信登录流程采用基于会话管理的方式,避免每次都从微信服务端获取授权:
+
+1. 前端通过微信 SDK 获取登录凭证(code)和手机号加密数据
+2. 后端接收凭证和加密数据,与微信服务器交互获取用户信息
+3. 后端创建或更新用户信息,并生成会话令牌(token)返回给前端
+4. 前端存储令牌,后续请求时携带令牌进行身份验证
+5. 令牌过期时,后端自动使用刷新令牌获取新的访问令牌
+
+## 后端依赖
+
+```xml
+
+
+ com.github.binarywang
+ weixin-java-miniapp
+ 4.5.0
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.11.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.11.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.11.5
+ runtime
+
+```
+
+## 后端配置
+
+```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 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 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 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 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 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 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 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 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 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 onError(ServerWebExchange exchange, String message, HttpStatus status) {
+ ServerHttpResponse response = exchange.getResponse();
+ response.setStatusCode(status);
+ response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
+
+ Map 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` - 登出
diff --git a/src/api/auth.ts b/src/api/auth.ts
index 13adfc6..d9e2866 100644
--- a/src/api/auth.ts
+++ b/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 {
+ login(data: LoginData): Promise {
return request({
- 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 {
return request({
- 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 {
- return request({
- url: "/api/v1/auth/wechat-mini-login",
+ wechatMiniLogin(data: WxLoginData): Promise {
+ return request({
+ url: `${AUTH_BASE_URL}/wechat/mini-login`,
method: "POST",
- data: data,
- header: {
- "Content-Type": "application/json",
- },
+ data,
});
},
/**
- * 登出接口
+ * 微信一键登录 (通过手机号)
+ * @param data 包含code和phoneCode的登录数据
+ * @returns 登录结果
*/
- logout(): Promise {
- return request({
- url: "/api/v1/auth/logout",
- method: "DELETE",
+ wechatPhoneLogin(data: WxLoginData): Promise {
+ return request({
+ 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 {
+ return request({
+ 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;
- };
-}
diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue
index 6143f89..01c8b1c 100644
--- a/src/pages/login/index.vue
+++ b/src/pages/login/index.vue
@@ -3,46 +3,48 @@
-
-
-
+
+
-
-
+
+
-
+
@@ -54,13 +56,41 @@
class="login-btn"
:disabled="loading"
:style="loading ? 'opacity: 0.7;' : ''"
- @click="handleLogin"
+ @click="handleAccountLogin"
>
- 登录
+ {{ loading ? "登录中..." : "账号登录" }}
+
+
+
+ 使用手机号一键登录
+
+
-
+
+
+ 微信一键登录
+ 授权后将获取您的手机号
+
+
+
+
+
+ 使用账号密码登录
+
+
+
+
+
@@ -91,21 +121,24 @@
@@ -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);
+ }
}
diff --git a/src/static/images/login-bg.svg b/src/static/images/login-bg.svg
index 765ad33..2cac2fb 100644
--- a/src/static/images/login-bg.svg
+++ b/src/static/images/login-bg.svg
@@ -2,30 +2,27 @@
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/src/store/modules/user.store.ts b/src/store/modules/user.store.ts
index 03d4f5c..e790e9b 100644
--- a/src/store/modules/user.store.ts
+++ b/src/store/modules/user.store.ts
@@ -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(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 => {
+ const loginByWechatMini = (data: WxLoginData): Promise => {
return new Promise((resolve, reject) => {
AuthAPI.wechatMiniLogin(data)
.then((result) => {
@@ -54,6 +54,34 @@ export const useUserStore = defineStore("user", () => {
});
};
+ // 微信手机号一键登录
+ const loginByWechatPhone = (data: WxLoginData): Promise => {
+ 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 => {
+ 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,
};
});
diff --git a/src/styles/theme.scss b/src/styles/theme.scss
index 613f706..c84b48c 100644
--- a/src/styles/theme.scss
+++ b/src/styles/theme.scss
@@ -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;
+}
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index 70e4753..0cfbea4 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -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;
+ }
+}
diff --git a/src/utils/request.ts b/src/utils/request.ts
index 585dac4..13d89d2 100644
--- a/src/utils/request.ts
+++ b/src/utils/request.ts
@@ -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(options: UniApp.RequestOptions): Promise {
- // 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 => {
+ 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;
-
- // 业务状态码 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 {
+ url: string;
+ method: "GET" | "POST" | "PUT" | "DELETE";
+ data?: T;
+ header?: Record;
+ timeout?: number;
+ responseType?: "text" | "arraybuffer";
+ // 是否跳过令牌刷新 (用于刷新令牌接口本身)
+ skipTokenRefresh?: boolean;
+}
+
+// 请求函数
+function request(options: RequestOptions): Promise {
+ return new Promise((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;