From e4e3f0fdb38b050511b32db4155528d96e840b67 Mon Sep 17 00:00:00 2001 From: H1d3rOne Date: Tue, 24 Mar 2026 11:55:29 +0800 Subject: [PATCH] feat: add h5 account auth flow --- .../java/xuan/controller/UserController.java | 286 +++++++++++++++++- src/main/java/xuan/entity/UserInfo.java | 12 + .../xuan/repository/UserInfoRepository.java | 1 + .../java/xuan/service/UserInfoService.java | 63 ++++ src/main/resources/application.properties | 11 +- xuan-deduct-uniapp/index.html | 12 + .../src/components/auth-modal/auth-modal.vue | 235 +++++++++++++- xuan-deduct-uniapp/src/index.html | 17 -- xuan-deduct-uniapp/src/pages.json | 8 + .../src/pages/auth/wechat-callback.vue | 131 ++++++++ xuan-deduct-uniapp/src/utils/api.js | 18 ++ xuan-deduct-uniapp/src/utils/authCheck.js | 25 ++ xuan-deduct-uniapp/vite.config.ts | 6 +- 13 files changed, 774 insertions(+), 51 deletions(-) create mode 100644 xuan-deduct-uniapp/index.html delete mode 100644 xuan-deduct-uniapp/src/index.html create mode 100644 xuan-deduct-uniapp/src/pages/auth/wechat-callback.vue diff --git a/src/main/java/xuan/controller/UserController.java b/src/main/java/xuan/controller/UserController.java index d5ed3ea..fac7e01 100644 --- a/src/main/java/xuan/controller/UserController.java +++ b/src/main/java/xuan/controller/UserController.java @@ -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 login(@RequestBody Map 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 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 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 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 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 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 getUserInfo(@RequestParam String userId) { @@ -146,6 +262,41 @@ public class UserController { } return result; } + + @PostMapping("/h5/register") + public Map registerH5(@RequestBody Map params) { + Map 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 loginH5(@RequestBody Map params) { + Map 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 updateVipLevel(@RequestBody Map params) { @@ -407,4 +558,109 @@ public class UserController { } return result; } + + private Map buildUserData(UserInfo user) { + Map 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 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 payload + ) { + Map 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 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 params) { + StringBuilder builder = new StringBuilder(); + for (Map.Entry 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 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"; + } } diff --git a/src/main/java/xuan/entity/UserInfo.java b/src/main/java/xuan/entity/UserInfo.java index 0ae41c4..747a93f 100644 --- a/src/main/java/xuan/entity/UserInfo.java +++ b/src/main/java/xuan/entity/UserInfo.java @@ -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; } diff --git a/src/main/java/xuan/repository/UserInfoRepository.java b/src/main/java/xuan/repository/UserInfoRepository.java index c53ad18..843d841 100644 --- a/src/main/java/xuan/repository/UserInfoRepository.java +++ b/src/main/java/xuan/repository/UserInfoRepository.java @@ -8,4 +8,5 @@ import xuan.entity.UserInfo; public interface UserInfoRepository extends JpaRepository { UserInfo findByOpenId(String openId); UserInfo findByUserId(String userId); + UserInfo findByLoginName(String loginName); } diff --git a/src/main/java/xuan/service/UserInfoService.java b/src/main/java/xuan/service/UserInfoService.java index 3c7dd5f..f3b16eb 100644 --- a/src/main/java/xuan/service/UserInfoService.java +++ b/src/main/java/xuan/service/UserInfoService.java @@ -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); + } + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 606335f..70f8d81 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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} diff --git a/xuan-deduct-uniapp/index.html b/xuan-deduct-uniapp/index.html new file mode 100644 index 0000000..a5cf36a --- /dev/null +++ b/xuan-deduct-uniapp/index.html @@ -0,0 +1,12 @@ + + + + + + 排盘工具 + + +
+ + + diff --git a/xuan-deduct-uniapp/src/components/auth-modal/auth-modal.vue b/xuan-deduct-uniapp/src/components/auth-modal/auth-modal.vue index 0d84126..91dba80 100644 --- a/xuan-deduct-uniapp/src/components/auth-modal/auth-modal.vue +++ b/xuan-deduct-uniapp/src/components/auth-modal/auth-modal.vue @@ -18,13 +18,74 @@ 🔐 - 微信授权登录 + {{ isH5 ? '账号登录' : '微信授权登录' }} {{ getStatusText() }} + + + + 注册 + + + 登录 + + + + + + + + + + {{ h5Mode === 'register' ? '注册后将自动登录,密码至少 6 位。' : '输入用户名和密码登录已有账号。' }} + + + + + + + + - + 1 @@ -98,6 +159,7 @@ - - - -
- - - diff --git a/xuan-deduct-uniapp/src/pages.json b/xuan-deduct-uniapp/src/pages.json index 6739796..53575d8 100644 --- a/xuan-deduct-uniapp/src/pages.json +++ b/xuan-deduct-uniapp/src/pages.json @@ -281,6 +281,14 @@ "navigationBarTextStyle": "white" } }, + { + "path": "pages/auth/wechat-callback", + "style": { + "navigationBarTitleText": "微信登录中", + "navigationBarBackgroundColor": "#07C160", + "navigationBarTextStyle": "white" + } + }, { "path": "pages/share/view", "style": { diff --git a/xuan-deduct-uniapp/src/pages/auth/wechat-callback.vue b/xuan-deduct-uniapp/src/pages/auth/wechat-callback.vue new file mode 100644 index 0000000..784e3c0 --- /dev/null +++ b/xuan-deduct-uniapp/src/pages/auth/wechat-callback.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/xuan-deduct-uniapp/src/utils/api.js b/xuan-deduct-uniapp/src/utils/api.js index 72181c4..8039f37 100644 --- a/xuan-deduct-uniapp/src/utils/api.js +++ b/xuan-deduct-uniapp/src/utils/api.js @@ -429,6 +429,24 @@ const userApi = { description: description } }) + }, + getWechatH5LoginUrl: (returnUrl) => { + const query = returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : '' + return `${BASE_URL}/user/wechat/h5/start${query}` + }, + registerH5: (data) => { + return request({ + url: '/user/h5/register', + method: 'POST', + data + }) + }, + loginH5: (data) => { + return request({ + url: '/user/h5/login', + method: 'POST', + data + }) } } diff --git a/xuan-deduct-uniapp/src/utils/authCheck.js b/xuan-deduct-uniapp/src/utils/authCheck.js index e153fa3..1de3f20 100644 --- a/xuan-deduct-uniapp/src/utils/authCheck.js +++ b/xuan-deduct-uniapp/src/utils/authCheck.js @@ -68,6 +68,31 @@ const authCheck = { const userInfo = this.getUserInfo() return userInfo ? userInfo.userId : null }, + + saveLogin(userData, fallbackUsername, baseUrl) { + if (!userData) { + return + } + + uni.setStorageSync('userId', userData.userId) + uni.setStorageSync('userInfo', JSON.stringify(userData)) + uni.setStorageSync('userCoins', userData.coins || 0) + uni.setStorageSync('vipLevel', userData.vipLevel || 0) + uni.setStorageSync('username', userData.username || fallbackUsername || '微信用户') + + let avatarUrl = userData.avatarUrl || '' + if ( + baseUrl && + avatarUrl && + !avatarUrl.startsWith('http') && + !avatarUrl.startsWith('wxfile://') + ) { + avatarUrl = baseUrl.replace('/api', '') + avatarUrl + } + + uni.setStorageSync('userAvatar', avatarUrl) + uni.setStorageSync('isLoggedIn', true) + }, logout() { uni.removeStorageSync('isLoggedIn') diff --git a/xuan-deduct-uniapp/vite.config.ts b/xuan-deduct-uniapp/vite.config.ts index 428716b..d329853 100644 --- a/xuan-deduct-uniapp/vite.config.ts +++ b/xuan-deduct-uniapp/vite.config.ts @@ -3,5 +3,9 @@ import uni from '@dcloudio/vite-plugin-uni' export default defineConfig({ plugins: [uni()], - root: 'src' + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true + } })