feat: phone validate

This commit is contained in:
2025-12-20 12:56:06 +08:00
parent 22e288bf98
commit dbeb0a5733
19 changed files with 397 additions and 89 deletions

View File

@@ -5,12 +5,11 @@
package admin
import (
"go.ipao.vip/gen/field"
"quyun/v2/app/middlewares"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"go.ipao.vip/gen/field"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"

View File

@@ -1,27 +1,29 @@
package http
import (
_ "embed"
"errors"
"quyun/v2/app/services"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// @provider
type auth struct{}
type auth struct {
jwt *jwt.JWT
}
// Phone
//
// @Summary 手机验证
// @Tags Auth
// @Produce json
// @Param form body PhoneValidationForm true "手机号"
// @Success 200 {object} any "成功"
// @Router /v1/auth/phone [post]
// @Bind phone body
func (ctl *posts) Phone(ctx fiber.Ctx, form *PhoneValidation) error {
// @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) {
@@ -33,20 +35,39 @@ func (ctl *posts) Phone(ctx fiber.Ctx, form *PhoneValidation) error {
return nil
}
type PhoneValidation struct {
type PhoneValidationForm struct {
Phone string `json:"phone,omitempty"`
Code *string `json:"code,omitempty"`
}
type TokenResponse struct {
Token string `json:"token,omitempty"`
}
// Validate
//
// @Summary 手机验证
// @Tags Auth
// @Produce json
// @Param body body PhoneValidationForm true "请求体"
// @Success 200 {object} any "成功"
// @Router /v1/auth/validate [post]
// @Bind phone body
func (ctl *posts) Validate(ctx fiber.Ctx, form *PhoneValidation) error {
// TODO: send sms
return nil
// @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 || *body.Code != "1234" {
return nil, errors.New("验证码错误")
}
// generate token for user
jwtToken, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{UserID: user.ID}))
if err != nil {
return nil, errors.Wrap(err, "failed to create token")
}
return &TokenResponse{Token: jwtToken}, nil
}

View File

@@ -69,7 +69,7 @@ func (ctl *posts) List(
}
postIds := lo.Map(pager.Items.([]*models.Post), func(item *models.Post, _ int) int64 { return item.ID })
if len(postIds) > 0 {
if len(postIds) > 0 && user != nil {
userBoughtIds, err := services.Users.BatchCheckHasBought(ctx, user.ID, postIds)
if err != nil {
log.WithError(err).Errorf("BatchCheckHasBought err: %v", err)
@@ -146,10 +146,14 @@ func (ctl *posts) Show(ctx fiber.Ctx, post *models.Post, user *models.User) (*Po
return nil, fiber.ErrNotFound
}
bought, err := services.Users.HasBought(ctx, user.ID, post.ID)
var err error
bought := false
if user != nil {
bought, err = services.Users.HasBought(ctx, user.ID, post.ID)
if err != nil {
return nil, err
}
}
medias, err := services.Posts.GetMediasByIds(ctx, post.HeadImages.Data())
if err != nil {
@@ -200,10 +204,14 @@ func (ctl *posts) Play(ctx fiber.Ctx, post *models.Post, user *models.User) (*Pl
// Url: "https://github.com/mediaelement/mediaelement-files/raw/refs/heads/master/big_buck_bunny.mp4",
// }, nil
preview := false
preview := true
bought, err := services.Users.HasBought(ctx, user.ID, post.ID)
if !bought || err != nil {
preview = true
if err != nil {
preview = false
}
if bought {
preview = false
}
log.Infof("Fetching play URL for post ID: %d", post.ID)

View File

@@ -5,6 +5,7 @@ import (
"quyun/v2/providers/ali"
"quyun/v2/providers/app"
"quyun/v2/providers/job"
"quyun/v2/providers/jwt"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
@@ -13,8 +14,12 @@ import (
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func() (*auth, error) {
obj := &auth{}
if err := container.Container.Provide(func(
jwt *jwt.JWT,
) (*auth, error) {
obj := &auth{
jwt: jwt,
}
return obj, nil
}); err != nil {
@@ -36,11 +41,13 @@ func Provide(opts ...opt.Option) error {
return err
}
if err := container.Container.Provide(func(
auth *auth,
middlewares *middlewares.Middlewares,
posts *posts,
users *users,
) (contracts.HttpRoute, error) {
obj := &Routes{
auth: auth,
middlewares: middlewares,
posts: posts,
users: users,

View File

@@ -5,12 +5,11 @@
package http
import (
"go.ipao.vip/gen/field"
"quyun/v2/app/middlewares"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"go.ipao.vip/gen/field"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
@@ -26,6 +25,7 @@ type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
auth *auth
posts *posts
users *users
}
@@ -45,6 +45,17 @@ func (r *Routes) Name() string {
// Register registers all HTTP routes with the provided fiber router.
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// Register routes for controller: auth
r.log.Debugf("Registering route: Post /v1/auth/phone -> auth.Phone")
router.Post("/v1/auth/phone"[len(r.Path()):], Func1(
r.auth.Phone,
Body[PhoneValidationForm]("form"),
))
r.log.Debugf("Registering route: Post /v1/auth/validate -> auth.Validate")
router.Post("/v1/auth/validate"[len(r.Path()):], DataFunc1(
r.auth.Validate,
Body[PhoneValidationForm]("body"),
))
// Register routes for controller: posts
r.log.Debugf("Registering route: Get /v1/posts -> posts.List")
router.Get("/v1/posts"[len(r.Path()):], DataFunc3(
@@ -78,14 +89,6 @@ func (r *Routes) Register(router fiber.Router) {
Query[ListQuery]("query"),
Local[*models.User]("user"),
))
r.log.Debugf("Registering route: Post /v1/auth/phone -> posts.Phone")
router.Post("/v1/auth/phone"[len(r.Path()):], Func0(
r.posts.Phone,
))
r.log.Debugf("Registering route: Post /v1/auth/validate -> posts.Validate")
router.Post("/v1/auth/validate"[len(r.Path()):], Func0(
r.posts.Validate,
))
r.log.Debugf("Registering route: Post /v1/posts/:id/buy -> posts.Buy")
router.Post("/v1/posts/:id/buy"[len(r.Path()):], DataFunc2(
r.posts.Buy,

View File

@@ -16,6 +16,16 @@ func (f *Middlewares) AuthFrontend(ctx fiber.Ctx) error {
return ctx.Next()
}
if ctx.Path() == "/v1/posts" {
return ctx.Next()
}
if strings.HasPrefix(ctx.Path(), "/v1/posts/") && strings.HasSuffix(ctx.Path(), "show") {
return ctx.Next()
}
if strings.HasPrefix(ctx.Path(), "/v1/posts/") && strings.HasSuffix(ctx.Path(), "play") {
return ctx.Next()
}
if f.app.IsDevMode() && false {
user, err := services.Users.FindByID(ctx.Context(), 1001)
if err != nil {

View File

@@ -707,6 +707,17 @@ const docTemplate = `{
"Auth"
],
"summary": "手机验证",
"parameters": [
{
"description": "手机号",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.PhoneValidationForm"
}
}
],
"responses": {
"200": {
"description": "成功",
@@ -724,6 +735,17 @@ const docTemplate = `{
"Auth"
],
"summary": "手机验证",
"parameters": [
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.PhoneValidationForm"
}
}
],
"responses": {
"200": {
"description": "成功",
@@ -1283,6 +1305,17 @@ const docTemplate = `{
}
}
},
"http.PhoneValidationForm": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"phone": {
"type": "string"
}
}
},
"http.PlayUrl": {
"type": "object",
"properties": {

View File

@@ -701,6 +701,17 @@
"Auth"
],
"summary": "手机验证",
"parameters": [
{
"description": "手机号",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.PhoneValidationForm"
}
}
],
"responses": {
"200": {
"description": "成功",
@@ -718,6 +729,17 @@
"Auth"
],
"summary": "手机验证",
"parameters": [
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.PhoneValidationForm"
}
}
],
"responses": {
"200": {
"description": "成功",
@@ -1277,6 +1299,17 @@
}
}
},
"http.PhoneValidationForm": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"phone": {
"type": "string"
}
}
},
"http.PlayUrl": {
"type": "object",
"properties": {

View File

@@ -164,6 +164,13 @@ definitions:
description: Valid is true if Time is not NULL
type: boolean
type: object
http.PhoneValidationForm:
properties:
code:
type: string
phone:
type: string
type: object
http.PlayUrl:
properties:
url:
@@ -832,6 +839,13 @@ paths:
- Admin Users
/v1/auth/phone:
post:
parameters:
- description: 手机号
in: body
name: form
required: true
schema:
$ref: '#/definitions/http.PhoneValidationForm'
produces:
- application/json
responses:
@@ -843,6 +857,13 @@ paths:
- Auth
/v1/auth/validate:
post:
parameters:
- description: 请求体
in: body
name: body
required: true
schema:
$ref: '#/definitions/http.PhoneValidationForm'
produces:
- application/json
responses:

View File

@@ -0,0 +1,14 @@
import client from "./client";
export const authApi = {
sendPhoneSms(phone) {
return client.post("/auth/phone", { phone: String(phone || "").trim() });
},
validateSmsCode(phone, code) {
return client.post("/auth/validate", {
phone: String(phone || "").trim(),
code: String(code || "").trim(),
});
},
};

View File

@@ -1,5 +1,10 @@
import axios from 'axios';
function getCurrentPathWithQueryHash() {
const { pathname, search, hash } = window.location;
return `${pathname}${search}${hash}`;
}
// Create axios instance with default config
const client = axios.create({
baseURL: '/v1',
@@ -29,13 +34,19 @@ client.interceptors.response.use(
return response
},
error => {
const noAuthRedirect = Boolean(error?.config?.meta?.noAuthRedirect);
// Handle HTTP errors here
if (error.response) {
// Handle 401 Unauthorized error
if (error.response.status === 401) {
const redirectUrl = encodeURIComponent(window.location.href);
window.location.href = `/v1/auth/wechat?redirect=${redirectUrl}`;
return;
if (noAuthRedirect) {
return Promise.reject(error);
}
const redirectPath = encodeURIComponent(getCurrentPathWithQueryHash());
sessionStorage.setItem('post_auth_redirect', getCurrentPathWithQueryHash());
sessionStorage.removeItem('phone_verified');
window.location.href = `/verify-phone?redirect=${redirectPath}`;
return Promise.reject(error);
}
// Server responded with error status
console.error('API Error:', error.response.status, error.response.data);

View File

@@ -3,6 +3,7 @@ import client from './client';
export const postApi = {
list({ page = 1, limit = 10, keyword = '' } = {}) {
return client.get('/posts', {
meta: { noAuthRedirect: true },
params: {
page,
limit,
@@ -12,10 +13,10 @@ export const postApi = {
},
play(id) {
return client.get(`/posts/${id}/play`);
return client.get(`/posts/${id}/play`, { meta: { noAuthRedirect: true } });
},
show(id) {
return client.get(`/posts/${id}/show`);
return client.get(`/posts/${id}/show`, { meta: { noAuthRedirect: true } });
},
mine({ page = 1, limit = 10 } = {}) {
return client.get('/posts/mine', {

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const activeTab = ref(0)
const isPhoneVerified = () => sessionStorage.getItem('phone_verified') === '1'
const tabs = [
{ label: '列表', route: '/', icon: AiOutlineHome },
@@ -13,6 +14,11 @@ const tabs = [
]
const switchTab = (index, route) => {
if ((route === '/purchased' || route === '/profile') && !isPhoneVerified()) {
const redirect = encodeURIComponent(route)
router.replace(`/verify-phone?redirect=${redirect}`)
return
}
activeTab.value = index
router.replace(route)
}

View File

@@ -27,6 +27,11 @@ const routes = [
path: '/posts/:id',
name: 'article-detail',
component: () => import('@/views/ArticleDetail.vue')
},
{
path: '/verify-phone',
name: 'verify-phone',
component: () => import('@/views/VerifyPhone.vue')
}
]

View File

@@ -87,6 +87,11 @@ const updateMediaSource = async () => {
};
const handleBuy = async () => {
if (sessionStorage.getItem('phone_verified') !== '1') {
const redirect = encodeURIComponent(router.currentRoute.value.fullPath);
router.replace(`/verify-phone?redirect=${redirect}`);
return;
}
// confirm
if (!confirm("确认购买该曲谱?")) {
return;

View File

@@ -0,0 +1,123 @@
<script setup>
import { authApi } from "@/api/authApi";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const phone = ref("");
const code = ref("");
const sending = ref(false);
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 startCountdown = (seconds = 60) => {
countdown.value = seconds;
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}, 1000);
};
const handleSend = async () => {
if (!canSend.value) return;
sending.value = true;
try {
await authApi.sendPhoneSms(phone.value);
startCountdown(60);
alert("验证码已发送");
} catch (error) {
console.log(error)
alert(error?.response?.data?.message || "发送验证码失败");
} finally {
sending.value = false;
}
};
const normalizeRedirect = (value) => {
if (!value) return null;
try {
const decoded = decodeURIComponent(String(value));
if (decoded.startsWith("/")) return decoded;
return null;
} catch {
return null;
}
};
const handleVerify = async () => {
if (!canVerify.value) return;
verifying.value = true;
try {
await authApi.validateSmsCode(phone.value, code.value);
sessionStorage.setItem("phone_verified", "1");
const redirect =
normalizeRedirect(route.query.redirect) ||
normalizeRedirect(sessionStorage.getItem("post_auth_redirect")) ||
"/";
sessionStorage.removeItem("post_auth_redirect");
router.replace(redirect);
} catch (error) {
alert(error?.response?.data || "验证失败");
} finally {
verifying.value = false;
}
};
onMounted(() => {
const redirect = normalizeRedirect(route.query.redirect);
if (redirect) sessionStorage.setItem("post_auth_redirect", redirect);
});
</script>
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<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">
未认证用户需要验证手机号后才能访问已购买我的并进行购买操作
</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>
<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'">
<span v-if="countdown > 0">{{ countdown }}s</span>
<span v-else>发送验证码</span>
</button>
</div>
</div>
<button :disabled="!canVerify" @click="handleVerify"
class="w-full rounded-xl px-4 py-3 font-semibold transition-colors"
:class="canVerify ? 'bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800' : 'bg-gray-100 text-gray-400'">
{{ verifying ? "验证中..." : "验证并继续" }}
</button>
<button class="w-full text-sm text-gray-500 hover:text-gray-700" @click="router.replace('/')">
暂不验证返回首页
</button>
</div>
</div>
</div>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,10 @@ import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import compression from 'vite-plugin-compression';
export default defineConfig({
export default defineConfig(() => {
const backendUrl = process.env.VITE_BACKEND_URL || 'http://127.0.0.1:8088';
return {
plugins: [
vue(),
compression({
@@ -31,7 +34,11 @@ export default defineConfig({
port: 3001,
open: true,
proxy: {
'/v1': 'http://localhost:8088',
'/v1': {
target: backendUrl,
changeOrigin: true,
secure: false,
},
}
},
build: {
@@ -53,4 +60,5 @@ export default defineConfig({
}
}
}
};
});