docs: refine wechat auth design and plan
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
| 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. |
|
||||
@@ -27,6 +28,9 @@
|
||||
| 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. |
|
||||
@@ -45,11 +49,13 @@
|
||||
|
||||
**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
|
||||
|
||||
@@ -217,7 +223,43 @@ In `backend-single/src/main/resources/application.yml`, under `emotion:`, add:
|
||||
base-url: https://api.weixin.qq.com
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Compile backend**
|
||||
- [ ] **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:
|
||||
|
||||
@@ -228,15 +270,17 @@ mvn -q -DskipTests compile
|
||||
|
||||
Expected: command exits with status `0`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [ ] **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"
|
||||
```
|
||||
@@ -374,7 +418,8 @@ public class WechatMiniProgramServiceImpl implements WechatMiniProgramService {
|
||||
throw new BusinessException("微信接口调用凭证获取失败");
|
||||
}
|
||||
|
||||
long ttl = Math.max(60L, response.getExpiresIn() - ACCESS_TOKEN_CACHE_BUFFER_SECONDS);
|
||||
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();
|
||||
}
|
||||
@@ -442,13 +487,170 @@ git commit -m "feat: add wechat mini program API client"
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend User Lookup And Auth Service Methods
|
||||
### 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**
|
||||
@@ -494,6 +696,7 @@ 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;
|
||||
```
|
||||
|
||||
@@ -502,6 +705,9 @@ Add field:
|
||||
```java
|
||||
@Autowired
|
||||
private WechatMiniProgramService wechatMiniProgramService;
|
||||
|
||||
@Autowired
|
||||
private UserIdentityMergeService userIdentityMergeService;
|
||||
```
|
||||
|
||||
Add constant:
|
||||
@@ -532,7 +738,16 @@ private AuthResponse buildAuthResponse(User user) {
|
||||
|
||||
Then replace the duplicate token response block in `login`, `register`, and `refreshToken` with `return buildAuthResponse(user);`.
|
||||
|
||||
- [ ] **Step 5: Implement WeChat login**
|
||||
- [ ] **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:
|
||||
|
||||
@@ -582,7 +797,7 @@ private User autoRegisterWechatUser(String openid, String nickname, String avata
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement phone binding and auto-identification**
|
||||
- [ ] **Step 7: Implement phone binding, data transfer, and auto-identification**
|
||||
|
||||
In `AuthServiceImpl`, add:
|
||||
|
||||
@@ -618,8 +833,16 @@ public AuthResponse bindWechatPhone(String currentUserId, WechatPhoneBindRequest
|
||||
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(currentUser.getThirdPartyId());
|
||||
phoneUser.setThirdPartyId(openid);
|
||||
if (!StringUtils.hasText(phoneUser.getAvatar()) && StringUtils.hasText(currentUser.getAvatar())) {
|
||||
phoneUser.setAvatar(currentUser.getAvatar());
|
||||
}
|
||||
@@ -632,7 +855,7 @@ public AuthResponse bindWechatPhone(String currentUserId, WechatPhoneBindRequest
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Compile backend**
|
||||
- [ ] **Step 8: Compile backend**
|
||||
|
||||
```bash
|
||||
cd backend-single
|
||||
@@ -641,53 +864,165 @@ mvn -q -DskipTests compile
|
||||
|
||||
Expected: command exits with status `0`.
|
||||
|
||||
- [ ] **Step 8: Add service tests for identity rules**
|
||||
- [ ] **Step 9: Add service tests for identity rules**
|
||||
|
||||
Create `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java` with a Spring test or Mockito-based test that covers:
|
||||
Create `backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void wechatLoginCreatesUserForNewOpenid() {
|
||||
// Mock WechatMiniProgramService.code2Session to return openid.
|
||||
// Mock UserService.getByThirdParty to return null.
|
||||
// Mock UserService.createUser to return a new enabled user.
|
||||
// Assert AuthResponse has accessToken and userInfo.id from the new user.
|
||||
}
|
||||
package com.emotion.service;
|
||||
|
||||
@Test
|
||||
void bindWechatPhoneReturnsExistingPhoneUser() {
|
||||
// Current user has thirdPartyType=wechat-mp and thirdPartyId=openid-a.
|
||||
// Phone API returns 13800138000.
|
||||
// UserService.getByPhone returns a different enabled user without thirdPartyId.
|
||||
// Assert returned AuthResponse userInfo.id is the phone user's id.
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the repository's existing test patterns from `AuthControllerTest` and Mockito. If direct unit construction is blocked by private helpers, use `@SpringBootTest` and `@MockBean` for `WechatMiniProgramService`.
|
||||
|
||||
- [ ] **Step 9: Run auth tests**
|
||||
- [ ] **Step 10: Run auth tests**
|
||||
|
||||
```bash
|
||||
cd backend-single
|
||||
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest test
|
||||
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,UserIdentityMergeServiceImplTest test
|
||||
```
|
||||
|
||||
Expected: command exits with status `0`.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
- [ ] **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 4: Backend Auth Controller And Security Allowlist
|
||||
### Task 5: Backend Auth Controller And Security Allowlist
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/controller/AuthController.java`
|
||||
@@ -809,7 +1144,7 @@ git commit -m "feat: expose wechat auth endpoints"
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Mini Program Auth Service And Store
|
||||
### Task 6: Mini Program Auth Service And Store
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/services/auth.js`
|
||||
@@ -913,7 +1248,49 @@ loginWithWechat,
|
||||
bindWechatPhone,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build mini program**
|
||||
- [ ] **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
|
||||
@@ -922,7 +1299,7 @@ npm run build:mp-weixin
|
||||
|
||||
Expected: command exits with status `0`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/services/auth.js
|
||||
@@ -932,7 +1309,7 @@ git commit -m "feat: add mini program wechat auth service methods"
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Login Page Supports WeChat And Phone Login
|
||||
### Task 7: Login Page Supports WeChat And Phone Login
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/login/index.vue`
|
||||
@@ -1108,7 +1485,7 @@ git commit -m "feat: add wechat login to mini program login page"
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Logged-In Phone Binding Entry
|
||||
### Task 8: Logged-In Phone Binding Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/MineView.vue`
|
||||
@@ -1120,8 +1497,8 @@ In `MineView.vue`, add:
|
||||
|
||||
```javascript
|
||||
const currentUser = computed(() => store.userInfo || {})
|
||||
const hasPhone = computed(() => Boolean(currentUser.value.phone || profile.value.phone))
|
||||
const canBindWechatPhone = computed(() => store.isLoggedIn && !hasPhone.value)
|
||||
const 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
|
||||
@@ -1209,7 +1586,7 @@ git commit -m "feat: add wechat phone binding entry"
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Full Verification And Documentation Notes
|
||||
### Task 9: Full Verification And Documentation Notes
|
||||
|
||||
**Files:**
|
||||
- Read: `docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md`
|
||||
@@ -1220,7 +1597,7 @@ git commit -m "feat: add wechat phone binding entry"
|
||||
|
||||
```bash
|
||||
cd backend-single
|
||||
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest test
|
||||
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest,UserIdentityMergeServiceImplTest test
|
||||
```
|
||||
|
||||
Expected: command exits with status `0`.
|
||||
@@ -1285,16 +1662,19 @@ Expected: no uncommitted implementation changes except intentionally uncommitted
|
||||
|
||||
| Spec Requirement | Plan Coverage |
|
||||
| --- | --- |
|
||||
| Free WeChat openid login | Tasks 1, 2, 3, 4, 5, 6 |
|
||||
| Keep phone/SMS login | Tasks 5 and 6 preserve existing `login(phone, smsCode)` and UI form |
|
||||
| Optional phone authorization | Tasks 2, 3, 4, 5, 7 |
|
||||
| Phone identifies existing account and auto-binds openid | Task 3 Step 6 |
|
||||
| Existing phone user becomes canonical identity | Task 3 Step 6 returns tokens for `phoneUser` |
|
||||
| Nickname/avatar saved when provided | Task 3 Step 5 |
|
||||
| Reuse current JWT/AuthResponse/session restore | Tasks 3, 4, 5 |
|
||||
| Login page matches existing style | Task 6 |
|
||||
| Protected phone binding endpoint | Task 4 Steps 2-3 and test list |
|
||||
| Build and verification | Task 8 |
|
||||
| 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
|
||||
|
||||
@@ -1305,4 +1685,5 @@ This plan contains no incomplete placeholder markers, no deferred sections, and
|
||||
- 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.
|
||||
|
||||
@@ -32,6 +32,8 @@ The mini program currently supports phone number plus SMS code login only. The n
|
||||
- The current backend issues JWT plus Redis-backed access/refresh tokens through `AuthServiceImpl`.
|
||||
- The mini program manifest already has a WeChat appid: `wxaf2eaba72d28f0e4`.
|
||||
- `application.yml` security ignore URLs must include the new public WeChat login endpoint, while the phone binding endpoint must remain protected by JWT.
|
||||
- `UserInfoResponse` exposes phone, nickname, and avatar, but it does not expose whether the current account has a WeChat binding. The frontend needs a non-sensitive `wechatBound` boolean instead of receiving openid.
|
||||
- Binding an openid from a temporary WeChat user to an existing phone user must also transfer or preserve data created under the temporary user; otherwise early actions before phone binding can be stranded on the temporary account.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
@@ -62,6 +64,12 @@ Add configuration under `emotion.wechat.mini-program`:
|
||||
|
||||
Secrets must come from environment/profile configuration in deploy environments. `app-secret` must not be sent to the client or logged.
|
||||
|
||||
Add a database migration for WeChat identity integrity:
|
||||
|
||||
- Add a unique composite index on `t_user(third_party_type, third_party_id)`.
|
||||
- MySQL allows multiple `NULL` values in a unique index, so ordinary phone users without WeChat binding are unaffected.
|
||||
- Before transferring an openid from a temporary WeChat user to an existing phone user, clear the temporary user's third-party fields so the unique index remains valid.
|
||||
|
||||
### DTOs
|
||||
|
||||
Add request/response DTOs:
|
||||
@@ -75,6 +83,7 @@ Add request/response DTOs:
|
||||
- `code`: required phone authorization code from `getPhoneNumber`.
|
||||
|
||||
- The response can reuse `AuthResponse` for login and phone binding so token handling stays consistent.
|
||||
- Extend `UserInfoResponse` with `wechatBound: boolean`. It must be computed from the server-side third-party fields and must not expose openid or session key.
|
||||
|
||||
### Service Boundaries
|
||||
|
||||
@@ -85,6 +94,13 @@ Add `WechatMiniProgramService` for WeChat API calls:
|
||||
- `getPhoneNumber(phoneCode)` returns the verified phone number.
|
||||
- Translate WeChat API error codes into clear `BusinessException` messages.
|
||||
|
||||
Add `UserIdentityMergeService` for account convergence:
|
||||
|
||||
- `mergeTemporaryWechatUserIntoPhoneUser(String temporaryWechatUserId, String phoneUserId)` transfers user-owned rows from the temporary WeChat user to the canonical phone user.
|
||||
- The service updates the core mini-program tables that store `user_id`: `t_user_profile`, `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`, and `t_analytics_event`.
|
||||
- It also updates user-facing shared data where appropriate: `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_user_stats`, and `t_ai_call_log`.
|
||||
- If both users have a singleton row such as `t_user_profile` or `t_user_stats`, keep the existing phone user's row and only transfer rows that do not conflict. This avoids overwriting a mature phone account with sparse temporary data.
|
||||
|
||||
Extend `AuthService` with:
|
||||
|
||||
- `wechatLogin(WechatLoginRequest request)`
|
||||
@@ -122,8 +138,9 @@ For phone binding:
|
||||
4. Query existing user by phone.
|
||||
5. If no phone user exists, set current user's `phone` and mark verified.
|
||||
6. If the phone user is the current user, ensure openid and phone are both present.
|
||||
7. If another enabled user owns the phone, bind the openid to that phone user and issue new tokens for the phone user.
|
||||
7. If another enabled user owns the phone, treat that phone user as canonical.
|
||||
8. If the phone user already has a different WeChat openid, reject with a clear "phone already bound to another WeChat account" message.
|
||||
9. If the phone user can accept the binding, transfer user-owned rows from the temporary WeChat user to the phone user, clear the temporary user's third-party fields, bind the openid to the phone user, and issue new tokens for the phone user.
|
||||
|
||||
When the phone belongs to an existing account, the existing account is the canonical identity. The response must return tokens for that account so future openid login lands on the same user as phone SMS login.
|
||||
|
||||
@@ -152,8 +169,9 @@ Both methods store returned tokens exactly like existing `login()` and `refreshT
|
||||
|
||||
Add to `mini-program/src/stores/app.js`:
|
||||
|
||||
- `wechatLogin(profilePayload)`
|
||||
- `loginWithWechat(profilePayload)`
|
||||
- `bindWechatPhone(phoneCode)`
|
||||
- `fetchCurrentUserInfo()`, called after login, token restore, and phone binding.
|
||||
- Keep `login(phone, smsCode)` unchanged.
|
||||
- After phone binding returns new tokens, refresh current profile/user state.
|
||||
|
||||
@@ -161,7 +179,7 @@ Add to `mini-program/src/stores/app.js`:
|
||||
|
||||
Add a small phone-bind entry in the logged-in experience, preferably in `MineView.vue` or onboarding completion area:
|
||||
|
||||
- Show only when logged in and `userInfo/userProfile` has no phone.
|
||||
- Show only when logged in, `userInfo.phone` is empty, and `userInfo.wechatBound` is true.
|
||||
- Use WeChat `button open-type="getPhoneNumber"` on mp-weixin.
|
||||
- On non-WeChat/H5 builds, show the existing phone SMS login/bind fallback.
|
||||
- If authorization succeeds and backend returns tokens for an existing phone user, overwrite local tokens and refresh profile.
|
||||
@@ -185,6 +203,8 @@ Use WeChat's current nickname/avatar collection pattern in profile or onboarding
|
||||
- Phone authorization denied: do not log out; show a non-blocking message that the user can bind a phone later in the profile center.
|
||||
- Phone API unavailable or permission not enabled: keep openid login active and route user normally.
|
||||
- Existing phone is bound to another openid: reject binding and ask user to use phone login or contact support.
|
||||
- Existing phone account has data while temporary WeChat account also has data: transfer non-conflicting temporary rows to the phone account before switching tokens.
|
||||
- Temporary WeChat account after successful merge: clear its `third_party_type` and `third_party_id` and disable it from future WeChat login matching.
|
||||
- Network failures: preserve current session restore behavior and avoid clearing tokens unless backend explicitly rejects auth.
|
||||
|
||||
## Data Flow
|
||||
@@ -207,8 +227,9 @@ Use WeChat's current nickname/avatar collection pattern in profile or onboarding
|
||||
4. Backend exchanges code for phone number.
|
||||
5. Backend finds existing user by phone.
|
||||
6. Backend binds openid to the phone user or writes phone to current user.
|
||||
7. Backend returns `AuthResponse`.
|
||||
8. Mini program replaces tokens and refreshes user/profile state.
|
||||
7. If an existing phone user is canonical, backend transfers non-conflicting temporary user data and clears the temporary WeChat identity.
|
||||
8. Backend returns `AuthResponse`.
|
||||
9. Mini program replaces tokens and refreshes user/profile state.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -218,10 +239,13 @@ Backend:
|
||||
- WeChat login returns the same user for a known openid.
|
||||
- Phone binding writes phone to current WeChat user when no existing phone user exists.
|
||||
- Phone binding binds openid to an existing phone user and returns that user's tokens.
|
||||
- Phone binding transfers temporary WeChat user data to the existing phone user before returning phone-user tokens.
|
||||
- Temporary WeChat user no longer matches future openid login after a successful transfer.
|
||||
- Phone binding rejects a phone user already bound to a different openid.
|
||||
- Existing phone SMS login still works.
|
||||
- Token validation and refresh still work after WeChat login.
|
||||
- `/api/auth/wechat/login` is public, and `/api/auth/wechat/phone` rejects unauthenticated requests.
|
||||
- `UserInfoResponse` returns `wechatBound` without returning openid.
|
||||
|
||||
Mini program:
|
||||
|
||||
@@ -232,6 +256,7 @@ Mini program:
|
||||
- Denying phone authorization does not break logged-in state.
|
||||
- Successful phone authorization refreshes tokens and profile.
|
||||
- Personal center reflects nickname/avatar/phone state where available.
|
||||
- Restored sessions fetch current user info so phone binding visibility is correct after app restart.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
Reference in New Issue
Block a user