wip: 临时提交

This commit is contained in:
Ray.Hao
2025-05-29 12:41:21 +08:00
parent 913f434a43
commit 220f719c38
8 changed files with 1209 additions and 288 deletions

609
README-wechat-login.md Normal file
View 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` - 登出

View File

@@ -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;
};
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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,
};
});

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;