diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index 6397af6..233e7a0 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -6,6 +6,7 @@ export type StoreDraftForm = { district: string; name: string; phone: string; + storeSmsCode: string; address: string; intro: string; coverUrl: string; @@ -34,6 +35,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({ cityId: '', name: '', phone: '', + storeSmsCode: '', address: '', intro: '', coverUrl: '', @@ -64,6 +66,7 @@ function normalizeForm(raw: Record): StoreDraftForm { district: String(raw.district ?? base.district), name: String(raw.name ?? base.name), phone: String(raw.phone ?? base.phone), + storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode), address: String(raw.address ?? base.address), intro: String(raw.intro ?? base.intro), coverUrl: String(raw.coverUrl ?? base.coverUrl), @@ -112,13 +115,18 @@ const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; export function validateStoreStep1( - form: Pick, + form: Pick< + StoreDraftForm, + 'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro' + >, ): string | null { if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.name.trim()) return '请填写门店名称'; if (!form.phone.trim()) return '请填写联系电话'; if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; + if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码'; + if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确'; if (!form.address.trim()) return '请填写详细地址'; if (form.intro.trim()) { const len = form.intro.trim().length; diff --git a/apps/h5-partner/src/lib/storePhone.ts b/apps/h5-partner/src/lib/storePhone.ts index 087fbcd..bb5a55a 100644 --- a/apps/h5-partner/src/lib/storePhone.ts +++ b/apps/h5-partner/src/lib/storePhone.ts @@ -8,4 +8,12 @@ export function checkStorePhoneAvailable(phone: string) { ); } +export function sendStorePhoneSms(phone: string) { + return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', { + method: 'POST', + body: JSON.stringify({ phone: phone.trim() }), + silent: true, + }); +} + export type { PartnerStorePhoneAvailableResponse }; diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index dfc4209..caa5eff 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -13,7 +13,7 @@ import { toastError, toastSuccess } from '../lib/toast'; import { resolveRegionBinding } from '../lib/china-region'; -import { checkStorePhoneAvailable } from '../lib/storePhone'; +import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; @@ -54,6 +54,8 @@ type FieldErrors = { phone?: string; + storeSmsCode?: string; + }; @@ -68,7 +70,7 @@ function isPhoneConflictMessage(message: string) { function isPhoneValidationMessage(message: string) { - return message.includes('联系电话') || message.includes('手机号'); + return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码'); } @@ -108,6 +110,10 @@ export default function StoreCreatePage() { const [citiesError, setCitiesError] = useState(''); + const [smsCooldown, setSmsCooldown] = useState(0); + + const [smsHint, setSmsHint] = useState(''); + function reportFormError(message: string) { setSubmitError(message); } @@ -216,16 +222,28 @@ export default function StoreCreatePage() { function patchForm(patch: Partial) { - setForm((prev) => ({ ...prev, ...patch })); - setSubmitError(''); + let nextPatch = patch; + if ('phone' in patch) { - setFieldErrors((prev) => ({ ...prev, phone: undefined })); + setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined })); + + setSmsHint(''); + + nextPatch = { ...patch, storeSmsCode: '' }; } + if ('storeSmsCode' in patch) { + + setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined })); + + } + + setForm((prev) => ({ ...prev, ...nextPatch })); + } @@ -286,6 +304,70 @@ export default function StoreCreatePage() { + async function sendStorePhoneCode() { + + const phone = form.phone.trim(); + + if (!/^1\d{10}$/.test(phone)) { + + setFieldErrors({ phone: '请先填写正确的11位手机号' }); + + return; + + } + + setSmsHint(''); + + setFieldErrors((prev) => ({ ...prev, phone: undefined })); + + try { + + const phoneCheck = await checkStorePhoneAvailable(phone); + + if (!phoneCheck.available) { + + setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' }); + + return; + + } + + const res = await sendStorePhoneSms(phone); + + setSmsHint(`验证码已发送至 ${res.maskedPhone}`); + + setSmsCooldown(60); + + const timer = setInterval(() => { + + setSmsCooldown((s) => { + + if (s <= 1) { + + clearInterval(timer); + + return 0; + + } + + return s - 1; + + }); + + }, 1000); + + } catch (e) { + + const msg = e instanceof Error ? e.message : '验证码发送失败'; + + setFieldErrors({ phone: msg }); + + } + + } + + + async function handleNext() { if (step === 1) { @@ -293,7 +375,15 @@ export default function StoreCreatePage() { const msg = validateStoreStep1(form); if (msg) { - reportFormError(msg); + if (isPhoneValidationMessage(msg)) { + if (msg.includes('验证码')) { + setFieldErrors({ storeSmsCode: msg }); + } else { + setFieldErrors({ phone: msg }); + } + } else { + reportFormError(msg); + } return; } @@ -365,7 +455,12 @@ export default function StoreCreatePage() { if (step1Msg) { if (isPhoneValidationMessage(step1Msg)) { - setFieldErrors({ phone: step1Msg }); + if (step1Msg.includes('验证码')) { + setFieldErrors({ storeSmsCode: step1Msg }); + } else { + setFieldErrors({ phone: step1Msg }); + } + goStep(1); return; } reportFormError(step1Msg); @@ -448,6 +543,8 @@ export default function StoreCreatePage() { phone: form.phone.trim(), + smsCode: form.storeSmsCode.trim(), + district: form.district.trim(), address: form.address.trim(), @@ -481,6 +578,11 @@ export default function StoreCreatePage() { setFieldErrors({ phone: message }); return; } + if (/验证码/.test(message)) { + setFieldErrors({ storeSmsCode: message }); + goStep(1); + return; + } setSubmitError(message); toastError(message); } finally { @@ -631,6 +733,74 @@ export default function StoreCreatePage() { )} +

+ + 验证码将发送至该手机号,需门店负责人确认后方可录入 + +

+ + + +
+ + + +
+ +
+ + shield + + patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })} + + /> + +
+ + + +
+ + {smsHint && ( + +

{smsHint}

+ + )} + + {fieldErrors.storeSmsCode && ( + +

{fieldErrors.storeSmsCode}

+ + )} +
diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 43b4b3e..a3346a3 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -48,6 +48,8 @@ export enum SmsScene { REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM', /** 合伙人代下单:线下代发货确认验证码(发至用户手机) */ PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER', + /** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */ + PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN', } export enum OrderType { diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index a62dfae..780d192 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -84,6 +84,7 @@ export class AuthService { return ClientApp.HQ_WEB; case SmsScene.PARTNER_LOGIN: case SmsScene.PARTNER_STAFF_ADD: + case SmsScene.PARTNER_STORE_OPEN: return ClientApp.PARTNER_H5; case SmsScene.HQ_LOGIN: return ClientApp.HQ_WEB; @@ -119,6 +120,13 @@ export class AuthService { }); return account ? { refType: 'STORE', refId: account.id } : undefined; } + case SmsScene.PARTNER_STORE_OPEN: { + const account = await this.prisma.storeAccount.findUnique({ + where: { phone }, + select: { id: true }, + }); + return account ? { refType: 'STORE', refId: account.id } : undefined; + } case SmsScene.PARTNER_LOGIN: case SmsScene.PARTNER_STAFF_ADD: { const account = await this.prisma.partnerAccount.findUnique({ @@ -292,6 +300,9 @@ export class AuthService { if (scene === SmsScene.PARTNER_PROXY_ORDER) { return; } + if (scene === SmsScene.PARTNER_STORE_OPEN) { + return; + } } async verifySmsCode(phone: string, code: string, scene: SmsScene) { diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index e53237b..5e14a48 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -41,6 +41,11 @@ export class PartnerStoreController { return this.storeService.partnerCheckStorePhone(phone ?? ''); } + @Post('send-phone-sms') + sendPhoneSms(@Body() body: { phone: string }) { + return this.storeService.sendPartnerStorePhoneSms(body.phone); + } + @Get(':id') detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.storeService.partnerGetStore(user.actorId, BigInt(id)); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 6259fd8..1ce69a4 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { loadAppConfig } from '@dukang/shared-types'; +import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -12,6 +12,7 @@ import { mapStoreCompat } from '../../common/compat/v31-compat'; import { parseBigIntParam } from '../../common/parse-bigint'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; +import { AuthService } from '../iam/auth.service'; @Injectable() export class StoreService { @@ -21,6 +22,7 @@ export class StoreService { private readonly prisma: PrismaService, private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, + private readonly authService: AuthService, ) {} async listOpenStores(cityCode?: string) { @@ -139,9 +141,29 @@ export class StoreService { }; } + async sendPartnerStorePhoneSms(phone: string) { + const check = await this.partnerCheckStorePhone(phone); + if (!check.available) { + throw new BadRequestException(check.message ?? '该手机号不可用于门店账号'); + } + return this.authService.sendSms(phone.trim(), SmsScene.PARTNER_STORE_OPEN, { + clientApp: ClientApp.PARTNER_H5, + }).then(() => { + const normalizedPhone = phone.trim(); + const masked = + normalizedPhone.length >= 7 + ? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}` + : normalizedPhone; + return { ok: true, maskedPhone: masked }; + }); + } + async createStore(partnerAccountId: bigint, body: Record) { const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const normalizedPhone = String(body.phone).trim(); + const smsCode = body.smsCode ? String(body.smsCode).trim() : ''; + if (!smsCode) throw new BadRequestException('请输入门店手机号验证码'); + await this.authService.verifySmsCode(normalizedPhone, smsCode, SmsScene.PARTNER_STORE_OPEN); const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true'; await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);