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

53 KiB
Raw Blame History

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:

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:

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:

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:

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:

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:

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:

  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:

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:

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:

/**
 * Whether the account has a WeChat mini program identity bound.
 */
private Boolean wechatBound;
  • Step 7: Compile backend

Run:

cd server
mvn -q -DskipTests compile

Expected: command exits with status 0.

  • Step 8: Commit
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:

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:

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:

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
cd server
mvn -q -Dtest=WechatMiniProgramServiceImplTest test

Expected: command exits with status 0.

  • Step 5: Commit
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:

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:

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:

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
cd server
mvn -q -Dtest=UserIdentityMergeServiceImplTest test

Expected: command exits with status 0.

  • Step 5: Commit
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:

User getByThirdParty(String thirdPartyType, String thirdPartyId);

In server/src/main/java/com/emotion/service/impl/UserServiceImpl.java, add:

@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:

AuthResponse wechatLogin(WechatLoginRequest request);

AuthResponse bindWechatPhone(String currentUserId, WechatPhoneBindRequest request);
  • Step 3: Add AuthServiceImpl constants and dependency

In AuthServiceImpl, add imports:

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:

@Autowired
private WechatMiniProgramService wechatMiniProgramService;

@Autowired
private UserIdentityMergeService userIdentityMergeService;

Add constant:

private static final String WECHAT_MP_TYPE = "wechat-mp";
  • Step 4: Add shared token response helper

In AuthServiceImpl, add this private helper near generateRefreshToken:

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:

response.setWechatBound(WECHAT_MP_TYPE.equals(user.getThirdPartyType())
        && StringUtils.hasText(user.getThirdPartyId()));
  • Step 6: Implement WeChat login

In AuthServiceImpl, add:

@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:

@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
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:

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

Expected: command exits with status 0.

  • Step 11: Commit
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:

import com.emotion.dto.request.WechatLoginRequest;
import com.emotion.dto.request.WechatPhoneBindRequest;

Add methods:

@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:

"/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:

"/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:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

Add tests:

@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
cd server
mvn -q -Dtest=AuthControllerTest test

Expected: command exits with status 0.

  • Step 6: Commit
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:

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:

persistAuthTokens(response)
  • Step 2: Add WeChat service methods

In mini-program/src/services/auth.js, add:

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:

wechatLogin,
bindWechatPhone,
  • Step 3: Store returned userInfo

In mini-program/src/stores/app.js, add userInfo updates in existing login after authService.login:

state.userInfo = res.data?.userInfo || null

Add store methods:

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:

loginWithWechat,
bindWechatPhone,
  • Step 4: Add current user info refresh for restored sessions

In mini-program/src/stores/app.js, add:

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():

state.isLoggedIn = true
await fetchCurrentUserInfo()
await fetchUserProfile({ throwOnError: true })

Also call it after successful existing phone login:

state.userInfo = res.data?.userInfo || null
await fetchCurrentUserInfo()

Add it to the returned store object:

fetchCurrentUserInfo,
  • Step 5: Build mini program
cd mini-program
npm run build:mp-weixin

Expected: command exits with status 0.

  • Step 6: Commit
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:

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:

routeAfterLogin(result.hasProfile)
  • Step 2: Add primary WeChat button above phone form

In the .login-card template, above <view class="form">, add:

<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:

<text v-if="loading && activeMethod === 'phone'">登录中...</text>
<text v-else>开启旅程</text>

At the start of handleLogin, set:

activeMethod.value = 'phone'

And in the finally, clear:

activeMethod.value = ''
  • Step 4: Add styles matching current glass theme

Add styles:

.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
cd mini-program
npm run build:mp-weixin

Expected: command exits with status 0.

  • Step 6: Commit
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:

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:

<!-- #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:

.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
cd mini-program
npm run build:mp-weixin

Expected: command exits with status 0.

  • Step 5: Commit
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

cd server
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest,UserIdentityMergeServiceImplTest test

Expected: command exits with status 0.

  • Step 2: Compile backend
cd server
mvn -q -DskipTests compile

Expected: command exits with status 0.

  • Step 3: Build mini program
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:

### 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.