feat: add h5 account auth flow

This commit is contained in:
H1d3rOne committed 2026-03-24 11:55:29 +08:00
1 parent ef6346a563
commit e4e3f0fdb3
13 files changed
+774 -51

No files matched your search

+271 -15
View File
@@ -2,20 +2,29 @@ package xuan.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.multipart.MultipartFile;
import xuan.entity.UserInfo;
import xuan.service.UserInfoService;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/api/user")
@@ -30,6 +39,15 @@ public class UserController {
@Value("${wechat.secret:}")
private String appSecret;
@Value("${wechat.web.appid:}")
private String webAppId;
@Value("${wechat.web.secret:}")
private String webAppSecret;
@Value("${wechat.web.callback-base-url:}")
private String webCallbackBaseUrl;
@PostMapping("/login")
public Map<String, Object> login(@RequestBody Map<String, Object> params) {
@@ -90,23 +108,10 @@ public class UserController {
}
UserInfo user = userInfoService.loginOrRegister(openId, username, avatarUrl);
System.out.println("登录成功: userId=" + user.getUserId() + ", openId=" + user.getOpenId());
Map<String, Object> data = new HashMap<>();
data.put("id", user.getId());
data.put("userId", user.getUserId());
data.put("openId", user.getOpenId());
data.put("avatarUrl", user.getAvatarUrl());
data.put("username", user.getUsername());
data.put("phone", user.getPhone());
data.put("vipLevel", user.getVipLevel());
data.put("coins", user.getCoins());
data.put("streakDays", user.getStreakDays());
data.put("createTime", user.getCreateTime());
result.put("success", true);
result.put("data", data);
result.put("data", buildUserData(user));
} catch (Exception e) {
System.err.println("登录异常: " + e.getMessage());
e.printStackTrace();
@@ -115,6 +120,117 @@ public class UserController {
}
return result;
}
@GetMapping("/wechat/h5/start")
public ResponseEntity<Void> startWechatH5Login(
@RequestParam(required = false) String returnUrl,
HttpServletRequest request
) {
String targetReturnUrl = StringUtils.hasText(returnUrl)
? returnUrl
: resolveDefaultFrontendReturnUrl(request);
if (!StringUtils.hasText(webAppId) || !StringUtils.hasText(webAppSecret)) {
return redirect(buildFrontendRedirectUrl(targetReturnUrl, false, "未配置微信网站应用登录"));
}
String callbackBaseUrl = StringUtils.hasText(webCallbackBaseUrl)
? webCallbackBaseUrl
: resolveRequestBaseUrl(request);
String callbackUrl = UriComponentsBuilder
.fromHttpUrl(callbackBaseUrl + "/api/user/wechat/h5/callback")
.queryParam("returnUrl", targetReturnUrl)
.build()
.encode()
.toUriString();
String wechatLoginUrl = UriComponentsBuilder
.fromHttpUrl("https://open.weixin.qq.com/connect/qrconnect")
.queryParam("appid", webAppId)
.queryParam("redirect_uri", callbackUrl)
.queryParam("response_type", "code")
.queryParam("scope", "snsapi_login")
.queryParam("state", "xuan_h5_login")
.build()
.encode()
.toUriString() + "#wechat_redirect";
return redirect(wechatLoginUrl);
}
@GetMapping("/wechat/h5/callback")
public ResponseEntity<Void> handleWechatH5Callback(
@RequestParam(required = false) String code,
@RequestParam(required = false) String returnUrl
) {
String targetReturnUrl = StringUtils.hasText(returnUrl)
? returnUrl
: "/";
if (!StringUtils.hasText(webAppId) || !StringUtils.hasText(webAppSecret)) {
return redirect(buildFrontendRedirectUrl(targetReturnUrl, false, "未配置微信网站应用登录"));
}
if (!StringUtils.hasText(code)) {
return redirect(buildFrontendRedirectUrl(targetReturnUrl, false, "未获取到微信登录凭证"));
}
try {
RestTemplate restTemplate = new RestTemplate();
String accessTokenUrl = UriComponentsBuilder
.fromHttpUrl("https://api.weixin.qq.com/sns/oauth2/access_token")
.queryParam("appid", webAppId)
.queryParam("secret", webAppSecret)
.queryParam("code", code)
.queryParam("grant_type", "authorization_code")
.build()
.encode()
.toUriString();
@SuppressWarnings("unchecked")
Map<String, Object> tokenResult = restTemplate.getForObject(accessTokenUrl, Map.class);
if (tokenResult == null || !tokenResult.containsKey("openid") || !tokenResult.containsKey("access_token")) {
return redirect(buildFrontendRedirectUrl(targetReturnUrl, false, extractWechatError(tokenResult, "微信扫码登录失败")));
}
String openId = String.valueOf(tokenResult.get("openid"));
String accessToken = String.valueOf(tokenResult.get("access_token"));
String username = "微信用户";
String avatarUrl = "";
String userInfoUrl = UriComponentsBuilder
.fromHttpUrl("https://api.weixin.qq.com/sns/userinfo")
.queryParam("access_token", accessToken)
.queryParam("openid", openId)
.queryParam("lang", "zh_CN")
.build()
.encode()
.toUriString();
try {
@SuppressWarnings("unchecked")
Map<String, Object> userInfoResult = restTemplate.getForObject(userInfoUrl, Map.class);
if (userInfoResult != null && !userInfoResult.containsKey("errcode")) {
if (userInfoResult.get("nickname") != null) {
username = String.valueOf(userInfoResult.get("nickname"));
}
if (userInfoResult.get("headimgurl") != null) {
avatarUrl = String.valueOf(userInfoResult.get("headimgurl"));
}
}
} catch (Exception e) {
System.err.println("获取微信用户信息失败: " + e.getMessage());
}
UserInfo user = userInfoService.loginOrRegister(openId, username, avatarUrl);
return redirect(buildFrontendRedirectUrl(targetReturnUrl, true, null, buildUserData(user)));
} catch (Exception e) {
System.err.println("H5微信扫码回调异常: " + e.getMessage());
e.printStackTrace();
return redirect(buildFrontendRedirectUrl(targetReturnUrl, false, "微信扫码登录失败,请重试"));
}
}
@GetMapping("/info")
public Map<String, Object> getUserInfo(@RequestParam String userId) {
@@ -146,6 +262,41 @@ public class UserController {
}
return result;
}
@PostMapping("/h5/register")
public Map<String, Object> registerH5(@RequestBody Map<String, Object> params) {
Map<String, Object> result = new HashMap<>();
try {
String loginName = params.get("loginName") != null ? params.get("loginName").toString() : "";
String nickname = params.get("nickname") != null ? params.get("nickname").toString() : "";
String password = params.get("password") != null ? params.get("password").toString() : "";
UserInfo user = userInfoService.registerWithPassword(loginName, nickname, password);
result.put("success", true);
result.put("data", buildUserData(user));
} catch (Exception e) {
result.put("success", false);
result.put("error", e.getMessage());
}
return result;
}
@PostMapping("/h5/login")
public Map<String, Object> loginH5(@RequestBody Map<String, Object> params) {
Map<String, Object> result = new HashMap<>();
try {
String loginName = params.get("loginName") != null ? params.get("loginName").toString() : "";
String password = params.get("password") != null ? params.get("password").toString() : "";
UserInfo user = userInfoService.loginWithPassword(loginName, password);
result.put("success", true);
result.put("data", buildUserData(user));
} catch (Exception e) {
result.put("success", false);
result.put("error", e.getMessage());
}
return result;
}
@PostMapping("/updateVip")
public Map<String, Object> updateVipLevel(@RequestBody Map<String, Object> params) {
@@ -407,4 +558,109 @@ public class UserController {
}
return result;
}
private Map<String, Object> buildUserData(UserInfo user) {
Map<String, Object> data = new HashMap<>();
data.put("id", user.getId());
data.put("userId", user.getUserId());
data.put("openId", user.getOpenId());
data.put("avatarUrl", user.getAvatarUrl());
data.put("username", user.getUsername());
data.put("loginName", user.getLoginName());
data.put("phone", user.getPhone());
data.put("vipLevel", user.getVipLevel());
data.put("coins", user.getCoins());
data.put("streakDays", user.getStreakDays());
data.put("createTime", user.getCreateTime());
return data;
}
private ResponseEntity<Void> redirect(String targetUrl) {
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(targetUrl));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
private String buildFrontendRedirectUrl(String returnUrl, boolean success, String errorMessage) {
return buildFrontendRedirectUrl(returnUrl, success, errorMessage, null);
}
private String buildFrontendRedirectUrl(
String returnUrl,
boolean success,
String errorMessage,
Map<String, Object> payload
) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("success", success ? "1" : "0");
if (!success && StringUtils.hasText(errorMessage)) {
params.put("error", errorMessage);
}
if (payload != null) {
params.putAll(payload);
}
return appendParamsToUrl(returnUrl, params);
}
private String appendParamsToUrl(String url, Map<String, Object> params) {
if (!StringUtils.hasText(url)) {
return "/";
}
int hashIndex = url.indexOf('#');
if (hashIndex >= 0) {
String prefix = url.substring(0, hashIndex);
String fragment = url.substring(hashIndex + 1);
String separator = fragment.contains("?") ? "&" : "?";
return prefix + "#" + fragment + separator + toQueryString(params);
}
String separator = url.contains("?") ? "&" : "?";
return url + separator + toQueryString(params);
}
private String toQueryString(Map<String, Object> params) {
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (entry.getValue() == null) {
continue;
}
if (builder.length() > 0) {
builder.append("&");
}
builder
.append(urlEncode(entry.getKey()))
.append("=")
.append(urlEncode(String.valueOf(entry.getValue())));
}
return builder.toString();
}
private String urlEncode(String value) {
try {
return URLEncoder.encode(value, "UTF-8");
} catch (Exception e) {
throw new IllegalStateException("URL编码失败", e);
}
}
private String extractWechatError(Map<String, Object> wxResult, String fallback) {
if (wxResult == null) {
return fallback;
}
Object errMsg = wxResult.get("errmsg");
if (errMsg != null && StringUtils.hasText(String.valueOf(errMsg))) {
return String.valueOf(errMsg);
}
return fallback;
}
private String resolveRequestBaseUrl(HttpServletRequest request) {
return request.getScheme() + "://" + request.getServerName()
+ (request.getServerPort() == 80 || request.getServerPort() == 443 ? "" : ":" + request.getServerPort());
}
private String resolveDefaultFrontendReturnUrl(HttpServletRequest request) {
return resolveRequestBaseUrl(request) + "/#/pages/auth/wechat-callback";
}
}
+12
View File
@@ -19,6 +19,12 @@ public class UserInfo {
@Column(length = 50)
private String username;
@Column(unique = true, length = 50)
private String loginName;
@Column(length = 64)
private String passwordHash;
@Column(length = 20)
private String userId;
@@ -65,6 +71,12 @@ public class UserInfo {
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getLoginName() { return loginName; }
public void setLoginName(String loginName) { this.loginName = loginName; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
@@ -8,4 +8,5 @@ import xuan.entity.UserInfo;
public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
UserInfo findByOpenId(String openId);
UserInfo findByUserId(String userId);
UserInfo findByLoginName(String loginName);
}
@@ -8,6 +8,8 @@ import xuan.entity.CoinConsumeRecord;
import xuan.repository.UserInfoRepository;
import xuan.repository.CoinConsumeRecordRepository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Random;
@Service
@@ -40,6 +42,53 @@ public class UserInfoService {
return userInfoRepository.save(user);
}
public UserInfo registerWithPassword(String loginName, String nickname, String password) {
if (loginName == null || loginName.trim().isEmpty()) {
throw new IllegalArgumentException("请输入用户名");
}
if (nickname == null || nickname.trim().isEmpty()) {
throw new IllegalArgumentException("请输入昵称");
}
if (password == null || password.length() < 6) {
throw new IllegalArgumentException("密码至少6位");
}
String normalizedLoginName = loginName.trim();
if (userInfoRepository.findByLoginName(normalizedLoginName) != null) {
throw new IllegalArgumentException("用户名已存在");
}
UserInfo user = new UserInfo();
user.setOpenId("h5_" + normalizedLoginName);
user.setLoginName(normalizedLoginName);
user.setPasswordHash(hashPassword(password));
user.setUsername(nickname.trim());
user.setAvatarUrl("");
user.setUserId(generateUserId());
user.setVipLevel(0);
user.setStreakDays(0);
return userInfoRepository.save(user);
}
public UserInfo loginWithPassword(String loginName, String password) {
if (loginName == null || loginName.trim().isEmpty()) {
throw new IllegalArgumentException("请输入用户名");
}
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("请输入密码");
}
UserInfo user = userInfoRepository.findByLoginName(loginName.trim());
if (user == null || user.getPasswordHash() == null) {
throw new IllegalArgumentException("用户名或密码错误");
}
if (!user.getPasswordHash().equals(hashPassword(password))) {
throw new IllegalArgumentException("用户名或密码错误");
}
return user;
}
public UserInfo getUserInfo(String userId) {
return userInfoRepository.findByUserId(userId);
@@ -175,4 +224,18 @@ public class UserInfoService {
}
return sb.toString();
}
private String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(password.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("密码处理失败", e);
}
}
}
+8 -3
View File
@@ -9,7 +9,7 @@ spring.datasource.password=xuan_password
# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
@@ -17,5 +17,10 @@ spring.jpa.properties.hibernate.format_sql=true
logging.level.xuan=DEBUG
# WeChat Mini Program Configuration
wechat.appid=wxbae101ca6e1f61e6
wechat.secret=26f414d93627acf3e1d895231296b313
wechat.appid=${WECHAT_APPID:}
wechat.secret=${WECHAT_SECRET:}
# WeChat H5 QR Login Configuration
wechat.web.appid=${WECHAT_WEB_APPID:${WECHAT_APPID:}}
wechat.web.secret=${WECHAT_WEB_SECRET:${WECHAT_SECRET:}}
wechat.web.callback-base-url=${WECHAT_WEB_CALLBACK_BASE_URL:https://api.bemover.xyz}