Files
hotel-biz-h5/docs/superpowers/plans/2026-06-30-image-captcha-sms.md
2026-07-01 12:54:49 +08:00

893 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Image Captcha SMS Protection 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 a required image captcha before sending mobile SMS codes for the H5 verification app, reducing SMS abuse while keeping the existing mobile login flow.
**Architecture:** Reuse the existing `pig-auth` image captcha endpoint `/auth/code/image?randomStr=...`. The H5 login page will fetch and display that image, then send `phone`, `randomStr`, and `imageCode` to a new POST SMS endpoint in `pig-upms`; the backend validates the image captcha from Redis before sending the SMS code. Keep the existing SMS rate limiting and SMS login verification unchanged.
**Tech Stack:** Vue 3 + Vite + Vant + TypeScript frontend; Spring Boot Java backend; Redis via `StringRedisTemplate` / `RedisTemplate`; existing Pig response wrapper `R`.
---
## File Structure
Frontend repository: `/Users/andy/IdeaProjects/hotel-biz-h5`
- Modify `src/api/auth.ts`: add image captcha URL helper and change `sendMobileCode` to POST a structured payload.
- Modify `src/views/login/LoginView.vue`: add image captcha state, input field, refresh behavior, and send-SMS validation.
- Modify `src/api/mock.ts`: accept the new SMS payload shape in mock mode.
- Optional modify `docs/frontend-practices.md`: record that SMS sending requires image captcha.
Backend repository: `/Users/andy/IdeaProjects/one-feel-server`
- Create `pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java`: request body for SMS code sending.
- Modify `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java`: add POST endpoint and validate request body.
- Create `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java`: verify image captcha from Redis and delete it on success.
- Modify `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/PlatformMobileServiceImpl.java`: keep existing send logic; do not move captcha logic here unless controller becomes too large.
- Add focused backend tests under `pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/`.
---
### Task 1: Backend Request Form
**Files:**
- Create: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java`
- [ ] **Step 1: Create the request form**
```java
package com.pig4cloud.pig.admin.api.form;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "发送手机验证码请求")
public class SendMobileCodeForm {
@Schema(description = "手机号")
private String phone;
@Schema(description = "图形验证码随机标识")
private String randomStr;
@Schema(description = "图形验证码")
private String imageCode;
}
```
- [ ] **Step 2: Compile API module**
Run:
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-api -am -DskipTests compile
```
Expected: build succeeds with no compilation errors.
- [ ] **Step 3: Commit**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
git add pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java
git commit -m "新增发送短信验证码请求表单"
```
---
### Task 2: Backend Image Captcha Verifier
**Files:**
- Create: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java`
- Test: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifierTest.java`
- [ ] **Step 1: Write the failing unit test**
```java
package com.pig4cloud.pig.admin.service.impl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ImageCaptchaVerifierTest {
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOperations;
private ImageCaptchaVerifier verifier;
@BeforeEach
void setUp() {
redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
verifier = new ImageCaptchaVerifier(redisTemplate);
}
@Test
void verifyAndDeletePassesAndDeletesKeyWhenCodeMatches() {
when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn("8");
assertDoesNotThrow(() -> verifier.verifyAndDelete("abc123", "8"));
verify(redisTemplate).delete("DEFAULT_CODE_KEY:abc123");
}
@Test
void verifyAndDeleteRejectsBlankValues() {
assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("", "8"));
assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", ""));
}
@Test
void verifyAndDeleteRejectsMissingRedisCode() {
when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn(null);
assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", "8"));
}
@Test
void verifyAndDeleteRejectsWrongCode() {
when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn("8");
assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", "9"));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test
```
Expected: FAIL because `ImageCaptchaVerifier` does not exist.
- [ ] **Step 3: Implement the verifier**
```java
package com.pig4cloud.pig.admin.service.impl;
import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pig.common.core.constant.CacheConstants;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ImageCaptchaVerifier {
private final StringRedisTemplate redisTemplate;
public void verifyAndDelete(String randomStr, String imageCode) {
if (StrUtil.hasBlank(randomStr, imageCode)) {
throw new IllegalArgumentException("图形验证码不能为空");
}
String key = CacheConstants.DEFAULT_CODE_KEY + randomStr;
String savedCode = redisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(savedCode)) {
throw new IllegalArgumentException("图形验证码已过期,请刷新后重试");
}
if (!StrUtil.equalsIgnoreCase(savedCode, imageCode.trim())) {
throw new IllegalArgumentException("图形验证码不正确");
}
redisTemplate.delete(key);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
git add pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java \
pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifierTest.java
git commit -m "增加图形验证码校验器"
```
---
### Task 3: Backend POST SMS Endpoint
**Files:**
- Modify: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java`
- Uses: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java`
- Uses: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java`
- [ ] **Step 1: Add imports**
Add these imports if absent:
```java
import cn.hutool.core.lang.Validator;
import com.pig4cloud.pig.admin.api.form.SendMobileCodeForm;
import com.pig4cloud.pig.admin.service.impl.ImageCaptchaVerifier;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
```
- [ ] **Step 2: Inject the verifier**
Find the controller fields and constructor style. Add a field:
```java
private final ImageCaptchaVerifier imageCaptchaVerifier;
```
If `PlatformUserController` uses Lombok `@RequiredArgsConstructor`, this field is enough. If it has an explicit constructor, add `ImageCaptchaVerifier imageCaptchaVerifier` to that constructor and assign it.
- [ ] **Step 3: Add the new POST endpoint next to the existing GET endpoint**
```java
@PostMapping("/sendMobileCode")
public R<Boolean> sendMobileCode(@RequestBody SendMobileCodeForm form,
@RequestHeader(value = SecurityConstants.CLIENT_CONFIG_ID_PARAMETER_NAME, required = false) String clientConfigId) {
assertValidSmsClientConfig(form.getPhone(), clientConfigId);
assertValidImageCode(form);
imageCaptchaVerifier.verifyAndDelete(form.getRandomStr(), form.getImageCode());
return platformMobileService.sendSmsCode(form.getPhone(), clientConfigId);
}
```
- [ ] **Step 4: Add strict validation helpers**
Add these private methods near `assertValidSmsClientConfig`:
```java
private void assertValidImageCode(SendMobileCodeForm form) {
if (form == null || StrUtil.hasBlank(form.getRandomStr(), form.getImageCode())) {
throw new CheckedException("图形验证码不能为空");
}
}
private void assertValidSmsClientConfig(String phone, String clientConfigId) {
if (StrUtil.hasBlank(phone, clientConfigId)) {
throw new CheckedException("手机号或clientConfigId不能为空");
}
if (!Validator.isMobile(phone)) {
throw new CheckedException("手机号格式不正确");
}
ClientConfigEntity clientConfig = clientConfigService.getById(clientConfigId);
if (Objects.isNull(clientConfig) || StrUtil.isBlank(clientConfig.getClientTenantId())) {
log.error("短信验证码发送失败客户端配置不存在或未配置租户clientConfigId={}", clientConfigId);
throw new CheckedException("验证码发送失败");
}
try {
Long.parseLong(clientConfig.getClientTenantId());
}
catch (NumberFormatException ex) {
log.error("短信验证码发送失败客户端租户配置非法clientConfigId={}, clientTenantId={}", clientConfigId,
clientConfig.getClientTenantId());
throw new CheckedException("验证码发送失败");
}
}
```
Important: replace the existing `assertValidSmsClientConfig` body instead of creating a duplicate method.
- [ ] **Step 5: Keep the old GET endpoint temporarily**
Do not delete this method in the first rollout:
```java
@GetMapping("/sendMobileCode/{phone}")
public R<Boolean> sendMobileCode(@PathVariable String phone,
@RequestHeader(value = SecurityConstants.CLIENT_CONFIG_ID_PARAMETER_NAME, required = false) String clientConfigId) {
assertValidSmsClientConfig(phone, clientConfigId);
return platformMobileService.sendSmsCode(phone, clientConfigId);
}
```
Reason: keeping it avoids breaking other clients immediately. After H5 and other clients migrate, remove the GET endpoint in a separate cleanup.
- [ ] **Step 6: Compile backend**
Run:
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-biz -am -DskipTests compile
```
Expected: build succeeds with no compilation errors.
- [ ] **Step 7: Commit**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
git add pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java
git commit -m "短信验证码发送增加图形验证码校验"
```
---
### Task 4: Frontend API Contract
**Files:**
- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/api/auth.ts`
- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/api/mock.ts`
- [ ] **Step 1: Update `src/api/auth.ts` types and helpers**
Replace the existing `sendMobileCode` implementation with:
```ts
export interface SendMobileCodePayload {
phone: string
randomStr: string
imageCode: string
}
export const getImageCodeUrl = (randomStr: string) =>
joinUrl(env.authBase, 'code/image') + `?randomStr=${encodeURIComponent(randomStr)}&t=${Date.now()}`
export const sendMobileCode = async (payload: SendMobileCodePayload) => {
if (env.useMock) return mockApi.sendMobileCode(payload)
return await http.post<boolean>(joinUrl(env.adminBase, 'platformUser/sendMobileCode'), payload, {
headers: {
clientId: env.clientId,
clientConfigId: env.clientConfigId
}
}) as unknown as boolean
}
```
- [ ] **Step 2: Update `src/api/mock.ts`**
Change the mock method signature:
```ts
async sendMobileCode(payload: { phone: string; randomStr?: string; imageCode?: string }) {
await wait()
if (!payload.phone) return { data: false, msg: '请输入手机号' }
if (!payload.imageCode) return { data: false, msg: '请输入图形验证码' }
return { data: true, msg: '5678nianxx' }
}
```
- [ ] **Step 3: Run frontend typecheck to verify expected failures**
Run:
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
yarn typecheck
```
Expected: FAIL because `LoginView.vue` still calls `sendMobileCode(phone.value)`.
- [ ] **Step 4: Commit only if the expected failure is documented**
Do not commit a failing frontend state. Continue to Task 5 before committing frontend changes.
---
### Task 5: Frontend Login Page UI
**Files:**
- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/views/login/LoginView.vue`
- [ ] **Step 1: Update imports**
Change:
```ts
import { computed, ref } from 'vue'
import { sendMobileCode } from '@/api/auth'
```
to:
```ts
import { computed, onMounted, ref } from 'vue'
import { getImageCodeUrl, sendMobileCode } from '@/api/auth'
```
- [ ] **Step 2: Add captcha state**
Add below `const code = ref('')`:
```ts
const imageCode = ref('')
const captchaRandomStr = ref('')
const captchaUrl = ref('')
```
- [ ] **Step 3: Add captcha refresh helpers**
Add below `let timer: number | undefined`:
```ts
const createRandomStr = () => `${Date.now()}${Math.random().toString(36).slice(2, 10)}`
const refreshCaptcha = () => {
captchaRandomStr.value = createRandomStr()
captchaUrl.value = getImageCodeUrl(captchaRandomStr.value)
imageCode.value = ''
}
```
- [ ] **Step 4: Update send eligibility**
Replace:
```ts
const canSend = computed(() => /^1\d{10}$/.test(phone.value) && seconds.value === 0)
```
with:
```ts
const canSend = computed(() => /^1\d{10}$/.test(phone.value) && imageCode.value.trim().length > 0 && seconds.value === 0)
```
- [ ] **Step 5: Update `handleSend` validation and payload**
Replace the beginning of `handleSend` with:
```ts
const handleSend = async () => {
if (!/^1\d{10}$/.test(phone.value)) {
showToast('请输入正确手机号')
return
}
if (!imageCode.value.trim()) {
showToast('请输入图形验证码')
return
}
sending.value = true
try {
const result = await sendMobileCode({
phone: phone.value,
randomStr: captchaRandomStr.value,
imageCode: imageCode.value.trim()
})
const message = typeof result === 'object' && result && 'msg' in result ? result.msg : ''
if (message) {
code.value = String(message)
showToast(`验证码:${message}`)
} else {
showToast('验证码已发送')
}
startCountdown()
} catch (error) {
refreshCaptcha()
showToast(error instanceof Error ? error.message : '验证码发送失败')
} finally {
sending.value = false
}
}
```
- [ ] **Step 6: Initialize captcha on mount**
Add below `handleLogin`:
```ts
onMounted(refreshCaptcha)
```
- [ ] **Step 7: Add captcha field to template**
Insert this `van-field` between phone and SMS code fields:
```vue
<van-field
v-model="imageCode"
type="text"
name="imageCode"
maxlength="8"
placeholder="请输入图形验证码"
autocomplete="off"
clearable
>
<template #left-icon><ShieldCheck :size="18" /></template>
<template #button>
<button class="captcha-image-button" type="button" @click="refreshCaptcha">
<img v-if="captchaUrl" :src="captchaUrl" alt="图形验证码" />
<span v-else>刷新</span>
</button>
</template>
</van-field>
```
- [ ] **Step 8: Add scoped styles**
Add before the closing `</style>`:
```css
.captcha-image-button {
display: inline-flex;
width: 100px;
height: 40px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 0;
border-radius: var(--radius);
background: var(--surface-soft);
color: var(--primary-deep);
padding: 0;
}
.captcha-image-button img {
display: block;
width: 100px;
height: 40px;
object-fit: cover;
}
```
- [ ] **Step 9: Run frontend checks**
Run:
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
yarn typecheck
yarn build:test
```
Expected: both pass. `vite build` may still show the existing router dynamic/static import warning; that warning is unrelated.
- [ ] **Step 10: Commit frontend changes**
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
git add src/api/auth.ts src/api/mock.ts src/views/login/LoginView.vue
git commit -m "登录短信发送增加图形验证码"
```
---
### Task 6: End-to-End Manual Verification
**Files:**
- No code changes expected.
- [ ] **Step 1: Start backend services needed for auth, upms, gateway, and Redis**
Use the existing local backend startup process for `/Users/andy/IdeaProjects/one-feel-server`. Confirm these routes are reachable through the gateway:
```text
GET /auth/code/image?randomStr=manual-test
POST /admin/platformUser/sendMobileCode
POST /auth/oauth2/token
```
- [ ] **Step 2: Start frontend**
Run:
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
yarn dev
```
Expected: Vite starts and prints a local URL.
- [ ] **Step 3: Verify captcha image loads**
Open `/login`.
Expected:
- Login title shows `核销端`.
- Phone input is visible.
- Image captcha input is visible.
- Captcha image loads from `/auth/code/image`.
- Clicking the image refreshes the URL and clears the image code input.
- [ ] **Step 4: Verify empty captcha is blocked by frontend**
Enter a valid phone number and leave image captcha empty. Click `获取验证码`.
Expected:
- No SMS request is sent.
- Toast says `请输入图形验证码`.
- [ ] **Step 5: Verify wrong captcha is rejected by backend**
Enter a valid phone number and a wrong image captcha. Click `获取验证码`.
Expected:
- `POST /admin/platformUser/sendMobileCode` returns a failure response.
- Toast shows backend error.
- Captcha image refreshes.
- [ ] **Step 6: Verify correct captcha sends SMS**
Enter a valid phone number and the correct image captcha. Click `获取验证码`.
Expected:
- SMS send endpoint returns success.
- Countdown starts.
- Existing SMS frequency limit still applies if clicking repeatedly.
- [ ] **Step 7: Verify login still works**
Enter the SMS code and submit login.
Expected:
- Login calls `/auth/oauth2/token`.
- After token success, frontend calls `/hotelStaff/organizationMember/bindUserInfoAndGetUserMemberInfoByPhone`.
- Bound users enter `/home`.
- Unbound users see `未绑定组织,请联系管理员`.
---
### Task 7: Documentation Update
**Files:**
- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/docs/frontend-practices.md`
- [ ] **Step 1: Add SMS captcha note**
Add this bullet under `## 登录与组织关系`:
```markdown
- 发送短信验证码前必须先通过图形验证码校验;前端展示 `/auth/code/image` 生成的验证码图片,后端在 `/admin/platformUser/sendMobileCode` 校验 `randomStr``imageCode` 后才发送短信。
```
- [ ] **Step 2: Run docs diff check**
Run:
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
git diff --check docs/frontend-practices.md
```
Expected: no output.
- [ ] **Step 3: Commit docs**
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
git add docs/frontend-practices.md
git commit -m "补充短信图形验证码约定"
```
---
## Rollout Notes
- Keep the old GET endpoint during the first deployment to avoid breaking unknown clients.
- H5 should switch to the new POST endpoint immediately after backend deployment.
- After production confirms no clients call `GET /admin/platformUser/sendMobileCode/{phone}`, remove the GET endpoint in a later cleanup.
- Do not remove existing Redis SMS frequency limits; image captcha is an additional layer, not a replacement.
- Do not rely on frontend captcha checks for security; backend validation is required.
## Execution Checkpoints
### Checkpoint 0: Baseline Safety
**Purpose:** Confirm both repositories are in a known state before touching code.
**Scope:** No code changes.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
git status --short --branch
yarn typecheck
cd /Users/andy/IdeaProjects/one-feel-server
git status --short --branch
mvn -pl pig-upms/pig-upms-api -am -DskipTests compile
```
**Pass criteria:**
- Current branch and dirty files are understood.
- Existing frontend typecheck passes.
- Backend API module compiles before changes.
**Stop condition:** If baseline build fails for unrelated reasons, record the failure and do not start feature work until the owner decides whether to fix baseline first.
### Checkpoint 1: Backend Contract Ready
**Purpose:** Add the new SMS request body without changing runtime behavior.
**Scope:** Task 1 only.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-api -am -DskipTests compile
git show --stat --oneline HEAD
```
**Pass criteria:**
- `SendMobileCodeForm` exists with `phone`, `randomStr`, and `imageCode`.
- API module compiles.
- Commit contains only the request form.
**Rollback point:** Revert the request-form commit only.
### Checkpoint 2: Captcha Verifier Proven
**Purpose:** Prove Redis image captcha validation works before wiring it into the SMS endpoint.
**Scope:** Task 2 only.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test
git show --stat --oneline HEAD
```
**Pass criteria:**
- Matching captcha passes and deletes `DEFAULT_CODE_KEY:{randomStr}`.
- Blank, missing, and wrong captcha values fail.
- Unit test passes.
- Commit contains only verifier and verifier test.
**Stop condition:** If the project lacks test dependencies for this isolated unit test, stop and decide whether to add test dependencies or verify with an integration test instead.
**Rollback point:** Revert the verifier commit only.
### Checkpoint 3: Backend Endpoint Protected
**Purpose:** Make SMS sending require backend image captcha validation while preserving the old GET endpoint for compatibility.
**Scope:** Task 3 only.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/one-feel-server
mvn -pl pig-upms/pig-upms-biz -am -DskipTests compile
git diff --check
git show --stat --oneline HEAD
```
**Pass criteria:**
- New `POST /admin/platformUser/sendMobileCode` accepts `SendMobileCodeForm`.
- Backend validates phone format, `clientConfigId`, `randomStr`, and `imageCode`.
- Captcha verification runs before `platformMobileService.sendSmsCode`.
- Existing `GET /sendMobileCode/{phone}` is still present.
- Existing SMS rate limits are untouched.
**Manual smoke check after backend starts:**
```text
POST /admin/platformUser/sendMobileCode
body: {"phone":"13800000000","randomStr":"wrong","imageCode":"0000"}
Expected: failure response, no SMS sent.
```
**Rollback point:** Revert the backend endpoint commit. Old GET endpoint remains available.
### Checkpoint 4: Frontend Contract Switched
**Purpose:** Move H5 from URL-phone GET sending to structured POST sending.
**Scope:** Task 4 and Task 5.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
yarn typecheck
yarn build:test
git diff --check src/api/auth.ts src/api/mock.ts src/views/login/LoginView.vue
git show --stat --oneline HEAD
```
**Pass criteria:**
- Login page displays phone input, image captcha input, captcha image, SMS code input, and login button.
- `sendMobileCode` sends `phone`, `randomStr`, and `imageCode` to `POST /admin/platformUser/sendMobileCode`.
- Empty image captcha is blocked in the frontend with `请输入图形验证码`.
- Failed SMS send shows a toast and refreshes captcha.
- Mock mode still works.
**Manual smoke check with frontend only:**
```text
1. Start H5 in mock mode.
2. Open /login.
3. Try sending with empty image captcha.
4. Expected: toast says 请输入图形验证码 and no countdown starts.
5. Enter any image captcha in mock mode and send.
6. Expected: countdown starts and mock SMS code appears.
```
**Rollback point:** Revert the frontend commit. Backend POST endpoint can remain deployed without affecting old clients.
### Checkpoint 5: End-to-End Gate
**Purpose:** Verify the complete browser -> gateway -> auth/upms -> Redis flow before release.
**Scope:** Task 6.
**Verification steps:**
```text
1. Start Redis, gateway, pig-auth, pig-upms, and H5.
2. Open /login.
3. Confirm captcha image loads from /auth/code/image.
4. Enter wrong image captcha and click 获取验证码.
5. Expected: SMS endpoint returns failure and no countdown starts.
6. Refresh captcha, enter correct image captcha, and click 获取验证码.
7. Expected: SMS send succeeds and countdown starts.
8. Enter SMS code and login.
9. Expected: token endpoint succeeds, organization binding endpoint runs, bound user enters /home.
```
**Pass criteria:**
- Wrong captcha cannot send SMS.
- Correct captcha can send SMS.
- Captcha cannot be reused after one successful SMS send.
- Existing SMS frequency limit still blocks repeated sends.
- Login flow after receiving SMS code is unchanged.
**Stop condition:** If correct captcha still fails, inspect Redis key serialization between `ImageCodeEndpoint` and `ImageCaptchaVerifier` before changing frontend code.
### Checkpoint 6: Documentation And Release Readiness
**Purpose:** Lock the new behavior into project docs and prepare release notes.
**Scope:** Task 7 and release review.
**Verification commands:**
```bash
cd /Users/andy/IdeaProjects/hotel-biz-h5
git diff --check docs/frontend-practices.md
cd /Users/andy/IdeaProjects/one-feel-server
git log --oneline -3
cd /Users/andy/IdeaProjects/hotel-biz-h5
git log --oneline -3
```
**Pass criteria:**
- Docs say SMS sending requires image captcha.
- Backend commits and frontend commits are clearly separated.
- Release notes mention that old GET SMS endpoint is temporarily retained.
**Release decision:** Deploy backend first, then deploy H5. Do not deploy H5 before the backend POST endpoint is available.
## Self-Review
- Spec coverage: The plan covers existing image captcha reuse, backend POST endpoint, Redis verification, frontend login UI, API contract, tests, manual verification, and documentation.
- Placeholder scan: No `TBD`, `TODO`, or vague "handle edge cases" steps remain; each task includes concrete file paths and code.
- Type consistency: Frontend `SendMobileCodePayload` fields match backend `SendMobileCodeForm` fields: `phone`, `randomStr`, `imageCode`.