# 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 | `server/src/main/java/com/emotion/config/WechatMiniProgramProperties.java` | Binds WeChat mini program appid, secret, and API base URL from application config. |
| Create | `server/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql` | Prevents duplicate non-null WeChat third-party identities. |
| Modify | `server/src/main/resources/application.yml` | Adds default WeChat config keys and public login endpoint allowlist. |
| Modify | `server/src/main/java/com/emotion/config/WebMvcConfig.java` | Excludes `/auth/wechat/login` from JWT interception. |
| Modify | `server/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java` | Excludes `/api/auth/wechat/login` from the interceptor's internal public endpoint list. |
| Create | `server/src/main/java/com/emotion/dto/request/WechatLoginRequest.java` | Request body for WeChat openid login. |
| Create | `server/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java` | Request body for WeChat phone binding. |
| Create | `server/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` | Internal DTO for WeChat `code2Session` response. |
| Create | `server/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java` | Internal DTO for WeChat access token response. |
| Create | `server/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java` | Internal DTO for WeChat phone number response. |
| Create | `server/src/main/java/com/emotion/service/WechatMiniProgramService.java` | Interface for WeChat API calls. |
| Create | `server/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` | RestTemplate-backed WeChat client with Redis token caching. |
| Create | `server/src/main/java/com/emotion/service/UserIdentityMergeService.java` | Defines temporary WeChat user to phone user data transfer. |
| Create | `server/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java` | Transfers user-owned rows and avoids singleton row conflicts. |
| Modify | `server/src/main/java/com/emotion/dto/response/UserInfoResponse.java` | Adds non-sensitive `wechatBound` flag for frontend display logic. |
| Modify | `server/src/main/java/com/emotion/service/UserService.java` | Adds third-party identity lookup helper. |
| Modify | `server/src/main/java/com/emotion/service/impl/UserServiceImpl.java` | Implements third-party identity lookup. |
| Modify | `server/src/main/java/com/emotion/service/AuthService.java` | Adds WeChat login and phone binding operations. |
| Modify | `server/src/main/java/com/emotion/service/impl/AuthServiceImpl.java` | Implements openid login, phone identity merge/bind, and token issuing reuse. |
| Modify | `server/src/main/java/com/emotion/controller/AuthController.java` | Adds `/auth/wechat/login` and `/auth/wechat/phone`. |
| Test | `server/src/test/java/com/emotion/controller/AuthControllerTest.java` | Verifies controller contract. |
| Test | `server/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: `server/src/main/java/com/emotion/config/WechatMiniProgramProperties.java`
- Create: `server/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql`
- Create: `server/src/main/java/com/emotion/dto/request/WechatLoginRequest.java`
- Create: `server/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java`
- Create: `server/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java`
- Create: `server/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java`
- Create: `server/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java`
- Modify: `server/src/main/java/com/emotion/dto/response/UserInfoResponse.java`
- Modify: `server/src/main/resources/application.yml`
- Test: backend compile

- [ ] **Step 1: Create WeChat configuration properties**

Create `server/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 `server/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 `server/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 `server/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 `server/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 `server/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 `server/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 `server/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 `server/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 server
mvn -q -DskipTests compile
```

Expected: command exits with status `0`.

- [ ] **Step 8: Commit**

```bash
git add server/src/main/java/com/emotion/config/WechatMiniProgramProperties.java
git add server/src/main/resources/db/migration/V20260531__add_unique_wechat_identity.sql
git add server/src/main/java/com/emotion/dto/request/WechatLoginRequest.java
git add server/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java
git add server/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java
git add server/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java
git add server/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java
git add server/src/main/java/com/emotion/dto/response/UserInfoResponse.java
git add server/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: `server/src/main/java/com/emotion/service/WechatMiniProgramService.java`
- Create: `server/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java`
- Test: `server/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java`

- [ ] **Step 1: Create service interface**

Create `server/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 `server/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 `server/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 server
mvn -q -Dtest=WechatMiniProgramServiceImplTest test
```

Expected: command exits with status `0`.

- [ ] **Step 5: Commit**

```bash
git add server/src/main/java/com/emotion/service/WechatMiniProgramService.java
git add server/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java
git add server/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: `server/src/main/java/com/emotion/service/UserIdentityMergeService.java`
- Create: `server/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java`
- Test: `server/src/test/java/com/emotion/service/UserIdentityMergeServiceImplTest.java`

- [ ] **Step 1: Create merge service interface**

Create `server/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 `server/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 `server/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 server
mvn -q -Dtest=UserIdentityMergeServiceImplTest test
```

Expected: command exits with status `0`.

- [ ] **Step 5: Commit**

```bash
git add server/src/main/java/com/emotion/service/UserIdentityMergeService.java
git add server/src/main/java/com/emotion/service/impl/UserIdentityMergeServiceImpl.java
git add server/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: `server/src/main/java/com/emotion/service/UserService.java`
- Modify: `server/src/main/java/com/emotion/service/impl/UserServiceImpl.java`
- Modify: `server/src/main/java/com/emotion/service/AuthService.java`
- Modify: `server/src/main/java/com/emotion/service/impl/AuthServiceImpl.java`
- Modify: `server/src/main/java/com/emotion/dto/response/UserInfoResponse.java`
- Test: `server/src/test/java/com/emotion/service/AuthServiceWechatTest.java`

- [ ] **Step 1: Add third-party lookup to UserService**

In `server/src/main/java/com/emotion/service/UserService.java`, add:

```java
User getByThirdParty(String thirdPartyType, String thirdPartyId);
```

In `server/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 `server/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 server
mvn -q -DskipTests compile
```

Expected: command exits with status `0`.

- [ ] **Step 9: Add service tests for identity rules**

Create `server/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 server
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,UserIdentityMergeServiceImplTest test
```

Expected: command exits with status `0`.

- [ ] **Step 11: Commit**

```bash
git add server/src/main/java/com/emotion/service/UserService.java
git add server/src/main/java/com/emotion/service/impl/UserServiceImpl.java
git add server/src/main/java/com/emotion/service/AuthService.java
git add server/src/main/java/com/emotion/service/impl/AuthServiceImpl.java
git add server/src/main/java/com/emotion/dto/response/UserInfoResponse.java
git add server/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: `server/src/main/java/com/emotion/controller/AuthController.java`
- Modify: `server/src/main/java/com/emotion/config/WebMvcConfig.java`
- Modify: `server/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java`
- Test: `server/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 server
mvn -q -Dtest=AuthControllerTest test
```

Expected: command exits with status `0`.

- [ ] **Step 6: Commit**

```bash
git add server/src/main/java/com/emotion/controller/AuthController.java
git add server/src/main/java/com/emotion/config/WebMvcConfig.java
git add server/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java
git add server/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 server
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest,UserIdentityMergeServiceImplTest test
```

Expected: command exits with status `0`.

- [ ] **Step 2: Compile backend**

```bash
cd server
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 `server/部署说明.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.
