top推荐

This commit is contained in:
2025-11-25 16:00:09 +08:00
parent 24c5188eb0
commit 48ee6442b3
6 changed files with 414 additions and 138 deletions

View File

@@ -289,7 +289,24 @@ INSERT INTO `tb_crontab_task_meta` (
'topRecommendTask',
'execute',
'',
'[]',
'[
{
"name": "viewNum",
"description": "按浏览量取多少条",
"type": "InputNumber",
"valueType": "Integer",
"value": 10,
"required": true
},
{
"name": "timeNum",
"description": "按时间取多少条",
"type": "InputNumber",
"valueType": "Integer",
"value": 10,
"required": true
}
]',
0,
9,
'system',
@@ -310,7 +327,7 @@ INSERT INTO `tb_crontab_task` (
'topRecommendTask',
'0 0 1 * * ?',
'execute',
'{}',
'{"viewNum":10,"timeNum":10}',
1,
'system',
NOW()

View File

@@ -43,49 +43,108 @@ public class TopRecommendTask extends BaseTask {
logger.info("开始执行热门资源推荐任务");
try {
// 从参数中获取配置浏览量和时间排序各取多少条默认都为10
Integer viewNum = taskParams != null ? taskParams.getParamAsInt("viewNum") : null;
Integer timeNum = taskParams != null ? taskParams.getParamAsInt("timeNum") : null;
if (viewNum == null || viewNum <= 0) {
viewNum = 10;
}
if (timeNum == null || timeNum <= 0) {
timeNum = 10;
}
// 1. 清除旧的热门推荐recommend_type = 1
// clearOldRecommends();
// 2. 获取浏览量前10的资源
List<ResourceVO> topViewCountResources = getTopResourcesByViewCount(10);
logger.info("获取到浏览量前10的资源数量: {}", topViewCountResources.size());
// 3. 获取发布时间前10的资源
List<ResourceVO> topPublishTimeResources = getTopResourcesByPublishTime(10);
logger.info("获取到发布时间前10的资源数量: {}", topPublishTimeResources.size());
// 4. 合并并去重(使用 Set 确保资源ID唯一
// 2. 合并并去重(使用 Set 确保资源ID唯一并处理补位逻辑
Set<String> resourceIds = new HashSet<>();
List<TbResourceRecommend> recommendList = new ArrayList<>();
int orderNum = 1;
// 添加浏览量前10
for (ResourceVO vo : topViewCountResources) {
if (vo.getResource() != null && resourceIds.add(vo.getResource().getResourceID())) {
recommendList.add(createRecommend(vo.getResource().getResourceID(), orderNum++));
}
}
int viewAdded = 0;
int timeAdded = 0;
// 添加发布时间前10
for (ResourceVO vo : topPublishTimeResources) {
if (vo.getResource() != null && resourceIds.add(vo.getResource().getResourceID())) {
recommendList.add(createRecommend(vo.getResource().getResourceID(), orderNum++));
}
}
// 4.1 按浏览量补足 viewNum 条,采用分页方式逐页向后拿
int viewPageNumber = 1;
int viewPageSize = viewNum; // 每页拉 viewNum 条
// 5. 批量插入新推荐
if (!recommendList.isEmpty()) {
for (TbResourceRecommend recommend : recommendList) {
ResultDomain<TbResourceRecommend> addResult = resourceRecommendService.addRecommend(recommend);
if (!addResult.isSuccess()) {
logger.warn("插入推荐失败: 资源ID={}, 原因={}", recommend.getResourceID(), addResult.getMessage());
while (viewAdded < viewNum) {
List<ResourceVO> viewPageList = getViewCountPage(viewPageNumber, viewPageSize);
if (viewPageList == null || viewPageList.isEmpty()) {
logger.info("按浏览量补位时已经没有更多候选数据page={}", viewPageNumber);
break;
}
for (ResourceVO vo : viewPageList) {
if (viewAdded >= viewNum) {
break;
}
if (vo.getResource() == null) {
continue;
}
String resourceId = vo.getResource().getResourceID();
// 已经是热门推荐了,跳过,向后拿下一条
if (isAlreadyHotRecommend(resourceId)) {
logger.info("资源 {} 已经是热门推荐(按浏览量列表),跳过并向后补位", resourceId);
continue;
}
// 本次任务中已选过(可能来自另外一个列表),跳过并向后拿
if (!resourceIds.add(resourceId)) {
continue;
}
recommendList.add(createRecommend(resourceId, orderNum++));
viewAdded++;
}
logger.info("成功插入{}条热门推荐记录", recommendList.size());
} else {
logger.warn("没有找到符合条件的资源,未插入推荐记录");
viewPageNumber++;
}
// 4.2 按发布时间补足 timeNum 条,采用分页方式逐页向后拿
int timePageNumber = 1;
int timePageSize = timeNum; // 每页拉 timeNum 条,基本一两页就足够
while (timeAdded < timeNum) {
List<ResourceVO> pageList = getPublishTimePage(timePageNumber, timePageSize);
if (pageList == null || pageList.isEmpty()) {
logger.info("按时间补位时已经没有更多候选数据page={}", timePageNumber);
break;
}
for (ResourceVO vo : pageList) {
if (timeAdded >= timeNum) {
break;
}
if (vo.getResource() == null) {
continue;
}
String resourceId = vo.getResource().getResourceID();
// 已经是热门推荐了,跳过,向后拿下一条
if (isAlreadyHotRecommend(resourceId)) {
logger.info("资源 {} 已经是热门推荐(按时间列表),跳过并向后补位", resourceId);
continue;
}
// 本次任务中已选过(例如在浏览量列表中已经加入),跳过并向后拿
if (!resourceIds.add(resourceId)) {
continue;
}
recommendList.add(createRecommend(resourceId, orderNum++));
timeAdded++;
}
timePageNumber++;
}
logger.info("热门资源推荐任务执行成功,共推荐{}个资源", resourceIds.size());
} catch (Exception e) {
@@ -104,21 +163,20 @@ public class TopRecommendTask extends BaseTask {
}
/**
* 获取浏览量前N的资源
* 获取浏览量排序的一页资源,用于分页向后补位
*/
private List<ResourceVO> getTopResourcesByViewCount(int limit) {
private List<ResourceVO> getViewCountPage(int pageNumber, int pageSize) {
TbResource filter = new TbResource();
filter.setStatus(1); // 只查询已发布的资源
// 设置排序:按浏览量降序
// 设置排序:按浏览量降序,再按发布时间降序
List<BaseDTO.OrderType> orderTypes = new ArrayList<>();
orderTypes.add(new BaseDTO.OrderType("view_count", "DESC"));
orderTypes.add(new BaseDTO.OrderType("publish_time", "DESC"));
filter.setOrderTypes(orderTypes);
PageParam pageParam = new PageParam();
pageParam.setPageSize(limit);
pageParam.setOffset(0L);
pageParam.setPageSize(pageSize);
ResultDomain<ResourceVO> result = resourceService.getResourcePageOrder(filter, pageParam);
if (result.isSuccess() && result.getDataList() != null) {
@@ -128,9 +186,9 @@ public class TopRecommendTask extends BaseTask {
}
/**
* 获取发布时间前N的资源(最新发布)
* 获取发布时间排序的一页资源(最新发布),用于分页向后补位
*/
private List<ResourceVO> getTopResourcesByPublishTime(int limit) {
private List<ResourceVO> getPublishTimePage(int pageNumber, int pageSize) {
TbResource filter = new TbResource();
filter.setStatus(1); // 只查询已发布的资源
@@ -141,8 +199,7 @@ public class TopRecommendTask extends BaseTask {
filter.setOrderTypes(orderTypes);
PageParam pageParam = new PageParam();
pageParam.setPageSize(limit);
pageParam.setOffset(0L);
pageParam.setPageSize(pageSize);
ResultDomain<ResourceVO> result = resourceService.getResourcePageOrder(filter, pageParam);
if (result.isSuccess() && result.getDataList() != null) {
@@ -165,4 +222,19 @@ public class TopRecommendTask extends BaseTask {
recommend.setDeleted(false);
return recommend;
}
/**
* 判断资源是否已经在热门推荐中recommend_type = 1
*/
private boolean isAlreadyHotRecommend(String resourceId) {
try {
ResultDomain<Boolean> result = resourceRecommendService.isResourceRecommendedByType(resourceId, 1);
if (result != null && result.isSuccess() && result.getData() != null) {
return Boolean.TRUE.equals(result.getData());
}
} catch (Exception e) {
logger.error("检查资源是否已为热门推荐失败, resourceId={}", resourceId, e);
}
return false;
}
}

View File

@@ -0,0 +1,191 @@
<template>
<div class="params-container" v-if="paramsSchema && paramsSchema.length">
<div
v-for="param in paramsSchema"
:key="param.name"
class="param-item"
>
<span class="param-label">
{{ param.description }}
<span class="param-type">({{ param.type }})</span>
</span>
<!-- 文本输入框 -->
<el-input
v-if="param.type === 'Input'"
v-model="innerValue[param.name]"
:placeholder="`请输入${param.description}`"
clearable
/>
<!-- 数字输入框 -->
<el-input-number
v-else-if="param.type === 'InputNumber'"
v-model="innerNumberMap[param.name]"
:placeholder="`请输入${param.description}`"
controls-position="right"
style="width: 100%"
/>
<!-- 日期选择器 -->
<el-date-picker
v-else-if="param.type === 'DatePicker'"
v-model="innerValue[param.name]"
type="date"
:placeholder="`请选择${param.description}`"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
<!-- 日期范围选择器 -->
<el-date-picker
v-else-if="param.type === 'DateRangePicker'"
v-model="innerValue[param.name]"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
<!-- 布尔开关 -->
<el-switch
v-else-if="param.type === 'Switch'"
v-model="innerBoolMap[param.name]"
active-text=""
inactive-text=""
/>
<!-- 下拉选择器 -->
<el-select
v-else-if="param.type === 'Select'"
v-model="innerValue[param.name]"
:placeholder="`请选择${param.description}`"
clearable
style="width: 100%"
>
<el-option
v-for="option in param.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch, reactive } from 'vue';
interface ParamOption {
label: string;
value: string | number | boolean;
}
interface ParamSchemaItem {
name: string;
description: string;
type:
| 'Input'
| 'InputNumber'
| 'DatePicker'
| 'DateRangePicker'
| 'Switch'
| 'Select';
valueType?: string;
value?: any;
required?: boolean;
options?: ParamOption[];
}
interface Props {
paramsSchema: ParamSchemaItem[];
modelValue: Record<string, any>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
// 内部值使用 reactive保证双向绑定
const innerValue = reactive<Record<string, any>>({});
// 为了兼容不同类型控件,做一些视图层的映射
const innerNumberMap = computed({
get() {
return innerValue as Record<string, number | undefined>;
},
set(val: Record<string, number | undefined>) {
Object.assign(innerValue, val);
}
});
const innerBoolMap = computed({
get() {
return innerValue as Record<string, boolean | undefined>;
},
set(val: Record<string, boolean | undefined>) {
Object.assign(innerValue, val);
}
});
// 初始化 / 外部变更时同步
watch(
() => props.modelValue,
(val) => {
Object.keys(innerValue).forEach((k) => delete innerValue[k]);
if (val) {
Object.assign(innerValue, val);
}
},
{ immediate: true, deep: true }
);
// 内部变更时向外同步
watch(
innerValue,
(val) => {
emit('update:modelValue', { ...val });
},
{ deep: true }
);
</script>
<style scoped lang="scss">
.params-container {
padding: 12px;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.param-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.param-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #606266;
font-weight: 500;
.param-type {
color: #909399;
font-weight: normal;
font-size: 12px;
margin-left: 4px;
}
}
}
</style>

View File

@@ -12,3 +12,4 @@ export { default as GenericSelector } from './GenericSelector.vue';
export { default as TreeNode } from './TreeNode.vue';
export { default as Notice } from './Notice.vue';
export { default as ChangeHome } from './ChangeHome.vue';
export { default as DynamicParamForm} from './DynamicParamForm.vue'

View File

@@ -185,16 +185,15 @@
<div class="form-item">
<span class="form-label required">爬虫模板</span>
<el-select
v-model="selectedTemplate"
v-model="selectedCategory"
placeholder="请选择爬虫模板"
style="width: 100%"
>
<el-option
v-for="template in crawlerTemplates"
:key="template.name"
:label="template.name"
:value="template"
:value="template.name"
/>
</el-select>
<span class="form-tip">
@@ -226,75 +225,10 @@
<!-- 动态参数表单 -->
<div class="form-item" v-if="selectedMethod && selectedMethod.params && selectedMethod.params.length > 0">
<span class="form-label">方法参数</span>
<div class="params-container">
<div v-for="param in selectedMethod.params" :key="param.name" class="param-item">
<span class="param-label">
{{ param.description }}
<span class="param-type">({{ param.type }})</span>
</span>
<!-- 文本输入框 -->
<el-input
v-if="param.type === 'Input'"
v-model="dynamicParams[param.name]"
:placeholder="`请输入${param.description}`"
clearable
/>
<!-- 数字输入框 -->
<el-input-number
v-else-if="param.type === 'InputNumber'"
v-model="dynamicParams[param.name]"
:placeholder="`请输入${param.description}`"
controls-position="right"
style="width: 100%"
/>
<!-- 日期选择器 -->
<el-date-picker
v-else-if="param.type === 'DatePicker'"
v-model="dynamicParams[param.name]"
type="date"
:placeholder="`请选择${param.description}`"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
<!-- 日期范围选择器 -->
<el-date-picker
v-else-if="param.type === 'DateRangePicker'"
v-model="dynamicParams[param.name]"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
<!-- 布尔开关 -->
<el-switch
v-else-if="param.type === 'Switch'"
v-model="dynamicParams[param.name]"
active-text=""
inactive-text=""
/>
<!-- 下拉选择器 -->
<el-select
v-else-if="param.type === 'Select'"
v-model="dynamicParams[param.name]"
:placeholder="`请选择${param.description}`"
clearable
style="width: 100%"
>
<el-option
v-for="option in param.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
<DynamicParamForm
v-model="dynamicParams"
:params-schema="selectedMethod.params"
/>
</div>
<div class="form-item">
@@ -405,7 +339,7 @@ import { crontabApi } from '@/apis/crontab';
import { userApi } from '@/apis/system/user';
import type { CrontabTask, TaskMeta, CrontabItem, CrontabMethod, CrontabParam, PageParam, CreateTaskRequest, RecipientUserInfo, UserVO, ResultDomain, EmailDefault } from '@/types';
import { AdminLayout } from '@/views/admin';
import { GenericSelector } from '@/components';
import { GenericSelector, DynamicParamForm } from '@/components';
defineOptions({
name: 'NewsCrawlerView'
});
@@ -417,11 +351,17 @@ const crawlerList = ref<CrontabTask[]>([]);
// 爬虫元数据
const taskMetaList = ref<TaskMeta[]>([]);
const crawlerTemplates = ref<CrontabItem[]>([]); // 转换后的模板结构
const selectedTemplate = ref<CrontabItem | null>(null);
const selectedMethodId = ref<string>(''); // 选中的方法ID(metaId)
const selectedMetaId = ref<string>(''); // 选中的元数据ID
const selectedCategory = ref<string>(''); // 选中的模板分类名称
const selectedMethodId = ref<string>(''); // 选中的方法ID(metaId)
const selectedMetaId = ref<string>(''); // 选中的元数据ID
const dynamicParams = ref<Record<string, any>>({});
// 当前选中的模板对象(通过 category 查找)
const selectedTemplate = computed<CrontabItem | null>(() => {
if (!selectedCategory.value) return null;
return crawlerTemplates.value.find(t => t.name === selectedCategory.value) || null;
});
// 邮件接收人相关
const useDefaultRecipients = ref<boolean>(false);
const defaultRecipients = ref<RecipientUserInfo[]>([]);
@@ -512,10 +452,9 @@ const selectedRecipients = computed(() => {
});
// 监听模板选择变化
watch(selectedTemplate, (newTemplate, oldTemplate) => {
// 只在用户手动切换模板时重置oldTemplate存在且不为null时才重置)
// 编辑回填时oldTemplate为null不会触发重置
if (newTemplate && oldTemplate) {
watch(selectedCategory, (newCategory, oldCategory) => {
// 只在用户手动切换模板时重置oldCategory存在且不为时才重置)
if (newCategory && oldCategory) {
selectedMethodId.value = '';
dynamicParams.value = {};
}
@@ -732,7 +671,7 @@ async function loadCrawlerTemplates() {
params: meta.paramSchema ? JSON.parse(meta.paramSchema) : []
}))
}));
console.log(" crawlerTemplates.value",crawlerTemplates.value);
} else {
ElMessage.error(result.message || '加载爬虫模板失败');
@@ -801,7 +740,7 @@ function handleSizeChange(size: number) {
function handleAdd() {
isEdit.value = false;
resetFormData();
selectedTemplate.value = null;
selectedCategory.value = '';
selectedMethodId.value = '';
dynamicParams.value = {};
dialogVisible.value = true;
@@ -813,7 +752,7 @@ async function handleEdit(row: CrontabTask) {
Object.assign(formData, row);
// 重置选择
selectedTemplate.value = null;
selectedCategory.value = '';
selectedMethodId.value = '';
dynamicParams.value = {};
@@ -844,7 +783,7 @@ async function handleEdit(row: CrontabTask) {
const method = template.methods.find(m => m.metaId === row.metaId);
if (method) {
// 找到匹配的方法设置template和method
selectedTemplate.value = template;
selectedCategory.value = template.name;
selectedMethodId.value = method.metaId || '';
selectedMetaId.value = method.metaId || '';

View File

@@ -244,11 +244,10 @@
</el-form-item>
<el-form-item label="任务参数" prop="methodParams">
<el-input
v-model="taskForm.methodParams"
type="textarea"
:rows="4"
placeholder="请输入JSON格式的参数例如{}"
<DynamicParamForm
class="dynamic-params"
v-model="dynamicParams"
:params-schema="currentParamSchema"
/>
</el-form-item>
@@ -285,6 +284,7 @@ import {
} from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import AdminLayout from '@/views/admin/AdminLayout.vue';
import { DynamicParamForm } from '@/components';
import type { CrontabTask, TaskMeta, PageParam, CreateTaskRequest } from '@/types';
// 搜索表单
@@ -306,6 +306,10 @@ const taskList = ref<CrontabTask[]>([]);
const taskMetaList = ref<TaskMeta[]>([]);
const loading = ref(false);
// 动态任务参数
const dynamicParams = ref<Record<string, any>>({});
const currentParamSchema = ref<any[]>([]);
// 对话框
const dialogVisible = ref(false);
const dialogTitle = ref('新增任务');
@@ -395,6 +399,9 @@ function handleAddTask() {
methodParams: '{}',
status: 1
});
dynamicParams.value = {};
currentParamSchema.value = [];
dialogVisible.value = true;
}
@@ -412,6 +419,25 @@ function handleEdit(task: CrontabTask) {
methodParams: task.methodParams || '{}',
status: task.status
});
// 根据 metaId 加载参数 schema
const meta = taskMetaList.value.find(m => m.metaId === task.metaId);
if (meta) {
try {
currentParamSchema.value = meta.paramSchema ? JSON.parse(meta.paramSchema) : [];
} catch {
currentParamSchema.value = [];
}
} else {
currentParamSchema.value = [];
}
// 回填动态参数
try {
dynamicParams.value = task.methodParams ? JSON.parse(task.methodParams) : {};
} catch {
dynamicParams.value = {};
}
dialogVisible.value = true;
}
@@ -421,6 +447,34 @@ function handleMetaChange(metaId: string) {
if (meta) {
taskForm.taskName = meta.name;
taskForm.description = meta.description;
// 解析参数 schema
try {
currentParamSchema.value = meta.paramSchema ? JSON.parse(meta.paramSchema) : [];
} catch {
currentParamSchema.value = [];
}
// 根据 schema 默认值初始化 dynamicParams
const baseParams: Record<string, any> = {};
if (Array.isArray(currentParamSchema.value)) {
currentParamSchema.value.forEach((p: any) => {
if (p && p.name !== undefined && p.value !== undefined) {
baseParams[p.name] = p.value;
}
});
}
// 如果表单中已有 methodParams合并覆盖默认值
try {
const exist = taskForm.methodParams ? JSON.parse(taskForm.methodParams) : {};
dynamicParams.value = { ...baseParams, ...exist };
} catch {
dynamicParams.value = baseParams;
}
} else {
currentParamSchema.value = [];
dynamicParams.value = {};
}
}
@@ -432,11 +486,11 @@ async function handleSubmit() {
if (!valid) return;
try {
// 验证JSON格式
// 根据动态参数生成 methodParams
try {
JSON.parse(taskForm.methodParams || '{}');
taskForm.methodParams = JSON.stringify(dynamicParams.value || {});
} catch {
ElMessage.error('任务参数必须是有效的JSON格式');
ElMessage.error('任务参数序列化失败');
return;
}
@@ -614,7 +668,9 @@ onMounted(() => {
margin-left: auto;
}
}
.dynamic-params{
width: 100%;
}
.crawler-list {
min-height: 400px;