From 893b5316f3050c4aaeaa10652b56e7561bab215e Mon Sep 17 00:00:00 2001 From: Jony Date: Thu, 2 Jul 2026 15:01:57 +0800 Subject: [PATCH] feat: add book category functionality, fix XSS protection and validation issues, update film category mapping --- src/config/config.default.ts | 11 +- src/entities.ts | 57 ++++++++++- src/modules/knowledge/menu.json | 75 ++++++++++++++ src/modules/knowledge/service/film.ts | 140 +++++++++++++++++++++++--- src/modules/knowledge/utils/xss.ts | 106 +++++++++++++++++++ 5 files changed, 371 insertions(+), 18 deletions(-) diff --git a/src/config/config.default.ts b/src/config/config.default.ts index ef1a0d3..5091b27 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -35,7 +35,16 @@ export default { // 文件上传 upload: { fileSize: '200mb', - whitelist: null, + whitelist: [ + // 图片文件 + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', + // 文档文件 + '.pdf', '.doc', '.docx', + // 表格文件 + '.xls', '.xlsx', + // 文本文件 + '.txt', '.csv' + ], }, // 缓存 可切换成其他缓存如:redis http://www.midwayjs.org/docs/extensions/caching cacheManager: { diff --git a/src/entities.ts b/src/entities.ts index 5b2a4bd..0acd9b4 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -1,2 +1,57 @@ // 自动生成的文件,请勿手动修改 -export const entities = []; +import * as entity0 from './modules/user/entity/wx'; +import * as entity1 from './modules/user/entity/info'; +import * as entity2 from './modules/user/entity/address'; +import * as entity3 from './modules/task/entity/log'; +import * as entity4 from './modules/task/entity/info'; +import * as entity5 from './modules/space/entity/type'; +import * as entity6 from './modules/space/entity/info'; +import * as entity7 from './modules/recycle/entity/data'; +import * as entity8 from './modules/plugin/entity/info'; +import * as entity9 from './modules/knowledge/entity/film'; +import * as entity10 from './modules/knowledge/entity/film-category'; +import * as entity11 from './modules/knowledge/entity/book'; +import * as entity12 from './modules/knowledge/entity/book-category'; +import * as entity13 from './modules/dict/entity/type'; +import * as entity14 from './modules/dict/entity/info'; +import * as entity15 from './modules/demo/entity/goods'; +import * as entity16 from './modules/base/entity/base'; +import * as entity17 from './modules/base/entity/sys/user_role'; +import * as entity18 from './modules/base/entity/sys/user'; +import * as entity19 from './modules/base/entity/sys/role_menu'; +import * as entity20 from './modules/base/entity/sys/role_department'; +import * as entity21 from './modules/base/entity/sys/role'; +import * as entity22 from './modules/base/entity/sys/param'; +import * as entity23 from './modules/base/entity/sys/menu'; +import * as entity24 from './modules/base/entity/sys/log'; +import * as entity25 from './modules/base/entity/sys/department'; +import * as entity26 from './modules/base/entity/sys/conf'; +export const entities = [ + ...Object.values(entity0), + ...Object.values(entity1), + ...Object.values(entity2), + ...Object.values(entity3), + ...Object.values(entity4), + ...Object.values(entity5), + ...Object.values(entity6), + ...Object.values(entity7), + ...Object.values(entity8), + ...Object.values(entity9), + ...Object.values(entity10), + ...Object.values(entity11), + ...Object.values(entity12), + ...Object.values(entity13), + ...Object.values(entity14), + ...Object.values(entity15), + ...Object.values(entity16), + ...Object.values(entity17), + ...Object.values(entity18), + ...Object.values(entity19), + ...Object.values(entity20), + ...Object.values(entity21), + ...Object.values(entity22), + ...Object.values(entity23), + ...Object.values(entity24), + ...Object.values(entity25), + ...Object.values(entity26), +]; diff --git a/src/modules/knowledge/menu.json b/src/modules/knowledge/menu.json index 32e043c..ac4395c 100644 --- a/src/modules/knowledge/menu.json +++ b/src/modules/knowledge/menu.json @@ -142,6 +142,81 @@ "childMenus": [] } ] + }, + } + ] + }, + { + "name": "书籍管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-goods", + "orderNum": 2, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "书籍列表", + "router": "/knowledge/book", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 1, + "viewPath": "modules/knowledge/views/book/index.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "新增", + "router": null, + "perms": "knowledge:book:add", + "type": 2, + "icon": null, + "orderNum": 1, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "knowledge:book:delete", + "type": 2, + "icon": null, + "orderNum": 2, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "knowledge:book:info,knowledge:book:update", + "type": 2, + "icon": null, + "orderNum": 3, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "查询", + "router": null, + "perms": "knowledge:book:page,knowledge:book:list,knowledge:book:info", + "type": 2, + "icon": null, + "orderNum": 4, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + } + ] } ] } diff --git a/src/modules/knowledge/service/film.ts b/src/modules/knowledge/service/film.ts index 2c048aa..70e3365 100644 --- a/src/modules/knowledge/service/film.ts +++ b/src/modules/knowledge/service/film.ts @@ -3,7 +3,14 @@ import { Inject, Provide } from '@midwayjs/core'; import { BaseService } from '@cool-midway/core'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; -import { escapeObject } from '../utils/xss'; +import { + escapeObject, + filterImageUrls, + truncateString, + validateYear, + validateRating, + validateCategoryId +} from '../utils/xss'; /** * 知识库模块-电影信息 @@ -16,14 +23,99 @@ export class KnowledgeFilmService extends BaseService { @Inject() ctx; + /** + * 分页查询 + * - 重写自 BaseService.page,绕开 BaseSqliteService.fieldEq 注释掉 sqlParams.push 的 bug + * (该 bug 会导致 quality/categoryId 等精确匹配字段的查询参数未传递,typeorm 默认 0,触发 SQLITE_MISMATCH) + * - 同时处理 watched 字段(前端布尔/字符串 → 数字 1/0)的类型转换 + * @param query 查询条件 + * @param option 查询配置 + */ + async page(query: any, option?: any) { + // 1. watched 类型转换:布尔/字符串 → 数字 1/0 + if (query.watched !== undefined && query.watched !== null && query.watched !== '') { + query.watched = query.watched === 'true' || query.watched === true ? 1 : 0; + } + + // 2. 字符串数字 → 数字 + if (query.categoryId !== undefined && query.categoryId !== null && query.categoryId !== '') { + if (typeof query.categoryId === 'string') { + const num = parseInt(query.categoryId, 10); + if (!isNaN(num)) { + query.categoryId = num; + } + } + } + + const { page: pageNo = 1, size = 20, keyWord = '', order = 'id', sort = 'desc' } = query; + const pageNum = Math.max(1, parseInt(String(pageNo), 10) || 1); + const pageSize = Math.max(1, parseInt(String(size), 10) || 20); + const offset = (pageNum - 1) * pageSize; + + // 3. 构造 QueryBuilder,手动管理所有字段,避免走 fieldEq 有 bug 的路径 + const qb = this.knowledgeFilmEntity.createQueryBuilder('a'); + + // quality 精确匹配 + if (query.quality !== undefined && query.quality !== null && query.quality !== '') { + qb.andWhere('a.quality = :quality', { quality: String(query.quality) }); + } + + // categoryId 精确匹配 + if (query.categoryId !== undefined && query.categoryId !== null && query.categoryId !== '') { + qb.andWhere('a.categoryId = :categoryId', { categoryId: query.categoryId }); + } + + // watched 精确匹配 + if (query.watched !== undefined && query.watched !== null && query.watched !== '') { + qb.andWhere('a.watched = :watched', { watched: query.watched }); + } + + // 关键字搜索(name、director、mainCharacters 三个字段) + if (keyWord) { + const like = `%${keyWord}%`; + qb.andWhere( + `(a.name LIKE :kw OR a.director LIKE :kw OR a.mainCharacters LIKE :kw)`, + { kw: like } + ); + } + + // 排序 + const allowedSort = ['asc', 'desc'].includes(String(sort).toLowerCase()) ? String(sort).toUpperCase() : 'DESC'; + const orderField = ['id', 'createTime', 'updateTime', 'name', 'year'].includes(order) ? order : 'id'; + qb.orderBy(`a.${orderField}`, allowedSort as 'ASC' | 'DESC'); + + // 分页 + qb.skip(offset).take(pageSize); + + // 4. 执行查询 + const [list, total] = await qb.getManyAndCount(); + + return { + list, + pagination: { + page: pageNum, + size: pageSize, + total: Number(total) + } + }; + } + /** * 新增电影 * - 业务层查重:检查同名电影是否已存在 * - 对用户输入进行 XSS 过滤,防止存储型 XSS 攻击 - * - 排除 posters 字段(JSON数组,不需要转义) + * - 对 posters 字段进行 URL 白名单校验,只允许 http/https 协议 * @param params 电影数据 */ async add(params: any) { + // 字段长度截断,防止超出数据库字段限制 + if (params.name) params.name = truncateString(params.name, 200); + if (params.director) params.director = truncateString(params.director, 200); + if (params.country) params.country = truncateString(params.country, 100); + if (params.language) params.language = truncateString(params.language, 100); + if (params.quality) params.quality = truncateString(params.quality, 10); + if (params.link) params.link = truncateString(params.link, 500); + // 业务层查重:检查同名电影是否已存在 if (params.name) { const exist = await this.knowledgeFilmEntity.findOne({ @@ -33,6 +125,10 @@ export class KnowledgeFilmService extends BaseService { throw new Error(`电影"${params.name}"已存在,请勿重复添加`); } } + // URL 白名单过滤 posters 字段 + if (params.posters) { + params.posters = filterImageUrls(params.posters); + } // XSS 过滤 - 排除 posters 字段(JSON 数组) const safeParams = escapeObject(params, ['posters']); return super.add(safeParams); @@ -41,10 +137,22 @@ export class KnowledgeFilmService extends BaseService { /** * 更新电影 * - 对用户输入进行 XSS 过滤,防止存储型 XSS 攻击 - * - 排除 posters 字段(JSON数组,不需要转义) + * - 对 posters 字段进行 URL 白名单校验,只允许 http/https 协议 * @param params 电影数据 */ async update(params: any) { + // 字段长度截断,防止超出数据库字段限制 + if (params.name) params.name = truncateString(params.name, 200); + if (params.director) params.director = truncateString(params.director, 200); + if (params.country) params.country = truncateString(params.country, 100); + if (params.language) params.language = truncateString(params.language, 100); + if (params.quality) params.quality = truncateString(params.quality, 10); + if (params.link) params.link = truncateString(params.link, 500); + + // URL 白名单过滤 posters 字段 + if (params.posters) { + params.posters = filterImageUrls(params.posters); + } // XSS 过滤 - 排除 posters 字段(JSON 数组) const safeParams = escapeObject(params, ['posters']); return super.update(safeParams); @@ -81,25 +189,25 @@ export class KnowledgeFilmService extends BaseService { } try { - // 映射 Excel 列名到数据库字段 + // 映射 Excel 列名到数据库字段,并进行字段校验与截断 const filmData = { - name: film.name || '', - director: film.director || film['导演'] || film['导演/作者'] || '', - year: film.year || film['年份'] || null, - country: film.country || film['国家'] || '', - language: film.language || film['语言'] || '', + name: truncateString(film.name || '', 200), + director: truncateString(film.director || film['导演'] || film['导演/作者'] || '', 200), + year: validateYear(film.year || film['年份']), + country: truncateString(film.country || film['国家'] || '', 100), + language: truncateString(film.language || film['语言'] || '', 100), mainCharacters: film.mainCharacters || film['主要人物'] || '', synopsis: film.synopsis || film['内容简介'] || '', backgroundStory: film.backgroundStory || film['背景故事'] || '', - posters: film.posters || film['海报链接'] + posters: filterImageUrls(film.posters || film['海报链接'] ? [film.posters || film['海报链接']] - : [], - categoryId: film.categoryId ?? film['分类ID'] ?? film['分类'] ?? 45, - quality: film.quality || film['质量'] || film['质量评级'] || 'C', + : []), + categoryId: validateCategoryId(film.categoryId ?? film['分类ID'] ?? film['分类'], 45), + quality: truncateString(film.quality || film['质量'] || film['质量评级'] || 'C', 10), watched: film.watched || film['是否已看'] || false, - doubanRating: film.doubanRating || film['豆瓣评分'] || null, - tmdbRating: film.tmdbRating || film['TMDB评分'] || null, - link: film.link || film['链接'] || '', + doubanRating: validateRating(film.doubanRating || film['豆瓣评分']), + tmdbRating: validateRating(film.tmdbRating || film['TMDB评分']), + link: truncateString(film.link || film['链接'] || '', 500), honors: film.honors || film['荣誉'] || '', highlights: film.highlights || film['亮点所在'] || '', whyWorthWatching: film.whyWorthWatching || film['为什么值得一看'] || '' diff --git a/src/modules/knowledge/utils/xss.ts b/src/modules/knowledge/utils/xss.ts index d2aafdf..41a4042 100644 --- a/src/modules/knowledge/utils/xss.ts +++ b/src/modules/knowledge/utils/xss.ts @@ -21,6 +21,112 @@ export function escapeHtml(str: string): string { .replace(/'/g, '''); } +/** + * 验证图片 URL 是否安全 + * 只允许 http:// 和 https:// 协议,防止 javascript: 等恶意协议 + * @param url 图片 URL + * @returns 是否安全 + */ +export function isValidImageUrl(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + // 只允许 http:// 和 https:// 协议 + return url.startsWith('http://') || url.startsWith('https://'); +} + +/** + * 过滤图片 URL 数组,移除不安全的 URL + * @param urls 图片 URL 数组 + * @returns 安全的 URL 数组 + */ +export function filterImageUrls(urls: string[] | any): string[] { + if (!Array.isArray(urls)) { + return []; + } + return urls.filter(url => isValidImageUrl(url)); +} + +/** + * 截断字符串到指定长度,防止超出数据库字段长度限制 + * @param str 输入字符串 + * @param maxLength 最大长度 + * @returns 截断后的字符串 + */ +export function truncateString(str: string, maxLength: number): string { + if (!str || typeof str !== 'string') { + return str; + } + return str.length > maxLength ? str.substring(0, maxLength) : str; +} + +/** + * 验证并规范化年份字段 + * 只允许 1800-当前年份+10 范围内的合理年份 + * @param year 输入年份 + * @returns 有效的年份数字或 null + */ +export function validateYear(year: any): number | null { + if (year === null || year === undefined || year === '') { + return null; + } + const num = parseInt(String(year), 10); + if (isNaN(num)) { + return null; + } + const currentYear = new Date().getFullYear(); + if (num < 1800 || num > currentYear + 10) { + return null; + } + return num; +} + +/** + * 验证评分字段(豆瓣评分、TMDB评分) + * 只允许 0-10 范围内的数字,精度到小数点后1位 + * @param rating 输入评分 + * @returns 有效的评分数字或 null + */ +export function validateRating(rating: any): number | null { + if (rating === null || rating === undefined || rating === '') { + return null; + } + // 解析字符串格式的评分,如 "TMDB 7.4" / "豆瓣 8.0" + if (typeof rating === 'string') { + const match = rating.match(/(\d+\.?\d*)/); + if (!match) { + return null; + } + rating = parseFloat(match[1]); + } + const num = parseFloat(String(rating)); + if (isNaN(num)) { + return null; + } + if (num < 0 || num > 10) { + return null; + } + return Math.round(num * 10) / 10; +} + +/** + * 验证分类ID + * 必须是正整数 + * @param categoryId 输入分类ID + * @param defaultValue 默认值 + * @returns 有效的分类ID + */ +export function validateCategoryId(categoryId: any, defaultValue: number | null = null): number | null { + if (categoryId === null || categoryId === undefined || categoryId === '') { + return defaultValue; + } + const num = parseInt(String(categoryId), 10); + if (isNaN(num) || num <= 0) { + return defaultValue; + } + return num; +} + /** * 递归转义对象中的所有字符串字段 * @param obj 输入对象