feat: 添加手动设置短信验证码功能及相关前端支持
Some checks failed
build quyun / Build (push) Failing after 1m26s

This commit is contained in:
2025-12-23 23:59:01 +08:00
parent d9737d4ee3
commit 6c9063a2c3
6 changed files with 154 additions and 18 deletions

View File

@@ -156,6 +156,11 @@ func (r *Routes) Register(router fiber.Router) {
r.smsCodeSends.List, r.smsCodeSends.List,
Query[dto.SmsCodeSendListQuery]("query"), Query[dto.SmsCodeSendListQuery]("query"),
)) ))
r.log.Debugf("Registering route: Post /admin/v1/sms-code-sends/manual-set -> smsCodeSends.ManualSet")
router.Post("/admin/v1/sms-code-sends/manual-set"[len(r.Path()):], DataFunc1(
r.smsCodeSends.ManualSet,
Body[SmsCodeManualSetBody]("body"),
))
// Register routes for controller: statistics // Register routes for controller: statistics
r.log.Debugf("Registering route: Get /admin/v1/statistics -> statistics.statistics") r.log.Debugf("Registering route: Get /admin/v1/statistics -> statistics.statistics")
router.Get("/admin/v1/statistics"[len(r.Path()):], DataFunc0( router.Get("/admin/v1/statistics"[len(r.Path()):], DataFunc0(

View File

@@ -6,6 +6,7 @@ import (
"quyun/v2/app/services" "quyun/v2/app/services"
"quyun/v2/database" "quyun/v2/database"
"quyun/v2/database/models" "quyun/v2/database/models"
"time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
@@ -13,6 +14,11 @@ import (
// @provider // @provider
type smsCodeSends struct{} type smsCodeSends struct{}
type SmsCodeManualSetBody struct {
Phone string `json:"phone"`
Code string `json:"code"`
}
// List // List
// //
// @Summary 短信验证码发送记录 // @Summary 短信验证码发送记录
@@ -58,3 +64,17 @@ func (ctl *smsCodeSends) List(ctx fiber.Ctx, query *dto.SmsCodeSendListQuery) (*
Items: items, Items: items,
}, nil }, nil
} }
// ManualSet
//
// @Summary 手动设置短信验证码(用于短信认证)
// @Tags Admin SMS
// @Accept json
// @Produce json
// @Param body body SmsCodeManualSetBody true "请求体"
// @Success 200 {object} models.SmsCodeSend "成功"
// @Router /admin/v1/sms-code-sends/manual-set [post]
// @Bind body body
func (ctl *smsCodeSends) ManualSet(ctx fiber.Ctx, body *SmsCodeManualSetBody) (*models.SmsCodeSend, error) {
return services.Users.SetPhoneCode(ctx.Context(), body.Phone, body.Code, 5*time.Minute)
}

View File

@@ -359,6 +359,64 @@ func (m *users) gen4Digits() (string, error) {
return fmt.Sprintf("%04d", n.Int64()), nil return fmt.Sprintf("%04d", n.Int64()), nil
} }
// SetPhoneCode 手动设置短信验证码(后台操作);默认有效期 5 分钟,不受发送频率限制。
func (m *users) SetPhoneCode(ctx context.Context, phone, code string, ttl time.Duration) (*models.SmsCodeSend, error) {
phone = m.normalizePhone(phone)
code = strings.TrimSpace(code)
if phone == "" {
return nil, errors.New("手机号不能为空")
}
if code == "" {
return nil, errors.New("验证码不能为空")
}
if len(code) != 4 {
return nil, errors.New("验证码必须为 4 位数字")
}
for _, r := range code {
if r < '0' || r > '9' {
return nil, errors.New("验证码必须为 4 位数字")
}
}
// 前置校验:手机号必须已注册
_, err := m.FindByPhone(ctx, phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("手机号未注册,请联系管理员开通")
}
return nil, errors.Wrap(err, "failed to find user by phone")
}
now := time.Now()
if ttl <= 0 {
ttl = 5 * time.Minute
}
expiresAt := now.Add(ttl)
m.mu.Lock()
m.ensurePhoneAuthMaps()
m.codeByPhone[phone] = phoneCodeEntry{code: code, expiresAt: expiresAt}
m.lastSentAtByPhone[phone] = now
m.mu.Unlock()
if _db == nil {
return nil, errors.New("db not initialized")
}
record := &models.SmsCodeSend{
Phone: phone,
Code: code,
SentAt: now,
ExpiresAt: expiresAt,
}
if err := _db.WithContext(ctx).Create(record).Error; err != nil {
return nil, err
}
log.Infof("SetPhoneCode to %s: code=%s", phone, code)
return record, nil
}
// SendPhoneCode 发送短信验证码(内存限流:同一手机号 58s 内仅允许发送一次;验证码 5 分钟过期)。 // SendPhoneCode 发送短信验证码(内存限流:同一手机号 58s 内仅允许发送一次;验证码 5 分钟过期)。
func (m *users) SendPhoneCode(ctx context.Context, phone string) error { func (m *users) SendPhoneCode(ctx context.Context, phone string) error {
phone = m.normalizePhone(phone) phone = m.normalizePhone(phone)

View File

@@ -1,16 +0,0 @@
package models
import "time"
const TableNameSmsCodeSend = "sms_code_sends"
// SmsCodeSend mapped from table <sms_code_sends>
type SmsCodeSend struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
Phone string `gorm:"column:phone;type:character varying(20);not null" json:"phone"`
Code string `gorm:"column:code;type:character varying(20);not null" json:"code"`
SentAt time.Time `gorm:"column:sent_at;type:timestamp without time zone;not null;default:now()" json:"sent_at"`
ExpiresAt time.Time `gorm:"column:expires_at;type:timestamp without time zone;not null" json:"expires_at"`
}
func (*SmsCodeSend) TableName() string { return TableNameSmsCodeSend }

View File

@@ -9,6 +9,11 @@ export const smsCodeSendService = {
phone: phone.trim() phone: phone.trim()
} }
}); });
},
manualSet({ phone = '', code = '' } = {}) {
return httpClient.post('/sms-code-sends/manual-set', {
phone: phone.trim(),
code: code.trim()
});
} }
}; };

View File

@@ -1,8 +1,10 @@
<script setup> <script setup>
import { smsCodeSendService } from '@/api/smsCodeSendService'; import { smsCodeSendService } from '@/api/smsCodeSendService';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import Button from 'primevue/button';
import Column from 'primevue/column'; import Column from 'primevue/column';
import DataTable from 'primevue/datatable'; import DataTable from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import ProgressSpinner from 'primevue/progressspinner'; import ProgressSpinner from 'primevue/progressspinner';
import Toast from 'primevue/toast'; import Toast from 'primevue/toast';
@@ -15,6 +17,11 @@ const phone = ref('');
const loading = ref(false); const loading = ref(false);
const searchTimeout = ref(null); const searchTimeout = ref(null);
const manualDialogVisible = ref(false);
const manualPhone = ref('');
const manualCode = ref('');
const manualSaving = ref(false);
const records = ref({ const records = ref({
items: [], items: [],
total: 0, total: 0,
@@ -62,14 +69,72 @@ const onSearch = () => {
onMounted(() => { onMounted(() => {
fetchRecords(); fetchRecords();
}); });
const gen4Digits = () => {
return String(Math.floor(Math.random() * 10000)).padStart(4, '0');
};
const openManualDialog = () => {
manualPhone.value = '';
manualCode.value = gen4Digits();
manualDialogVisible.value = true;
};
const regenerateCode = () => {
manualCode.value = gen4Digits();
};
const submitManualSet = async () => {
manualSaving.value = true;
try {
const resp = await smsCodeSendService.manualSet({
phone: manualPhone.value,
code: manualCode.value
});
toast.add({ severity: 'success', summary: '成功', detail: `已设置验证码:${resp.data.code}`, life: 3000 });
manualDialogVisible.value = false;
fetchRecords();
} catch (error) {
console.error('Failed to manual set sms code:', error);
toast.add({ severity: 'error', summary: '错误', detail: '设置验证码失败', life: 3000 });
} finally {
manualSaving.value = false;
}
};
</script> </script>
<template> <template>
<Toast /> <Toast />
<Dialog v-model:visible="manualDialogVisible" modal header="手动设置认证码" :style="{ width: '420px' }">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
<InputText v-model="manualPhone" class="w-full" placeholder="请输入 11 位手机号" inputmode="numeric"
maxlength="11" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
<div class="flex gap-2">
<InputText v-model="manualCode" class="flex-1" readonly />
<Button label="生成" icon="pi pi-refresh" severity="secondary" @click="regenerateCode" />
</div>
<div class="text-xs text-gray-500 mt-1">有效期 5 分钟</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button label="取消" text @click="manualDialogVisible = false" />
<Button label="设置" severity="success" :loading="manualSaving" @click="submitManualSet" />
</div>
</template>
</Dialog>
<div class="w-full"> <div class="w-full">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-semibold text-gray-800">短信认证</h1> <h1 class="text-2xl font-semibold text-gray-800">短信认证</h1>
<Button label="手动设置认证码" icon="pi pi-key" severity="success" @click="openManualDialog" />
</div> </div>
<div class="card"> <div class="card">
@@ -112,4 +177,3 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </template>