# Mini Program WeChat Auth Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add WeChat openid login to the mini program while preserving phone/SMS login, and automatically bind WeChat openid to an existing phone user when WeChat phone authorization succeeds. **Architecture:** The backend owns all WeChat secrets and identity decisions. The mini program sends WeChat login or phone authorization codes to backend auth endpoints, stores the returned existing `AuthResponse` tokens, and reuses the current session/profile routing. Phone/SMS login remains the alternate path and shares the same token/session system. **Tech Stack:** Java 17, Spring Boot 2.7, MyBatis-Plus, Redis, RestTemplate, UniApp, Vue 3, WeChat Mini Program APIs. **Spec:** `docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md` --- ## File Structure | Action | File | Responsibility | | --- | --- | --- | | Create | `backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java` | Binds WeChat mini program appid, secret, and API base URL from application config. | | Modify | `backend-single/src/main/resources/application.yml` | Adds default WeChat config keys and public login endpoint allowlist. | | Modify | `backend-single/src/main/java/com/emotion/config/WebMvcConfig.java` | Excludes `/auth/wechat/login` from JWT interception. | | Modify | `backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java` | Excludes `/api/auth/wechat/login` from the interceptor's internal public endpoint list. | | Create | `backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java` | Request body for WeChat openid login. | | Create | `backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java` | Request body for WeChat phone binding. | | Create | `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` | Internal DTO for WeChat `code2Session` response. | | Create | `backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java` | Internal DTO for WeChat access token response. | | Create | `backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java` | Internal DTO for WeChat phone number response. | | Create | `backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java` | Interface for WeChat API calls. | | Create | `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` | RestTemplate-backed WeChat client with Redis token caching. | | Modify | `backend-single/src/main/java/com/emotion/service/UserService.java` | Adds third-party identity lookup helper. | | Modify | `backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java` | Implements third-party identity lookup. | | Modify | `backend-single/src/main/java/com/emotion/service/AuthService.java` | Adds WeChat login and phone binding operations. | | Modify | `backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java` | Implements openid login, phone identity merge/bind, and token issuing reuse. | | Modify | `backend-single/src/main/java/com/emotion/controller/AuthController.java` | Adds `/auth/wechat/login` and `/auth/wechat/phone`. | | Test | `backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java` | Verifies controller contract. | | Test | `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java` | Verifies identity matching and binding behavior. | | Modify | `mini-program/src/services/auth.js` | Adds `wechatLogin` and `bindWechatPhone`. | | Modify | `mini-program/src/stores/app.js` | Adds store methods and tracks returned auth user info. | | Modify | `mini-program/src/pages/login/index.vue` | Adds WeChat primary login while keeping phone/SMS form. | | Modify | `mini-program/src/pages/main/MineView.vue` | Adds phone binding entry for logged-in WeChat users without a phone. | --- ### Task 1: Backend WeChat Configuration And DTOs **Files:** - Create: `backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java` - Create: `backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java` - Create: `backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java` - Create: `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` - Create: `backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java` - Create: `backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java` - Modify: `backend-single/src/main/resources/application.yml` - Test: backend compile - [ ] **Step 1: Create WeChat configuration properties** Create `backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java`: ```java package com.emotion.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @Data @Configuration @ConfigurationProperties(prefix = "emotion.wechat.mini-program") public class WechatMiniProgramProperties { private String appId; private String appSecret; private String baseUrl = "https://api.weixin.qq.com"; } ``` - [ ] **Step 2: Create public request DTOs** Create `backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java`: ```java package com.emotion.dto.request; import lombok.Data; import javax.validation.constraints.NotBlank; @Data public class WechatLoginRequest { @NotBlank(message = "微信登录凭证不能为空") private String code; private String nickname; private String avatar; } ``` Create `backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java`: ```java package com.emotion.dto.request; import lombok.Data; import javax.validation.constraints.NotBlank; @Data public class WechatPhoneBindRequest { @NotBlank(message = "微信手机号授权凭证不能为空") private String code; } ``` - [ ] **Step 3: Create internal WeChat API response DTOs** Create `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java`: ```java package com.emotion.dto.wechat; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data public class WechatCodeSessionResponse { private String openid; @JsonProperty("session_key") private String sessionKey; private String unionid; private Integer errcode; private String errmsg; public boolean isSuccess() { return errcode == null || errcode == 0; } } ``` Create `backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java`: ```java package com.emotion.dto.wechat; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data public class WechatAccessTokenResponse { @JsonProperty("access_token") private String accessToken; @JsonProperty("expires_in") private Integer expiresIn; private Integer errcode; private String errmsg; public boolean isSuccess() { return (errcode == null || errcode == 0) && accessToken != null && !accessToken.isBlank(); } } ``` Create `backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java`: ```java package com.emotion.dto.wechat; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data public class WechatPhoneNumberResponse { private Integer errcode; private String errmsg; @JsonProperty("phone_info") private PhoneInfo phoneInfo; public boolean isSuccess() { return (errcode == null || errcode == 0) && phoneInfo != null && phoneInfo.getPhoneNumber() != null && !phoneInfo.getPhoneNumber().isBlank(); } @Data public static class PhoneInfo { @JsonProperty("phoneNumber") private String phoneNumber; @JsonProperty("purePhoneNumber") private String purePhoneNumber; @JsonProperty("countryCode") private String countryCode; } } ``` - [ ] **Step 4: Add WeChat config keys** In `backend-single/src/main/resources/application.yml`, under `emotion:`, add: ```yaml wechat: mini-program: app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wxaf2eaba72d28f0e4} app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:} base-url: https://api.weixin.qq.com ``` - [ ] **Step 5: Compile backend** Run: ```bash cd backend-single mvn -q -DskipTests compile ``` Expected: command exits with status `0`. - [ ] **Step 6: Commit** ```bash git add backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java git add backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java git add backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java git add backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java git add backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java git add backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java git add backend-single/src/main/resources/application.yml git commit -m "feat: add wechat mini program auth DTOs and config" ``` --- ### Task 2: Backend WeChat API Client **Files:** - Create: `backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java` - Create: `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` - Test: `backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java` - [ ] **Step 1: Create service interface** Create `backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java`: ```java package com.emotion.service; import com.emotion.dto.wechat.WechatCodeSessionResponse; import com.emotion.dto.wechat.WechatPhoneNumberResponse; public interface WechatMiniProgramService { WechatCodeSessionResponse code2Session(String code); WechatPhoneNumberResponse.PhoneInfo getPhoneNumber(String phoneCode); } ``` - [ ] **Step 2: Create RestTemplate implementation** Create `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java`: ```java package com.emotion.service.impl; import com.emotion.config.WechatMiniProgramProperties; import com.emotion.dto.wechat.WechatAccessTokenResponse; import com.emotion.dto.wechat.WechatCodeSessionResponse; import com.emotion.dto.wechat.WechatPhoneNumberResponse; import com.emotion.exception.BusinessException; import com.emotion.service.WechatMiniProgramService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @Slf4j @Service @RequiredArgsConstructor public class WechatMiniProgramServiceImpl implements WechatMiniProgramService { private static final String ACCESS_TOKEN_CACHE_KEY = "wechat:mini-program:access-token"; private static final long ACCESS_TOKEN_CACHE_BUFFER_SECONDS = 300L; private final RestTemplate restTemplate; private final RedisTemplate redisTemplate; private final WechatMiniProgramProperties properties; @Override public WechatCodeSessionResponse code2Session(String code) { validateConfigured(); String url = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + "/sns/jscode2session") .queryParam("appid", properties.getAppId()) .queryParam("secret", properties.getAppSecret()) .queryParam("js_code", code) .queryParam("grant_type", "authorization_code") .toUriString(); WechatCodeSessionResponse response = restTemplate.getForObject(url, WechatCodeSessionResponse.class); if (response == null || !response.isSuccess() || !StringUtils.hasText(response.getOpenid())) { throw new BusinessException("微信登录凭证校验失败"); } return response; } @Override public WechatPhoneNumberResponse.PhoneInfo getPhoneNumber(String phoneCode) { validateConfigured(); String accessToken = getStableAccessToken(); String url = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + "/wxa/business/getuserphonenumber") .queryParam("access_token", accessToken) .toUriString(); Map body = new HashMap<>(); body.put("code", phoneCode); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); WechatPhoneNumberResponse response = restTemplate.postForObject( url, new HttpEntity<>(body, headers), WechatPhoneNumberResponse.class ); if (response == null || !response.isSuccess()) { throw new BusinessException("微信手机号授权失败"); } return response.getPhoneInfo(); } private String getStableAccessToken() { Object cached = redisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY); if (cached instanceof String && StringUtils.hasText((String) cached)) { return (String) cached; } String url = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + "/cgi-bin/stable_token") .toUriString(); Map body = new HashMap<>(); body.put("grant_type", "client_credential"); body.put("appid", properties.getAppId()); body.put("secret", properties.getAppSecret()); body.put("force_refresh", false); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); WechatAccessTokenResponse response = restTemplate.postForObject( url, new HttpEntity<>(body, headers), WechatAccessTokenResponse.class ); if (response == null || !response.isSuccess()) { throw new BusinessException("微信接口调用凭证获取失败"); } long ttl = Math.max(60L, response.getExpiresIn() - ACCESS_TOKEN_CACHE_BUFFER_SECONDS); redisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, response.getAccessToken(), ttl, TimeUnit.SECONDS); return response.getAccessToken(); } private void validateConfigured() { if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) { throw new BusinessException("微信小程序登录未配置"); } } } ``` - [ ] **Step 3: Create focused unit tests for error behavior** Create `backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java`: ```java package com.emotion.service; import com.emotion.config.WechatMiniProgramProperties; import com.emotion.exception.BusinessException; import com.emotion.service.impl.WechatMiniProgramServiceImpl; import org.junit.jupiter.api.Test; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.web.client.RestTemplate; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; class WechatMiniProgramServiceImplTest { @Test void code2SessionRejectsMissingSecret() { WechatMiniProgramProperties properties = new WechatMiniProgramProperties(); properties.setAppId("wx-test"); properties.setAppSecret(""); WechatMiniProgramService service = new WechatMiniProgramServiceImpl( mock(RestTemplate.class), mock(RedisTemplate.class), properties ); assertThrows(BusinessException.class, () -> service.code2Session("login-code")); } } ``` - [ ] **Step 4: Run test** ```bash cd backend-single mvn -q -Dtest=WechatMiniProgramServiceImplTest test ``` Expected: command exits with status `0`. - [ ] **Step 5: Commit** ```bash git add backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java git add backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java git add backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java git commit -m "feat: add wechat mini program API client" ``` --- ### Task 3: Backend User Lookup And Auth Service Methods **Files:** - Modify: `backend-single/src/main/java/com/emotion/service/UserService.java` - Modify: `backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java` - Modify: `backend-single/src/main/java/com/emotion/service/AuthService.java` - Modify: `backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java` - Test: `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java` - [ ] **Step 1: Add third-party lookup to UserService** In `backend-single/src/main/java/com/emotion/service/UserService.java`, add: ```java User getByThirdParty(String thirdPartyType, String thirdPartyId); ``` In `backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java`, add: ```java @Override public User getByThirdParty(String thirdPartyType, String thirdPartyId) { if (!StringUtils.hasText(thirdPartyType) || !StringUtils.hasText(thirdPartyId)) { return null; } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getThirdPartyType, thirdPartyType) .eq(User::getThirdPartyId, thirdPartyId) .eq(User::getIsDeleted, 0); return this.getOne(wrapper); } ``` - [ ] **Step 2: Add AuthService methods** In `backend-single/src/main/java/com/emotion/service/AuthService.java`, import the two request DTOs and add: ```java AuthResponse wechatLogin(WechatLoginRequest request); AuthResponse bindWechatPhone(String currentUserId, WechatPhoneBindRequest request); ``` - [ ] **Step 3: Add AuthServiceImpl constants and dependency** In `AuthServiceImpl`, add imports: ```java import com.emotion.dto.request.WechatLoginRequest; import com.emotion.dto.request.WechatPhoneBindRequest; import com.emotion.dto.wechat.WechatCodeSessionResponse; import com.emotion.dto.wechat.WechatPhoneNumberResponse; import com.emotion.service.WechatMiniProgramService; ``` Add field: ```java @Autowired private WechatMiniProgramService wechatMiniProgramService; ``` Add constant: ```java private static final String WECHAT_MP_TYPE = "wechat-mp"; ``` - [ ] **Step 4: Add shared token response helper** In `AuthServiceImpl`, add this private helper near `generateRefreshToken`: ```java private AuthResponse buildAuthResponse(User user) { String accessToken = generateAccessToken(user); String refreshToken = generateRefreshToken(user); userService.updateLastActiveTime(user.getId(), LocalDateTime.now()); AuthResponse response = new AuthResponse(); response.setAccessToken(accessToken); response.setRefreshToken(refreshToken); response.setExpiresIn((long) TOKEN_EXPIRE_HOURS * 3600); response.setUserInfo(convertToUserInfoResponse(user)); response.setLoginTime(LocalDateTime.now().format(DATE_TIME_FORMATTER)); return response; } ``` Then replace the duplicate token response block in `login`, `register`, and `refreshToken` with `return buildAuthResponse(user);`. - [ ] **Step 5: Implement WeChat login** In `AuthServiceImpl`, add: ```java @Override public AuthResponse wechatLogin(WechatLoginRequest request) { WechatCodeSessionResponse session = wechatMiniProgramService.code2Session(request.getCode()); String openid = session.getOpenid(); User user = userService.getByThirdParty(WECHAT_MP_TYPE, openid); if (user == null) { user = autoRegisterWechatUser(openid, request.getNickname(), request.getAvatar()); } else if (user.getStatus() != 1) { throw new AuthException("账号已被禁用"); } else { boolean changed = false; if (StringUtils.hasText(request.getNickname()) && !StringUtils.hasText(user.getNickname())) { user.setNickname(request.getNickname()); changed = true; } if (StringUtils.hasText(request.getAvatar()) && !StringUtils.hasText(user.getAvatar())) { user.setAvatar(request.getAvatar()); changed = true; } if (changed) { userService.updateById(user); } } return buildAuthResponse(user); } private User autoRegisterWechatUser(String openid, String nickname, String avatar) { String username = generateRandomUsername(); String account = "wx_" + openid; User user = userService.createUser(account, username, generateRandomPassword(), null, null); user.setThirdPartyType(WECHAT_MP_TYPE); user.setThirdPartyId(openid); if (StringUtils.hasText(nickname)) { user.setNickname(nickname); } if (StringUtils.hasText(avatar)) { user.setAvatar(avatar); } userService.updateById(user); return user; } ``` - [ ] **Step 6: Implement phone binding and auto-identification** In `AuthServiceImpl`, add: ```java @Override public AuthResponse bindWechatPhone(String currentUserId, WechatPhoneBindRequest request) { User currentUser = userService.getById(currentUserId); if (currentUser == null || currentUser.getIsDeleted() == 1) { throw new AuthException("用户不存在"); } if (!WECHAT_MP_TYPE.equals(currentUser.getThirdPartyType()) || !StringUtils.hasText(currentUser.getThirdPartyId())) { throw new BusinessException("当前账号未绑定微信身份"); } WechatPhoneNumberResponse.PhoneInfo phoneInfo = wechatMiniProgramService.getPhoneNumber(request.getCode()); String phone = StringUtils.hasText(phoneInfo.getPurePhoneNumber()) ? phoneInfo.getPurePhoneNumber() : phoneInfo.getPhoneNumber(); User phoneUser = userService.getByPhone(phone); if (phoneUser == null || phoneUser.getId().equals(currentUser.getId())) { currentUser.setPhone(phone); currentUser.setIsVerified(1); userService.updateById(currentUser); return buildAuthResponse(currentUser); } if (phoneUser.getStatus() != 1) { throw new AuthException("手机号对应账号已被禁用"); } if (StringUtils.hasText(phoneUser.getThirdPartyId()) && !currentUser.getThirdPartyId().equals(phoneUser.getThirdPartyId())) { throw new BusinessException("该手机号已绑定其他微信账号"); } phoneUser.setThirdPartyType(WECHAT_MP_TYPE); phoneUser.setThirdPartyId(currentUser.getThirdPartyId()); if (!StringUtils.hasText(phoneUser.getAvatar()) && StringUtils.hasText(currentUser.getAvatar())) { phoneUser.setAvatar(currentUser.getAvatar()); } if (!StringUtils.hasText(phoneUser.getNickname()) && StringUtils.hasText(currentUser.getNickname())) { phoneUser.setNickname(currentUser.getNickname()); } phoneUser.setIsVerified(1); userService.updateById(phoneUser); return buildAuthResponse(phoneUser); } ``` - [ ] **Step 7: Compile backend** ```bash cd backend-single mvn -q -DskipTests compile ``` Expected: command exits with status `0`. - [ ] **Step 8: Add service tests for identity rules** Create `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java` with a Spring test or Mockito-based test that covers: ```java @Test void wechatLoginCreatesUserForNewOpenid() { // Mock WechatMiniProgramService.code2Session to return openid. // Mock UserService.getByThirdParty to return null. // Mock UserService.createUser to return a new enabled user. // Assert AuthResponse has accessToken and userInfo.id from the new user. } @Test void bindWechatPhoneReturnsExistingPhoneUser() { // Current user has thirdPartyType=wechat-mp and thirdPartyId=openid-a. // Phone API returns 13800138000. // UserService.getByPhone returns a different enabled user without thirdPartyId. // Assert returned AuthResponse userInfo.id is the phone user's id. } ``` Use the repository's existing test patterns from `AuthControllerTest` and Mockito. If direct unit construction is blocked by private helpers, use `@SpringBootTest` and `@MockBean` for `WechatMiniProgramService`. - [ ] **Step 9: Run auth tests** ```bash cd backend-single mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest test ``` Expected: command exits with status `0`. - [ ] **Step 10: Commit** ```bash git add backend-single/src/main/java/com/emotion/service/UserService.java git add backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java git add backend-single/src/main/java/com/emotion/service/AuthService.java git add backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java git add backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java git commit -m "feat: add wechat auth identity binding logic" ``` --- ### Task 4: Backend Auth Controller And Security Allowlist **Files:** - Modify: `backend-single/src/main/java/com/emotion/controller/AuthController.java` - Modify: `backend-single/src/main/java/com/emotion/config/WebMvcConfig.java` - Modify: `backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java` - Test: `backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java` - [ ] **Step 1: Add controller endpoints** In `AuthController.java`, import: ```java import com.emotion.dto.request.WechatLoginRequest; import com.emotion.dto.request.WechatPhoneBindRequest; ``` Add methods: ```java @PostMapping("/wechat/login") @Operation(summary = "微信小程序登录", description = "使用微信登录 code 换取 openid 并登录或自动注册") public Result wechatLogin(@Valid @RequestBody WechatLoginRequest request) { AuthResponse response = authService.wechatLogin(request); return Result.success("微信登录成功", response); } @PostMapping("/wechat/phone") @Operation(summary = "绑定微信授权手机号", description = "使用微信手机号授权 code 获取手机号并自动识别绑定已有用户") public Result bindWechatPhone(@Valid @RequestBody WechatPhoneBindRequest request) { AuthResponse response = authService.bindWechatPhone(UserContextUtils.requireCurrentUserId(), request); return Result.success("手机号绑定成功", response); } ``` - [ ] **Step 2: Allow public WeChat login in WebMvcConfig** In `WebMvcConfig.java`, add to `excludePathPatterns`: ```java "/auth/wechat/login", ``` Do not exclude `/auth/wechat/phone`; it must require JWT. - [ ] **Step 3: Allow public WeChat login in JwtAuthInterceptor internal list** In `JwtAuthInterceptor.java`, add to `publicEndpoints`: ```java "/api/auth/wechat/login", ``` Do not add `/api/auth/wechat/phone`. - [ ] **Step 4: Add controller tests** In `AuthControllerTest.java`, add static imports for `post` and `content` if missing: ```java import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; ``` Add tests: ```java @Test public void testWechatLogin() throws Exception { AuthResponse response = new AuthResponse(); response.setAccessToken("access"); response.setRefreshToken("refresh"); when(authService.wechatLogin(org.mockito.ArgumentMatchers.any())).thenReturn(response); mockMvc.perform(post("/api/auth/wechat/login").contextPath("/api") .contentType("application/json") .content("{\"code\":\"wx-code\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.accessToken").value("access")); } @Test public void testWechatPhoneBind() throws Exception { AuthResponse response = new AuthResponse(); response.setAccessToken("phone-access"); response.setRefreshToken("phone-refresh"); when(authService.bindWechatPhone(org.mockito.ArgumentMatchers.eq("test-user"), org.mockito.ArgumentMatchers.any())) .thenReturn(response); mockMvc.perform(post("/api/auth/wechat/phone").contextPath("/api") .contentType("application/json") .content("{\"code\":\"phone-code\"}") .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.accessToken").value("phone-access")); } ``` - [ ] **Step 5: Run controller tests** ```bash cd backend-single mvn -q -Dtest=AuthControllerTest test ``` Expected: command exits with status `0`. - [ ] **Step 6: Commit** ```bash git add backend-single/src/main/java/com/emotion/controller/AuthController.java git add backend-single/src/main/java/com/emotion/config/WebMvcConfig.java git add backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java git add backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java git commit -m "feat: expose wechat auth endpoints" ``` --- ### Task 5: Mini Program Auth Service And Store **Files:** - Modify: `mini-program/src/services/auth.js` - Modify: `mini-program/src/stores/app.js` - Test: mini program build - [ ] **Step 1: Add shared token persistence helper** In `mini-program/src/services/auth.js`, add near the top: ```javascript const persistAuthTokens = (response) => { if (!response.data) return const { accessToken, refreshToken } = response.data if (accessToken) { uni.setStorageSync('access_token', accessToken) } if (refreshToken) { uni.setStorageSync('refresh_token', refreshToken) } } ``` Then replace the duplicated token-save block in `login()` and `refreshToken()` with: ```javascript persistAuthTokens(response) ``` - [ ] **Step 2: Add WeChat service methods** In `mini-program/src/services/auth.js`, add: ```javascript export const wechatLogin = async ({ code, nickname, avatar }) => { const response = await post('/auth/wechat/login', { code, nickname, avatar }) persistAuthTokens(response) return response } export const bindWechatPhone = async ({ code }) => { const response = await post('/auth/wechat/phone', { code }) persistAuthTokens(response) return response } ``` Add both methods to the default export: ```javascript wechatLogin, bindWechatPhone, ``` - [ ] **Step 3: Store returned userInfo** In `mini-program/src/stores/app.js`, add `userInfo` updates in existing `login` after `authService.login`: ```javascript state.userInfo = res.data?.userInfo || null ``` Add store methods: ```javascript const loginWithWechat = async (payload = {}) => { state.isLoading = true try { const res = await authService.wechatLogin(payload) state.userInfo = res.data?.userInfo || null state.isLoggedIn = true await fetchUserProfile() return { success: true, hasProfile: hasProfile.value, userInfo: state.userInfo } } catch (error) { return { success: false, error: error.message } } finally { state.isLoading = false } } const bindWechatPhone = async (code) => { state.isLoading = true try { const res = await authService.bindWechatPhone({ code }) state.userInfo = res.data?.userInfo || null state.isLoggedIn = true await fetchUserProfile() return { success: true, userInfo: state.userInfo, hasProfile: hasProfile.value } } catch (error) { return { success: false, error: error.message } } finally { state.isLoading = false } } ``` Add them to the returned object: ```javascript loginWithWechat, bindWechatPhone, ``` - [ ] **Step 4: Build mini program** ```bash cd mini-program npm run build:mp-weixin ``` Expected: command exits with status `0`. - [ ] **Step 5: Commit** ```bash git add mini-program/src/services/auth.js git add mini-program/src/stores/app.js git commit -m "feat: add mini program wechat auth service methods" ``` --- ### Task 6: Login Page Supports WeChat And Phone Login **Files:** - Modify: `mini-program/src/pages/login/index.vue` - Test: mini program build - [ ] **Step 1: Add WeChat login handler** In `mini-program/src/pages/login/index.vue`, add: ```javascript const activeMethod = ref('') const routeAfterLogin = (hasProfile) => { if (hasProfile) { uni.redirectTo({ url: '/pages/main/index?tab=script' }) } else { uni.redirectTo({ url: '/pages/onboarding/index' }) } } const getWechatLoginCode = () => { return new Promise((resolve, reject) => { uni.login({ provider: 'weixin', success: (res) => { if (res.code) { resolve(res.code) } else { reject(new Error('微信登录未返回凭证')) } }, fail: (error) => reject(error) }) }) } const handleWechatLogin = async () => { if (loading.value) return loading.value = true activeMethod.value = 'wechat' try { const code = await getWechatLoginCode() const result = await store.loginWithWechat({ code }) if (result.success) { routeAfterLogin(result.hasProfile) } else { uni.showToast({ title: result.error || '微信登录失败', icon: 'none' }) } } catch (error) { uni.showToast({ title: '微信登录失败,请稍后重试', icon: 'none' }) } finally { loading.value = false activeMethod.value = '' } } ``` Change `handleLogin` success routing to: ```javascript routeAfterLogin(result.hasProfile) ``` - [ ] **Step 2: Add primary WeChat button above phone form** In the `.login-card` template, above ``, add: ```vue ``` - [ ] **Step 3: Update phone button loading text** Change the phone login button text block to: ```vue 登录中... 开启旅程 ``` At the start of `handleLogin`, set: ```javascript activeMethod.value = 'phone' ``` And in the `finally`, clear: ```javascript activeMethod.value = '' ``` - [ ] **Step 4: Add styles matching current glass theme** Add styles: ```css .wechat-btn { width: 100%; height: 88rpx; margin-bottom: 26rpx; padding: 0; border: 0; border-radius: 28rpx; display: flex; align-items: center; justify-content: center; color: #ffffff; font-size: 28rpx; font-weight: 700; background: linear-gradient(135deg, #34d399 0%, #22c55e 48%, #16a34a 100%); box-shadow: 0 14rpx 34rpx rgba(34, 197, 94, 0.26), inset 0 1rpx 0 rgba(255, 255, 255, 0.22); } .wechat-btn::after { border: 0; } .wechat-btn.disabled { opacity: 0.52; } .login-divider { display: flex; align-items: center; gap: 18rpx; margin-bottom: 30rpx; } .login-divider view { flex: 1; height: 1rpx; background: rgba(217, 190, 255, 0.18); } .login-divider text { color: rgba(225, 209, 255, 0.52); font-size: 22rpx; } ``` - [ ] **Step 5: Build mini program** ```bash cd mini-program npm run build:mp-weixin ``` Expected: command exits with status `0`. - [ ] **Step 6: Commit** ```bash git add mini-program/src/pages/login/index.vue git commit -m "feat: add wechat login to mini program login page" ``` --- ### Task 7: Logged-In Phone Binding Entry **Files:** - Modify: `mini-program/src/pages/main/MineView.vue` - Test: mini program build - [ ] **Step 1: Add phone binding computed state and handler** In `MineView.vue`, add: ```javascript const currentUser = computed(() => store.userInfo || {}) const hasPhone = computed(() => Boolean(currentUser.value.phone || profile.value.phone)) const canBindWechatPhone = computed(() => store.isLoggedIn && !hasPhone.value) const handleWechatPhoneBind = async (event) => { const code = event?.detail?.code if (!code) { uni.showToast({ title: '可稍后在个人中心绑定手机号', icon: 'none' }) return } const result = await store.bindWechatPhone(code) if (result.success) { uni.showToast({ title: '手机号绑定成功', icon: 'success' }) return } uni.showToast({ title: result.error || '手机号绑定失败', icon: 'none' }) } ``` - [ ] **Step 2: Add menu item for WeChat phone binding** In the `.menu-list`, before the account switching menu item, add: ```vue ``` - [ ] **Step 3: Add button reset styles and phone icon** Add styles: ```css .bind-phone-button { width: 100%; margin: 0; text-align: left; border: 1rpx solid rgba(160, 112, 255, 0.14); } .bind-phone-button::after { border: 0; } .phone-icon { border: 3rpx solid currentColor; border-radius: 8rpx; } .phone-icon::before { content: ''; position: absolute; left: 10rpx; right: 10rpx; bottom: 5rpx; height: 3rpx; border-radius: 999rpx; background: currentColor; } ``` - [ ] **Step 4: Build mini program** ```bash cd mini-program npm run build:mp-weixin ``` Expected: command exits with status `0`. - [ ] **Step 5: Commit** ```bash git add mini-program/src/pages/main/MineView.vue git commit -m "feat: add wechat phone binding entry" ``` --- ### Task 8: Full Verification And Documentation Notes **Files:** - Read: `docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md` - Optional Modify: `mini-program/README.md` - Test: backend tests and mini program build - [ ] **Step 1: Run backend auth tests** ```bash cd backend-single mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest test ``` Expected: command exits with status `0`. - [ ] **Step 2: Compile backend** ```bash cd backend-single mvn -q -DskipTests compile ``` Expected: command exits with status `0`. - [ ] **Step 3: Build mini program** ```bash cd mini-program npm run build:mp-weixin ``` Expected: command exits with status `0`. - [ ] **Step 4: Manual WeChat DevTools check** Open `mini-program/unpackage/dist/build/mp-weixin` in WeChat DevTools and verify: 1. Login page shows "微信一键登录" and the phone/SMS form. 2. Phone/SMS login still submits to `/auth/login`. 3. WeChat login submits to `/auth/wechat/login`. 4. Successful login follows existing profile routing. 5. Personal center shows phone binding when no phone is present. 6. Denying phone authorization keeps the user logged in. - [ ] **Step 5: Add README note if config is not documented elsewhere** If no deployment doc already lists WeChat env vars, add this section to `mini-program/README.md` or `backend-single/部署说明.md`: ```markdown ### WeChat Mini Program Login Backend environment variables: ```bash WECHAT_MINI_PROGRAM_APP_ID=wxaf2eaba72d28f0e4 WECHAT_MINI_PROGRAM_APP_SECRET= ``` WeChat openid login works without phone authorization. Phone authorization is optional and, when available, is used to bind the WeChat openid to an existing phone account. ``` - [ ] **Step 6: Final status** ```bash git status --short ``` Expected: no uncommitted implementation changes except intentionally uncommitted local environment files. --- ## Self-Review: Spec Coverage Check | Spec Requirement | Plan Coverage | | --- | --- | | Free WeChat openid login | Tasks 1, 2, 3, 4, 5, 6 | | Keep phone/SMS login | Tasks 5 and 6 preserve existing `login(phone, smsCode)` and UI form | | Optional phone authorization | Tasks 2, 3, 4, 5, 7 | | Phone identifies existing account and auto-binds openid | Task 3 Step 6 | | Existing phone user becomes canonical identity | Task 3 Step 6 returns tokens for `phoneUser` | | Nickname/avatar saved when provided | Task 3 Step 5 | | Reuse current JWT/AuthResponse/session restore | Tasks 3, 4, 5 | | Login page matches existing style | Task 6 | | Protected phone binding endpoint | Task 4 Steps 2-3 and test list | | Build and verification | Task 8 | ## Placeholder Scan This plan contains no incomplete placeholder markers, no deferred sections, and no undefined task names. Each code-changing step includes exact files and code snippets. ## Type Consistency Check - Backend request DTOs use `code`, `nickname`, and `avatar`; frontend service sends the same names. - Backend phone binding DTO uses `code`; frontend `bindWechatPhone({ code })` sends the same name. - `thirdPartyType` and `thirdPartyId` map to existing `User` fields. - `AuthResponse` stays the only frontend token response shape.