WorkDayStatisticsService.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994
  1. import { AppDataSource, WorkDayStatistics, Work, PlatformAccount, UserDayStatistics } from '../models/index.js';
  2. import { 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. /**
  276. * 口径变更:user_day_statistics 的 play/comment/like/collect/fans_increase 等字段为“每日单独值”
  277. * 因此:
  278. * - 区间统计:直接按日期范围 SUM
  279. * - 单日统计:startDate=endDate 时,也同样按该日 SUM(无需再做“累计差”)
  280. * 粉丝数:使用 platform_accounts.fans_count(当前值)
  281. */
  282. const [fansRows, udsRows] = await Promise.all([
  283. this.accountRepository
  284. .createQueryBuilder('pa')
  285. .select('pa.platform', 'platform')
  286. .addSelect('COALESCE(SUM(pa.fansCount), 0)', 'fansCount')
  287. .where('pa.userId = :userId', { userId })
  288. .groupBy('pa.platform')
  289. .getRawMany(),
  290. this.userDayStatisticsRepository
  291. .createQueryBuilder('uds')
  292. .innerJoin(PlatformAccount, 'pa', 'pa.id = uds.account_id')
  293. .select('pa.platform', 'platform')
  294. .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
  295. .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
  296. .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
  297. .addSelect('COALESCE(SUM(uds.collect_count), 0)', 'collectsCount')
  298. .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
  299. .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
  300. .where('pa.user_id = :userId', { userId })
  301. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  302. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  303. .groupBy('pa.platform')
  304. .getRawMany(),
  305. ]);
  306. const fansMap = new Map<string, number>();
  307. for (const row of fansRows || []) {
  308. const platform = String(row.platform || '');
  309. if (!platform) continue;
  310. fansMap.set(platform, Number(row.fansCount) || 0);
  311. }
  312. const statMap = new Map<string, {
  313. viewsCount: number;
  314. commentsCount: number;
  315. likesCount: number;
  316. collectsCount: number;
  317. fansIncrease: number;
  318. latestUpdateTime?: string | Date | null;
  319. }>();
  320. for (const row of udsRows || []) {
  321. const platform = String(row.platform || '');
  322. if (!platform) continue;
  323. statMap.set(platform, {
  324. viewsCount: Number(row.viewsCount) || 0,
  325. commentsCount: Number(row.commentsCount) || 0,
  326. likesCount: Number(row.likesCount) || 0,
  327. collectsCount: Number(row.collectsCount) || 0,
  328. fansIncrease: Number(row.fansIncrease) || 0,
  329. latestUpdateTime: row.latestUpdateTime ?? null,
  330. });
  331. }
  332. const platforms = new Set<string>([...fansMap.keys(), ...statMap.keys()]);
  333. const platformData: PlatformStatItem[] = Array.from(platforms).map((platform) => {
  334. const stat = statMap.get(platform);
  335. const fansCount = fansMap.get(platform) ?? 0;
  336. const latestUpdate = stat?.latestUpdateTime ? new Date(stat.latestUpdateTime as any) : null;
  337. return {
  338. platform,
  339. fansCount,
  340. fansIncrease: stat?.fansIncrease ?? 0,
  341. viewsCount: stat?.viewsCount ?? 0,
  342. likesCount: stat?.likesCount ?? 0,
  343. commentsCount: stat?.commentsCount ?? 0,
  344. collectsCount: stat?.collectsCount ?? 0,
  345. updateTime: latestUpdate ? latestUpdate.toISOString() : undefined,
  346. };
  347. });
  348. platformData.sort((a, b) => (b.fansCount || 0) - (a.fansCount || 0));
  349. return platformData;
  350. }
  351. /**
  352. * 批量获取作品的历史统计数据
  353. */
  354. async getWorkStatisticsHistory(
  355. workIds: number[],
  356. options: {
  357. startDate?: string;
  358. endDate?: string;
  359. }
  360. ): Promise<Record<string, WorkStatisticsItem[]>> {
  361. const { startDate, endDate } = options;
  362. const queryBuilder = this.statisticsRepository
  363. .createQueryBuilder('wds')
  364. .select('wds.work_id', 'workId')
  365. .addSelect('wds.record_date', 'recordDate')
  366. .addSelect('wds.play_count', 'playCount')
  367. .addSelect('wds.like_count', 'likeCount')
  368. .addSelect('wds.comment_count', 'commentCount')
  369. .addSelect('wds.share_count', 'shareCount')
  370. .addSelect('wds.collect_count', 'collectCount')
  371. .where('wds.work_id IN (:...workIds)', { workIds })
  372. .orderBy('wds.work_id', 'ASC')
  373. .addOrderBy('wds.record_date', 'ASC');
  374. if (startDate) {
  375. queryBuilder.andWhere('wds.record_date >= :startDate', { startDate });
  376. }
  377. if (endDate) {
  378. queryBuilder.andWhere('wds.record_date <= :endDate', { endDate });
  379. }
  380. const results = await queryBuilder.getRawMany();
  381. // 按 workId 分组
  382. const groupedData: Record<string, WorkStatisticsItem[]> = {};
  383. for (const row of results) {
  384. const workId = String(row.workId);
  385. if (!groupedData[workId]) {
  386. groupedData[workId] = [];
  387. }
  388. const recordDate = row.recordDate instanceof Date
  389. ? row.recordDate.toISOString().split('T')[0]
  390. : String(row.recordDate).split('T')[0];
  391. groupedData[workId].push({
  392. recordDate,
  393. playCount: parseInt(row.playCount) || 0,
  394. likeCount: parseInt(row.likeCount) || 0,
  395. commentCount: parseInt(row.commentCount) || 0,
  396. shareCount: parseInt(row.shareCount) || 0,
  397. collectCount: parseInt(row.collectCount) || 0,
  398. });
  399. }
  400. return groupedData;
  401. }
  402. /**
  403. * 获取每个账号在指定日期(<= targetDate)时,user_day_statistics 的“最新一条”
  404. * 主要用于:总粉丝、更新时间等需要“最新状态”的字段。
  405. */
  406. private async getLatestUserDayStatsAtDate(
  407. accountIds: number[],
  408. targetDate: string
  409. ): Promise<Map<number, { fansCount: number; updatedAt: Date | null; recordDate: Date | null }>> {
  410. const map = new Map<number, { fansCount: number; updatedAt: Date | null; recordDate: Date | null }>();
  411. if (!accountIds.length) return map;
  412. // MySQL: 派生表先取每个账号 <= targetDate 的最新日期,再回连取该日数据
  413. const placeholders = accountIds.map(() => '?').join(',');
  414. const sql = `
  415. SELECT
  416. uds.account_id AS accountId,
  417. uds.fans_count AS fansCount,
  418. uds.record_date AS recordDate,
  419. uds.updated_at AS updatedAt
  420. FROM user_day_statistics uds
  421. INNER JOIN (
  422. SELECT account_id, MAX(record_date) AS record_date
  423. FROM user_day_statistics
  424. WHERE account_id IN (${placeholders})
  425. AND record_date <= ?
  426. GROUP BY account_id
  427. ) latest
  428. ON latest.account_id = uds.account_id AND latest.record_date = uds.record_date
  429. `;
  430. const rows: any[] = await AppDataSource.query(sql, [...accountIds, targetDate]);
  431. for (const row of rows || []) {
  432. const accountId = Number(row.accountId) || 0;
  433. if (!accountId) continue;
  434. const fansCount = Number(row.fansCount) || 0;
  435. const recordDate = row.recordDate ? new Date(row.recordDate) : null;
  436. const updatedAt = row.updatedAt ? new Date(row.updatedAt) : null;
  437. map.set(accountId, { fansCount, updatedAt, recordDate });
  438. }
  439. return map;
  440. }
  441. /**
  442. * 获取指定日期(= dateStr)每个账号在 user_day_statistics 的数据(用于“昨日”口径的一一对应)。
  443. */
  444. private async getUserDayStatsByExactDate(
  445. accountIds: number[],
  446. dateStr: string
  447. ): Promise<Map<number, { playCount: number; commentCount: number; likeCount: number; fansIncrease: number; updatedAt: Date | null }>> {
  448. const map = new Map<number, { playCount: number; commentCount: number; likeCount: number; fansIncrease: number; updatedAt: Date | null }>();
  449. if (!accountIds.length) return map;
  450. const rows = await this.userDayStatisticsRepository
  451. .createQueryBuilder('uds')
  452. .select('uds.account_id', 'accountId')
  453. .addSelect('uds.play_count', 'playCount')
  454. .addSelect('uds.comment_count', 'commentCount')
  455. .addSelect('uds.like_count', 'likeCount')
  456. .addSelect('uds.fans_increase', 'fansIncrease')
  457. .addSelect('uds.updated_at', 'updatedAt')
  458. .where('uds.account_id IN (:...accountIds)', { accountIds })
  459. .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
  460. .getRawMany();
  461. for (const row of rows || []) {
  462. const accountId = Number(row.accountId) || 0;
  463. if (!accountId) continue;
  464. map.set(accountId, {
  465. playCount: Number(row.playCount) || 0,
  466. commentCount: Number(row.commentCount) || 0,
  467. likeCount: Number(row.likeCount) || 0,
  468. fansIncrease: Number(row.fansIncrease) || 0,
  469. updatedAt: row.updatedAt ? new Date(row.updatedAt) : null,
  470. });
  471. }
  472. return map;
  473. }
  474. /**
  475. * 获取数据总览
  476. * 返回账号列表和汇总统计数据
  477. */
  478. async getOverview(userId: number): Promise<{
  479. accounts: Array<{
  480. id: number;
  481. nickname: string;
  482. username: string;
  483. avatarUrl: string | null;
  484. platform: string;
  485. groupId: number | null;
  486. groupName?: string | null;
  487. fansCount: number;
  488. totalIncome: number | null;
  489. yesterdayIncome: number | null;
  490. totalViews: number | null;
  491. yesterdayViews: number | null;
  492. yesterdayComments: number;
  493. yesterdayLikes: number;
  494. yesterdayFansIncrease: number;
  495. updateTime: string;
  496. status: string;
  497. }>;
  498. summary: {
  499. totalAccounts: number;
  500. totalIncome: number;
  501. yesterdayIncome: number;
  502. totalViews: number;
  503. yesterdayViews: number;
  504. totalFans: number;
  505. yesterdayComments: number;
  506. yesterdayLikes: number;
  507. yesterdayFansIncrease: number;
  508. };
  509. }> {
  510. // 只查询支持的平台:抖音、百家号、视频号、小红书
  511. const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
  512. // 获取用户的所有账号(只包含支持的平台)
  513. const accounts = await this.accountRepository.find({
  514. where: {
  515. userId,
  516. platform: In(allowedPlatforms),
  517. },
  518. relations: ['group'],
  519. });
  520. // 使用中国时区(UTC+8)计算“今天/昨天”的业务日期
  521. // 思路:在当前 UTC 时间基础上 +8 小时,再取 ISO 日期部分,即为中国日历日期
  522. const now = new Date();
  523. const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
  524. const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
  525. // 格式化为 YYYY-MM-DD,与 MySQL DATE 字段匹配
  526. const todayStr = chinaNow.toISOString().split('T')[0];
  527. const yesterdayStr = chinaYesterday.toISOString().split('T')[0];
  528. logger.info(`[WorkDayStatistics] getOverview - userId: ${userId}, today: ${todayStr}, yesterday: ${yesterdayStr}`);
  529. const accountIds = accounts.map(a => a.id);
  530. // 账号总数:platform_accounts 中 user_id 对应数量(等价于 accounts.length)
  531. const totalAccounts = accounts.length;
  532. // 列表“总播放/汇总总播放”:统一从 works.play_count 聚合(累计)
  533. const worksPlayRows = accountIds.length
  534. ? await this.workRepository
  535. .createQueryBuilder('w')
  536. .select('w.accountId', 'accountId')
  537. .addSelect('COALESCE(SUM(w.playCount), 0)', 'playCount')
  538. .where('w.userId = :userId', { userId })
  539. .andWhere('w.accountId IN (:...accountIds)', { accountIds })
  540. .groupBy('w.accountId')
  541. .getRawMany()
  542. : [];
  543. const totalPlayMap = new Map<number, number>();
  544. for (const row of worksPlayRows || []) {
  545. totalPlayMap.set(Number(row.accountId) || 0, Number(row.playCount) || 0);
  546. }
  547. // “昨日”口径:只取 user_day_statistics 指定日期那一行,一一对应
  548. const yesterdayUdsMap = await this.getUserDayStatsByExactDate(accountIds, yesterdayStr);
  549. // 粉丝数口径:直接取 platform_accounts.fans_count(不跟随 user_day_statistics)
  550. const accountList: Array<{
  551. id: number;
  552. nickname: string;
  553. username: string;
  554. avatarUrl: string | null;
  555. platform: string;
  556. groupId: number | null;
  557. groupName?: string | null;
  558. fansCount: number;
  559. totalIncome: number | null;
  560. yesterdayIncome: number | null;
  561. totalViews: number | null;
  562. yesterdayViews: number | null;
  563. yesterdayComments: number;
  564. yesterdayLikes: number;
  565. yesterdayFansIncrease: number;
  566. updateTime: string;
  567. status: string;
  568. }> = [];
  569. // 汇总统计数据
  570. let totalIncome = 0;
  571. let yesterdayIncome = 0;
  572. let totalViews = 0;
  573. let yesterdayViews = 0;
  574. let totalFans = 0;
  575. let yesterdayComments = 0;
  576. let yesterdayLikes = 0;
  577. let yesterdayFansIncrease = 0;
  578. for (const account of accounts) {
  579. const accountTotalViews = totalPlayMap.get(account.id) ?? 0;
  580. const yesterdayUds = yesterdayUdsMap.get(account.id);
  581. const accountFansCount = account.fansCount || 0;
  582. const accountYesterdayViews = yesterdayUds?.playCount ?? 0;
  583. const accountYesterdayComments = yesterdayUds?.commentCount ?? 0;
  584. const accountYesterdayLikes = yesterdayUds?.likeCount ?? 0;
  585. const accountYesterdayFansIncrease = yesterdayUds?.fansIncrease ?? 0;
  586. const updateTime = (yesterdayUds?.updatedAt ?? account.updatedAt).toISOString();
  587. accountList.push({
  588. id: account.id,
  589. nickname: account.accountName || '',
  590. username: account.accountId || '',
  591. avatarUrl: account.avatarUrl,
  592. platform: account.platform,
  593. groupId: account.groupId,
  594. groupName: account.group?.name ?? null,
  595. fansCount: accountFansCount,
  596. totalIncome: null,
  597. yesterdayIncome: null,
  598. totalViews: accountTotalViews,
  599. yesterdayViews: accountYesterdayViews,
  600. yesterdayComments: accountYesterdayComments,
  601. yesterdayLikes: accountYesterdayLikes,
  602. yesterdayFansIncrease: accountYesterdayFansIncrease,
  603. updateTime,
  604. status: account.status,
  605. });
  606. totalViews += accountTotalViews;
  607. totalFans += accountFansCount;
  608. yesterdayViews += accountYesterdayViews;
  609. yesterdayComments += accountYesterdayComments;
  610. yesterdayLikes += accountYesterdayLikes;
  611. yesterdayFansIncrease += accountYesterdayFansIncrease;
  612. }
  613. return {
  614. accounts: accountList,
  615. summary: {
  616. totalAccounts,
  617. totalIncome,
  618. yesterdayIncome,
  619. totalViews,
  620. yesterdayViews,
  621. totalFans,
  622. yesterdayComments,
  623. yesterdayLikes,
  624. yesterdayFansIncrease,
  625. },
  626. };
  627. }
  628. /**
  629. * 获取平台详情数据
  630. * 包括汇总统计、每日汇总数据和账号列表
  631. */
  632. async getPlatformDetail(
  633. userId: number,
  634. platform: string,
  635. options: {
  636. startDate: string;
  637. endDate: string;
  638. }
  639. ): Promise<{
  640. summary: {
  641. totalAccounts: number;
  642. totalIncome: number;
  643. viewsCount: number;
  644. commentsCount: number;
  645. likesCount: number;
  646. fansIncrease: number;
  647. recommendationCount: number | null; // 推荐量(部分平台支持)
  648. };
  649. dailyData: Array<{
  650. date: string;
  651. income: number;
  652. recommendationCount: number | null;
  653. viewsCount: number;
  654. commentsCount: number;
  655. likesCount: number;
  656. fansIncrease: number;
  657. }>;
  658. accounts: Array<{
  659. id: number;
  660. nickname: string;
  661. username: string;
  662. avatarUrl: string | null;
  663. platform: string;
  664. income: number | null;
  665. recommendationCount: number | null;
  666. viewsCount: number | null;
  667. commentsCount: number;
  668. likesCount: number;
  669. fansIncrease: number;
  670. updateTime: string;
  671. }>;
  672. }> {
  673. const { startDate, endDate } = options;
  674. const startDateStr = startDate;
  675. const endDateStr = endDate;
  676. // 获取该平台的所有账号
  677. const accounts = await this.accountRepository.find({
  678. where: {
  679. userId,
  680. platform: platform as any,
  681. },
  682. relations: ['group'],
  683. });
  684. if (accounts.length === 0) {
  685. return {
  686. summary: {
  687. totalAccounts: 0,
  688. totalIncome: 0,
  689. viewsCount: 0,
  690. commentsCount: 0,
  691. likesCount: 0,
  692. fansIncrease: 0,
  693. recommendationCount: null,
  694. },
  695. dailyData: [],
  696. accounts: [],
  697. };
  698. }
  699. /**
  700. * 口径变更:user_day_statistics 的各项数据为“每日单独值”,不再是累计值
  701. * 因此平台详情:
  702. * - 区间汇总:直接 SUM(user_day_statistics.*)(按账号、按天)
  703. * - 每日汇总:按 record_date 分组 SUM
  704. */
  705. const accountIds = accounts.map(a => a.id);
  706. const totalAccounts = accounts.length;
  707. const [dailyRows, perAccountRows] = await Promise.all([
  708. // 按日期维度汇总(每天所有账号数据之和)
  709. // 这里直接使用原生 SQL,以保证和文档/手动验证时看到的 SQL 完全一致:
  710. //
  711. // SELECT
  712. // uds.record_date AS recordDate,
  713. // COALESCE(SUM(uds.play_count), 0) AS viewsCount,
  714. // COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
  715. // COALESCE(SUM(uds.like_count), 0) AS likesCount,
  716. // COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
  717. // FROM user_day_statistics uds
  718. // WHERE uds.account_id IN (...)
  719. // AND uds.record_date >= ?
  720. // AND uds.record_date <= ?
  721. // GROUP BY uds.record_date
  722. // ORDER BY uds.record_date ASC;
  723. (async () => {
  724. if (!accountIds.length) return [];
  725. const inPlaceholders = accountIds.map(() => '?').join(',');
  726. const sql = `
  727. SELECT
  728. uds.record_date AS recordDate,
  729. COALESCE(SUM(uds.play_count), 0) AS viewsCount,
  730. COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
  731. COALESCE(SUM(uds.like_count), 0) AS likesCount,
  732. COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
  733. FROM user_day_statistics uds
  734. WHERE uds.account_id IN (${inPlaceholders})
  735. AND uds.record_date >= ?
  736. AND uds.record_date <= ?
  737. GROUP BY uds.record_date
  738. ORDER BY uds.record_date ASC
  739. `;
  740. const params = [...accountIds, startDateStr, endDateStr];
  741. return await AppDataSource.query(sql, params);
  742. })(),
  743. // 按账号维度汇总(区间内所有天的和)
  744. this.userDayStatisticsRepository
  745. .createQueryBuilder('uds')
  746. .select('uds.account_id', 'accountId')
  747. .addSelect('COUNT(1)', 'rowCount')
  748. .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
  749. .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
  750. .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
  751. .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
  752. .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
  753. .where('uds.account_id IN (:...accountIds)', { accountIds })
  754. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  755. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  756. .groupBy('uds.account_id')
  757. .getRawMany(),
  758. ]);
  759. // ===== 按日期汇总:每日汇总数据 =====
  760. const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
  761. for (const row of dailyRows || []) {
  762. if (!row.recordDate) continue;
  763. /**
  764. * 注意:record_date 在实体里是 DATE 类型,TypeORM 读出来通常是 Date 对象。
  765. * 之前用 String(row.recordDate).slice(0, 10) 会得到类似 "Wed Jan 28" 这样的字符串前 10 位,
  766. * 导致 key 和下面 this.formatDate(cursor) 生成的 "YYYY-MM-DD" 不一致,从而 dailyMap 命中失败,全部变成 0。
  767. *
  768. * 这里改成显式按本地时间拼出 "YYYY-MM-DD",确保与 startDate/endDate 的格式一致。
  769. */
  770. let dateKey: string;
  771. if (row.recordDate instanceof Date) {
  772. const y = row.recordDate.getFullYear();
  773. const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
  774. const d = String(row.recordDate.getDate()).padStart(2, '0');
  775. dateKey = `${y}-${m}-${d}`;
  776. } else {
  777. // 数据库如果已经返回字符串,例如 "2026-01-28",直接截前 10 位即可
  778. dateKey = String(row.recordDate).slice(0, 10);
  779. }
  780. const prev = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
  781. dailyMap.set(dateKey, {
  782. views: prev.views + (Number(row.viewsCount) || 0),
  783. comments: prev.comments + (Number(row.commentsCount) || 0),
  784. likes: prev.likes + (Number(row.likesCount) || 0),
  785. fansIncrease: prev.fansIncrease + (Number(row.fansIncrease) || 0),
  786. });
  787. }
  788. // 补齐日期区间(没有数据也返回 0)
  789. const dailyData: Array<{
  790. date: string;
  791. income: number;
  792. recommendationCount: number | null;
  793. viewsCount: number;
  794. commentsCount: number;
  795. likesCount: number;
  796. fansIncrease: number;
  797. }> = [];
  798. const dStart = new Date(startDateStr);
  799. const dEnd = new Date(endDateStr);
  800. const cursor = new Date(dStart);
  801. while (cursor <= dEnd) {
  802. const dateKey = this.formatDate(cursor);
  803. const v = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
  804. dailyData.push({
  805. date: dateKey,
  806. income: 0,
  807. recommendationCount: null,
  808. viewsCount: v.views,
  809. commentsCount: v.comments,
  810. likesCount: v.likes,
  811. fansIncrease: v.fansIncrease,
  812. });
  813. cursor.setDate(cursor.getDate() + 1);
  814. }
  815. // ===== 按账号汇总:账号列表 & 顶部汇总 =====
  816. const perAccountMap = new Map<
  817. number,
  818. { rowCount: number; views: number; comments: number; likes: number; fansIncrease: number; latestUpdateTime: Date | null }
  819. >();
  820. for (const row of perAccountRows || []) {
  821. const accountId = Number(row.accountId) || 0;
  822. if (!accountId) continue;
  823. perAccountMap.set(accountId, {
  824. rowCount: Number(row.rowCount) || 0,
  825. views: Number(row.viewsCount) || 0,
  826. comments: Number(row.commentsCount) || 0,
  827. likes: Number(row.likesCount) || 0,
  828. fansIncrease: Number(row.fansIncrease) || 0,
  829. latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
  830. });
  831. }
  832. // 顶部汇总:直接用账号维度汇总,确保和“账号详细数据”一致
  833. let totalViews = 0;
  834. let totalComments = 0;
  835. let totalLikes = 0;
  836. let totalFansIncrease = 0;
  837. for (const agg of perAccountMap.values()) {
  838. totalViews += agg.views;
  839. totalComments += agg.comments;
  840. totalLikes += agg.likes;
  841. totalFansIncrease += agg.fansIncrease;
  842. }
  843. const accountList: Array<{
  844. id: number;
  845. nickname: string;
  846. username: string;
  847. avatarUrl: string | null;
  848. platform: string;
  849. income: number | null;
  850. recommendationCount: number | null;
  851. viewsCount: number | null;
  852. commentsCount: number;
  853. likesCount: number;
  854. fansIncrease: number;
  855. updateTime: string;
  856. }> = accounts.map((account) => {
  857. const agg =
  858. perAccountMap.get(account.id) ?? { rowCount: 0, views: 0, comments: 0, likes: 0, fansIncrease: 0, latestUpdateTime: null };
  859. const updateTime = agg.latestUpdateTime ? this.formatUpdateTime(agg.latestUpdateTime) : '';
  860. return {
  861. id: account.id,
  862. nickname: account.accountName || '',
  863. username: account.accountId || '',
  864. avatarUrl: account.avatarUrl,
  865. platform: account.platform,
  866. income: null,
  867. recommendationCount: null,
  868. // 没有任何记录时,前端展示“获取失败”,避免把“无数据”误显示成 0
  869. viewsCount: agg.rowCount > 0 ? agg.views : null,
  870. commentsCount: agg.comments,
  871. likesCount: agg.likes,
  872. fansIncrease: agg.fansIncrease,
  873. updateTime,
  874. };
  875. });
  876. return {
  877. summary: {
  878. totalAccounts,
  879. totalIncome: 0, // 收益数据需要从其他表获取
  880. viewsCount: totalViews,
  881. commentsCount: totalComments,
  882. likesCount: totalLikes,
  883. fansIncrease: totalFansIncrease,
  884. recommendationCount: null, // 推荐量(部分平台支持)
  885. },
  886. dailyData,
  887. accounts: accountList,
  888. };
  889. }
  890. /**
  891. * 格式化更新时间为 "MM-DD HH:mm" 格式
  892. */
  893. private formatUpdateTime(date: Date): string {
  894. const month = String(date.getMonth() + 1).padStart(2, '0');
  895. const day = String(date.getDate()).padStart(2, '0');
  896. const hours = String(date.getHours()).padStart(2, '0');
  897. const minutes = String(date.getMinutes()).padStart(2, '0');
  898. return `${month}-${day} ${hours}:${minutes}`;
  899. }
  900. }