2026-01-09 23:18:52 +08:00
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
2026-01-14 22:26:32 +08:00
|
|
|
|
import { Repository, LessThan } from 'typeorm';
|
2026-01-09 23:18:52 +08:00
|
|
|
|
import { BidItem } from '../entities/bid-item.entity';
|
2026-01-13 19:46:41 +08:00
|
|
|
|
import { CrawlInfoAdd } from '../../crawler/entities/crawl-info-add.entity';
|
2026-01-15 16:17:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getDaysAgo,
|
|
|
|
|
|
setStartOfDay,
|
|
|
|
|
|
setEndOfDay,
|
|
|
|
|
|
} from '../../common/utils/timezone.util';
|
2026-01-09 23:18:52 +08:00
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
interface FindAllQuery {
|
|
|
|
|
|
page?: number;
|
|
|
|
|
|
limit?: number;
|
|
|
|
|
|
source?: string;
|
|
|
|
|
|
keyword?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SourceResult {
|
|
|
|
|
|
source: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 22:51:36 +08:00
|
|
|
|
export interface CrawlInfoAddStats {
|
2026-01-14 22:26:32 +08:00
|
|
|
|
source: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
latestUpdate: Date | string;
|
|
|
|
|
|
latestPublishDate: Date | string | null;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CrawlInfoAddRawResult {
|
|
|
|
|
|
source: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
latestPublishDate: Date | string | null;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
latestUpdate: Date | string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:18:52 +08:00
|
|
|
|
@Injectable()
|
|
|
|
|
|
export class BidsService {
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
@InjectRepository(BidItem)
|
|
|
|
|
|
private bidRepository: Repository<BidItem>,
|
2026-01-13 19:46:41 +08:00
|
|
|
|
@InjectRepository(CrawlInfoAdd)
|
|
|
|
|
|
private crawlInfoRepository: Repository<CrawlInfoAdd>,
|
2026-01-09 23:18:52 +08:00
|
|
|
|
) {}
|
|
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
async findAll(query?: FindAllQuery) {
|
2026-01-09 23:18:52 +08:00
|
|
|
|
const { page = 1, limit = 10, source, keyword } = query || {};
|
|
|
|
|
|
const qb = this.bidRepository.createQueryBuilder('bid');
|
|
|
|
|
|
|
|
|
|
|
|
if (source) {
|
|
|
|
|
|
qb.andWhere('bid.source = :source', { source });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (keyword) {
|
|
|
|
|
|
qb.andWhere('bid.title LIKE :keyword', { keyword: `%${keyword}%` });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
qb.orderBy('bid.publishDate', 'DESC')
|
2026-01-14 22:26:32 +08:00
|
|
|
|
.skip((Number(page) - 1) * Number(limit))
|
|
|
|
|
|
.take(Number(limit));
|
2026-01-09 23:18:52 +08:00
|
|
|
|
|
|
|
|
|
|
const [items, total] = await qb.getManyAndCount();
|
|
|
|
|
|
return { items, total };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async createOrUpdate(data: Partial<BidItem>) {
|
2026-01-13 00:39:43 +08:00
|
|
|
|
// Use title or a hash of title to check for duplicates
|
2026-01-14 22:26:32 +08:00
|
|
|
|
const item = await this.bidRepository.findOne({
|
|
|
|
|
|
where: { title: data.title },
|
|
|
|
|
|
});
|
2026-01-09 23:18:52 +08:00
|
|
|
|
if (item) {
|
|
|
|
|
|
Object.assign(item, data);
|
|
|
|
|
|
return this.bidRepository.save(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.bidRepository.save(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async cleanOldData() {
|
2026-01-15 16:17:41 +08:00
|
|
|
|
const thirtyDaysAgo = getDaysAgo(30);
|
2026-01-09 23:18:52 +08:00
|
|
|
|
return this.bidRepository.delete({
|
|
|
|
|
|
createdAt: LessThan(thirtyDaysAgo),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-12 02:09:48 +08:00
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
async getSources(): Promise<string[]> {
|
2026-01-12 02:09:48 +08:00
|
|
|
|
const result = await this.bidRepository
|
|
|
|
|
|
.createQueryBuilder('bid')
|
2026-01-14 22:26:32 +08:00
|
|
|
|
.select('DISTINCT bid.source', 'source')
|
2026-01-12 02:09:48 +08:00
|
|
|
|
.where('bid.source IS NOT NULL')
|
|
|
|
|
|
.orderBy('bid.source', 'ASC')
|
2026-01-14 22:26:32 +08:00
|
|
|
|
.getRawMany<SourceResult>();
|
|
|
|
|
|
return result.map((item) => item.source);
|
2026-01-12 02:09:48 +08:00
|
|
|
|
}
|
2026-01-12 12:28:37 +08:00
|
|
|
|
|
|
|
|
|
|
async getRecentBids() {
|
2026-01-15 16:17:41 +08:00
|
|
|
|
const thirtyDaysAgo = setStartOfDay(getDaysAgo(30));
|
2026-01-14 22:26:32 +08:00
|
|
|
|
|
2026-01-12 12:28:37 +08:00
|
|
|
|
return this.bidRepository
|
|
|
|
|
|
.createQueryBuilder('bid')
|
|
|
|
|
|
.where('bid.publishDate >= :thirtyDaysAgo', { thirtyDaysAgo })
|
|
|
|
|
|
.orderBy('bid.publishDate', 'DESC')
|
|
|
|
|
|
.getMany();
|
|
|
|
|
|
}
|
2026-01-12 18:59:17 +08:00
|
|
|
|
|
2026-01-13 20:56:21 +08:00
|
|
|
|
async getPinnedBids() {
|
|
|
|
|
|
return this.bidRepository
|
|
|
|
|
|
.createQueryBuilder('bid')
|
|
|
|
|
|
.where('bid.pin = :pin', { pin: true })
|
|
|
|
|
|
.orderBy('bid.publishDate', 'DESC')
|
|
|
|
|
|
.getMany();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
async getBidsByDateRange(
|
|
|
|
|
|
startDate?: string,
|
|
|
|
|
|
endDate?: string,
|
|
|
|
|
|
keywords?: string[],
|
|
|
|
|
|
) {
|
2026-01-12 18:59:17 +08:00
|
|
|
|
const qb = this.bidRepository.createQueryBuilder('bid');
|
|
|
|
|
|
|
|
|
|
|
|
if (startDate) {
|
2026-01-15 16:17:41 +08:00
|
|
|
|
const start = setStartOfDay(new Date(startDate));
|
2026-01-12 18:59:17 +08:00
|
|
|
|
qb.andWhere('bid.publishDate >= :startDate', { startDate: start });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (endDate) {
|
2026-01-15 16:17:41 +08:00
|
|
|
|
const end = setEndOfDay(new Date(endDate));
|
2026-01-12 18:59:17 +08:00
|
|
|
|
qb.andWhere('bid.publishDate <= :endDate', { endDate: end });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 22:00:39 +08:00
|
|
|
|
if (keywords && keywords.length > 0) {
|
2026-01-14 22:26:32 +08:00
|
|
|
|
const keywordConditions = keywords
|
|
|
|
|
|
.map((keyword, index) => {
|
|
|
|
|
|
return `bid.title LIKE :keyword${index}`;
|
|
|
|
|
|
})
|
|
|
|
|
|
.join(' OR ');
|
|
|
|
|
|
qb.andWhere(
|
|
|
|
|
|
`(${keywordConditions})`,
|
|
|
|
|
|
keywords.reduce((params, keyword, index) => {
|
|
|
|
|
|
params[`keyword${index}`] = `%${keyword}%`;
|
|
|
|
|
|
return params;
|
|
|
|
|
|
}, {}),
|
|
|
|
|
|
);
|
2026-01-12 22:00:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 18:59:17 +08:00
|
|
|
|
return qb.orderBy('bid.publishDate', 'DESC').getMany();
|
|
|
|
|
|
}
|
2026-01-12 22:00:39 +08:00
|
|
|
|
|
2026-01-13 20:56:21 +08:00
|
|
|
|
async updatePin(title: string, pin: boolean) {
|
|
|
|
|
|
const item = await this.bidRepository.findOne({ where: { title } });
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
throw new Error('Bid not found');
|
|
|
|
|
|
}
|
|
|
|
|
|
item.pin = pin;
|
|
|
|
|
|
return this.bidRepository.save(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
async getCrawlInfoAddStats(): Promise<CrawlInfoAddStats[]> {
|
2026-01-13 19:46:41 +08:00
|
|
|
|
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
|
2026-01-12 22:00:39 +08:00
|
|
|
|
const query = `
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
source,
|
|
|
|
|
|
count,
|
|
|
|
|
|
latestPublishDate,
|
|
|
|
|
|
error,
|
2026-01-15 23:48:07 +08:00
|
|
|
|
strftime('%Y-%m-%d %H:%M:%S', createdAt, '+8 hours') as latestUpdate
|
2026-01-12 22:00:39 +08:00
|
|
|
|
FROM crawl_info_add
|
2026-01-13 19:46:41 +08:00
|
|
|
|
WHERE (source, createdAt) IN (
|
|
|
|
|
|
SELECT source, MAX(createdAt)
|
2026-01-12 22:00:39 +08:00
|
|
|
|
FROM crawl_info_add
|
|
|
|
|
|
GROUP BY source
|
|
|
|
|
|
)
|
|
|
|
|
|
ORDER BY source ASC
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
const results =
|
|
|
|
|
|
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
|
2026-01-12 22:00:39 +08:00
|
|
|
|
|
2026-01-14 22:26:32 +08:00
|
|
|
|
return results.map((item) => ({
|
|
|
|
|
|
source: String(item.source),
|
|
|
|
|
|
count: Number(item.count),
|
2026-01-15 23:48:07 +08:00
|
|
|
|
latestUpdate: item.latestUpdate,
|
2026-01-12 22:00:39 +08:00
|
|
|
|
latestPublishDate: item.latestPublishDate,
|
2026-01-14 21:33:35 +08:00
|
|
|
|
// 确保 error 字段正确处理:null 或空字符串都转换为 null,非空字符串保留
|
2026-01-14 22:26:32 +08:00
|
|
|
|
error:
|
|
|
|
|
|
item.error && String(item.error).trim() !== ''
|
|
|
|
|
|
? String(item.error)
|
|
|
|
|
|
: null,
|
2026-01-12 22:00:39 +08:00
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-01-09 23:18:52 +08:00
|
|
|
|
}
|