WorkDayStatisticsService.ts 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. import { AppDataSource, WorkDayStatistics, Work, PlatformAccount, UserDayStatistics } from '../models/index.js';
  2. import { Between, In } from 'typeorm';
  3. import { logger } from '../utils/logger.js';
  4. interface StatisticsItem {
  5. workId: number;
  6. playCount?: number;
  7. likeCount?: number;
  8. commentCount?: number;
  9. shareCount?: number;
  10. collectCount?: number;
  11. }
  12. interface SaveResult {
  13. inserted: number;
  14. updated: number;
  15. }
  16. interface TrendData {
  17. dates: string[];
  18. fans: number[];
  19. views: number[];
  20. likes: number[];
  21. comments: number[];
  22. shares: number[];
  23. collects: number[];
  24. }
  25. interface PlatformStatItem {
  26. platform: string;
  27. fansCount: number;
  28. fansIncrease: number;
  29. viewsCount: number;
  30. likesCount: number;
  31. commentsCount: number;
  32. collectsCount: number;
  33. updateTime?: string;
  34. }
  35. interface WorkStatisticsItem {
  36. recordDate: string;
  37. playCount: number;
  38. likeCount: number;
  39. commentCount: number;
  40. shareCount: number;
  41. collectCount: number;
  42. }
  43. export class WorkDayStatisticsService {
  44. private statisticsRepository = AppDataSource.getRepository(WorkDayStatistics);
  45. private workRepository = AppDataSource.getRepository(Work);
  46. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  47. private userDayStatisticsRepository = AppDataSource.getRepository(UserDayStatistics);
  48. private formatDate(d: Date) {
  49. const yyyy = d.getFullYear();
  50. const mm = String(d.getMonth() + 1).padStart(2, '0');
  51. const dd = String(d.getDate()).padStart(2, '0');
  52. return `${yyyy}-${mm}-${dd}`;
  53. }
  54. /**
  55. * 获取某个账号在指定日期(<= targetDate)时各作品的“最新一条”累计数据总和
  56. * 口径:对该账号所有作品,每个作品取 record_date <= targetDate 的最大日期那条记录,然后把 play/like/comment/collect 求和
  57. */
  58. private async getWorkSumsAtDate(
  59. workIds: number[],
  60. targetDate: string
  61. ): Promise<{ views: number; likes: number; comments: number; collects: number }> {
  62. if (!workIds.length) {
  63. return { views: 0, likes: 0, comments: 0, collects: 0 };
  64. }
  65. // MySQL: 派生表先取每个作品 <= targetDate 的最新日期,再回连取该日数据求和
  66. // 注意:workIds 使用 IN (...),由 TypeORM 负责参数化,避免注入
  67. const placeholders = workIds.map(() => '?').join(',');
  68. const sql = `
  69. SELECT
  70. COALESCE(SUM(wds.play_count), 0) AS views,
  71. COALESCE(SUM(wds.like_count), 0) AS likes,
  72. COALESCE(SUM(wds.comment_count), 0) AS comments,
  73. COALESCE(SUM(wds.collect_count), 0) AS collects
  74. FROM work_day_statistics wds
  75. INNER JOIN (
  76. SELECT wds2.work_id, MAX(wds2.record_date) AS record_date
  77. FROM work_day_statistics wds2
  78. WHERE wds2.work_id IN (${placeholders})
  79. AND wds2.record_date <= ?
  80. GROUP BY wds2.work_id
  81. ) latest
  82. ON latest.work_id = wds.work_id AND latest.record_date = wds.record_date
  83. `;
  84. const rows = await AppDataSource.query(sql, [...workIds, targetDate]);
  85. const row = rows?.[0] || {};
  86. return {
  87. views: Number(row.views) || 0,
  88. likes: Number(row.likes) || 0,
  89. comments: Number(row.comments) || 0,
  90. collects: Number(row.collects) || 0,
  91. };
  92. }
  93. /**
  94. * 保存作品日统计数据
  95. * 当天的数据走更新流,日期变化走新增流
  96. */
  97. async saveStatistics(statistics: StatisticsItem[]): Promise<SaveResult> {
  98. const today = new Date();
  99. today.setHours(0, 0, 0, 0);
  100. let insertedCount = 0;
  101. let updatedCount = 0;
  102. for (const stat of statistics) {
  103. if (!stat.workId) continue;
  104. // 检查当天是否已有记录
  105. const existing = await this.statisticsRepository.findOne({
  106. where: {
  107. workId: stat.workId,
  108. recordDate: today,
  109. },
  110. });
  111. if (existing) {
  112. // 更新已有记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
  113. await this.statisticsRepository.update(existing.id, {
  114. playCount: stat.playCount ?? existing.playCount,
  115. likeCount: stat.likeCount ?? existing.likeCount,
  116. commentCount: stat.commentCount ?? existing.commentCount,
  117. shareCount: stat.shareCount ?? existing.shareCount,
  118. collectCount: stat.collectCount ?? existing.collectCount,
  119. });
  120. updatedCount++;
  121. } else {
  122. // 插入新记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
  123. const newStat = this.statisticsRepository.create({
  124. workId: stat.workId,
  125. recordDate: today,
  126. playCount: stat.playCount ?? 0,
  127. likeCount: stat.likeCount ?? 0,
  128. commentCount: stat.commentCount ?? 0,
  129. shareCount: stat.shareCount ?? 0,
  130. collectCount: stat.collectCount ?? 0,
  131. });
  132. await this.statisticsRepository.save(newStat);
  133. insertedCount++;
  134. }
  135. }
  136. return { inserted: insertedCount, updated: updatedCount };
  137. }
  138. /**
  139. * 获取数据趋势
  140. */
  141. async getTrend(
  142. userId: number,
  143. options: {
  144. days?: number;
  145. startDate?: string;
  146. endDate?: string;
  147. accountId?: number;
  148. }
  149. ): Promise<TrendData> {
  150. const { days = 7, startDate, endDate, accountId } = options;
  151. // 计算日期范围
  152. let dateStart: Date;
  153. let dateEnd: Date;
  154. if (startDate && endDate) {
  155. dateStart = new Date(startDate);
  156. dateEnd = new Date(endDate);
  157. } else {
  158. dateEnd = new Date();
  159. dateStart = new Date();
  160. dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
  161. }
  162. // 构建查询(不再从 work_day_statistics 读取粉丝数,粉丝数从 user_day_statistics 表获取)
  163. const queryBuilder = this.statisticsRepository
  164. .createQueryBuilder('wds')
  165. .innerJoin(Work, 'w', 'wds.work_id = w.id')
  166. .select('wds.record_date', 'recordDate')
  167. .addSelect('w.accountId', 'accountId')
  168. .addSelect('SUM(wds.play_count)', 'accountViews')
  169. .addSelect('SUM(wds.like_count)', 'accountLikes')
  170. .addSelect('SUM(wds.comment_count)', 'accountComments')
  171. .addSelect('SUM(wds.share_count)', 'accountShares')
  172. .addSelect('SUM(wds.collect_count)', 'accountCollects')
  173. .where('w.userId = :userId', { userId })
  174. .andWhere('wds.record_date >= :dateStart', { dateStart })
  175. .andWhere('wds.record_date <= :dateEnd', { dateEnd })
  176. .groupBy('wds.record_date')
  177. .addGroupBy('w.accountId')
  178. .orderBy('wds.record_date', 'ASC');
  179. if (accountId) {
  180. queryBuilder.andWhere('w.accountId = :accountId', { accountId });
  181. }
  182. const accountResults = await queryBuilder.getRawMany();
  183. // 按日期汇总所有账号的数据
  184. const dateMap = new Map<string, {
  185. fans: number;
  186. views: number;
  187. likes: number;
  188. comments: number;
  189. shares: number;
  190. collects: number;
  191. }>();
  192. for (const row of accountResults) {
  193. const dateKey = row.recordDate instanceof Date
  194. ? row.recordDate.toISOString().split('T')[0]
  195. : String(row.recordDate).split('T')[0];
  196. if (!dateMap.has(dateKey)) {
  197. dateMap.set(dateKey, {
  198. fans: 0,
  199. views: 0,
  200. likes: 0,
  201. comments: 0,
  202. shares: 0,
  203. collects: 0,
  204. });
  205. }
  206. const current = dateMap.get(dateKey)!;
  207. current.fans += parseInt(row.accountFans) || 0;
  208. current.views += parseInt(row.accountViews) || 0;
  209. current.likes += parseInt(row.accountLikes) || 0;
  210. current.comments += parseInt(row.accountComments) || 0;
  211. current.shares += parseInt(row.accountShares) || 0;
  212. current.collects += parseInt(row.accountCollects) || 0;
  213. }
  214. // 构建响应数据
  215. const dates: string[] = [];
  216. const fans: number[] = [];
  217. const views: number[] = [];
  218. const likes: number[] = [];
  219. const comments: number[] = [];
  220. const shares: number[] = [];
  221. const collects: number[] = [];
  222. // 按日期排序
  223. const sortedDates = Array.from(dateMap.keys()).sort();
  224. for (const dateKey of sortedDates) {
  225. dates.push(dateKey.slice(5)); // "YYYY-MM-DD" -> "MM-DD"
  226. const data = dateMap.get(dateKey)!;
  227. fans.push(data.fans);
  228. views.push(data.views);
  229. likes.push(data.likes);
  230. comments.push(data.comments);
  231. shares.push(data.shares);
  232. collects.push(data.collects);
  233. }
  234. // 如果没有数据,生成空的日期范围
  235. if (dates.length === 0) {
  236. const d = new Date(dateStart);
  237. while (d <= dateEnd) {
  238. dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
  239. fans.push(0);
  240. views.push(0);
  241. likes.push(0);
  242. comments.push(0);
  243. shares.push(0);
  244. collects.push(0);
  245. d.setDate(d.getDate() + 1);
  246. }
  247. }
  248. return { dates, fans, views, likes, comments, shares, collects };
  249. }
  250. /**
  251. * 按平台分组获取统计数据
  252. */
  253. async getStatisticsByPlatform(
  254. userId: number,
  255. options: {
  256. days?: number;
  257. startDate?: string;
  258. endDate?: string;
  259. }
  260. ): Promise<PlatformStatItem[]> {
  261. const { days = 30, startDate, endDate } = options;
  262. // 计算日期范围
  263. let dateStart: Date;
  264. let dateEnd: Date;
  265. if (startDate && endDate) {
  266. dateStart = new Date(startDate);
  267. dateEnd = new Date(endDate);
  268. } else {
  269. dateEnd = new Date();
  270. dateStart = new Date();
  271. dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
  272. }
  273. const endDateStr = endDate ? endDate : this.formatDate(dateEnd);
  274. const startDateStr = startDate ? startDate : this.formatDate(dateStart);
  275. const isSingleDay = startDateStr === endDateStr;
  276. // 单日查询(如“昨天”)按“当日增量”口径:当日粉丝 - 前一日粉丝
  277. const startBaselineStr = (() => {
  278. if (!isSingleDay) return startDateStr;
  279. const d = new Date(endDateStr);
  280. d.setDate(d.getDate() - 1);
  281. return this.formatDate(d);
  282. })();
  283. // 获取用户的所有账号
  284. const accounts = await this.accountRepository.find({
  285. where: { userId },
  286. });
  287. // 按平台聚合数据:Map<platform, { fansCount, fansIncrease, viewsCount, likesCount, commentsCount, collectsCount, latestUpdateTime }>
  288. const platformMap = new Map<string, {
  289. fansCount: number;
  290. fansIncrease: number;
  291. viewsCount: number;
  292. likesCount: number;
  293. commentsCount: number;
  294. collectsCount: number;
  295. latestUpdateTime: Date | null;
  296. }>();
  297. // 遍历每个账号,计算该账号的数据,然后累加到对应平台
  298. for (const account of accounts) {
  299. // 获取该账号的作品列表(用于按“所有作品累计值之和”计算)
  300. const works = await this.workRepository.find({
  301. where: { accountId: account.id },
  302. select: ['id'],
  303. });
  304. const workIds = works.map(w => w.id);
  305. // ===== 粉丝口径修正 =====
  306. // 取 endDate 当天粉丝(若当天没有记录,则取 <= endDate 的最近一条)
  307. const endUserStat =
  308. (await this.userDayStatisticsRepository
  309. .createQueryBuilder('uds')
  310. .where('uds.account_id = :accountId', { accountId: account.id })
  311. .andWhere('DATE(uds.record_date) = :d', { d: endDateStr })
  312. .getOne()) ??
  313. (await this.userDayStatisticsRepository
  314. .createQueryBuilder('uds')
  315. .where('uds.account_id = :accountId', { accountId: account.id })
  316. .andWhere('DATE(uds.record_date) <= :d', { d: endDateStr })
  317. .orderBy('uds.record_date', 'DESC')
  318. .getOne());
  319. const endFans = endUserStat?.fansCount ?? account.fansCount ?? 0;
  320. // 取 baseline 当天粉丝(若没有记录,则取 <= baseline 的最近一条)
  321. const baselineUserStat =
  322. (await this.userDayStatisticsRepository
  323. .createQueryBuilder('uds')
  324. .where('uds.account_id = :accountId', { accountId: account.id })
  325. .andWhere('DATE(uds.record_date) = :d', { d: startBaselineStr })
  326. .getOne()) ??
  327. (await this.userDayStatisticsRepository
  328. .createQueryBuilder('uds')
  329. .where('uds.account_id = :accountId', { accountId: account.id })
  330. .andWhere('DATE(uds.record_date) <= :d', { d: startBaselineStr })
  331. .orderBy('uds.record_date', 'DESC')
  332. .getOne());
  333. const baselineFans = baselineUserStat?.fansCount ?? endFans;
  334. // 涨粉量允许为负数(掉粉),不做截断
  335. const accountFansIncrease = endFans - baselineFans;
  336. // ===== 播放/点赞/评论/收藏口径修正 =====
  337. // 单日:当日累计 - 前一日累计
  338. // 区间:end 日累计 - start 日累计
  339. const [endSums, baseSums] = await Promise.all([
  340. this.getWorkSumsAtDate(workIds, endDateStr),
  341. this.getWorkSumsAtDate(workIds, startBaselineStr),
  342. ]);
  343. const accountViewsIncrease = endSums.views - baseSums.views;
  344. const accountLikesIncrease = endSums.likes - baseSums.likes;
  345. const accountCommentsIncrease = endSums.comments - baseSums.comments;
  346. const accountCollectsIncrease = endSums.collects - baseSums.collects;
  347. // 累加到平台聚合数据
  348. const platformKey = account.platform;
  349. if (!platformMap.has(platformKey)) {
  350. platformMap.set(platformKey, {
  351. fansCount: 0,
  352. fansIncrease: 0,
  353. viewsCount: 0,
  354. likesCount: 0,
  355. commentsCount: 0,
  356. collectsCount: 0,
  357. latestUpdateTime: null,
  358. });
  359. }
  360. const platformStat = platformMap.get(platformKey)!;
  361. platformStat.fansCount += endFans;
  362. platformStat.fansIncrease += accountFansIncrease;
  363. platformStat.viewsCount += accountViewsIncrease;
  364. platformStat.likesCount += accountLikesIncrease;
  365. platformStat.commentsCount += accountCommentsIncrease;
  366. platformStat.collectsCount += accountCollectsIncrease;
  367. // 查询该账号在当前时间段内的最晚 updated_at
  368. const latestUserStat = await this.userDayStatisticsRepository
  369. .createQueryBuilder('uds')
  370. .where('uds.account_id = :accountId', { accountId: account.id })
  371. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  372. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  373. .orderBy('uds.updated_at', 'DESC')
  374. .getOne();
  375. if (latestUserStat && latestUserStat.updatedAt) {
  376. // 更新平台的最晚更新时间
  377. if (!platformStat.latestUpdateTime || latestUserStat.updatedAt > platformStat.latestUpdateTime) {
  378. platformStat.latestUpdateTime = latestUserStat.updatedAt;
  379. }
  380. }
  381. }
  382. // 转换为数组格式,按粉丝数降序排序
  383. const platformData: PlatformStatItem[] = Array.from(platformMap.entries()).map(([platform, stat]) => {
  384. // 格式化更新时间为 "MM-DD HH:mm" 格式
  385. let updateTime: string | undefined;
  386. if (stat.latestUpdateTime) {
  387. const date = new Date(stat.latestUpdateTime);
  388. const month = String(date.getMonth() + 1).padStart(2, '0');
  389. const day = String(date.getDate()).padStart(2, '0');
  390. const hours = String(date.getHours()).padStart(2, '0');
  391. const minutes = String(date.getMinutes()).padStart(2, '0');
  392. updateTime = `${month}-${day} ${hours}:${minutes}`;
  393. }
  394. return {
  395. platform,
  396. fansCount: stat.fansCount,
  397. fansIncrease: stat.fansIncrease,
  398. viewsCount: stat.viewsCount,
  399. likesCount: stat.likesCount,
  400. commentsCount: stat.commentsCount,
  401. collectsCount: stat.collectsCount,
  402. updateTime,
  403. };
  404. });
  405. platformData.sort((a, b) => b.fansCount - a.fansCount);
  406. return platformData;
  407. }
  408. /**
  409. * 批量获取作品的历史统计数据
  410. */
  411. async getWorkStatisticsHistory(
  412. workIds: number[],
  413. options: {
  414. startDate?: string;
  415. endDate?: string;
  416. }
  417. ): Promise<Record<string, WorkStatisticsItem[]>> {
  418. const { startDate, endDate } = options;
  419. const queryBuilder = this.statisticsRepository
  420. .createQueryBuilder('wds')
  421. .select('wds.work_id', 'workId')
  422. .addSelect('wds.record_date', 'recordDate')
  423. .addSelect('wds.play_count', 'playCount')
  424. .addSelect('wds.like_count', 'likeCount')
  425. .addSelect('wds.comment_count', 'commentCount')
  426. .addSelect('wds.share_count', 'shareCount')
  427. .addSelect('wds.collect_count', 'collectCount')
  428. .where('wds.work_id IN (:...workIds)', { workIds })
  429. .orderBy('wds.work_id', 'ASC')
  430. .addOrderBy('wds.record_date', 'ASC');
  431. if (startDate) {
  432. queryBuilder.andWhere('wds.record_date >= :startDate', { startDate });
  433. }
  434. if (endDate) {
  435. queryBuilder.andWhere('wds.record_date <= :endDate', { endDate });
  436. }
  437. const results = await queryBuilder.getRawMany();
  438. // 按 workId 分组
  439. const groupedData: Record<string, WorkStatisticsItem[]> = {};
  440. for (const row of results) {
  441. const workId = String(row.workId);
  442. if (!groupedData[workId]) {
  443. groupedData[workId] = [];
  444. }
  445. const recordDate = row.recordDate instanceof Date
  446. ? row.recordDate.toISOString().split('T')[0]
  447. : String(row.recordDate).split('T')[0];
  448. groupedData[workId].push({
  449. recordDate,
  450. playCount: parseInt(row.playCount) || 0,
  451. likeCount: parseInt(row.likeCount) || 0,
  452. commentCount: parseInt(row.commentCount) || 0,
  453. shareCount: parseInt(row.shareCount) || 0,
  454. collectCount: parseInt(row.collectCount) || 0,
  455. });
  456. }
  457. return groupedData;
  458. }
  459. /**
  460. * 获取数据总览
  461. * 返回账号列表和汇总统计数据
  462. */
  463. async getOverview(userId: number): Promise<{
  464. accounts: Array<{
  465. id: number;
  466. nickname: string;
  467. username: string;
  468. avatarUrl: string | null;
  469. platform: string;
  470. groupId: number | null;
  471. groupName?: string | null;
  472. fansCount: number;
  473. totalIncome: number | null;
  474. yesterdayIncome: number | null;
  475. totalViews: number | null;
  476. yesterdayViews: number | null;
  477. yesterdayComments: number;
  478. yesterdayLikes: number;
  479. yesterdayFansIncrease: number;
  480. updateTime: string;
  481. status: string;
  482. }>;
  483. summary: {
  484. totalAccounts: number;
  485. totalIncome: number;
  486. yesterdayIncome: number;
  487. totalViews: number;
  488. yesterdayViews: number;
  489. totalFans: number;
  490. yesterdayComments: number;
  491. yesterdayLikes: number;
  492. yesterdayFansIncrease: number;
  493. };
  494. }> {
  495. // 只查询支持的平台:抖音、百家号、视频号、小红书
  496. const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
  497. // 获取用户的所有账号(只包含支持的平台)
  498. const accounts = await this.accountRepository.find({
  499. where: {
  500. userId,
  501. platform: In(allowedPlatforms),
  502. },
  503. relations: ['group'],
  504. });
  505. // 使用中国时区(UTC+8)计算“今天/昨天”的业务日期
  506. // 思路:在当前 UTC 时间基础上 +8 小时,再取 ISO 日期部分,即为中国日历日期
  507. const now = new Date();
  508. const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
  509. const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
  510. // 格式化为 YYYY-MM-DD,与 MySQL DATE 字段匹配
  511. const todayStr = chinaNow.toISOString().split('T')[0];
  512. const yesterdayStr = chinaYesterday.toISOString().split('T')[0];
  513. logger.info(`[WorkDayStatistics] getOverview - userId: ${userId}, today: ${todayStr}, yesterday: ${yesterdayStr}`);
  514. const accountList: Array<{
  515. id: number;
  516. nickname: string;
  517. username: string;
  518. avatarUrl: string | null;
  519. platform: string;
  520. groupId: number | null;
  521. fansCount: number;
  522. totalIncome: number | null;
  523. yesterdayIncome: number | null;
  524. totalViews: number | null;
  525. yesterdayViews: number | null;
  526. yesterdayComments: number;
  527. yesterdayLikes: number;
  528. yesterdayFansIncrease: number;
  529. updateTime: string;
  530. status: string;
  531. }> = [];
  532. // 汇总统计数据
  533. let totalAccounts = 0;
  534. let totalIncome = 0;
  535. let yesterdayIncome = 0;
  536. let totalViews = 0;
  537. let yesterdayViews = 0;
  538. let totalFans = 0;
  539. let yesterdayComments = 0;
  540. let yesterdayLikes = 0;
  541. let yesterdayFansIncrease = 0;
  542. for (const account of accounts) {
  543. // 获取该账号的所有作品ID
  544. const works = await this.workRepository.find({
  545. where: { accountId: account.id },
  546. select: ['id'],
  547. });
  548. if (works.length === 0) {
  549. // 如果没有作品,只返回账号基本信息
  550. // 从 user_day_statistics 表获取账号的最新粉丝数
  551. // 使用日期字符串直接查询(更可靠,避免时区问题)
  552. const todayUserStat = await this.userDayStatisticsRepository
  553. .createQueryBuilder('uds')
  554. .where('uds.account_id = :accountId', { accountId: account.id })
  555. .andWhere('DATE(uds.record_date) = :today', { today: todayStr })
  556. .getOne();
  557. const yesterdayUserStat = await this.userDayStatisticsRepository
  558. .createQueryBuilder('uds')
  559. .where('uds.account_id = :accountId', { accountId: account.id })
  560. .andWhere('DATE(uds.record_date) = :yesterday', { yesterday: yesterdayStr })
  561. .getOne();
  562. // 获取今天的粉丝数:优先使用统计数据,如果没有则使用账号表的当前值
  563. let accountFansCount: number;
  564. if (todayUserStat) {
  565. accountFansCount = todayUserStat.fansCount || 0;
  566. } else {
  567. accountFansCount = account.fansCount || 0;
  568. }
  569. // 计算昨日涨粉(今天粉丝数 - 昨天粉丝数)
  570. let accountYesterdayFansIncrease: number;
  571. if (yesterdayUserStat) {
  572. // 昨天有数据,直接使用
  573. const yesterdayFans = yesterdayUserStat.fansCount || 0;
  574. accountYesterdayFansIncrease = Math.max(0, accountFansCount - yesterdayFans);
  575. logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - yesterday: ${yesterdayFans}, today: ${accountFansCount}, increase: ${accountYesterdayFansIncrease}`);
  576. } else {
  577. // 昨天没有数据,查找最近一天(早于今天)的数据作为基准
  578. const recentUserStat = await this.userDayStatisticsRepository
  579. .createQueryBuilder('uds')
  580. .where('uds.account_id = :accountId', { accountId: account.id })
  581. .andWhere('DATE(uds.record_date) < :today', { today: todayStr })
  582. .orderBy('uds.record_date', 'DESC')
  583. .getOne();
  584. if (recentUserStat) {
  585. const recentFans = recentUserStat.fansCount || 0;
  586. accountYesterdayFansIncrease = Math.max(0, accountFansCount - recentFans);
  587. logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - using recent: ${recentFans}, today: ${accountFansCount}, increase: ${accountYesterdayFansIncrease}`);
  588. } else {
  589. // 完全没有历史数据,涨粉为 0
  590. accountYesterdayFansIncrease = 0;
  591. logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - no history data, increase: 0`);
  592. }
  593. }
  594. accountList.push({
  595. id: account.id,
  596. nickname: account.accountName || '',
  597. username: account.accountId || '',
  598. avatarUrl: account.avatarUrl,
  599. platform: account.platform,
  600. groupId: account.groupId,
  601. groupName: account.group?.name ?? null,
  602. fansCount: accountFansCount,
  603. totalIncome: null,
  604. yesterdayIncome: null,
  605. totalViews: null,
  606. yesterdayViews: null,
  607. yesterdayComments: 0,
  608. yesterdayLikes: 0,
  609. yesterdayFansIncrease: accountYesterdayFansIncrease,
  610. updateTime: account.updatedAt.toISOString(),
  611. status: account.status,
  612. });
  613. // 即使没有作品,也要累加账号的粉丝数到总粉丝数
  614. totalAccounts++;
  615. totalFans += accountFansCount;
  616. yesterdayFansIncrease += accountYesterdayFansIncrease;
  617. continue;
  618. }
  619. const workIds = works.map(w => w.id);
  620. // 获取每个作品的最新日期统计数据(总播放量等,不再包含粉丝数)
  621. const latestStatsQuery = this.statisticsRepository
  622. .createQueryBuilder('wds')
  623. .select('wds.work_id', 'workId')
  624. .addSelect('MAX(wds.record_date)', 'latestDate')
  625. .addSelect('MAX(wds.play_count)', 'playCount')
  626. .addSelect('MAX(wds.like_count)', 'likeCount')
  627. .addSelect('MAX(wds.comment_count)', 'commentCount')
  628. .where('wds.work_id IN (:...workIds)', { workIds })
  629. .groupBy('wds.work_id');
  630. const latestStats = await latestStatsQuery.getRawMany();
  631. // 计算总播放量(所有作品最新日期的play_count总和)
  632. let accountTotalViews = 0;
  633. const latestDateMap = new Map<number, string>();
  634. for (const stat of latestStats) {
  635. accountTotalViews += parseInt(stat.playCount) || 0;
  636. latestDateMap.set(stat.workId, stat.latestDate);
  637. }
  638. // 获取昨天和今天的数据来计算增量(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
  639. const yesterdayStatsQuery = this.statisticsRepository
  640. .createQueryBuilder('wds')
  641. .select('wds.work_id', 'workId')
  642. .addSelect('SUM(wds.play_count)', 'playCount')
  643. .addSelect('SUM(wds.like_count)', 'likeCount')
  644. .addSelect('SUM(wds.comment_count)', 'commentCount')
  645. .where('wds.work_id IN (:...workIds)', { workIds })
  646. .andWhere('wds.record_date = :yesterday', { yesterday: yesterdayStr })
  647. .groupBy('wds.work_id');
  648. const todayStatsQuery = this.statisticsRepository
  649. .createQueryBuilder('wds')
  650. .select('wds.work_id', 'workId')
  651. .addSelect('SUM(wds.play_count)', 'playCount')
  652. .addSelect('SUM(wds.like_count)', 'likeCount')
  653. .addSelect('SUM(wds.comment_count)', 'commentCount')
  654. .where('wds.work_id IN (:...workIds)', { workIds })
  655. .andWhere('wds.record_date = :today', { today: todayStr })
  656. .groupBy('wds.work_id');
  657. const [yesterdayStats, todayStats] = await Promise.all([
  658. yesterdayStatsQuery.getRawMany(),
  659. todayStatsQuery.getRawMany(),
  660. ]);
  661. logger.info(`[WorkDayStatistics] Account ${account.id} (${account.accountName}) - workIds: ${workIds.length}, yesterdayStats: ${yesterdayStats.length}, todayStats: ${todayStats.length}`);
  662. if (yesterdayStats.length > 0 || todayStats.length > 0) {
  663. logger.debug(`[WorkDayStatistics] yesterdayStats:`, JSON.stringify(yesterdayStats.slice(0, 3)));
  664. logger.debug(`[WorkDayStatistics] todayStats:`, JSON.stringify(todayStats.slice(0, 3)));
  665. }
  666. // 计算昨日增量
  667. let accountYesterdayViews = 0;
  668. let accountYesterdayComments = 0;
  669. let accountYesterdayLikes = 0;
  670. let accountYesterdayFansIncrease = 0;
  671. // 按作品ID汇总(不再包含粉丝数)
  672. const yesterdayMap = new Map<number, { play: number; like: number; comment: number }>();
  673. const todayMap = new Map<number, { play: number; like: number; comment: number }>();
  674. for (const stat of yesterdayStats) {
  675. const workId = parseInt(String(stat.workId)) || 0;
  676. yesterdayMap.set(workId, {
  677. play: Number(stat.playCount) || 0,
  678. like: Number(stat.likeCount) || 0,
  679. comment: Number(stat.commentCount) || 0,
  680. });
  681. }
  682. for (const stat of todayStats) {
  683. const workId = parseInt(String(stat.workId)) || 0;
  684. todayMap.set(workId, {
  685. play: Number(stat.playCount) || 0,
  686. like: Number(stat.likeCount) || 0,
  687. comment: Number(stat.commentCount) || 0,
  688. });
  689. }
  690. logger.debug(`[WorkDayStatistics] Account ${account.id} - yesterdayMap size: ${yesterdayMap.size}, todayMap size: ${todayMap.size}`);
  691. // 计算增量(今天 - 昨天,不再包含粉丝数)
  692. for (const workId of workIds) {
  693. const todayData = todayMap.get(workId) || { play: 0, like: 0, comment: 0 };
  694. const yesterdayData = yesterdayMap.get(workId) || { play: 0, like: 0, comment: 0 };
  695. const viewsDiff = todayData.play - yesterdayData.play;
  696. const commentsDiff = todayData.comment - yesterdayData.comment;
  697. const likesDiff = todayData.like - yesterdayData.like;
  698. accountYesterdayViews += Math.max(0, viewsDiff);
  699. accountYesterdayComments += Math.max(0, commentsDiff);
  700. accountYesterdayLikes += Math.max(0, likesDiff);
  701. }
  702. logger.info(`[WorkDayStatistics] Account ${account.id} - Calculated: views=${accountYesterdayViews}, comments=${accountYesterdayComments}, likes=${accountYesterdayLikes}`);
  703. // 从 user_day_statistics 表获取账号的最新粉丝数和作品数
  704. // 优先使用统计数据,如果没有则使用账号表的当前值
  705. // 使用日期字符串直接查询(更可靠,避免时区问题)
  706. // todayStr 和 yesterdayStr 是中国时区的日期字符串(如 "2026-01-26")
  707. // 直接使用字符串查询,TypeORM 会自动转换为 DATE 类型进行比较
  708. const todayUserStat = await this.userDayStatisticsRepository
  709. .createQueryBuilder('uds')
  710. .where('uds.account_id = :accountId', { accountId: account.id })
  711. .andWhere('DATE(uds.record_date) = :today', { today: todayStr })
  712. .getOne();
  713. const yesterdayUserStat = await this.userDayStatisticsRepository
  714. .createQueryBuilder('uds')
  715. .where('uds.account_id = :accountId', { accountId: account.id })
  716. .andWhere('DATE(uds.record_date) = :yesterday', { yesterday: yesterdayStr })
  717. .getOne();
  718. // 获取今天的粉丝数:优先使用统计数据,如果没有则使用账号表的当前值
  719. let accountFansCount: number;
  720. let accountWorksCount: number;
  721. if (todayUserStat) {
  722. accountFansCount = todayUserStat.fansCount || 0;
  723. accountWorksCount = todayUserStat.worksCount || 0;
  724. } else {
  725. // 今天没有统计数据,使用账号表的当前值
  726. accountFansCount = account.fansCount || 0;
  727. accountWorksCount = account.worksCount || 0;
  728. }
  729. // 计算昨日涨粉(今天粉丝数 - 昨天粉丝数)
  730. let yesterdayFans: number;
  731. if (yesterdayUserStat) {
  732. // 昨天有数据,直接使用
  733. yesterdayFans = yesterdayUserStat.fansCount || 0;
  734. logger.debug(`[WorkDayStatistics] Account ${account.id} - yesterday has data: ${yesterdayFans}, today: ${accountFansCount}`);
  735. } else {
  736. // 昨天没有数据,查找最近一天(早于今天)的数据作为基准
  737. const recentUserStat = await this.userDayStatisticsRepository
  738. .createQueryBuilder('uds')
  739. .where('uds.account_id = :accountId', { accountId: account.id })
  740. .andWhere('DATE(uds.record_date) < :today', { today: todayStr })
  741. .orderBy('uds.record_date', 'DESC')
  742. .getOne();
  743. if (recentUserStat) {
  744. yesterdayFans = recentUserStat.fansCount || 0;
  745. logger.debug(`[WorkDayStatistics] Account ${account.id} - using recent data: ${yesterdayFans}, today: ${accountFansCount}`);
  746. } else {
  747. // 完全没有历史数据,用账号表的当前粉丝数作为基准(涨粉为 0)
  748. yesterdayFans = accountFansCount;
  749. logger.debug(`[WorkDayStatistics] Account ${account.id} - no history data, using current: ${accountFansCount}`);
  750. }
  751. }
  752. // 计算涨粉数(今天 - 昨天),确保不为负数
  753. accountYesterdayFansIncrease = Math.max(0, accountFansCount - yesterdayFans);
  754. logger.info(`[WorkDayStatistics] Account ${account.id} - fans increase: ${accountFansCount} - ${yesterdayFans} = ${accountYesterdayFansIncrease}`);
  755. accountList.push({
  756. id: account.id,
  757. nickname: account.accountName || '',
  758. username: account.accountId || '',
  759. avatarUrl: account.avatarUrl,
  760. platform: account.platform,
  761. groupId: account.groupId,
  762. groupName: account.group?.name ?? null,
  763. fansCount: accountFansCount,
  764. totalIncome: null, // 收益数据需要从其他表获取,暂时为null
  765. yesterdayIncome: null,
  766. totalViews: accountTotalViews > 0 ? accountTotalViews : null,
  767. yesterdayViews: accountYesterdayViews > 0 ? accountYesterdayViews : null,
  768. yesterdayComments: accountYesterdayComments,
  769. yesterdayLikes: accountYesterdayLikes,
  770. yesterdayFansIncrease: accountYesterdayFansIncrease,
  771. updateTime: account.updatedAt.toISOString(),
  772. status: account.status,
  773. });
  774. // 累加汇总数据
  775. totalAccounts++;
  776. totalViews += accountTotalViews;
  777. yesterdayViews += accountYesterdayViews;
  778. totalFans += accountFansCount;
  779. yesterdayComments += accountYesterdayComments;
  780. yesterdayLikes += accountYesterdayLikes;
  781. yesterdayFansIncrease += accountYesterdayFansIncrease;
  782. }
  783. return {
  784. accounts: accountList,
  785. summary: {
  786. totalAccounts,
  787. totalIncome,
  788. yesterdayIncome,
  789. totalViews,
  790. yesterdayViews,
  791. totalFans,
  792. yesterdayComments,
  793. yesterdayLikes,
  794. yesterdayFansIncrease,
  795. },
  796. };
  797. }
  798. /**
  799. * 获取平台详情数据
  800. * 包括汇总统计、每日汇总数据和账号列表
  801. */
  802. async getPlatformDetail(
  803. userId: number,
  804. platform: string,
  805. options: {
  806. startDate: string;
  807. endDate: string;
  808. }
  809. ): Promise<{
  810. summary: {
  811. totalAccounts: number;
  812. totalIncome: number;
  813. viewsCount: number;
  814. commentsCount: number;
  815. likesCount: number;
  816. fansIncrease: number;
  817. recommendationCount: number | null; // 推荐量(部分平台支持)
  818. };
  819. dailyData: Array<{
  820. date: string;
  821. income: number;
  822. recommendationCount: number | null;
  823. viewsCount: number;
  824. commentsCount: number;
  825. likesCount: number;
  826. fansIncrease: number;
  827. }>;
  828. accounts: Array<{
  829. id: number;
  830. nickname: string;
  831. username: string;
  832. avatarUrl: string | null;
  833. platform: string;
  834. income: number | null;
  835. recommendationCount: number | null;
  836. viewsCount: number | null;
  837. commentsCount: number;
  838. likesCount: number;
  839. fansIncrease: number;
  840. updateTime: string;
  841. }>;
  842. }> {
  843. const { startDate, endDate } = options;
  844. const startDateStr = startDate;
  845. const endDateStr = endDate;
  846. // 获取该平台的所有账号
  847. const accounts = await this.accountRepository.find({
  848. where: {
  849. userId,
  850. platform: platform as any,
  851. },
  852. relations: ['group'],
  853. });
  854. if (accounts.length === 0) {
  855. return {
  856. summary: {
  857. totalAccounts: 0,
  858. totalIncome: 0,
  859. viewsCount: 0,
  860. commentsCount: 0,
  861. likesCount: 0,
  862. fansIncrease: 0,
  863. recommendationCount: null,
  864. },
  865. dailyData: [],
  866. accounts: [],
  867. };
  868. }
  869. // 计算汇总统计
  870. let totalAccounts = 0;
  871. let totalViews = 0;
  872. let totalComments = 0;
  873. let totalLikes = 0;
  874. let totalFansIncrease = 0;
  875. // 按日期汇总数据
  876. const dailyMap = new Map<string, {
  877. views: number;
  878. comments: number;
  879. likes: number;
  880. fansIncrease: number;
  881. }>();
  882. // 账号详细列表
  883. const accountList: Array<{
  884. id: number;
  885. nickname: string;
  886. username: string;
  887. avatarUrl: string | null;
  888. platform: string;
  889. income: number | null;
  890. recommendationCount: number | null;
  891. viewsCount: number | null;
  892. commentsCount: number;
  893. likesCount: number;
  894. fansIncrease: number;
  895. updateTime: string;
  896. }> = [];
  897. for (const account of accounts) {
  898. const works = await this.workRepository.find({
  899. where: { accountId: account.id },
  900. select: ['id'],
  901. });
  902. const workIds = works.map(w => w.id);
  903. // 获取该账号在日期范围内的每日数据
  904. const dateStart = new Date(startDateStr);
  905. const dateEnd = new Date(endDateStr);
  906. const currentDate = new Date(dateStart);
  907. while (currentDate <= dateEnd) {
  908. const dateStr = this.formatDate(currentDate);
  909. // 获取该日期的数据
  910. const [daySums, prevDaySums] = await Promise.all([
  911. this.getWorkSumsAtDate(workIds, dateStr),
  912. this.getWorkSumsAtDate(workIds, this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000))),
  913. ]);
  914. const dayViews = daySums.views - prevDaySums.views;
  915. const dayComments = daySums.comments - prevDaySums.comments;
  916. const dayLikes = daySums.likes - prevDaySums.likes;
  917. // 获取粉丝数据
  918. const dayUserStat = await this.userDayStatisticsRepository
  919. .createQueryBuilder('uds')
  920. .where('uds.account_id = :accountId', { accountId: account.id })
  921. .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
  922. .getOne();
  923. const prevDayUserStat = await this.userDayStatisticsRepository
  924. .createQueryBuilder('uds')
  925. .where('uds.account_id = :accountId', { accountId: account.id })
  926. .andWhere('DATE(uds.record_date) = :d', { d: this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000)) })
  927. .getOne();
  928. const dayFans = (dayUserStat?.fansCount || 0) - (prevDayUserStat?.fansCount || 0);
  929. if (!dailyMap.has(dateStr)) {
  930. dailyMap.set(dateStr, {
  931. views: 0,
  932. comments: 0,
  933. likes: 0,
  934. fansIncrease: 0,
  935. });
  936. }
  937. const daily = dailyMap.get(dateStr)!;
  938. daily.views += Math.max(0, dayViews);
  939. daily.comments += Math.max(0, dayComments);
  940. daily.likes += Math.max(0, dayLikes);
  941. daily.fansIncrease += Math.max(0, dayFans);
  942. currentDate.setDate(currentDate.getDate() + 1);
  943. }
  944. // 计算账号的总数据(使用 endDate 的数据)
  945. const [endSums, startSums] = await Promise.all([
  946. this.getWorkSumsAtDate(workIds, endDateStr),
  947. this.getWorkSumsAtDate(workIds, startDateStr),
  948. ]);
  949. const accountViews = endSums.views - startSums.views;
  950. const accountComments = endSums.comments - startSums.comments;
  951. const accountLikes = endSums.likes - startSums.likes;
  952. // 获取粉丝数据
  953. const endUserStat = await this.userDayStatisticsRepository
  954. .createQueryBuilder('uds')
  955. .where('uds.account_id = :accountId', { accountId: account.id })
  956. .andWhere('DATE(uds.record_date) <= :d', { d: endDateStr })
  957. .orderBy('uds.record_date', 'DESC')
  958. .getOne();
  959. const startUserStat = await this.userDayStatisticsRepository
  960. .createQueryBuilder('uds')
  961. .where('uds.account_id = :accountId', { accountId: account.id })
  962. .andWhere('DATE(uds.record_date) <= :d', { d: startDateStr })
  963. .orderBy('uds.record_date', 'DESC')
  964. .getOne();
  965. const accountFansIncrease = (endUserStat?.fansCount || 0) - (startUserStat?.fansCount || 0);
  966. // 获取更新时间
  967. const latestUserStat = await this.userDayStatisticsRepository
  968. .createQueryBuilder('uds')
  969. .where('uds.account_id = :accountId', { accountId: account.id })
  970. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  971. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  972. .orderBy('uds.updated_at', 'DESC')
  973. .getOne();
  974. const updateTime = latestUserStat?.updatedAt
  975. ? this.formatUpdateTime(latestUserStat.updatedAt)
  976. : '';
  977. accountList.push({
  978. id: account.id,
  979. nickname: account.accountName || '',
  980. username: account.accountId || '',
  981. avatarUrl: account.avatarUrl,
  982. platform: account.platform,
  983. income: null, // 收益数据需要从其他表获取
  984. recommendationCount: null, // 推荐量(部分平台支持)
  985. viewsCount: accountViews > 0 ? accountViews : null,
  986. commentsCount: Math.max(0, accountComments),
  987. likesCount: Math.max(0, accountLikes),
  988. fansIncrease: Math.max(0, accountFansIncrease),
  989. updateTime,
  990. });
  991. totalAccounts++;
  992. totalViews += Math.max(0, accountViews);
  993. totalComments += Math.max(0, accountComments);
  994. totalLikes += Math.max(0, accountLikes);
  995. totalFansIncrease += Math.max(0, accountFansIncrease);
  996. }
  997. // 转换为每日数据数组
  998. const dailyData = Array.from(dailyMap.entries())
  999. .map(([date, data]) => ({
  1000. date,
  1001. income: 0, // 收益数据需要从其他表获取
  1002. recommendationCount: null, // 推荐量(部分平台支持)
  1003. viewsCount: data.views,
  1004. commentsCount: data.comments,
  1005. likesCount: data.likes,
  1006. fansIncrease: data.fansIncrease,
  1007. }))
  1008. .sort((a, b) => a.date.localeCompare(b.date));
  1009. return {
  1010. summary: {
  1011. totalAccounts,
  1012. totalIncome: 0, // 收益数据需要从其他表获取
  1013. viewsCount: totalViews,
  1014. commentsCount: totalComments,
  1015. likesCount: totalLikes,
  1016. fansIncrease: totalFansIncrease,
  1017. recommendationCount: null, // 推荐量(部分平台支持)
  1018. },
  1019. dailyData,
  1020. accounts: accountList,
  1021. };
  1022. }
  1023. /**
  1024. * 格式化更新时间为 "MM-DD HH:mm" 格式
  1025. */
  1026. private formatUpdateTime(date: Date): string {
  1027. const month = String(date.getMonth() + 1).padStart(2, '0');
  1028. const day = String(date.getDate()).padStart(2, '0');
  1029. const hours = String(date.getHours()).padStart(2, '0');
  1030. const minutes = String(date.getMinutes()).padStart(2, '0');
  1031. return `${month}-${day} ${hours}:${minutes}`;
  1032. }
  1033. }