feat: add SMS verification registration with UniSMS provider

- Add phone field to user model with index and helper methods
- Implement SMS provider interface with UniSMS (合一短信) implementation
- Add SMS verification code sending endpoint with rate limiting (1/60s)
- Support SMS registration in Register() (mutually exclusive with email)
- Add SMS configuration to admin settings (provider, keys, signature, template)
- Display phone number in admin user list contact column
- Add i18n translations for all SMS-related messages (zh-CN, en, zh-TW)
- Add Claude Code skills: sync-upstream, migrate-server
- Update CLAUDE.md with git conventions and deployment guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nosqli
2026-03-06 23:57:15 +08:00
parent 4a4cf0a0df
commit 835cd8e74b
26 changed files with 815 additions and 17 deletions

View File

@@ -50,6 +50,7 @@ import {
IconUser,
IconLock,
IconKey,
IconPhone,
} from '@douyinfe/semi-icons';
import {
onGitHubOAuthClicked,
@@ -79,6 +80,8 @@ const RegisterForm = () => {
password2: '',
email: '',
verification_code: '',
phone: '',
sms_verification_code: '',
wechat_verification_code: '',
});
const { username, password, password2 } = inputs;
@@ -142,9 +145,14 @@ const RegisterForm = () => {
);
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [showSmsVerification, setShowSmsVerification] = useState(false);
const [smsCodeLoading, setSmsCodeLoading] = useState(false);
const [smsDisableButton, setSmsDisableButton] = useState(false);
const [smsCountdown, setSmsCountdown] = useState(60);
useEffect(() => {
setShowEmailVerification(!!status?.email_verification);
setShowSmsVerification(!!status?.sms_verification);
if (status?.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
@@ -168,6 +176,19 @@ const RegisterForm = () => {
return () => clearInterval(countdownInterval); // Clean up on unmount
}, [disableButton, countdown]);
useEffect(() => {
let smsInterval = null;
if (smsDisableButton && smsCountdown > 0) {
smsInterval = setInterval(() => {
setSmsCountdown(smsCountdown - 1);
}, 1000);
} else if (smsCountdown === 0) {
setSmsDisableButton(false);
setSmsCountdown(60);
}
return () => clearInterval(smsInterval);
}, [smsDisableButton, smsCountdown]);
useEffect(() => {
return () => {
if (githubTimeoutRef.current) {
@@ -279,6 +300,31 @@ const RegisterForm = () => {
}
};
const sendSmsVerificationCode = async () => {
if (inputs.phone === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
return;
}
setSmsCodeLoading(true);
try {
const res = await API.get(
`/api/sms_verification?phone=${encodeURIComponent(inputs.phone)}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess(t('验证码发送成功,请查看手机短信!'));
setSmsDisableButton(true);
} else {
showError(message);
}
} catch (error) {
showError(t('发送验证码失败,请重试'));
} finally {
setSmsCodeLoading(false);
}
};
const handleGitHubClick = () => {
if (githubButtonDisabled) {
return;
@@ -637,6 +683,40 @@ const RegisterForm = () => {
</>
)}
{showSmsVerification && (
<>
<Form.Input
field='phone'
label={t('手机号')}
placeholder={t('输入手机号')}
name='phone'
onChange={(value) => handleChange('phone', value)}
prefix={<IconPhone />}
suffix={
<Button
onClick={sendSmsVerificationCode}
loading={smsCodeLoading}
disabled={smsDisableButton || smsCodeLoading}
>
{smsDisableButton
? `${t('重新发送')} (${smsCountdown})`
: t('获取验证码')}
</Button>
}
/>
<Form.Input
field='sms_verification_code'
label={t('短信验证码')}
placeholder={t('输入短信验证码')}
name='sms_verification_code'
onChange={(value) =>
handleChange('sms_verification_code', value)
}
prefix={<IconKey />}
/>
</>
)}
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='pt-4'>
<Checkbox

View File

@@ -50,6 +50,12 @@ const SystemSetting = () => {
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
SMSVerificationEnabled: '',
SMSProvider: '',
SMSAccessKeyId: '',
SMSAccessKeySecret: '',
SMSSignName: '',
SMSTemplateCode: '',
GitHubOAuthEnabled: '',
GitHubClientId: '',
GitHubClientSecret: '',
@@ -174,6 +180,7 @@ const SystemSetting = () => {
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
case 'SMSVerificationEnabled':
case 'GitHubOAuthEnabled':
case 'WeChatAuthEnabled':
case 'TelegramOAuthEnabled':
@@ -347,6 +354,34 @@ const SystemSetting = () => {
}
};
const submitSMS = async () => {
const options = [];
if (originInputs['SMSProvider'] !== inputs.SMSProvider) {
options.push({ key: 'SMSProvider', value: inputs.SMSProvider });
}
if (originInputs['SMSAccessKeyId'] !== inputs.SMSAccessKeyId) {
options.push({ key: 'SMSAccessKeyId', value: inputs.SMSAccessKeyId });
}
if (
originInputs['SMSAccessKeySecret'] !== inputs.SMSAccessKeySecret &&
inputs.SMSAccessKeySecret !== ''
) {
options.push({
key: 'SMSAccessKeySecret',
value: inputs.SMSAccessKeySecret,
});
}
if (originInputs['SMSSignName'] !== inputs.SMSSignName) {
options.push({ key: 'SMSSignName', value: inputs.SMSSignName });
}
if (originInputs['SMSTemplateCode'] !== inputs.SMSTemplateCode) {
options.push({ key: 'SMSTemplateCode', value: inputs.SMSTemplateCode });
}
if (options.length > 0) {
await updateOptions(options);
}
};
const submitEmailDomainWhitelist = async () => {
if (Array.isArray(emailDomainWhitelist)) {
await updateOptions([
@@ -681,6 +716,24 @@ const SystemSetting = () => {
if (optionKey === 'PasswordLoginEnabled' && !value) {
setShowPasswordLoginConfirmModal(true);
} else if (optionKey === 'SMSVerificationEnabled' && value) {
// When enabling SMS verification, disable email verification (mutual exclusion)
await updateOptions([
{ key: 'SMSVerificationEnabled', value: true },
{ key: 'EmailVerificationEnabled', value: false },
]);
if (formApiRef.current) {
formApiRef.current.setValue('EmailVerificationEnabled', false);
}
} else if (optionKey === 'EmailVerificationEnabled' && value) {
// When enabling email verification, disable SMS verification (mutual exclusion)
await updateOptions([
{ key: 'EmailVerificationEnabled', value: true },
{ key: 'SMSVerificationEnabled', value: false },
]);
if (formApiRef.current) {
formApiRef.current.setValue('SMSVerificationEnabled', false);
}
} else {
await updateOptions([{ key: optionKey, value }]);
}
@@ -1015,6 +1068,15 @@ const SystemSetting = () => {
>
{t('通过密码注册时需要进行邮箱验证')}
</Form.Checkbox>
<Form.Checkbox
field='SMSVerificationEnabled'
noLabel
onChange={(e) =>
handleCheckboxChange('SMSVerificationEnabled', e)
}
>
{t('通过密码注册时需要进行短信验证(与邮箱验证互斥)')}
</Form.Checkbox>
<Form.Checkbox
field='RegisterEnabled'
noLabel
@@ -1340,6 +1402,57 @@ const SystemSetting = () => {
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置短信服务')}>
<Text>{t('用以支持短信验证码注册(与邮箱验证互斥)')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Select
field='SMSProvider'
label={t('短信服务商')}
placeholder={t('选择短信服务商')}
style={{ width: '100%' }}
>
<Select.Option value='unisms'>UniSMS (合一短信)</Select.Option>
</Form.Select>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMSAccessKeyId'
label={t('AccessKey ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMSAccessKeySecret'
label={t('AccessKey Secret')}
type='password'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMSSignName'
label={t('短信签名')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMSTemplateCode'
label={t('短信模板 ID')}
/>
</Col>
</Row>
<Button onClick={submitSMS} style={{ marginTop: 10 }}>{t('保存短信设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 OIDC')}>
<Text>

View File

@@ -331,6 +331,26 @@ export const getUsersColumns = ({
key: 'quota_usage',
render: (text, record) => renderQuotaUsage(text, record, t),
},
{
title: t('联系方式'),
key: 'contact',
render: (text, record) => {
const parts = [];
if (record.email) parts.push(record.email);
if (record.phone) parts.push(record.phone);
return parts.length > 0 ? (
<Space spacing={2} vertical align='start'>
{parts.map((p, i) => (
<Tag key={i} color='white' shape='circle' className='!text-xs'>
{p}
</Tag>
))}
</Space>
) : (
'-'
);
},
},
{
title: t('分组'),
dataIndex: 'group',

View File

@@ -2603,6 +2603,21 @@
"验证数据库连接状态": "Verify database connection status",
"验证码": "Verification Code",
"验证码发送成功,请检查邮箱!": "The verification code was sent successfully, please check your email!",
"验证码发送成功,请查看手机短信!": "Verification code sent successfully, please check your SMS!",
"发送验证码失败,请重试": "Failed to send verification code, please try again",
"手机号": "Phone Number",
"输入手机号": "Enter phone number",
"短信验证码": "SMS Verification Code",
"输入短信验证码": "Enter SMS verification code",
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "SMS verification required for password registration (mutually exclusive with email verification)",
"配置短信服务": "Configure SMS Service",
"用以支持短信验证码注册(与邮箱验证互斥)": "To support SMS verification code registration (mutually exclusive with email verification)",
"短信服务商": "SMS Provider",
"选择短信服务商": "Select SMS provider",
"短信签名": "SMS Signature",
"短信模板 ID": "SMS Template ID",
"保存短信设置": "Save SMS Settings",
"联系方式": "Contact",
"验证设置": "Verify setup",
"验证身份": "Verify identity",
"验证配置错误": "Verification configuration error",

View File

@@ -2622,6 +2622,21 @@
"验证数据库连接状态": "验证数据库连接状态",
"验证码": "验证码",
"验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!",
"验证码发送成功,请查看手机短信!": "验证码发送成功,请查看手机短信!",
"发送验证码失败,请重试": "发送验证码失败,请重试",
"手机号": "手机号",
"输入手机号": "输入手机号",
"短信验证码": "短信验证码",
"输入短信验证码": "输入短信验证码",
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "通过密码注册时需要进行短信验证(与邮箱验证互斥)",
"配置短信服务": "配置短信服务",
"用以支持短信验证码注册(与邮箱验证互斥)": "用以支持短信验证码注册(与邮箱验证互斥)",
"短信服务商": "短信服务商",
"选择短信服务商": "选择短信服务商",
"短信签名": "短信签名",
"短信模板 ID": "短信模板 ID",
"保存短信设置": "保存短信设置",
"联系方式": "联系方式",
"验证设置": "验证设置",
"验证身份": "验证身份",
"验证配置错误": "验证配置错误",