新增个人中心页面与用户资料自助修改接口

- 新增 GET/PUT /api/me:登录用户可查看并修改自己的 nickname/gender/birthday,空串清空、缺省保持不变,生日校验格式与未来日期
- users 表新增 gender/birthday 字段(迁移 v6)及 model.Date 日期类型,管理员用户接口同步支持
- 前端新增 /profile 个人中心页面、头像下拉入口与登录守卫,保存后同步会话用户信息
- 静态服务对未命中的无扩展名路径回退 index.html,避免刷新前端路由 404
- 重新生成 Swagger 文档,补充 /api/me 与资料字段相关测试
This commit is contained in:
2026-09-21 16:05:23 +08:00
parent 2d7f4a63fe
commit 1fec77e81f
22 files changed
+1302 -18

No files matched your search

+152 -2
View File
@@ -151,6 +151,104 @@ const docTemplate = `{
}
}
},
"/me": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Return the authenticated user's profile, including groups.",
"produces": [
"application/json"
],
"tags": [
"profile"
],
"summary": "Get current user profile",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profile"
],
"summary": "Update current user profile",
"parameters": [
{
"description": "Fields to update",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/auth.UpdateProfileRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "invalid request",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/notes": {
"get": {
"security": [
@@ -873,7 +971,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.",
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.",
"consumes": [
"application/json"
],
@@ -1005,7 +1103,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password.",
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
"consumes": [
"application/json"
],
@@ -1210,6 +1308,24 @@ const docTemplate = `{
}
}
},
"auth.UpdateProfileRequest": {
"type": "object",
"properties": {
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"gender": {
"type": "string",
"example": "male"
},
"nickname": {
"type": "string",
"maxLength": 50,
"example": "Alice"
}
}
},
"httpx.ErrorResponse": {
"type": "object",
"properties": {
@@ -1245,12 +1361,20 @@ const docTemplate = `{
"avatar": {
"type": "string"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string",
"example": "male"
},
"groups": {
"type": "array",
"items": {
@@ -1350,11 +1474,24 @@ const docTemplate = `{
"maxLength": 255,
"example": "https://example.com/avatar.png"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"email": {
"type": "string",
"maxLength": 255,
"example": "alice@example.com"
},
"gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"example": "male"
},
"group_ids": {
"type": "array",
"items": {
@@ -1421,6 +1558,19 @@ const docTemplate = `{
"maxLength": 255,
"example": "https://example.com/avatar.png"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"example": "male"
},
"group_ids": {
"type": "array",
"items": {
+152 -2
View File
@@ -144,6 +144,104 @@
}
}
},
"/me": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Return the authenticated user's profile, including groups.",
"produces": [
"application/json"
],
"tags": [
"profile"
],
"summary": "Get current user profile",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profile"
],
"summary": "Update current user profile",
"parameters": [
{
"description": "Fields to update",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/auth.UpdateProfileRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "invalid request",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/notes": {
"get": {
"security": [
@@ -866,7 +964,7 @@
"BearerAuth": []
}
],
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.",
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.",
"consumes": [
"application/json"
],
@@ -998,7 +1096,7 @@
"BearerAuth": []
}
],
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password.",
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
"consumes": [
"application/json"
],
@@ -1203,6 +1301,24 @@
}
}
},
"auth.UpdateProfileRequest": {
"type": "object",
"properties": {
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"gender": {
"type": "string",
"example": "male"
},
"nickname": {
"type": "string",
"maxLength": 50,
"example": "Alice"
}
}
},
"httpx.ErrorResponse": {
"type": "object",
"properties": {
@@ -1238,12 +1354,20 @@
"avatar": {
"type": "string"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string",
"example": "male"
},
"groups": {
"type": "array",
"items": {
@@ -1343,11 +1467,24 @@
"maxLength": 255,
"example": "https://example.com/avatar.png"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"email": {
"type": "string",
"maxLength": 255,
"example": "alice@example.com"
},
"gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"example": "male"
},
"group_ids": {
"type": "array",
"items": {
@@ -1414,6 +1551,19 @@
"maxLength": 255,
"example": "https://example.com/avatar.png"
},
"birthday": {
"type": "string",
"example": "1995-06-15"
},
"gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"example": "male"
},
"group_ids": {
"type": "array",
"items": {
+108 -3
View File
@@ -53,6 +53,19 @@ definitions:
- password
- username
type: object
auth.UpdateProfileRequest:
properties:
birthday:
example: "1995-06-15"
type: string
gender:
example: male
type: string
nickname:
example: Alice
maxLength: 50
type: string
type: object
httpx.ErrorResponse:
properties:
error:
@@ -76,10 +89,16 @@ definitions:
properties:
avatar:
type: string
birthday:
example: "1995-06-15"
type: string
created_at:
type: string
email:
type: string
gender:
example: male
type: string
groups:
items:
$ref: '#/definitions/model.UserGroup'
@@ -144,10 +163,20 @@ definitions:
example: https://example.com/avatar.png
maxLength: 255
type: string
birthday:
example: "1995-06-15"
type: string
email:
example: alice@example.com
maxLength: 255
type: string
gender:
enum:
- male
- female
- other
example: male
type: string
group_ids:
example:
- 1
@@ -200,6 +229,16 @@ definitions:
example: https://example.com/avatar.png
maxLength: 255
type: string
birthday:
example: "1995-06-15"
type: string
gender:
enum:
- male
- female
- other
example: male
type: string
group_ids:
example:
- 1
@@ -353,6 +392,70 @@ paths:
summary: Health check
tags:
- system
/me:
get:
description: Return the authenticated user's profile, including groups.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Get current user profile
tags:
- profile
put:
consumes:
- application/json
description: Update the authenticated user's nickname, gender, and birthday.
birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps
the current value.
parameters:
- description: Fields to update
in: body
name: profile
required: true
schema:
$ref: '#/definitions/auth.UpdateProfileRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: invalid request
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Update current user profile
tags:
- profile
/notes:
get:
description: List notes ordered by id DESC. page starts at 1; page_size is 1-100,
@@ -827,8 +930,9 @@ paths:
consumes:
- application/json
description: Create a user and assign groups. username and email are unique;
password is 6-72 chars; defaults to the regular user group (id 1) when group_ids
is omitted.
password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD
and cannot be in the future; defaults to the regular user group (id 1) when
group_ids is omitted.
parameters:
- description: User payload
in: body
@@ -954,7 +1058,8 @@ paths:
consumes:
- application/json
description: Update user fields. group_ids replaces all groups; a non-empty
password resets the password.
password resets the password; birthday accepts YYYY-MM-DD, an empty string
clears it, and omitting it keeps the current value.
parameters:
- description: User ID
example: 1
+2
View File
@@ -13,6 +13,8 @@ export interface AuthUser {
email: string
nickname: string
avatar: string
gender: string
birthday: string | null
status: number
groups: AuthUserGroup[]
}
+19
View File
@@ -0,0 +1,19 @@
import { request } from './http'
import type { AuthUser } from './auth'
export interface UpdateProfilePayload {
nickname: string
gender: string
birthday: string
}
export function getProfile(): Promise<AuthUser> {
return request<AuthUser>('/me')
}
export function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
return request<AuthUser>('/me', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
@@ -95,6 +95,13 @@ function logout() {
v-if="menuOpen"
class="absolute right-0 top-full mt-2 w-36 overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg"
>
<RouterLink
:to="{ name: 'profile' }"
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
@click="menuOpen = false"
>
{{ t('header.profile') }}
</RouterLink>
<button
type="button"
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
+25
View File
@@ -10,6 +10,7 @@ const enUS: MessageSchema = {
register: 'Sign up',
login: 'Log in',
userMenu: 'User menu',
profile: 'Profile',
logout: 'Log out',
},
home: {
@@ -93,6 +94,30 @@ const enUS: MessageSchema = {
sessionExpired: 'Session expired, please log in again',
},
},
profile: {
title: 'Profile',
subtitle: 'View and edit your basic information',
basicInfo: 'Basic information',
nickname: 'Nickname',
nicknamePlaceholder: 'Falls back to username when empty',
gender: 'Gender',
genderOptions: {
unset: 'Not set',
male: 'Male',
female: 'Female',
other: 'Other',
},
birthday: 'Birthday',
save: 'Save changes',
saveSuccess: 'Your profile has been updated',
errors: {
nicknameLength: 'Nickname must be at most 50 characters',
birthdayInvalid: 'Invalid birthday format',
birthdayFuture: 'Birthday cannot be in the future',
invalid: 'The submitted information is invalid, please check and retry',
network: 'Network error, please try again later',
},
},
}
export default enUS
+25
View File
@@ -10,6 +10,7 @@ const jaJP: MessageSchema = {
register: '新規登録',
login: 'ログイン',
userMenu: 'ユーザーメニュー',
profile: 'マイページ',
logout: 'ログアウト',
},
home: {
@@ -93,6 +94,30 @@ const jaJP: MessageSchema = {
sessionExpired: 'ログインの有効期限が切れました。もう一度ログインしてください',
},
},
profile: {
title: 'マイページ',
subtitle: '基本情報の確認と編集',
basicInfo: '基本情報',
nickname: 'ニックネーム',
nicknamePlaceholder: '未入力の場合はユーザー名を表示します',
gender: '性別',
genderOptions: {
unset: '未設定',
male: '男性',
female: '女性',
other: 'その他',
},
birthday: '誕生日',
save: '変更を保存',
saveSuccess: 'プロフィールを更新しました',
errors: {
nicknameLength: 'ニックネームは 50 文字以内で入力してください',
birthdayInvalid: '誕生日の形式が正しくありません',
birthdayFuture: '誕生日は今日より後に設定できません',
invalid: '入力内容が無効です。確認してもう一度お試しください',
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
},
},
}
export default jaJP
+25
View File
@@ -8,6 +8,7 @@ const zhCN = {
register: '注册',
login: '登录',
userMenu: '用户菜单',
profile: '个人中心',
logout: '退出登录',
},
home: {
@@ -91,6 +92,30 @@ const zhCN = {
sessionExpired: '登录已过期,请重新登录',
},
},
profile: {
title: '个人中心',
subtitle: '查看并修改你的基本信息',
basicInfo: '基本信息',
nickname: '昵称',
nicknamePlaceholder: '未填写时显示用户名',
gender: '性别',
genderOptions: {
unset: '未设置',
male: '男',
female: '女',
other: '其他',
},
birthday: '生日',
save: '保存修改',
saveSuccess: '个人资料已更新',
errors: {
nicknameLength: '昵称最多 50 个字符',
birthdayInvalid: '生日格式不正确',
birthdayFuture: '生日不能晚于今天',
invalid: '提交的信息无效,请检查后重试',
network: '网络异常,请稍后重试',
},
},
}
export type MessageSchema = typeof zhCN
+19
View File
@@ -1,9 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { useAuthStore } from '@/stores/auth'
declare module 'vue-router' {
interface RouteMeta {
blank?: boolean
requiresAuth?: boolean
}
}
@@ -27,7 +29,24 @@ const router = createRouter({
component: () => import('../views/RegisterView.vue'),
meta: { blank: true },
},
{
path: '/profile',
name: 'profile',
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true },
},
],
})
router.beforeEach((to) => {
if (!to.meta.requiresAuth) {
return true
}
const auth = useAuthStore()
if (auth.isAuthenticated) {
return true
}
return { name: 'login', query: { redirect: to.fullPath } }
})
export default router
+24
View File
@@ -7,6 +7,11 @@ import {
type LoginResponse,
type RegisterPayload,
} from '@/api/auth'
import {
getProfile as getProfileRequest,
updateProfile as updateProfileRequest,
type UpdateProfilePayload,
} from '@/api/profile'
import { setAuthToken } from '@/api/http'
const TOKEN_KEY = 'rill-token'
@@ -54,6 +59,11 @@ export const useAuthStore = defineStore('auth', () => {
setAuthToken(null)
}
function applyUser(data: AuthUser) {
user.value = data
localStorage.setItem(USER_KEY, JSON.stringify(data))
}
function applySession(data: LoginResponse) {
token.value = data.token
user.value = data.user
@@ -74,6 +84,18 @@ export const useAuthStore = defineStore('auth', () => {
return registerRequest(payload)
}
async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
const data = await updateProfileRequest(payload)
applyUser(data)
return data
}
async function refreshProfile(): Promise<AuthUser> {
const data = await getProfileRequest()
applyUser(data)
return data
}
function logout() {
clear()
}
@@ -96,6 +118,8 @@ export const useAuthStore = defineStore('auth', () => {
isAdmin,
login,
register,
updateProfile,
refreshProfile,
logout,
restore,
}
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
interface ProfileForm {
nickname: string
gender: string
birthday: string
}
const { t } = useI18n()
const auth = useAuthStore()
const today = new Date().toLocaleDateString('sv-SE')
const genderOptions = [
{ value: '', labelKey: 'profile.genderOptions.unset' },
{ value: 'male', labelKey: 'profile.genderOptions.male' },
{ value: 'female', labelKey: 'profile.genderOptions.female' },
{ value: 'other', labelKey: 'profile.genderOptions.other' },
] as const satisfies { value: string; labelKey: string }[]
const validationSchema = computed(() =>
toTypedSchema(
z.object({
nickname: z.string().max(50, t('profile.errors.nicknameLength')),
gender: z.enum(['', 'male', 'female', 'other']),
birthday: z
.string()
.refine(
(value) => value === '' || /^\d{4}-\d{2}-\d{2}$/.test(value),
t('profile.errors.birthdayInvalid'),
)
.refine((value) => value === '' || value <= today, t('profile.errors.birthdayFuture')),
}),
),
)
const { defineField, handleSubmit, errors, isSubmitting, setValues } = useForm<ProfileForm>({
validationSchema,
initialValues: {
nickname: auth.user?.nickname ?? '',
gender: auth.user?.gender ?? '',
birthday: auth.user?.birthday ?? '',
},
})
const [nickname, nicknameProps] = defineField('nickname')
const [gender, genderProps] = defineField('gender')
const [birthday, birthdayProps] = defineField('birthday')
const submitError = ref('')
const saved = ref(false)
const avatarInitial = computed(() => auth.displayName.slice(0, 1).toUpperCase())
onMounted(async () => {
try {
const profile = await auth.refreshProfile()
setValues({
nickname: profile.nickname ?? '',
gender: profile.gender ?? '',
birthday: profile.birthday ?? '',
})
} catch {
// 401 由全局未授权处理,其余错误保留本地缓存的用户信息
}
})
const onSubmit = handleSubmit(async (values) => {
submitError.value = ''
saved.value = false
try {
await auth.updateProfile({
nickname: values.nickname,
gender: values.gender,
birthday: values.birthday,
})
saved.value = true
} catch (error) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
submitError.value = t('profile.errors.invalid')
} else {
submitError.value = t('profile.errors.network')
}
}
})
</script>
<template>
<div class="mx-auto max-w-[1600px] px-4 py-6">
<div class="mx-auto max-w-2xl space-y-4">
<section class="flex items-center gap-4 rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line">
<img
v-if="auth.user?.avatar"
:src="auth.user.avatar"
alt=""
class="h-16 w-16 shrink-0 rounded-full object-cover"
/>
<span
v-else
class="grid h-16 w-16 shrink-0 place-items-center rounded-full bg-primary/10 text-xl font-semibold text-primary"
>
{{ avatarInitial }}
</span>
<div class="min-w-0">
<h1 class="truncate text-xl font-semibold">{{ t('profile.title') }}</h1>
<p class="mt-1 text-sm text-content-3">{{ t('profile.subtitle') }}</p>
</div>
</section>
<form
class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line sm:p-8"
novalidate
@submit="onSubmit"
>
<h2 class="text-base font-semibold">{{ t('profile.basicInfo') }}</h2>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<div>
<label for="username" class="mb-1.5 block text-sm text-content-2">
{{ t('auth.username') }}
</label>
<input
id="username"
:value="auth.user?.username"
type="text"
disabled
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm text-content-3"
/>
</div>
<div>
<label for="email" class="mb-1.5 block text-sm text-content-2">
{{ t('auth.email') }}
</label>
<input
id="email"
:value="auth.user?.email"
type="text"
disabled
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm text-content-3"
/>
</div>
</div>
<div class="mt-4">
<label for="nickname" class="mb-1.5 block text-sm text-content-2">
{{ t('profile.nickname') }}
</label>
<input
id="nickname"
v-model="nickname"
v-bind="nicknameProps"
type="text"
maxlength="50"
:placeholder="t('profile.nicknamePlaceholder')"
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
:class="errors.nickname ? 'border-red-500' : 'border-line'"
/>
<p v-if="errors.nickname" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.nickname }}
</p>
</div>
<div class="mt-4">
<span class="mb-1.5 block text-sm text-content-2">{{ t('profile.gender') }}</span>
<div class="flex flex-wrap gap-2">
<label
v-for="option in genderOptions"
:key="option.value"
class="cursor-pointer"
>
<input
v-model="gender"
v-bind="genderProps"
type="radio"
:value="option.value"
class="peer sr-only"
/>
<span
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40"
>
{{ t(option.labelKey) }}
</span>
</label>
</div>
<p v-if="errors.gender" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.gender }}
</p>
</div>
<div class="mt-4">
<label for="birthday" class="mb-1.5 block text-sm text-content-2">
{{ t('profile.birthday') }}
</label>
<input
id="birthday"
v-model="birthday"
v-bind="birthdayProps"
type="date"
:max="today"
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors focus:border-primary sm:max-w-52"
:class="errors.birthday ? 'border-red-500' : 'border-line'"
/>
<p v-if="errors.birthday" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.birthday }}
</p>
</div>
<p
v-if="submitError"
class="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
>
{{ submitError }}
</p>
<p v-else-if="saved" class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
{{ t('profile.saveSuccess') }}
</p>
<button
type="submit"
:disabled="isSubmitting"
class="mt-6 h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto sm:px-8"
>
{{ isSubmitting ? t('common.submitting') : t('profile.save') }}
</button>
</form>
</div>
</div>
</template>
+4 -1
View File
@@ -18,7 +18,7 @@ import (
"rill/internal/usergroup"
)
// RegisterRoutes 注册 API 路由。health、swagger、auth 公开;notes 需登录;用户与用户组管理仅限管理员。
// RegisterRoutes 注册 API 路由。health、swagger、auth 公开;notes 与个人资料需登录;用户与用户组管理仅限管理员。
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
authn := auth.NewAuthenticator(cfg)
@@ -40,6 +40,9 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
authed := rg.Group("", authn.RequireAuth(db))
{
authed.GET("/me", auth.Me())
authed.PUT("/me", auth.UpdateMe(db))
notes := authed.Group("/notes")
{
notes.GET("", note.List(db))
+101
View File
@@ -49,6 +49,9 @@ func TestRegister(t *testing.T) {
if len(user.Groups) != 1 || user.Groups[0].ID != model.GroupIDUser {
t.Fatalf("注册用户组异常: %+v", user.Groups)
}
if user.Gender != "" || !user.Birthday.IsZero() {
t.Errorf("注册默认资料字段应为空: gender=%q birthday=%v", user.Gender, user.Birthday.Time)
}
w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{
"username": "alice2", "email": "alice2@example.com", "password": "secret123",
@@ -249,3 +252,101 @@ func TestAuthMiddleware(t *testing.T) {
t.Errorf("管理员访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
}
func TestProfile(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
registered := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
authed := env.Router(env.Sign(registered.ID))
if w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/me", nil); w.Code != http.StatusUnauthorized {
t.Errorf("匿名获取个人资料状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
w := testutil.Call(t, authed, http.MethodGet, "/api/me", nil)
if w.Code != http.StatusOK {
t.Fatalf("获取个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
me := testutil.DecodeUser(t, w)
if me.ID != registered.ID || me.Username != "alice" || me.Email != "alice@example.com" {
t.Fatalf("个人资料异常: %+v", me)
}
if len(me.Groups) != 1 || me.Groups[0].ID != model.GroupIDUser {
t.Errorf("个人资料用户组异常: %+v", me.Groups)
}
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"nickname": "Alice",
"gender": model.GenderMale,
"birthday": "1995-06-15",
})
if w.Code != http.StatusOK {
t.Fatalf("更新个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := testutil.DecodeUser(t, w)
if updated.Nickname != "Alice" || updated.Gender != model.GenderMale {
t.Errorf("更新结果异常: %+v", updated)
}
if updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("更新生日异常: %v", updated.Birthday.Time)
}
var stored model.User
if err := env.DB.First(&stored, registered.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.Nickname != "Alice" || stored.Gender != model.GenderMale || stored.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("数据库未更新: %+v", stored)
}
// 只传部分字段时其余字段保持不变,附带的管理员字段不得生效。
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"gender": model.GenderFemale,
"status": 0,
"group_ids": []uint{model.GroupIDAdmin},
})
if w.Code != http.StatusOK {
t.Fatalf("部分更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
partial := testutil.DecodeUser(t, w)
if partial.Gender != model.GenderFemale || partial.Nickname != "Alice" {
t.Errorf("部分更新结果异常: %+v", partial)
}
if partial.Birthday.IsZero() || partial.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("未提供 birthday 时不应修改: %v", partial.Birthday.Time)
}
if partial.Status != 1 || len(partial.Groups) != 1 || partial.Groups[0].ID != model.GroupIDUser {
t.Errorf("普通用户不得修改状态或用户组: %+v", partial)
}
cases := []struct {
name string
body map[string]any
}{
{"非法性别", map[string]any{"gender": "unknown"}},
{"生日格式非法", map[string]any{"birthday": "15-06-1995"}},
{"生日在未来", map[string]any{"birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
{"昵称超长", map[string]any{"nickname": strings.Repeat("a", 51)}},
}
for _, tc := range cases {
w := testutil.Call(t, authed, http.MethodPut, "/api/me", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{"nickname": "", "gender": "", "birthday": ""})
if w.Code != http.StatusOK {
t.Fatalf("清空个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
if cleared := testutil.DecodeUser(t, w); cleared.Nickname != "" || cleared.Gender != "" || !cleared.Birthday.IsZero() {
t.Errorf("清空失败: %+v", cleared)
}
var clearedStored model.User
if err := env.DB.First(&clearedStored, registered.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if clearedStored.Nickname != "" || clearedStored.Gender != "" || !clearedStored.Birthday.IsZero() {
t.Errorf("数据库未清空: %+v", clearedStored)
}
}
+102
View File
@@ -0,0 +1,102 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"rill/internal/httpx"
"rill/internal/user"
)
// UpdateProfileRequest 更新个人资料请求,仅更新请求中提供的字段;gender 空串表示清空。
type UpdateProfileRequest struct {
Nickname *string `json:"nickname" binding:"omitempty,max=50" example:"Alice"`
Gender *string `json:"gender" binding:"omitempty,len=0|oneof=male female other" example:"male"`
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
}
// @Summary Get current user profile
// @Description Return the authenticated user's profile, including groups.
// @Tags profile
// @Produce json
// @Success 200 {object} model.User
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "account disabled"
// @Router /me [get]
func Me() gin.HandlerFunc {
return func(c *gin.Context) {
current, ok := currentUser(c)
if !ok {
httpx.RespondUnauthorized(c)
return
}
c.JSON(http.StatusOK, current)
}
}
// @Summary Update current user profile
// @Description Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.
// @Tags profile
// @Accept json
// @Produce json
// @Param profile body auth.UpdateProfileRequest true "Fields to update"
// @Success 200 {object} model.User
// @Failure 400 {object} httpx.ErrorResponse "invalid request"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /me [put]
func UpdateMe(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
return
}
current, ok := currentUser(c)
if !ok {
httpx.RespondUnauthorized(c)
return
}
updates := make(map[string]any, 3)
if req.Nickname != nil {
updates["nickname"] = *req.Nickname
current.Nickname = *req.Nickname
}
if req.Gender != nil {
updates["gender"] = *req.Gender
current.Gender = *req.Gender
}
if req.Birthday != nil {
birthday, err := user.NormalizeBirthday(req.Birthday)
if err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
return
}
if birthday.IsZero() {
updates["birthday"] = nil
} else {
updates["birthday"] = birthday
}
current.Birthday = birthday
}
if len(updates) == 0 {
c.JSON(http.StatusOK, current)
return
}
ctx := c.Request.Context()
if err := db.WithContext(ctx).Model(&current).Updates(updates).Error; err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, current)
}
}
+60
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -100,6 +101,14 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
if admin.Nickname != "Administrator" {
t.Errorf("初始管理员昵称应为英文: %q", admin.Nickname)
}
for _, column := range []string{"gender", "birthday"} {
if !db.Migrator().HasColumn(&model.User{}, column) {
t.Errorf("users 表缺少列 %s", column)
}
}
if admin.Gender != "" || !admin.Birthday.IsZero() {
t.Errorf("初始管理员资料字段应为空: gender=%q birthday=%v", admin.Gender, admin.Birthday.Time)
}
if _, err := bcrypt.Cost([]byte(admin.PasswordHash)); err != nil {
t.Errorf("初始管理员密码哈希无效: %v", err)
}
@@ -180,6 +189,57 @@ func TestGeneratePassword(t *testing.T) {
}
}
func TestAddProfileFieldsMigration(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := Migrate(ctx, db); err != nil {
t.Fatalf("执行迁移失败: %v", err)
}
migrator := db.Migrator()
for _, column := range []string{"gender", "birthday"} {
if err := migrator.DropColumn(&model.User{}, column); err != nil {
t.Fatalf("删除列 %s 失败: %v", column, err)
}
}
// 用原始 SQL 模拟旧版本数据(当时表里还没有 gender/birthday 列)。
now := time.Now()
if err := db.WithContext(ctx).Exec(
"INSERT INTO users (username, email, password_hash, nickname, avatar, status, created_at, updated_at) VALUES (?, ?, ?, '', '', 1, ?, ?)",
"legacy", "legacy@example.com", "x", now, now,
).Error; err != nil {
t.Fatalf("创建存量用户失败: %v", err)
}
var addProfile Migration
for _, m := range migrations {
if m.Version == 6 {
addProfile = m
}
}
if addProfile.Up == nil {
t.Fatal("未找到 v6 迁移")
}
if err := addProfile.Up(db.WithContext(ctx)); err != nil {
t.Fatalf("执行 v6 迁移失败: %v", err)
}
for _, column := range []string{"gender", "birthday"} {
if !migrator.HasColumn(&model.User{}, column) {
t.Errorf("v6 未补齐列 %s", column)
}
}
var got model.User
if err := db.WithContext(ctx).Where("username = ?", "legacy").First(&got).Error; err != nil {
t.Fatalf("查询存量用户失败: %v", err)
}
if got.Gender != "" || !got.Birthday.IsZero() {
t.Errorf("存量用户资料字段应默认空: gender=%q birthday=%v", got.Gender, got.Birthday.Time)
}
}
func TestTranslateBuiltinDataMigration(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
+7
View File
@@ -83,6 +83,13 @@ var migrations = []Migration{
return nil
},
},
{
Version: 6,
Name: "add_user_profile_fields",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.User{})
},
},
}
// schemaMigration 记录已应用的迁移版本。
+81
View File
@@ -0,0 +1,81 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"time"
)
const dateLayout = "2006-01-02"
// Date 仅日期字段(YYYY-MM-DD),零值表示未设置,JSON 序列化为 null。
type Date struct {
time.Time
}
// IsZero 是否未设置。
func (d Date) IsZero() bool {
return d.Time.IsZero()
}
// Value 实现 driver.Valuer:零值写入 NULL。
func (d Date) Value() (driver.Value, error) {
if d.Time.IsZero() {
return nil, nil
}
return d.Time.Format(dateLayout), nil
}
// Scan 实现 sql.Scanner,兼容 time.Time、字符串与 []byte。
func (d *Date) Scan(value any) error {
switch v := value.(type) {
case nil:
d.Time = time.Time{}
case time.Time:
d.Time = v
case string:
return d.parse(v)
case []byte:
return d.parse(string(v))
default:
return fmt.Errorf("unsupported date value: %T", value)
}
return nil
}
func (d *Date) parse(value string) error {
if value == "" {
d.Time = time.Time{}
return nil
}
for _, layout := range []string{dateLayout, time.RFC3339} {
if parsed, err := time.Parse(layout, value); err == nil {
d.Time = parsed
return nil
}
}
return errors.New("invalid date: " + value)
}
// MarshalJSON 输出 YYYY-MM-DD,未设置时输出 null。
func (d Date) MarshalJSON() ([]byte, error) {
if d.Time.IsZero() {
return []byte("null"), nil
}
return json.Marshal(d.Time.Format(dateLayout))
}
// UnmarshalJSON 支持 null、空串与 YYYY-MM-DD。
func (d *Date) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
d.Time = time.Time{}
return nil
}
var value string
if err := json.Unmarshal(data, &value); err != nil {
return err
}
return d.parse(value)
}
+9
View File
@@ -6,6 +6,13 @@ import (
"gorm.io/gorm"
)
// 性别取值,空字符串表示未设置。
const (
GenderMale = "male"
GenderFemale = "female"
GenderOther = "other"
)
// User 用户,与用户组为多对多关系。
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -14,6 +21,8 @@ type User struct {
PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:50" json:"nickname"`
Avatar string `gorm:"size:255" json:"avatar"`
Gender string `gorm:"size:10;not null;default:''" json:"gender" example:"male"`
Birthday Date `gorm:"type:date" json:"birthday" swaggertype:"string" example:"1995-06-15"`
Status int8 `gorm:"not null;default:1" json:"status"`
Groups []UserGroup `gorm:"-" json:"groups"`
CreatedAt time.Time `json:"created_at"`
+55 -9
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -21,19 +22,23 @@ var ErrGroupsNotFound = errors.New("user group not found")
// CreateRequest 创建用户请求。
type CreateRequest struct {
Username string `json:"username" binding:"required,max=50" example:"alice"`
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
GroupIDs []uint `json:"group_ids" example:"1"`
Username string `json:"username" binding:"required,max=50" example:"alice"`
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"`
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
GroupIDs []uint `json:"group_ids" example:"1"`
}
// UpdateRequest 更新用户请求,仅更新请求中提供的字段。
type UpdateRequest struct {
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"`
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
Password string `json:"password" binding:"omitempty,min=6,max=72" example:"secret123"`
GroupIDs *[]uint `json:"group_ids" example:"1"`
@@ -94,7 +99,7 @@ func List(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Create a user
// @Description Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.
// @Description Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.
// @Tags users
// @Accept json
// @Produce json
@@ -121,6 +126,11 @@ func Create(db *gorm.DB) gin.HandlerFunc {
httpx.RespondServerError(c, err, "生成密码哈希失败")
return
}
birthday, err := NormalizeBirthday(req.Birthday)
if err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
return
}
status := int8(1)
if req.Status != nil {
@@ -147,6 +157,8 @@ func Create(db *gorm.DB) gin.HandlerFunc {
PasswordHash: string(hash),
Nickname: req.Nickname,
Avatar: req.Avatar,
Gender: req.Gender,
Birthday: birthday,
Status: status,
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -202,7 +214,7 @@ func Get(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Update a user
// @Description Update user fields. group_ids replaces all groups; a non-empty password resets the password.
// @Description Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.
// @Tags users
// @Accept json
// @Produce json
@@ -250,6 +262,21 @@ func Update(db *gorm.DB) gin.HandlerFunc {
updates := map[string]any{
"nickname": req.Nickname,
"avatar": req.Avatar,
"gender": req.Gender,
}
var birthday model.Date
if req.Birthday != nil {
value, err := NormalizeBirthday(req.Birthday)
if err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
return
}
birthday = value
if birthday.IsZero() {
updates["birthday"] = nil
} else {
updates["birthday"] = birthday
}
}
if req.Status != nil {
updates["status"] = *req.Status
@@ -285,6 +312,10 @@ func Update(db *gorm.DB) gin.HandlerFunc {
user.Nickname = req.Nickname
user.Avatar = req.Avatar
user.Gender = req.Gender
if req.Birthday != nil {
user.Birthday = birthday
}
if req.Status != nil {
user.Status = *req.Status
}
@@ -427,6 +458,21 @@ func ReplaceGroups(tx *gorm.DB, userID uint, groupIDs []uint) error {
return nil
}
// NormalizeBirthday 校验生日格式与范围,空串表示清空(返回零值)。
func NormalizeBirthday(value *string) (model.Date, error) {
if value == nil || *value == "" {
return model.Date{}, nil
}
birthday, err := time.Parse("2006-01-02", *value)
if err != nil {
return model.Date{}, errors.New("invalid birthday")
}
if birthday.After(time.Now()) {
return model.Date{}, errors.New("birthday cannot be in the future")
}
return model.Date{Time: birthday}, nil
}
// findGroups 查询用户组并校验全部存在。
func findGroups(ctx context.Context, db *gorm.DB, ids []uint) ([]model.UserGroup, error) {
unique := dedupeIDs(ids)
+81
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
@@ -158,3 +159,83 @@ func TestUserValidation(t *testing.T) {
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
}
}
func TestUserProfileFields(t *testing.T) {
env := testutil.Setup(t)
r := env.AdminRouter()
w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
"username": "dave",
"email": "dave@example.com",
"password": "secret123",
"gender": "male",
"birthday": "1995-06-15",
})
if w.Code != http.StatusCreated {
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
}
created := testutil.DecodeUser(t, w)
if created.Gender != model.GenderMale {
t.Errorf("gender = %q, 期望 %q", created.Gender, model.GenderMale)
}
if created.Birthday.IsZero() || created.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("birthday 异常: %v", created.Birthday.Time)
}
cases := []struct {
name string
body map[string]any
}{
{"非法性别", map[string]any{"username": "e1", "email": "e1@example.com", "password": "secret123", "gender": "unknown"}},
{"生日格式非法", map[string]any{"username": "e2", "email": "e2@example.com", "password": "secret123", "birthday": "15-06-1995"}},
{"生日在未来", map[string]any{"username": "e3", "email": "e3@example.com", "password": "secret123", "birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
}
for _, tc := range cases {
w := testutil.Call(t, r, http.MethodPost, "/api/users", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
detailPath := fmt.Sprintf("/api/users/%d", created.ID)
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{
"nickname": "Dave",
"gender": model.GenderFemale,
"birthday": "1990-01-01",
})
if w.Code != http.StatusOK {
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := testutil.DecodeUser(t, w)
if updated.Gender != model.GenderFemale || updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1990-01-01" {
t.Fatalf("更新结果异常: gender=%q birthday=%v", updated.Gender, updated.Birthday.Time)
}
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": model.GenderFemale})
if w.Code != http.StatusOK {
t.Fatalf("更新状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
kept := testutil.DecodeUser(t, w)
if kept.Birthday.IsZero() {
t.Error("未提供 birthday 时不应变为 null")
} else if got := kept.Birthday.Format("2006-01-02"); got != "1990-01-01" {
t.Errorf("未提供 birthday 不应修改: %q", got)
}
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": "", "birthday": ""})
if w.Code != http.StatusOK {
t.Fatalf("清空状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
if cleared := testutil.DecodeUser(t, w); cleared.Gender != "" || !cleared.Birthday.IsZero() {
t.Errorf("清空失败: gender=%q birthday=%v", cleared.Gender, cleared.Birthday.Time)
}
var stored model.User
if err := env.DB.First(&stored, created.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.Gender != "" || !stored.Birthday.IsZero() {
t.Errorf("数据库未清空: gender=%q birthday=%v", stored.Gender, stored.Birthday.Time)
}
}
+9 -1
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
@@ -95,6 +96,7 @@ func main() {
// 静态文件服务
fs := http.FileServer(http.Dir(cfg.Static.Dir))
indexFile := filepath.Join(cfg.Static.Dir, "index.html")
// 中间件处理路由
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, cfg.API.Prefix) {
@@ -102,7 +104,13 @@ func main() {
return
}
// 处理静态文件
// 处理静态文件;未命中的无扩展名路径回退到 index.html,支持前端 history 路由
cleanPath := filepath.Clean(c.Request.URL.Path)
if _, err := os.Stat(filepath.Join(cfg.Static.Dir, cleanPath)); err != nil && filepath.Ext(cleanPath) == "" {
c.File(indexFile)
c.Abort()
return
}
fs.ServeHTTP(c.Writer, c.Request)
c.Abort()
})