This commit is contained in:
@@ -156,6 +156,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.smsCodeSends.List,
|
||||
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
|
||||
r.log.Debugf("Registering route: Get /admin/v1/statistics -> statistics.statistics")
|
||||
router.Get("/admin/v1/statistics"[len(r.Path()):], DataFunc0(
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/models"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
@@ -13,6 +14,11 @@ import (
|
||||
// @provider
|
||||
type smsCodeSends struct{}
|
||||
|
||||
type SmsCodeManualSetBody struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// List
|
||||
//
|
||||
// @Summary 短信验证码发送记录
|
||||
@@ -58,3 +64,17 @@ func (ctl *smsCodeSends) List(ctx fiber.Ctx, query *dto.SmsCodeSendListQuery) (*
|
||||
Items: items,
|
||||
}, 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)
|
||||
}
|
||||
|
||||
@@ -359,6 +359,64 @@ func (m *users) gen4Digits() (string, error) {
|
||||
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 分钟过期)。
|
||||
func (m *users) SendPhoneCode(ctx context.Context, phone string) error {
|
||||
phone = m.normalizePhone(phone)
|
||||
|
||||
@@ -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 }
|
||||
@@ -9,6 +9,11 @@ export const smsCodeSendService = {
|
||||
phone: phone.trim()
|
||||
}
|
||||
});
|
||||
},
|
||||
manualSet({ phone = '', code = '' } = {}) {
|
||||
return httpClient.post('/sms-code-sends/manual-set', {
|
||||
phone: phone.trim(),
|
||||
code: code.trim()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { smsCodeSendService } from '@/api/smsCodeSendService';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Toast from 'primevue/toast';
|
||||
@@ -15,6 +17,11 @@ const phone = ref('');
|
||||
const loading = ref(false);
|
||||
const searchTimeout = ref(null);
|
||||
|
||||
const manualDialogVisible = ref(false);
|
||||
const manualPhone = ref('');
|
||||
const manualCode = ref('');
|
||||
const manualSaving = ref(false);
|
||||
|
||||
const records = ref({
|
||||
items: [],
|
||||
total: 0,
|
||||
@@ -62,14 +69,72 @@ const onSearch = () => {
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-800">短信认证</h1>
|
||||
<Button label="手动设置认证码" icon="pi pi-key" severity="success" @click="openManualDialog" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -112,4 +177,3 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user