1690 lines
54 KiB
Markdown
1690 lines
54 KiB
Markdown
# 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.
|