diff --git a/docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md b/docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md new file mode 100644 index 0000000..aaf2ae7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md @@ -0,0 +1,1308 @@ +# 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.