WorkDayStatisticsService.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599
  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 getAccountsAnalytics(
  633. userId: number,
  634. options: {
  635. startDate: string;
  636. endDate: string;
  637. platform?: string;
  638. groupId?: number;
  639. }
  640. ): Promise<{
  641. accounts: Array<{
  642. id: number;
  643. nickname: string;
  644. username: string;
  645. avatarUrl: string | null;
  646. platform: string;
  647. groupId: number | null;
  648. income: number | null;
  649. recommendationCount: number | null;
  650. viewsCount: number;
  651. commentsCount: number;
  652. likesCount: number;
  653. fansIncrease: number;
  654. fansCount: number;
  655. updateTime: string;
  656. status: string;
  657. }>;
  658. summary: {
  659. totalAccounts: number;
  660. totalIncome: number;
  661. recommendationCount: number | null;
  662. viewsCount: number;
  663. commentsCount: number;
  664. likesCount: number;
  665. fansIncrease: number;
  666. totalFans: number;
  667. };
  668. }> {
  669. const { startDate, endDate, platform, groupId } = options;
  670. // 只查询支持的平台:抖音、百家号、视频号、小红书
  671. const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
  672. const accountQuery = this.accountRepository
  673. .createQueryBuilder('pa')
  674. .where('pa.userId = :userId', { userId })
  675. .andWhere('pa.platform IN (:...allowedPlatforms)', { allowedPlatforms });
  676. if (platform) {
  677. accountQuery.andWhere('pa.platform = :platform', { platform });
  678. }
  679. if (groupId) {
  680. accountQuery.andWhere('pa.groupId = :groupId', { groupId });
  681. }
  682. const accounts = await accountQuery.getMany();
  683. if (accounts.length === 0) {
  684. return {
  685. accounts: [],
  686. summary: {
  687. totalAccounts: 0,
  688. totalIncome: 0,
  689. recommendationCount: null,
  690. viewsCount: 0,
  691. commentsCount: 0,
  692. likesCount: 0,
  693. fansIncrease: 0,
  694. totalFans: 0,
  695. },
  696. };
  697. }
  698. const accountIds = accounts.map(a => a.id);
  699. // 使用 user_day_statistics 统计区间内的播放/评论/点赞/涨粉等
  700. const statsRows = await this.userDayStatisticsRepository
  701. .createQueryBuilder('uds')
  702. .select('uds.account_id', 'accountId')
  703. .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
  704. .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
  705. .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
  706. .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
  707. .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
  708. .where('uds.account_id IN (:...accountIds)', { accountIds })
  709. .andWhere('DATE(uds.record_date) >= :startDate', { startDate })
  710. .andWhere('DATE(uds.record_date) <= :endDate', { endDate })
  711. .groupBy('uds.account_id')
  712. .getRawMany();
  713. const statMap = new Map<
  714. number,
  715. {
  716. viewsCount: number;
  717. commentsCount: number;
  718. likesCount: number;
  719. fansIncrease: number;
  720. latestUpdateTime: Date | null;
  721. }
  722. >();
  723. for (const row of statsRows || []) {
  724. const accountId = Number(row.accountId) || 0;
  725. if (!accountId) continue;
  726. statMap.set(accountId, {
  727. viewsCount: Number(row.viewsCount) || 0,
  728. commentsCount: Number(row.commentsCount) || 0,
  729. likesCount: Number(row.likesCount) || 0,
  730. fansIncrease: Number(row.fansIncrease) || 0,
  731. latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
  732. });
  733. }
  734. const resultAccounts: Array<{
  735. id: number;
  736. nickname: string;
  737. username: string;
  738. avatarUrl: string | null;
  739. platform: string;
  740. groupId: number | null;
  741. income: number | null;
  742. recommendationCount: number | null;
  743. viewsCount: number;
  744. commentsCount: number;
  745. likesCount: number;
  746. fansIncrease: number;
  747. fansCount: number;
  748. updateTime: string;
  749. status: string;
  750. }> = [];
  751. let totalViews = 0;
  752. let totalComments = 0;
  753. let totalLikes = 0;
  754. let totalFansIncrease = 0;
  755. let totalFans = 0;
  756. for (const account of accounts) {
  757. const stat =
  758. statMap.get(account.id) ?? {
  759. viewsCount: 0,
  760. commentsCount: 0,
  761. likesCount: 0,
  762. fansIncrease: 0,
  763. latestUpdateTime: account.updatedAt ?? null,
  764. };
  765. const fansCount = account.fansCount || 0;
  766. const updateTime = stat.latestUpdateTime
  767. ? this.formatUpdateTime(stat.latestUpdateTime)
  768. : this.formatUpdateTime(account.updatedAt ?? new Date());
  769. resultAccounts.push({
  770. id: account.id,
  771. nickname: account.accountName || '',
  772. username: account.accountId || '',
  773. avatarUrl: account.avatarUrl,
  774. platform: account.platform,
  775. groupId: account.groupId ?? null,
  776. income: null,
  777. recommendationCount: null,
  778. viewsCount: stat.viewsCount,
  779. commentsCount: stat.commentsCount,
  780. likesCount: stat.likesCount,
  781. fansIncrease: stat.fansIncrease,
  782. fansCount,
  783. updateTime,
  784. status: account.status,
  785. });
  786. totalViews += stat.viewsCount;
  787. totalComments += stat.commentsCount;
  788. totalLikes += stat.likesCount;
  789. totalFansIncrease += stat.fansIncrease;
  790. totalFans += fansCount;
  791. }
  792. return {
  793. accounts: resultAccounts,
  794. summary: {
  795. totalAccounts: accounts.length,
  796. totalIncome: 0,
  797. recommendationCount: null,
  798. viewsCount: totalViews,
  799. commentsCount: totalComments,
  800. likesCount: totalLikes,
  801. fansIncrease: totalFansIncrease,
  802. totalFans,
  803. },
  804. };
  805. }
  806. /**
  807. * 获取单个账号的详情数据
  808. * 包括:汇总统计、每日数据、作品列表
  809. * 说明:
  810. * - 收益、推荐量目前数据库尚未接入,统一返回 0
  811. */
  812. async getAccountDetail(
  813. userId: number,
  814. accountId: number,
  815. options: {
  816. startDate: string;
  817. endDate: string;
  818. }
  819. ): Promise<{
  820. summary: {
  821. income: number;
  822. recommendationCount: number;
  823. viewsCount: number;
  824. commentsCount: number;
  825. likesCount: number;
  826. fansIncrease: number;
  827. };
  828. dailyData: Array<{
  829. date: string;
  830. income: number;
  831. recommendationCount: number;
  832. viewsCount: number;
  833. commentsCount: number;
  834. likesCount: number;
  835. fansIncrease: number;
  836. }>;
  837. works: Array<{
  838. id: number;
  839. title: string;
  840. coverUrl: string;
  841. platform: string;
  842. publishTime: string | null;
  843. recommendCount: number;
  844. viewsCount: number;
  845. commentsCount: number;
  846. sharesCount: number;
  847. collectsCount: number;
  848. likesCount: number;
  849. }>;
  850. }> {
  851. const { startDate, endDate } = options;
  852. const account = await this.accountRepository.findOne({
  853. where: { id: accountId, userId },
  854. });
  855. if (!account) {
  856. // 账号不存在或不属于该用户,返回空数据
  857. return {
  858. summary: {
  859. income: 0,
  860. recommendationCount: 0,
  861. viewsCount: 0,
  862. commentsCount: 0,
  863. likesCount: 0,
  864. fansIncrease: 0,
  865. },
  866. dailyData: [],
  867. works: [],
  868. };
  869. }
  870. const startDateStr = startDate;
  871. const endDateStr = endDate;
  872. // 1. 每日数据:直接从 user_day_statistics 获取指定账号的每日记录
  873. const udsRows = await this.userDayStatisticsRepository
  874. .createQueryBuilder('uds')
  875. .select('uds.record_date', 'recordDate')
  876. .addSelect('uds.play_count', 'viewsCount')
  877. .addSelect('uds.comment_count', 'commentsCount')
  878. .addSelect('uds.like_count', 'likesCount')
  879. .addSelect('uds.fans_increase', 'fansIncrease')
  880. .where('uds.account_id = :accountId', { accountId })
  881. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  882. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  883. .orderBy('uds.record_date', 'ASC')
  884. .getRawMany();
  885. const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
  886. for (const row of udsRows || []) {
  887. if (!row.recordDate) continue;
  888. let dateKey: string;
  889. if (row.recordDate instanceof Date) {
  890. const y = row.recordDate.getFullYear();
  891. const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
  892. const d = String(row.recordDate.getDate()).padStart(2, '0');
  893. dateKey = `${y}-${m}-${d}`;
  894. } else {
  895. dateKey = String(row.recordDate).slice(0, 10);
  896. }
  897. dailyMap.set(dateKey, {
  898. views: Number(row.viewsCount) || 0,
  899. comments: Number(row.commentsCount) || 0,
  900. likes: Number(row.likesCount) || 0,
  901. fansIncrease: Number(row.fansIncrease) || 0,
  902. });
  903. }
  904. const dStart = new Date(startDateStr);
  905. const dEnd = new Date(endDateStr);
  906. const cursor = new Date(dStart);
  907. const dailyData: Array<{
  908. date: string;
  909. income: number;
  910. recommendationCount: number;
  911. viewsCount: number;
  912. commentsCount: number;
  913. likesCount: number;
  914. fansIncrease: number;
  915. }> = [];
  916. let totalViews = 0;
  917. let totalComments = 0;
  918. let totalLikes = 0;
  919. let totalFansIncrease = 0;
  920. while (cursor <= dEnd) {
  921. const dateKey = this.formatDate(cursor);
  922. const value = dailyMap.get(dateKey) ?? {
  923. views: 0,
  924. comments: 0,
  925. likes: 0,
  926. fansIncrease: 0,
  927. };
  928. dailyData.push({
  929. date: dateKey,
  930. income: 0,
  931. recommendationCount: 0,
  932. viewsCount: value.views,
  933. commentsCount: value.comments,
  934. likesCount: value.likes,
  935. fansIncrease: value.fansIncrease,
  936. });
  937. totalViews += value.views;
  938. totalComments += value.comments;
  939. totalLikes += value.likes;
  940. totalFansIncrease += value.fansIncrease;
  941. cursor.setDate(cursor.getDate() + 1);
  942. }
  943. // 2. 作品列表:按作品聚合 work_day_statistics 区间内的数据
  944. const worksRows = await this.workRepository
  945. .createQueryBuilder('w')
  946. .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
  947. wStart: startDateStr,
  948. wEnd: endDateStr,
  949. })
  950. .select('w.id', 'id')
  951. .addSelect('w.title', 'title')
  952. .addSelect('w.cover_url', 'coverUrl')
  953. .addSelect('w.platform', 'platform')
  954. .addSelect('w.publish_time', 'publishTime')
  955. .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
  956. .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
  957. .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
  958. .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
  959. .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
  960. .where('w.userId = :userId', { userId })
  961. .andWhere('w.accountId = :accountId', { accountId })
  962. .groupBy('w.id')
  963. .orderBy('w.publish_time', 'DESC')
  964. .getRawMany();
  965. const works = (worksRows || []).map((row) => {
  966. const publishTime =
  967. row.publishTime instanceof Date
  968. ? row.publishTime.toISOString()
  969. : row.publishTime
  970. ? String(row.publishTime)
  971. : null;
  972. return {
  973. id: Number(row.id),
  974. title: row.title || '',
  975. coverUrl: row.coverUrl || '',
  976. platform: row.platform || '',
  977. publishTime,
  978. // 推荐量目前没有独立字段,统一返回 0
  979. recommendCount: 0,
  980. viewsCount: Number(row.viewsCount) || 0,
  981. commentsCount: Number(row.commentsCount) || 0,
  982. sharesCount: Number(row.sharesCount) || 0,
  983. collectsCount: Number(row.collectsCount) || 0,
  984. likesCount: Number(row.likesCount) || 0,
  985. };
  986. });
  987. return {
  988. summary: {
  989. income: 0,
  990. recommendationCount: 0,
  991. viewsCount: totalViews,
  992. commentsCount: totalComments,
  993. likesCount: totalLikes,
  994. fansIncrease: totalFansIncrease,
  995. },
  996. dailyData,
  997. works,
  998. };
  999. }
  1000. /**
  1001. * 获取作品数据列表(用于「作品数据」页)
  1002. * 依据 work_day_statistics 进行区间汇总统计
  1003. */
  1004. async getWorksAnalytics(
  1005. userId: number,
  1006. options: {
  1007. startDate: string;
  1008. endDate: string;
  1009. platform?: string;
  1010. accountIds?: number[];
  1011. groupId?: number;
  1012. keyword?: string;
  1013. sortBy?: 'publish_desc' | 'publish_asc' | 'views_desc' | 'likes_desc' | 'comments_desc';
  1014. page?: number;
  1015. pageSize?: number;
  1016. }
  1017. ): Promise<{
  1018. summary: {
  1019. totalWorks: number;
  1020. recommendCount: number;
  1021. viewsCount: number;
  1022. commentsCount: number;
  1023. sharesCount: number;
  1024. collectsCount: number;
  1025. likesCount: number;
  1026. };
  1027. total: number;
  1028. works: Array<{
  1029. id: number;
  1030. title: string;
  1031. coverUrl: string;
  1032. platform: string;
  1033. accountId: number;
  1034. accountName: string;
  1035. accountAvatar: string | null;
  1036. workType: string;
  1037. publishTime: string | null;
  1038. recommendCount: number;
  1039. viewsCount: number;
  1040. commentsCount: number;
  1041. sharesCount: number;
  1042. collectsCount: number;
  1043. likesCount: number;
  1044. }>;
  1045. }> {
  1046. const {
  1047. startDate,
  1048. endDate,
  1049. platform,
  1050. accountIds,
  1051. groupId,
  1052. keyword,
  1053. sortBy = 'publish_desc',
  1054. page = 1,
  1055. pageSize = 20,
  1056. } = options;
  1057. const startDateStr = startDate;
  1058. const endDateStr = endDate;
  1059. // 基础查询:当前用户的作品
  1060. const qb = this.workRepository
  1061. .createQueryBuilder('w')
  1062. .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
  1063. wStart: startDateStr,
  1064. wEnd: endDateStr,
  1065. })
  1066. .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
  1067. .select('w.id', 'id')
  1068. .addSelect('w.title', 'title')
  1069. .addSelect('w.cover_url', 'coverUrl')
  1070. .addSelect('w.platform', 'platform')
  1071. .addSelect('w.accountId', 'accountId')
  1072. .addSelect('pa.accountName', 'accountName')
  1073. .addSelect('pa.avatarUrl', 'accountAvatar')
  1074. .addSelect('w.status', 'workType')
  1075. .addSelect('w.publish_time', 'publishTime')
  1076. .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
  1077. .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
  1078. .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
  1079. .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
  1080. .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
  1081. .where('w.userId = :userId', { userId });
  1082. if (platform) {
  1083. qb.andWhere('w.platform = :platform', { platform });
  1084. }
  1085. if (accountIds && accountIds.length > 0) {
  1086. qb.andWhere('w.accountId IN (:...accountIds)', { accountIds });
  1087. }
  1088. if (groupId) {
  1089. qb.andWhere('pa.groupId = :groupId', { groupId });
  1090. }
  1091. if (keyword && keyword.trim()) {
  1092. const kw = `%${keyword.trim()}%`;
  1093. qb.andWhere('(w.title LIKE :kw OR pa.accountName LIKE :kw)', { kw });
  1094. }
  1095. qb.groupBy('w.id');
  1096. // 排序:统一按发布时间倒序(最新的在前)
  1097. qb.orderBy('w.publish_time', 'DESC');
  1098. // 统计总数(作品数)
  1099. const countQb = this.workRepository
  1100. .createQueryBuilder('w')
  1101. .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
  1102. .where('w.userId = :userId', { userId });
  1103. if (platform) {
  1104. countQb.andWhere('w.platform = :platform', { platform });
  1105. }
  1106. if (accountIds && accountIds.length > 0) {
  1107. countQb.andWhere('w.accountId IN (:...accountIds)', { accountIds });
  1108. }
  1109. if (groupId) {
  1110. countQb.andWhere('pa.groupId = :groupId', { groupId });
  1111. }
  1112. if (keyword && keyword.trim()) {
  1113. const kw = `%${keyword.trim()}%`;
  1114. countQb.andWhere('(w.title LIKE :kw OR pa.accountName LIKE :kw)', { kw });
  1115. }
  1116. const total = await countQb.getCount();
  1117. // 分页
  1118. const offset = (page - 1) * pageSize;
  1119. qb.skip(offset).take(pageSize);
  1120. const rows = await qb.getRawMany();
  1121. let totalViews = 0;
  1122. let totalComments = 0;
  1123. let totalShares = 0;
  1124. let totalCollects = 0;
  1125. let totalLikes = 0;
  1126. const works = rows.map((row) => {
  1127. const views = Number(row.viewsCount) || 0;
  1128. const comments = Number(row.commentsCount) || 0;
  1129. const shares = Number(row.sharesCount) || 0;
  1130. const collects = Number(row.collectsCount) || 0;
  1131. const likes = Number(row.likesCount) || 0;
  1132. totalViews += views;
  1133. totalComments += comments;
  1134. totalShares += shares;
  1135. totalCollects += collects;
  1136. totalLikes += likes;
  1137. const publishTime =
  1138. row.publishTime instanceof Date
  1139. ? row.publishTime.toISOString()
  1140. : row.publishTime
  1141. ? String(row.publishTime)
  1142. : null;
  1143. return {
  1144. id: Number(row.id),
  1145. title: row.title || '',
  1146. coverUrl: row.coverUrl || '',
  1147. platform: row.platform || '',
  1148. accountId: Number(row.accountId) || 0,
  1149. accountName: row.accountName || '',
  1150. accountAvatar: row.accountAvatar || null,
  1151. workType: row.workType || '动态',
  1152. publishTime,
  1153. recommendCount: 0,
  1154. viewsCount: views,
  1155. commentsCount: comments,
  1156. sharesCount: shares,
  1157. collectsCount: collects,
  1158. likesCount: likes,
  1159. };
  1160. });
  1161. return {
  1162. summary: {
  1163. totalWorks: total,
  1164. recommendCount: 0,
  1165. viewsCount: totalViews,
  1166. commentsCount: totalComments,
  1167. sharesCount: totalShares,
  1168. collectsCount: totalCollects,
  1169. likesCount: totalLikes,
  1170. },
  1171. total,
  1172. works,
  1173. };
  1174. }
  1175. /**
  1176. * 获取平台详情数据
  1177. * 包括汇总统计、每日汇总数据和账号列表
  1178. */
  1179. async getPlatformDetail(
  1180. userId: number,
  1181. platform: string,
  1182. options: {
  1183. startDate: string;
  1184. endDate: string;
  1185. }
  1186. ): Promise<{
  1187. summary: {
  1188. totalAccounts: number;
  1189. totalIncome: number;
  1190. viewsCount: number;
  1191. commentsCount: number;
  1192. likesCount: number;
  1193. fansIncrease: number;
  1194. recommendationCount: number | null; // 推荐量(部分平台支持)
  1195. };
  1196. dailyData: Array<{
  1197. date: string;
  1198. income: number;
  1199. recommendationCount: number | null;
  1200. viewsCount: number;
  1201. commentsCount: number;
  1202. likesCount: number;
  1203. fansIncrease: number;
  1204. }>;
  1205. accounts: Array<{
  1206. id: number;
  1207. nickname: string;
  1208. username: string;
  1209. avatarUrl: string | null;
  1210. platform: string;
  1211. income: number | null;
  1212. recommendationCount: number | null;
  1213. viewsCount: number | null;
  1214. commentsCount: number;
  1215. likesCount: number;
  1216. fansIncrease: number;
  1217. updateTime: string;
  1218. }>;
  1219. }> {
  1220. const { startDate, endDate } = options;
  1221. const startDateStr = startDate;
  1222. const endDateStr = endDate;
  1223. // 获取该平台的所有账号
  1224. const accounts = await this.accountRepository.find({
  1225. where: {
  1226. userId,
  1227. platform: platform as any,
  1228. },
  1229. relations: ['group'],
  1230. });
  1231. if (accounts.length === 0) {
  1232. return {
  1233. summary: {
  1234. totalAccounts: 0,
  1235. totalIncome: 0,
  1236. viewsCount: 0,
  1237. commentsCount: 0,
  1238. likesCount: 0,
  1239. fansIncrease: 0,
  1240. recommendationCount: null,
  1241. },
  1242. dailyData: [],
  1243. accounts: [],
  1244. };
  1245. }
  1246. /**
  1247. * 口径变更:user_day_statistics 的各项数据为“每日单独值”,不再是累计值
  1248. * 因此平台详情:
  1249. * - 区间汇总:直接 SUM(user_day_statistics.*)(按账号、按天)
  1250. * - 每日汇总:按 record_date 分组 SUM
  1251. */
  1252. const accountIds = accounts.map(a => a.id);
  1253. const totalAccounts = accounts.length;
  1254. const [dailyRows, perAccountRows] = await Promise.all([
  1255. // 按日期维度汇总(每天所有账号数据之和)
  1256. // 这里直接使用原生 SQL,以保证和文档/手动验证时看到的 SQL 完全一致:
  1257. //
  1258. // SELECT
  1259. // uds.record_date AS recordDate,
  1260. // COALESCE(SUM(uds.play_count), 0) AS viewsCount,
  1261. // COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
  1262. // COALESCE(SUM(uds.like_count), 0) AS likesCount,
  1263. // COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
  1264. // FROM user_day_statistics uds
  1265. // WHERE uds.account_id IN (...)
  1266. // AND uds.record_date >= ?
  1267. // AND uds.record_date <= ?
  1268. // GROUP BY uds.record_date
  1269. // ORDER BY uds.record_date ASC;
  1270. (async () => {
  1271. if (!accountIds.length) return [];
  1272. const inPlaceholders = accountIds.map(() => '?').join(',');
  1273. const sql = `
  1274. SELECT
  1275. uds.record_date AS recordDate,
  1276. COALESCE(SUM(uds.play_count), 0) AS viewsCount,
  1277. COALESCE(SUM(uds.comment_count), 0) AS commentsCount,
  1278. COALESCE(SUM(uds.like_count), 0) AS likesCount,
  1279. COALESCE(SUM(uds.fans_increase), 0) AS fansIncrease
  1280. FROM user_day_statistics uds
  1281. WHERE uds.account_id IN (${inPlaceholders})
  1282. AND uds.record_date >= ?
  1283. AND uds.record_date <= ?
  1284. GROUP BY uds.record_date
  1285. ORDER BY uds.record_date ASC
  1286. `;
  1287. const params = [...accountIds, startDateStr, endDateStr];
  1288. return await AppDataSource.query(sql, params);
  1289. })(),
  1290. // 按账号维度汇总(区间内所有天的和)
  1291. this.userDayStatisticsRepository
  1292. .createQueryBuilder('uds')
  1293. .select('uds.account_id', 'accountId')
  1294. .addSelect('COUNT(1)', 'rowCount')
  1295. .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
  1296. .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
  1297. .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
  1298. .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
  1299. .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
  1300. .where('uds.account_id IN (:...accountIds)', { accountIds })
  1301. .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
  1302. .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
  1303. .groupBy('uds.account_id')
  1304. .getRawMany(),
  1305. ]);
  1306. // ===== 按日期汇总:每日汇总数据 =====
  1307. const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
  1308. for (const row of dailyRows || []) {
  1309. if (!row.recordDate) continue;
  1310. /**
  1311. * 注意:record_date 在实体里是 DATE 类型,TypeORM 读出来通常是 Date 对象。
  1312. * 之前用 String(row.recordDate).slice(0, 10) 会得到类似 "Wed Jan 28" 这样的字符串前 10 位,
  1313. * 导致 key 和下面 this.formatDate(cursor) 生成的 "YYYY-MM-DD" 不一致,从而 dailyMap 命中失败,全部变成 0。
  1314. *
  1315. * 这里改成显式按本地时间拼出 "YYYY-MM-DD",确保与 startDate/endDate 的格式一致。
  1316. */
  1317. let dateKey: string;
  1318. if (row.recordDate instanceof Date) {
  1319. const y = row.recordDate.getFullYear();
  1320. const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
  1321. const d = String(row.recordDate.getDate()).padStart(2, '0');
  1322. dateKey = `${y}-${m}-${d}`;
  1323. } else {
  1324. // 数据库如果已经返回字符串,例如 "2026-01-28",直接截前 10 位即可
  1325. dateKey = String(row.recordDate).slice(0, 10);
  1326. }
  1327. const prev = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
  1328. dailyMap.set(dateKey, {
  1329. views: prev.views + (Number(row.viewsCount) || 0),
  1330. comments: prev.comments + (Number(row.commentsCount) || 0),
  1331. likes: prev.likes + (Number(row.likesCount) || 0),
  1332. fansIncrease: prev.fansIncrease + (Number(row.fansIncrease) || 0),
  1333. });
  1334. }
  1335. // 补齐日期区间(没有数据也返回 0)
  1336. const dailyData: Array<{
  1337. date: string;
  1338. income: number;
  1339. recommendationCount: number | null;
  1340. viewsCount: number;
  1341. commentsCount: number;
  1342. likesCount: number;
  1343. fansIncrease: number;
  1344. }> = [];
  1345. const dStart = new Date(startDateStr);
  1346. const dEnd = new Date(endDateStr);
  1347. const cursor = new Date(dStart);
  1348. while (cursor <= dEnd) {
  1349. const dateKey = this.formatDate(cursor);
  1350. const v = dailyMap.get(dateKey) ?? { views: 0, comments: 0, likes: 0, fansIncrease: 0 };
  1351. dailyData.push({
  1352. date: dateKey,
  1353. income: 0,
  1354. recommendationCount: null,
  1355. viewsCount: v.views,
  1356. commentsCount: v.comments,
  1357. likesCount: v.likes,
  1358. fansIncrease: v.fansIncrease,
  1359. });
  1360. cursor.setDate(cursor.getDate() + 1);
  1361. }
  1362. // ===== 按账号汇总:账号列表 & 顶部汇总 =====
  1363. const perAccountMap = new Map<
  1364. number,
  1365. { rowCount: number; views: number; comments: number; likes: number; fansIncrease: number; latestUpdateTime: Date | null }
  1366. >();
  1367. for (const row of perAccountRows || []) {
  1368. const accountId = Number(row.accountId) || 0;
  1369. if (!accountId) continue;
  1370. perAccountMap.set(accountId, {
  1371. rowCount: Number(row.rowCount) || 0,
  1372. views: Number(row.viewsCount) || 0,
  1373. comments: Number(row.commentsCount) || 0,
  1374. likes: Number(row.likesCount) || 0,
  1375. fansIncrease: Number(row.fansIncrease) || 0,
  1376. latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
  1377. });
  1378. }
  1379. // 顶部汇总:直接用账号维度汇总,确保和“账号详细数据”一致
  1380. let totalViews = 0;
  1381. let totalComments = 0;
  1382. let totalLikes = 0;
  1383. let totalFansIncrease = 0;
  1384. for (const agg of perAccountMap.values()) {
  1385. totalViews += agg.views;
  1386. totalComments += agg.comments;
  1387. totalLikes += agg.likes;
  1388. totalFansIncrease += agg.fansIncrease;
  1389. }
  1390. const accountList: Array<{
  1391. id: number;
  1392. nickname: string;
  1393. username: string;
  1394. avatarUrl: string | null;
  1395. platform: string;
  1396. income: number | null;
  1397. recommendationCount: number | null;
  1398. viewsCount: number | null;
  1399. commentsCount: number;
  1400. likesCount: number;
  1401. fansIncrease: number;
  1402. updateTime: string;
  1403. }> = accounts.map((account) => {
  1404. const agg =
  1405. perAccountMap.get(account.id) ?? { rowCount: 0, views: 0, comments: 0, likes: 0, fansIncrease: 0, latestUpdateTime: null };
  1406. const updateTime = agg.latestUpdateTime ? this.formatUpdateTime(agg.latestUpdateTime) : '';
  1407. return {
  1408. id: account.id,
  1409. nickname: account.accountName || '',
  1410. username: account.accountId || '',
  1411. avatarUrl: account.avatarUrl,
  1412. platform: account.platform,
  1413. income: null,
  1414. recommendationCount: null,
  1415. // 没有任何记录时,前端展示“获取失败”,避免把“无数据”误显示成 0
  1416. viewsCount: agg.rowCount > 0 ? agg.views : null,
  1417. commentsCount: agg.comments,
  1418. likesCount: agg.likes,
  1419. fansIncrease: agg.fansIncrease,
  1420. updateTime,
  1421. };
  1422. });
  1423. return {
  1424. summary: {
  1425. totalAccounts,
  1426. totalIncome: 0, // 收益数据需要从其他表获取
  1427. viewsCount: totalViews,
  1428. commentsCount: totalComments,
  1429. likesCount: totalLikes,
  1430. fansIncrease: totalFansIncrease,
  1431. recommendationCount: null, // 推荐量(部分平台支持)
  1432. },
  1433. dailyData,
  1434. accounts: accountList,
  1435. };
  1436. }
  1437. /**
  1438. * 格式化更新时间为统一的人类可读格式:
  1439. * - 如果是今年:MM-DD HH:mm(例如:01-22 10:22)
  1440. * - 如果是往年:YYYY-MM-DD HH:mm(例如:2025-12-22 10:22)
  1441. */
  1442. private formatUpdateTime(date: Date): string {
  1443. const y = date.getFullYear();
  1444. const nowYear = new Date().getFullYear();
  1445. const month = String(date.getMonth() + 1).padStart(2, '0');
  1446. const day = String(date.getDate()).padStart(2, '0');
  1447. const hours = String(date.getHours()).padStart(2, '0');
  1448. const minutes = String(date.getMinutes()).padStart(2, '0');
  1449. if (y === nowYear) {
  1450. return `${month}-${day} ${hours}:${minutes}`;
  1451. }
  1452. return `${y}-${month}-${day} ${hours}:${minutes}`;
  1453. }
  1454. }