Files
happy-life-star/docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md
T

1309 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String, Object> 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<String, String> 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<String, Object> 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<User> 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<AuthResponse> wechatLogin(@Valid @RequestBody WechatLoginRequest request) {
AuthResponse response = authService.wechatLogin(request);
return Result.success("微信登录成功", response);
}
@PostMapping("/wechat/phone")
@Operation(summary = "绑定微信授权手机号", description = "使用微信手机号授权 code 获取手机号并自动识别绑定已有用户")
public Result<AuthResponse> 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 `<view class="form">`, add:
```vue
<button
class="wechat-btn"
:class="{ disabled: loading }"
:disabled="loading"
@click="handleWechatLogin"
>
<text v-if="loading && activeMethod === 'wechat'">微信登录中...</text>
<text v-else>微信一键登录</text>
</button>
<view class="login-divider">
<view></view>
<text>或使用手机号登录</text>
<view></view>
</view>
```
- [ ] **Step 3: Update phone button loading text**
Change the phone login button text block to:
```vue
<text v-if="loading && activeMethod === 'phone'">登录中...</text>
<text v-else>开启旅程</text>
```
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
<!-- #ifdef MP-WEIXIN -->
<button
v-if="canBindWechatPhone"
class="menu-item bind-phone-button"
open-type="getPhoneNumber"
@getphonenumber="handleWechatPhoneBind"
>
<view class="menu-icon phone-icon"></view>
<text>绑定微信手机号</text>
<text class="menu-arrow"></text>
</button>
<!-- #endif -->
```
- [ ] **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=<mini-program-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.