AccountService.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122
  1. import { AppDataSource, PlatformAccount, AccountGroup } from '../models/index.js';
  2. import { AppError } from '../middleware/error.js';
  3. import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared';
  4. import type {
  5. PlatformType,
  6. PlatformAccount as PlatformAccountType,
  7. AccountGroup as AccountGroupType,
  8. AddPlatformAccountRequest,
  9. UpdatePlatformAccountRequest,
  10. CreateAccountGroupRequest,
  11. QRCodeInfo,
  12. LoginStatusResult,
  13. } from '@media-manager/shared';
  14. import { wsManager } from '../websocket/index.js';
  15. import { WS_EVENTS } from '@media-manager/shared';
  16. import { CookieManager } from '../automation/cookie.js';
  17. import { logger } from '../utils/logger.js';
  18. import { headlessBrowserService } from './HeadlessBrowserService.js';
  19. import { aiService } from '../ai/index.js';
  20. import { UserDayStatisticsService } from './UserDayStatisticsService.js';
  21. import { XiaohongshuAccountOverviewImportService } from './XiaohongshuAccountOverviewImportService.js';
  22. import { DouyinAccountOverviewImportService } from './DouyinAccountOverviewImportService.js';
  23. import { BaijiahaoContentOverviewImportService } from './BaijiahaoContentOverviewImportService.js';
  24. import { WeixinVideoDataCenterImportService } from './WeixinVideoDataCenterImportService.js';
  25. interface GetAccountsParams {
  26. platform?: string;
  27. groupId?: number;
  28. status?: string;
  29. }
  30. export class AccountService {
  31. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  32. private groupRepository = AppDataSource.getRepository(AccountGroup);
  33. // ============ 账号分组 ============
  34. async getGroups(userId: number): Promise<AccountGroupType[]> {
  35. const groups = await this.groupRepository.find({
  36. where: { userId },
  37. order: { createdAt: 'ASC' },
  38. });
  39. return groups.map(this.formatGroup);
  40. }
  41. async createGroup(userId: number, data: CreateAccountGroupRequest): Promise<AccountGroupType> {
  42. const group = this.groupRepository.create({
  43. userId,
  44. name: data.name,
  45. description: data.description || null,
  46. });
  47. await this.groupRepository.save(group);
  48. return this.formatGroup(group);
  49. }
  50. async updateGroup(
  51. userId: number,
  52. groupId: number,
  53. data: Partial<CreateAccountGroupRequest>
  54. ): Promise<AccountGroupType> {
  55. const group = await this.groupRepository.findOne({
  56. where: { id: groupId, userId },
  57. });
  58. if (!group) {
  59. throw new AppError('分组不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  60. }
  61. await this.groupRepository.update(groupId, {
  62. name: data.name ?? group.name,
  63. description: data.description ?? group.description,
  64. });
  65. const updated = await this.groupRepository.findOne({ where: { id: groupId } });
  66. return this.formatGroup(updated!);
  67. }
  68. async deleteGroup(userId: number, groupId: number): Promise<void> {
  69. const group = await this.groupRepository.findOne({
  70. where: { id: groupId, userId },
  71. });
  72. if (!group) {
  73. throw new AppError('分组不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  74. }
  75. await this.groupRepository.delete(groupId);
  76. }
  77. // ============ 平台账号 ============
  78. async getAccounts(userId: number, params: GetAccountsParams): Promise<PlatformAccountType[]> {
  79. const queryBuilder = this.accountRepository
  80. .createQueryBuilder('account')
  81. .where('account.userId = :userId', { userId });
  82. if (params.platform) {
  83. queryBuilder.andWhere('account.platform = :platform', { platform: params.platform });
  84. }
  85. if (params.groupId) {
  86. queryBuilder.andWhere('account.groupId = :groupId', { groupId: params.groupId });
  87. }
  88. if (params.status) {
  89. queryBuilder.andWhere('account.status = :status', { status: params.status });
  90. }
  91. const accounts = await queryBuilder
  92. .orderBy('account.createdAt', 'DESC')
  93. .getMany();
  94. return accounts.map(this.formatAccount);
  95. }
  96. async getAccountById(userId: number, accountId: number): Promise<PlatformAccountType> {
  97. const account = await this.accountRepository.findOne({
  98. where: { id: accountId, userId },
  99. });
  100. if (!account) {
  101. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  102. }
  103. return this.formatAccount(account);
  104. }
  105. /**
  106. * 获取账号的 Cookie 数据(用于客户端打开后台)
  107. */
  108. async getAccountCookie(userId: number, accountId: number): Promise<string> {
  109. const account = await this.accountRepository.findOne({
  110. where: { id: accountId, userId },
  111. });
  112. if (!account) {
  113. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  114. }
  115. if (!account.cookieData) {
  116. throw new AppError('账号没有 Cookie 数据', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION_ERROR);
  117. }
  118. // 尝试解密 Cookie
  119. let decryptedCookies: string;
  120. try {
  121. decryptedCookies = CookieManager.decrypt(account.cookieData);
  122. logger.info(`[AccountService] Cookie 解密成功,账号: ${account.accountName}`);
  123. } catch (error) {
  124. // 如果解密失败,返回原始数据
  125. logger.warn(`[AccountService] Cookie 解密失败,使用原始数据,账号: ${account.accountName}`);
  126. decryptedCookies = account.cookieData;
  127. }
  128. // 验证 Cookie 格式
  129. try {
  130. const parsed = JSON.parse(decryptedCookies);
  131. if (Array.isArray(parsed) && parsed.length > 0) {
  132. logger.info(`[AccountService] Cookie 格式验证通过,共 ${parsed.length} 个 Cookie`);
  133. // 记录关键 Cookie(用于调试)
  134. const keyNames = parsed.slice(0, 3).map((c: any) => c.name).join(', ');
  135. logger.info(`[AccountService] 关键 Cookie: ${keyNames}`);
  136. }
  137. } catch {
  138. logger.warn(`[AccountService] Cookie 不是 JSON 格式,可能是字符串格式`);
  139. }
  140. return decryptedCookies;
  141. }
  142. async addAccount(userId: number, data: AddPlatformAccountRequest & {
  143. accountInfo?: {
  144. accountId?: string;
  145. accountName?: string;
  146. avatarUrl?: string;
  147. fansCount?: number;
  148. worksCount?: number;
  149. };
  150. }): Promise<PlatformAccountType> {
  151. const platform = data.platform as PlatformType;
  152. // 解密 Cookie(如果是加密的)
  153. let cookieData = data.cookieData;
  154. let decryptedCookies: string;
  155. try {
  156. // 尝试解密(如果是通过浏览器登录获取的加密Cookie)
  157. decryptedCookies = CookieManager.decrypt(cookieData);
  158. } catch {
  159. // 如果解密失败,可能是直接粘贴的Cookie字符串
  160. decryptedCookies = cookieData;
  161. }
  162. // 检查客户端传入的 accountId 是否有效(不是纯时间戳)
  163. const clientAccountId = data.accountInfo?.accountId;
  164. const isValidClientAccountId = clientAccountId && !this.isTimestampBasedId(clientAccountId);
  165. // 从 Cookie 中提取账号 ID(部分平台的 Cookie 包含真实用户 ID)
  166. const accountIdFromCookie = this.extractAccountIdFromCookie(platform, decryptedCookies);
  167. // 某些平台应优先使用 API 返回的真实 ID,而不是 Cookie 中的值
  168. // - 抖音:使用抖音号(unique_id,如 Ethanfly9392),而不是 Cookie 中的 passport_uid
  169. // - 小红书:使用小红书号(red_num),而不是 Cookie 中的 userid
  170. // - 百家号:使用 new_uc_id,而不是 Cookie 中的 BDUSS
  171. // - 视频号/头条:使用 API 返回的真实账号 ID
  172. const platformsPreferApiId: PlatformType[] = ['douyin', 'xiaohongshu', 'baijiahao', 'weixin_video', 'toutiao'];
  173. const preferApiId = platformsPreferApiId.includes(platform);
  174. // 确定最终的 accountId
  175. let finalAccountId: string;
  176. if (preferApiId && isValidClientAccountId) {
  177. // 对于优先使用 API ID 的平台,先用客户端传入的有效 ID
  178. finalAccountId = this.normalizeAccountId(platform, clientAccountId);
  179. logger.info(`[addAccount] Using API-based accountId for ${platform}: ${finalAccountId}`);
  180. } else if (accountIdFromCookie) {
  181. // 其他平台优先使用 Cookie 中提取的 ID
  182. finalAccountId = accountIdFromCookie;
  183. logger.info(`[addAccount] Using accountId from cookie: ${finalAccountId}`);
  184. } else if (isValidClientAccountId) {
  185. // 再次尝试客户端 ID
  186. finalAccountId = this.normalizeAccountId(platform, clientAccountId);
  187. logger.info(`[addAccount] Using valid client accountId: ${finalAccountId}`);
  188. } else {
  189. finalAccountId = `${platform}_${Date.now()}`;
  190. logger.warn(`[addAccount] Using timestamp-based accountId as fallback: ${finalAccountId}`);
  191. }
  192. // 使用传入的账号信息(来自浏览器登录会话),或使用默认值
  193. const accountInfo = {
  194. accountId: finalAccountId,
  195. accountName: data.accountInfo?.accountName || `${platform}账号`,
  196. avatarUrl: data.accountInfo?.avatarUrl || null,
  197. fansCount: data.accountInfo?.fansCount || 0,
  198. worksCount: data.accountInfo?.worksCount || 0,
  199. };
  200. logger.info(`Adding account for ${platform}: ${accountInfo.accountId}, name: ${accountInfo.accountName}`);
  201. // 检查是否已存在相同账号
  202. const existing = await this.accountRepository.findOne({
  203. where: { userId, platform, accountId: accountInfo.accountId },
  204. });
  205. if (existing) {
  206. // 更新已存在的账号
  207. await this.accountRepository.update(existing.id, {
  208. cookieData: cookieData, // 存储原始数据(可能是加密的)
  209. accountName: accountInfo.accountName,
  210. avatarUrl: accountInfo.avatarUrl,
  211. fansCount: accountInfo.fansCount,
  212. worksCount: accountInfo.worksCount,
  213. status: 'active',
  214. groupId: data.groupId || existing.groupId,
  215. proxyConfig: data.proxyConfig || existing.proxyConfig,
  216. });
  217. const updated = await this.accountRepository.findOne({ where: { id: existing.id } });
  218. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
  219. // 异步刷新账号信息(获取准确的粉丝数、作品数等)
  220. this.refreshAccountAsync(userId, existing.id, platform).catch(err => {
  221. logger.warn(`[addAccount] Background refresh failed for existing account ${existing.id}:`, err);
  222. });
  223. return this.formatAccount(updated!);
  224. }
  225. // 创建新账号
  226. const account = this.accountRepository.create({
  227. userId,
  228. platform,
  229. cookieData: cookieData,
  230. groupId: data.groupId || null,
  231. proxyConfig: data.proxyConfig || null,
  232. status: 'active',
  233. accountId: accountInfo.accountId,
  234. accountName: accountInfo.accountName,
  235. avatarUrl: accountInfo.avatarUrl,
  236. fansCount: accountInfo.fansCount,
  237. worksCount: accountInfo.worksCount,
  238. });
  239. await this.accountRepository.save(account);
  240. // 通知其他客户端
  241. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_ADDED, { account: this.formatAccount(account) });
  242. // 异步刷新账号信息(获取准确的粉丝数、作品数等)
  243. // 不阻塞返回,后台执行
  244. this.refreshAccountAsync(userId, account.id, platform).catch(err => {
  245. logger.warn(`[addAccount] Background refresh failed for account ${account.id}:`, err);
  246. });
  247. // 新增账号后,按平台触发一次“近30天数据同步”,用于初始化 user_day_statistics
  248. this.initStatisticsForNewAccountAsync(account).catch(err => {
  249. logger.warn(
  250. `[addAccount] Initial statistics sync failed for account ${account.id} (${platform}):`,
  251. err
  252. );
  253. });
  254. return this.formatAccount(account);
  255. }
  256. /**
  257. * 异步刷新账号信息(用于添加账号后获取准确数据)
  258. */
  259. private async refreshAccountAsync(userId: number, accountId: number, platform: PlatformType): Promise<void> {
  260. // 延迟 2 秒执行,等待前端处理完成
  261. await new Promise(resolve => setTimeout(resolve, 2000));
  262. logger.info(`[addAccount] Starting background refresh for account ${accountId} (${platform})`);
  263. try {
  264. await this.refreshAccount(userId, accountId);
  265. logger.info(`[addAccount] Background refresh completed for account ${accountId}`);
  266. } catch (error) {
  267. logger.warn(`[addAccount] Background refresh failed for account ${accountId}:`, error);
  268. }
  269. }
  270. /**
  271. * 新增账号后,按平台触发一次“近30天数据同步”,用于初始化 user_day_statistics
  272. * 仿照定时任务里跑的导入服务,但只针对当前账号
  273. */
  274. private async initStatisticsForNewAccountAsync(account: PlatformAccount): Promise<void> {
  275. const platform = account.platform as PlatformType;
  276. // 延迟几秒,避免和前端后续操作/账号刷新抢占浏览器资源
  277. await new Promise((resolve) => setTimeout(resolve, 3000));
  278. logger.info(
  279. `[addAccount] Starting initial statistics import for account ${account.id} (${platform})`
  280. );
  281. try {
  282. if (platform === 'xiaohongshu') {
  283. await XiaohongshuAccountOverviewImportService.runDailyImport();
  284. } else if (platform === 'douyin') {
  285. await DouyinAccountOverviewImportService.runDailyImport();
  286. } else if (platform === 'baijiahao') {
  287. await BaijiahaoContentOverviewImportService.runDailyImport();
  288. } else if (platform === 'weixin_video') {
  289. await WeixinVideoDataCenterImportService.runDailyImport();
  290. } else {
  291. logger.info(
  292. `[addAccount] Initial statistics import skipped for unsupported platform ${platform}`
  293. );
  294. return;
  295. }
  296. logger.info(
  297. `[addAccount] Initial statistics import completed for account ${account.id} (${platform})`
  298. );
  299. } catch (error) {
  300. logger.warn(
  301. `[addAccount] Initial statistics import encountered error for account ${account.id} (${platform}):`,
  302. error
  303. );
  304. // 出错时不抛出,让前端添加账号流程不受影响
  305. }
  306. }
  307. async updateAccount(
  308. userId: number,
  309. accountId: number,
  310. data: UpdatePlatformAccountRequest
  311. ): Promise<PlatformAccountType> {
  312. const account = await this.accountRepository.findOne({
  313. where: { id: accountId, userId },
  314. });
  315. if (!account) {
  316. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  317. }
  318. await this.accountRepository.update(accountId, {
  319. groupId: data.groupId !== undefined ? data.groupId : account.groupId,
  320. proxyConfig: data.proxyConfig !== undefined ? data.proxyConfig : account.proxyConfig,
  321. status: data.status ?? account.status,
  322. });
  323. const updated = await this.accountRepository.findOne({ where: { id: accountId } });
  324. // 通知其他客户端
  325. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
  326. return this.formatAccount(updated!);
  327. }
  328. async deleteAccount(userId: number, accountId: number): Promise<void> {
  329. const account = await this.accountRepository.findOne({
  330. where: { id: accountId, userId },
  331. });
  332. if (!account) {
  333. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  334. }
  335. await this.accountRepository.delete(accountId);
  336. // 通知其他客户端
  337. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DELETED, { accountId });
  338. }
  339. async refreshAccount(userId: number, accountId: number): Promise<PlatformAccountType & { needReLogin?: boolean }> {
  340. const account = await this.accountRepository.findOne({
  341. where: { id: accountId, userId },
  342. });
  343. if (!account) {
  344. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  345. }
  346. const platform = account.platform as PlatformType;
  347. const updateData: Partial<PlatformAccount> = {
  348. updatedAt: new Date(),
  349. };
  350. let needReLogin = false;
  351. let aiRefreshSuccess = false;
  352. // 尝试通过无头浏览器刷新账号信息
  353. if (account.cookieData) {
  354. try {
  355. // 解密 Cookie
  356. let decryptedCookies: string;
  357. try {
  358. decryptedCookies = CookieManager.decrypt(account.cookieData);
  359. } catch {
  360. decryptedCookies = account.cookieData;
  361. }
  362. // 解析 Cookie - 支持两种格式
  363. let cookieList: { name: string; value: string; domain: string; path: string }[];
  364. let cookieParseError = false;
  365. try {
  366. // 先尝试 JSON 格式
  367. cookieList = JSON.parse(decryptedCookies);
  368. } catch {
  369. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  370. cookieList = this.parseCookieString(decryptedCookies, platform);
  371. if (cookieList.length === 0) {
  372. logger.error(`Invalid cookie format for account ${accountId}`);
  373. cookieParseError = true;
  374. }
  375. }
  376. if (cookieList.length > 0 && !cookieParseError) {
  377. // 抖音、小红书、百家号直接使用 API 获取准确数据,不使用 AI(因为 AI 可能识别错误)
  378. // 但是如果 API 调用失败,作为备用方案仍会尝试 AI
  379. const platformsSkipAI: PlatformType[] = ['douyin', 'xiaohongshu', 'baijiahao'];
  380. const shouldUseAI = aiService.isAvailable() && !platformsSkipAI.includes(platform);
  381. logger.info(`[refreshAccount] Platform: ${platform}, shouldUseAI: ${shouldUseAI}, aiAvailable: ${aiService.isAvailable()}`);
  382. // ========== AI 辅助刷新(部分平台使用) ==========
  383. if (shouldUseAI) {
  384. try {
  385. logger.info(`[AI Refresh] Starting AI-assisted refresh for account ${accountId} (${platform})`);
  386. // 使用无头浏览器截图,然后 AI 分析
  387. const aiResult = await this.refreshAccountWithAI(platform, cookieList, accountId);
  388. if (aiResult.needReLogin) {
  389. // AI 检测到需要重新登录
  390. updateData.status = 'expired';
  391. needReLogin = true;
  392. aiRefreshSuccess = true;
  393. logger.warn(`[AI Refresh] Account ${accountId} needs re-login (detected by AI)`);
  394. } else if (aiResult.accountInfo) {
  395. // AI 成功获取到账号信息
  396. updateData.status = 'active';
  397. updateData.accountName = aiResult.accountInfo.accountName;
  398. if (aiResult.accountInfo.avatarUrl) {
  399. updateData.avatarUrl = aiResult.accountInfo.avatarUrl;
  400. }
  401. if (aiResult.accountInfo.fansCount !== undefined) {
  402. updateData.fansCount = aiResult.accountInfo.fansCount;
  403. }
  404. if (aiResult.accountInfo.worksCount !== undefined) {
  405. updateData.worksCount = aiResult.accountInfo.worksCount;
  406. }
  407. aiRefreshSuccess = true;
  408. logger.info(`[AI Refresh] Successfully refreshed account ${accountId}: ${aiResult.accountInfo.accountName}`);
  409. }
  410. } catch (aiError) {
  411. logger.warn(`[AI Refresh] AI-assisted refresh failed for account ${accountId}:`, aiError);
  412. // AI 刷新失败,继续使用原有逻辑
  413. }
  414. }
  415. // ========== 原有逻辑(AI 失败时的备用方案) ==========
  416. if (!aiRefreshSuccess) {
  417. const cookieStatus = await headlessBrowserService.checkCookieStatus(platform, cookieList);
  418. if (cookieStatus.needReLogin) {
  419. updateData.status = 'expired';
  420. needReLogin = true;
  421. logger.warn(
  422. `Account ${accountId} (${account.accountName}) needs re-login: reason=${cookieStatus.reason}, source=${cookieStatus.source}, msg=${cookieStatus.message || ''}`
  423. );
  424. } else if (cookieStatus.uncertain) {
  425. logger.warn(
  426. `Account ${accountId} (${account.accountName}) cookie status uncertain: source=${cookieStatus.source}, msg=${cookieStatus.message || ''}`
  427. );
  428. } else if (cookieStatus.isValid) {
  429. updateData.status = 'active';
  430. try {
  431. const profile = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
  432. // 检查是否获取到有效信息(排除默认名称)
  433. const defaultNames = [
  434. `${platform}账号`, '未知账号', '抖音账号', '小红书账号',
  435. '快手账号', '视频号账号', 'B站账号', '头条账号', '百家号账号'
  436. ];
  437. const isValidProfile = profile.accountName && !defaultNames.includes(profile.accountName);
  438. if (isValidProfile) {
  439. updateData.accountName = profile.accountName;
  440. updateData.avatarUrl = profile.avatarUrl;
  441. // 仅在粉丝数有效时更新(避免因获取失败导致的归零)
  442. if (profile.fansCount !== undefined) {
  443. // 如果新粉丝数为 0,但原粉丝数 > 0,可能是获取失败,记录警告并跳过更新
  444. if (profile.fansCount === 0 && (account.fansCount || 0) > 0) {
  445. logger.warn(`[refreshAccount] Fans count dropped to 0 for ${accountId} (was ${account.fansCount}). Ignoring potential fetch error.`);
  446. } else {
  447. updateData.fansCount = profile.fansCount;
  448. }
  449. }
  450. if (profile.worksCount === 0 && (account.worksCount || 0) > 0) {
  451. logger.warn(`[refreshAccount] Works count dropped to 0 for ${accountId} (was ${account.worksCount}). Ignoring potential fetch error.`);
  452. } else {
  453. updateData.worksCount = profile.worksCount;
  454. }
  455. // 如果获取到了有效的 accountId(如抖音号),也更新它
  456. // 这样可以修正之前使用错误 ID(如 Cookie 值)保存的账号
  457. if (profile.accountId && !this.isTimestampBasedId(profile.accountId)) {
  458. const newAccountId = this.normalizeAccountId(platform, profile.accountId);
  459. // 只有当新 ID 与旧 ID 不同时才更新
  460. if (newAccountId !== account.accountId) {
  461. updateData.accountId = newAccountId;
  462. logger.info(`[refreshAccount] Updating accountId from ${account.accountId} to ${newAccountId}`);
  463. }
  464. }
  465. logger.info(`Refreshed account info for ${platform}: ${profile.accountName}, fans: ${profile.fansCount}, works: ${profile.worksCount}`);
  466. } else {
  467. // 获取的信息无效,但 Cookie 有效,保持 active 状态
  468. logger.warn(`Could not fetch valid account info for ${accountId}, but cookie is valid`);
  469. }
  470. } catch (infoError) {
  471. // 获取账号信息失败,但 Cookie 检查已通过,保持 active 状态
  472. logger.warn(`Failed to fetch account info for ${accountId}, but cookie is valid:`, infoError);
  473. // 对于百家号,如果是获取信息失败,可能是分散认证问题,不需要立即标记为失败
  474. if (platform === 'baijiahao') {
  475. logger.info(`[baijiahao] Account info fetch failed for ${accountId}, but this might be due to distributed auth. Keeping status active.`);
  476. }
  477. }
  478. } else {
  479. logger.warn(
  480. `Account ${accountId} (${account.accountName}) cookie invalid without explicit re-login: source=${cookieStatus.source}, reason=${cookieStatus.reason}`
  481. );
  482. }
  483. }
  484. }
  485. // Cookie 解析失败时,不改变状态
  486. } catch (error) {
  487. logger.error(`Failed to refresh account ${accountId}:`, error);
  488. // 不抛出错误,不改变状态,只更新时间戳
  489. }
  490. }
  491. // 没有 Cookie 数据时,不改变状态
  492. await this.accountRepository.update(accountId, updateData);
  493. const updated = await this.accountRepository.findOne({ where: { id: accountId } });
  494. // 刷新账号时不再写入 user_day_statistics,仅保留:每天定时任务 + 添加账号时的 initStatisticsForNewAccountAsync
  495. // if (updated) {
  496. // try {
  497. // const userDayStatisticsService = new UserDayStatisticsService();
  498. // await userDayStatisticsService.saveStatistics({
  499. // accountId,
  500. // fansCount: updated.fansCount || 0,
  501. // worksCount: updated.worksCount || 0,
  502. // });
  503. // logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
  504. // } catch (error) {
  505. // logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
  506. // }
  507. // }
  508. // 通知其他客户端
  509. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
  510. return { ...this.formatAccount(updated!), needReLogin };
  511. }
  512. /**
  513. * 使用 AI 辅助刷新账号信息
  514. */
  515. private async refreshAccountWithAI(
  516. platform: PlatformType,
  517. cookieList: { name: string; value: string; domain: string; path: string }[],
  518. accountId: number
  519. ): Promise<{
  520. needReLogin: boolean;
  521. accountInfo?: {
  522. accountName: string;
  523. avatarUrl?: string;
  524. fansCount?: number;
  525. worksCount?: number;
  526. };
  527. }> {
  528. // 使用无头浏览器访问平台后台并截图
  529. const screenshot = await headlessBrowserService.capturePageScreenshot(platform, cookieList);
  530. if (!screenshot) {
  531. throw new Error('Failed to capture screenshot');
  532. }
  533. // 第一步:使用 AI 分析登录状态
  534. const loginStatus = await aiService.analyzeLoginStatus(screenshot, platform);
  535. logger.info(`[AI Refresh] Login status for account ${accountId}:`, {
  536. isLoggedIn: loginStatus.isLoggedIn,
  537. hasVerification: loginStatus.hasVerification,
  538. });
  539. // 如果 AI 检测到未登录或有验证码,说明需要重新登录
  540. if (!loginStatus.isLoggedIn || loginStatus.hasVerification) {
  541. return { needReLogin: true };
  542. }
  543. // 第二步:使用 AI 提取账号信息
  544. const accountInfo = await aiService.extractAccountInfo(screenshot, platform);
  545. logger.info(`[AI Refresh] Account info extraction for ${accountId}:`, {
  546. found: accountInfo.found,
  547. accountName: accountInfo.accountName,
  548. });
  549. if (accountInfo.found && accountInfo.accountName) {
  550. return {
  551. needReLogin: false,
  552. accountInfo: {
  553. accountName: accountInfo.accountName,
  554. fansCount: accountInfo.fansCount ? parseInt(String(accountInfo.fansCount)) : undefined,
  555. worksCount: accountInfo.worksCount ? parseInt(String(accountInfo.worksCount)) : undefined,
  556. },
  557. };
  558. }
  559. // AI 未能提取到账号信息,但登录状态正常
  560. // 返回空结果,让原有逻辑处理
  561. return { needReLogin: false };
  562. }
  563. /**
  564. * 检查账号 Cookie 是否有效
  565. */
  566. async checkAccountStatus(
  567. userId: number,
  568. accountId: number
  569. ): Promise<{ isValid: boolean; needReLogin: boolean; uncertain: boolean }> {
  570. const account = await this.accountRepository.findOne({
  571. where: { id: accountId, userId },
  572. });
  573. if (!account) {
  574. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  575. }
  576. if (!account.cookieData) {
  577. // 更新状态为过期
  578. await this.accountRepository.update(accountId, { status: 'expired' });
  579. return { isValid: false, needReLogin: true, uncertain: false };
  580. }
  581. const platform = account.platform as PlatformType;
  582. try {
  583. // 解密 Cookie
  584. let decryptedCookies: string;
  585. try {
  586. decryptedCookies = CookieManager.decrypt(account.cookieData);
  587. } catch {
  588. decryptedCookies = account.cookieData;
  589. }
  590. // 解析 Cookie - 支持两种格式
  591. let cookieList: { name: string; value: string; domain: string; path: string }[];
  592. try {
  593. // 先尝试 JSON 格式
  594. cookieList = JSON.parse(decryptedCookies);
  595. } catch {
  596. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  597. cookieList = this.parseCookieString(decryptedCookies, platform);
  598. if (cookieList.length === 0) {
  599. await this.accountRepository.update(accountId, { status: 'expired' });
  600. return { isValid: false, needReLogin: true, uncertain: false };
  601. }
  602. }
  603. const cookieStatus = await headlessBrowserService.checkCookieStatus(platform, cookieList);
  604. // 更新账号状态
  605. if (cookieStatus.needReLogin) {
  606. await this.accountRepository.update(accountId, { status: 'expired' });
  607. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, {
  608. account: { ...this.formatAccount(account), status: 'expired' }
  609. });
  610. return { isValid: false, needReLogin: true, uncertain: false };
  611. }
  612. if (cookieStatus.uncertain) {
  613. return { isValid: true, needReLogin: false, uncertain: true };
  614. }
  615. if (cookieStatus.isValid && account.status === 'expired') {
  616. // 如果之前是过期状态但现在有效了,更新为正常
  617. await this.accountRepository.update(accountId, { status: 'active' });
  618. }
  619. return { isValid: cookieStatus.isValid, needReLogin: false, uncertain: false };
  620. } catch (error) {
  621. logger.error(`Failed to check account status ${accountId}:`, error);
  622. return { isValid: true, needReLogin: false, uncertain: true };
  623. }
  624. }
  625. /**
  626. * 批量刷新所有账号状态
  627. */
  628. async refreshAllAccounts(userId: number): Promise<{ refreshed: number; failed: number }> {
  629. const accounts = await this.accountRepository.find({
  630. where: { userId },
  631. });
  632. let refreshed = 0;
  633. let failed = 0;
  634. for (const account of accounts) {
  635. try {
  636. await this.refreshAccount(userId, account.id);
  637. refreshed++;
  638. } catch (error) {
  639. logger.error(`Failed to refresh account ${account.id}:`, error);
  640. failed++;
  641. }
  642. }
  643. logger.info(`Refreshed ${refreshed} accounts for user ${userId}, ${failed} failed`);
  644. return { refreshed, failed };
  645. }
  646. async getQRCode(platform: string): Promise<QRCodeInfo> {
  647. // TODO: 调用对应平台适配器获取二维码
  648. // 这里返回模拟数据
  649. return {
  650. qrcodeUrl: `https://example.com/qrcode/${platform}/${Date.now()}`,
  651. qrcodeKey: `qr_${platform}_${Date.now()}`,
  652. expireTime: Date.now() + 300000, // 5分钟后过期
  653. };
  654. }
  655. async checkQRCodeStatus(platform: string, qrcodeKey: string): Promise<LoginStatusResult> {
  656. // TODO: 调用对应平台适配器检查扫码状态
  657. // 这里返回模拟数据
  658. return {
  659. status: 'waiting',
  660. message: '等待扫码',
  661. };
  662. }
  663. /**
  664. * 验证 Cookie 并获取账号信息(用于 Electron 内嵌浏览器登录)
  665. */
  666. async verifyCookieAndGetInfo(platform: PlatformType, cookieData: string): Promise<{
  667. success: boolean;
  668. message?: string;
  669. accountInfo?: {
  670. accountId: string;
  671. accountName: string;
  672. avatarUrl: string;
  673. fansCount: number;
  674. worksCount: number;
  675. };
  676. }> {
  677. try {
  678. const domainMap: Record<string, string> = {
  679. douyin: '.douyin.com',
  680. kuaishou: '.kuaishou.com',
  681. xiaohongshu: '.xiaohongshu.com',
  682. weixin_video: '.qq.com',
  683. bilibili: '.bilibili.com',
  684. toutiao: '.toutiao.com',
  685. baijiahao: '.baidu.com',
  686. qie: '.qq.com',
  687. dayuhao: '.alibaba.com',
  688. };
  689. let cookieList: { name: string; value: string; domain: string; path: string }[] = [];
  690. try {
  691. const parsed = JSON.parse(cookieData) as Array<{
  692. name?: string;
  693. value?: string;
  694. domain?: string;
  695. path?: string;
  696. }>;
  697. if (Array.isArray(parsed) && parsed.length > 0) {
  698. const domain = domainMap[platform] || `.${platform}.com`;
  699. cookieList = parsed
  700. .filter(c => c?.name && c?.value)
  701. .map(c => ({
  702. name: String(c.name),
  703. value: String(c.value),
  704. domain: c.domain ? String(c.domain) : domain,
  705. path: c.path ? String(c.path) : '/',
  706. }));
  707. }
  708. } catch {
  709. cookieList = this.parseCookieString(cookieData, platform);
  710. }
  711. if (cookieList.length === 0) {
  712. return { success: false, message: 'Cookie 格式无效' };
  713. }
  714. // 先尝试获取账号信息(可以同时验证登录状态)
  715. // 如果能成功获取到有效信息,说明登录是有效的
  716. try {
  717. const profile = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
  718. // 检查是否获取到有效信息(排除默认名称)
  719. const defaultNames = [
  720. `${platform}账号`, '未知账号', '抖音账号', '小红书账号',
  721. '快手账号', '视频号账号', 'B站账号', '头条账号', '百家号账号'
  722. ];
  723. const isValidProfile = profile.accountName && !defaultNames.includes(profile.accountName);
  724. if (isValidProfile) {
  725. return {
  726. success: true,
  727. accountInfo: {
  728. // 确保 accountId 带有平台前缀
  729. accountId: this.normalizeAccountId(platform, profile.accountId || ''),
  730. accountName: profile.accountName,
  731. avatarUrl: profile.avatarUrl || '',
  732. fansCount: profile.fansCount || 0,
  733. worksCount: profile.worksCount || 0,
  734. },
  735. };
  736. }
  737. // 未能获取有效信息,再验证 Cookie 是否有效
  738. logger.info(`[verifyCookieAndGetInfo] Could not get valid profile for ${platform}, checking cookie validity...`);
  739. } catch (infoError) {
  740. logger.warn(`Failed to fetch account info for ${platform}:`, infoError);
  741. }
  742. // 账号信息获取失败或无效,检查 Cookie 是否有效
  743. const cookieStatus = await headlessBrowserService.checkCookieStatus(platform, cookieList);
  744. if (cookieStatus.isValid) {
  745. // Cookie 有效但未能获取账号信息,返回基本成功
  746. return {
  747. success: true,
  748. message: '登录成功,但无法获取详细信息',
  749. accountInfo: {
  750. accountId: `${platform}_${Date.now()}`,
  751. accountName: `${platform}账号`,
  752. avatarUrl: '',
  753. fansCount: 0,
  754. worksCount: 0,
  755. },
  756. };
  757. }
  758. if (cookieStatus.needReLogin) {
  759. // Cookie 无效
  760. return { success: false, message: '登录状态无效或已过期' };
  761. }
  762. return { success: false, message: '无法验证登录状态,请稍后重试' };
  763. } catch (error) {
  764. logger.error(`Failed to verify cookie for ${platform}:`, error);
  765. return {
  766. success: false,
  767. message: error instanceof Error ? error.message : '验证失败'
  768. };
  769. }
  770. }
  771. /**
  772. * 将 cookie 字符串解析为 cookie 列表
  773. */
  774. private parseCookieString(cookieString: string, platform: PlatformType): {
  775. name: string;
  776. value: string;
  777. domain: string;
  778. path: string;
  779. }[] {
  780. // 获取平台对应的域名
  781. const domainMap: Record<string, string> = {
  782. douyin: '.douyin.com',
  783. kuaishou: '.kuaishou.com',
  784. xiaohongshu: '.xiaohongshu.com',
  785. weixin_video: '.qq.com',
  786. bilibili: '.bilibili.com',
  787. toutiao: '.toutiao.com',
  788. baijiahao: '.baidu.com',
  789. qie: '.qq.com',
  790. dayuhao: '.alibaba.com',
  791. };
  792. const domain = domainMap[platform] || `.${platform}.com`;
  793. // 解析 "name=value; name2=value2" 格式的 cookie 字符串
  794. const cookies: { name: string; value: string; domain: string; path: string }[] = [];
  795. const pairs = cookieString.split(';');
  796. for (const pair of pairs) {
  797. const trimmed = pair.trim();
  798. if (!trimmed) continue;
  799. const eqIndex = trimmed.indexOf('=');
  800. if (eqIndex === -1) continue;
  801. const name = trimmed.substring(0, eqIndex).trim();
  802. const value = trimmed.substring(eqIndex + 1).trim();
  803. if (name && value) {
  804. cookies.push({
  805. name,
  806. value,
  807. domain,
  808. path: '/',
  809. });
  810. }
  811. }
  812. return cookies;
  813. }
  814. private formatGroup(group: AccountGroup): AccountGroupType {
  815. return {
  816. id: group.id,
  817. userId: group.userId,
  818. name: group.name,
  819. description: group.description,
  820. createdAt: group.createdAt.toISOString(),
  821. };
  822. }
  823. private formatAccount(account: PlatformAccount): PlatformAccountType {
  824. return {
  825. id: account.id,
  826. userId: account.userId,
  827. platform: account.platform,
  828. accountName: account.accountName || '',
  829. accountId: account.accountId || '',
  830. avatarUrl: account.avatarUrl,
  831. fansCount: account.fansCount,
  832. worksCount: account.worksCount,
  833. status: account.status,
  834. proxyConfig: account.proxyConfig,
  835. groupId: account.groupId,
  836. cookieExpireAt: account.cookieExpireAt?.toISOString() || null,
  837. createdAt: account.createdAt.toISOString(),
  838. updatedAt: account.updatedAt.toISOString(),
  839. };
  840. }
  841. /**
  842. * 从 Cookie 中提取账号 ID(最可靠的方式)
  843. * 不同平台使用不同的 Cookie 字段来标识用户
  844. */
  845. private extractAccountIdFromCookie(platform: PlatformType, cookieString: string): string | null {
  846. // 各平台用于标识用户的 Cookie 名称(按优先级排序)
  847. const platformCookieNames: Record<string, string[]> = {
  848. douyin: ['passport_uid', 'uid_tt', 'ttwid', 'sessionid_ss'],
  849. kuaishou: ['userId', 'passToken', 'did'],
  850. xiaohongshu: ['customerClientId', 'web_session', 'xsecappid'],
  851. weixin_video: ['wxuin', 'pass_ticket', 'uin'],
  852. bilibili: ['DedeUserID', 'SESSDATA', 'bili_jct'],
  853. toutiao: ['sso_uid', 'sessionid', 'passport_uid'],
  854. baijiahao: ['BDUSS', 'STOKEN', 'BAIDUID'],
  855. qie: ['uin', 'skey', 'p_uin'],
  856. dayuhao: ['login_aliyunid', 'cna', 'munb'],
  857. };
  858. const targetCookieNames = platformCookieNames[platform] || [];
  859. if (targetCookieNames.length === 0) {
  860. return null;
  861. }
  862. try {
  863. // 尝试解析 JSON 格式的 Cookie
  864. let cookieList: { name: string; value: string }[];
  865. try {
  866. cookieList = JSON.parse(cookieString);
  867. } catch {
  868. // 如果不是 JSON,尝试解析 "name=value; name2=value2" 格式
  869. cookieList = this.parseCookieString(cookieString, platform).map(c => ({
  870. name: c.name,
  871. value: c.value,
  872. }));
  873. }
  874. if (!Array.isArray(cookieList) || cookieList.length === 0) {
  875. return null;
  876. }
  877. // 按优先级查找 Cookie
  878. for (const cookieName of targetCookieNames) {
  879. const cookie = cookieList.find(c => c.name === cookieName);
  880. if (cookie?.value) {
  881. // 获取 Cookie 值,处理可能的编码
  882. let cookieValue = cookie.value;
  883. // 处理特殊格式的 Cookie(如 ttwid 可能包含分隔符)
  884. if (cookieValue.includes('|')) {
  885. cookieValue = cookieValue.split('|')[1] || cookieValue;
  886. }
  887. if (cookieValue.includes('%')) {
  888. try {
  889. cookieValue = decodeURIComponent(cookieValue);
  890. } catch {
  891. // 解码失败,使用原值
  892. }
  893. }
  894. // 截取合理长度(避免过长的 ID)
  895. if (cookieValue.length > 64) {
  896. cookieValue = cookieValue.slice(0, 64);
  897. }
  898. const accountId = `${platform}_${cookieValue}`;
  899. logger.info(`[extractAccountIdFromCookie] Found ${cookieName} for ${platform}: ${accountId}`);
  900. return accountId;
  901. }
  902. }
  903. return null;
  904. } catch (error) {
  905. logger.warn(`[extractAccountIdFromCookie] Failed to extract accountId from cookie for ${platform}:`, error);
  906. return null;
  907. }
  908. }
  909. /**
  910. * 平台短前缀映射(统一使用短前缀)
  911. */
  912. private static readonly SHORT_PREFIX_MAP: Record<PlatformType, string> = {
  913. 'douyin': 'dy_',
  914. 'xiaohongshu': 'xhs_',
  915. 'weixin_video': 'sph_',
  916. 'baijiahao': 'bjh_',
  917. 'kuaishou': 'ks_',
  918. 'bilibili': 'bili_',
  919. 'toutiao': 'tt_',
  920. 'qie': 'qie_',
  921. 'dayuhao': 'dyh_',
  922. };
  923. /**
  924. * 标准化 accountId 格式,统一使用短前缀
  925. * 例如:Ethanfly9392 -> dy_Ethanfly9392
  926. */
  927. private normalizeAccountId(platform: PlatformType, accountId: string): string {
  928. const shortPrefix = AccountService.SHORT_PREFIX_MAP[platform] || `${platform}_`;
  929. if (!accountId) {
  930. return `${shortPrefix}${Date.now()}`;
  931. }
  932. // 如果已经有正确的短前缀,直接返回
  933. if (accountId.startsWith(shortPrefix)) {
  934. return accountId;
  935. }
  936. // 移除任何已有的前缀(短前缀或完整前缀)
  937. const allShortPrefixes = Object.values(AccountService.SHORT_PREFIX_MAP);
  938. const allFullPrefixes = Object.keys(AccountService.SHORT_PREFIX_MAP).map(p => `${p}_`);
  939. const allPrefixes = [...allShortPrefixes, ...allFullPrefixes];
  940. let cleanId = accountId;
  941. for (const prefix of allPrefixes) {
  942. if (cleanId.startsWith(prefix)) {
  943. cleanId = cleanId.slice(prefix.length);
  944. break;
  945. }
  946. }
  947. // 添加正确的短前缀
  948. return `${shortPrefix}${cleanId}`;
  949. }
  950. /**
  951. * 检查 accountId 是否是基于时间戳生成的(不可靠的 ID)
  952. * 时间戳 ID 格式通常是:dy_1737619200000 或 douyin_1737619200000
  953. */
  954. private isTimestampBasedId(accountId: string): boolean {
  955. if (!accountId) return true;
  956. // 检查是否匹配 前缀_时间戳 格式(支持短前缀和完整前缀)
  957. const timestampPattern = /^[a-z_]+_(\d{13,})$/;
  958. const match = accountId.match(timestampPattern);
  959. if (!match) {
  960. return false;
  961. }
  962. // 提取数字部分,检查是否是合理的时间戳(2020年到2030年之间)
  963. if (match[1]) {
  964. const timestamp = parseInt(match[1]);
  965. const minTimestamp = new Date('2020-01-01').getTime(); // 1577836800000
  966. const maxTimestamp = new Date('2030-01-01').getTime(); // 1893456000000
  967. if (timestamp >= minTimestamp && timestamp <= maxTimestamp) {
  968. logger.info(`[isTimestampBasedId] Detected timestamp-based ID: ${accountId}`);
  969. return true;
  970. }
  971. }
  972. return false;
  973. }
  974. }