开合伙人子账号要验证手机号
This commit is contained in:
@@ -10,12 +10,21 @@ export function listPartnerStaff() {
|
|||||||
return request<PartnerStaffItem[]>('PARTNER_H5', '/partner/staff');
|
return request<PartnerStaffItem[]>('PARTNER_H5', '/partner/staff');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sendPartnerStaffPhoneSms(phone: string) {
|
||||||
|
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/staff/send-phone-sms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: phone.trim() }),
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||||
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: body.name,
|
name: body.name,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
|
smsCode: body.smsCode,
|
||||||
staffRole: PartnerStaffRole.INTERNAL,
|
staffRole: PartnerStaffRole.INTERNAL,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { createPartnerStaff } from '../lib/staff';
|
import { createPartnerStaff, sendPartnerStaffPhoneSms } from '../lib/staff';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
name?: string;
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
smsCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StaffCreatePage() {
|
export default function StaffCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [smsCode, setSmsCode] = useState('');
|
||||||
|
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||||
|
const [smsHint, setSmsHint] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
@@ -25,13 +29,51 @@ export default function StaffCreatePage() {
|
|||||||
|
|
||||||
function patchPhone(value: string) {
|
function patchPhone(value: string) {
|
||||||
setPhone(value);
|
setPhone(value);
|
||||||
|
setSmsHint('');
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
setFieldErrors((prev) => ({ ...prev, phone: undefined, smsCode: undefined }));
|
||||||
|
if (smsCode) setSmsCode('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSmsCode(value: string) {
|
||||||
|
setSmsCode(value.replace(/\D/g, ''));
|
||||||
|
setSubmitError('');
|
||||||
|
setFieldErrors((prev) => ({ ...prev, smsCode: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPhoneCode() {
|
||||||
|
const trimmedPhone = phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||||
|
setFieldErrors({ phone: '请先填写正确的11位手机号' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSmsHint('');
|
||||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await sendPartnerStaffPhoneSms(trimmedPhone);
|
||||||
|
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 submit() {
|
async function submit() {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
const trimmedPhone = phone.trim();
|
const trimmedPhone = phone.trim();
|
||||||
|
const trimmedSmsCode = smsCode.trim();
|
||||||
const nextFieldErrors: FieldErrors = {};
|
const nextFieldErrors: FieldErrors = {};
|
||||||
|
|
||||||
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
|
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
|
||||||
@@ -40,6 +82,11 @@ export default function StaffCreatePage() {
|
|||||||
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||||
nextFieldErrors.phone = '请输入正确的11位手机号';
|
nextFieldErrors.phone = '请输入正确的11位手机号';
|
||||||
}
|
}
|
||||||
|
if (!trimmedSmsCode) {
|
||||||
|
nextFieldErrors.smsCode = '请输入手机号验证码';
|
||||||
|
} else if (!/^\d{4,6}$/.test(trimmedSmsCode)) {
|
||||||
|
nextFieldErrors.smsCode = '验证码格式不正确';
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(nextFieldErrors).length) {
|
if (Object.keys(nextFieldErrors).length) {
|
||||||
setFieldErrors(nextFieldErrors);
|
setFieldErrors(nextFieldErrors);
|
||||||
@@ -54,11 +101,16 @@ export default function StaffCreatePage() {
|
|||||||
await createPartnerStaff({
|
await createPartnerStaff({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
phone: trimmedPhone,
|
phone: trimmedPhone,
|
||||||
|
smsCode: trimmedSmsCode,
|
||||||
});
|
});
|
||||||
toastSuccess('子账号创建成功');
|
toastSuccess('子账号创建成功');
|
||||||
navigate('/center/staff');
|
navigate('/center/staff');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : '创建失败';
|
const message = e instanceof Error ? e.message : '创建失败';
|
||||||
|
if (message.includes('验证码')) {
|
||||||
|
setFieldErrors({ smsCode: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message.includes('手机号')) {
|
if (message.includes('手机号')) {
|
||||||
setFieldErrors({ phone: message });
|
setFieldErrors({ phone: message });
|
||||||
return;
|
return;
|
||||||
@@ -114,6 +166,38 @@ export default function StaffCreatePage() {
|
|||||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
<label>手机号验证码 <span className="text-primary">*</span></label>
|
||||||
|
<div className="partner-input-row">
|
||||||
|
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||||
|
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="请输入短信验证码"
|
||||||
|
value={smsCode}
|
||||||
|
onChange={(e) => patchSmsCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-code-btn"
|
||||||
|
disabled={smsCooldown > 0 || submitting}
|
||||||
|
onClick={() => void sendPhoneCode()}
|
||||||
|
>
|
||||||
|
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{smsHint && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||||
|
)}
|
||||||
|
{fieldErrors.smsCode && (
|
||||||
|
<p className="partner-field-error" role="alert">{fieldErrors.smsCode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="partner-info-banner">
|
<div className="partner-info-banner">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
|
|||||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||||
account_ops: [
|
account_ops: [
|
||||||
'partner_staff_create',
|
'partner_staff_create',
|
||||||
|
'partner_staff_sms_send',
|
||||||
|
'partner_staff_sms_verify_fail',
|
||||||
'partner_staff_update',
|
'partner_staff_update',
|
||||||
'partner_staff_delete',
|
'partner_staff_delete',
|
||||||
'partner_staff_permission_update',
|
'partner_staff_permission_update',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface PartnerStaffItem {
|
|||||||
export interface CreatePartnerStaffRequest {
|
export interface CreatePartnerStaffRequest {
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
smsCode: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
|
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export class SendPartnerStaffPhoneSmsDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreatePartnerStaffDto {
|
export class CreatePartnerStaffDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
smsCode: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { PartnerStaffService } from './partner-staff.service';
|
import { PartnerStaffService } from './partner-staff.service';
|
||||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
import {
|
||||||
|
CreatePartnerStaffDto,
|
||||||
|
SendPartnerStaffPhoneSmsDto,
|
||||||
|
UpdatePartnerStaffDto,
|
||||||
|
} from './dto/partner-staff.dto';
|
||||||
|
|
||||||
@Controller('partner/staff')
|
@Controller('partner/staff')
|
||||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
@@ -15,6 +19,11 @@ export class PartnerStaffController {
|
|||||||
return this.staffService.listStaff(user.actorId);
|
return this.staffService.listStaff(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('send-phone-sms')
|
||||||
|
sendPhoneSms(@CurrentUser() user: AuthUser, @Body() dto: SendPartnerStaffPhoneSmsDto) {
|
||||||
|
return this.staffService.sendStaffPhoneSms(user, dto.phone);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
|
||||||
return this.staffService.createStaff(user, dto);
|
return this.staffService.createStaff(user, dto);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
|
||||||
Injectable,
|
Injectable,
|
||||||
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
||||||
|
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export class PartnerStaffService {
|
|||||||
|
|
||||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@@ -25,6 +27,48 @@ export class PartnerStaffService {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
||||||
export class PartnerStaffService {
|
export class PartnerStaffService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
|
||||||
|
private readonly analytics: AnalyticsService,
|
||||||
|
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
|
||||||
|
) {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async listStaff(parentAccountId: bigint) {
|
||||||
|
|
||||||
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
|
|
||||||
|
where: { parentAccountId },
|
||||||
|
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) => this.toStaffItem(row));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
||||||
|
|
||||||
|
const parentAccountId = actor.actorId;
|
||||||
|
|
||||||
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
|
|
||||||
|
where: { id: parentAccountId },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent.isPrimary !== 1) {
|
||||||
|
|
||||||
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +86,18 @@ export class PartnerStaffService {
|
|||||||
|
|
||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
||||||
|
|
||||||
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const masked = this.maskPhone(normalized);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
||||||
|
|
||||||
|
clientApp: ClientApp.PARTNER_H5,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||||
@@ -64,6 +120,7 @@ export class PartnerStaffService {
|
|||||||
|
|
||||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||||
|
|
||||||
|
status: 'failed',
|
||||||
|
|
||||||
reason: err.message,
|
reason: err.message,
|
||||||
|
|
||||||
@@ -180,3 +237,4 @@ export class PartnerStaffService {
|
|||||||
name,
|
name,
|
||||||
|
|
||||||
phone: this.maskPhone(phone),
|
phone: this.maskPhone(phone),
|
||||||
|
|
||||||
Reference in New Issue
Block a user