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:
hailin 2026-03-09 06:42:49 -07:00
parent e2057bfe68
commit 79d6e0b98a
6 changed files with 155 additions and 61 deletions

View File

@ -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')}

View File

@ -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>{' '}

View File

@ -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.emailService.sendInviteEmail({ // 发短信
to: email, this.smsService.sendInviteSms({
tenantName: tenant.name, phoneNumber: contact,
role: saved.role, tenantName: tenant.name,
token: saved.token, role: saved.role,
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`)); inviteUrl,
}).catch((err) => this.logger.error(`邀请短信发送失败: ${err.message}`));
} else {
// 发邮件
this.emailService.sendInviteEmail({
to: contact,
tenantName: tenant.name,
role: saved.role,
token: saved.token,
}).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,

View File

@ -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;

View File

@ -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)}`);

View File

@ -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);
} }
} }