AccountService.ts 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  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. const svc = new XiaohongshuAccountOverviewImportService();
  284. await svc.importAccountLast30Days(account);
  285. } else if (platform === 'douyin') {
  286. const svc = new DouyinAccountOverviewImportService();
  287. await svc.importAccountLast30Days(account);
  288. } else if (platform === 'baijiahao') {
  289. const svc = new BaijiahaoContentOverviewImportService();
  290. await svc.importAccountLast30Days(account);
  291. } else if (platform === 'weixin_video') {
  292. const svc = new WeixinVideoDataCenterImportService();
  293. await svc.importAccountLast30Days(account);
  294. } else {
  295. logger.info(
  296. `[addAccount] Initial statistics import skipped for unsupported platform ${platform}`
  297. );
  298. return;
  299. }
  300. logger.info(
  301. `[addAccount] Initial statistics import completed for account ${account.id} (${platform})`
  302. );
  303. } catch (error) {
  304. logger.warn(
  305. `[addAccount] Initial statistics import encountered error for account ${account.id} (${platform}):`,
  306. error
  307. );
  308. // 出错时不抛出,让前端添加账号流程不受影响
  309. }
  310. }
  311. async updateAccount(
  312. userId: number,
  313. accountId: number,
  314. data: UpdatePlatformAccountRequest
  315. ): Promise<PlatformAccountType> {
  316. const account = await this.accountRepository.findOne({
  317. where: { id: accountId, userId },
  318. });
  319. if (!account) {
  320. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  321. }
  322. await this.accountRepository.update(accountId, {
  323. groupId: data.groupId !== undefined ? data.groupId : account.groupId,
  324. proxyConfig: data.proxyConfig !== undefined ? data.proxyConfig : account.proxyConfig,
  325. status: data.status ?? account.status,
  326. });
  327. const updated = await this.accountRepository.findOne({ where: { id: accountId } });
  328. // 通知其他客户端
  329. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
  330. return this.formatAccount(updated!);
  331. }
  332. async deleteAccount(userId: number, accountId: number): Promise<void> {
  333. const account = await this.accountRepository.findOne({
  334. where: { id: accountId, userId },
  335. });
  336. if (!account) {
  337. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  338. }
  339. await this.accountRepository.delete(accountId);
  340. // 通知其他客户端
  341. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DELETED, { accountId });
  342. }
  343. async refreshAccount(userId: number, accountId: number): Promise<PlatformAccountType & { needReLogin?: boolean }> {
  344. const account = await this.accountRepository.findOne({
  345. where: { id: accountId, userId },
  346. });
  347. if (!account) {
  348. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  349. }
  350. const platform = account.platform as PlatformType;
  351. const updateData: Partial<PlatformAccount> = {
  352. updatedAt: new Date(),
  353. };
  354. let needReLogin = false;
  355. let aiRefreshSuccess = false;
  356. // 尝试通过无头浏览器刷新账号信息
  357. if (account.cookieData) {
  358. try {
  359. // 解密 Cookie
  360. let decryptedCookies: string;
  361. try {
  362. decryptedCookies = CookieManager.decrypt(account.cookieData);
  363. } catch {
  364. decryptedCookies = account.cookieData;
  365. }
  366. // 解析 Cookie - 支持两种格式
  367. let cookieList: { name: string; value: string; domain: string; path: string }[];
  368. let cookieParseError = false;
  369. try {
  370. // 先尝试 JSON 格式
  371. cookieList = JSON.parse(decryptedCookies);
  372. } catch {
  373. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  374. cookieList = this.parseCookieString(decryptedCookies, platform);
  375. if (cookieList.length === 0) {
  376. logger.error(`Invalid cookie format for account ${accountId}`);
  377. cookieParseError = true;
  378. }
  379. }
  380. if (cookieList.length > 0 && !cookieParseError) {
  381. // 抖音、小红书、百家号直接使用 API 获取准确数据,不使用 AI(因为 AI 可能识别错误)
  382. const platformsSkipAI: PlatformType[] = ['douyin', 'xiaohongshu', 'baijiahao'];
  383. const shouldUseAI = aiService.isAvailable() && !platformsSkipAI.includes(platform);
  384. logger.info(`[refreshAccount] Platform: ${platform}, shouldUseAI: ${shouldUseAI}, aiAvailable: ${aiService.isAvailable()}`);
  385. // ========== AI 辅助刷新(部分平台使用) ==========
  386. if (shouldUseAI) {
  387. try {
  388. logger.info(`[AI Refresh] Starting AI-assisted refresh for account ${accountId} (${platform})`);
  389. // 使用无头浏览器截图,然后 AI 分析
  390. const aiResult = await this.refreshAccountWithAI(platform, cookieList, accountId);
  391. if (aiResult.needReLogin) {
  392. // AI 检测到需要重新登录
  393. updateData.status = 'expired';
  394. needReLogin = true;
  395. aiRefreshSuccess = true;
  396. logger.warn(`[AI Refresh] Account ${accountId} needs re-login (detected by AI)`);
  397. } else if (aiResult.accountInfo) {
  398. // AI 成功获取到账号信息
  399. updateData.status = 'active';
  400. updateData.accountName = aiResult.accountInfo.accountName;
  401. if (aiResult.accountInfo.avatarUrl) {
  402. updateData.avatarUrl = aiResult.accountInfo.avatarUrl;
  403. }
  404. if (aiResult.accountInfo.fansCount !== undefined) {
  405. updateData.fansCount = aiResult.accountInfo.fansCount;
  406. }
  407. if (aiResult.accountInfo.worksCount !== undefined) {
  408. updateData.worksCount = aiResult.accountInfo.worksCount;
  409. }
  410. aiRefreshSuccess = true;
  411. logger.info(`[AI Refresh] Successfully refreshed account ${accountId}: ${aiResult.accountInfo.accountName}`);
  412. }
  413. } catch (aiError) {
  414. logger.warn(`[AI Refresh] AI-assisted refresh failed for account ${accountId}:`, aiError);
  415. // AI 刷新失败,继续使用原有逻辑
  416. }
  417. }
  418. // ========== 原有逻辑(AI 失败时的备用方案) ==========
  419. if (!aiRefreshSuccess) {
  420. // 第一步:通过浏览器检查 Cookie 是否有效
  421. const isValid = await headlessBrowserService.checkCookieValid(platform, cookieList);
  422. if (!isValid) {
  423. // Cookie 已过期,需要重新登录
  424. updateData.status = 'expired';
  425. needReLogin = true;
  426. logger.warn(`Account ${accountId} (${account.accountName}) cookie expired, need re-login`);
  427. } else {
  428. // Cookie 有效,尝试获取账号信息
  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. updateData.fansCount = profile.fansCount;
  442. updateData.worksCount = profile.worksCount;
  443. // 如果获取到了有效的 accountId(如抖音号),也更新它
  444. // 这样可以修正之前使用错误 ID(如 Cookie 值)保存的账号
  445. if (profile.accountId && !this.isTimestampBasedId(profile.accountId)) {
  446. const newAccountId = this.normalizeAccountId(platform, profile.accountId);
  447. // 只有当新 ID 与旧 ID 不同时才更新
  448. if (newAccountId !== account.accountId) {
  449. updateData.accountId = newAccountId;
  450. logger.info(`[refreshAccount] Updating accountId from ${account.accountId} to ${newAccountId}`);
  451. }
  452. }
  453. logger.info(`Refreshed account info for ${platform}: ${profile.accountName}, fans: ${profile.fansCount}, works: ${profile.worksCount}`);
  454. } else {
  455. // 获取的信息无效,但 Cookie 有效,保持 active 状态
  456. logger.warn(`Could not fetch valid account info for ${accountId}, but cookie is valid`);
  457. }
  458. } catch (infoError) {
  459. // 获取账号信息失败,但 Cookie 检查已通过,保持 active 状态
  460. logger.warn(`Failed to fetch account info for ${accountId}, but cookie is valid:`, infoError);
  461. }
  462. }
  463. }
  464. }
  465. // Cookie 解析失败时,不改变状态
  466. } catch (error) {
  467. logger.error(`Failed to refresh account ${accountId}:`, error);
  468. // 不抛出错误,不改变状态,只更新时间戳
  469. }
  470. }
  471. // 没有 Cookie 数据时,不改变状态
  472. await this.accountRepository.update(accountId, updateData);
  473. const updated = await this.accountRepository.findOne({ where: { id: accountId } });
  474. // 保存账号每日统计数据(粉丝数、作品数)
  475. // 无论是否更新了粉丝数/作品数,都要保存当前值到统计表,确保每天都有记录
  476. // TODO: 暂时注释掉,待后续需要时再启用
  477. // if (updated) {
  478. // try {
  479. // const userDayStatisticsService = new UserDayStatisticsService();
  480. // await userDayStatisticsService.saveStatistics({
  481. // accountId,
  482. // fansCount: updated.fansCount || 0,
  483. // worksCount: updated.worksCount || 0,
  484. // });
  485. // logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
  486. // } catch (error) {
  487. // logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
  488. // // 不抛出错误,不影响主流程
  489. // }
  490. // }
  491. // 通知其他客户端
  492. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
  493. return { ...this.formatAccount(updated!), needReLogin };
  494. }
  495. /**
  496. * 使用 AI 辅助刷新账号信息
  497. */
  498. private async refreshAccountWithAI(
  499. platform: PlatformType,
  500. cookieList: { name: string; value: string; domain: string; path: string }[],
  501. accountId: number
  502. ): Promise<{
  503. needReLogin: boolean;
  504. accountInfo?: {
  505. accountName: string;
  506. avatarUrl?: string;
  507. fansCount?: number;
  508. worksCount?: number;
  509. };
  510. }> {
  511. // 使用无头浏览器访问平台后台并截图
  512. const screenshot = await headlessBrowserService.capturePageScreenshot(platform, cookieList);
  513. if (!screenshot) {
  514. throw new Error('Failed to capture screenshot');
  515. }
  516. // 第一步:使用 AI 分析登录状态
  517. const loginStatus = await aiService.analyzeLoginStatus(screenshot, platform);
  518. logger.info(`[AI Refresh] Login status for account ${accountId}:`, {
  519. isLoggedIn: loginStatus.isLoggedIn,
  520. hasVerification: loginStatus.hasVerification,
  521. });
  522. // 如果 AI 检测到未登录或有验证码,说明需要重新登录
  523. if (!loginStatus.isLoggedIn || loginStatus.hasVerification) {
  524. return { needReLogin: true };
  525. }
  526. // 第二步:使用 AI 提取账号信息
  527. const accountInfo = await aiService.extractAccountInfo(screenshot, platform);
  528. logger.info(`[AI Refresh] Account info extraction for ${accountId}:`, {
  529. found: accountInfo.found,
  530. accountName: accountInfo.accountName,
  531. });
  532. if (accountInfo.found && accountInfo.accountName) {
  533. return {
  534. needReLogin: false,
  535. accountInfo: {
  536. accountName: accountInfo.accountName,
  537. fansCount: accountInfo.fansCount ? parseInt(String(accountInfo.fansCount)) : undefined,
  538. worksCount: accountInfo.worksCount ? parseInt(String(accountInfo.worksCount)) : undefined,
  539. },
  540. };
  541. }
  542. // AI 未能提取到账号信息,但登录状态正常
  543. // 返回空结果,让原有逻辑处理
  544. return { needReLogin: false };
  545. }
  546. /**
  547. * 检查账号 Cookie 是否有效
  548. */
  549. async checkAccountStatus(userId: number, accountId: number): Promise<{ isValid: boolean }> {
  550. const account = await this.accountRepository.findOne({
  551. where: { id: accountId, userId },
  552. });
  553. if (!account) {
  554. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
  555. }
  556. if (!account.cookieData) {
  557. // 更新状态为过期
  558. await this.accountRepository.update(accountId, { status: 'expired' });
  559. return { isValid: false };
  560. }
  561. const platform = account.platform as PlatformType;
  562. try {
  563. // 解密 Cookie
  564. let decryptedCookies: string;
  565. try {
  566. decryptedCookies = CookieManager.decrypt(account.cookieData);
  567. } catch {
  568. decryptedCookies = account.cookieData;
  569. }
  570. // 解析 Cookie - 支持两种格式
  571. let cookieList: { name: string; value: string; domain: string; path: string }[];
  572. try {
  573. // 先尝试 JSON 格式
  574. cookieList = JSON.parse(decryptedCookies);
  575. } catch {
  576. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  577. cookieList = this.parseCookieString(decryptedCookies, platform);
  578. if (cookieList.length === 0) {
  579. await this.accountRepository.update(accountId, { status: 'expired' });
  580. return { isValid: false };
  581. }
  582. }
  583. // 使用 API 检查 Cookie 是否有效
  584. const isValid = await headlessBrowserService.checkCookieValid(platform, cookieList);
  585. // 更新账号状态
  586. if (!isValid) {
  587. await this.accountRepository.update(accountId, { status: 'expired' });
  588. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, {
  589. account: { ...this.formatAccount(account), status: 'expired' }
  590. });
  591. } else if (account.status === 'expired') {
  592. // 如果之前是过期状态但现在有效了,更新为正常
  593. await this.accountRepository.update(accountId, { status: 'active' });
  594. }
  595. return { isValid };
  596. } catch (error) {
  597. logger.error(`Failed to check account status ${accountId}:`, error);
  598. return { isValid: false };
  599. }
  600. }
  601. /**
  602. * 批量刷新所有账号状态
  603. */
  604. async refreshAllAccounts(userId: number): Promise<{ refreshed: number; failed: number }> {
  605. const accounts = await this.accountRepository.find({
  606. where: { userId },
  607. });
  608. let refreshed = 0;
  609. let failed = 0;
  610. for (const account of accounts) {
  611. try {
  612. await this.refreshAccount(userId, account.id);
  613. refreshed++;
  614. } catch (error) {
  615. logger.error(`Failed to refresh account ${account.id}:`, error);
  616. failed++;
  617. }
  618. }
  619. logger.info(`Refreshed ${refreshed} accounts for user ${userId}, ${failed} failed`);
  620. return { refreshed, failed };
  621. }
  622. async getQRCode(platform: string): Promise<QRCodeInfo> {
  623. // TODO: 调用对应平台适配器获取二维码
  624. // 这里返回模拟数据
  625. return {
  626. qrcodeUrl: `https://example.com/qrcode/${platform}/${Date.now()}`,
  627. qrcodeKey: `qr_${platform}_${Date.now()}`,
  628. expireTime: Date.now() + 300000, // 5分钟后过期
  629. };
  630. }
  631. async checkQRCodeStatus(platform: string, qrcodeKey: string): Promise<LoginStatusResult> {
  632. // TODO: 调用对应平台适配器检查扫码状态
  633. // 这里返回模拟数据
  634. return {
  635. status: 'waiting',
  636. message: '等待扫码',
  637. };
  638. }
  639. /**
  640. * 验证 Cookie 并获取账号信息(用于 Electron 内嵌浏览器登录)
  641. */
  642. async verifyCookieAndGetInfo(platform: PlatformType, cookieData: string): Promise<{
  643. success: boolean;
  644. message?: string;
  645. accountInfo?: {
  646. accountId: string;
  647. accountName: string;
  648. avatarUrl: string;
  649. fansCount: number;
  650. worksCount: number;
  651. };
  652. }> {
  653. try {
  654. // 将 cookie 字符串转换为 cookie 列表格式
  655. const cookieList = this.parseCookieString(cookieData, platform);
  656. if (cookieList.length === 0) {
  657. return { success: false, message: 'Cookie 格式无效' };
  658. }
  659. // 先尝试获取账号信息(可以同时验证登录状态)
  660. // 如果能成功获取到有效信息,说明登录是有效的
  661. try {
  662. const profile = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
  663. // 检查是否获取到有效信息(排除默认名称)
  664. const defaultNames = [
  665. `${platform}账号`, '未知账号', '抖音账号', '小红书账号',
  666. '快手账号', '视频号账号', 'B站账号', '头条账号', '百家号账号'
  667. ];
  668. const isValidProfile = profile.accountName && !defaultNames.includes(profile.accountName);
  669. if (isValidProfile) {
  670. return {
  671. success: true,
  672. accountInfo: {
  673. // 确保 accountId 带有平台前缀
  674. accountId: this.normalizeAccountId(platform, profile.accountId || ''),
  675. accountName: profile.accountName,
  676. avatarUrl: profile.avatarUrl || '',
  677. fansCount: profile.fansCount || 0,
  678. worksCount: profile.worksCount || 0,
  679. },
  680. };
  681. }
  682. // 未能获取有效信息,再验证 Cookie 是否有效
  683. logger.info(`[verifyCookieAndGetInfo] Could not get valid profile for ${platform}, checking cookie validity...`);
  684. } catch (infoError) {
  685. logger.warn(`Failed to fetch account info for ${platform}:`, infoError);
  686. }
  687. // 账号信息获取失败或无效,检查 Cookie 是否有效
  688. const isValid = await headlessBrowserService.checkCookieValid(platform, cookieList);
  689. if (isValid) {
  690. // Cookie 有效但未能获取账号信息,返回基本成功
  691. return {
  692. success: true,
  693. message: '登录成功,但无法获取详细信息',
  694. accountInfo: {
  695. accountId: `${platform}_${Date.now()}`,
  696. accountName: `${platform}账号`,
  697. avatarUrl: '',
  698. fansCount: 0,
  699. worksCount: 0,
  700. },
  701. };
  702. } else {
  703. // Cookie 无效
  704. return { success: false, message: '登录状态无效或已过期' };
  705. }
  706. } catch (error) {
  707. logger.error(`Failed to verify cookie for ${platform}:`, error);
  708. return {
  709. success: false,
  710. message: error instanceof Error ? error.message : '验证失败'
  711. };
  712. }
  713. }
  714. /**
  715. * 将 cookie 字符串解析为 cookie 列表
  716. */
  717. private parseCookieString(cookieString: string, platform: PlatformType): {
  718. name: string;
  719. value: string;
  720. domain: string;
  721. path: string;
  722. }[] {
  723. // 获取平台对应的域名
  724. const domainMap: Record<string, string> = {
  725. douyin: '.douyin.com',
  726. kuaishou: '.kuaishou.com',
  727. xiaohongshu: '.xiaohongshu.com',
  728. weixin_video: '.qq.com',
  729. bilibili: '.bilibili.com',
  730. toutiao: '.toutiao.com',
  731. baijiahao: '.baidu.com',
  732. qie: '.qq.com',
  733. dayuhao: '.alibaba.com',
  734. };
  735. const domain = domainMap[platform] || `.${platform}.com`;
  736. // 解析 "name=value; name2=value2" 格式的 cookie 字符串
  737. const cookies: { name: string; value: string; domain: string; path: string }[] = [];
  738. const pairs = cookieString.split(';');
  739. for (const pair of pairs) {
  740. const trimmed = pair.trim();
  741. if (!trimmed) continue;
  742. const eqIndex = trimmed.indexOf('=');
  743. if (eqIndex === -1) continue;
  744. const name = trimmed.substring(0, eqIndex).trim();
  745. const value = trimmed.substring(eqIndex + 1).trim();
  746. if (name && value) {
  747. cookies.push({
  748. name,
  749. value,
  750. domain,
  751. path: '/',
  752. });
  753. }
  754. }
  755. return cookies;
  756. }
  757. private formatGroup(group: AccountGroup): AccountGroupType {
  758. return {
  759. id: group.id,
  760. userId: group.userId,
  761. name: group.name,
  762. description: group.description,
  763. createdAt: group.createdAt.toISOString(),
  764. };
  765. }
  766. private formatAccount(account: PlatformAccount): PlatformAccountType {
  767. return {
  768. id: account.id,
  769. userId: account.userId,
  770. platform: account.platform,
  771. accountName: account.accountName || '',
  772. accountId: account.accountId || '',
  773. avatarUrl: account.avatarUrl,
  774. fansCount: account.fansCount,
  775. worksCount: account.worksCount,
  776. status: account.status,
  777. proxyConfig: account.proxyConfig,
  778. groupId: account.groupId,
  779. cookieExpireAt: account.cookieExpireAt?.toISOString() || null,
  780. createdAt: account.createdAt.toISOString(),
  781. updatedAt: account.updatedAt.toISOString(),
  782. };
  783. }
  784. /**
  785. * 从 Cookie 中提取账号 ID(最可靠的方式)
  786. * 不同平台使用不同的 Cookie 字段来标识用户
  787. */
  788. private extractAccountIdFromCookie(platform: PlatformType, cookieString: string): string | null {
  789. // 各平台用于标识用户的 Cookie 名称(按优先级排序)
  790. const platformCookieNames: Record<string, string[]> = {
  791. douyin: ['passport_uid', 'uid_tt', 'ttwid', 'sessionid_ss'],
  792. kuaishou: ['userId', 'passToken', 'did'],
  793. xiaohongshu: ['customerClientId', 'web_session', 'xsecappid'],
  794. weixin_video: ['wxuin', 'pass_ticket', 'uin'],
  795. bilibili: ['DedeUserID', 'SESSDATA', 'bili_jct'],
  796. toutiao: ['sso_uid', 'sessionid', 'passport_uid'],
  797. baijiahao: ['BDUSS', 'STOKEN', 'BAIDUID'],
  798. qie: ['uin', 'skey', 'p_uin'],
  799. dayuhao: ['login_aliyunid', 'cna', 'munb'],
  800. };
  801. const targetCookieNames = platformCookieNames[platform] || [];
  802. if (targetCookieNames.length === 0) {
  803. return null;
  804. }
  805. try {
  806. // 尝试解析 JSON 格式的 Cookie
  807. let cookieList: { name: string; value: string }[];
  808. try {
  809. cookieList = JSON.parse(cookieString);
  810. } catch {
  811. // 如果不是 JSON,尝试解析 "name=value; name2=value2" 格式
  812. cookieList = this.parseCookieString(cookieString, platform).map(c => ({
  813. name: c.name,
  814. value: c.value,
  815. }));
  816. }
  817. if (!Array.isArray(cookieList) || cookieList.length === 0) {
  818. return null;
  819. }
  820. // 按优先级查找 Cookie
  821. for (const cookieName of targetCookieNames) {
  822. const cookie = cookieList.find(c => c.name === cookieName);
  823. if (cookie?.value) {
  824. // 获取 Cookie 值,处理可能的编码
  825. let cookieValue = cookie.value;
  826. // 处理特殊格式的 Cookie(如 ttwid 可能包含分隔符)
  827. if (cookieValue.includes('|')) {
  828. cookieValue = cookieValue.split('|')[1] || cookieValue;
  829. }
  830. if (cookieValue.includes('%')) {
  831. try {
  832. cookieValue = decodeURIComponent(cookieValue);
  833. } catch {
  834. // 解码失败,使用原值
  835. }
  836. }
  837. // 截取合理长度(避免过长的 ID)
  838. if (cookieValue.length > 64) {
  839. cookieValue = cookieValue.slice(0, 64);
  840. }
  841. const accountId = `${platform}_${cookieValue}`;
  842. logger.info(`[extractAccountIdFromCookie] Found ${cookieName} for ${platform}: ${accountId}`);
  843. return accountId;
  844. }
  845. }
  846. return null;
  847. } catch (error) {
  848. logger.warn(`[extractAccountIdFromCookie] Failed to extract accountId from cookie for ${platform}:`, error);
  849. return null;
  850. }
  851. }
  852. /**
  853. * 平台短前缀映射(统一使用短前缀)
  854. */
  855. private static readonly SHORT_PREFIX_MAP: Record<PlatformType, string> = {
  856. 'douyin': 'dy_',
  857. 'xiaohongshu': 'xhs_',
  858. 'weixin_video': 'sph_',
  859. 'baijiahao': 'bjh_',
  860. 'kuaishou': 'ks_',
  861. 'bilibili': 'bili_',
  862. 'toutiao': 'tt_',
  863. 'qie': 'qie_',
  864. 'dayuhao': 'dyh_',
  865. };
  866. /**
  867. * 标准化 accountId 格式,统一使用短前缀
  868. * 例如:Ethanfly9392 -> dy_Ethanfly9392
  869. */
  870. private normalizeAccountId(platform: PlatformType, accountId: string): string {
  871. const shortPrefix = AccountService.SHORT_PREFIX_MAP[platform] || `${platform}_`;
  872. if (!accountId) {
  873. return `${shortPrefix}${Date.now()}`;
  874. }
  875. // 如果已经有正确的短前缀,直接返回
  876. if (accountId.startsWith(shortPrefix)) {
  877. return accountId;
  878. }
  879. // 移除任何已有的前缀(短前缀或完整前缀)
  880. const allShortPrefixes = Object.values(AccountService.SHORT_PREFIX_MAP);
  881. const allFullPrefixes = Object.keys(AccountService.SHORT_PREFIX_MAP).map(p => `${p}_`);
  882. const allPrefixes = [...allShortPrefixes, ...allFullPrefixes];
  883. let cleanId = accountId;
  884. for (const prefix of allPrefixes) {
  885. if (cleanId.startsWith(prefix)) {
  886. cleanId = cleanId.slice(prefix.length);
  887. break;
  888. }
  889. }
  890. // 添加正确的短前缀
  891. return `${shortPrefix}${cleanId}`;
  892. }
  893. /**
  894. * 检查 accountId 是否是基于时间戳生成的(不可靠的 ID)
  895. * 时间戳 ID 格式通常是:dy_1737619200000 或 douyin_1737619200000
  896. */
  897. private isTimestampBasedId(accountId: string): boolean {
  898. if (!accountId) return true;
  899. // 检查是否匹配 前缀_时间戳 格式(支持短前缀和完整前缀)
  900. const timestampPattern = /^[a-z_]+_(\d{13,})$/;
  901. const match = accountId.match(timestampPattern);
  902. if (!match) {
  903. return false;
  904. }
  905. // 提取数字部分,检查是否是合理的时间戳(2020年到2030年之间)
  906. if (match[1]) {
  907. const timestamp = parseInt(match[1]);
  908. const minTimestamp = new Date('2020-01-01').getTime(); // 1577836800000
  909. const maxTimestamp = new Date('2030-01-01').getTime(); // 1893456000000
  910. if (timestamp >= minTimestamp && timestamp <= maxTimestamp) {
  911. logger.info(`[isTimestampBasedId] Detected timestamp-based ID: ${accountId}`);
  912. return true;
  913. }
  914. }
  915. return false;
  916. }
  917. }