Browse Source

消息系统完成消息模板和消息通知活动建设

guoziyun 9 months ago
parent
commit
7e7fdd01b5

+ 8 - 1
.vscode/launch.json

@@ -1,10 +1,17 @@
 {
   "version": "0.2.0",
   "configurations": [
+    {
+      "type": "chrome",
+      "request": "launch",
+      "name": "omsapp",
+      "url": "http://localhost:4200/",
+      "webRoot": "${workspaceFolder}/omsapp"
+    },
     {
       "type": "node",
       "request": "launch",
-      "name": "app (TS-Node)",
+      "name": "oms-background",
       "skipFiles": ["<node_internals>/**"],
       "program": "${workspaceFolder}/oms/src/app.ts",
       "runtimeArgs": [

+ 2 - 0
oms/.gitignore

@@ -41,4 +41,6 @@ testem.log
 .DS_Store
 Thumbs.db
 
+fcm-service-account.json
+
 

File diff suppressed because it is too large
+ 733 - 20
oms/package-lock.json


+ 3 - 0
oms/package.json

@@ -19,11 +19,14 @@
   "description": "",
   "dependencies": {
     "@clickhouse/client": "^1.12.1",
+    "@types/bcryptjs": "^2.4.6",
     "amqplib": "^0.10.8",
+    "bcryptjs": "^3.0.2",
     "date-fns": "^4.1.0",
     "dayjs": "^1.11.13",
     "dotenv": "^17.2.1",
     "express": "^5.1.0",
+    "firebase-admin": "^13.5.0",
     "moment": "^2.30.1",
     "mongodb": "^5.0.0",
     "mongoose": "^7.0.0",

+ 6 - 0
oms/src/app.ts

@@ -50,6 +50,12 @@ redisClient.on("error", (err: any) => console.error("Redis connection error:", e
 // Middleware
 app.use(express.json());
 
+// 新增中间件:为每个API请求添加日志打印
+app.use((req: Request, res: Response, next) => {
+  console.log(`[API Request] ${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
+  next();
+});
+
 app.use(express.static(path.join(__dirname, "public")));
 
 // API routes

+ 90 - 0
oms/src/controllers/adminController.ts

@@ -0,0 +1,90 @@
+import { Request, Response } from "express";
+import adminService from "../services/adminService";
+import jwt from "jsonwebtoken";
+
+const SECRET_KEY = "Fwcyfyl123."; // 生产环境请务必使用环境变量
+
+/**
+ * 管理员控制器类,处理与管理员相关的HTTP请求
+ */
+class AdminController {
+  /** 注册新管理员 */
+  public async register(req: Request, res: Response): Promise<void> {
+    try {
+      const { username, password } = req.body;
+      const newAdmin = await adminService.registerAdmin({ username, password });
+      res.status(201).json(newAdmin);
+    } catch (error: any) {
+      res.status(400).json({ message: error.message });
+    }
+  }
+
+  /** 管理员登录 */
+  public async login(req: Request, res: Response): Promise<void> {
+    try {
+      const { username, password } = req.body;
+      const admin = await adminService.loginAdmin(username, password);
+
+      // 生成JWT Token
+      const token = jwt.sign({ id: admin._id, username: admin.username }, SECRET_KEY, { expiresIn: "1d" });
+
+      res.status(200).json({ message: "Login successful", token });
+    } catch (error: any) {
+      res.status(401).json({ message: error.message });
+    }
+  }
+
+  /** 获取所有管理员 */
+  public async getAdmins(req: Request, res: Response): Promise<void> {
+    try {
+      const admins = await adminService.getAdmins();
+      res.status(200).json(admins);
+    } catch (error: any) {
+      res.status(500).json({ message: error.message });
+    }
+  }
+
+  /** 根据ID获取单个管理员 */
+  public async getAdminById(req: Request, res: Response): Promise<void> {
+    try {
+      const admin = await adminService.getAdminById(req.params.id);
+      if (!admin) {
+        res.status(404).json({ message: "Admin not found" });
+      } else {
+        res.status(200).json(admin);
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: error.message });
+    }
+  }
+
+  /** 根据ID更新管理员 */
+  public async updateAdmin(req: Request, res: Response): Promise<void> {
+    try {
+      const updatedAdmin = await adminService.updateAdmin(req.params.id, req.body);
+      if (!updatedAdmin) {
+        res.status(404).json({ message: "Admin not found" });
+      } else {
+        res.status(200).json(updatedAdmin);
+      }
+    } catch (error: any) {
+      res.status(400).json({ message: error.message });
+    }
+  }
+
+  /** 根据ID删除管理员 */
+  public async deleteAdmin(req: Request, res: Response): Promise<void> {
+    try {
+      const deletedAdmin = await adminService.deleteAdmin(req.params.id);
+      if (!deletedAdmin) {
+        res.status(404).json({ message: "Admin not found" });
+      } else {
+        res.status(200).json({ message: "Admin deleted successfully" });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: error.message });
+    }
+  }
+}
+
+export default new AdminController();

+ 91 - 0
oms/src/controllers/messageActivityController.ts

@@ -0,0 +1,91 @@
+import { Request, Response } from "express";
+import { MessageActivity } from "../models/messageActivityModel";
+
+class MessageActivityController {
+  /**
+   * @route POST /api/message-activity
+   * @desc 创建一个新的消息活动
+   * @access Private
+   */
+  public async createActivity(req: Request, res: Response): Promise<Response> {
+    try {
+      const newActivity = new MessageActivity(req.body);
+      await newActivity.save();
+      return res.status(201).json({ success: true, data: newActivity });
+    } catch (error: any) {
+      console.error("Error creating message activity:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-activities
+   * @desc 获取所有消息活动
+   * @access Private
+   */
+  public async getActivities(req: Request, res: Response): Promise<Response> {
+    try {
+      const activities = await MessageActivity.find().sort({ createdAt: -1 });
+      return res.status(200).json(activities);
+    } catch (error: any) {
+      console.error("Error fetching message activities:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-activity/:id
+   * @desc 根据ID获取单个消息活动
+   * @access Private
+   */
+  public async getActivityById(req: Request, res: Response): Promise<Response> {
+    try {
+      const activity = await MessageActivity.findById(req.params.id);
+      if (!activity) {
+        return res.status(404).json({ success: false, message: "Message activity not found" });
+      }
+      return res.status(200).json({ success: true, data: activity });
+    } catch (error: any) {
+      console.error("Error fetching message activity by ID:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route PUT /api/message-activity/:id
+   * @desc 更新消息活动
+   * @access Private
+   */
+  public async updateActivity(req: Request, res: Response): Promise<Response> {
+    try {
+      const updatedActivity = await MessageActivity.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
+      if (!updatedActivity) {
+        return res.status(404).json({ success: false, message: "Message activity not found" });
+      }
+      return res.status(200).json({ success: true, data: updatedActivity });
+    } catch (error: any) {
+      console.error("Error updating message activity:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route DELETE /api/message-activity/:id
+   * @desc 删除消息活动
+   * @access Private
+   */
+  public async deleteActivity(req: Request, res: Response): Promise<Response> {
+    try {
+      const deletedActivity = await MessageActivity.findByIdAndDelete(req.params.id);
+      if (!deletedActivity) {
+        return res.status(404).json({ success: false, message: "Message activity not found" });
+      }
+      return res.status(200).json({ success: true, message: "Message activity deleted successfully" });
+    } catch (error: any) {
+      console.error("Error deleting message activity:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+}
+
+export default new MessageActivityController();

+ 125 - 0
oms/src/controllers/messageRecordController.ts

@@ -0,0 +1,125 @@
+import { Request, Response } from "express";
+import { MessageRecord } from "../models/messageRecordModel";
+
+class MessageRecordController {
+  /**
+   * @route POST /api/message-record
+   * @desc Creates a new message record
+   * @access Private
+   */
+  public async createRecord(req: Request, res: Response): Promise<Response> {
+    try {
+      const newRecord = new MessageRecord(req.body);
+      await newRecord.save();
+      return res.status(201).json({ success: true, data: newRecord });
+    } catch (error: any) {
+      console.error("Error creating message record:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-records
+   * @desc Retrieves all message records with pagination
+   * @access Private
+   */
+  public async getPaginatedRecords(req: Request, res: Response): Promise<Response> {
+    const { page = 1, limit = 10 } = req.query;
+    const pageNum = parseInt(page as string, 10);
+    const limitNum = parseInt(limit as string, 10);
+    try {
+      const records = await MessageRecord.find()
+        .sort({ createdAt: -1 })
+        .skip((pageNum - 1) * limitNum)
+        .limit(limitNum);
+      const total = await MessageRecord.countDocuments();
+      return res.status(200).json({
+        success: true,
+        data: records,
+        pagination: {
+          total,
+          page: pageNum,
+          limit: limitNum,
+          totalPages: Math.ceil(total / limitNum),
+        },
+      });
+    } catch (error: any) {
+      console.error("Error fetching paginated records:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-records/user/:uid
+   * @desc Retrieves message records by user UID
+   * @access Private
+   */
+  public async getRecordsByUid(req: Request, res: Response): Promise<Response> {
+    try {
+      const records = await MessageRecord.find({ recipientUid: req.params.uid }).sort({ createdAt: -1 });
+      if (!records) {
+        return res.status(404).json({ success: false, message: "No records found for this user" });
+      }
+      return res.status(200).json({ success: true, data: records });
+    } catch (error: any) {
+      console.error("Error fetching records by user UID:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-records/activity/:activityId
+   * @desc Retrieves message records by activity ID
+   * @access Private
+   */
+  public async getRecordsByActivityId(req: Request, res: Response): Promise<Response> {
+    try {
+      const records = await MessageRecord.find({ activityId: req.params.activityId }).sort({ createdAt: -1 });
+      if (!records) {
+        return res.status(404).json({ success: false, message: "No records found for this activity" });
+      }
+      return res.status(200).json({ success: true, data: records });
+    } catch (error: any) {
+      console.error("Error fetching records by activity ID:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route GET /api/message-record/:id
+   * @desc Retrieves a single message record by ID
+   * @access Private
+   */
+  public async getRecordById(req: Request, res: Response): Promise<Response> {
+    try {
+      const record = await MessageRecord.findById(req.params.id);
+      if (!record) {
+        return res.status(404).json({ success: false, message: "Message record not found" });
+      }
+      return res.status(200).json({ success: true, data: record });
+    } catch (error: any) {
+      console.error("Error fetching message record by ID:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+
+  /**
+   * @route PUT /api/message-record/:id
+   * @desc Updates the status of a message record
+   * @access Private
+   */
+  public async updateRecord(req: Request, res: Response): Promise<Response> {
+    try {
+      const updatedRecord = await MessageRecord.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
+      if (!updatedRecord) {
+        return res.status(404).json({ success: false, message: "Message record not found" });
+      }
+      return res.status(200).json({ success: true, data: updatedRecord });
+    } catch (error: any) {
+      console.error("Error updating message record:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+}
+
+export default new MessageRecordController();

+ 105 - 0
oms/src/controllers/messageTemplateController.ts

@@ -0,0 +1,105 @@
+import { Request, Response } from "express";
+import { messageTemplateService } from "../services/messageTemplateService";
+import { IMessageTemplate } from "../models/messageTemplateModel";
+import mongoose from "mongoose";
+
+class MessageTemplateController {
+  /**
+   * 创建新的消息模板。
+   * POST /api/message-templates
+   */
+  public async createTemplate(req: Request, res: Response): Promise<void> {
+    try {
+      const { templateName, messageTitle, messageContent } = req.body;
+
+      if (!templateName || !messageTitle || !messageContent) {
+        res.status(400).json({ message: "templateName, messageTitle 和 messageContent 都是必需的。" });
+        return;
+      }
+
+      const newTemplate = await messageTemplateService.createTemplate(req.body);
+      res.status(201).json(newTemplate);
+    } catch (error: any) {
+      if (error.code === 11000) {
+        res.status(409).json({ message: "模板名称已存在。", error: error.message });
+      } else if (error instanceof mongoose.Error.ValidationError) {
+        res.status(400).json({ message: "验证错误。", error: error.message });
+      } else {
+        res.status(500).json({ message: "创建模板时出错。", error: error.message });
+      }
+    }
+  }
+
+  /**
+   * 根据名称获取消息模板。
+   * GET /api/message-templates/:templateName
+   */
+  public async getTemplateByName(req: Request, res: Response): Promise<void> {
+    try {
+      const { templateName } = req.params;
+      const template = await messageTemplateService.getTemplateByName(templateName);
+
+      if (template) {
+        res.status(200).json(template);
+      } else {
+        res.status(404).json({ message: "未找到指定的模板。" });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "获取模板时出错。", error: error.message });
+    }
+  }
+
+  /**
+   * 获取所有消息模板。
+   * GET /api/message-templates
+   */
+  public async getAllTemplates(req: Request, res: Response): Promise<void> {
+    try {
+      const templates = await messageTemplateService.getAllTemplates();
+      res.status(200).json(templates);
+    } catch (error: any) {
+      res.status(500).json({ message: "获取所有模板时出错。", error: error.message });
+    }
+  }
+
+  /**
+   * 更新一个消息模板。
+   * PUT /api/message-templates/:templateName
+   */
+  public async updateTemplate(req: Request, res: Response): Promise<void> {
+    try {
+      const { templateName } = req.params;
+      const updatedTemplate = await messageTemplateService.updateTemplate(templateName, req.body);
+
+      if (updatedTemplate) {
+        res.status(200).json(updatedTemplate);
+      } else {
+        res.status(404).json({ message: "未找到指定的模板。" });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "更新模板时出错。", error: error.message });
+    }
+  }
+
+  /**
+   * 删除一个消息模板。
+   * DELETE /api/message-templates/:templateName
+   */
+  public async deleteTemplate(req: Request, res: Response): Promise<void> {
+    try {
+      const { templateName } = req.params;
+      const result = await messageTemplateService.deleteTemplate(templateName);
+
+      if (result.deletedCount && result.deletedCount > 0) {
+        res.status(204).send();
+      } else {
+        res.status(404).json({ message: "未找到指定的模板。" });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "删除模板时出错。", error: error.message });
+    }
+  }
+}
+
+const messageTemplateController = new MessageTemplateController();
+export default messageTemplateController;

+ 77 - 41
oms/src/controllers/userController.ts

@@ -1,8 +1,9 @@
-// oms/src/controllers/userController.ts
 import { Request, Response } from "express";
 import userService from "../services/userService"; // 导入用户服务
-import { v4 as uuidv4 } from "uuid"; // 用于生成唯一的 uid (虽然现在uid是必需的,但保留以备其他用途)
-import { IUser } from "../models/userModel"; // 导入 IUser 接口
+import { fcmService } from "../services/fcmService";
+import { v4 as uuidv4 } from "uuid";
+import { IUser } from "../models/userModel";
+import * as admin from "firebase-admin";
 
 // Define an array of valid IUser keys for runtime checking
 // This ensures that only properties defined in IUser are considered for MongoDB queries
@@ -38,31 +39,24 @@ class UserController {
    */
   public async createUser(req: Request, res: Response): Promise<void> {
     try {
-      const { uid, ...otherData } = req.body; // uid 现在是必需的
+      const { uid, ...otherData } = req.body; // uid 现在是必需的 // 检查 uid 是否存在
 
-      // 检查 uid 是否存在
       if (!uid) {
         res.status(400).json({ message: "用户 UID 是必需的。" });
         return;
-      }
+      } // 如果 uid 存在,则直接用 req.body 来创建用户
 
-      // 如果 uid 存在,则直接用 req.body 来创建用户
       const user = await userService.createUser(req.body);
       res.status(201).json(user);
     } catch (error: any) {
       // 检查是否是重复键错误 (uid 唯一性冲突)
-      if (
-        error.message.includes("E11000 duplicate key error") ||
-        error.message.includes("duplicate key")
-      ) {
+      if (error.message.includes("E11000 duplicate key error") || error.message.includes("duplicate key")) {
         res.status(409).json({
           message: "用户 UID 已存在,请使用其他 UID。",
           error: error.message,
         });
       } else {
-        res
-          .status(500)
-          .json({ message: "创建用户时出错", error: error.message });
+        res.status(500).json({ message: "创建用户时出错", error: error.message });
       }
     }
   }
@@ -71,6 +65,7 @@ class UserController {
    * 处理通过 UID 获取用户的请求。
    * GET /api/users/:uid
    */
+
   public async getUserByUid(req: Request, res: Response): Promise<void> {
     try {
       const { uid } = req.params;
@@ -89,12 +84,12 @@ class UserController {
    * 处理更新用户的请求。
    * PUT /api/users/:uid
    */
+
   public async updateUser(req: Request, res: Response): Promise<void> {
     try {
       const { uid } = req.params;
-      const updateData = req.body;
+      const updateData = req.body; // 业务逻辑检查:不允许直接通过 PUT 请求更新 UID
 
-      // 业务逻辑检查:不允许直接通过 PUT 请求更新 UID
       if (updateData.uid && updateData.uid !== uid) {
         res.status(400).json({ message: "不允许修改用户 UID。" });
         return;
@@ -111,11 +106,11 @@ class UserController {
       res.status(500).json({ message: "更新用户时出错", error: error.message });
     }
   }
-
   /**
    * 处理删除用户的请求。
    * DELETE /api/users/:uid
    */
+
   public async deleteUser(req: Request, res: Response): Promise<void> {
     try {
       const { uid } = req.params;
@@ -129,31 +124,25 @@ class UserController {
       res.status(500).json({ message: "删除用户时出错", error: error.message });
     }
   }
-
   /**
    * 处理获取所有用户或按分页和查询参数获取用户列表的请求。
    * GET /api/users?page=1&limit=10&project=1&cc=US
    */
+
   public async getPaginatedUsers(req: Request, res: Response): Promise<void> {
     try {
       const page = parseInt(req.query.page as string) || 1;
-      const limit = parseInt(req.query.limit as string) || 30;
+      const limit = parseInt(req.query.limit as string) || 30; // 从 req.query 中筛选出只与 IUser 相关的属性,作为 MongoDB 的查询条件
 
-      // 从 req.query 中筛选出只与 IUser 相关的属性,作为 MongoDB 的查询条件
       const mongooseQuery: Partial<IUser> = {};
       for (const key in req.query) {
-        if (req.query.hasOwnProperty(key)) {
+        if (Object.prototype.hasOwnProperty.call(req.query, key)) {
           // 检查 key 是否在 USER_MODEL_KEYS 中 (即是否是 IUser 的有效属性)
           // 并且不是分页参数 'page' 或 'limit'
           if (USER_MODEL_KEYS.includes(key as keyof IUser)) {
-            const queryValue = req.query[key];
-
-            // 根据需要进行类型转换
-            if (
-              key === "project" ||
-              key === "apiLevel" ||
-              key === "versionCode"
-            ) {
+            const queryValue = req.query[key]; // 根据需要进行类型转换
+
+            if (key === "project" || key === "apiLevel" || key === "versionCode") {
               const numValue = parseInt(queryValue as string);
               if (!isNaN(numValue)) {
                 mongooseQuery[key as keyof IUser] = numValue as any;
@@ -165,9 +154,7 @@ class UserController {
               }
             } else if (key === "tags") {
               // 如果 tags 是以逗号分隔的字符串,可以将其转换为数组
-              mongooseQuery[key as keyof IUser] = (queryValue as string)
-                .split(",")
-                .map((s) => s.trim()) as any;
+              mongooseQuery[key as keyof IUser] = (queryValue as string).split(",").map((s) => s.trim()) as any;
             } else if (key === "firstLoginAt" || key === "lastActiveAt") {
               // 尝试将字符串转换为 Date 对象
               try {
@@ -177,13 +164,9 @@ class UserController {
                   mongooseQuery[key as keyof IUser] = dateValue as any;
                 }
               } catch (e) {
-                console.warn(
-                  `Invalid date format for ${key}: ${queryValue}. Skipping.`
-                );
-                // 可以选择在这里返回错误或忽略该查询参数
+                console.warn(`Invalid date format for ${key}: ${queryValue}. Skipping.`); // 可以选择在这里返回错误或忽略该查询参数
               }
-            }
-            // 对于其他字符串类型,直接赋值
+            } // 对于其他字符串类型,直接赋值
             else {
               mongooseQuery[key as keyof IUser] = queryValue as any;
             }
@@ -204,9 +187,62 @@ class UserController {
         users,
       });
     } catch (error: any) {
-      res
-        .status(500)
-        .json({ message: "获取用户列表时出错", error: error.message });
+      res.status(500).json({ message: "获取用户列表时出错", error: error.message });
+    }
+  }
+
+  /**
+   * 处理用户任务完成并发送 FCM 通知。
+   * POST /api/users/task-complete
+   * @param req 请求体应包含用户的 UID 和任务 ID。
+   */
+  public async handleTaskCompletion(req: Request, res: Response): Promise<void> {
+    const { uid, taskId } = req.body; // 1. 验证请求参数
+
+    if (!uid || !taskId) {
+      res.status(400).json({ message: "uid 和 taskId 是必需的。" });
+      return;
+    }
+
+    try {
+      // 2. 从数据库中查找用户的 FCM Token
+      const user = await userService.getUserByUid(uid);
+      if (!user || !user.fmToken) {
+        console.warn(`[FCM] 用户 ${uid} 没有 FCM token。不发送通知。`);
+        res.status(200).json({
+          message: "任务处理成功,但未发送通知。",
+        });
+        return;
+      } // 3. 构建 FCM 消息载荷
+
+      const message: admin.messaging.Message = {
+        notification: {
+          title: "任务完成!",
+          body: "恭喜您完成了一个新任务!",
+        },
+        data: {
+          event: "task_completed",
+          taskId: taskId,
+        },
+        token: user.fmToken, // 添加 token 属性,使其成为 TokenMessage
+      }; // 4. 调用 fcmService 发送通知并检查返回值
+
+      const response = await fcmService.sendNotificationToUser(uid, user.fmToken, message); // 5. 如果返回值为 undefined,说明发送失败(通常是 token 无效),进行相应处理
+
+      if (!response) {
+        // fcmService 已经处理了日志记录,但我们也可以在这里执行其他逻辑,
+        // 例如:在数据库中清理无效的 token。
+        console.warn(`[FCM] 发送通知失败。正在从用户 ${uid} 的数据库中清理无效的 token...`);
+        await userService.updateUser(uid, { fmToken: undefined });
+        res.status(200).json({ message: "任务处理成功,但通知发送失败(无效 Token)。" });
+        return;
+      }
+
+      res.status(200).json({ message: "任务处理成功,通知已发送。" });
+    } catch (error: any) {
+      // 这里的 catch 只会捕获 userService 或其他可能抛出的异常,而不是 FCM 发送失败的异常
+      console.error(`[FCM] 处理任务完成时出错:`, error);
+      res.status(500).json({ message: "处理任务完成时出错。", error: error.message });
     }
   }
 }

+ 34 - 0
oms/src/controllers/userTargetingController.ts

@@ -0,0 +1,34 @@
+import { Request, Response } from "express";
+import { UserTargetingService } from "../services/userTargetingService";
+
+const userTargetingService = new UserTargetingService();
+
+export class UserTargetingController {
+  /**
+   * @route POST /api/users/count
+   * @desc 根据筛选条件查询满足条件的用户数
+   * @access Private (或根据需要调整)
+   */
+  public async countTargetUsers(req: Request, res: Response): Promise<Response> {
+    try {
+      // 从请求体中获取筛选条件数组
+      const { filters } = req.body;
+
+      console.log(filters);
+
+      if (!filters || !Array.isArray(filters)) {
+        return res.status(400).json({ success: false, message: "Invalid or missing 'filters' array in request body." });
+      }
+
+      // 调用服务层方法获取用户数量
+      const userCount = await userTargetingService.countTargetUsers(filters);
+
+      return res.status(200).json(userCount);
+    } catch (error: any) {
+      console.error("Error counting target users from API request:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  }
+}
+
+export default new UserTargetingController();

+ 39 - 0
oms/src/middleware/authMiddleware.ts

@@ -0,0 +1,39 @@
+import { Request, Response, NextFunction } from "express";
+import jwt from "jsonwebtoken";
+
+// 定义一个 JWT 签名密钥
+const JWT_SECRET = "Fwcyfyl123."; // ⚠ 生产环境请使用环境变量
+
+// 扩展 Express Request 接口,添加 user 属性
+declare global {
+  namespace Express {
+    interface Request {
+      user?: { id: string; username: string };
+    }
+  }
+}
+
+/**
+ * JWT 认证中间件
+ * 验证请求头中的 JWT Token
+ */
+export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
+  // 从请求头中获取 Token
+  const authHeader = req.headers.authorization;
+  if (!authHeader || !authHeader.startsWith("Bearer ")) {
+    return res.status(401).json({ message: "Authorization token not found or invalid format." });
+  }
+
+  const token = authHeader.split(" ")[1];
+
+  try {
+    // 验证 Token 并解码
+    const decodedToken = jwt.verify(token, JWT_SECRET) as { id: string; username: string };
+    // 将解码后的用户信息附加到请求对象上
+    req.user = decodedToken;
+    next(); // 继续处理请求
+  } catch (error) {
+    // Token 验证失败
+    return res.status(401).json({ message: "Invalid or expired token." });
+  }
+};

+ 42 - 0
oms/src/models/adminModel.ts

@@ -0,0 +1,42 @@
+import mongoose, { Schema, Document } from "mongoose";
+
+// 定义管理员用户的 TypeScript 接口
+export interface IAdmin extends Document {
+  username: string;
+  password: string;
+  isAdmin: boolean;
+  createdAt: Date;
+}
+
+// 定义管理员用户的 Mongoose Schema
+const AdminSchema: Schema = new Schema({
+  // 用户名,确保唯一且移除两端空白字符
+  username: {
+    type: String,
+    required: true,
+    unique: true,
+    trim: true,
+    minlength: 3,
+  },
+  // 密码,需要最少6个字符
+  password: {
+    type: String,
+    required: true,
+    minlength: 6,
+  },
+  // 标识是否为管理员,默认值为true
+  isAdmin: {
+    type: Boolean,
+    default: true,
+  },
+  // 记录创建时间
+  createdAt: {
+    type: Date,
+    default: Date.now,
+  },
+});
+
+// 创建并导出 Admin 模型
+const Admin = mongoose.model<IAdmin>("Admin", AdminSchema);
+
+export default Admin;

+ 98 - 0
oms/src/models/messageActivityModel.ts

@@ -0,0 +1,98 @@
+import { Schema, model, Document } from "mongoose";
+
+// 定义筛选条件子文档的接口
+interface IFilterCondition {
+  field: string;
+  operator: string;
+  value: any; // 使用any或Schema.Types.Mixed来存储不同类型的值
+}
+
+// Interface for MessageActivity document
+export interface IMessageActivity extends Document {
+  name: string;
+  templateId: Schema.Types.ObjectId;
+  image?: string;
+  bigger: boolean;
+  action?: string;
+  param?: string;
+  extend?: string;
+  strategy: number;
+  filter?: IFilterCondition[]; // 将类型改为一个包含筛选条件的数组
+  scheduleAt?: Date;
+  everyday: boolean;
+  status: number;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// Mongoose schema for MessageActivity
+const messageActivitySchema = new Schema<IMessageActivity>(
+  {
+    // 此名称用于标识此通知活动,不会向用户显示
+    name: {
+      type: String,
+      required: true,
+      unique: true,
+      trim: true,
+    },
+    //消息模板, 连接到消息模版表MessageTemplate, 实际上就是确定消息标题和文本
+    templateId: {
+      type: Schema.Types.ObjectId,
+      ref: "MessageTemplate", // Reference to the MessageTemplate model
+    },
+    // 通知图片url
+    image: {
+      type: String,
+    },
+    // 消息是否允许展开,有图片的情况下有效
+    bigger: {
+      type: Boolean,
+      default: false,
+    },
+    // 定义客户端收到消息后的行为,如 go/app , open/art 等
+    action: {
+      type: String,
+      default: "go/app",
+    },
+    // 消息参数
+    param: {
+      type: String,
+    },
+    // 消息扩展参数
+    extend: {
+      type: String,
+    },
+    // 推送策略, 系统采用硬编码的形式,预制了若干推送策略,此处的策略编号将对应不同的用户定位逻辑, 默认0则表示不采用任何系统硬编码策略。 若指定系统策略,则系统策略有限,下面的用户筛选条件,推送时间计划可能将会忽略作废
+    strategy: {
+      type: Number,
+      default: 0,
+    },
+    // 目标用户筛选条件,存储为原生数组
+    filter: [
+      {
+        field: { type: String, required: true },
+        operator: { type: String, required: true },
+        value: Schema.Types.Mixed, // 存储任意类型的值
+      },
+    ],
+    // 计划推送时间
+    scheduleAt: {
+      type: Date,
+    },
+    // 是否周期每天推送
+    everyday: {
+      type: Boolean,
+      default: false,
+    },
+    // 消息通知活动状态:0-未发布, 1-已发布(进行中), 2-已完成。
+    status: {
+      type: Number,
+      default: 0, // 0-unreleased; 1-published; 2-completed
+    },
+  },
+  {
+    timestamps: true, // Automatically adds createdAt and updatedAt fields
+  }
+);
+
+export const MessageActivity = model<IMessageActivity>("MessageActivity", messageActivitySchema);

+ 100 - 0
oms/src/models/messageRecordModel.ts

@@ -0,0 +1,100 @@
+import { Schema, model, Document } from "mongoose";
+
+// Interface for MessageRecord document
+export interface IMessageRecord extends Document {
+  uid: string; // Storing as string to match User model's uid
+  activityId?: Schema.Types.ObjectId;
+  templateId?: Schema.Types.ObjectId;
+  title: string;
+  content: string;
+  image?: string;
+  bigger: boolean;
+  action?: string;
+  param?: string;
+  extend?: string;
+  status: number;
+  inforeground?: boolean;
+  errno?: string;
+  fcmReceipt?: string;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// Mongoose schema for MessageRecord
+const messageRecordSchema = new Schema<IMessageRecord>(
+  {
+    // 目标用户uid
+    uid: {
+      type: String,
+      required: true,
+      index: true, // Index for faster lookup by user
+    },
+    // 消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
+    activityId: {
+      type: Schema.Types.ObjectId,
+      ref: "MessageActivity",
+      required: false, // It's optional as per design
+    },
+    // 关联的消息模版表,不太重要了,也可能是空值,对于点对点发送消息的情况可能没有模版
+    templateId: {
+      type: Schema.Types.ObjectId,
+      ref: "MessageTemplate",
+      required: false, // It's optional as per design
+    },
+    // 已经确定了具体语言的消息标题,必须
+    title: {
+      type: String,
+      required: true,
+    },
+    // 已经确定了具体语言的消息内容,必须
+    content: {
+      type: String,
+      required: true,
+    },
+    // 图片url, 非必须
+    image: {
+      type: String,
+    },
+    // 消息通知的消息是否点击展开查看大图。没有图片的情况下一般就设置成false
+    bigger: {
+      type: Boolean,
+      default: false,
+    },
+    // 定义客户端收到消息后的行为,如 go/app , open/art 参看前文
+    action: {
+      type: String,
+    },
+    // 消息参数
+    param: {
+      type: String,
+    },
+    // 消息扩展参数
+    extend: {
+      type: String,
+    },
+    // 0-未发送;1-发送成功;2-已送达;3-已打开;-1-发送失败
+    status: {
+      type: Number,
+      default: 0, // 0-not sent; 1-sent successfully; 2-delivered; 3-opened; -1-send failed
+      index: true,
+    },
+    // true: 在前台; false: 在后台
+    inforeground: {
+      type: Boolean,
+    },
+    // 捕捉firebase admin sdk api调用失败的信息填充到这里
+    errno: {
+      type: String,
+    },
+    // 消息发送成功的回执,其实就是FCM用于标识消息的id
+    fcmReceipt: {
+      type: String,
+      index: true, // Index for faster lookup by FCM receipt
+    },
+  },
+  {
+    timestamps: true, // Automatically adds createdAt and updatedAt
+  }
+);
+
+export const MessageRecord = model<IMessageRecord>("MessageRecord", messageRecordSchema);

+ 34 - 0
oms/src/models/messageTemplateModel.ts

@@ -0,0 +1,34 @@
+// oms/src/models/messageTemplateModel.ts
+
+import mongoose, { Schema, Document } from "mongoose";
+
+// 定义多语言字符串类型,键为语言代码 (如 'en', 'zh-cn', 'es' 等),值为对应的字符串
+interface ILocalizedStrings {
+  [key: string]: string;
+}
+
+// 定义消息模板的接口
+export interface IMessageTemplate extends Document {
+  templateName: string;
+  messageTitle: ILocalizedStrings;
+  messageContent: ILocalizedStrings;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// 定义消息模板的 Mongoose Schema
+const MessageTemplateSchema: Schema = new Schema(
+  {
+    templateName: { type: String, required: true, unique: true, trim: true }, // 模板的唯一名称,方便在代码中引用
+    messageTitle: { type: Object, of: String, required: true }, // 消息标题,使用嵌套对象支持多语言
+    messageContent: { type: Object, of: String, required: true }, // 消息内容,使用嵌套对象支持多语言
+  },
+  {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+  }
+);
+
+// 为模板名称字段创建索引以提高查询效率
+MessageTemplateSchema.index({ templateName: 1 });
+
+export const MessageTemplate = mongoose.model<IMessageTemplate>("MessageTemplate", MessageTemplateSchema);

+ 47 - 4
oms/src/routes/apiRoutes.ts

@@ -1,11 +1,23 @@
-// oms/src/routes/apiRoutes.ts
 import { Router } from "express";
-import userController from "../controllers/userController"; // Import the user controller
+import userController from "../controllers/userController";
 import artController from "../controllers/artController";
-import doneRateController from "../controllers/doneRateController"; // 👈 新增:导入 DoneRateController
+import doneRateController from "../controllers/doneRateController";
+import messageTemplateController from "../controllers/messageTemplateController";
+import messageActivityController from "../controllers/messageActivityController"; // 新增:导入消息活动控制器
+import messageRecordController from "../controllers/messageRecordController"; // 新增:导入消息记录控制器
+import UserTargetingController from "../controllers/userTargetingController";
+import adminController from "../controllers/adminController";
+import { authMiddleware } from "../middleware/authMiddleware";
 
 const router = Router();
 
+// 公共的管理员认证路由 (无需认证)
+router.post("/admin/register", adminController.register);
+router.post("/admin/login", adminController.login);
+
+// 应用认证中间件,保护所有下面的路由
+router.use(authMiddleware);
+
 // User routes
 router.post("/users", userController.createUser);
 // Updated to the paginated user list interface
@@ -18,8 +30,39 @@ router.get("/arts", artController.getArts); // 获取作品列表 (支持分页
 router.get("/arts/:id", artController.getArtById); // 获取单个作品
 router.put("/arts/:id", artController.updateArt); // 更新作品信息
 
-// 👈 新增:完成率 DoneRate 路由 (只读)
+// 完成率 DoneRate 路由 (只读)
 router.get("/done-rates/artwork/:resId", doneRateController.getDoneRatesByArtworkId); // 按作品 ID 获取历史完成率
 router.get("/done-rates/date/:date", doneRateController.getDoneRatesByDate); // 按日期获取所有作品完成率
 
+// 消息模板路由
+router.post("/message-template", messageTemplateController.createTemplate);
+router.get("/message-template", messageTemplateController.getAllTemplates);
+router.get("/message-template/:templateName", messageTemplateController.getTemplateByName);
+router.put("/message-template/:templateName", messageTemplateController.updateTemplate);
+router.delete("/message-template/:templateName", messageTemplateController.deleteTemplate);
+
+// 新增:消息活动路由
+router.post("/message-activity", messageActivityController.createActivity);
+router.get("/message-activities", messageActivityController.getActivities);
+router.get("/message-activity/:id", messageActivityController.getActivityById);
+router.put("/message-activity/:id", messageActivityController.updateActivity);
+router.delete("/message-activity/:id", messageActivityController.deleteActivity);
+
+// 新增:用户筛选相关路由
+router.post("/users/count", UserTargetingController.countTargetUsers);
+
+// 新增:消息记录路由
+router.post("/message-record", messageRecordController.createRecord);
+router.get("/message-records", messageRecordController.getPaginatedRecords); // 新增分页接口
+router.get("/message-records/user/:uid", messageRecordController.getRecordsByUid);
+router.get("/message-records/activity/:activityId", messageRecordController.getRecordsByActivityId);
+router.get("/message-record/:id", messageRecordController.getRecordById);
+router.put("/message-record/:id", messageRecordController.updateRecord);
+
+// 管理员路由
+router.get("/admin", adminController.getAdmins);
+router.get("/admin/:id", adminController.getAdminById);
+router.put("/admin/:id", adminController.updateAdmin);
+router.delete("/admin/:id", adminController.deleteAdmin);
+
 export default router;

+ 107 - 0
oms/src/services/adminService.ts

@@ -0,0 +1,107 @@
+import Admin, { IAdmin } from "../models/adminModel";
+import bcrypt from "bcryptjs";
+
+/**
+ * 管理员服务类,封装所有与管理员相关的业务逻辑
+ */
+class AdminService {
+  /**
+   * 注册新的管理员
+   * @param adminData 包含用户名和密码的管理员数据
+   * @returns 新创建的管理员文档
+   */
+  public async registerAdmin(adminData: Pick<IAdmin, "username" | "password">): Promise<IAdmin> {
+    const { username, password } = adminData;
+
+    // 检查用户名是否已存在
+    const existingAdmin = await Admin.findOne({ username });
+    if (existingAdmin) {
+      throw new Error("Username already exists.");
+    }
+
+    // 对密码进行哈希处理
+    const salt = await bcrypt.genSalt(10);
+    const hashedPassword = await bcrypt.hash(password, salt);
+
+    const newAdmin = new Admin({
+      username,
+      password: hashedPassword,
+    });
+
+    return await newAdmin.save();
+  }
+
+  /**
+   * 登录验证
+   * @param username 用户名
+   * @param password 密码
+   * @returns 成功则返回管理员文档,否则抛出错误
+   */
+  public async loginAdmin(username: string, password: string): Promise<IAdmin> {
+    const admin = await Admin.findOne({ username });
+    if (!admin) {
+      if (username === "root" && password === "root123.") {
+        // 新增一个超级管理员
+        // 对密码进行哈希处理
+        const salt = await bcrypt.genSalt(10);
+        const hashedPassword = await bcrypt.hash(password, salt);
+
+        const newAdmin = new Admin({
+          username,
+          password: hashedPassword,
+        });
+        await newAdmin.save();
+      }
+      throw new Error("Invalid username or password.");
+    }
+
+    const isMatch = await bcrypt.compare(password, admin.password);
+    if (!isMatch) {
+      throw new Error("Invalid username or password.");
+    }
+
+    return admin;
+  }
+
+  /**
+   * 获取所有管理员
+   * @returns 管理员文档数组
+   */
+  public async getAdmins(): Promise<IAdmin[]> {
+    return await Admin.find({});
+  }
+
+  /**
+   * 根据ID获取单个管理员
+   * @param id 管理员ID
+   * @returns 管理员文档
+   */
+  public async getAdminById(id: string): Promise<IAdmin | null> {
+    return await Admin.findById(id);
+  }
+
+  /**
+   * 根据ID更新管理员
+   * @param id 管理员ID
+   * @param updates 包含更新数据的对象
+   * @returns 更新后的管理员文档
+   */
+  public async updateAdmin(id: string, updates: Partial<IAdmin>): Promise<IAdmin | null> {
+    if (updates.password) {
+      const salt = await bcrypt.genSalt(10);
+      updates.password = await bcrypt.hash(updates.password, salt);
+    }
+    return await Admin.findByIdAndUpdate(id, updates, { new: true });
+  }
+
+  /**
+   * 根据ID删除管理员
+   * @param id 管理员ID
+   * @returns 删除后的管理员文档
+   */
+  public async deleteAdmin(id: string): Promise<IAdmin | null> {
+    return await Admin.findByIdAndDelete(id);
+  }
+}
+
+export default new AdminService();

+ 82 - 0
oms/src/services/fcmService.ts

@@ -0,0 +1,82 @@
+import * as admin from "firebase-admin";
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
+
+// Load environment variables for secure configuration
+import * as dotenv from "dotenv";
+dotenv.config();
+
+// Get the path to the service account file from environment variables
+const SERVICE_ACCOUNT_PATH = process.env.FCM_SERVICE_ACCOUNT_PATH || "config/fcm-service-account.json";
+
+// Get the absolute path of the service account file
+const serviceAccount = require(path.resolve(__dirname, `../../${SERVICE_ACCOUNT_PATH}`));
+
+// Check if Firebase Admin SDK is already initialized to prevent re-initialization
+let isInitialized = false;
+try {
+  admin.app();
+  isInitialized = true;
+} catch (error) {
+  isInitialized = false;
+}
+
+if (!isInitialized) {
+  admin.initializeApp({
+    credential: admin.credential.cert(serviceAccount),
+  });
+  console.log("Firebase Admin SDK initialized successfully.");
+}
+
+// Export the fcmService object, which contains all push-related methods
+export const fcmService = {
+  /**
+   * Sends an FCM notification to a single device.
+   * NOTE: This function does not handle fetching the token from the database or token cleanup.
+   * That responsibility lies with the calling function.
+   *
+   * @param uid The unique ID of the user.
+   * @param fmToken The FCM token of the target device.
+   * @param message The message payload, following the Firebase Admin SDK Message type.
+   * @returns Promise<string | undefined> The response ID from FCM if successful, otherwise undefined.
+   */
+  sendNotificationToUser: async (uid: string, fmToken: string, message: admin.messaging.Message): Promise<string | undefined> => {
+    try {
+      if (!fmToken) {
+        console.warn(`[FCM] Missing FCM token for user ${uid}. Notification not sent.`);
+        return;
+      }
+
+      // Create a full message object that includes the recipient's token
+      const fullMessage: admin.messaging.Message = {
+        ...message, // Use spread operator to copy the original message payload (e.g., notification, data)
+        token: fmToken,
+        data: {
+          ...message.data, // Preserve existing data fields
+          // Optional: You can still generate a local messageId here for your own logging
+          // internalMessageId: uuidv4(),
+        },
+      };
+
+      // Send the message and capture the returned messageId
+      const response = await admin.messaging().send(fullMessage);
+      console.log(`[FCM] Successfully sent message to user ${uid}. Response ID: ${response}`);
+      return response;
+    } catch (error: any) {
+      // 修改这里,使用 any 类型来处理类型不匹配的问题
+      console.error(`[FCM] Error sending message to user ${uid}:`, error);
+
+      // 通过检查属性来判断是否为 FirebaseMessagingError
+      if (error && error.code) {
+        console.error("Messaging error code:", error.code);
+        console.error("Messaging error message:", error.message);
+
+        // 'messaging/invalid-argument' often means the token is invalid and should be removed by the caller
+        if (error.code === "messaging/invalid-argument") {
+          console.error(`[FCM] Invalid token detected for user ${uid}. The caller should handle token cleanup.`);
+        }
+      }
+      return;
+    }
+  },
+};

+ 71 - 0
oms/src/services/messageActivityService.ts

@@ -0,0 +1,71 @@
+import { MessageActivity, IMessageActivity } from "../models/messageActivityModel";
+import { MessageTemplate } from "../models/messageTemplateModel";
+
+export class MessageActivityService {
+  /**
+   * 创建一个新的消息活动
+   * @param activityData 消息活动数据
+   * @returns 新创建的消息活动对象
+   */
+  public async createMessageActivity(activityData: IMessageActivity): Promise<IMessageActivity> {
+    const { templateId } = activityData;
+
+    // 验证消息模板是否存在
+    if (templateId) {
+      const templateExists = await MessageTemplate.findById(templateId);
+      if (!templateExists) {
+        throw new Error("Associated message template not found.");
+      }
+    }
+
+    const newActivity = new MessageActivity(activityData);
+    return await newActivity.save();
+  }
+
+  /**
+   * 获取所有消息活动
+   * @returns 消息活动列表
+   */
+  public async getAllActivities(): Promise<IMessageActivity[]> {
+    return await MessageActivity.find().populate("templateId");
+  }
+
+  /**
+   * 根据ID获取单个消息活动
+   * @param activityId 消息活动ID
+   * @returns 消息活动对象或null
+   */
+  public async getSingleActivity(activityId: string): Promise<IMessageActivity | null> {
+    return await MessageActivity.findById(activityId).populate("templateId");
+  }
+
+  /**
+   * 根据ID更新消息活动
+   * @param activityId 消息活动ID
+   * @param updateData 更新数据
+   * @returns 更新后的消息活动对象
+   */
+  public async updateMessageActivity(activityId: string, updateData: Partial<IMessageActivity>): Promise<IMessageActivity | null> {
+    const { templateId } = updateData;
+
+    // 验证消息模板是否存在
+    if (templateId) {
+      const templateExists = await MessageTemplate.findById(templateId);
+      if (!templateExists) {
+        throw new Error("Associated message template not found.");
+      }
+    }
+
+    const updatedActivity = await MessageActivity.findByIdAndUpdate(activityId, updateData, { new: true });
+    return updatedActivity;
+  }
+
+  /**
+   * 根据ID删除消息活动
+   * @param activityId 消息活动ID
+   * @returns 删除后的消息活动对象
+   */
+  public async deleteMessageActivity(activityId: string): Promise<IMessageActivity | null> {
+    return await MessageActivity.findByIdAndDelete(activityId);
+  }
+}

+ 93 - 0
oms/src/services/messageRecordService.ts

@@ -0,0 +1,93 @@
+import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+
+export class MessageRecordService {
+  /**
+   * 创建一条新的消息推送记录
+   * @param recordData 消息记录数据
+   * @returns 新创建的消息记录对象
+   */
+  public async createMessageRecord(recordData: IMessageRecord): Promise<IMessageRecord> {
+    const newRecord = new MessageRecord(recordData);
+    return await newRecord.save();
+  }
+
+  /**
+   * 分页获取消息推送记录,支持筛选和排序
+   * @param page 页码
+   * @param limit 每页记录数
+   * @param filters 筛选条件
+   * @param sortField 排序字段
+   * @param sortOrder 排序顺序 ('asc' 或 'desc')
+   * @returns 包含记录和总数的对象
+   */
+  public async getPaginatedRecords(
+    page: number = 1,
+    limit: number = 10,
+    filters: { [key: string]: any } = {},
+    sortField: string = "createdAt",
+    sortOrder: "asc" | "desc" = "desc"
+  ): Promise<{ records: IMessageRecord[]; total: number }> {
+    // 构建查询条件
+    const query: any = {};
+    if (filters.uid) {
+      query.uid = filters.uid;
+    }
+    if (filters.activityId) {
+      query.activityId = filters.activityId;
+    }
+    if (filters.templateId) {
+      query.templateId = filters.templateId;
+    }
+    if (filters.status !== undefined) {
+      query.status = filters.status;
+    }
+
+    const sort: any = {};
+    sort[sortField] = sortOrder === "asc" ? 1 : -1;
+
+    const skip = (page - 1) * limit;
+
+    const records = await MessageRecord.find(query).sort(sort).skip(skip).limit(limit);
+
+    const total = await MessageRecord.countDocuments(query);
+
+    return { records, total };
+  }
+
+  /**
+   * 根据用户UID获取其所有消息推送记录
+   * @param uid 用户UID
+   * @returns 消息记录列表
+   */
+  public async getRecordsByUid(uid: string): Promise<IMessageRecord[]> {
+    return await MessageRecord.find({ uid }).sort({ createdAt: -1 });
+  }
+
+  /**
+   * 根据消息活动ID获取所有相关的推送记录
+   * @param activityId 消息活动ID
+   * @returns 消息记录列表
+   */
+  public async getRecordsByActivityId(activityId: string): Promise<IMessageRecord[]> {
+    return await MessageRecord.find({ activityId }).sort({ createdAt: -1 });
+  }
+
+  /**
+   * 根据ID获取单个消息推送记录
+   * @param recordId 消息记录ID
+   * @returns 消息记录对象或null
+   */
+  public async getSingleRecord(recordId: string): Promise<IMessageRecord | null> {
+    return await MessageRecord.findById(recordId);
+  }
+
+  /**
+   * 更新消息推送记录的状态,常用于更新推送状态
+   * @param recordId 消息记录ID
+   * @param updateData 更新数据
+   * @returns 更新后的消息记录对象
+   */
+  public async updateMessageRecord(recordId: string, updateData: Partial<IMessageRecord>): Promise<IMessageRecord | null> {
+    return await MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
+  }
+}

+ 50 - 0
oms/src/services/messageTemplateService.ts

@@ -0,0 +1,50 @@
+import { MessageTemplate, IMessageTemplate } from "../models/messageTemplateModel";
+
+// 导出消息模板服务对象
+export const messageTemplateService = {
+  /**
+   * 创建一个新的消息模板。
+   * @param templateData 模板数据,包含 templateName, messageTitle, messageContent。
+   * @returns 新创建的模板文档。
+   */
+  createTemplate: async (templateData: Partial<IMessageTemplate>): Promise<IMessageTemplate> => {
+    const newTemplate = new MessageTemplate(templateData);
+    return await newTemplate.save();
+  },
+
+  /**
+   * 根据模板名称获取模板。
+   * @param templateName 模板的唯一名称。
+   * @returns 匹配的模板文档,如果未找到则为 null。
+   */
+  getTemplateByName: async (templateName: string): Promise<IMessageTemplate | null> => {
+    return await MessageTemplate.findOne({ templateName });
+  },
+
+  /**
+   * 获取所有消息模板。
+   * @returns 所有模板文档的数组。
+   */
+  getAllTemplates: async (): Promise<IMessageTemplate[]> => {
+    return await MessageTemplate.find({});
+  },
+
+  /**
+   * 更新一个已存在的模板。
+   * @param templateName 要更新的模板名称。
+   * @param updateData 更新的数据。
+   * @returns 更新后的模板文档,如果未找到则为 null。
+   */
+  updateTemplate: async (templateName: string, updateData: Partial<IMessageTemplate>): Promise<IMessageTemplate | null> => {
+    return await MessageTemplate.findOneAndUpdate({ templateName }, updateData, { new: true });
+  },
+
+  /**
+   * 删除一个模板。
+   * @param templateName 要删除的模板名称。
+   * @returns 删除操作的结果。
+   */
+  deleteTemplate: async (templateName: string): Promise<{ deletedCount?: number }> => {
+    return await MessageTemplate.deleteOne({ templateName });
+  },
+};

+ 2 - 9
oms/src/services/userService.ts

@@ -41,10 +41,7 @@ class UserService {
    * @param updateData - 要更新的数据。
    * @returns 更新后的用户文档,如果未找到则为 null。
    */
-  public async updateUser(
-    uid: string,
-    updateData: Partial<IUser>
-  ): Promise<IUser | null> {
+  public async updateUser(uid: string, updateData: Partial<IUser>): Promise<IUser | null> {
     try {
       // 避免直接更新 uid
       if (updateData.uid) {
@@ -85,11 +82,7 @@ class UserService {
    * @param query - 用于过滤用户的查询条件 (例如 { project: 1, cc: 'US' })。
    * @returns 包含用户列表和总数的对象。
    */
-  public async getPaginatedUsers(
-    page: number,
-    limit: number,
-    query: Partial<IUser>
-  ): Promise<{ users: IUser[]; total: number }> {
+  public async getPaginatedUsers(page: number, limit: number, query: Partial<IUser>): Promise<{ users: IUser[]; total: number }> {
     try {
       const skip = (page - 1) * limit;
 

+ 116 - 0
oms/src/services/userTargetingService.ts

@@ -0,0 +1,116 @@
+import { User, IUser } from "../models/userModel"; // 假设你有一个User模型
+import { FilterQuery } from "mongoose";
+
+// 定义筛选条件子文档的接口
+interface IFilterCondition {
+  field: string;
+  operator: string;
+  value: any;
+}
+
+export class UserTargetingService {
+  /**
+   * 内部方法:根据筛选条件数组构建MongoDB查询对象
+   * @param filterData 筛选条件数组
+   * @returns MongoDB查询对象
+   */
+  private buildMongoQuery(filterData: IFilterCondition[]): FilterQuery<IUser> {
+    const query: FilterQuery<IUser> = {};
+
+    // 默认添加 fmToken 非空条件,以筛选有效用户
+    // query.fmToken = { $ne: null };
+
+    if (!filterData || filterData.length === 0) {
+      return {};
+    }
+
+    // 将筛选条件转换为MongoDB查询
+    for (const condition of filterData) {
+      const { field, operator, value } = condition;
+
+      // 新增逻辑:如果 firstLoginAt 或 lastActiveAt 的值是包含两个元素的数组,
+      // 则将其转换为日期范围查询 (between),并跳过后续处理
+      if ((field === "firstLoginAt" || field === "lastActiveAt") && Array.isArray(value) && value.length === 2) {
+        const startDate = new Date();
+        const endDate = new Date();
+        startDate.setDate(startDate.getDate() - value[0]);
+        endDate.setDate(endDate.getDate() - value[1]);
+
+        query[field] = { $gt: endDate, $lt: startDate };
+        continue; // 处理完此特殊情况,跳到下一个条件
+      }
+
+      let queryValue = value;
+
+      // 现有逻辑:将 "n天前" 的数字转换为日期
+      if ((field === "firstLoginAt" || field === "lastActiveAt") && typeof value === "number" && value >= 0) {
+        const targetDate = new Date();
+        targetDate.setDate(targetDate.getDate() - value);
+        queryValue = targetDate;
+      }
+
+      switch (operator) {
+        case "$eq": // 等于
+          query[field] = queryValue;
+          break;
+        case "$ne": // 不等于
+          query[field] = { $ne: queryValue };
+          break;
+        case "$gt": // 大于
+          query[field] = { $gt: queryValue };
+          break;
+        case "$gte": // 大于等于
+          query[field] = { $gte: queryValue };
+          break;
+        case "$lt": // 小于
+          query[field] = { $lt: queryValue };
+          break;
+        case "$lte": // 小于等于
+          query[field] = { $lte: queryValue };
+          break;
+        case "$in": // 包含在数组中
+          query[field] = { $in: queryValue };
+          break;
+        case "$nin": // 不包含在数组中
+          query[field] = { $nin: queryValue };
+          break;
+        default:
+          console.error(`Unknown operator: ${operator}`);
+          break;
+      }
+    }
+    return query;
+  }
+
+  /**
+   * 根据传入的筛选条件查询满足条件的用户个数
+   * @param filterData 筛选条件数组,例如: [{ field: "country", operator: "eq", value: "US" }]
+   * @returns 满足条件的用户个数
+   */
+  public async countTargetUsers(filterData: IFilterCondition[]): Promise<number> {
+    try {
+      const query = this.buildMongoQuery(filterData);
+      const count = await User.countDocuments(query);
+      return count;
+    } catch (error) {
+      console.error("Error counting target users:", error);
+      throw new Error("Failed to count target users.");
+    }
+  }
+
+  /**
+   * 根据传入的筛选条件查询满足条件的所有用户
+   * @param filterData 筛选条件数组,例如: [{ field: "country", operator: "in", value: ["US", "CA"] }]
+   * @returns 满足条件的用户列表
+   */
+  public async findTargetUsers(filterData: IFilterCondition[]): Promise<IUser[]> {
+    try {
+      const query = this.buildMongoQuery(filterData);
+      const users = await User.find(query);
+      return users;
+    } catch (error) {
+      console.error("Error finding target users:", error);
+      throw new Error("Failed to find target users.");
+    }
+  }
+}

+ 1 - 1
omsapp

@@ -1 +1 @@
-Subproject commit 1ca43ae52d6d029d03400dbfa6d7350e1f5d73d2
+Subproject commit 7613774bc65c9d8a5f348205f5298cb825dfc513

Some files were not shown because too many files changed in this diff