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

1690 lines
54 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. |
| Create | `backend-single/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql` | Prevents duplicate non-null WeChat third-party identities. |
| 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. |
| Create | `backend-single/src/main/java/com/emotion/service/UserIdentityMergeService.java` | Defines temporary WeChat user to phone user data transfer. |
| Create | `backend-single/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java` | Transfers user-owned rows and avoids singleton row conflicts. |
| Modify | `backend-single/src/main/java/com/emotion/dto/response/UserInfoResponse.java` | Adds non-sensitive `wechatBound` flag for frontend display logic. |
| 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/resources/db/migration/V20260531__add_unique_wechat_identity.sql`
- 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/java/com/emotion/dto/response/UserInfoResponse.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: Add WeChat identity uniqueness migration**
Before applying the migration in a shared environment, run:
```sql
SELECT third_party_type, third_party_id, COUNT(*) AS count
FROM t_user
WHERE third_party_type IS NOT NULL
AND third_party_id IS NOT NULL
AND is_deleted = 0
GROUP BY third_party_type, third_party_id
HAVING COUNT(*) > 1;
```
Expected: no rows. If rows exist, resolve duplicates before adding the index.
Create `backend-single/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql`:
```sql
ALTER TABLE t_user
ADD UNIQUE KEY uk_user_third_party_identity (third_party_type, third_party_id);
```
MySQL permits multiple `NULL` values in a unique index, so users without WeChat binding are unaffected. Implementation must clear a temporary WeChat user's third-party fields before transferring the openid to another user.
- [ ] **Step 6: Add non-sensitive WeChat binding flag**
In `backend-single/src/main/java/com/emotion/dto/response/UserInfoResponse.java`, add:
```java
/**
* Whether the account has a WeChat mini program identity bound.
*/
private Boolean wechatBound;
```
- [ ] **Step 7: Compile backend**
Run:
```bash
cd backend-single
mvn -q -DskipTests compile
```
Expected: command exits with status `0`.
- [ ] **Step 8: Commit**
```bash
git add backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java
git add backend-single/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql
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/java/com/emotion/dto/response/UserInfoResponse.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 expiresIn = response.getExpiresIn() == null ? 7200L : response.getExpiresIn();
long ttl = Math.max(60L, expiresIn - 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 Identity Merge Service
**Files:**
- Create: `backend-single/src/main/java/com/emotion/service/UserIdentityMergeService.java`
- Create: `backend-single/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java`
- Test: `backend-single/src/test/java/com/emotion/service/UserIdentityMergeServiceImplTest.java`
- [ ] **Step 1: Create merge service interface**
Create `backend-single/src/main/java/com/emotion/service/UserIdentityMergeService.java`:
```java
package com.emotion.service;
public interface UserIdentityMergeService {
void mergeTemporaryWechatUserIntoPhoneUser(String temporaryWechatUserId, String phoneUserId);
}
```
- [ ] **Step 2: Create JdbcTemplate implementation**
Create `backend-single/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java`:
```java
package com.emotion.service.impl;
import com.emotion.service.UserIdentityMergeService;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserIdentityMergeServiceImpl implements UserIdentityMergeService {
private final JdbcTemplate jdbcTemplate;
private static final List<String> DIRECT_TRANSFER_TABLES = List.of(
"t_life_event",
"t_epic_script",
"t_life_path",
"t_life_path_step",
"t_social_content_item",
"t_social_profile_insight",
"t_user_consent_log",
"t_tts_task",
"t_analytics_event",
"t_conversation",
"t_message",
"t_coze_api_call",
"t_emotion_analysis",
"t_emotion_record",
"t_diary_post",
"t_diary_comment",
"t_community_post",
"t_comment",
"t_ai_call_log"
);
private static final List<String> SINGLETON_TABLES = List.of(
"t_user_profile",
"t_user_stats"
);
@Override
@Transactional(rollbackFor = Exception.class)
public void mergeTemporaryWechatUserIntoPhoneUser(String temporaryWechatUserId, String phoneUserId) {
if (!StringUtils.hasText(temporaryWechatUserId)
|| !StringUtils.hasText(phoneUserId)
|| temporaryWechatUserId.equals(phoneUserId)) {
return;
}
for (String table : DIRECT_TRANSFER_TABLES) {
updateUserId(table, temporaryWechatUserId, phoneUserId);
}
for (String table : SINGLETON_TABLES) {
transferSingletonIfTargetMissing(table, temporaryWechatUserId, phoneUserId);
}
}
private void updateUserId(String table, String fromUserId, String toUserId) {
jdbcTemplate.update("UPDATE " + table + " SET user_id = ? WHERE user_id = ?", toUserId, fromUserId);
}
private void transferSingletonIfTargetMissing(String table, String fromUserId, String toUserId) {
Integer targetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM " + table + " WHERE user_id = ? AND is_deleted = 0",
Integer.class,
toUserId
);
if (targetCount != null && targetCount > 0) {
return;
}
updateUserId(table, fromUserId, toUserId);
}
}
```
- [ ] **Step 3: Add merge service test**
Create `backend-single/src/test/java/com/emotion/service/UserIdentityMergeServiceImplTest.java`:
```java
package com.emotion.service;
import com.emotion.service.impl.UserIdentityMergeServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class UserIdentityMergeServiceImplTest {
@Test
void mergeTransfersDirectTablesAndChecksSingletons() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.queryForObject(any(String.class), eq(Integer.class), eq("phone-user"))).thenReturn(0);
UserIdentityMergeService service = new UserIdentityMergeServiceImpl(jdbcTemplate);
service.mergeTemporaryWechatUserIntoPhoneUser("wx-temp", "phone-user");
verify(jdbcTemplate, atLeastOnce()).update(any(String.class), eq("phone-user"), eq("wx-temp"));
verify(jdbcTemplate, atLeastOnce()).queryForObject(any(String.class), eq(Integer.class), eq("phone-user"));
}
}
```
- [ ] **Step 4: Run merge service test**
```bash
cd backend-single
mvn -q -Dtest=UserIdentityMergeServiceImplTest test
```
Expected: command exits with status `0`.
- [ ] **Step 5: Commit**
```bash
git add backend-single/src/main/java/com/emotion/service/UserIdentityMergeService.java
git add backend-single/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java
git add backend-single/src/test/java/com/emotion/service/UserIdentityMergeServiceImplTest.java
git commit -m "feat: add wechat user identity merge service"
```
---
### Task 4: 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`
- Modify: `backend-single/src/main/java/com/emotion/dto/response/UserInfoResponse.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.UserIdentityMergeService;
import com.emotion.service.WechatMiniProgramService;
```
Add field:
```java
@Autowired
private WechatMiniProgramService wechatMiniProgramService;
@Autowired
private UserIdentityMergeService userIdentityMergeService;
```
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: Add wechatBound to user info conversion**
In `AuthServiceImpl.convertToUserInfoResponse`, after copying properties and setting times, add:
```java
response.setWechatBound(WECHAT_MP_TYPE.equals(user.getThirdPartyType())
&& StringUtils.hasText(user.getThirdPartyId()));
```
- [ ] **Step 6: 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 7: Implement phone binding, data transfer, 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("该手机号已绑定其他微信账号");
}
String openid = currentUser.getThirdPartyId();
userIdentityMergeService.mergeTemporaryWechatUserIntoPhoneUser(currentUser.getId(), phoneUser.getId());
currentUser.setThirdPartyType(null);
currentUser.setThirdPartyId(null);
currentUser.setStatus(0);
userService.updateById(currentUser);
phoneUser.setThirdPartyType(WECHAT_MP_TYPE);
phoneUser.setThirdPartyId(openid);
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 8: Compile backend**
```bash
cd backend-single
mvn -q -DskipTests compile
```
Expected: command exits with status `0`.
- [ ] **Step 9: Add service tests for identity rules**
Create `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java`:
```java
package com.emotion.service;
import com.emotion.dto.request.WechatLoginRequest;
import com.emotion.dto.request.WechatPhoneBindRequest;
import com.emotion.dto.response.AuthResponse;
import com.emotion.dto.wechat.WechatCodeSessionResponse;
import com.emotion.dto.wechat.WechatPhoneNumberResponse;
import com.emotion.entity.User;
import com.emotion.service.impl.AuthServiceImpl;
import com.emotion.util.JwtUtil;
import com.emotion.util.TokenUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthServiceWechatTest {
@Mock
private UserService userService;
@Mock
private WechatMiniProgramService wechatMiniProgramService;
@Mock
private UserIdentityMergeService userIdentityMergeService;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtUtil jwtUtil;
@Mock
private TokenUtil tokenUtil;
@InjectMocks
private AuthServiceImpl authService;
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("access-token");
when(jwtUtil.generateRefreshToken(anyString(), anyString())).thenReturn("refresh-token");
when(passwordEncoder.encode(anyString())).thenReturn("encoded-password");
}
@Test
void wechatLoginCreatesUserForNewOpenid() {
WechatCodeSessionResponse session = new WechatCodeSessionResponse();
session.setOpenid("openid-a");
when(wechatMiniProgramService.code2Session("login-code")).thenReturn(session);
when(userService.getByThirdParty("wechat-mp", "openid-a")).thenReturn(null);
User created = new User();
created.setId("wx-user");
created.setUsername("wx-name");
created.setNickname("wx-name");
created.setStatus(1);
created.setIsDeleted(0);
when(userService.createUser(anyString(), anyString(), anyString(), eq(null), eq(null))).thenReturn(created);
WechatLoginRequest request = new WechatLoginRequest();
request.setCode("login-code");
AuthResponse response = authService.wechatLogin(request);
assertEquals("access-token", response.getAccessToken());
assertEquals("wx-user", response.getUserInfo().getId());
verify(userService).updateById(created);
}
@Test
void bindWechatPhoneReturnsExistingPhoneUserAndTransfersTemporaryData() {
User temporaryWechatUser = new User();
temporaryWechatUser.setId("wx-temp");
temporaryWechatUser.setUsername("wx-temp-name");
temporaryWechatUser.setThirdPartyType("wechat-mp");
temporaryWechatUser.setThirdPartyId("openid-a");
temporaryWechatUser.setStatus(1);
temporaryWechatUser.setIsDeleted(0);
when(userService.getById("wx-temp")).thenReturn(temporaryWechatUser);
WechatPhoneNumberResponse.PhoneInfo phoneInfo = new WechatPhoneNumberResponse.PhoneInfo();
phoneInfo.setPhoneNumber("13800138000");
phoneInfo.setPurePhoneNumber("13800138000");
when(wechatMiniProgramService.getPhoneNumber("phone-code")).thenReturn(phoneInfo);
User phoneUser = new User();
phoneUser.setId("phone-user");
phoneUser.setUsername("phone-name");
phoneUser.setPhone("13800138000");
phoneUser.setStatus(1);
phoneUser.setIsDeleted(0);
when(userService.getByPhone("13800138000")).thenReturn(phoneUser);
WechatPhoneBindRequest request = new WechatPhoneBindRequest();
request.setCode("phone-code");
AuthResponse response = authService.bindWechatPhone("wx-temp", request);
assertNotNull(response);
assertEquals("phone-user", response.getUserInfo().getId());
assertEquals("openid-a", phoneUser.getThirdPartyId());
assertEquals("wechat-mp", phoneUser.getThirdPartyType());
assertEquals(0, temporaryWechatUser.getStatus());
verify(userIdentityMergeService).mergeTemporaryWechatUserIntoPhoneUser("wx-temp", "phone-user");
verify(userService).updateById(temporaryWechatUser);
verify(userService).updateById(phoneUser);
}
}
```
- [ ] **Step 10: Run auth tests**
```bash
cd backend-single
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,UserIdentityMergeServiceImplTest test
```
Expected: command exits with status `0`.
- [ ] **Step 11: 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/main/java/com/emotion/dto/response/UserInfoResponse.java
git add backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java
git commit -m "feat: add wechat auth identity binding logic"
```
---
### Task 5: 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 6: 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: Add current user info refresh for restored sessions**
In `mini-program/src/stores/app.js`, add:
```javascript
const fetchCurrentUserInfo = async () => {
try {
const res = await authService.getCurrentUserInfo()
state.userInfo = res.data || null
return state.userInfo
} catch (error) {
logAuth('userInfo:fail', {
message: error.message,
statusCode: error.statusCode,
code: error.code
})
return null
}
}
```
Call it after every successful token validation or refresh in `restoreSession()`:
```javascript
state.isLoggedIn = true
await fetchCurrentUserInfo()
await fetchUserProfile({ throwOnError: true })
```
Also call it after successful existing phone login:
```javascript
state.userInfo = res.data?.userInfo || null
await fetchCurrentUserInfo()
```
Add it to the returned store object:
```javascript
fetchCurrentUserInfo,
```
- [ ] **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/services/auth.js
git add mini-program/src/stores/app.js
git commit -m "feat: add mini program wechat auth service methods"
```
---
### Task 7: 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 8: 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))
const canBindWechatPhone = computed(() => store.isLoggedIn && currentUser.value.wechatBound && !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 9: 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,UserIdentityMergeServiceImplTest 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, 4, 5, 6, 7 |
| Keep phone/SMS login | Tasks 6 and 7 preserve existing `login(phone, smsCode)` and UI form |
| Optional phone authorization | Tasks 2, 4, 5, 6, 8 |
| Phone identifies existing account and auto-binds openid | Task 4 Step 7 |
| Existing phone user becomes canonical identity | Task 4 Step 7 returns tokens for `phoneUser` |
| Temporary WeChat user data is not stranded | Task 3 and Task 4 Step 7 |
| Temporary WeChat identity is cleared after transfer | Task 4 Step 7 |
| Nickname/avatar saved when provided | Task 4 Step 6 |
| Reuse current JWT/AuthResponse/session restore | Tasks 4, 5, 6 |
| Login page matches existing style | Task 7 |
| Protected phone binding endpoint | Task 5 Steps 2-3 and test list |
| Restored sessions know phone and WeChat-bound state | Task 6 Step 4 |
| Build and verification | Task 9 |
## 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.
- `UserInfoResponse.wechatBound` maps to frontend `currentUser.value.wechatBound`.
- `AuthResponse` stays the only frontend token response shape.