feat(invite): support phone number invitation with SMS notification
- TenantInvite entity: email nullable + phone field added - createInvite() auto-detects email vs phone, routes to email/SMS - SmsService: add sendInviteSms() with ALIYUN_SMS_INVITE_TEMPLATE_CODE - acceptInvite(): handle phone-based invites (uniqueness check + insert) - my-org page: email/phone toggle on invite form - /invite/[token] page: display phone or email from invite info - DB migration: phone column added, email made nullable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e2057bfe68
commit
79d6e0b98a
|
|
@ -41,7 +41,8 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
export default function MyOrgPage() {
|
export default function MyOrgPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteMethod, setInviteMethod] = useState<'email' | 'phone'>('email');
|
||||||
|
const [inviteContact, setInviteContact] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState('viewer');
|
const [inviteRole, setInviteRole] = useState('viewer');
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
@ -66,13 +67,13 @@ export default function MyOrgPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: (body: { email: string; role: string }) =>
|
mutationFn: (body: { email?: string; phone?: string; role: string }) =>
|
||||||
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
|
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
|
||||||
onSuccess: (invite) => {
|
onSuccess: (invite) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['my-org', 'invites'] });
|
queryClient.invalidateQueries({ queryKey: ['my-org', 'invites'] });
|
||||||
const link = `${window.location.origin}/invite/${invite.token}`;
|
const link = `${window.location.origin}/invite/${invite.token}`;
|
||||||
setInviteLink(link);
|
setInviteLink(link);
|
||||||
setInviteEmail('');
|
setInviteContact('');
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
|
|
@ -81,8 +82,11 @@ export default function MyOrgPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInvite = () => {
|
const handleInvite = () => {
|
||||||
if (!inviteEmail.trim()) { setInviteError('请填写邮箱'); return; }
|
if (!inviteContact.trim()) { setInviteError(inviteMethod === 'email' ? '请填写邮箱' : '请填写手机号'); return; }
|
||||||
inviteMutation.mutate({ email: inviteEmail.trim(), role: inviteRole });
|
const body = inviteMethod === 'email'
|
||||||
|
? { email: inviteContact.trim(), role: inviteRole }
|
||||||
|
: { phone: inviteContact.trim(), role: inviteRole };
|
||||||
|
inviteMutation.mutate(body);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = () => {
|
||||||
|
|
@ -162,12 +166,25 @@ export default function MyOrgPage() {
|
||||||
{/* Invite form */}
|
{/* Invite form */}
|
||||||
<div className="border rounded-lg p-5 space-y-4 bg-card">
|
<div className="border rounded-lg p-5 space-y-4 bg-card">
|
||||||
<h2 className="text-base font-semibold">邀请成员</h2>
|
<h2 className="text-base font-semibold">邀请成员</h2>
|
||||||
|
{/* Email / Phone toggle */}
|
||||||
|
<div className="flex rounded-md border overflow-hidden w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setInviteMethod('email'); setInviteContact(''); }}
|
||||||
|
className={`px-4 py-1.5 text-xs font-medium transition-colors ${inviteMethod === 'email' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>邮箱邀请</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setInviteMethod('phone'); setInviteContact(''); }}
|
||||||
|
className={`px-4 py-1.5 text-xs font-medium transition-colors ${inviteMethod === 'phone' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>手机邀请</button>
|
||||||
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type={inviteMethod === 'email' ? 'email' : 'tel'}
|
||||||
value={inviteEmail}
|
value={inviteContact}
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
onChange={(e) => setInviteContact(e.target.value)}
|
||||||
placeholder="输入邮箱地址"
|
placeholder={inviteMethod === 'email' ? '输入邮箱地址' : '输入手机号'}
|
||||||
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
|
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
|
|
@ -210,7 +227,7 @@ export default function MyOrgPage() {
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/20">
|
<tr className="border-b bg-muted/20">
|
||||||
<th className="text-left px-4 py-2 font-medium">邮箱</th>
|
<th className="text-left px-4 py-2 font-medium">邮箱 / 手机</th>
|
||||||
<th className="text-left px-4 py-2 font-medium">角色</th>
|
<th className="text-left px-4 py-2 font-medium">角色</th>
|
||||||
<th className="text-left px-4 py-2 font-medium">过期时间</th>
|
<th className="text-left px-4 py-2 font-medium">过期时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -218,7 +235,7 @@ export default function MyOrgPage() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{pendingInvites.map((inv) => (
|
{pendingInvites.map((inv) => (
|
||||||
<tr key={inv.id} className="border-b last:border-b-0">
|
<tr key={inv.id} className="border-b last:border-b-0">
|
||||||
<td className="px-4 py-2">{inv.email}</td>
|
<td className="px-4 py-2">{(inv as any).phone || inv.email}</td>
|
||||||
<td className="px-4 py-2">{ROLE_LABELS[inv.role] ?? inv.role}</td>
|
<td className="px-4 py-2">{ROLE_LABELS[inv.role] ?? inv.role}</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground text-xs">
|
<td className="px-4 py-2 text-muted-foreground text-xs">
|
||||||
{new Date(inv.expiresAt).toLocaleDateString('zh-CN')}
|
{new Date(inv.expiresAt).toLocaleDateString('zh-CN')}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { apiClient } from '@/infrastructure/api/api-client';
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
interface InviteInfo {
|
interface InviteInfo {
|
||||||
email: string;
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
role: string;
|
role: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
|
@ -122,8 +123,8 @@ export default function AcceptInvitePage() {
|
||||||
<span className="font-medium">{invite?.tenantName}</span>
|
<span className="font-medium">{invite?.tenantName}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">{t('inviteEmail')}</span>{' '}
|
<span className="text-muted-foreground">{invite?.phone ? '手机:' : t('inviteEmail')}</span>{' '}
|
||||||
<span className="font-medium">{invite?.email}</span>
|
<span className="font-medium">{invite?.phone || invite?.email}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">{t('inviteRole')}</span>{' '}
|
<span className="text-muted-foreground">{t('inviteRole')}</span>{' '}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { TenantProvisioningService } from '@it0/database';
|
||||||
import { UserRepository } from '../../infrastructure/repositories/user.repository';
|
import { UserRepository } from '../../infrastructure/repositories/user.repository';
|
||||||
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
|
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
|
||||||
import { EmailService } from '../../infrastructure/email/email.service';
|
import { EmailService } from '../../infrastructure/email/email.service';
|
||||||
|
import { SmsService } from '../../infrastructure/sms/sms.service';
|
||||||
import { User } from '../../domain/entities/user.entity';
|
import { User } from '../../domain/entities/user.entity';
|
||||||
import { ApiKey } from '../../domain/entities/api-key.entity';
|
import { ApiKey } from '../../domain/entities/api-key.entity';
|
||||||
import { Tenant } from '../../domain/entities/tenant.entity';
|
import { Tenant } from '../../domain/entities/tenant.entity';
|
||||||
|
|
@ -42,6 +43,7 @@ export class AuthService {
|
||||||
private readonly tenantProvisioningService: TenantProvisioningService,
|
private readonly tenantProvisioningService: TenantProvisioningService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
|
private readonly smsService: SmsService,
|
||||||
) {
|
) {
|
||||||
this.refreshSecret =
|
this.refreshSecret =
|
||||||
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
||||||
|
|
@ -393,15 +395,16 @@ export class AuthService {
|
||||||
|
|
||||||
async createInvite(
|
async createInvite(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
email: string,
|
contact: string, // email or phone
|
||||||
role: string,
|
role: string,
|
||||||
invitedBy: string,
|
invitedBy: string,
|
||||||
|
appBaseUrl?: string,
|
||||||
): Promise<TenantInvite> {
|
): Promise<TenantInvite> {
|
||||||
// Check tenant exists — tenantId here is the slug (matches user.tenantId in JWT)
|
const isPhone = /^[+\d][\d\s\-().]{6,}$/.test(contact) && !contact.includes('@');
|
||||||
|
|
||||||
|
// Check tenant exists
|
||||||
const tenant = await this.tenantRepository.findOneBy({ slug: tenantId });
|
const tenant = await this.tenantRepository.findOneBy({ slug: tenantId });
|
||||||
if (!tenant) {
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
throw new NotFoundException('Tenant not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user quota
|
// Check user quota
|
||||||
if (tenant.maxUsers !== -1) {
|
if (tenant.maxUsers !== -1) {
|
||||||
|
|
@ -418,34 +421,48 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing pending invite
|
// Check for existing pending invite (by email or phone)
|
||||||
const existing = await this.inviteRepository.findOne({
|
const whereClause = isPhone
|
||||||
where: { tenantId, email, status: 'pending' as const },
|
? { tenantId, phone: contact, status: 'pending' as const }
|
||||||
});
|
: { tenantId, email: contact, status: 'pending' as const };
|
||||||
|
const existing = await this.inviteRepository.findOne({ where: whereClause });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('An invitation has already been sent to this email');
|
throw new ConflictException(`An invitation has already been sent to ${isPhone ? 'this phone' : 'this email'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invite = this.inviteRepository.create({
|
const invite = this.inviteRepository.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
tenantId,
|
tenantId,
|
||||||
email,
|
email: isPhone ? null : contact,
|
||||||
|
phone: isPhone ? contact : null,
|
||||||
token: crypto.randomBytes(32).toString('hex'),
|
token: crypto.randomBytes(32).toString('hex'),
|
||||||
role: role || RoleType.VIEWER,
|
role: role || RoleType.VIEWER,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
invitedBy,
|
invitedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.inviteRepository.save(invite);
|
const saved = await this.inviteRepository.save(invite);
|
||||||
|
const baseUrl = appBaseUrl || process.env.APP_BASE_URL || 'https://it0.szaiai.com';
|
||||||
|
const inviteUrl = `${baseUrl}/invite/${saved.token}`;
|
||||||
|
|
||||||
// 发送邀请通知(fire-and-forget,不阻塞响应)
|
if (isPhone) {
|
||||||
|
// 发短信
|
||||||
|
this.smsService.sendInviteSms({
|
||||||
|
phoneNumber: contact,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
role: saved.role,
|
||||||
|
inviteUrl,
|
||||||
|
}).catch((err) => this.logger.error(`邀请短信发送失败: ${err.message}`));
|
||||||
|
} else {
|
||||||
|
// 发邮件
|
||||||
this.emailService.sendInviteEmail({
|
this.emailService.sendInviteEmail({
|
||||||
to: email,
|
to: contact,
|
||||||
tenantName: tenant.name,
|
tenantName: tenant.name,
|
||||||
role: saved.role,
|
role: saved.role,
|
||||||
token: saved.token,
|
token: saved.token,
|
||||||
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`));
|
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
@ -469,18 +486,15 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateInvite(token: string): Promise<{
|
async validateInvite(token: string): Promise<{
|
||||||
email: string;
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
role: string;
|
role: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}> {
|
}> {
|
||||||
const invite = await this.inviteRepository.findOneBy({ token });
|
const invite = await this.inviteRepository.findOneBy({ token });
|
||||||
if (!invite) {
|
if (!invite) throw new NotFoundException('Invalid invitation link');
|
||||||
throw new NotFoundException('Invalid invitation link');
|
if (invite.status !== 'pending') throw new BadRequestException(`Invitation has been ${invite.status}`);
|
||||||
}
|
|
||||||
if (invite.status !== 'pending') {
|
|
||||||
throw new BadRequestException(`Invitation has been ${invite.status}`);
|
|
||||||
}
|
|
||||||
if (new Date() > invite.expiresAt) {
|
if (new Date() > invite.expiresAt) {
|
||||||
invite.status = 'expired';
|
invite.status = 'expired';
|
||||||
await this.inviteRepository.save(invite);
|
await this.inviteRepository.save(invite);
|
||||||
|
|
@ -490,6 +504,7 @@ export class AuthService {
|
||||||
const tenant = await this.tenantRepository.findOneBy({ slug: invite.tenantId });
|
const tenant = await this.tenantRepository.findOneBy({ slug: invite.tenantId });
|
||||||
return {
|
return {
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
|
phone: invite.phone,
|
||||||
tenantName: tenant?.name || 'Unknown',
|
tenantName: tenant?.name || 'Unknown',
|
||||||
role: invite.role,
|
role: invite.role,
|
||||||
expiresAt: invite.expiresAt.toISOString(),
|
expiresAt: invite.expiresAt.toISOString(),
|
||||||
|
|
@ -503,7 +518,7 @@ export class AuthService {
|
||||||
): Promise<{
|
): Promise<{
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { id: string; email: string; name: string; roles: string[]; tenantId: string };
|
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: string };
|
||||||
}> {
|
}> {
|
||||||
const invite = await this.inviteRepository.findOneBy({ token });
|
const invite = await this.inviteRepository.findOneBy({ token });
|
||||||
if (!invite || invite.status !== 'pending') {
|
if (!invite || invite.status !== 'pending') {
|
||||||
|
|
@ -521,38 +536,38 @@ export class AuthService {
|
||||||
throw new BadRequestException('Tenant is not active');
|
throw new BadRequestException('Tenant is not active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contact = invite.email || invite.phone;
|
||||||
|
const isPhoneInvite = !invite.email && !!invite.phone;
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
const userId = crypto.randomUUID();
|
const userId = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const schemaName = `it0_t_${tenant.slug}`;
|
const schemaName = `it0_t_${tenant.slug}`;
|
||||||
|
|
||||||
// Insert user into the tenant's schema
|
|
||||||
const qr = this.dataSource.createQueryRunner();
|
const qr = this.dataSource.createQueryRunner();
|
||||||
await qr.connect();
|
await qr.connect();
|
||||||
try {
|
try {
|
||||||
await qr.startTransaction();
|
await qr.startTransaction();
|
||||||
// Check if email already exists in public.users (cross-tenant uniqueness)
|
// Check uniqueness in public.users
|
||||||
const existingPublic = await qr.query(
|
const existingPublic = isPhoneInvite
|
||||||
`SELECT id FROM public.users WHERE email = $1 LIMIT 1`,
|
? await qr.query(`SELECT id FROM public.users WHERE phone = $1 LIMIT 1`, [invite.phone])
|
||||||
[invite.email],
|
: await qr.query(`SELECT id FROM public.users WHERE email = $1 LIMIT 1`, [invite.email]);
|
||||||
);
|
|
||||||
if (existingPublic.length > 0) {
|
if (existingPublic.length > 0) {
|
||||||
throw new ConflictException('Email already registered');
|
throw new ConflictException(isPhoneInvite ? 'Phone already registered' : 'Email already registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
// a. Insert into public.users — enables login via email lookup
|
// a. Insert into public.users
|
||||||
await qr.query(
|
await qr.query(
|
||||||
`INSERT INTO public.users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at)
|
`INSERT INTO public.users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
[userId, tenant.slug, invite.email, passwordHash, name, [invite.role], true, now, now],
|
[userId, tenant.slug, invite.email ?? null, invite.phone ?? null, passwordHash, name, [invite.role], true, now, now],
|
||||||
);
|
);
|
||||||
|
|
||||||
// b. Insert into tenant schema for tenant-context management
|
// b. Insert into tenant schema
|
||||||
await qr.query(`SET LOCAL search_path TO "${schemaName}", public`);
|
await qr.query(`SET LOCAL search_path TO "${schemaName}", public`);
|
||||||
await qr.query(
|
await qr.query(
|
||||||
`INSERT INTO users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at)
|
`INSERT INTO users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
[userId, tenant.slug, invite.email, passwordHash, name, [invite.role], true, now, now],
|
[userId, tenant.slug, invite.email ?? null, invite.phone ?? null, passwordHash, name, [invite.role], true, now, now],
|
||||||
);
|
);
|
||||||
await qr.commitTransaction();
|
await qr.commitTransaction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -570,7 +585,8 @@ export class AuthService {
|
||||||
const user = new User();
|
const user = new User();
|
||||||
user.id = userId;
|
user.id = userId;
|
||||||
user.tenantId = tenant.slug;
|
user.tenantId = tenant.slug;
|
||||||
user.email = invite.email;
|
user.email = invite.email ?? undefined;
|
||||||
|
user.phone = invite.phone ?? undefined;
|
||||||
user.passwordHash = passwordHash;
|
user.passwordHash = passwordHash;
|
||||||
user.name = name;
|
user.name = name;
|
||||||
user.roles = [invite.role];
|
user.roles = [invite.role];
|
||||||
|
|
@ -582,6 +598,7 @@ export class AuthService {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ export class TenantInvite {
|
||||||
@Column({ type: 'varchar', length: 255 })
|
@Column({ type: 'varchar', length: 255 })
|
||||||
tenantId!: string;
|
tenantId!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255 })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
email!: string;
|
email!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
phone!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255, unique: true })
|
@Column({ type: 'varchar', length: 255, unique: true })
|
||||||
token!: string;
|
token!: string;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,60 @@ export class SmsService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendInviteSms(params: {
|
||||||
|
phoneNumber: string;
|
||||||
|
tenantName: string;
|
||||||
|
role: string;
|
||||||
|
inviteUrl: string;
|
||||||
|
}): Promise<SmsSendResult> {
|
||||||
|
const { phoneNumber, tenantName, role, inviteUrl } = params;
|
||||||
|
const normalized = this.normalizePhone(phoneNumber);
|
||||||
|
const templateCode = this.configService.get('ALIYUN_SMS_INVITE_TEMPLATE_CODE', '');
|
||||||
|
|
||||||
|
const roleLabel: Record<string, string> = {
|
||||||
|
admin: '管理员', operator: '操作员', viewer: '只读成员',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`[SMS] 发送邀请短信到 ${this.maskPhone(normalized)}`);
|
||||||
|
|
||||||
|
if (!this.enabled || !this.client || !templateCode) {
|
||||||
|
this.logger.warn(`[SMS 模拟] 邀请短信 → ${this.maskPhone(normalized)} | 链接: ${inviteUrl}`);
|
||||||
|
return { success: true, code: 'OK', message: '模拟发送成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = new $Dysmsapi20170525.SendSmsRequest({
|
||||||
|
phoneNumbers: normalized,
|
||||||
|
signName: this.signName,
|
||||||
|
templateCode,
|
||||||
|
templateParam: JSON.stringify({
|
||||||
|
company: tenantName,
|
||||||
|
role: roleLabel[role] || role,
|
||||||
|
url: inviteUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const runtime = new $Util.RuntimeOptions({ connectTimeout: 15000, readTimeout: 15000 });
|
||||||
|
const resp = await this.client.sendSmsWithOptions(req, runtime);
|
||||||
|
const body = resp.body;
|
||||||
|
const result: SmsSendResult = {
|
||||||
|
success: body?.code === 'OK',
|
||||||
|
requestId: body?.requestId,
|
||||||
|
bizId: body?.bizId,
|
||||||
|
code: body?.code,
|
||||||
|
message: body?.message,
|
||||||
|
};
|
||||||
|
if (result.success) {
|
||||||
|
this.logger.log(`[SMS] 邀请短信发送成功: requestId=${result.requestId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`[SMS] 邀请短信发送失败: code=${result.code}, msg=${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[SMS] 邀请短信异常: ${error.message}`);
|
||||||
|
return { success: false, code: error.code || 'UNKNOWN_ERROR', message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
||||||
const normalized = this.normalizePhone(phoneNumber);
|
const normalized = this.normalizePhone(phoneNumber);
|
||||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhone(normalized)}`);
|
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhone(normalized)}`);
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,13 @@ export class AuthController {
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
async createMyOrgInvite(
|
async createMyOrgInvite(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { email: string; role?: string },
|
@Body() body: { email?: string; phone?: string; role?: string },
|
||||||
) {
|
) {
|
||||||
const slug = req.user?.tenantId;
|
const slug = req.user?.tenantId;
|
||||||
const userId = req.user?.sub || req.user?.id;
|
const userId = req.user?.sub || req.user?.id;
|
||||||
if (!slug) throw new UnauthorizedException('No tenant context');
|
if (!slug) throw new UnauthorizedException('No tenant context');
|
||||||
return this.authService.createInvite(slug, body.email, body.role || 'viewer', userId);
|
const contact = body.email || body.phone;
|
||||||
|
if (!contact) throw new BadRequestException('email or phone is required');
|
||||||
|
return this.authService.createInvite(slug, contact, body.role || 'viewer', userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue