需求分析与技术选型

微光电子书屋需求分析

本系统需要用户管理,电子书管理,电子书阅读三个基础功能。

  1. 用户管理
    注册登录
    角色权限
  • 管理员
  • 普通用户
  • VIP
  1. 电子书管理
    书籍信息
    书架管理
    管理员书籍上传

  2. 电子书阅读
    PDF阅读
    EPUB阅读

微光电子书屋技术选型

前端:Vue3 + Vite + Element Plus
接口文档:springdoc
后端:Spring Boot + MySQL + MyBatis-Plus

数据库

用户表-users

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
`userName` varchar(256) NULL COMMENT '用户昵称',
`userAccount` varchar(256) NULL COMMENT '账号',
`avatarUrl` varchar(1024) NULL COMMENT '用户头像',
`gender` tinyint NULL COMMENT '性别',
`userPassword` varchar(512) NOT NULL COMMENT '密码',
`userStatus` int DEFAULT '0' NULL COMMENT '状态 0-正常',
`createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '创建时间',
`updateTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除',
`userRole` tinyint DEFAULT '0' NULL COMMENT '用户角色 0-普通用户 1-管理员 2-VIP',
UNIQUE KEY `uk_user_account` (`userAccount`),
PRIMARY KEY (`id`)
) COMMENT='用户表';

书籍信息表-books

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DROP TABLE IF EXISTS `books`;
CREATE TABLE `books` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
`title` VARCHAR(255) NOT NULL COMMENT '书名',
`author` VARCHAR(100) NOT NULL COMMENT '作者',
`isbn` VARCHAR(20) UNIQUE COMMENT 'ISBN号(可选)',
`coverUrl` VARCHAR(255) DEFAULT NULL COMMENT '封面图路径',
`description` TEXT COMMENT '简介',
`filePath` VARCHAR(255) NOT NULL COMMENT 'EPUB/PDF文件在服务器的相对路径',
`filePize` BIGINT DEFAULT '0' COMMENT '文件大小(字节)',
format ENUM('epub', 'pdf') NOT NULL DEFAULT 'epub' COMMENT '格式',
`bookStatus` TINYINT DEFAULT '1' COMMENT '状态:1=正常,0=下架',
`createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '创建时间',
`updateTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) COMMENT='书籍信息表';

用户书架-user_bookshelves

1
2
3
4
5
6
7
8
9
10
11
12
DROP TABLE IF EXISTS `user_bookshelf`;
CREATE TABLE `user_bookshelf` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`userId` bigint NOT NULL COMMENT '用户ID',
`bookId` bigint NOT NULL COMMENT '书籍ID',
`createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '加入书架时间',
`isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除:0-未删除 1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_book` (`userId`, `bookId`),
INDEX `idx_userId` (`userId`),
INDEX `idx_bookId` (`bookId`)
) COMMENT='用户书架表';

功能开发中。。。
阅读进度表-reading_progresses

1
2
3
4
5
6
7
8
9
10
11
12
13
DROP TABLE IF EXISTS `reading_progress`;
CREATE TABLE `reading_progress` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`userId` bigint NOT NULL COMMENT '用户ID',
`bookId` bigint NOT NULL COMMENT '书籍ID',
`currentLocation` varchar(1024) NOT NULL COMMENT '当前位置(EPUB CFI 或 PDF 页码)',
`lastReadTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '最后阅读时间',
`isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除:0-未删除 1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_book_progress` (`userId`, `bookId`),
INDEX `idx_userId` (`userId`),
INDEX `idx_bookId` (`bookId`)
) COMMENT='阅读进度表';

前端

初始化

npm create vue@latest
选择功能:
Typescript + Router + Pinia + Eslint + Prettier
引入组件库 Element Plus 2.11.9
npm install element-plus --save
完整引入 main.ts

1
2
3
4
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// size 设置表单组件的默认尺寸,zIndex 设置弹出组件的层级,默认值2000
app.use(ElementPlus,{ size: 'small', zIndex: 3000 })

配置vite代理

vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export default defineConfig({
plugins: [
vue(),
vueDevTools(), // 热更新
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
port: 3000,
proxy: {
// 代理到 Spring Boot 后端
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false, //允许自签名证书(开发用)
//前端请求的 /api/book/upload 改写成 → /book/upload 所以不需要
// rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
// 书籍详情相关定义环境变量
define: {
'import.meta.env.VITE_API_BASE': JSON.stringify(process.env.VITE_API_BASE || 'http://localhost:8080')
}
})

创建全局布局

在src目录创建layouts/CommonLayout.vue
引入布局模块并修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div class="common-layout">
<el-container>
<EHeader/>
<el-main class="main">
<router-view/>
</el-main>
<el-footer class="footer">微光电子书屋</el-footer>
</el-container>
</div>
</template>

<script lang="ts" setup>

import EHeader from "@/components/EHeader.vue";
</script>

<style scoped>
.footer {
text-align: center;
padding: 16px;
background-color: #f5f7fa;
}
</style>

安装 Axios

npm install axios
通过Axios文档CV过来
创建请求配置文件/src/request.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import axios from "axios";

alert(process.env.NODE_ENV);
// 创建axios实例(文档中有)
const myAxios = axios.create({
// 区分开发和线上环境
baseURL:
process.env.NODE_ENV === "development"
? "http://localhost:8080"
: "https://wzcwzc10.github.io",
timeout: 10000,
withCredentials: true, // 允许携带cookie
});

// 请求拦截器
myAxios.interceptors.request.use(
function (config) {
return config;
},
function (error) {
return Promise.reject(error);
}
);

// 响应拦截器
myAxios.interceptors.response.use(
function (response) {
console.log(response);

const { data } = response;
console.log(data);
// 未登录
if (data.code === 40100) {
// 不是获取用户信息接口,且不是登录页面,则跳转到登录页面
if (
!response.request.responseURL.includes("user/current") &&
!window.location.pathname.includes("/user/login")
) {
window.location.href = `/user/login?redirect=${window.location.href}`;
}
}
return response;
},
function (error) {
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
alert('请求超时,请稍后重试');
} else if (error.response?.status === 500) {
alert('服务器开小差了~');
}
return Promise.reject(error);
}
);
// 导出自定义的axios实例 myAxios
export default myAxios;

创建用户状态管理

前后端联动
遇到端口冲突:–port 3000
useLoginUserStore.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { defineStore } from "pinia";
import { ref } from "vue";
import { getCurrentUser } from "@/api/user";

// 定义全局状态管理
export const useLoginUserStore = defineStore("loginUser", () => {
const loginUser = ref<any>({
username: "未登录",
});

// 远程获取登录用户信息
async function fetchLoginUser() {
const res = await getCurrentUser();
if (res.data.code === 0 && res.data.data) {
loginUser.value = res.data.data;
}
}

// 单独设置信息
function setLoginUser(newLoginUser: any) {
loginUser.value = newLoginUser;
}

// 清空用户(用于登出)
function clearLoginUser() {
loginUser.value = { username: "未登录" };
}

// 更新用户信息
async function updateLoginUser() {
const res = await getCurrentUser();
if (res.data.code === 0 && res.data.data) {
loginUser.value = res.data.data;
}
}

return { loginUser, fetchLoginUser, setLoginUser, clearLoginUser };
});

新建src/api/user.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 引入自定义的axios实例
import myAxios from "@/request";

/**
* 用户注册
* @param params
*/
// 导出用户注册接口,async 是异步函数
export const userRegister = async (params: any) => {
return myAxios.request({
url: "/api/user/register",
method: "POST",
data: params,
});
};

/**
* 用户登录
* @param params
*/
export const userLogin = async (params: any) => {
return myAxios.request({
url: "/api/user/login",
method: "POST",
data: params,
});
};

/**
* 用户注销
* @param params
*/
export const userLogout = async (params: any) => {
return myAxios.request({
url: "/api/user/logout",
method: "POST",
data: params,
});
};

/**
* 更新用户信息
* @param params
*/
export const updateUser = async (params: any) => {
return myAxios.request({
url: "/api/user/update",
method: "POST",
data: params,
});
};

/**
* 获取当前用户
*/
export const getCurrentUser = async () => {
return myAxios.request({
url: "/api/user/current",
method: "GET",
});
};

/**
* 获取用户列表
* @param username
*/
export const searchUsers = async (username: any) => {
return myAxios.request({
url: "/api/user/search",
method: "GET",
params: {
username,
},
});
};

/**
* 删除用户
* @param id
*/
export const deleteUser = async (id: string) => {
return myAxios.request({
url: "/api/user/delete",
method: "POST",
data: id,
// 关键点:要传递 JSON 格式的值
headers: {
"Content-Type": "application/json",
},
});
};

路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from "@/views/HomeView.vue";
import UserLoginView from "@/views/UserLoginView.vue";
import UserRegisterView from "@/views/UserRegisterView.vue";
import AboutView from "@/views/AboutView.vue";
import MyBookView from "@/views/MyBookView.vue";
import UserView from "@/views/UserView.vue";
import BookDetailView from "@/views/BookDetailView.vue";
import BookReaderView from "@/views/BookReaderView.vue";
import UploadView from "@/views/UploadView.vue";
import HelpView from "@/views/HelpView.vue";

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
//() => import
{ path: '/', name: 'home', component: HomeView },
{ path: '/about', name: 'about', component: AboutView },
{ path: '/help', name: 'help', component: HelpView },
{ path: "/user/login", name: "userLogin", component: UserLoginView },
{ path: "/user/register", name: "userRegister", component: UserRegisterView },
{ path: "/bookshelf", name: "bookshelf", component: MyBookView },
{ path: '/user', name: 'user', component: UserView},
{ path: '/book/:id', name: 'bookDetail', component: BookDetailView},
{ path: '/book/reader:id', name: 'bookReader', component: BookReaderView },
{ path: '/upload', name: 'upload', component: UploadView },
],
})
export default router

电子书阅读功能

集成 EPUB 阅读器(推荐 epub.js)

1
2
3
4
5
npm install epubjs
# 如果用 TypeScript,加类型(可选)
npm install --save-dev @types/epubjs
# PDF 阅读器(目前使用原生)
npm install pdfjs-dist

页面

全局页面组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getCurrentUser } from '@/api/user';

const currentUser = ref<{
id: number;
userName: string;
userAccount: string;
avatarUrl: string;
gender: number;
userRole: number;
} | null>(null);

const loading = ref<boolean>(true);

onMounted(async () => {
try {
const response = await getCurrentUser();
// 假设 code === 0 表示成功
if (response.data.code === 0) {
currentUser.value = response.data.data; // 提取 data 中的用户对象
} else {
console.error('获取用户失败:', response.data.message);
}
} catch (error) {
console.error('请求异常:', error);
} finally {
loading.value = false;
}
});
</script>
<template>
<div v-if="loading" style="margin-top: 20px">
加载中...
</div>
<el-descriptions
v-else
title="微光名片"
direction="vertical"
border
style="margin-top: 20px; max-width: 500px"
>
<el-descriptions-item
:rowspan="2"
:width="140"
label="头像"
align="center"
>
<el-image
style="width: 100px; height: 100px; border-radius: 50%"
:src="currentUser?.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'"
fit="cover"
alt="用户头像"
/>
</el-descriptions-item>
<el-descriptions-item label="账号">{{ currentUser?.userAccount || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ currentUser?.userName || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="性别">
{{currentUser?.gender === 1 ? '男' : currentUser?.gender === 0 ? '女' : '未知' }}
</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag size="small">
{{currentUser?.userRole === 0 ? '普通用户' : currentUser?.userRole === 1 ? '管理员' : currentUser?.userRole === 2 ? 'VIP':'未知'}}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="ID">{{ currentUser?.id || '无' }}</el-descriptions-item>
</el-descriptions>
</template>

<style scoped>
/* 可选:居中或美化 */
.el-descriptions {
max-width: 500px;
margin: 0 auto;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
<script setup lang="ts">
import {Search} from "@element-plus/icons-vue";
import {computed, onMounted, reactive, ref, toRefs} from "vue";
import {useRouter} from "vue-router";
import {userLogout} from "@/api/user.ts";
import {LoginOut} from "@/components/LoginOut.js";
import {useLoginUserStore} from "@/stores/userLoginUserStore.ts";
import {ElMessage} from "element-plus";

const router = useRouter() // 获取 router 实例
const activeIndex = ref('1')
const input = ref('')
const { logout } = LoginOut(); // 引入全局登出
const avatarUrl = ref('')

// 获取用户状态
const userStore = useLoginUserStore();
// 判断是否已登录,选项隐藏相关
const isLogin = computed(() => {
return userStore.loginUser?.username !== '未登录';
});
const handleSelect = async(key: string, keyPath: string[]) => {
console.log(key, keyPath)
switch (key) {
case '0':
router.push('/')
break
case '1-1':
router.push('/user/login')
break
case '1-2':
router.push('/user')
break
case '1-4':
await logout();
break
case '2':
router.push('/bookshelf')
break
case '3':
router.push('/help')
break
case '4':
router.push('/about')
break
case '5':
router.push('/upload')
break
}
}
// 初始化表单(从 Pinia store 读取当前用户)
onMounted(async() => {
// 刷新后消失问题解决
await userStore.fetchLoginUser();
const user = userStore.loginUser;
if ( user.username === '未登录') {
ElMessage.warning('用户未登录');
return;
}
avatarUrl.value = user.avatarUrl || '';
});

// 头像
const circleUrl = computed(() => {
const user = userStore.loginUser;
return user.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png';
});

</script>

<template>
<el-header height="90px" class="header">
<el-menu
:default-active="activeIndex"
mode="horizontal"
background-color="#ffffff"
text-color="#606266"
active-text-color="#409EFF"
:ellipsis="false"
@select="handleSelect"
class="main-menu"
>
<!-- Logo 区域 -->
<el-menu-item index="0" class="logo-item">
<img src="@/assets/images/logo.png" alt="logo" class="logo-img" />
<span class="logo-text">
<el-text type="success" size="large">微光</el-text>
<el-text type="primary" size="large">电子书屋</el-text>
</span>
</el-menu-item>
<div class="menu-spacer"></div>
<!-- 搜索输入框 -->
<el-menu-item class="search-item">
<el-input size="large"
v-model="input"
placeholder="请输入书名"
:suffix-icon="Search"
class="search-input"
/>
</el-menu-item>
<!-- 留空 -->
<div class="menu-spacer"></div>
<!-- 导航菜单 -->
<el-avatar :size="50" :src="circleUrl" v-if="isLogin" />
<el-sub-menu index="1" v-if="isLogin">
<template #title>个人</template>
<el-menu-item index="1-2">个人信息</el-menu-item>
<el-menu-item index="1-3">VIP</el-menu-item>
<el-menu-item index="1-4">退出登录</el-menu-item>
</el-sub-menu>
<el-menu-item index="1-1" v-if="!isLogin">登录</el-menu-item>
<el-menu-item index="2" v-if="isLogin">我的书架</el-menu-item>
<el-menu-item index="3" v-if="isLogin">帮助</el-menu-item>
<el-menu-item index="4">关于</el-menu-item>
<el-menu-item index="5" v-if="isLogin">上传</el-menu-item>
</el-menu>
</el-header>
</template>

<style scoped>
.header {
padding: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); /* 可选:增加一点阴影提升层次感 */
}

.main-menu {
border: none;
display: flex;
align-items: center;
height: 100%;
}

.logo-item {
display: flex;
align-items: center;
gap: 8px;
padding-left: 20px !important;
}

.logo-img {
width: 42px;
height: auto;
}

.logo-text {
display: flex;
align-items: center;
gap: 4px;
}

/* 搜索框区域 */
.search-item {
padding: 0 20px !important;
}

.search-input {
width: 260px;
}

/* 在 logo 和 搜索之间撑开空间 */
.menu-spacer {
flex: 1;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
<template>
<el-scrollbar
height="1200px"
width="100%"
@end-reached="handleLoadMore"
:always="false"
style="overflow-x: hidden"
>
<el-row :gutter="30" class="card-container">
<el-col
v-for="colIndex in 3"
:key="colIndex"
:span="8"
class="column"
>
<div
v-for="book in columns[colIndex - 1]"
:key="book.id"
class="card-item"
@click="goToDetail(book.id)"
>
<el-card shadow="hover" class="custom-card">
<div class="book-cover">
<img
v-if="book.coverUrl"
:src="book.coverUrl.startsWith('http') ? book.coverUrl : 'http://localhost:8080' + book.coverUrl"
alt="封面"
@error="setDefaultCover"
/>
<el-icon v-else size="40" color="#ccc">
<Document />
</el-icon>
</div>
<div class="book-info">
<div class="book-title">{{ book.title }}</div>
<div class="book-author">作者:{{ book.author }}</div>
</div>
</el-card>
</div>
</el-col>
</el-row>

<div v-if="loading && books.length > 0" class="loading-tip">
加载中...
</div>

<div v-if="!loading && books.length === 0" class="no-data">
暂无书籍
</div>
</el-scrollbar>
</template>

<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import router from '@/router'
import myAxios from "@/request.ts";

// 数据
const books = ref<any[]>([])
const loading = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = 12

// 三列布局
const columns = computed(() => {
const cols: any[][] = [[], [], []]
books.value.forEach((book, index) => {
cols[index % 3].push(book)
})
return cols
})

// 获取书籍列表
const fetchBooks = async (page: number, append = false) => {
if (loading.value || !hasMore.value) return
loading.value = true

try {
const res = await myAxios.get('/api/book/list', {
params: {
current: page,
pageSize: pageSize,
// keyword: '' // 后续可加搜索
}
})

// 根据你的 BaseResponse 结构:{ code: 0, data: [...] }
if (res.data.code !== 0) {
throw new Error(res.data.message || '请求失败')
}

const list = res.data.data || []

if (append) {
books.value.push(...list)
} else {
books.value = list
}

hasMore.value = list.length === pageSize
currentPage.value = page
} catch (err: any) {
console.error('加载书籍失败:', err)
// 错误已在 request.ts 拦截器中处理(如 500、超时等),这里可不重复提示
hasMore.value = false
} finally {
loading.value = false
}
}

// 加载更多
const handleLoadMore = (direction: string) => {
if (direction === 'bottom') {
fetchBooks(currentPage.value + 1, true)
}
}

// 默认封面
const setDefaultCover = (e: Event) => {
const img = e.target as HTMLImageElement
img.src = '/default-cover.png' // 放在 public/default-cover.png
}

// 跳转详情
const goToDetail = (bookId: number) => {
router.push(`/book/${bookId}`)
}

// 初始加载
onMounted(() => {
fetchBooks(1)
})
</script>

<style scoped>
.card-container {
padding: 20px;
}

.column {
display: flex;
flex-direction: column;
gap: 16px;
}

.custom-card {
width: 80%;
min-height: 280px;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s;
}

.custom-card:hover {
transform: translateY(-4px);
}

.book-cover {
width: 100%;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
border-radius: 8px;
overflow: hidden;
}

.book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}

.book-info {
margin-top: 12px;
text-align: center;
}

.book-title {
font-weight: bold;
font-size: 16px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.book-author {
font-size: 14px;
color: #666;
margin-top: 6px;
}

.loading-tip,
.no-data {
text-align: center;
padding: 20px;
color: var(--el-color-info);
}
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<el-upload
class="avatar-uploader"
action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<img v-if="imageUrl" :src="imageUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'

import type { UploadProps } from 'element-plus'

const imageUrl = ref('')

const handleAvatarSuccess: UploadProps['onSuccess'] = (
response,
uploadFile
) => {
imageUrl.value = URL.createObjectURL(uploadFile.raw!)
}

const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (rawFile.type !== 'image/jpeg') {
ElMessage.error('Avatar picture must be JPG format!')
return false
} else if (rawFile.size / 1024 / 1024 > 2) {
ElMessage.error('Avatar picture size can not exceed 2MB!')
return false
}
return true
}
</script>

<style scoped>
.avatar-uploader .avatar {
width: 178px;
height: 178px;
display: block;
}
</style>

<style>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
}
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export function LoginOut() {
const router = useRouter();
const userStore = useLoginUserStore();

/**
* 全局登出函数
*/
const logout = async () => {
try {
// 1. 调用后端登出接口
await userLogout({});

// 2. 清空前端用户状态
userStore.clearLoginUser();

// 3. 提示 + 跳转
ElMessage.success('已退出登录');
router.push('/user/login');
} catch (error: any) {
console.error('登出失败:', error);
const msg = error?.response?.data?.message || '登出失败,请重试';
ElMessage.error(msg);
}
};

return {
logout,
};
}

页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
<div class="home">
<el-container>
<el-header>
<ECarousel/>
</el-header>
<el-main>
<ECord/>
</el-main>
</el-container>
</div>
</template>

<style scoped>
.home {
display: flex;
justify-content: center;
align-items: stretch;
min-height: 75vh;
background: linear-gradient(0deg, rgba(144, 255, 0, 0.5), rgb(255, 255, 255));
}
.el-header {
text-align: center;
background: rgb(255, 255, 255);
}
.el-main {
padding: 150px;
}
</style>
<script setup lang="ts">
import ECarousel from "@/components/ECarousel.vue";
import ECord from "@/components/ECord.vue";
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
<template>
<div class="profile-layout">
<!-- 左侧:设置面板 -->
<div class="settings-panel">
<el-card class="settings-card">
<template #header>
<div class="card-header">
<span>
<div style="display: flex; align-items: center; gap: 8px;">
当前账号
<el-avatar :src="avatarUrl || undefined" fallback-icon="UserFilled" />
</div>
</span>
</div>
</template>

<el-collapse v-model="activeNames" @change="handleChange">
<!-- 修改信息 -->
<el-collapse-item title="修改信息" name="1">
<div class="form-item">
<label>头像</label>
<div style="display: flex; gap: 8px; align-items: center;">
<el-input v-model="avatarUrl" style="width: 240px" placeholder="请输入头像网址" />
<el-button type="success" :icon="Check" circle @click="saveField('avatarUrl')" />
</div>
</div>

<div class="form-item">
<label>昵称</label>
<div style="display: flex; gap: 8px; align-items: center;">
<el-input v-model="userName" style="width: 240px" placeholder="请输入昵称" />
<el-button type="success" :icon="Check" circle @click="saveField('userName')" />
</div>
</div>

<div class="form-item">
<label>性别</label>
<div style="display: flex; gap: 8px; align-items: center;">
<el-select v-model="gender" placeholder="请选择" style="width: 240px">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="success" :icon="Check" circle @click="saveField('gender')" :loading="loading.gender"/>
</div>
</div>
</el-collapse-item>

<!-- 获取VIP -->
<el-collapse-item title="获取VIP" name="2">
<div class="payment-method">
<p>请选择支付方式:</p>
<el-button type="primary" plain style="margin-right: 12px;">微信支付</el-button>
<el-button type="primary" plain>支付宝</el-button>
</div>
</el-collapse-item>
</el-collapse>

<template #footer>微光电子书屋</template>
</el-card>
</div>

<!-- 右侧:名片组件 -->
<div class="business-card-wrapper">
<BusinessCard />
</div>
</div>
</template>

<script lang="ts" setup>
import {onMounted, ref, watch} from 'vue'
import { Check } from '@element-plus/icons-vue'
import BusinessCard from "@/components/BusinessCard.vue"
import {useLoginUserStore} from "@/stores/userLoginUserStore.ts";
import {ElMessage} from "element-plus";
import {updateUser} from "@/api/user.ts";

// 获取当前登录用户状态(用于初始化表单)
const userStore = useLoginUserStore();
const activeNames = ref(['1'])
const loading = ref<{ [key: string]: boolean }>({});
const handleChange = (val: string | string[]) => {
console.log('当前展开项:', val)
}

const options = [
{ value: 1, label: '男' },
{ value: 0, label: '女' },
{ value: 2, label: '保密' },
]

// 初始化表单(从 Pinia store 读取当前用户)
onMounted(async() => {
// 刷新后消失问题解决
await userStore.fetchLoginUser();
const user = userStore.loginUser;
if ( user.username === '未登录') {
ElMessage.warning('用户未登录');
return;
}
avatarUrl.value = user.avatarUrl || '';
userName.value = user.userName || '';
gender.value = user.gender || '';
});

const avatarUrl = ref('')
const userName = ref('')
const gender = ref('')

// 保存单个字段
const saveField = async (field: 'avatarUrl' | 'userName' | 'gender') => {
// 防重复点击
if (loading.value[field]) return;

let data: any = {};
if (field === 'avatarUrl') {
if (!avatarUrl.value.trim()) {
ElMessage.warning('请输入头像链接');
return;
}
data.avatarUrl = avatarUrl.value;
} else if (field === 'userName') {
if (!userName.value.trim()) {
ElMessage.warning('昵称不能为空');
return;
}
data.userName = userName.value;
} else if (field === 'gender') {
data.gender = gender.value;
}

try {
loading.value[field] = true;
const res = await updateUser(data);

if (res.data.code === 0) {
ElMessage.success('保存成功');

// ✅ 同步更新 Pinia store(让 BusinessCard 立即刷新)
userStore.setLoginUser({
...userStore.loginUser,
...data,
});
} else {
ElMessage.error(res.data.message || '保存失败');
}
} catch (error: any) {
console.error('更新失败:', error);
ElMessage.error('网络错误,请重试');
} finally {
loading.value[field] = false;
}
};

// watch(gender, (newVal, oldVal) => {
// if (newVal !== oldVal) { // 避免初始化时触发
// saveField('gender');
// }
// });
</script>

<style scoped>
.profile-layout {
display: flex;
height: 75vh;
width: 90vw;
overflow: hidden;
}

.settings-panel {
flex: 1;
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start; /* 防止卡片垂直居中,保持顶部对齐 */
overflow-y: auto;
}

.settings-card {
max-width: 480px;
width: 100%;
}

.business-card-wrapper {
flex: 1 1 200px; /* 固定宽度 300px,可根据需要调整 */
background-color: #ffffff;
border-left: 1px solid rgb(255, 255, 255);
overflow-y: hidden;
}

/* 表单项样式保留 */
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 6px;
font-weight: bold;
}
.payment-method {
padding: 8px 0;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<template>
<div class="form-container">
<el-form
ref="loginFormRef"
style="max-width: 400px; width: 100%; padding: 24px"
:model="loginForm"
status-icon
:rules="loginRules"
label-width="auto"
class="demo-loginForm"
>
<h2 style="text-align: center; margin-bottom: 24px">用户登录</h2>
<el-form-item label="账号" prop="user">
<el-input v-model.number="loginForm.userAccount" placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码" prop="pass">
<el-input v-model="loginForm.userPassword" type="password" autocomplete="off" placeholder="请输入密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitLogin(loginFormRef)" style="width: 100%">
登录
</el-button>
<el-button link type="primary" style="width: 100%; margin-top: 12px" @click="goToRegister">
没有账号?去注册
</el-button>
</el-form-item>
</el-form>
</div>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useRouter } from 'vue-router'
import {userLogin} from "@/api/user.ts";
import {useLoginUserStore} from "@/stores/userLoginUserStore.ts";
import {ElMessage} from "element-plus";

const loginFormRef = ref<FormInstance>()

const loginForm = reactive({
userAccount: '',
userPassword: ''
})

const validateUser = (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请输入账号'))
} else if (!/^[a-zA-Z0-9]+$/.test(value)) {
callback(new Error('账号只能包含字母和数字'));
} else {
callback()
}
}

const validatePass = (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请输入密码'))
} else {
callback()
}
}

const loginRules = reactive<FormRules<typeof loginForm>>({
userAccount: [{ validator: validateUser, trigger: 'blur' }],
userPassword: [{ validator: validatePass, trigger: 'blur' }]
})

const submitLogin = async (formEl: FormInstance | undefined) => {
if (!formEl) return;

const valid = await formEl.validate(); // 使用 Promise 风格
if (valid) {
try {
const res = await userLogin({
userAccount: loginForm.userAccount, // 注意:后端字段名是否为 username?
userPassword: loginForm.userPassword,
});

if (res.data?.code === 0) {
// 登录成功:更新全局用户状态
const store = useLoginUserStore();
store.setLoginUser(res.data.data); // 或根据实际返回结构调整

ElMessage.success('登录成功!');

// 跳转到首页(根据你的路由配置)
router.push('/'); // 或 '/dashboard' 等
} else {
ElMessage.error(res.data?.message || '登录失败,请检查账号或密码');
}
} catch (error) {
ElMessage.error('网络错误或服务器异常');
console.error('登录请求失败:', error);
}
}
};

// 跳转到注册页(使用 Vue Router)
const router = useRouter()
const goToRegister = () => {
router.push('/user/register') // 根据路由配置调整
}
</script>

<style scoped>
.form-container {
display: flex;
justify-content: center;
align-items: stretch;
min-height: 75vh;
background-color: rgb(255, 255, 255);
}
</style>

后端

初始化

Spring Boot 3.5.8
选择功能:
Lombok + Spring Web + Spring Boot DevTools + Spring Configuration Processor + MySQL Driver + MyBatis Framework
引入 MyBatis-Plus

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.14</version>
</dependency>

引入 commons-lang3 依赖

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>

配置application.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
spring:
application:
name: wg-Ebook

datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/wgebook
username: root
password: 123456

servlet:
multipart:
enabled: true
max-file-size: 100MB # 单个文件最大 100MB
max-request-size: 100MB # 整个请求最大 100MB

server:
port: 8080
servlet:
session:
timeout: 1800s # 单位:秒,例如 1800s = 30 分钟

# MyBatisX自动命名类似映射的冲突解决
mybatis-plus:
configuration:
map-underscore-to-camel-case: false

接口文档

1
2
3
4
5
6
<!-- 显式引入最新 springdoc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.14</version> <!-- 最新稳定版 -->
</dependency>

http://localhost:8080/swagger-ui.html

主类

common 模块

创建 common 模块,用于存放通用类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@RestController
@RequestMapping("/api/progress")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class ReadingProgressController {

@Resource
private ReadingProgressService readingProgressService;

/**
* 保存或更新阅读进度(当前用户)
*/
@PostMapping("/save")
public BaseResponse<Boolean> saveProgress(@RequestBody ReadingProgress progress, HttpServletRequest request) {
if (progress == null || progress.getBookId() == null || progress.getBookId() <= 0
|| progress.getCurrentLocation() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);
progress.setUserId(currentUserId);
progress.setLastReadTime(new Date());
progress.setIsDelete(CommonConstant.NOT_DELETED);

// 查找是否已有记录
ReadingProgress existing = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getBookId, progress.getBookId())
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.one();

boolean result;
if (existing != null) {
// 更新
existing.setCurrentLocation(progress.getCurrentLocation());
existing.setLastReadTime(progress.getLastReadTime());
result = readingProgressService.updateById(existing);
} else {
// 新增
result = readingProgressService.save(progress);
}

return ResultUtils.success(result);
}

/**
* 获取某本书的阅读进度(当前用户)
*/
@GetMapping("/get")
public BaseResponse<ReadingProgress> getProgress(Long bookId, HttpServletRequest request) {
if (bookId == null || bookId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);

ReadingProgress progress = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getBookId, bookId)
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.one();

return ResultUtils.success(progress); // 可能为 null,前端需处理
}

/**
* 获取当前用户所有阅读进度
*/
@GetMapping("/list")
public BaseResponse<List<ReadingProgress>> listProgress(HttpServletRequest request) {
Long currentUserId = getCurrentUserId(request);

List<ReadingProgress> list = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.list();

return ResultUtils.success(list);
}

// --- 工具方法 ---

private Long getCurrentUserId(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
if (userObj == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
try {
fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj;
return user.getId();
} catch (Exception e) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 错误码
*/
public enum ErrorCode {

SUCCESS(0, "ok", ""),
PARAMS_ERROR(40000, "请求参数错误", ""),
NULL_ERROR(40001, "请求数据为空", ""),
NOT_LOGIN(40100, "未登录", ""),
NO_AUTH(40101, "无权限", ""),
SYSTEM_ERROR(50000, "系统内部异常", "");
// BooksController 错误码

private final int code;

/**
* 状态码信息
*/
private final String message;

/**
* 状态码描述(详情)
*/
private final String description;

ErrorCode(int code, String message, String description) {
this.code = code;
this.message = message;
this.description = description;
}

public int getCode() {
return code;
}

public String getMessage() {
return message;
}


public String getDescription() {
return description;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 返回工具类
*/
public class ResultUtils {

/**
* 成功
* @param data 数据
* @param <T> 泛型
* @return BaseResponse
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}

/**
* 失败
* @param errorCode
* @return
*/
public static BaseResponse error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}

/**
* 失败
* @param code 错误码
* @param message 错误信息
* @param description 错误描述
* @return BaseResponse
*/
public static BaseResponse error(int code, String message, String description) {
return new BaseResponse(code, null, message, description);
}

/**
* 失败
* @param errorCode 错误码
* @return BaseResponse
*/
public static BaseResponse error(ErrorCode errorCode, String message, String description) {
return new BaseResponse(errorCode.getCode(), null, message, description);
}

/**
* 失败
* @param errorCode 错误码
* @return BaseResponse
*/
public static BaseResponse error(ErrorCode errorCode, String description) {
return new BaseResponse(errorCode.getCode(), errorCode.getMessage(), description);
}
}

contant 模块

创建 contant 模块,用于存放常量类。

1
2
3
4
5
6
7
8
/**
* 通用常量 是否删除
* @author wgzc
*/
public interface CommonConstant {
int NOT_DELETED = 0; // 未删除
int DELETED = 1; // 已删除
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 用户常量
* Constant
*/
public interface UserConstant {

/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "userLoginState";

// ------- 权限 --------
int DEFAULT_ROLE = 0; // 普通用户
int ADMIN_ROLE = 1; // 管理员
int VIP_ROLE = 2; // VIP
}
1
2
3
4
5
6
7
8
/**
* 书籍常量
* @author wgzc
*/
public interface BookConstant {
int BOOK_STATUS_NORMAL = 1; // 正常
int BOOK_STATUS_DELETED = 0; // 下架/删除
}

Controller 类

创建 Controller 类,用于处理请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
@RestController
@RequestMapping("/api/book")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class BooksController {

@Resource
private BooksService booksService;

/**
* 添加书籍(仅管理员)
*/
@PostMapping("/add")
public BaseResponse<Long> addBook(@RequestBody Books book, HttpServletRequest request) {
if (book == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH, "仅管理员可添加书籍");
}

// 校验必要字段
if (StringUtils.isAnyBlank(book.getTitle(), book.getAuthor(), book.getFilePath())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "书名、作者、文件路径不能为空");
}

// 清洗可选字段:将空字符串转为 null
if (book.getIsbn() != null && book.getIsbn().trim().isEmpty()) {
book.setIsbn(null);
}
if (book.getCoverUrl() != null && book.getCoverUrl().trim().isEmpty()) {
book.setCoverUrl(null);
}
if (book.getDescription() != null && book.getDescription().trim().isEmpty()) {
book.setDescription(null);
}

boolean saved = booksService.save(book);
if (!saved) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "添加失败");
}
return ResultUtils.success(book.getId());
}

/**
* 删除书籍(逻辑下架,仅管理员)
*/
@PostMapping("/delete")
public BaseResponse<Boolean> deleteBook(@RequestBody long id, HttpServletRequest request) {
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Books book = booksService.getById(id);
if (book == null) {
throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不存在");
}

book.setBookStatus(BookConstant.BOOK_STATUS_DELETED);
boolean updated = booksService.updateById(book);
return ResultUtils.success(updated);
}

/**
* 更新书籍信息(仅管理员)
*/
@PostMapping("/update")
public BaseResponse<Boolean> updateBook(@RequestBody Books book, HttpServletRequest request) {
if (book == null || book.getId() == null || book.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}

Books existing = booksService.getById(book.getId());
if (existing == null) {
throw new BusinessException(ErrorCode.NULL_ERROR);
}

// 只允许更新非核心字段(如 description、coverUrl 等),但保留原 status
// 若需要完全覆盖,可直接 updateById(book)
boolean updated = booksService.updateById(book);
return ResultUtils.success(updated);
}

/**
* 获取书籍详情(公开)
*/
@GetMapping("/get")
public BaseResponse<Books> getBook(long id) {
if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Books book = booksService.getById(id);
if (book == null || book.getBookStatus() != BookConstant.BOOK_STATUS_NORMAL) {
throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不存在或已下架");
}
return ResultUtils.success(book);
}

@GetMapping("/list")
public BaseResponse<List<Books>> listBooks(
String keyword,
@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int pageSize) {

// 使用 LambdaQueryWrapper,通过方法引用绑定字段
LambdaQueryWrapper<Books> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Books::getBookStatus, BookConstant.BOOK_STATUS_NORMAL);

if (StringUtils.isNotBlank(keyword)) {
queryWrapper.and(wrapper ->
wrapper.like(Books::getTitle, keyword)
.or()
.like(Books::getAuthor, keyword)
);
}

// TODO: 如果后续要分页,这里可以用 page() 方法
List<Books> list = booksService.list(queryWrapper);
return ResultUtils.success(list);
}

/**
* 管理员查看所有书籍(含下架)
*/
@GetMapping("/admin/list")
public BaseResponse<List<Books>> adminListBooks(String keyword, HttpServletRequest request) {
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}

QueryWrapper<Books> queryWrapper = new QueryWrapper<>();
if (StringUtils.isNotBlank(keyword)) {
queryWrapper.and(wrapper ->
wrapper.like("title", keyword)
.or()
.like("author", keyword)
);
}

List<Books> list = booksService.list(queryWrapper);
return ResultUtils.success(list);
}

// --- 工具方法 ---

private boolean isAdmin(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute("userLoginState"); // 对应 USER_LOGIN_STATE
if (userObj == null) return false;
// 假设 User 类中有 getUserRole(),且 ADMIN_ROLE = 1
// 你可能需要从 session 中获取完整 User 对象
// 这里简化:假设 session 中存的是 User
try {
fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj;
return user != null && user.getUserRole() == 1; // 1 为管理员
} catch (Exception e) {
return false;
}
}
// 获取书籍详情(公开)
@GetMapping("/{id}")
public BaseResponse<Books> getBookById(@PathVariable Long id) {
if (id == null || id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Books book = booksService.getById(id);
if (book == null || !Integer.valueOf(BookConstant.BOOK_STATUS_NORMAL).equals(book.getBookStatus())) {
throw new BusinessException(ErrorCode.NULL_ERROR);
}
return ResultUtils.success(book);
}

/**
* 批量获取书籍详情(公开)
*/
@PostMapping("/batch")
public BaseResponse<List<Books>> getBooksByIds(@RequestBody Map<String, List<Long>> request) {
List<Long> ids = request.get("ids");
if (ids == null || ids.isEmpty()) {
return ResultUtils.success(List.of());
}

List<Books> books = booksService.listByIds(ids);
// 过滤掉非正常状态的书籍(安全)
List<Books> validBooks = books.stream()
.filter(book -> book.getBookStatus() != null && book.getBookStatus() == BookConstant.BOOK_STATUS_NORMAL)
.collect(Collectors.toList());

return ResultUtils.success(validBooks);
}

/**
* 阅读书籍(公开)
*/
@GetMapping("/read/{id}")
public void readBook(@PathVariable Long id, HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (id == null || id <= 0) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "无效的书籍ID");
return;
}

Books book = booksService.getById(id);
if (book == null || book.getBookStatus() == null || book.getBookStatus() != BookConstant.BOOK_STATUS_NORMAL)
{
response.sendError(HttpServletResponse.SC_NOT_FOUND, "书籍不存在或已下架");
return;
}

String filePath = book.getFilePath();
if (StringUtils.isBlank(filePath)) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "书籍文件路径缺失");
return;
}

// 安全处理:防止路径穿越(如 ../../etc/passwd)
File file = new File("src/main/resources/static", filePath);
if (!file.exists() || !file.isFile()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "书籍文件不存在");
return;
}

// 设置响应头
response.setContentType(getContentType(book.getFormat()));
response.setContentLengthLong(file.length());
response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(book.getTitle(), "UTF-8") + "." + book.getFormat() + "\"");

// 支持 Range 请求(用于大文件分段加载,PDF 阅读器常用)
try (FileInputStream fis = new FileInputStream(file);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
}

private String getContentType(String format) {
if ("pdf".equalsIgnoreCase(format)) {
return "application/pdf";
} else if ("epub".equalsIgnoreCase(format)) {
return "application/epub+zip";
}
return "application/octet-stream";
}

/**
* 上传书籍
*/
@PostMapping("/upload")
public BaseResponse<String> uploadFile(@RequestParam("file") MultipartFile file,
HttpServletRequest request) {
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH, "仅管理员可上传文件");
}

if (file.isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件不能为空");
}

// 1. 校验文件类型
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件名无效");
}

String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
if (!"epub".equals(ext) && !"pdf".equals(ext)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "仅支持 EPUB 或 PDF 格式");
}

// 2. 生成唯一文件名(避免中文/冲突)
String uniqueName = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + "." + ext;

// 3. 保存路径(开发阶段:static/ebooks/)
String uploadDir = "src/main/resources/static/ebooks/";
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}

try {
Path filePath = Paths.get(uploadDir, uniqueName);
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
// log.error("文件保存失败", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件上传失败");
}

// 4. 返回相对路径(供前端填入 filePath 字段)
return ResultUtils.success("ebooks/" + uniqueName);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@RestController
@RequestMapping("/api/progress")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class ReadingProgressController {

@Resource
private ReadingProgressService readingProgressService;

/**
* 保存或更新阅读进度(当前用户)
*/
@PostMapping("/save")
public BaseResponse<Boolean> saveProgress(@RequestBody ReadingProgress progress, HttpServletRequest request) {
if (progress == null || progress.getBookId() == null || progress.getBookId() <= 0
|| progress.getCurrentLocation() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);
progress.setUserId(currentUserId);
progress.setLastReadTime(new Date());
progress.setIsDelete(CommonConstant.NOT_DELETED);

// 查找是否已有记录
ReadingProgress existing = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getBookId, progress.getBookId())
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.one();

boolean result;
if (existing != null) {
// 更新
existing.setCurrentLocation(progress.getCurrentLocation());
existing.setLastReadTime(progress.getLastReadTime());
result = readingProgressService.updateById(existing);
} else {
// 新增
result = readingProgressService.save(progress);
}

return ResultUtils.success(result);
}

/**
* 获取某本书的阅读进度(当前用户)
*/
@GetMapping("/get")
public BaseResponse<ReadingProgress> getProgress(Long bookId, HttpServletRequest request) {
if (bookId == null || bookId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);

ReadingProgress progress = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getBookId, bookId)
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.one();

return ResultUtils.success(progress); // 可能为 null,前端需处理
}

/**
* 获取当前用户所有阅读进度
*/
@GetMapping("/list")
public BaseResponse<List<ReadingProgress>> listProgress(HttpServletRequest request) {
Long currentUserId = getCurrentUserId(request);

List<ReadingProgress> list = readingProgressService.lambdaQuery()
.eq(ReadingProgress::getUserId, currentUserId)
.eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED)
.list();

return ResultUtils.success(list);
}

// --- 工具方法 ---

private Long getCurrentUserId(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
if (userObj == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
try {
fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj;
return user.getId();
} catch (Exception e) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
@RestController
@RequestMapping("/api/bookshelf")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class UserBookshelfController {

@Resource
private UserBookshelfService userBookshelfService;

/**
* 添加书籍到书架(当前用户)
*/
@PostMapping("/add")
public BaseResponse<Boolean> addToBookshelf(
@RequestBody AddToBookshelfRequest addRequest,
HttpServletRequest request) {

Long bookId = addRequest.getBookId();
if (bookId == null || bookId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);

// 🔑 关键:useLogicDelete = false,查所有记录(包括 isDelete=1)
UserBookshelf existing = userBookshelfService.getOne(
Wrappers.<UserBookshelf>lambdaQuery()
.eq(UserBookshelf::getUserId, currentUserId)
.eq(UserBookshelf::getBookId, bookId),
false // ← 忽略逻辑删除条件
);

if (existing != null) {
if (existing.getIsDelete() == CommonConstant.DELETED) {
// 恢复
existing.setIsDelete(CommonConstant.NOT_DELETED);
existing.setCreateTime(new Date());
boolean updated = userBookshelfService.updateById(existing);
return ResultUtils.success(updated);
} else {
// 已存在且未删除
return ResultUtils.success(true);
}
} else {
// 全新插入
UserBookshelf shelfItem = new UserBookshelf();
shelfItem.setUserId(currentUserId);
shelfItem.setBookId(bookId);
shelfItem.setCreateTime(new Date());
shelfItem.setIsDelete(CommonConstant.NOT_DELETED);
boolean saved = userBookshelfService.save(shelfItem);
return ResultUtils.success(saved);
}
}

/**
* 从书架移除书籍(软删除)
*/
@PostMapping("/remove")
public BaseResponse<Boolean> removeFromBookshelf(
@RequestBody BookIdRequest bookRequest,
HttpServletRequest request) {

Long bookId = bookRequest.getBookId();
if (bookId == null || bookId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

Long currentUserId = getCurrentUserId(request);

UserBookshelf existing = userBookshelfService.lambdaQuery()
.eq(UserBookshelf::getUserId, currentUserId)
.eq(UserBookshelf::getBookId, bookId)
.eq(UserBookshelf::getIsDelete, CommonConstant.NOT_DELETED)
.one();

if (existing == null) {
throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不在书架中");
}

// 正确方式:使用 removeById 触发逻辑删除
return ResultUtils.success(userBookshelfService.removeById(existing.getId()));
}
/**
* 获取当前用户的书架列表
*/
@GetMapping("/list")
public BaseResponse<List<UserBookshelf>> getBookshelf(HttpServletRequest request) {
Long currentUserId = getCurrentUserId(request);

List<UserBookshelf> list = userBookshelfService.lambdaQuery()
.eq(UserBookshelf::getUserId, currentUserId)
.eq(UserBookshelf::getIsDelete, CommonConstant.NOT_DELETED)
.list();

return ResultUtils.success(list);
}

// --- 工具方法 ---

private Long getCurrentUserId(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
if (userObj == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
try {
fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj;
return user.getId();
} catch (Exception e) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
@RestController
@RequestMapping("/api/user")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class UserController {

@Resource
private UserService userService;

/**
* 用户注册
* @param userRegisterRequest 注册信息
* @return 用户id
*/
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
// 校验
if (userRegisterRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}
long result = userService.userRegister(userAccount, userPassword, checkPassword);
return ResultUtils.success(result);
}

/**
* 用户登录
* @param userLoginRequest 登录信息
* @param request 请求
* @return 用户信息
*/
@PostMapping("/login")
public BaseResponse<User> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
if (userLoginRequest == null) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword)) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}
User user = userService.userLogin(userAccount, userPassword, request);
return ResultUtils.success(user);
}

/**
* 用户注销
* @param request
* @return
*/
@PostMapping("/logout")
public BaseResponse<Integer> userLogout(HttpServletRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
int result = userService.userLogout(request);
return ResultUtils.success(result);
}

/**
* 获取当前用户
* @param request
* @return
*/
@GetMapping("/current")
public BaseResponse<User> getCurrentUser(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN);
}
long userId = currentUser.getId();
// TODO 校验用户是否合法
User user = userService.getById(userId);
User safetyUser = userService.getSafetyUser(user);
return ResultUtils.success(safetyUser);
}


/**
* 搜索用户
* @param username
* @param request
* @return
*/
@GetMapping("/search")
public BaseResponse<List<User>> searchUsers(String username, HttpServletRequest request) {
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH, "缺少管理员权限");
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
if (StringUtils.isNotBlank(username)) {
queryWrapper.like("username", username);
}
List<User> userList = userService.list(queryWrapper);
List<User> list = userList.stream().map(user -> userService.getSafetyUser(user)).collect(Collectors.toList());
return ResultUtils.success(list);
}

/**
* 删除用户
* @param id
* @param request
* @return
*/
@PostMapping("/delete")
public BaseResponse<Boolean> deleteUser(@RequestBody long id, HttpServletRequest request) {
if (!isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
boolean b = userService.removeById(id);
return ResultUtils.success(b);
}

/**
* 是否为管理员
*/
private boolean isAdmin(HttpServletRequest request) {
// 仅管理员可查询
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User user = (User) userObj;
return user != null && user.getUserRole() == ADMIN_ROLE;
}

/**
* 是否为VIP
*/
private boolean isVIP(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User user = (User) userObj;
return user != null && user.getUserRole() == VIP_ROLE;
}

/**
* 用户信息更新
*/
@PostMapping("/update")
public BaseResponse<Integer> updateUser(@RequestBody User user, HttpServletRequest request) {
// 1. 校验参数
if (user == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户信息不能为空");
}

// 2. 从 Session 获取当前登录用户
HttpSession session = request.getSession(false);
if (session == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN, "未登录");
}

Object currentUserObj = session.getAttribute(USER_LOGIN_STATE);
if (currentUserObj == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN, "请先登录");
}

User currentUser = (User) currentUserObj;
Long currentUserId = currentUser.getId();
if (currentUserId == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN, "用户状态异常");
}

// 3. 安全校验:只允许更新自己的信息(防止越权)
if (user.getId() != null && !user.getId().equals(currentUserId)) {
throw new BusinessException(ErrorCode.NO_AUTH, "无权限修改他人信息");
}

// 4. 设置要更新的用户 ID(确保只能改自己)
user.setId(currentUserId);

// 5. 调用 Service 更新
boolean result = userService.updateUser(user);
if (!result) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "更新失败");
}

return ResultUtils.success(1); // 1 表示成功
}
}

Service 类

创建 Service 类,用于处理业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public interface UserService extends IService<User> {
/**
* 用户注册
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param checkPassword 校验密码
* @return 新用户id
*/
long userRegister(String userAccount, String userPassword, String checkPassword);

/**
* 用户登录
* @param userAccount 用户账户
* @param userPassword 用户密码
* @return 脱敏后的用户信息
*/
User userLogin(String userAccount, String userPassword, HttpServletRequest request);

/**
* 用户脱敏
* @param originUser 原始用户
* @return 脱敏后的用户
*/
User getSafetyUser(User originUser);

/**
* 用户注销
* @param request 请求
* @return 退出登录结果
*/
int userLogout(HttpServletRequest request);

/**
* 用户更新
* @param safetyUser 用户信息
* @return 更新结果
*/
boolean updateUser(User safetyUser);
}

相应实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{
// 注入 UserMapper
@Resource
private UserMapper userMapper;

/**
* 盐值,混淆密码
*/
private static final String SALT = "wgzc";

@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
/* 1. 校验 */
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 2) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userPassword.length() < 5 || checkPassword.length() < 5) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}

//账户不能包含特殊字符
String validateRegExp = "[A-Za-z0-9_\\\\-\\\\u4e00-\\\\u9fa5]+";
Matcher matcher = Pattern.compile(validateRegExp).matcher(userAccount);
if (!matcher.find()) {
return -9;
}

//校验密码是否相同
if (!userPassword.equals(checkPassword)) {
return -2;
}

//账户不能重复
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
Long count = userMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号重复");
}
/* 2.加密 */
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());

/* 3.插入数据 */
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
boolean saveResult = this.save(user);
if (!saveResult) {
return -1;
}
return user.getId();
}

@Override
public User userLogin(String userAccount, String userPassword, HttpServletRequest request) {
/* 1. 校验 */
if (StringUtils.isAnyBlank(userAccount,userPassword)) {
return null;
}
if(userAccount.length() < 2){
return null;
}
if(userPassword.length() < 5){
return null;
}

//账户不能包含特殊字符
String validPattern = "[^A-Za-z0-9_\\\\\\\\-\\\\\\\\u4e00-\\\\\\\\u9fa5]";
Matcher matcher = Pattern.compile(validPattern).matcher(userAccount);
if (matcher.find()) {
return null;
}

/* 2.加密 */
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
//查询用户是否存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount",userAccount);
queryWrapper.eq("userPassword",encryptPassword);
User user = userMapper.selectOne(queryWrapper);
if (user == null) {
log.error("user login failed,userAccount cannot match userPassword");
return null;
}
/* 3.用户脱敏 */
User safetyUser = getSafetyUser(user);

/*4.记录用户的登录态 */
request.getSession().setAttribute(USER_LOGIN_STATE,safetyUser);

return safetyUser;
}

/**
* 用户脱敏
*
* @param originUser 原始用户信息
* @return 脱敏后的用户信息
*/
@Override
public User getSafetyUser(User originUser) {
if (originUser == null) {
return null;
}
User safetyUser = new User();
safetyUser.setId(originUser.getId());
safetyUser.setUserName(originUser.getUserName());
safetyUser.setUserAccount(originUser.getUserAccount());
safetyUser.setGender(originUser.getGender());
safetyUser.setAvatarUrl(originUser.getAvatarUrl());
safetyUser.setUserStatus(originUser.getUserStatus());
safetyUser.setCreateTime(originUser.getCreateTime());
safetyUser.setUserRole(originUser.getUserRole());
return safetyUser;
}

/**
* 用户注销
* @param request 请求
*/
@Override
public int userLogout(HttpServletRequest request) {
// 移除登录态
request.getSession().removeAttribute(USER_LOGIN_STATE);
return 1;
}

/**
* 用户信息更新
* @param user 用户信息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateUser(User user) {
if (user == null || user.getId() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}

// 校验敏感字段(如不能改用户名、邮箱等)
// 只允许改 avatarUrl, userName, gender
User updateData = new User();
updateData.setId(user.getId());
updateData.setAvatarUrl(user.getAvatarUrl());
updateData.setUserName(user.getUserName());
updateData.setGender(user.getGender());

// 执行更新(MyBatis-Plus 示例)
boolean updated = this.updateById(updateData);

// 使用原生 MyBatis,调用 mapper.update(updateData);
return updated;
}
}
1
2
3
4
5
public interface BooksService extends IService<Books> {}
// 相应实现类
@Service
public class BooksServiceImpl extends ServiceImpl<BooksMapper, Books>
implements BooksService{}
1
2
3
4
5
public interface UserBookshelfService extends IService<UserBookshelf> {}
// 相应实现类
@Service
public class UserBookshelfServiceImpl extends ServiceImpl<UserBookshelfMapper, UserBookshelf>
implements UserBookshelfService {}

Mapper 类

创建 Mapper 类,用于操作数据库。

1
2
3
4
public interface BooksMapper extends BaseMapper<Books> {}
public interface ReadingProgressMapper extends BaseMapper<ReadingProgress> {}
public interface UserBookshelfMapper extends BaseMapper<UserBookshelf> {}
public interface UserMapper extends BaseMapper<User> {}

Exception 类

创建 Exception 类,用于处理异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 自定义异常类
*/
public class BusinessException extends RuntimeException {

/**
* 异常码
*/
private final int code;

/**
* 描述
*/
private final String description;

public BusinessException(String message, int code, String description) {
super(message);
this.code = code;
this.description = description;
}

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.description = errorCode.getDescription();
}

public BusinessException(ErrorCode errorCode, String description) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.description = description;
}

public int getCode() {
return code;
}


public String getDescription() {
return description;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 全局异常处理器
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("businessException: " + e.getMessage(), e);
return ResultUtils.error(e.getCode(), e.getMessage(), e.getDescription());
}

@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("runtimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage(), "");
}
}

Model 类

创建 Model 类,用于封装数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* 用户表
*/
@TableName(value ="user")
@Data
public class User {
/**
* id
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 用户昵称
*/
private String userName;

/**
* 账号
*/
private String userAccount;

/**
* 用户头像
*/
private String avatarUrl;

/**
* 性别 1-男 0-女
*/
private Integer gender;

/**
* 密码
*/
private String userPassword;

/**
* 状态 0-正常
*/
private Integer userStatus;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

/**
* 是否删除
*/
@TableLogic
private Integer isDelete;

/**
* 用户角色 0-普通用户 1-管理员 2-VIP
*/
private Integer userRole;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* 书籍信息表
* @TableName books
*/
@TableName(value ="books")
@Data
public class Books {
/**
* id
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 书名
*/
private String title;

/**
* 作者
*/
private String author;

/**
* ISBN号(可选)
*/
private String isbn;

/**
* 封面图路径
*/
private String coverUrl;

/**
* 简介
*/
private String description;

/**
* EPUB/PDF文件在服务器的相对路径
*/
private String filePath;

/**
* 文件大小(字节)
*/
private Long fileSize;

/**
* 格式
*/
private String format;

/**
* 状态:1=正常,0=下架
*/
@TableField(value = "bookStatus")
private Integer bookStatus;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 用户书架表
* @TableName user_bookshelf
*/
@TableName(value ="user_bookshelf")
@Data
public class UserBookshelf {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 用户ID
*/
private Long userId;

/**
* 书籍ID
*/
private Long bookId;

/**
* 加入书架时间
*/
private Date createTime;

/**
* 是否删除:0-未删除 1-已删除
*/
private Integer isDelete;
}

请求体

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 用户注册请求体
*
* @author wgzc
*/
@Data
public class UserRegisterRequest implements Serializable {
@Serial
private static final long serialVersionUID = 3191241716373120793L;
private String userAccount;
private String userPassword;
private String checkPassword;
}
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 用户登录请求体
*
* @author wgzc
*/
@Data
public class UserLoginRequest implements Serializable {
@Serial
private static final long serialVersionUID = 3191241716373120793L;
private String userAccount;
private String userPassword;
}
1
2
3
4
@Data
public class BookIdRequest {
private Long bookId;
}
1
2
3
4
@Data
public class AddToBookshelfRequest {
private Long bookId;
}
1
2
3
4
5
6
@Data
public class BookUpload {
private Long bookId;
private String title;
private String fileUrl;
}