Compare commits

...

2 Commits

Author SHA1 Message Date
88d42470c4 Refactor code structure for improved readability and maintainability
Some checks failed
build quyun / Build (push) Failing after 1m23s
2025-12-20 13:42:49 +08:00
3f716b669c feat: add send phone code 2025-12-20 13:37:20 +08:00
7 changed files with 198 additions and 41 deletions

View File

@@ -8,6 +8,7 @@ import (
web "quyun/v2/app/http"
"quyun/v2/app/jobs"
"quyun/v2/app/middlewares"
"quyun/v2/app/services"
_ "quyun/v2/docs"
"quyun/v2/providers/ali"
"quyun/v2/providers/app"
@@ -47,6 +48,7 @@ func Command() atom.Option {
).
With(
jobs.Provide,
services.Provide,
middlewares.Provide,
),
),

View File

@@ -6,7 +6,6 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// @provider
@@ -18,49 +17,43 @@ type auth struct {
//
// @Summary 手机验证
// @Tags Auth
// @Accept json
// @Produce json
// @Param form body PhoneValidationForm true "手机号"
// @Success 200 {object} any "成功"
// @Router /v1/auth/phone [post]
// @Bind form body
func (ctl *auth) Phone(ctx fiber.Ctx, form *PhoneValidationForm) error {
_, err := services.Users.FindByPhone(ctx, form.Phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("手机号未注册,请联系管理员开通")
}
return err
}
// TODO: send sms
return nil
return services.Users.SendPhoneCode(ctx, form.Phone)
}
type PhoneValidationForm struct {
Phone string `json:"phone,omitempty"`
Code *string `json:"code,omitempty"`
Phone string `json:"phone,omitempty"` // 手机号(必须为已注册用户)
Code *string `json:"code,omitempty"` // 短信验证码(/v1/auth/validate 使用)
}
type TokenResponse struct {
Token string `json:"token,omitempty"`
Token string `json:"token,omitempty"` // 登录 token用于 Authorization: Bearer <token>
}
// Validate
//
// @Summary 手机验证
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body PhoneValidationForm true "请求体"
// @Success 200 {object} any "成功"
// @Success 200 {object} TokenResponse "成功"
// @Router /v1/auth/validate [post]
// @Bind body body
func (ctl *auth) Validate(ctx fiber.Ctx, body *PhoneValidationForm) (*TokenResponse, error) {
user, err := services.Users.FindByPhone(ctx, body.Phone)
if err != nil {
return nil, errors.New("手机号未注册,请联系管理员开通")
if body.Code == nil {
return nil, errors.New("验证码不能为空")
}
if body.Code == nil || *body.Code != "1234" {
return nil, errors.New("验证码错误")
user, err := services.Users.ValidatePhoneCode(ctx, body.Phone, *body.Code)
if err != nil {
return nil, err
}
// generate token for user

View File

@@ -54,6 +54,9 @@ func Provide(opts ...opt.Option) error {
}
if err := container.Container.Provide(func() (*users, error) {
obj := &users{}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}); err != nil {

View File

@@ -2,17 +2,38 @@ package services
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"strings"
"sync"
"time"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"github.com/pkg/errors"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"gorm.io/gorm"
)
// @provider
type users struct{}
type users struct {
mu sync.Mutex `inject:"false"`
lastSentAtByPhone map[string]time.Time `inject:"false"`
codeByPhone map[string]phoneCodeEntry `inject:"false"`
}
// prepare
func (m *users) Prepare() error {
m.lastSentAtByPhone = make(map[string]time.Time)
m.codeByPhone = make(map[string]phoneCodeEntry)
return nil
}
// List returns a paginated list of users
func (m *users) List(
@@ -216,3 +237,127 @@ func (m *users) FindByPhone(ctx context.Context, phone string) (*models.User, er
tbl, query := models.UserQuery.QueryContext(ctx)
return query.Where(tbl.Phone.Eq(phone)).First()
}
type phoneCodeEntry struct {
code string
expiresAt time.Time
}
func (m *users) ensurePhoneAuthMaps() {
if m.lastSentAtByPhone == nil {
m.lastSentAtByPhone = make(map[string]time.Time)
}
if m.codeByPhone == nil {
m.codeByPhone = make(map[string]phoneCodeEntry)
}
}
func (m *users) normalizePhone(phone string) string {
return strings.TrimSpace(phone)
}
func (m *users) isSendTooFrequent(now time.Time, phone string) bool {
last, ok := m.lastSentAtByPhone[phone]
if !ok {
return false
}
// 前端倒计时 60s后端用 58s 做保护,避免客户端/服务端时间误差导致“刚到 60s 仍被拒绝”。
return now.Sub(last) < 58*time.Second
}
func (m *users) gen4Digits() (string, error) {
// 0000-9999
n, err := rand.Int(rand.Reader, big.NewInt(10000))
if err != nil {
return "", errors.Wrap(err, "failed to generate sms code")
}
return fmt.Sprintf("%04d", n.Int64()), nil
}
// SendPhoneCode 发送短信验证码(内存限流:同一手机号 58s 内仅允许发送一次;验证码 5 分钟过期)。
func (m *users) SendPhoneCode(ctx context.Context, phone string) error {
phone = m.normalizePhone(phone)
if phone == "" {
return errors.New("手机号不能为空")
}
// 前置校验:手机号必须已注册
_, err := m.FindByPhone(ctx, phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("手机号未注册,请联系管理员开通")
}
return errors.Wrap(err, "failed to find user by phone")
}
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
m.ensurePhoneAuthMaps()
if m.isSendTooFrequent(now, phone) {
return errors.New("验证码发送过于频繁,请稍后再试")
}
code, err := m.gen4Digits()
if err != nil {
return err
}
// 生成/覆盖验证码:同一手机号再次发送时以最新验证码为准
m.codeByPhone[phone] = phoneCodeEntry{
code: code,
expiresAt: now.Add(5 * time.Minute),
}
m.lastSentAtByPhone[phone] = now
// log phone and code
log.Infof("SendPhoneCode to %s: code=%s", phone, code)
// TODO: 这里应调用实际短信服务商发送 code当前仅做内存发码与校验支撑。
return nil
}
// ValidatePhoneCode 校验短信验证码,成功后删除验证码并返回用户信息(用于生成 token
func (m *users) ValidatePhoneCode(ctx context.Context, phone, code string) (*models.User, error) {
phone = m.normalizePhone(phone)
code = strings.TrimSpace(code)
if phone == "" {
return nil, errors.New("手机号不能为空")
}
if code == "" {
return nil, errors.New("验证码不能为空")
}
// 先确认手机号存在,避免对不存在手机号暴露验证码状态
user, 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()
m.mu.Lock()
defer m.mu.Unlock()
m.ensurePhoneAuthMaps()
entry, ok := m.codeByPhone[phone]
if !ok {
return nil, errors.New("验证码已过期或不存在")
}
if now.After(entry.expiresAt) {
delete(m.codeByPhone, phone)
return nil, errors.New("验证码已过期或不存在")
}
if entry.code != code {
return nil, errors.New("验证码错误")
}
// 验证通过后删除验证码,防止重复验证(防重放)。
delete(m.codeByPhone, phone)
return user, nil
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/gofiber/fiber/v3/middleware/compress"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/limiter"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/gofiber/fiber/v3/middleware/requestid"
@@ -137,7 +136,7 @@ func Provide(opts ...opt.Option) error {
}))
// rate limit (enable standard headers; adjust Max via config if needed)
engine.Use(limiter.New(limiter.Config{Max: 0}))
// engine.Use(limiter.New(limiter.Config{Max: 0}))
// static files (Fiber v3 Static helper moved; enable via filesystem middleware later)
// if config.StaticRoute != nil && config.StaticPath != nil { ... }

View File

@@ -13,8 +13,21 @@ const verifying = ref(false);
const countdown = ref(0);
let countdownTimer = null;
const canSend = computed(() => !sending.value && countdown.value <= 0 && phone.value.trim().length >= 6);
const canVerify = computed(() => !verifying.value && phone.value.trim() !== "" && code.value.trim() !== "");
const normalizeDigits = (value, maxLen) => String(value || "").replace(/\D/g, "").slice(0, maxLen);
const onPhoneInput = (e) => {
phone.value = normalizeDigits(e?.target?.value, 11);
};
const onCodeInput = (e) => {
code.value = normalizeDigits(e?.target?.value, 4);
};
const isValidPhone = computed(() => phone.value.length === 11);
const isValidCode = computed(() => code.value.length === 4);
const canSend = computed(() => !sending.value && countdown.value <= 0 && isValidPhone.value);
const canVerify = computed(() => !verifying.value && isValidPhone.value && isValidCode.value);
const startCountdown = (seconds = 60) => {
countdown.value = seconds;
@@ -90,24 +103,26 @@ onMounted(() => {
<div class="w-full max-w-[420px] bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<h1 class="text-xl font-semibold text-gray-900">手机号验证</h1>
<p class="text-sm text-gray-500 mt-2">
未认证用户需要验证手机号后才能访问已购买我的并进行购买操作
请登录后再进行操作 无法验证请联系管理员微信13932043996
</p>
<div class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
<input v-model="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号"
class="w-full rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
</div>
<div class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
<input :value="phone" @input="onPhoneInput" inputmode="numeric" autocomplete="tel"
placeholder="请输入 11 位手机号" maxlength="11"
class="w-full rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
<div class="flex gap-2">
<input v-model="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入短信验证码"
class="flex-1 rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
<button :disabled="!canSend" @click="handleSend"
class="whitespace-nowrap rounded-xl px-4 py-3 text-sm font-medium border transition-colors"
:class="canSend ? 'bg-primary-600 text-white border-primary-600 hover:bg-primary-700 active:bg-primary-800' : 'bg-gray-100 text-gray-400 border-gray-200'">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
<div class="flex gap-2">
<input :value="code" @input="onCodeInput" inputmode="numeric" autocomplete="one-time-code"
placeholder="请输入 4 位验证码" maxlength="4"
class="flex-1 rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
<button :disabled="!canSend" @click="handleSend"
class="whitespace-nowrap rounded-xl px-4 py-3 text-sm font-medium border transition-colors"
:class="canSend ? 'bg-primary-600 text-white border-primary-600 hover:bg-primary-700 active:bg-primary-800' : 'bg-gray-100 text-gray-400 border-gray-200'">
<span v-if="countdown > 0">{{ countdown }}s</span>
<span v-else>发送验证码</span>
</button>
@@ -121,7 +136,7 @@ onMounted(() => {
</button>
<button class="w-full text-sm text-gray-500 hover:text-gray-700" @click="router.replace('/')">
暂不验证返回
暂不验证返回上一
</button>
</div>
</div>

File diff suppressed because one or more lines are too long