active-user-daily-notify.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import mongoose, { Schema, Document } from "mongoose";
  2. import { User, IUser } from "../../src/models/userModel";
  3. // 确保先导入并注册 MessageTemplate 模型
  4. import { IMessageTemplate, MessageTemplate } from "../../src/models/messageTemplateModel";
  5. // 确保 MessageTemplate 模型已被注册
  6. mongoose.model("MessageTemplate", MessageTemplate.schema);
  7. // 然后再导入和使用 MessageStrategy 模型
  8. import { MessageStrategy, IMessageStrategy } from "../../src/models/messageStrategyModel";
  9. import { MessageRecord, IMessageRecord } from "../../src/models/messageRecordModel";
  10. import { FCMService } from "../../src/services/fcmService";
  11. import Art, { IArt } from "../../src/models/artModel";
  12. const strategyName = "active_new_content_notify";
  13. const fcmService = FCMService.getInstance();
  14. // 国家代码到语言的映射表,包含您提供的 top10 国家,可根据需求扩展
  15. const countryCodeToLanguageMap: { [key: string]: string } = {
  16. CN: "zh-cn",
  17. US: "en",
  18. JP: "ja",
  19. FR: "fr",
  20. DE: "de",
  21. ES: "es", // Spain
  22. MX: "es", // Mexico
  23. CL: "es", // Chile
  24. BR: "pt", // Brazil
  25. RU: "ru", // Russia
  26. IN: "hi", // India
  27. ID: "id", // Indonesia
  28. IT: "it",
  29. KR: "ko",
  30. TH: "th",
  31. TR: "tr",
  32. VN: "vi",
  33. };
  34. /**
  35. * 根据用户的 lang 或 cc 字段推断其语言。
  36. * @param user 用户对象
  37. * @returns 推断出的语言代码
  38. */
  39. function getUserLanguage(user: IUser): string {
  40. if (user.lang) {
  41. return user.lang;
  42. }
  43. if (user.cc && countryCodeToLanguageMap[user.cc]) {
  44. return countryCodeToLanguageMap[user.cc];
  45. }
  46. return "en"; // 最终默认语言为英语
  47. }
  48. /**
  49. * 将多语言模板转换为 FCM 消息数据格式。
  50. * @param template 消息模板
  51. * @param userLang 用户的语言代码
  52. */
  53. function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string): { [key: string]: string } {
  54. // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
  55. const lang = template.messageContent[userLang] ? userLang : "en";
  56. return {
  57. title: template.messageTitle[lang] || template.messageTitle["en"] || "",
  58. content: template.messageContent[lang] || template.messageContent["en"] || "",
  59. image: template.image || "",
  60. bigger: String(template.bigger || false),
  61. action: template.action || "go/app",
  62. param: template.param || "",
  63. extend: template.extend || "",
  64. };
  65. }
  66. /**
  67. * 获取今日的用于消息推送的2幅画作。
  68. * 查询 art 表,查找 tags 标签等于 'fcm' 的记录,按 publishTime 倒序排,取头部的2个 art。
  69. * @returns 包含两个画作 ID 的数组 [art_id1, art_id2]
  70. */
  71. async function getTodaysArtworksForFCM(): Promise<string[]> {
  72. try {
  73. const artworks = await Art.find({ tags: "fcm" })
  74. // const artworks = await Art.find({})
  75. .sort({ publishTime: -1 }) // 倒序排序
  76. .limit(2) // 限制为2个
  77. .lean<IArt[]>();
  78. // 如果找到,返回 ID 数组;如果不足2个,则返回找到的所有 ID
  79. return artworks.map((art) => art._id.toString());
  80. } catch (error) {
  81. console.error("查询今日画作失败:", error);
  82. return [];
  83. }
  84. }
  85. /**
  86. * 检查错误是否为 FCM 令牌失效错误。
  87. * @param error 错误对象
  88. * @returns 如果是令牌失效错误则返回 true
  89. */
  90. function isTokenInvalidationError(error: any): boolean {
  91. return error && (error.code === "messaging/registration-token-not-registered" || error.code === "messaging/invalid-registration-token");
  92. }
  93. /**
  94. * 发送并记录FCM消息。
  95. * 此函数现在将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
  96. * @param uid 用户ID
  97. * @param fcmToken 用户的FCM Token
  98. * @param template 消息模板
  99. * @param messageData 消息数据
  100. * @param strategyId 消息策略ID
  101. * @param strategyName 消息策略名称
  102. */
  103. const sendAndRecordMessage = async (uid: string, fcmToken: string, template: IMessageTemplate, messageData: { [key: string]: string }, strategyId: mongoose.Types.ObjectId, strategyName: string) => {
  104. let messageRecord: IMessageRecord | null = null;
  105. let fcmReceipt = null;
  106. let messageStatus = 0;
  107. let errorMessage = null;
  108. try {
  109. // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
  110. messageRecord = await MessageRecord.create({
  111. uid: uid,
  112. templateId: template._id,
  113. templateName: template.templateName,
  114. strategyId: strategyId,
  115. strategyName: strategyName,
  116. title: messageData.title,
  117. content: messageData.content,
  118. image: messageData.image,
  119. bigger: messageData.bigger === "true",
  120. action: messageData.action,
  121. param: messageData.param,
  122. extend: messageData.extend,
  123. plannedSendAt: new Date(),
  124. status: 0,
  125. });
  126. // 2. 将记录的 _id 添加到消息数据中作为 msgid
  127. const finalMessageData = {
  128. ...messageData,
  129. msgid: messageRecord._id.toString(),
  130. };
  131. // 3. 尝试发送消息
  132. const sendResult = await fcmService.sendMessage(fcmToken, finalMessageData);
  133. if (sendResult instanceof Error) {
  134. throw sendResult;
  135. }
  136. fcmReceipt = sendResult;
  137. messageStatus = 1;
  138. console.log(`成功发送消息给用户 ${uid}。`);
  139. } catch (error) {
  140. const errorInfo = (error as any).errorInfo;
  141. const isInvalidToken = isTokenInvalidationError(error);
  142. messageStatus = -1;
  143. errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
  144. if (isInvalidToken) {
  145. // 如果是无效令牌错误,清空该用户的 fmToken
  146. await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
  147. console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
  148. errorMessage += " (Token cleared)";
  149. } else {
  150. console.error(`发送消息给用户 ${uid} 失败:`, error);
  151. }
  152. } finally {
  153. // 4. 不论成功与否,更新 MessageRecord 的状态和结果
  154. if (messageRecord) {
  155. await MessageRecord.findByIdAndUpdate(messageRecord._id, {
  156. status: messageStatus,
  157. fcmReceipt: fcmReceipt as any,
  158. actualSendAt: new Date(),
  159. errno: errorMessage,
  160. });
  161. }
  162. }
  163. };
  164. /**
  165. * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
  166. * 此方法通过cron外部调用。
  167. *
  168. * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
  169. */
  170. export async function run(): Promise<void> {
  171. console.log("脚本开始:发送活跃用户每日通知...");
  172. try {
  173. const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  174. // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
  175. const activeUsers = await User.find({
  176. lastActiveAt: { $gte: sevenDaysAgo },
  177. fmToken: { $nin: [null, ""] },
  178. // versionName: { $in: ["5.8.0-debug"] },
  179. // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
  180. versionCode: { $gte: 347 },
  181. })
  182. .select("_id uid fmToken lang cc")
  183. .lean<IUser[]>();
  184. if (activeUsers.length === 0) {
  185. console.log("未找到符合条件的用户,脚本结束。");
  186. return;
  187. }
  188. console.log(`找到 ${activeUsers.length} 位活跃用户。`);
  189. const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
  190. if (!strategy || !strategy.templates || strategy.templates.length < 2) {
  191. console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板,或模板数量不足2个。`);
  192. return;
  193. }
  194. const templates = strategy.templates as IMessageTemplate[];
  195. console.log(`找到 ${templates.length} 个消息模板。`);
  196. const todaysArtworks = await getTodaysArtworksForFCM();
  197. if (todaysArtworks.length < 2) {
  198. console.warn("今日用于FCM消息推送的画作数量不足2个,无法执行双消息策略。脚本结束。");
  199. return;
  200. }
  201. const artwork1Id = todaysArtworks[0];
  202. const artwork2Id = todaysArtworks[1];
  203. console.log(`今日画作ID:${artwork1Id} 和 ${artwork2Id}`);
  204. // 为每个用户预先选择并存储两条消息的模板
  205. const messagesToSend = activeUsers.map((user) => {
  206. const randomIndex1 = Math.floor(Math.random() * templates.length);
  207. let randomIndex2 = Math.floor(Math.random() * templates.length);
  208. while (randomIndex2 === randomIndex1) {
  209. randomIndex2 = Math.floor(Math.random() * templates.length);
  210. }
  211. return {
  212. user,
  213. template1: templates[randomIndex1],
  214. template2: templates[randomIndex2],
  215. };
  216. });
  217. // --- 立即发送第一批消息 ---
  218. console.log("\n开始发送第一批消息...");
  219. for (const messageData of messagesToSend) {
  220. const user = messageData.user;
  221. const userLang = getUserLanguage(user);
  222. const fcmToken = user.fmToken as string;
  223. const data1 = getMessageDataFromTemplate(messageData.template1, userLang);
  224. data1.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/fcm/640/${artwork1Id}.png`;
  225. data1.bigger = "true";
  226. data1.action = "go/art";
  227. data1.param = artwork1Id;
  228. await sendAndRecordMessage(user.uid, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
  229. }
  230. console.log("第一批消息发送完成。");
  231. // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
  232. // return new Promise<void>((resolve) => {
  233. // setTimeout(async () => {
  234. // console.log("\n定时任务触发:开始发送第二批消息...");
  235. // for (const messageData of messagesToSend) {
  236. // const user = messageData.user;
  237. // const userLang = getUserLanguage(user);
  238. // const fcmToken = user.fmToken as string;
  239. // const data2 = getMessageDataFromTemplate(messageData.template2, userLang);
  240. // data2.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artwork2Id}.png`;
  241. // data2.bigger = "true";
  242. // data2.action = "go/art";
  243. // data2.param = artwork2Id;
  244. // await sendAndRecordMessage(user.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
  245. // }
  246. // console.log("第二批消息发送完成。");
  247. // // 所有任务完成后,安全地断开数据库连接
  248. // await disconnectFromDatabase();
  249. // resolve();
  250. // }, 30 * 60 * 1000);
  251. // });
  252. } catch (error) {
  253. console.error("脚本执行过程中发生致命错误:", error);
  254. throw error;
  255. }
  256. }
  257. // 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
  258. if (require.main === module) {
  259. run()
  260. .then(() => {
  261. console.log("脚本执行完毕,退出进程。");
  262. process.exit(0);
  263. })
  264. .catch((err) => {
  265. console.error("脚本执行失败:", err);
  266. process.exit(1);
  267. });
  268. }