Files
bidding_watcher/src/bids/services/bid.service.ts
dmy 9257c78e72 feat(timezone): implement UTC and Beijing time conversion utilities and update bid handling
- Add functions to convert between UTC and Beijing time in timezone utility.
- Update BidsService to return latest update and publish dates in Beijing time.
- Modify BidCrawlerService to store publish dates in UTC format.
- Change database timezone configuration to UTC for consistency.
2026-01-16 00:00:00 +08:00

204 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { BidItem } from '../entities/bid-item.entity';
import { CrawlInfoAdd } from '../../crawler/entities/crawl-info-add.entity';
import {
getDaysAgo,
setStartOfDay,
setEndOfDay,
utcToBeijing,
} from '../../common/utils/timezone.util';
interface FindAllQuery {
page?: number;
limit?: number;
source?: string;
keyword?: string;
}
interface SourceResult {
source: string;
}
export interface CrawlInfoAddStats {
source: string;
count: number;
latestUpdate: Date | string | null;
latestPublishDate: Date | string | null;
error: string | null;
}
interface CrawlInfoAddRawResult {
source: string;
count: number;
latestPublishDate: Date | string | null;
error: string | null;
latestUpdate: Date | string;
}
@Injectable()
export class BidsService {
constructor(
@InjectRepository(BidItem)
private bidRepository: Repository<BidItem>,
@InjectRepository(CrawlInfoAdd)
private crawlInfoRepository: Repository<CrawlInfoAdd>,
) {}
async findAll(query?: FindAllQuery) {
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')
.skip((Number(page) - 1) * Number(limit))
.take(Number(limit));
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
async createOrUpdate(data: Partial<BidItem>) {
// Use title or a hash of title to check for duplicates
const item = await this.bidRepository.findOne({
where: { title: data.title },
});
if (item) {
Object.assign(item, data);
return this.bidRepository.save(item);
}
return this.bidRepository.save(data);
}
async cleanOldData() {
const thirtyDaysAgo = getDaysAgo(30);
return this.bidRepository.delete({
createdAt: LessThan(thirtyDaysAgo),
});
}
async getSources(): Promise<string[]> {
const result = await this.bidRepository
.createQueryBuilder('bid')
.select('DISTINCT bid.source', 'source')
.where('bid.source IS NOT NULL')
.orderBy('bid.source', 'ASC')
.getRawMany<SourceResult>();
return result.map((item) => item.source);
}
async getRecentBids() {
const thirtyDaysAgo = setStartOfDay(getDaysAgo(30));
return this.bidRepository
.createQueryBuilder('bid')
.where('bid.publishDate >= :thirtyDaysAgo', { thirtyDaysAgo })
.orderBy('bid.publishDate', 'DESC')
.getMany();
}
async getPinnedBids() {
return this.bidRepository
.createQueryBuilder('bid')
.where('bid.pin = :pin', { pin: true })
.orderBy('bid.publishDate', 'DESC')
.getMany();
}
async getBidsByDateRange(
startDate?: string,
endDate?: string,
keywords?: string[],
) {
const qb = this.bidRepository.createQueryBuilder('bid');
if (startDate) {
const start = setStartOfDay(new Date(startDate));
qb.andWhere('bid.publishDate >= :startDate', { startDate: start });
}
if (endDate) {
const end = setEndOfDay(new Date(endDate));
qb.andWhere('bid.publishDate <= :endDate', { endDate: end });
}
if (keywords && keywords.length > 0) {
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;
}, {}),
);
}
return qb.orderBy('bid.publishDate', 'DESC').getMany();
}
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);
}
async getCrawlInfoAddStats(): Promise<CrawlInfoAddStats[]> {
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
const query = `
SELECT
source,
count,
latestPublishDate,
error,
createdAt as latestUpdate
FROM crawl_info_add
WHERE (source, createdAt) IN (
SELECT source, MAX(createdAt)
FROM crawl_info_add
GROUP BY source
)
ORDER BY source ASC
`;
const results =
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
return results.map((item) => {
// 将UTC时间转换为北京时间显示
const latestUpdateBeijing = item.latestUpdate
? utcToBeijing(new Date(item.latestUpdate))
: null;
const latestPublishDateBeijing = item.latestPublishDate
? utcToBeijing(new Date(item.latestPublishDate))
: null;
return {
source: String(item.source),
count: Number(item.count),
latestUpdate: latestUpdateBeijing,
latestPublishDate: latestPublishDateBeijing,
// 确保 error 字段正确处理null 或空字符串都转换为 null非空字符串保留
error:
item.error && String(item.error).trim() !== ''
? String(item.error)
: null,
};
});
}
}