| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846 |
- import { AppDataSource, WorkDayStatistics, Work, PlatformAccount, UserDayStatistics } from '../models/index.js';
- import { In } from 'typeorm';
- import { logger } from '../utils/logger.js';
- interface StatisticsItem {
- workId: number;
- playCount?: number;
- exposureCount?: number;
- likeCount?: number;
- recommendCount?: number;
- commentCount?: number;
- shareCount?: number;
- collectCount?: number;
- fansIncrease?: number;
- followCount?: number;
- coverClickRate?: string;
- avgWatchDuration?: string;
- totalWatchDuration?: string;
- completionRate?: string;
- twoSecondExitRate?: string;
- }
- interface SaveResult {
- inserted: number;
- updated: number;
- }
- // 单个平台的趋势数据
- interface PlatformTrendItem {
- platform: string; // 平台标识
- platformName: string; // 平台中文名
- fansIncrease: number[]; // 涨粉数
- views: number[]; // 播放数
- likes: number[]; // 点赞数
- comments: number[]; // 评论数
- }
- // 趋势数据(按平台分组)
- interface TrendData {
- dates: string[]; // 日期数组
- platforms: PlatformTrendItem[]; // 各平台数据
- }
- interface PlatformStatItem {
- platform: string;
- fansCount: number;
- fansIncrease: number;
- viewsCount: number;
- likesCount: number;
- commentsCount: number;
- collectsCount: number;
- updateTime?: string;
- }
- interface WorkStatisticsItem {
- recordDate: string;
- playCount: number;
- exposureCount?: number;
- likeCount: number;
- commentCount: number;
- shareCount: number;
- collectCount: number;
- fansIncrease?: number;
- followCount?: number; // 视频号:关注数
- totalWatchDuration?: string;
- avgWatchDuration?: string;
- coverClickRate?: string;
- completionRate?: string;
- twoSecondExitRate?: string;
- }
- export class WorkDayStatisticsService {
- private statisticsRepository = AppDataSource.getRepository(WorkDayStatistics);
- private workRepository = AppDataSource.getRepository(Work);
- private accountRepository = AppDataSource.getRepository(PlatformAccount);
- private userDayStatisticsRepository = AppDataSource.getRepository(UserDayStatistics);
- private formatDate(d: Date) {
- const yyyy = d.getFullYear();
- const mm = String(d.getMonth() + 1).padStart(2, '0');
- const dd = String(d.getDate()).padStart(2, '0');
- return `${yyyy}-${mm}-${dd}`;
- }
- /**
- * 按作品 ID 删除该作品的所有每日统计(work 被删除时调用,或用于清理孤儿数据)
- * @returns 被删除的行数
- */
- async deleteByWorkId(workId: number): Promise<number> {
- const result = await this.statisticsRepository.delete({ workId });
- return result.affected ?? 0;
- }
- /**
- * 获取某个账号在指定日期(<= targetDate)时各作品的“最新一条”累计数据总和
- * 口径:对该账号所有作品,每个作品取 record_date <= targetDate 的最大日期那条记录,然后把 play/like/comment/collect 求和
- */
- private async getWorkSumsAtDate(
- workIds: number[],
- targetDate: string
- ): Promise<{ views: number; likes: number; comments: number; collects: number }> {
- if (!workIds.length) {
- return { views: 0, likes: 0, comments: 0, collects: 0 };
- }
- // MySQL: 派生表先取每个作品 <= targetDate 的最新日期,再回连取该日数据求和
- // 注意:workIds 使用 IN (...),由 TypeORM 负责参数化,避免注入
- const placeholders = workIds.map(() => '?').join(',');
- const sql = `
- SELECT
- COALESCE(SUM(wds.play_count), 0) AS views,
- COALESCE(SUM(wds.like_count), 0) AS likes,
- COALESCE(SUM(wds.comment_count), 0) AS comments,
- COALESCE(SUM(wds.collect_count), 0) AS collects
- FROM work_day_statistics wds
- INNER JOIN (
- SELECT wds2.work_id, MAX(wds2.record_date) AS record_date
- FROM work_day_statistics wds2
- WHERE wds2.work_id IN (${placeholders})
- AND wds2.record_date <= ?
- GROUP BY wds2.work_id
- ) latest
- ON latest.work_id = wds.work_id AND latest.record_date = wds.record_date
- `;
- const rows = await AppDataSource.query(sql, [...workIds, targetDate]);
- const row = rows?.[0] || {};
- return {
- views: Number(row.views) || 0,
- likes: Number(row.likes) || 0,
- comments: Number(row.comments) || 0,
- collects: Number(row.collects) || 0,
- };
- }
- /**
- * 保存作品日统计数据(按「今天」的中国时间日历日)
- * 当天的数据走更新流,日期变化走新增流
- */
- async saveStatistics(statistics: StatisticsItem[]): Promise<SaveResult> {
- const today = this.getTodayInChina();
- let insertedCount = 0;
- let updatedCount = 0;
- for (const stat of statistics) {
- if (!stat.workId) continue;
- // 检查当天是否已有记录
- const existing = await this.statisticsRepository.findOne({
- where: {
- workId: stat.workId,
- recordDate: today,
- },
- });
- if (existing) {
- // 更新已有记录
- await this.statisticsRepository.update(existing.id, {
- playCount: stat.playCount ?? existing.playCount,
- exposureCount: stat.exposureCount ?? existing.exposureCount,
- likeCount: stat.likeCount ?? existing.likeCount,
- recommendCount: stat.recommendCount ?? existing.recommendCount,
- commentCount: stat.commentCount ?? existing.commentCount,
- shareCount: stat.shareCount ?? existing.shareCount,
- collectCount: stat.collectCount ?? existing.collectCount,
- fansIncrease: stat.fansIncrease ?? existing.fansIncrease,
- followCount: stat.followCount ?? existing.followCount,
- coverClickRate: stat.coverClickRate ?? existing.coverClickRate ?? '0',
- avgWatchDuration: stat.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
- totalWatchDuration: stat.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
- completionRate: stat.completionRate ?? existing.completionRate ?? '0',
- twoSecondExitRate: stat.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
- });
- updatedCount++;
- } else {
- // 插入新记录
- const newStat = this.statisticsRepository.create({
- workId: stat.workId,
- recordDate: today,
- playCount: stat.playCount ?? 0,
- exposureCount: stat.exposureCount ?? 0,
- likeCount: stat.likeCount ?? 0,
- recommendCount: stat.recommendCount ?? 0,
- commentCount: stat.commentCount ?? 0,
- shareCount: stat.shareCount ?? 0,
- collectCount: stat.collectCount ?? 0,
- fansIncrease: stat.fansIncrease ?? 0,
- followCount: stat.followCount ?? 0,
- coverClickRate: stat.coverClickRate ?? '0',
- avgWatchDuration: stat.avgWatchDuration ?? '0',
- totalWatchDuration: stat.totalWatchDuration ?? '0',
- completionRate: stat.completionRate ?? '0',
- twoSecondExitRate: stat.twoSecondExitRate ?? '0',
- });
- await this.statisticsRepository.save(newStat);
- insertedCount++;
- }
- }
- return { inserted: insertedCount, updated: updatedCount };
- }
- /**
- * 获取「中国时区(Asia/Shanghai)当前日历日」的 Date
- * 用于存储 record_date,避免服务器时区与业务日期不一致
- */
- private getTodayInChina(): Date {
- const formatter = new Intl.DateTimeFormat('en-CA', {
- timeZone: 'Asia/Shanghai',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- });
- const parts = formatter.formatToParts(new Date());
- const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
- const y = parseInt(get('year'), 10);
- const m = parseInt(get('month'), 10) - 1;
- const d = parseInt(get('day'), 10);
- return new Date(Date.UTC(y, m, d, 0, 0, 0, 0));
- }
- /**
- * 保存指定日期的作品日统计数据(按 workId + recordDate 维度 upsert)
- * 说明:recordDate 会被归零到当天 00:00:00(本地时间),避免主键冲突
- */
- async saveStatisticsForDate(
- workId: number,
- recordDate: Date,
- patch: Omit<StatisticsItem, 'workId'>
- ): Promise<SaveResult> {
- const d = new Date(recordDate);
- d.setHours(0, 0, 0, 0);
- const existing = await this.statisticsRepository.findOne({
- where: { workId, recordDate: d },
- });
- if (existing) {
- await this.statisticsRepository.update(existing.id, {
- playCount: patch.playCount ?? existing.playCount,
- exposureCount: patch.exposureCount ?? existing.exposureCount,
- likeCount: patch.likeCount ?? existing.likeCount,
- recommendCount: patch.recommendCount ?? existing.recommendCount,
- commentCount: patch.commentCount ?? existing.commentCount,
- shareCount: patch.shareCount ?? existing.shareCount,
- collectCount: patch.collectCount ?? existing.collectCount,
- fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
- followCount: patch.followCount ?? existing.followCount,
- coverClickRate: patch.coverClickRate ?? existing.coverClickRate ?? '0',
- avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
- totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
- completionRate: patch.completionRate ?? existing.completionRate ?? '0',
- twoSecondExitRate: patch.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
- });
- return { inserted: 0, updated: 1 };
- }
- const newStat = this.statisticsRepository.create({
- workId,
- recordDate: d,
- playCount: patch.playCount ?? 0,
- exposureCount: patch.exposureCount ?? 0,
- likeCount: patch.likeCount ?? 0,
- recommendCount: patch.recommendCount ?? 0,
- commentCount: patch.commentCount ?? 0,
- shareCount: patch.shareCount ?? 0,
- collectCount: patch.collectCount ?? 0,
- fansIncrease: patch.fansIncrease ?? 0,
- followCount: patch.followCount ?? 0,
- coverClickRate: patch.coverClickRate ?? '0',
- avgWatchDuration: patch.avgWatchDuration ?? '0',
- totalWatchDuration: patch.totalWatchDuration ?? '0',
- completionRate: patch.completionRate ?? '0',
- twoSecondExitRate: patch.twoSecondExitRate ?? '0',
- });
- await this.statisticsRepository.save(newStat);
- return { inserted: 1, updated: 0 };
- }
- /**
- * 批量保存指定日期范围的作品日统计数据(每条记录自带日期)
- */
- async saveStatisticsForDateBatch(
- items: Array<{ workId: number; recordDate: Date } & Omit<StatisticsItem, 'workId'>>
- ): Promise<SaveResult> {
- let insertedCount = 0;
- let updatedCount = 0;
- for (const it of items) {
- const { workId, recordDate, ...patch } = it;
- const result = await this.saveStatisticsForDate(workId, recordDate, patch);
- insertedCount += result.inserted;
- updatedCount += result.updated;
- }
- logger.info(
- `[WorkDayStatistics] Date-batch save completed: inserted=${insertedCount}, updated=${updatedCount}`
- );
- return { inserted: insertedCount, updated: updatedCount };
- }
- // 平台名称映射
- private platformNameMap: Record<string, string> = {
- xiaohongshu: '小红书',
- douyin: '抖音',
- kuaishou: '快手',
- weixin: '视频号',
- weixin_video: '视频号',
- shipinhao: '视频号',
- baijiahao: '百家号',
- };
- /**
- * 获取数据趋势
- * 从 user_day_statistics 表获取近30天的数据
- * 按平台分组返回,每个平台下所有账号的总和为一条曲线
- */
- async getTrend(
- userId: number,
- options: {
- days?: number;
- startDate?: string;
- endDate?: string;
- accountId?: number;
- }
- ): Promise<TrendData> {
- const { days = 30, startDate, endDate, accountId } = options;
- // 计算日期范围
- let dateStart: Date;
- let dateEnd: Date;
- if (startDate && endDate) {
- dateStart = new Date(startDate);
- dateEnd = new Date(endDate);
- } else {
- dateEnd = new Date();
- dateStart = new Date();
- dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
- }
- // 获取该用户所有的账号(包含平台信息)
- const userAccounts = await this.accountRepository.find({
- where: { userId },
- select: ['id', 'platform'],
- });
- if (userAccounts.length === 0) {
- // 用户没有账号,返回空数据
- return this.generateEmptyTrendData(dateStart, dateEnd);
- }
- // 按平台分组账号
- const platformAccountsMap = new Map<string, number[]>();
- for (const account of userAccounts) {
- const platform = account.platform;
- if (!platformAccountsMap.has(platform)) {
- platformAccountsMap.set(platform, []);
- }
- platformAccountsMap.get(platform)!.push(account.id);
- }
- // 如果指定了特定账号,只查询该账号所属平台
- if (accountId) {
- const targetAccount = userAccounts.find(a => a.id === accountId);
- if (targetAccount) {
- platformAccountsMap.clear();
- platformAccountsMap.set(targetAccount.platform, [accountId]);
- }
- }
- // 生成完整的日期数组
- const dates: string[] = [];
- const dateKeys: string[] = [];
- const d = new Date(dateStart);
- while (d <= dateEnd) {
- const dateKey = this.formatDate(d);
- const displayDate = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- dates.push(displayDate);
- dateKeys.push(dateKey);
- d.setDate(d.getDate() + 1);
- }
- // 获取用户拥有的所有平台
- const userPlatforms = Array.from(platformAccountsMap.keys());
- // 查询数据:按平台和日期分组
- const allAccountIds = userAccounts.map(a => a.id);
- const results = await this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .innerJoin(PlatformAccount, 'pa', 'uds.account_id = pa.id')
- .select('pa.platform', 'platform')
- .addSelect('uds.record_date', 'recordDate')
- .addSelect('SUM(uds.fans_increase)', 'totalFansIncrease')
- .addSelect('SUM(uds.play_count)', 'totalViews')
- .addSelect('SUM(uds.like_count)', 'totalLikes')
- .addSelect('SUM(uds.comment_count)', 'totalComments')
- .where('uds.account_id IN (:...accountIds)', { accountIds: allAccountIds })
- .andWhere('uds.record_date >= :dateStart', { dateStart })
- .andWhere('uds.record_date <= :dateEnd', { dateEnd })
- .groupBy('pa.platform')
- .addGroupBy('uds.record_date')
- .orderBy('uds.record_date', 'ASC')
- .getRawMany();
- // 构建 平台 -> 日期 -> 数据 的映射
- const platformDateMap = new Map<string, Map<string, {
- fansIncrease: number;
- views: number;
- likes: number;
- comments: number;
- }>>();
- for (const row of results) {
- const platform = row.platform;
- const dateKey = row.recordDate instanceof Date
- ? row.recordDate.toISOString().split('T')[0]
- : String(row.recordDate).split('T')[0];
- if (!platformDateMap.has(platform)) {
- platformDateMap.set(platform, new Map());
- }
- platformDateMap.get(platform)!.set(dateKey, {
- fansIncrease: parseInt(row.totalFansIncrease) || 0,
- views: parseInt(row.totalViews) || 0,
- likes: parseInt(row.totalLikes) || 0,
- comments: parseInt(row.totalComments) || 0,
- });
- }
- // 构建各平台的数据数组
- const platforms: PlatformTrendItem[] = [];
- for (const platform of userPlatforms) {
- const dateDataMap = platformDateMap.get(platform) || new Map();
-
- const fansIncrease: number[] = [];
- const views: number[] = [];
- const likes: number[] = [];
- const comments: number[] = [];
- for (const dateKey of dateKeys) {
- const data = dateDataMap.get(dateKey);
- if (data) {
- fansIncrease.push(data.fansIncrease);
- views.push(data.views);
- likes.push(data.likes);
- comments.push(data.comments);
- } else {
- fansIncrease.push(0);
- views.push(0);
- likes.push(0);
- comments.push(0);
- }
- }
- platforms.push({
- platform,
- platformName: this.platformNameMap[platform] || platform,
- fansIncrease,
- views,
- likes,
- comments,
- });
- }
- return { dates, platforms };
- }
- /**
- * 生成空的趋势数据
- */
- private generateEmptyTrendData(dateStart: Date, dateEnd: Date): TrendData {
- const dates: string[] = [];
- const d = new Date(dateStart);
- while (d <= dateEnd) {
- dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
- d.setDate(d.getDate() + 1);
- }
- return { dates, platforms: [] };
- }
- /**
- * 按平台分组获取统计数据
- */
- async getStatisticsByPlatform(
- userId: number,
- options: {
- days?: number;
- startDate?: string;
- endDate?: string;
- }
- ): Promise<PlatformStatItem[]> {
- const { days = 30, startDate, endDate } = options;
- // 计算日期范围
- let dateStart: Date;
- let dateEnd: Date;
- if (startDate && endDate) {
- dateStart = new Date(startDate);
- dateEnd = new Date(endDate);
- } else {
- dateEnd = new Date();
- dateStart = new Date();
- dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
- }
- const endDateStr = endDate ? endDate : this.formatDate(dateEnd);
- const startDateStr = startDate ? startDate : this.formatDate(dateStart);
- /**
- * 口径变更:user_day_statistics 的 play/comment/like/collect/fans_increase 等字段为“每日单独值”
- * 因此:
- * - 区间统计:直接按日期范围 SUM
- * - 单日统计:startDate=endDate 时,也同样按该日 SUM(无需再做“累计差”)
- * 粉丝数:使用 platform_accounts.fans_count(当前值)
- */
- const [fansRows, udsRows] = await Promise.all([
- this.accountRepository
- .createQueryBuilder('pa')
- .select('pa.platform', 'platform')
- .addSelect('COALESCE(SUM(pa.fansCount), 0)', 'fansCount')
- .where('pa.userId = :userId', { userId })
- .groupBy('pa.platform')
- .getRawMany(),
- this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .innerJoin(PlatformAccount, 'pa', 'pa.id = uds.account_id')
- .select('pa.platform', 'platform')
- .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
- .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
- .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
- .addSelect('COALESCE(SUM(uds.collect_count), 0)', 'collectsCount')
- .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
- .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
- .where('pa.user_id = :userId', { userId })
- .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
- .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
- .groupBy('pa.platform')
- .getRawMany(),
- ]);
- const fansMap = new Map<string, number>();
- for (const row of fansRows || []) {
- const platform = String(row.platform || '');
- if (!platform) continue;
- fansMap.set(platform, Number(row.fansCount) || 0);
- }
- const statMap = new Map<string, {
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- collectsCount: number;
- fansIncrease: number;
- latestUpdateTime?: string | Date | null;
- }>();
- for (const row of udsRows || []) {
- const platform = String(row.platform || '');
- if (!platform) continue;
- statMap.set(platform, {
- viewsCount: Number(row.viewsCount) || 0,
- commentsCount: Number(row.commentsCount) || 0,
- likesCount: Number(row.likesCount) || 0,
- collectsCount: Number(row.collectsCount) || 0,
- fansIncrease: Number(row.fansIncrease) || 0,
- latestUpdateTime: row.latestUpdateTime ?? null,
- });
- }
- const platforms = new Set<string>([...fansMap.keys(), ...statMap.keys()]);
- const platformData: PlatformStatItem[] = Array.from(platforms).map((platform) => {
- const stat = statMap.get(platform);
- const fansCount = fansMap.get(platform) ?? 0;
- const latestUpdate = stat?.latestUpdateTime ? new Date(stat.latestUpdateTime as any) : null;
- return {
- platform,
- fansCount,
- fansIncrease: stat?.fansIncrease ?? 0,
- viewsCount: stat?.viewsCount ?? 0,
- likesCount: stat?.likesCount ?? 0,
- commentsCount: stat?.commentsCount ?? 0,
- collectsCount: stat?.collectsCount ?? 0,
- updateTime: latestUpdate ? latestUpdate.toISOString() : undefined,
- };
- });
- platformData.sort((a, b) => (b.fansCount || 0) - (a.fansCount || 0));
- return platformData;
- }
- /**
- * 批量获取作品的历史统计数据
- */
- async getWorkStatisticsHistory(
- workIds: number[],
- options: {
- startDate?: string;
- endDate?: string;
- }
- ): Promise<Record<string, WorkStatisticsItem[]>> {
- const { startDate, endDate } = options;
- const queryBuilder = this.statisticsRepository
- .createQueryBuilder('wds')
- .select('wds.work_id', 'workId')
- .addSelect('wds.record_date', 'recordDate')
- .addSelect('wds.play_count', 'playCount')
- .addSelect('wds.exposure_count', 'exposureCount')
- .addSelect('wds.like_count', 'likeCount')
- .addSelect('wds.comment_count', 'commentCount')
- .addSelect('wds.share_count', 'shareCount')
- .addSelect('wds.collect_count', 'collectCount')
- .addSelect('wds.fans_increase', 'fansIncrease')
- .addSelect('wds.follow_count', 'followCount')
- .addSelect('wds.total_watch_duration', 'totalWatchDuration')
- .addSelect('wds.avg_watch_duration', 'avgWatchDuration')
- .addSelect('wds.cover_click_rate', 'coverClickRate')
- .addSelect('wds.completion_rate', 'completionRate')
- .addSelect('wds.two_second_exit_rate', 'twoSecondExitRate')
- .where('wds.work_id IN (:...workIds)', { workIds })
- .orderBy('wds.work_id', 'ASC')
- .addOrderBy('wds.record_date', 'ASC');
- if (startDate) {
- queryBuilder.andWhere('wds.record_date >= :startDate', { startDate });
- }
- if (endDate) {
- queryBuilder.andWhere('wds.record_date <= :endDate', { endDate });
- }
- const results = await queryBuilder.getRawMany();
- // 按 workId 分组
- const groupedData: Record<string, WorkStatisticsItem[]> = {};
- for (const row of results) {
- const workId = String(row.workId);
- if (!groupedData[workId]) {
- groupedData[workId] = [];
- }
- const recordDate = row.recordDate instanceof Date
- ? row.recordDate.toISOString().split('T')[0]
- : String(row.recordDate).split('T')[0];
- groupedData[workId].push({
- recordDate,
- playCount: parseInt(row.playCount) || 0,
- exposureCount: parseInt(row.exposureCount) || 0,
- likeCount: parseInt(row.likeCount) || 0,
- commentCount: parseInt(row.commentCount) || 0,
- shareCount: parseInt(row.shareCount) || 0,
- collectCount: parseInt(row.collectCount) || 0,
- fansIncrease: parseInt(row.fansIncrease) || 0,
- followCount: parseInt(row.followCount) || 0,
- totalWatchDuration: row.totalWatchDuration || '0',
- avgWatchDuration: row.avgWatchDuration || '0',
- coverClickRate: row.coverClickRate || '0',
- completionRate: row.completionRate || '0',
- twoSecondExitRate: row.twoSecondExitRate || '0',
- });
- }
- return groupedData;
- }
- /**
- * 获取每个账号在指定日期(<= targetDate)时,user_day_statistics 的“最新一条”
- * 主要用于:总粉丝、更新时间等需要“最新状态”的字段。
- */
- private async getLatestUserDayStatsAtDate(
- accountIds: number[],
- targetDate: string
- ): Promise<Map<number, { fansCount: number; updatedAt: Date | null; recordDate: Date | null }>> {
- const map = new Map<number, { fansCount: number; updatedAt: Date | null; recordDate: Date | null }>();
- if (!accountIds.length) return map;
- // MySQL: 派生表先取每个账号 <= targetDate 的最新日期,再回连取该日数据
- const placeholders = accountIds.map(() => '?').join(',');
- const sql = `
- SELECT
- uds.account_id AS accountId,
- uds.fans_count AS fansCount,
- uds.record_date AS recordDate,
- uds.updated_at AS updatedAt
- FROM user_day_statistics uds
- INNER JOIN (
- SELECT account_id, MAX(record_date) AS record_date
- FROM user_day_statistics
- WHERE account_id IN (${placeholders})
- AND record_date <= ?
- GROUP BY account_id
- ) latest
- ON latest.account_id = uds.account_id AND latest.record_date = uds.record_date
- `;
- const rows: any[] = await AppDataSource.query(sql, [...accountIds, targetDate]);
- for (const row of rows || []) {
- const accountId = Number(row.accountId) || 0;
- if (!accountId) continue;
- const fansCount = Number(row.fansCount) || 0;
- const recordDate = row.recordDate ? new Date(row.recordDate) : null;
- const updatedAt = row.updatedAt ? new Date(row.updatedAt) : null;
- map.set(accountId, { fansCount, updatedAt, recordDate });
- }
- return map;
- }
- /**
- * 获取指定日期(= dateStr)每个账号在 user_day_statistics 的数据(用于“昨日”口径的一一对应)。
- */
- private async getUserDayStatsByExactDate(
- accountIds: number[],
- dateStr: string
- ): Promise<Map<number, { playCount: number; commentCount: number; likeCount: number; fansIncrease: number; updatedAt: Date | null }>> {
- const map = new Map<number, { playCount: number; commentCount: number; likeCount: number; fansIncrease: number; updatedAt: Date | null }>();
- if (!accountIds.length) return map;
- const rows = await this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .select('uds.account_id', 'accountId')
- .addSelect('uds.play_count', 'playCount')
- .addSelect('uds.comment_count', 'commentCount')
- .addSelect('uds.like_count', 'likeCount')
- .addSelect('uds.fans_increase', 'fansIncrease')
- .addSelect('uds.updated_at', 'updatedAt')
- .where('uds.account_id IN (:...accountIds)', { accountIds })
- .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
- .getRawMany();
- for (const row of rows || []) {
- const accountId = Number(row.accountId) || 0;
- if (!accountId) continue;
- map.set(accountId, {
- playCount: Number(row.playCount) || 0,
- commentCount: Number(row.commentCount) || 0,
- likeCount: Number(row.likeCount) || 0,
- fansIncrease: Number(row.fansIncrease) || 0,
- updatedAt: row.updatedAt ? new Date(row.updatedAt) : null,
- });
- }
- return map;
- }
- /**
- * 获取数据总览
- * 返回账号列表和汇总统计数据
- */
- async getOverview(userId: number): Promise<{
- accounts: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- groupId: number | null;
- groupName?: string | null;
- fansCount: number;
- worksCount: number | null;
- totalIncome: number | null;
- yesterdayIncome: number | null;
- totalViews: number | null;
- yesterdayViews: number | null;
- yesterdayComments: number;
- yesterdayLikes: number;
- yesterdayFansIncrease: number;
- updateTime: string;
- status: string;
- }>;
- summary: {
- totalAccounts: number;
- totalWorks: number;
- totalIncome: number;
- yesterdayIncome: number;
- totalViews: number;
- yesterdayViews: number;
- totalFans: number;
- yesterdayComments: number;
- yesterdayLikes: number;
- yesterdayFansIncrease: number;
- };
- }> {
- // 只查询支持的平台:抖音、百家号、视频号、小红书
- const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-
- // 获取用户的所有账号(只包含支持的平台)
- const accounts = await this.accountRepository.find({
- where: {
- userId,
- platform: In(allowedPlatforms),
- },
- relations: ['group'],
- });
- // 使用中国时区(UTC+8)计算“今天/昨天”的业务日期
- // 思路:在当前 UTC 时间基础上 +8 小时,再取 ISO 日期部分,即为中国日历日期
- const now = new Date();
- const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
- const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
- // 格式化为 YYYY-MM-DD,与 MySQL DATE 字段匹配
- const todayStr = chinaNow.toISOString().split('T')[0];
- const yesterdayStr = chinaYesterday.toISOString().split('T')[0];
-
- logger.info(`[WorkDayStatistics] getOverview - userId: ${userId}, today: ${todayStr}, yesterday: ${yesterdayStr}`);
- const accountIds = accounts.map(a => a.id);
- // 账号总数:platform_accounts 中 user_id 对应数量(等价于 accounts.length)
- const totalAccounts = accounts.length;
- // 列表“总播放/汇总总播放”:统一从 works.play_count 聚合(累计)
- const worksPlayRows = accountIds.length
- ? await this.workRepository
- .createQueryBuilder('w')
- .select('w.accountId', 'accountId')
- .addSelect('COALESCE(SUM(w.playCount), 0)', 'playCount')
- .where('w.userId = :userId', { userId })
- .andWhere('w.accountId IN (:...accountIds)', { accountIds })
- .groupBy('w.accountId')
- .getRawMany()
- : [];
- const totalPlayMap = new Map<number, number>();
- for (const row of worksPlayRows || []) {
- totalPlayMap.set(Number(row.accountId) || 0, Number(row.playCount) || 0);
- }
- // “昨日”口径:只取 user_day_statistics 指定日期那一行,一一对应
- const yesterdayUdsMap = await this.getUserDayStatsByExactDate(accountIds, yesterdayStr);
- // 粉丝数口径:直接取 platform_accounts.fans_count(不跟随 user_day_statistics)
- const accountList: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- groupId: number | null;
- groupName?: string | null;
- fansCount: number;
- totalIncome: number | null;
- yesterdayIncome: number | null;
- totalViews: number | null;
- yesterdayViews: number | null;
- yesterdayComments: number;
- yesterdayLikes: number;
- yesterdayFansIncrease: number;
- updateTime: string;
- status: string;
- }> = [];
- // 汇总统计数据
- let totalIncome = 0;
- let yesterdayIncome = 0;
- let totalWorks = 0;
- let totalViews = 0;
- let yesterdayViews = 0;
- let totalFans = 0;
- let yesterdayComments = 0;
- let yesterdayLikes = 0;
- let yesterdayFansIncrease = 0;
- for (const account of accounts) {
- const accountTotalViews = totalPlayMap.get(account.id) ?? 0;
- const yesterdayUds = yesterdayUdsMap.get(account.id);
- const accountFansCount = account.fansCount || 0;
- const accountWorksCount = account.worksCount || 0;
- const accountYesterdayViews = yesterdayUds?.playCount ?? 0;
- const accountYesterdayComments = yesterdayUds?.commentCount ?? 0;
- const accountYesterdayLikes = yesterdayUds?.likeCount ?? 0;
- const accountYesterdayFansIncrease = yesterdayUds?.fansIncrease ?? 0;
- const updateTime = (yesterdayUds?.updatedAt ?? account.updatedAt).toISOString();
- accountList.push({
- id: account.id,
- nickname: account.accountName || '',
- username: account.accountId || '',
- avatarUrl: account.avatarUrl,
- platform: account.platform,
- groupId: account.groupId,
- groupName: account.group?.name ?? null,
- fansCount: accountFansCount,
- worksCount: accountWorksCount,
- totalIncome: null,
- yesterdayIncome: null,
- totalViews: accountTotalViews,
- yesterdayViews: accountYesterdayViews,
- yesterdayComments: accountYesterdayComments,
- yesterdayLikes: accountYesterdayLikes,
- yesterdayFansIncrease: accountYesterdayFansIncrease,
- updateTime,
- status: account.status,
- });
- totalWorks += accountWorksCount;
- totalViews += accountTotalViews;
- totalFans += accountFansCount;
- yesterdayViews += accountYesterdayViews;
- yesterdayComments += accountYesterdayComments;
- yesterdayLikes += accountYesterdayLikes;
- yesterdayFansIncrease += accountYesterdayFansIncrease;
- }
- return {
- accounts: accountList,
- summary: {
- totalAccounts,
- totalWorks,
- totalIncome,
- yesterdayIncome,
- totalViews,
- yesterdayViews,
- totalFans,
- yesterdayComments,
- yesterdayLikes,
- yesterdayFansIncrease,
- },
- };
- }
- /**
- * 获取账号维度的区间统计数据
- * 用于「账号数据」页,支持按日期区间、平台、分组筛选
- */
- async getAccountsAnalytics(
- userId: number,
- options: {
- startDate: string;
- endDate: string;
- platform?: string;
- groupId?: number;
- }
- ): Promise<{
- accounts: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- groupId: number | null;
- income: number | null;
- recommendationCount: number | null;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- fansCount: number;
- updateTime: string;
- status: string;
- }>;
- summary: {
- totalAccounts: number;
- totalIncome: number;
- recommendationCount: number | null;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- totalFans: number;
- };
- }> {
- const { startDate, endDate, platform, groupId } = options;
- // 只查询支持的平台:抖音、百家号、视频号、小红书
- const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
- const accountQuery = this.accountRepository
- .createQueryBuilder('pa')
- .where('pa.userId = :userId', { userId })
- .andWhere('pa.platform IN (:...allowedPlatforms)', { allowedPlatforms });
- if (platform) {
- accountQuery.andWhere('pa.platform = :platform', { platform });
- }
- if (groupId) {
- accountQuery.andWhere('pa.groupId = :groupId', { groupId });
- }
- const accounts = await accountQuery.getMany();
- if (accounts.length === 0) {
- return {
- accounts: [],
- summary: {
- totalAccounts: 0,
- totalIncome: 0,
- recommendationCount: null,
- viewsCount: 0,
- commentsCount: 0,
- likesCount: 0,
- fansIncrease: 0,
- totalFans: 0,
- },
- };
- }
- const accountIds = accounts.map(a => a.id);
- // 使用 user_day_statistics 统计区间内的播放/评论/点赞/涨粉等
- const statsRows = await this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .select('uds.account_id', 'accountId')
- .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
- .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
- .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
- .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
- .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
- .where('uds.account_id IN (:...accountIds)', { accountIds })
- .andWhere('DATE(uds.record_date) >= :startDate', { startDate })
- .andWhere('DATE(uds.record_date) <= :endDate', { endDate })
- .groupBy('uds.account_id')
- .getRawMany();
- const statMap = new Map<
- number,
- {
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- latestUpdateTime: Date | null;
- }
- >();
- for (const row of statsRows || []) {
- const accountId = Number(row.accountId) || 0;
- if (!accountId) continue;
- statMap.set(accountId, {
- viewsCount: Number(row.viewsCount) || 0,
- commentsCount: Number(row.commentsCount) || 0,
- likesCount: Number(row.likesCount) || 0,
- fansIncrease: Number(row.fansIncrease) || 0,
- latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
- });
- }
- const resultAccounts: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- groupId: number | null;
- income: number | null;
- recommendationCount: number | null;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- fansCount: number;
- updateTime: string;
- status: string;
- }> = [];
- let totalViews = 0;
- let totalComments = 0;
- let totalLikes = 0;
- let totalFansIncrease = 0;
- let totalFans = 0;
- for (const account of accounts) {
- const stat =
- statMap.get(account.id) ?? {
- viewsCount: 0,
- commentsCount: 0,
- likesCount: 0,
- fansIncrease: 0,
- latestUpdateTime: account.updatedAt ?? null,
- };
- const fansCount = account.fansCount || 0;
- const updateTime = stat.latestUpdateTime
- ? this.formatUpdateTime(stat.latestUpdateTime)
- : this.formatUpdateTime(account.updatedAt ?? new Date());
- resultAccounts.push({
- id: account.id,
- nickname: account.accountName || '',
- username: account.accountId || '',
- avatarUrl: account.avatarUrl,
- platform: account.platform,
- groupId: account.groupId ?? null,
- income: null,
- recommendationCount: null,
- viewsCount: stat.viewsCount,
- commentsCount: stat.commentsCount,
- likesCount: stat.likesCount,
- fansIncrease: stat.fansIncrease,
- fansCount,
- updateTime,
- status: account.status,
- });
- totalViews += stat.viewsCount;
- totalComments += stat.commentsCount;
- totalLikes += stat.likesCount;
- totalFansIncrease += stat.fansIncrease;
- totalFans += fansCount;
- }
- return {
- accounts: resultAccounts,
- summary: {
- totalAccounts: accounts.length,
- totalIncome: 0,
- recommendationCount: null,
- viewsCount: totalViews,
- commentsCount: totalComments,
- likesCount: totalLikes,
- fansIncrease: totalFansIncrease,
- totalFans,
- },
- };
- }
- /**
- * 获取单个账号的详情数据
- * 包括:汇总统计、每日数据、作品列表
- * 说明:
- * - 收益、推荐量目前数据库尚未接入,统一返回 0
- */
- async getAccountDetail(
- userId: number,
- accountId: number,
- options: {
- startDate: string;
- endDate: string;
- }
- ): Promise<{
- summary: {
- income: number;
- recommendationCount: number;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- };
- dailyData: Array<{
- date: string;
- income: number;
- recommendationCount: number;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- }>;
- works: Array<{
- id: number;
- title: string;
- coverUrl: string;
- platform: string;
- publishTime: string | null;
- recommendCount: number;
- viewsCount: number;
- commentsCount: number;
- sharesCount: number;
- collectsCount: number;
- likesCount: number;
- }>;
- }> {
- const { startDate, endDate } = options;
- const account = await this.accountRepository.findOne({
- where: { id: accountId, userId },
- });
- if (!account) {
- // 账号不存在或不属于该用户,返回空数据
- return {
- summary: {
- income: 0,
- recommendationCount: 0,
- viewsCount: 0,
- commentsCount: 0,
- likesCount: 0,
- fansIncrease: 0,
- },
- dailyData: [],
- works: [],
- };
- }
- const startDateStr = startDate;
- const endDateStr = endDate;
- // 1. 每日数据:直接从 user_day_statistics 获取指定账号的每日记录
- const udsRows = await this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .select('uds.record_date', 'recordDate')
- .addSelect('uds.play_count', 'viewsCount')
- .addSelect('uds.comment_count', 'commentsCount')
- .addSelect('uds.like_count', 'likesCount')
- .addSelect('uds.fans_increase', 'fansIncrease')
- .where('uds.account_id = :accountId', { accountId })
- .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
- .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
- .orderBy('uds.record_date', 'ASC')
- .getRawMany();
- const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
- for (const row of udsRows || []) {
- if (!row.recordDate) continue;
- let dateKey: string;
- if (row.recordDate instanceof Date) {
- const y = row.recordDate.getFullYear();
- const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
- const d = String(row.recordDate.getDate()).padStart(2, '0');
- dateKey = `${y}-${m}-${d}`;
- } else {
- dateKey = String(row.recordDate).slice(0, 10);
- }
- dailyMap.set(dateKey, {
- views: Number(row.viewsCount) || 0,
- comments: Number(row.commentsCount) || 0,
- likes: Number(row.likesCount) || 0,
- fansIncrease: Number(row.fansIncrease) || 0,
- });
- }
- const dStart = new Date(startDateStr);
- const dEnd = new Date(endDateStr);
- const cursor = new Date(dStart);
- const dailyData: Array<{
- date: string;
- income: number;
- recommendationCount: number;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- }> = [];
- let totalViews = 0;
- let totalComments = 0;
- let totalLikes = 0;
- let totalFansIncrease = 0;
- while (cursor <= dEnd) {
- const dateKey = this.formatDate(cursor);
- const value = dailyMap.get(dateKey) ?? {
- views: 0,
- comments: 0,
- likes: 0,
- fansIncrease: 0,
- };
- dailyData.push({
- date: dateKey,
- income: 0,
- recommendationCount: 0,
- viewsCount: value.views,
- commentsCount: value.comments,
- likesCount: value.likes,
- fansIncrease: value.fansIncrease,
- });
- totalViews += value.views;
- totalComments += value.comments;
- totalLikes += value.likes;
- totalFansIncrease += value.fansIncrease;
- cursor.setDate(cursor.getDate() + 1);
- }
- // 2. 作品列表:按作品聚合 work_day_statistics 区间内的数据
- const worksRows = await this.workRepository
- .createQueryBuilder('w')
- .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
- wStart: startDateStr,
- wEnd: endDateStr,
- })
- .select('w.id', 'id')
- .addSelect('w.title', 'title')
- .addSelect('w.cover_url', 'coverUrl')
- .addSelect('w.platform', 'platform')
- .addSelect('w.publish_time', 'publishTime')
- .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
- .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
- .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
- .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
- .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
- .where('w.userId = :userId', { userId })
- .andWhere('w.accountId = :accountId', { accountId })
- .groupBy('w.id')
- .orderBy('w.publish_time', 'DESC')
- .getRawMany();
- const works = (worksRows || []).map((row) => {
- const publishTime =
- row.publishTime instanceof Date
- ? row.publishTime.toISOString()
- : row.publishTime
- ? String(row.publishTime)
- : null;
- return {
- id: Number(row.id),
- title: row.title || '',
- coverUrl: row.coverUrl || '',
- platform: row.platform || '',
- publishTime,
- // 推荐量目前没有独立字段,统一返回 0
- recommendCount: 0,
- viewsCount: Number(row.viewsCount) || 0,
- commentsCount: Number(row.commentsCount) || 0,
- sharesCount: Number(row.sharesCount) || 0,
- collectsCount: Number(row.collectsCount) || 0,
- likesCount: Number(row.likesCount) || 0,
- };
- });
- return {
- summary: {
- income: 0,
- recommendationCount: 0,
- viewsCount: totalViews,
- commentsCount: totalComments,
- likesCount: totalLikes,
- fansIncrease: totalFansIncrease,
- },
- dailyData,
- works,
- };
- }
- /**
- * 获取作品数据列表(用于「作品数据」页)
- * 依据 work_day_statistics 进行区间汇总统计
- */
- async getWorksAnalytics(
- userId: number,
- options: {
- startDate: string;
- endDate: string;
- platform?: string;
- accountIds?: number[];
- groupId?: number;
- keyword?: string;
- sortBy?: 'publish_desc' | 'publish_asc' | 'views_desc' | 'likes_desc' | 'comments_desc';
- page?: number;
- pageSize?: number;
- }
- ): Promise<{
- summary: {
- totalWorks: number;
- recommendCount: number;
- viewsCount: number;
- commentsCount: number;
- sharesCount: number;
- collectsCount: number;
- likesCount: number;
- };
- total: number;
- works: Array<{
- id: number;
- title: string;
- coverUrl: string;
- platform: string;
- accountId: number;
- accountName: string;
- accountAvatar: string | null;
- workType: string;
- publishTime: string | null;
- recommendCount: number;
- viewsCount: number;
- commentsCount: number;
- sharesCount: number;
- collectsCount: number;
- likesCount: number;
- }>;
- }> {
- const {
- startDate,
- endDate,
- platform,
- accountIds,
- groupId,
- keyword,
- sortBy = 'publish_desc',
- page = 1,
- pageSize = 20,
- } = options;
- const startDateStr = startDate;
- const endDateStr = endDate;
- // 作品列表:指标直接取 works 表 yesterday_* 快照;时间范围仅用于筛选「发布时间」落在范围内的作品
- // 说明:不再按日期范围聚合 work_day_statistics(避免口径随筛选范围变化)
- const qb = this.workRepository
- .createQueryBuilder('w')
- .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
- .select('w.id', 'id')
- .addSelect('w.title', 'title')
- .addSelect('w.cover_url', 'coverUrl')
- .addSelect('w.platform', 'platform')
- .addSelect('w.accountId', 'accountId')
- .addSelect('pa.accountName', 'accountName')
- .addSelect('pa.avatarUrl', 'accountAvatar')
- .addSelect('w.status', 'workType')
- .addSelect('w.publish_time', 'publishTime')
- .addSelect('COALESCE(w.yesterday_play_count, 0)', 'viewsCount')
- .addSelect('COALESCE(w.yesterday_comment_count, 0)', 'commentsCount')
- .addSelect('COALESCE(w.yesterday_share_count, 0)', 'sharesCount')
- .addSelect('COALESCE(w.yesterday_collect_count, 0)', 'collectsCount')
- .addSelect('COALESCE(w.yesterday_like_count, 0)', 'likesCount')
- .where('w.userId = :userId', { userId })
- .andWhere('w.publish_time IS NOT NULL')
- .andWhere('DATE(w.publish_time) >= :startDate AND DATE(w.publish_time) <= :endDate', {
- startDate: startDateStr,
- endDate: endDateStr,
- });
- if (platform) {
- qb.andWhere('w.platform = :platform', { platform });
- }
- if (accountIds && accountIds.length > 0) {
- qb.andWhere('w.accountId IN (:...accountIds)', { accountIds });
- }
- if (groupId) {
- qb.andWhere('pa.groupId = :groupId', { groupId });
- }
- if (keyword && keyword.trim()) {
- const kw = `%${keyword.trim()}%`;
- qb.andWhere('w.title LIKE :kw', { kw });
- }
- // SQL 里只按发布时间倒序,具体排序口径在内存中根据 sortBy 再处理
- qb.orderBy('w.publish_time', 'DESC');
- // 先获取全部满足条件的作品聚合行,再在内存中做分页和汇总
- const allRows = await qb.getRawMany();
- // 根据 sortBy 对结果进行排序
- const sortedRows = [...allRows];
- sortedRows.sort((a, b) => {
- const getNum = (v: unknown) => Number(v) || 0;
- if (sortBy === 'views_desc') {
- return getNum(b.viewsCount) - getNum(a.viewsCount);
- }
- if (sortBy === 'likes_desc') {
- return getNum(b.likesCount) - getNum(a.likesCount);
- }
- if (sortBy === 'comments_desc') {
- return getNum(b.commentsCount) - getNum(a.commentsCount);
- }
- // 发布时间排序(默认 publish_desc)
- const toTime = (v: unknown) => {
- if (!v) return 0;
- if (v instanceof Date) return v.getTime();
- const t = new Date(String(v)).getTime();
- return Number.isNaN(t) ? 0 : t;
- };
- const ta = toTime(a.publishTime);
- const tb = toTime(b.publishTime);
- if (sortBy === 'publish_asc') {
- return ta - tb;
- }
- // publish_desc
- return tb - ta;
- });
- const total = sortedRows.length;
- const offset = (page - 1) * pageSize;
- const pagedRows = sortedRows.slice(offset, offset + pageSize);
- let totalViews = 0;
- let totalComments = 0;
- let totalShares = 0;
- let totalCollects = 0;
- let totalLikes = 0;
- // 汇总统计使用所有作品(而不是当前页),确保顶部统计口径统一
- for (const row of sortedRows) {
- const views = Number(row.viewsCount) || 0;
- const comments = Number(row.commentsCount) || 0;
- const shares = Number(row.sharesCount) || 0;
- const collects = Number(row.collectsCount) || 0;
- const likes = Number(row.likesCount) || 0;
- totalViews += views;
- totalComments += comments;
- totalShares += shares;
- totalCollects += collects;
- totalLikes += likes;
- }
- // 当前页作品列表只返回分页后的数据
- const works = pagedRows.map((row) => {
- const views = Number(row.viewsCount) || 0;
- const comments = Number(row.commentsCount) || 0;
- const shares = Number(row.sharesCount) || 0;
- const collects = Number(row.collectsCount) || 0;
- const likes = Number(row.likesCount) || 0;
- const publishTime =
- row.publishTime instanceof Date
- ? row.publishTime.toISOString()
- : row.publishTime
- ? String(row.publishTime)
- : null;
- return {
- id: Number(row.id),
- title: row.title || '',
- coverUrl: row.coverUrl || '',
- platform: row.platform || '',
- accountId: Number(row.accountId) || 0,
- accountName: row.accountName || '',
- accountAvatar: row.accountAvatar || null,
- workType: row.workType || '动态',
- publishTime,
- recommendCount: 0,
- viewsCount: views,
- commentsCount: comments,
- sharesCount: shares,
- collectsCount: collects,
- likesCount: likes,
- };
- });
- return {
- summary: {
- totalWorks: total,
- recommendCount: 0,
- viewsCount: totalViews,
- commentsCount: totalComments,
- sharesCount: totalShares,
- collectsCount: totalCollects,
- likesCount: totalLikes,
- },
- total,
- works,
- };
- }
- /**
- * 获取平台详情数据
- * 包括汇总统计、每日汇总数据和账号列表
- */
- async getPlatformDetail(
- userId: number,
- platform: string,
- options: {
- startDate: string;
- endDate: string;
- }
- ): Promise<{
- summary: {
- totalAccounts: number;
- totalIncome: number;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- recommendationCount: number | null; // 推荐量(部分平台支持)
- };
- dailyData: Array<{
- date: string;
- income: number;
- recommendationCount: number | null;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- }>;
- accounts: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- income: number | null;
- recommendationCount: number | null;
- viewsCount: number | null;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- updateTime: string;
- }>;
- }> {
- const { startDate, endDate } = options;
- const startDateStr = startDate;
- const endDateStr = endDate;
- // 获取该平台的所有账号
- const accounts = await this.accountRepository.find({
- where: {
- userId,
- platform: platform as any,
- },
- relations: ['group'],
- });
- if (accounts.length === 0) {
- return {
- summary: {
- totalAccounts: 0,
- totalIncome: 0,
- viewsCount: 0,
- commentsCount: 0,
- likesCount: 0,
- fansIncrease: 0,
- recommendationCount: null,
- },
- dailyData: [],
- accounts: [],
- };
- }
- /**
- * 口径变更:user_day_statistics 的各项数据为“每日单独值”,不再是累计值
- * 因此平台详情:
- * - 区间汇总:直接 SUM(user_day_statistics.*)(按账号、按天)
- * - 每日汇总:按 record_date 分组 SUM
- */
- const accountIds = accounts.map(a => a.id);
- const totalAccounts = accounts.length;
- const [dailyRows, perAccountRows] = await Promise.all([
- // 按日期维度汇总(每天所有账号数据之和)
- // 这里直接使用原生 SQL,以保证和文档/手动验证时看到的 SQL 完全一致:
- //
- // SELECT
- // uds.record_date AS recordDate,
- // COALESCE(SUM(uds.play_count), 0) AS viewsCount,
- // COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
- // COALESCE(SUM(uds.like_count), 0) AS likesCount,
- // COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
- // FROM user_day_statistics uds
- // WHERE uds.account_id IN (...)
- // AND uds.record_date >= ?
- // AND uds.record_date <= ?
- // GROUP BY uds.record_date
- // ORDER BY uds.record_date ASC;
- (async () => {
- if (!accountIds.length) return [];
- const inPlaceholders = accountIds.map(() => '?').join(',');
- const sql = `
- SELECT
- uds.record_date AS recordDate,
- COALESCE(SUM(uds.play_count), 0) AS viewsCount,
- COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
- COALESCE(SUM(uds.like_count), 0) AS likesCount,
- COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
- FROM user_day_statistics uds
- WHERE uds.account_id IN (${inPlaceholders})
- AND uds.record_date >= ?
- AND uds.record_date <= ?
- GROUP BY uds.record_date
- ORDER BY uds.record_date ASC
- `;
- const params = [...accountIds, startDateStr, endDateStr];
- return await AppDataSource.query(sql, params);
- })(),
- // 按账号维度汇总(区间内所有天的和)
- this.userDayStatisticsRepository
- .createQueryBuilder('uds')
- .select('uds.account_id', 'accountId')
- .addSelect('COUNT(1)', 'rowCount')
- .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
- .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
- .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
- .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
- .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
- .where('uds.account_id IN (:...accountIds)', { accountIds })
- .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
- .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
- .groupBy('uds.account_id')
- .getRawMany(),
- ]);
- // ===== 按日期汇总:每日汇总数据 =====
- const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
- for (const row of dailyRows || []) {
- if (!row.recordDate) continue;
- /**
- * 注意:record_date 在实体里是 DATE 类型,TypeORM 读出来通常是 Date 对象。
- * 之前用 String(row.recordDate).slice(0, 10) 会得到类似 "Wed Jan 28" 这样的字符串前 10 位,
- * 导致 key 和下面 this.formatDate(cursor) 生成的 "YYYY-MM-DD" 不一致,从而 dailyMap 命中失败,全部变成 0。
- *
- * 这里改成显式按本地时间拼出 "YYYY-MM-DD",确保与 startDate/endDate 的格式一致。
- */
- let dateKey: string;
- if (row.recordDate instanceof Date) {
- const y = row.recordDate.getFullYear();
- const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
- const d = String(row.recordDate.getDate()).padStart(2, '0');
- dateKey = `${y}-${m}-${d}`;
- } else {
- // 数据库如果已经返回字符串,例如 "2026-01-28",直接截前 10 位即可
- dateKey = String(row.recordDate).slice(0, 10);
- }
- const prev = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
- dailyMap.set(dateKey, {
- views: prev.views + (Number(row.viewsCount) || 0),
- comments: prev.comments + (Number(row.commentsCount) || 0),
- likes: prev.likes + (Number(row.likesCount) || 0),
- fansIncrease: prev.fansIncrease + (Number(row.fansIncrease) || 0),
- });
- }
- // 补齐日期区间(没有数据也返回 0)
- const dailyData: Array<{
- date: string;
- income: number;
- recommendationCount: number | null;
- viewsCount: number;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- }> = [];
- const dStart = new Date(startDateStr);
- const dEnd = new Date(endDateStr);
- const cursor = new Date(dStart);
- while (cursor <= dEnd) {
- const dateKey = this.formatDate(cursor);
- const v = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
- dailyData.push({
- date: dateKey,
- income: 0,
- recommendationCount: null,
- viewsCount: v.views,
- commentsCount: v.comments,
- likesCount: v.likes,
- fansIncrease: v.fansIncrease,
- });
- cursor.setDate(cursor.getDate() + 1);
- }
- // ===== 按账号汇总:账号列表 & 顶部汇总 =====
- const perAccountMap = new Map<
- number,
- { rowCount: number; views: number; comments: number; likes: number; fansIncrease: number; latestUpdateTime: Date | null }
- >();
- for (const row of perAccountRows || []) {
- const accountId = Number(row.accountId) || 0;
- if (!accountId) continue;
- perAccountMap.set(accountId, {
- rowCount: Number(row.rowCount) || 0,
- views: Number(row.viewsCount) || 0,
- comments: Number(row.commentsCount) || 0,
- likes: Number(row.likesCount) || 0,
- fansIncrease: Number(row.fansIncrease) || 0,
- latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
- });
- }
- // 顶部汇总:直接用账号维度汇总,确保和“账号详细数据”一致
- let totalViews = 0;
- let totalComments = 0;
- let totalLikes = 0;
- let totalFansIncrease = 0;
- for (const agg of perAccountMap.values()) {
- totalViews += agg.views;
- totalComments += agg.comments;
- totalLikes += agg.likes;
- totalFansIncrease += agg.fansIncrease;
- }
- const accountList: Array<{
- id: number;
- nickname: string;
- username: string;
- avatarUrl: string | null;
- platform: string;
- income: number | null;
- recommendationCount: number | null;
- viewsCount: number | null;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- updateTime: string;
- }> = accounts.map((account) => {
- const agg =
- perAccountMap.get(account.id) ?? { rowCount: 0, views: 0, comments: 0, likes: 0, fansIncrease: 0, latestUpdateTime: null };
- const updateTime = agg.latestUpdateTime ? this.formatUpdateTime(agg.latestUpdateTime) : '';
- return {
- id: account.id,
- nickname: account.accountName || '',
- username: account.accountId || '',
- avatarUrl: account.avatarUrl,
- platform: account.platform,
- income: null,
- recommendationCount: null,
- // 没有任何记录时,前端展示“获取失败”,避免把“无数据”误显示成 0
- viewsCount: agg.rowCount > 0 ? agg.views : null,
- commentsCount: agg.comments,
- likesCount: agg.likes,
- fansIncrease: agg.fansIncrease,
- updateTime,
- };
- });
- return {
- summary: {
- totalAccounts,
- totalIncome: 0, // 收益数据需要从其他表获取
- viewsCount: totalViews,
- commentsCount: totalComments,
- likesCount: totalLikes,
- fansIncrease: totalFansIncrease,
- recommendationCount: null, // 推荐量(部分平台支持)
- },
- dailyData,
- accounts: accountList,
- };
- }
- /**
- * 格式化更新时间为统一的人类可读格式:
- * - 如果是今年:MM-DD HH:mm(例如:01-22 10:22)
- * - 如果是往年:YYYY-MM-DD HH:mm(例如:2025-12-22 10:22)
- */
- private formatUpdateTime(date: Date): string {
- const y = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- // 始终返回完整的 YYYY-MM-DD HH:mm 格式,避免前端 dayjs 解析省略年份时被误解析为 2001 年
- return `${y}-${month}-${day} ${hours}:${minutes}`;
- }
- }
|