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

40 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 backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java Binds WeChat mini program appid, secret, and API base URL from application config.
Modify backend-single/src/main/resources/application.yml Adds default WeChat config keys and public login endpoint allowlist.
Modify backend-single/src/main/java/com/emotion/config/WebMvcConfig.java Excludes /auth/wechat/login from JWT interception.
Modify backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java Excludes /api/auth/wechat/login from the interceptor's internal public endpoint list.
Create backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java Request body for WeChat openid login.
Create backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java Request body for WeChat phone binding.
Create backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java Internal DTO for WeChat code2Session response.
Create backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java Internal DTO for WeChat access token response.
Create backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java Internal DTO for WeChat phone number response.
Create backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java Interface for WeChat API calls.
Create backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java RestTemplate-backed WeChat client with Redis token caching.
Modify backend-single/src/main/java/com/emotion/service/UserService.java Adds third-party identity lookup helper.
Modify backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java Implements third-party identity lookup.
Modify backend-single/src/main/java/com/emotion/service/AuthService.java Adds WeChat login and phone binding operations.
Modify backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java Implements openid login, phone identity merge/bind, and token issuing reuse.
Modify backend-single/src/main/java/com/emotion/controller/AuthController.java Adds /auth/wechat/login and /auth/wechat/phone.
Test backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java Verifies controller contract.
Test backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java Verifies identity matching and binding behavior.
Modify mini-program/src/services/auth.js Adds wechatLogin and bindWechatPhone.
Modify mini-program/src/stores/app.js Adds store methods and tracks returned auth user info.
Modify mini-program/src/pages/login/index.vue Adds WeChat primary login while keeping phone/SMS form.
Modify mini-program/src/pages/main/MineView.vue Adds phone binding entry for logged-in WeChat users without a phone.

Task 1: Backend WeChat Configuration And DTOs

Files:

  • Create: backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java

  • Create: backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java

  • Create: backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java

  • Create: backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java

  • Create: backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java

  • Create: backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java

  • Modify: backend-single/src/main/resources/application.yml

  • Test: backend compile

  • Step 1: Create WeChat configuration properties

Create backend-single/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 backend-single/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 backend-single/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 backend-single/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 backend-single/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 backend-single/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 backend-single/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: Compile backend

Run:

cd backend-single
mvn -q -DskipTests compile

Expected: command exits with status 0.

  • Step 6: Commit
git add backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java
git add backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java
git add backend-single/src/main/java/com/emotion/dto/request/WechatPhoneBindRequest.java
git add backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java
git add backend-single/src/main/java/com/emotion/dto/wechat/WechatAccessTokenResponse.java
git add backend-single/src/main/java/com/emotion/dto/wechat/WechatPhoneNumberResponse.java
git add backend-single/src/main/resources/application.yml
git commit -m "feat: add wechat mini program auth DTOs and config"

Task 2: Backend WeChat API Client

Files:

  • Create: backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java

  • Create: backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java

  • Test: backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java

  • Step 1: Create service interface

Create backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java:

package com.emotion.service;

import com.emotion.dto.wechat.WechatCodeSessionResponse;
import com.emotion.dto.wechat.WechatPhoneNumberResponse;

public interface WechatMiniProgramService {
    WechatCodeSessionResponse code2Session(String code);

    WechatPhoneNumberResponse.PhoneInfo getPhoneNumber(String phoneCode);
}
  • Step 2: Create RestTemplate implementation

Create backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java:

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 ttl = Math.max(60L, response.getExpiresIn() - ACCESS_TOKEN_CACHE_BUFFER_SECONDS);
        redisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, response.getAccessToken(), ttl, TimeUnit.SECONDS);
        return response.getAccessToken();
    }

    private void validateConfigured() {
        if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) {
            throw new BusinessException("微信小程序登录未配置");
        }
    }
}
  • Step 3: Create focused unit tests for error behavior

Create backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java:

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 backend-single
mvn -q -Dtest=WechatMiniProgramServiceImplTest test

Expected: command exits with status 0.

  • Step 5: Commit
git add backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java
git add backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java
git add backend-single/src/test/java/com/emotion/service/WechatMiniProgramServiceImplTest.java
git commit -m "feat: add wechat mini program API client"

Task 3: Backend User Lookup And Auth Service Methods

Files:

  • Modify: backend-single/src/main/java/com/emotion/service/UserService.java

  • Modify: backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java

  • Modify: backend-single/src/main/java/com/emotion/service/AuthService.java

  • Modify: backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java

  • Test: backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java

  • Step 1: Add third-party lookup to UserService

In backend-single/src/main/java/com/emotion/service/UserService.java, add:

User getByThirdParty(String thirdPartyType, String thirdPartyId);

In backend-single/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 backend-single/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.WechatMiniProgramService;

Add field:

@Autowired
private WechatMiniProgramService wechatMiniProgramService;

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: 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 6: Implement phone binding 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("该手机号已绑定其他微信账号");
    }

    phoneUser.setThirdPartyType(WECHAT_MP_TYPE);
    phoneUser.setThirdPartyId(currentUser.getThirdPartyId());
    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 7: Compile backend
cd backend-single
mvn -q -DskipTests compile

Expected: command exits with status 0.

  • Step 8: Add service tests for identity rules

Create backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java with a Spring test or Mockito-based test that covers:

@Test
void wechatLoginCreatesUserForNewOpenid() {
    // Mock WechatMiniProgramService.code2Session to return openid.
    // Mock UserService.getByThirdParty to return null.
    // Mock UserService.createUser to return a new enabled user.
    // Assert AuthResponse has accessToken and userInfo.id from the new user.
}

@Test
void bindWechatPhoneReturnsExistingPhoneUser() {
    // Current user has thirdPartyType=wechat-mp and thirdPartyId=openid-a.
    // Phone API returns 13800138000.
    // UserService.getByPhone returns a different enabled user without thirdPartyId.
    // Assert returned AuthResponse userInfo.id is the phone user's id.
}

Use the repository's existing test patterns from AuthControllerTest and Mockito. If direct unit construction is blocked by private helpers, use @SpringBootTest and @MockBean for WechatMiniProgramService.

  • Step 9: Run auth tests
cd backend-single
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest test

Expected: command exits with status 0.

  • Step 10: Commit
git add backend-single/src/main/java/com/emotion/service/UserService.java
git add backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java
git add backend-single/src/main/java/com/emotion/service/AuthService.java
git add backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java
git add backend-single/src/test/java/com/emotion/service/AuthServiceWechatTest.java
git commit -m "feat: add wechat auth identity binding logic"

Task 4: Backend Auth Controller And Security Allowlist

Files:

  • Modify: backend-single/src/main/java/com/emotion/controller/AuthController.java

  • Modify: backend-single/src/main/java/com/emotion/config/WebMvcConfig.java

  • Modify: backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java

  • Test: backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java

  • Step 1: Add controller endpoints

In AuthController.java, import:

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 backend-single
mvn -q -Dtest=AuthControllerTest test

Expected: command exits with status 0.

  • Step 6: Commit
git add backend-single/src/main/java/com/emotion/controller/AuthController.java
git add backend-single/src/main/java/com/emotion/config/WebMvcConfig.java
git add backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java
git add backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java
git commit -m "feat: expose wechat auth endpoints"

Task 5: 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: 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/services/auth.js
git add mini-program/src/stores/app.js
git commit -m "feat: add mini program wechat auth service methods"

Task 6: 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 7: 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 || profile.value.phone))
const canBindWechatPhone = computed(() => store.isLoggedIn && !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 8: 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 backend-single
mvn -q -Dtest=AuthControllerTest,AuthServiceWechatTest,WechatMiniProgramServiceImplTest test

Expected: command exits with status 0.

  • Step 2: Compile backend
cd backend-single
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 backend-single/部署说明.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, 3, 4, 5, 6
Keep phone/SMS login Tasks 5 and 6 preserve existing login(phone, smsCode) and UI form
Optional phone authorization Tasks 2, 3, 4, 5, 7
Phone identifies existing account and auto-binds openid Task 3 Step 6
Existing phone user becomes canonical identity Task 3 Step 6 returns tokens for phoneUser
Nickname/avatar saved when provided Task 3 Step 5
Reuse current JWT/AuthResponse/session restore Tasks 3, 4, 5
Login page matches existing style Task 6
Protected phone binding endpoint Task 4 Steps 2-3 and test list
Build and verification Task 8

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.
  • AuthResponse stays the only frontend token response shape.