diff --git a/pom.xml b/pom.xml
index 96e0e08ce..949978260 100644
--- a/pom.xml
+++ b/pom.xml
@@ -46,6 +46,7 @@
         <easyexcel.verion>2.2.7</easyexcel.verion>
         <velocity.version>2.2</velocity.version>
         <screw.version>1.0.5</screw.version>
+        <!-- 三方云服务相关 -->
     </properties>
 
     <!-- 依赖声明 -->
@@ -271,6 +272,27 @@
             <version>${screw.version}</version>
         </dependency>
 
+        <!-- 三方云服务相关 -->
+
+        <!-- SMS SDK begin -->
+        <dependency>
+            <groupId>com.yunpian.sdk</groupId>
+            <artifactId>yunpian-java-sdk</artifactId>
+            <version>1.2.7</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>aliyun-java-sdk-core</artifactId>
+            <version>4.5.18</version>
+        </dependency>
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
+            <version>2.1.0</version>
+        </dependency>
+        <!-- SMS SDK end -->
+
     </dependencies>
 
 
diff --git a/ruoyi-ui/.env.demo1024 b/ruoyi-ui/.env.demo1024
new file mode 100644
index 000000000..ffeadbf8c
--- /dev/null
+++ b/ruoyi-ui/.env.demo1024
@@ -0,0 +1,7 @@
+NODE_ENV = production
+
+# 测试环境配置
+ENV = 'staging'
+
+# 芋道管理系统/测试环境
+VUE_APP_BASE_API = 'http://127.0.0.1:48080'
diff --git a/ruoyi-ui/package.json b/ruoyi-ui/package.json
index 46718e1b0..1249d16b3 100644
--- a/ruoyi-ui/package.json
+++ b/ruoyi-ui/package.json
@@ -8,6 +8,7 @@
     "dev": "vue-cli-service serve",
     "build:prod": "vue-cli-service build",
     "build:stage": "vue-cli-service build --mode staging",
+    "build:demo1024": "vue-cli-service build --mode demo1024",
     "preview": "node build/index.js --preview",
     "lint": "eslint --ext .js,.vue src"
   },
diff --git a/ruoyi-ui/src/api/system/sms/smsChannel.js b/ruoyi-ui/src/api/system/sms/smsChannel.js
new file mode 100644
index 000000000..4e38de05f
--- /dev/null
+++ b/ruoyi-ui/src/api/system/sms/smsChannel.js
@@ -0,0 +1,52 @@
+import request from '@/utils/request'
+
+// 创建短信渠道
+export function createSmsChannel(data) {
+  return request({
+    url: '/system/sms-channel/create',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新短信渠道
+export function updateSmsChannel(data) {
+  return request({
+    url: '/system/sms-channel/update',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除短信渠道
+export function deleteSmsChannel(id) {
+  return request({
+    url: '/system/sms-channel/delete?id=' + id,
+    method: 'delete'
+  })
+}
+
+// 获得短信渠道
+export function getSmsChannel(id) {
+  return request({
+    url: '/system/sms-channel/get?id=' + id,
+    method: 'get'
+  })
+}
+
+// 获得短信渠道分页
+export function getSmsChannelPage(query) {
+  return request({
+    url: '/system/sms-channel/page',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获得短信渠道精简列表
+export function getSimpleSmsChannels() {
+  return request({
+    url: '/system/sms-channel/list-all-simple',
+    method: 'get',
+  })
+}
diff --git a/ruoyi-ui/src/api/system/sms/smsLog.js b/ruoyi-ui/src/api/system/sms/smsLog.js
new file mode 100644
index 000000000..8a9083b71
--- /dev/null
+++ b/ruoyi-ui/src/api/system/sms/smsLog.js
@@ -0,0 +1,20 @@
+import request from '@/utils/request'
+
+// 获得短信日志分页
+export function getSmsLogPage(query) {
+  return request({
+    url: '/system/sms-log/page',
+    method: 'get',
+    params: query
+  })
+}
+
+// 导出短信日志 Excel
+export function exportSmsLogExcel(query) {
+  return request({
+    url: '/system/sms-log/export-excel',
+    method: 'get',
+    params: query,
+    responseType: 'blob'
+  })
+}
diff --git a/ruoyi-ui/src/api/system/sms/smsTemplate.js b/ruoyi-ui/src/api/system/sms/smsTemplate.js
new file mode 100644
index 000000000..d6d933044
--- /dev/null
+++ b/ruoyi-ui/src/api/system/sms/smsTemplate.js
@@ -0,0 +1,64 @@
+import request from '@/utils/request'
+
+// 创建短信模板
+export function createSmsTemplate(data) {
+  return request({
+    url: '/system/sms-template/create',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新短信模板
+export function updateSmsTemplate(data) {
+  return request({
+    url: '/system/sms-template/update',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除短信模板
+export function deleteSmsTemplate(id) {
+  return request({
+    url: '/system/sms-template/delete?id=' + id,
+    method: 'delete'
+  })
+}
+
+// 获得短信模板
+export function getSmsTemplate(id) {
+  return request({
+    url: '/system/sms-template/get?id=' + id,
+    method: 'get'
+  })
+}
+
+// 获得短信模板分页
+export function getSmsTemplatePage(query) {
+  return request({
+    url: '/system/sms-template/page',
+    method: 'get',
+    params: query
+  })
+}
+
+// 创建短信模板
+export function sendSms(data) {
+  return request({
+    url: '/system/sms-template/send-sms',
+    method: 'post',
+    data: data
+  })
+}
+
+// 导出短信模板 Excel
+export function exportSmsTemplateExcel(query) {
+  return request({
+    url: '/system/sms-template/export-excel',
+    method: 'get',
+    params: query,
+    responseType: 'blob'
+  })
+}
+
diff --git a/ruoyi-ui/src/utils/dict.js b/ruoyi-ui/src/utils/dict.js
index 3817a86fe..e3dc92d5b 100644
--- a/ruoyi-ui/src/utils/dict.js
+++ b/ruoyi-ui/src/utils/dict.js
@@ -17,6 +17,10 @@ export const DICT_TYPE = {
   SYS_OPERATE_TYPE: 'sys_operate_type',
   SYS_LOGIN_RESULT: 'sys_login_result',
   SYS_CONFIG_TYPE: 'sys_config_type',
+  SYS_SMS_CHANNEL_CODE: 'sys_sms_channel_code',
+  SYS_SMS_TEMPLATE_TYPE: 'sys_sms_template_type',
+  SYS_SMS_SEND_STATUS: 'sys_sms_send_status',
+  SYS_SMS_RECEIVE_STATUS: 'sys_sms_receive_status',
 
   INF_REDIS_TIMEOUT_TYPE: 'inf_redis_timeout_type',
   INF_JOB_STATUS: 'inf_job_status',
diff --git a/ruoyi-ui/src/views/system/operatelog/index.vue b/ruoyi-ui/src/views/system/operatelog/index.vue
index 8833a749e..7131654bf 100644
--- a/ruoyi-ui/src/views/system/operatelog/index.vue
+++ b/ruoyi-ui/src/views/system/operatelog/index.vue
@@ -163,7 +163,6 @@ export default {
         businessType: undefined,
         status: undefined
       },
-
     };
   },
   created() {
diff --git a/ruoyi-ui/src/views/system/sms/smsChannel.vue b/ruoyi-ui/src/views/system/sms/smsChannel.vue
new file mode 100644
index 000000000..ae4b9d7f2
--- /dev/null
+++ b/ruoyi-ui/src/views/system/sms/smsChannel.vue
@@ -0,0 +1,542 @@
+<template>
+  <div class="app-container">
+
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="短信签名" prop="signature">
+        <el-input v-model="queryParams.signature" placeholder="请输入短信签名" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="启用状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择启用状态" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间">
+        <el-date-picker v-model="dateRangeCreateTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['system:sms-channel:create']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport"
+                   v-hasPermi="['system:sms-channel:export']">导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="短信签名" align="center" prop="signature" />
+      <el-table-column label="渠道编码" align="center" prop="code">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, scope.row.code) }}</span>
+        </template>
+      </el-table-column>>
+      <el-table-column label="启用状态" align="center" prop="status">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_COMMON_STATUS, scope.row.status) }}</span>
+        </template>
+      </el-table-column>>
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column label="短信 API 的账号" align="center" prop="apiKey" />
+      <el-table-column label="短信 API 的秘钥" align="center" prop="apiSecret" />
+      <el-table-column label="短信发送回调 URL" align="center" prop="callbackUrl" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
+                     v-hasPermi="['system:sms-channel:update']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
+                     v-hasPermi="['system:sms-channel:delete']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页组件 -->
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="getList"/>
+
+    <!-- 对话框(添加 / 修改) -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="短信签名" prop="signature">
+          <el-input v-model="form.signature" placeholder="请输入短信签名" />
+        </el-form-item>
+        <el-form-item label="渠道编码" prop="code">
+          <el-input v-model="form.code" placeholder="请输入渠道编码" />
+        </el-form-item>
+        <el-form-item label="启用状态">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                      :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" placeholder="请输入备注" />
+        </el-form-item>
+        <el-form-item label="短信 API 的账号" prop="apiKey">
+          <el-input v-model="form.apiKey" placeholder="请输入短信 API 的账号" />
+        </el-form-item>
+        <el-form-item label="短信 API 的秘钥" prop="apiSecret">
+          <el-input v-model="form.apiSecret" placeholder="请输入短信 API 的秘钥" />
+        </el-form-item>
+        <el-form-item label="短信发送回调 URL" prop="callbackUrl">
+          <el-input v-model="form.callbackUrl" placeholder="请输入短信发送回调 URL" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { createSmsChannel, updateSmsChannel, deleteSmsChannel, getSmsChannel, getSmsChannelPage,
+  getSimpleSmsChannels } from "@/api/system/sms/smsChannel";
+
+export default {
+  name: "SmsChannel",
+  components: {
+  },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 短信渠道列表
+      list: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      dateRangeCreateTime: [],
+      // 查询参数
+      queryParams: {
+        pageNo: 1,
+        pageSize: 10,
+        signature: null,
+        status: null,
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        signature: [{ required: true, message: "短信签名不能为空", trigger: "blur" }],
+        code: [{ required: true, message: "渠道编码不能为空", trigger: "blur" }],
+        status: [{ required: true, message: "启用状态不能为空", trigger: "blur" }],
+        apiKey: [{ required: true, message: "短信 API 的账号不能为空", trigger: "blur" }],
+      },
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      // 处理查询参数
+      let params = {...this.queryParams};
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行查询
+      getSmsChannelPage(params).then(response => {
+        this.list = response.data.list;
+        this.total = response.data.total;
+        this.loading = false;
+      });
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 表单重置 */
+    reset() {
+      this.form = {
+        id: undefined,
+        signature: undefined,
+        code: undefined,
+        status: undefined,
+        remark: undefined,
+        apiKey: undefined,
+        apiSecret: undefined,
+        callbackUrl: undefined,
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNo = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRangeCreateTime = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加短信渠道";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id;
+      getSmsChannel(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改短信渠道";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        // 修改的提交
+        if (this.form.id != null) {
+          updateSmsChannel(this.form).then(response => {
+            this.msgSuccess("修改成功");
+            this.open = false;
+            this.getList();
+          });
+          return;
+        }
+        // 添加的提交
+        createSmsChannel(this.form).then(response => {
+          this.msgSuccess("新增成功");
+          this.open = false;
+          this.getList();
+        });
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const id = row.id;
+      this.$confirm('是否确认删除短信渠道编号为"' + id + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return deleteSmsChannel(id);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      })
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      // 处理查询参数
+      let params = {...this.queryParams};
+      params.pageNo = undefined;
+      params.pageSize = undefined;
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行导出
+      this.$confirm('是否确认导出所有短信渠道数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return exportSmsChannelExcel(params);
+      }).then(response => {
+        this.downloadExcel(response, '短信渠道.xls');
+      })
+    }
+  }
+};
+</script><template>
+  <div class="app-container">
+
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="短信签名" prop="signature">
+        <el-input v-model="queryParams.signature" placeholder="请输入短信签名" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="启用状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择启用状态" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间">
+        <el-date-picker v-model="dateRangeCreateTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['system:sms-channel:create']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport"
+                   v-hasPermi="['system:sms-channel:export']">导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="短信签名" align="center" prop="signature" />
+      <el-table-column label="渠道编码" align="center" prop="code">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, scope.row.code) }}</span>
+        </template>
+      </el-table-column>>
+      <el-table-column label="启用状态" align="center" prop="status">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_COMMON_STATUS, scope.row.status) }}</span>
+        </template>
+      </el-table-column>>
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
+                     v-hasPermi="['system:sms-channel:update']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
+                     v-hasPermi="['system:sms-channel:delete']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页组件 -->
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="getList"/>
+
+    <!-- 对话框(添加 / 修改) -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="130px">
+        <el-form-item label="短信签名" prop="signature">
+          <el-input v-model="form.signature" placeholder="请输入短信签名" />
+        </el-form-item>
+        <el-form-item label="渠道编码" prop="code">
+          <el-select v-model="form.code" placeholder="请选择渠道编码" :disabled="form.id > 0">
+            <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_SMS_CHANNEL_CODE)"
+                       :key="dict.value" :label="dict.label" :value="dict.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="启用状态">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                      :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" placeholder="请输入备注" />
+        </el-form-item>
+        <el-form-item label="短信 API 的账号" prop="apiKey">
+          <el-input v-model="form.apiKey" placeholder="请输入短信 API 的账号" />
+        </el-form-item>
+        <el-form-item v-if="form.code !== 'YUN_PIAN'" label="短信 API 的秘钥" prop="apiSecret">
+          <el-input v-model="form.apiSecret" placeholder="请输入短信 API 的秘钥" />
+        </el-form-item>
+        <el-form-item label="短信发送回调 URL" prop="callbackUrl">
+          <el-input v-model="form.callbackUrl" placeholder="请输入短信发送回调 URL" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { createSmsChannel, updateSmsChannel, deleteSmsChannel, getSmsChannel, getSmsChannelPage } from "@/api/system/sms/smsChannel";
+
+export default {
+  name: "SmsChannel",
+  components: {
+  },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 短信渠道列表
+      list: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      dateRangeCreateTime: [],
+      // 查询参数
+      queryParams: {
+        pageNo: 1,
+        pageSize: 10,
+        signature: null,
+        status: null,
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      apiKeyEnableChannelCodes: ['YUN_PIAN'],
+      rules: {
+        signature: [{ required: true, message: "短信签名不能为空", trigger: "blur" }],
+        code: [{ required: true, message: "渠道编码不能为空", trigger: "blur" }],
+        status: [{ required: true, message: "启用状态不能为空", trigger: "blur" }],
+        apiKey: [{ required: true, message: "短信 API 的账号不能为空", trigger: "blur" }],
+        apiSecret: [{ required: true, message: "短信 API 的秘钥不能为空", trigger: "blur" }],
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      // 处理查询参数
+      let params = {...this.queryParams};
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行查询
+      getSmsChannelPage(params).then(response => {
+        this.list = response.data.list;
+        this.total = response.data.total;
+        this.loading = false;
+      });
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 表单重置 */
+    reset() {
+      this.form = {
+        id: undefined,
+        signature: undefined,
+        code: undefined,
+        status: undefined,
+        remark: undefined,
+        apiKey: undefined,
+        apiSecret: undefined,
+        callbackUrl: undefined,
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNo = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRangeCreateTime = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加短信渠道";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id;
+      getSmsChannel(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改短信渠道";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        // 修改的提交
+        if (this.form.id != null) {
+          updateSmsChannel(this.form).then(response => {
+            this.msgSuccess("修改成功");
+            this.open = false;
+            this.getList();
+          });
+          return;
+        }
+        // 添加的提交
+        createSmsChannel(this.form).then(response => {
+          this.msgSuccess("新增成功");
+          this.open = false;
+          this.getList();
+        });
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const id = row.id;
+      this.$confirm('是否确认删除短信渠道编号为"' + id + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return deleteSmsChannel(id);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      })
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      // 处理查询参数
+      let params = {...this.queryParams};
+      params.pageNo = undefined;
+      params.pageSize = undefined;
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行导出
+      this.$confirm('是否确认导出所有短信渠道数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return exportSmsChannelExcel(params);
+      }).then(response => {
+        this.downloadExcel(response, '短信渠道.xls');
+      })
+    }
+  }
+};
+</script>
diff --git a/ruoyi-ui/src/views/system/sms/smsLog.vue b/ruoyi-ui/src/views/system/sms/smsLog.vue
new file mode 100644
index 000000000..137f04850
--- /dev/null
+++ b/ruoyi-ui/src/views/system/sms/smsLog.vue
@@ -0,0 +1,297 @@
+<template>
+  <div class="app-container">
+
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
+      <el-form-item label="手机号" prop="mobile">
+        <el-input v-model="queryParams.mobile" placeholder="请输入手机号" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="短信渠道" prop="channelId">
+        <el-select v-model="queryParams.channelId" placeholder="请选择短信渠道" clearable size="small">
+          <el-option v-for="channel in channelOptions"
+                     :key="channel.id" :value="channel.id"
+                     :label="channel.signature + '【' + getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, channel.code) + '】'" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="模板编号" prop="templateId">
+        <el-input v-model="queryParams.templateId" placeholder="请输入模板编号" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="发送状态" prop="sendStatus">
+        <el-select v-model="queryParams.sendStatus" placeholder="请选择发送状态" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_SMS_SEND_STATUS)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="发送时间">
+        <el-date-picker v-model="dateRangeSendTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item label="接收状态" prop="receiveStatus">
+        <el-select v-model="queryParams.receiveStatus" placeholder="请选择接收状态" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_SMS_RECEIVE_STATUS)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="接收时间">
+        <el-date-picker v-model="dateRangeReceiveTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['system:sms-log:create']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport"
+                   v-hasPermi="['system:sms-log:export']">导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="手机号" align="center" prop="mobile" width="120">
+        <template slot-scope="scope">
+          <div>{{ scope.row.mobile }}</div>
+          <div v-if="scope.row.userType && scope.row.userId">
+            {{ getDictDataLabel(DICT_TYPE.USER_TYPE, scope.row.userType) + '(' + scope.row.userId + ')' }}
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column label="短信内容" align="center" prop="templateContent" width="300" />
+      <el-table-column label="发送状态" align="center" width="180">
+        <template slot-scope="scope">
+          <div>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_SEND_STATUS, scope.row.sendStatus) }}</div>
+          <div>{{ parseTime(scope.row.sendTime) }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="接收状态" align="center" width="180">
+        <template slot-scope="scope">
+          <div>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_RECEIVE_STATUS, scope.row.receiveStatus) }}</div>
+          <div>{{ parseTime(scope.row.receiveTime) }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="短信渠道" align="center" width="120">
+        <template slot-scope="scope">
+          <div>{{ formatChannelSignature(scope.row.channelId) }}</div>
+          <div>【{{ getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, scope.row.channelCode) }}】</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="模板编号" align="center" prop="templateId" />
+      <el-table-column label="短信类型" align="center" prop="templateType">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_TEMPLATE_TYPE, scope.row.templateType) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleView(scope.row,scope.index)"
+                     v-hasPermi="['system:sms-log:query']">详细</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页组件 -->
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="getList"/>
+
+    <!-- 短信日志详细 -->
+    <el-dialog title="短信日志详细" :visible.sync="open" width="700px" append-to-body>
+      <el-form ref="form" :model="form" label-width="140px" size="mini">
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="日志主键:">{{ form.id }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="短信渠道:">
+              {{ formatChannelSignature(form.channelId) }}【{{ getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, form.channelCode) }}】
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="短信模板:">
+              {{ form.templateId }} | {{ form.templateCode}} | {{ getDictDataLabel(DICT_TYPE.SYS_SMS_TEMPLATE_TYPE, form.templateType) }}
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="API 的模板编号:">{{ form.apiTemplateId }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="用户信息:">{{ form.mobile }}
+              <span v-if="form.userType && form.userId"> | {{ getDictDataLabel(DICT_TYPE.USER_TYPE, form.userType) }} | {{ form.userId }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="短信内容:">{{ form.templateContent }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="短信参数:">{{ form.templateParams }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="创建时间:">{{ parseTime(form.createTime) }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="发送状态:">{{ getDictDataLabel(DICT_TYPE.SYS_SMS_SEND_STATUS, form.sendStatus) }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="发送时间:">{{ parseTime(form.sendTime) }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="发送结果:">{{ form.sendCode }} | {{ form.sendMsg }}
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="API 发送结果:">{{ form.apiSendCode }} | {{ form.apiSendMsg }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="API 短信编号:">{{ form.apiSerialNo }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="API 请求编号:">{{ form.apiRequestId }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="接收状态:">{{ getDictDataLabel(DICT_TYPE.SYS_SMS_RECEIVE_STATUS, form.receiveStatus) }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="接收时间:">{{ parseTime(form.receiveTime) }}</el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="API 接收结果:">{{ form.apiReceiveCode }} | {{ form.apiReceiveMsg }}
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="open = false">关 闭</el-button>
+      </div>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script>
+import { getSmsLogPage, exportSmsLogExcel } from "@/api/system/sms/smsLog";
+import {  getSimpleSmsChannels } from "@/api/system/sms/smsChannel";
+
+export default {
+  name: "SmsLog",
+  components: {
+  },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 短信日志列表
+      list: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      dateRangeSendTime: [],
+      dateRangeReceiveTime: [],
+      // 表单参数
+      form: {},
+      // 查询参数
+      queryParams: {
+        pageNo: 1,
+        pageSize: 10,
+        channelId: null,
+        templateId: null,
+        mobile: null,
+        sendStatus: null,
+        receiveStatus: null,
+      },
+      // 短信渠道
+      channelOptions: [],
+    };
+  },
+  created() {
+    this.getList();
+    // 获得短信渠道
+    getSimpleSmsChannels().then(response => {
+      this.channelOptions = response.data;
+    })
+  },
+  methods: {
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      // 处理查询参数
+      let params = {...this.queryParams};
+      this.addBeginAndEndTime(params, this.dateRangeSendTime, 'sendTime');
+      this.addBeginAndEndTime(params, this.dateRangeReceiveTime, 'receiveTime');
+      // 执行查询
+      getSmsLogPage(params).then(response => {
+        this.list = response.data.list;
+        this.total = response.data.total;
+        this.loading = false;
+      });
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNo = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRangeSendTime = [];
+      this.dateRangeReceiveTime = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      // 处理查询参数
+      let params = {...this.queryParams};
+      params.pageNo = undefined;
+      params.pageSize = undefined;
+      this.addBeginAndEndTime(params, this.dateRangeSendTime, 'sendTime');
+      this.addBeginAndEndTime(params, this.dateRangeReceiveTime, 'receiveTime');
+      // 执行导出
+      this.$confirm('是否确认导出所有短信日志数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return exportSmsLogExcel(params);
+      }).then(response => {
+        this.downloadExcel(response, '短信日志.xls');
+      })
+    },
+    /** 详细按钮操作 */
+    handleView(row) {
+      this.open = true;
+      this.form = row;
+    },
+    /** 格式化短信渠道 */
+    formatChannelSignature(channelId) {
+      for (const channel of this.channelOptions) {
+        if (channel.id === channelId) {
+          return channel.signature;
+        }
+      }
+      return '找不到签名:' + channelId;
+    }
+  }
+};
+</script>
diff --git a/ruoyi-ui/src/views/system/sms/smsTemplate.vue b/ruoyi-ui/src/views/system/sms/smsTemplate.vue
new file mode 100644
index 000000000..c65bf8838
--- /dev/null
+++ b/ruoyi-ui/src/views/system/sms/smsTemplate.vue
@@ -0,0 +1,405 @@
+<template>
+  <div class="app-container">
+
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="150px">
+      <el-form-item label="短信类型" prop="type">
+        <el-select v-model="queryParams.type" placeholder="请选择短信类型" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_SMS_TEMPLATE_TYPE)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择开启状态" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="模板编码" prop="code">
+        <el-input v-model="queryParams.code" placeholder="请输入模板编码" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="短信 API 的模板编号" prop="apiTemplateId">
+        <el-input v-model="queryParams.apiTemplateId" placeholder="请输入短信 API 的模板编号" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="短信渠道" prop="channelId">
+        <el-select v-model="queryParams.channelId" placeholder="请选择短信渠道" clearable size="small">
+          <el-option v-for="channel in channelOptions"
+                     :key="channel.id" :value="channel.id"
+                     :label="channel.signature + '【' + getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, channel.code) + '】'" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间">
+        <el-date-picker v-model="dateRangeCreateTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['system:sms-template:create']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport"
+                   v-hasPermi="['system:sms-template:export']">导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="模板编码" align="center" prop="code" />
+      <el-table-column label="模板名称" align="center" prop="name" />
+      <el-table-column label="模板内容" align="center" prop="content" width="300" />
+      <el-table-column label="短信类型" align="center" prop="type">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_SMS_TEMPLATE_TYPE, scope.row.type) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="开启状态" align="center" prop="status">
+        <template slot-scope="scope">
+          <span>{{ getDictDataLabel(DICT_TYPE.SYS_COMMON_STATUS, scope.row.status) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column label="短信 API 的模板编号" align="center" prop="apiTemplateId" width="180" />
+      <el-table-column label="短信渠道" align="center" width="120">
+        <template slot-scope="scope">
+          <div>{{ formatChannelSignature(scope.row.channelId) }}</div>
+          <div>【{{ getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, scope.row.channelCode) }}】</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-share" @click="handleSendSms(scope.row)"
+                     v-hasPermi="['system:sms-template:send-sms']">测试</el-button>
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
+                     v-hasPermi="['system:sms-template:update']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
+                     v-hasPermi="['system:sms-template:delete']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页组件 -->
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="getList"/>
+
+    <!-- 对话框(添加 / 修改) -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="140px">
+        <el-form-item label="短信渠道编号" prop="channelId">
+          <el-select v-model="form.channelId" placeholder="请选择短信渠道编号">
+            <el-option v-for="channel in channelOptions"
+                       :key="channel.id" :value="channel.id"
+                       :label="channel.signature + '【' + getDictDataLabel(DICT_TYPE.SYS_SMS_CHANNEL_CODE, channel.code) + '】'" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="短信类型" prop="type">
+          <el-select v-model="form.type" placeholder="请选择短信类型">
+            <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYS_SMS_TEMPLATE_TYPE)"
+                       :key="dict.value" :label="dict.label" :value="parseInt(dict.value)" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="模板编号" prop="code">
+          <el-input v-model="form.code" placeholder="请输入模板编号" />
+        </el-form-item>
+        <el-form-item label="模板名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入模板名称" />
+        </el-form-item>
+        <el-form-item label="模板内容" prop="content">
+          <el-input type="textarea" v-model="form.content" placeholder="请输入模板内容" />
+        </el-form-item>
+        <el-form-item label="开启状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="dict in this.getDictDatas(DICT_TYPE.SYS_COMMON_STATUS)"
+                      :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="短信 API 模板编号" prop="apiTemplateId">
+          <el-input v-model="form.apiTemplateId" placeholder="请输入短信 API 的模板编号" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 对话框(发送短信) -->
+    <el-dialog title="测试发送短信" :visible.sync="sendSmsOpen" width="500px" append-to-body>
+      <el-form ref="sendSmsForm" :model="sendSmsForm" :rules="sendSmsRules" label-width="140px">
+        <el-form-item label="模板内容" prop="content">
+          <el-input v-model="sendSmsForm.content" type="textarea" placeholder="请输入模板内容" readonly />
+        </el-form-item>
+        <el-form-item label="手机号" prop="mobile">
+          <el-input v-model="sendSmsForm.mobile" placeholder="请输入手机号" />
+        </el-form-item>
+        <el-form-item v-for="param in sendSmsForm.params" :label="'参数 {' + param + '}'" :prop="'templateParams.' + param">
+          <el-input v-model="sendSmsForm.templateParams[param]" :placeholder="'请输入 ' + param + ' 参数'" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitSendSmsForm">确 定</el-button>
+        <el-button @click="cancelSendSms">取 消</el-button>
+      </div>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script>
+import { createSmsTemplate, updateSmsTemplate, deleteSmsTemplate, getSmsTemplate, getSmsTemplatePage,
+  exportSmsTemplateExcel, sendSms } from "@/api/system/sms/smsTemplate";
+import {  getSimpleSmsChannels } from "@/api/system/sms/smsChannel";
+
+export default {
+  name: "SmsTemplate",
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 短信模板列表
+      list: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      dateRangeCreateTime: [],
+      // 查询参数
+      queryParams: {
+        pageNo: 1,
+        pageSize: 10,
+        type: null,
+        status: null,
+        code: null,
+        content: null,
+        apiTemplateId: null,
+        channelId: null,
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        type: [{ required: true, message: "短信类型不能为空", trigger: "change" }],
+        status: [{ required: true, message: "开启状态不能为空", trigger: "blur" }],
+        code: [{ required: true, message: "模板编码不能为空", trigger: "blur" }],
+        name: [{ required: true, message: "模板名称不能为空", trigger: "blur" }],
+        content: [{ required: true, message: "模板内容不能为空", trigger: "blur" }],
+        apiTemplateId: [{ required: true, message: "短信 API 的模板编号不能为空", trigger: "blur" }],
+        channelId: [{ required: true, message: "短信渠道编号不能为空", trigger: "change" }],
+      },
+      // 短信渠道
+      channelOptions: [],
+      // 发送短信
+      sendSmsOpen: false,
+      sendSmsForm: {
+        params: [], // 模板的参数列表
+      },
+      sendSmsRules: {
+        mobile: [{ required: true, message: "手机不能为空", trigger: "blur" }],
+        templateCode: [{ required: true, message: "手机不能为空", trigger: "blur" }],
+        templateParams: { }
+      }
+    };
+  },
+  created() {
+    this.getList();
+    // 获得短信渠道
+    getSimpleSmsChannels().then(response => {
+      this.channelOptions = response.data;
+    })
+  },
+  methods: {
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      // 处理查询参数
+      let params = {...this.queryParams};
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行查询
+      getSmsTemplatePage(params).then(response => {
+        this.list = response.data.list;
+        this.total = response.data.total;
+        this.loading = false;
+      });
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 表单重置 */
+    reset() {
+      this.form = {
+        id: undefined,
+        type: undefined,
+        status: undefined,
+        code: undefined,
+        name: undefined,
+        content: undefined,
+        remark: undefined,
+        apiTemplateId: undefined,
+        channelId: undefined,
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNo = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRangeCreateTime = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加短信模板";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id;
+      getSmsTemplate(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改短信模板";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        // 修改的提交
+        if (this.form.id != null) {
+          updateSmsTemplate(this.form).then(response => {
+            this.msgSuccess("修改成功");
+            this.open = false;
+            this.getList();
+          });
+          return;
+        }
+        // 添加的提交
+        createSmsTemplate(this.form).then(response => {
+          this.msgSuccess("新增成功");
+          this.open = false;
+          this.getList();
+        });
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const id = row.id;
+      this.$confirm('是否确认删除短信模板编号为"' + id + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return deleteSmsTemplate(id);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      })
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      // 处理查询参数
+      let params = {...this.queryParams};
+      params.pageNo = undefined;
+      params.pageSize = undefined;
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行导出
+      this.$confirm('是否确认导出所有短信模板数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return exportSmsTemplateExcel(params);
+      }).then(response => {
+        this.downloadExcel(response, '短信模板.xls');
+      })
+    },
+    /** 发送短息按钮 */
+    handleSendSms(row) {
+      this.resetSendSms(row);
+      // 设置参数
+      this.sendSmsForm.content = row.content;
+      this.sendSmsForm.params = row.params;
+      this.sendSmsForm.templateCode = row.code;
+      this.sendSmsForm.templateParams = row.params.reduce(function(obj, item) {
+        obj[item] = undefined;
+        return obj;
+      }, {});
+      // 根据 row 重置 rules
+      this.sendSmsRules.templateParams = row.params.reduce(function(obj, item) {
+        obj[item] = { required: true, message: '参数 ' + item + " 不能为空", trigger: "change" };
+        return obj;
+      }, {});
+      // 设置打开
+      this.sendSmsOpen = true;
+    },
+    /** 重置发送短信的表单 */
+    resetSendSms() {
+      // 根据 row 重置表单
+      this.sendSmsForm = {
+        content: undefined,
+        params: undefined,
+        mobile: undefined,
+        templateCode: undefined,
+        templateParams: {}
+      };
+      this.resetForm("sendSmsForm");
+    },
+    /** 取消发送短信 */
+    cancelSendSms() {
+      this.sendSmsOpen = false;
+      this.resetSendSms();
+    },
+    /** 提交按钮 */
+    submitSendSmsForm() {
+      this.$refs["sendSmsForm"].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        // 添加的提交
+        sendSms(this.sendSmsForm).then(response => {
+          this.msgSuccess("提交发送成功!发送结果,见发送日志编号:" + response.data);
+          this.sendSmsOpen = false;
+        });
+      });
+    },
+    /** 格式化短信渠道 */
+    formatChannelSignature(channelId) {
+      for (const channel of this.channelOptions) {
+        if (channel.id === channelId) {
+          return channel.signature;
+        }
+      }
+      return '找不到签名:' + channelId;
+    }
+  }
+};
+</script>
diff --git a/sql/ruoyi-vue-pro.sql b/sql/ruoyi-vue-pro.sql
index 8a95afdca..e636149fa 100644
--- a/sql/ruoyi-vue-pro.sql
+++ b/sql/ruoyi-vue-pro.sql
@@ -1,7 +1,7 @@
 /*
  Navicat Premium Data Transfer
 
- Source Server         : 127.0.0.1
+ Source Server         : local-mysql001
  Source Server Type    : MySQL
  Source Server Version : 50718
  Source Host           : localhost:3306
@@ -11,7 +11,7 @@
  Target Server Version : 50718
  File Encoding         : 65001
 
- Date: 21/03/2021 18:53:24
+ Date: 05/04/2021 23:51:38
 */
 
 SET NAMES utf8mb4;
@@ -43,7 +43,7 @@ CREATE TABLE `inf_api_access_log` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=1318 DEFAULT CHARSET=utf8mb4 COMMENT='API 访问日志表';
+) ENGINE=InnoDB AUTO_INCREMENT=1909 DEFAULT CHARSET=utf8mb4 COMMENT='API 访问日志表';
 
 -- ----------------------------
 -- Records of inf_api_access_log
@@ -84,7 +84,7 @@ CREATE TABLE `inf_api_error_log` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COMMENT='系统异常日志';
+) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COMMENT='系统异常日志';
 
 -- ----------------------------
 -- Records of inf_api_error_log
@@ -201,7 +201,7 @@ CREATE TABLE `inf_job_log` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=627 DEFAULT CHARSET=utf8mb4 COMMENT='定时任务日志表';
+) ENGINE=InnoDB AUTO_INCREMENT=1035 DEFAULT CHARSET=utf8mb4 COMMENT='定时任务日志表';
 
 -- ----------------------------
 -- Records of inf_job_log
@@ -264,7 +264,7 @@ CREATE TABLE `sys_dict_data` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=65 DEFAULT CHARSET=utf8mb4 COMMENT='字典数据表';
+) ENGINE=InnoDB AUTO_INCREMENT=70 DEFAULT CHARSET=utf8mb4 COMMENT='字典数据表';
 
 -- ----------------------------
 -- Records of sys_dict_data
@@ -324,6 +324,11 @@ INSERT INTO `sys_dict_data` VALUES (61, 2, '管理员', '2', 'user_type', 0, NUL
 INSERT INTO `sys_dict_data` VALUES (62, 0, '未处理', '0', 'inf_api_error_log_process_status', 0, NULL, '', '2021-02-26 07:07:19', '', '2021-02-26 08:11:23', b'0');
 INSERT INTO `sys_dict_data` VALUES (63, 1, '已处理', '1', 'inf_api_error_log_process_status', 0, NULL, '', '2021-02-26 07:07:26', '', '2021-02-26 08:11:29', b'0');
 INSERT INTO `sys_dict_data` VALUES (64, 2, '已忽略', '2', 'inf_api_error_log_process_status', 0, NULL, '', '2021-02-26 07:07:34', '', '2021-02-26 08:11:34', b'0');
+INSERT INTO `sys_dict_data` VALUES (65, 0, '云片', 'YUN_PIAN', 'sys_sms_channel_code', 0, NULL, '1', '2021-04-05 01:05:14', '1', '2021-04-05 01:05:14', b'0');
+INSERT INTO `sys_dict_data` VALUES (66, 0, '阿里云', 'ALIYUN', 'sys_sms_channel_code', 0, NULL, '1', '2021-04-05 01:05:26', '1', '2021-04-05 01:05:26', b'0');
+INSERT INTO `sys_dict_data` VALUES (67, 1, '验证码', '1', 'sys_sms_template_type', 0, NULL, '1', '2021-04-05 21:50:57', '1', '2021-04-05 21:50:57', b'0');
+INSERT INTO `sys_dict_data` VALUES (68, 2, '通知', '2', 'sys_sms_template_type', 0, NULL, '1', '2021-04-05 21:51:08', '1', '2021-04-05 21:51:08', b'0');
+INSERT INTO `sys_dict_data` VALUES (69, 0, '营销', '3', 'sys_sms_template_type', 0, NULL, '1', '2021-04-05 21:51:15', '1', '2021-04-05 21:51:15', b'0');
 COMMIT;
 
 -- ----------------------------
@@ -343,7 +348,7 @@ CREATE TABLE `sys_dict_type` (
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE,
   UNIQUE KEY `dict_type` (`type`)
-) ENGINE=InnoDB AUTO_INCREMENT=111 DEFAULT CHARSET=utf8mb4 COMMENT='字典类型表';
+) ENGINE=InnoDB AUTO_INCREMENT=113 DEFAULT CHARSET=utf8mb4 COMMENT='字典类型表';
 
 -- ----------------------------
 -- Records of sys_dict_type
@@ -366,6 +371,8 @@ INSERT INTO `sys_dict_type` VALUES (107, '定时任务状态', 'inf_job_status',
 INSERT INTO `sys_dict_type` VALUES (108, '定时任务日志状态', 'inf_job_log_status', 0, NULL, '', '2021-02-08 10:03:51', '', '2021-02-08 10:03:51', b'0');
 INSERT INTO `sys_dict_type` VALUES (109, '用户类型', 'user_type', 0, NULL, '', '2021-02-26 00:15:51', '', '2021-02-26 00:15:51', b'0');
 INSERT INTO `sys_dict_type` VALUES (110, 'API 异常数据的处理状态', 'inf_api_error_log_process_status', 0, NULL, '', '2021-02-26 07:07:01', '', '2021-02-26 07:07:01', b'0');
+INSERT INTO `sys_dict_type` VALUES (111, '短信渠道编码', 'sys_sms_channel_code', 0, NULL, '1', '2021-04-05 01:04:50', '1', '2021-04-05 01:04:50', b'0');
+INSERT INTO `sys_dict_type` VALUES (112, '短信模板的类型', 'sys_sms_template_type', 0, NULL, '1', '2021-04-05 21:50:43', '1', '2021-04-05 21:50:43', b'0');
 COMMIT;
 
 -- ----------------------------
@@ -386,7 +393,7 @@ CREATE TABLE `sys_login_log` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COMMENT='系统访问记录';
+) ENGINE=InnoDB AUTO_INCREMENT=34 DEFAULT CHARSET=utf8mb4 COMMENT='系统访问记录';
 
 -- ----------------------------
 -- Records of sys_login_log
@@ -415,7 +422,7 @@ CREATE TABLE `sys_menu` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=1093 DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';
+) ENGINE=InnoDB AUTO_INCREMENT=1100 DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';
 
 -- ----------------------------
 -- Records of sys_menu
@@ -530,6 +537,12 @@ INSERT INTO `sys_menu` VALUES (1089, '日志查询', 'infra:api-error-log:query'
 INSERT INTO `sys_menu` VALUES (1090, '文件管理', '', 2, 0, 2, 'file', 'upload', 'infra/file/index', 0, '', '2021-03-12 20:16:20', '1', '2021-03-13 11:07:05', b'0');
 INSERT INTO `sys_menu` VALUES (1091, '文件查询', 'infra:file:query', 3, 1, 1090, '', '', '', 0, '', '2021-03-12 20:16:20', '', '2021-03-12 20:16:20', b'0');
 INSERT INTO `sys_menu` VALUES (1092, '文件删除', 'infra:file:delete', 3, 4, 1090, '', '', '', 0, '', '2021-03-12 20:16:20', '', '2021-03-12 20:16:20', b'0');
+INSERT INTO `sys_menu` VALUES (1093, '短信管理', '', 1, 11, 1, 'sms', 'validCode', NULL, 0, '1', '2021-04-05 01:10:16', '1', '2021-04-05 01:11:38', b'0');
+INSERT INTO `sys_menu` VALUES (1094, '短信渠道', '', 2, 0, 1093, 'sms-channel', '', 'system/sms/smsChannel', 0, '', '2021-04-01 11:07:15', '1', '2021-04-05 20:32:53', b'0');
+INSERT INTO `sys_menu` VALUES (1095, '短信渠道查询', 'system:sms-channel:query', 3, 1, 1094, '', '', '', 0, '', '2021-04-01 11:07:15', '', '2021-04-01 11:07:15', b'0');
+INSERT INTO `sys_menu` VALUES (1096, '短信渠道创建', 'system:sms-channel:create', 3, 2, 1094, '', '', '', 0, '', '2021-04-01 11:07:15', '', '2021-04-01 11:07:15', b'0');
+INSERT INTO `sys_menu` VALUES (1097, '短信渠道更新', 'system:sms-channel:update', 3, 3, 1094, '', '', '', 0, '', '2021-04-01 11:07:15', '', '2021-04-01 11:07:15', b'0');
+INSERT INTO `sys_menu` VALUES (1098, '短信渠道删除', 'system:sms-channel:delete', 3, 4, 1094, '', '', '', 0, '', '2021-04-01 11:07:15', '', '2021-04-01 11:07:15', b'0');
 COMMIT;
 
 -- ----------------------------
@@ -589,7 +602,7 @@ CREATE TABLE `sys_operate_log` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=65 DEFAULT CHARSET=utf8mb4 COMMENT='操作日志记录';
+) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb4 COMMENT='操作日志记录';
 
 -- ----------------------------
 -- Records of sys_operate_log
@@ -834,6 +847,118 @@ INSERT INTO `sys_role_menu` VALUES (237, 101, 1064, '', '2021-01-21 03:23:27', '
 INSERT INTO `sys_role_menu` VALUES (238, 101, 1065, '', '2021-01-21 03:23:27', '', '2021-01-21 03:23:27', b'0');
 COMMIT;
 
+-- ----------------------------
+-- Table structure for sys_sms_channel
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_sms_channel`;
+CREATE TABLE `sys_sms_channel` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
+  `signature` varchar(10) NOT NULL COMMENT '短信签名',
+  `code` varchar(63) NOT NULL COMMENT '渠道编码',
+  `status` tinyint(4) NOT NULL COMMENT '开启状态',
+  `remark` varchar(255) DEFAULT NULL COMMENT '备注',
+  `api_key` varchar(63) NOT NULL COMMENT '短信 API 的账号',
+  `api_secret` varchar(63) DEFAULT NULL COMMENT '短信 API 的秘钥',
+  `callback_url` varchar(255) DEFAULT NULL COMMENT '短信发送回调 URL',
+  `creator` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COMMENT='短信渠道';
+
+-- ----------------------------
+-- Records of sys_sms_channel
+-- ----------------------------
+BEGIN;
+INSERT INTO `sys_sms_channel` VALUES (1, '芋道', 'YUN_PIAN', 0, '呵呵呵哒', '1555a14277cb8a608cf45a9e6a80d510', NULL, 'http://java.nat300.top/api/system/sms/callback/sms/yunpian', '', '2021-03-31 06:12:20', '1', '2021-04-05 21:02:38', b'0');
+INSERT INTO `sys_sms_channel` VALUES (2, 'Ballcat', 'ALIYUN', 0, '啦啦啦', 'LTAI5tCnKso2uG3kJ5gRav88', 'fGJ5SNXL7P1NHNRmJ7DJaMJGPyE55C', NULL, '', '2021-03-31 11:53:10', '1', '2021-04-05 21:02:44', b'0');
+INSERT INTO `sys_sms_channel` VALUES (3, '测试', 'YUN_PIAN', 0, '哈哈哈', '23132', NULL, 'http://www.baidu.com', '1', '2021-04-05 21:10:34', '1', '2021-04-05 21:10:34', b'0');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for sys_sms_log
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_sms_log`;
+CREATE TABLE `sys_sms_log` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
+  `channel_id` bigint(20) NOT NULL COMMENT '短信渠道编号',
+  `channel_code` varchar(63) NOT NULL COMMENT '短信渠道编码',
+  `template_id` bigint(20) NOT NULL COMMENT '模板编号',
+  `template_code` varchar(63) NOT NULL COMMENT '模板编码',
+  `template_type` tinyint(4) NOT NULL COMMENT '短信类型',
+  `template_content` varchar(255) NOT NULL COMMENT '短信内容',
+  `template_params` varchar(255) NOT NULL COMMENT '短信参数',
+  `api_template_id` varchar(63) NOT NULL COMMENT '短信 API 的模板编号',
+  `mobile` varchar(11) NOT NULL COMMENT '手机号',
+  `user_id` bigint(20) DEFAULT '0' COMMENT '用户编号',
+  `user_type` tinyint(4) DEFAULT '0' COMMENT '用户类型',
+  `send_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '发送状态',
+  `send_time` datetime DEFAULT NULL COMMENT '发送时间',
+  `send_code` int(11) DEFAULT NULL COMMENT '发送结果的编码',
+  `send_msg` varchar(255) DEFAULT NULL COMMENT '发送结果的提示',
+  `api_send_code` varchar(63) DEFAULT NULL COMMENT '短信 API 发送结果的编码',
+  `api_send_msg` varchar(255) DEFAULT NULL COMMENT '短信 API 发送失败的提示',
+  `api_request_id` varchar(255) DEFAULT NULL COMMENT '短信 API 发送返回的唯一请求 ID',
+  `api_serial_no` varchar(255) DEFAULT NULL COMMENT '短信 API 发送返回的序号',
+  `receive_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '接收状态',
+  `receive_time` datetime DEFAULT NULL COMMENT '接收时间',
+  `api_receive_code` varchar(63) DEFAULT NULL COMMENT 'API 接收结果的编码',
+  `api_receive_msg` varchar(255) DEFAULT NULL COMMENT 'API 接收结果的说明',
+  `creator` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COMMENT='短信moan';
+
+-- ----------------------------
+-- Records of sys_sms_log
+-- ----------------------------
+BEGIN;
+INSERT INTO `sys_sms_log` VALUES (15, 1, 'YUN_PIAN', 2, 'test_01', 1, '正在进行登录操作登陆,您的验证码是1234', '{\"code\":\"1234\",\"operation\":\"登陆\"}', '4383920', '15601691399', 1, 2, 10, '2021-04-04 23:24:13', 0, '成功', '0', '发送成功', NULL, '62922707786', 10, '2021-04-04 23:24:26', 'DELIVRD', 'DELIVRD', NULL, '2021-04-04 23:23:29', NULL, '2021-04-04 23:25:17', b'0');
+INSERT INTO `sys_sms_log` VALUES (16, 2, 'ALIYUN', 3, 'test_02', 1, '您的验证码1234,该验证码5分钟内有效,请勿泄漏于他人!', '{\"code\":\"1234\"}', 'SMS_207945135', '15601691399', 1, 2, 20, '2021-04-05 00:08:39', 999, '未知错误,需要解析', 'SDK.InvalidAccessKeySecret', 'SDK.InvalidAccessKeySecret : Specified Access Key Secret is not valid.\r\nRequestId : 2EA9C6A5-579F-4D21-B7D3-0AD3BA4F7741', '2EA9C6A5-579F-4D21-B7D3-0AD3BA4F7741', NULL, 0, NULL, NULL, NULL, NULL, '2021-04-05 00:08:39', NULL, '2021-04-05 00:08:39', b'0');
+INSERT INTO `sys_sms_log` VALUES (17, 2, 'ALIYUN', 3, 'test_02', 1, '您的验证码1234,该验证码5分钟内有效,请勿泄漏于他人!', '{\"code\":\"1234\"}', 'SMS_207945135', '15601691399', 1, 2, 20, '2021-04-05 00:09:43', 999, '未知错误,需要解析', 'SDK.InvalidAccessKeySecret', 'SDK.InvalidAccessKeySecret : Specified Access Key Secret is not valid.\r\nRequestId : BF766164-9C03-44FD-B6D3-ADA74118E432', 'BF766164-9C03-44FD-B6D3-ADA74118E432', NULL, 0, NULL, NULL, NULL, NULL, '2021-04-05 00:09:43', NULL, '2021-04-05 00:09:43', b'0');
+INSERT INTO `sys_sms_log` VALUES (18, 2, 'ALIYUN', 3, 'test_02', 1, '您的验证码1234,该验证码5分钟内有效,请勿泄漏于他人!', '{\"code\":\"1234\"}', 'SMS_207945135', '15601691399', 1, 2, 20, '2021-04-05 00:11:13', 999, '未知错误,需要解析', 'SDK.InvalidAccessKeySecret', 'SDK.InvalidAccessKeySecret : Specified Access Key Secret is not valid.\r\nRequestId : 2D7C0ABC-7538-45B4-BFEF-B610D591CE3D', '2D7C0ABC-7538-45B4-BFEF-B610D591CE3D', NULL, 0, NULL, NULL, NULL, NULL, '2021-04-05 00:11:12', NULL, '2021-04-05 00:11:13', b'0');
+INSERT INTO `sys_sms_log` VALUES (19, 2, 'ALIYUN', 3, 'test_02', 1, '您的验证码1234,该验证码5分钟内有效,请勿泄漏于他人!', '{\"code\":\"1234\"}', 'SMS_207945135', '15601691399', 1, 2, 20, '2021-04-05 00:12:21', 999, '未知错误,需要解析', 'SDK.InvalidAccessKeySecret', 'SDK.InvalidAccessKeySecret : Specified Access Key Secret is not valid.\r\nRequestId : 0A86DC5C-2985-474F-B076-748C9F2C5D3F', '0A86DC5C-2985-474F-B076-748C9F2C5D3F', NULL, 0, NULL, NULL, NULL, NULL, '2021-04-05 00:12:01', NULL, '2021-04-05 00:12:21', b'0');
+INSERT INTO `sys_sms_log` VALUES (20, 1, 'YUN_PIAN', 2, 'test_01', 1, '正在进行登录操作登陆,您的验证码是1234', '{\"code\":\"1234\",\"operation\":\"登陆\"}', '4383920', '15601691399', 1, 2, 10, '2021-04-05 00:14:36', 0, '成功', '0', '发送成功', NULL, '62923244790', 0, NULL, NULL, NULL, NULL, '2021-04-05 00:13:42', NULL, '2021-04-05 00:14:36', b'0');
+INSERT INTO `sys_sms_log` VALUES (21, 2, 'ALIYUN', 3, 'test_02', 1, '您的验证码1234,该验证码5分钟内有效,请勿泄漏于他人!', '{\"code\":\"1234\"}', 'SMS_207945135', '15601691399', 1, 2, 20, '2021-04-05 00:19:43', 999, '未知错误,需要解析', 'SDK.InvalidAccessKeySecret', 'SDK.InvalidAccessKeySecret : Specified Access Key Secret is not valid.\r\nRequestId : 3837C6D3-B96F-428C-BBB2-86135D4B5B99', '3837C6D3-B96F-428C-BBB2-86135D4B5B99', NULL, 0, NULL, NULL, NULL, NULL, '2021-04-05 00:15:06', NULL, '2021-04-05 00:19:44', b'0');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for sys_sms_template
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_sms_template`;
+CREATE TABLE `sys_sms_template` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
+  `type` tinyint(4) NOT NULL COMMENT '短信签名',
+  `status` tinyint(4) NOT NULL COMMENT '开启状态',
+  `code` varchar(63) NOT NULL COMMENT '模板编码',
+  `name` varchar(63) NOT NULL COMMENT '模板名称',
+  `content` varchar(255) NOT NULL COMMENT '模板内容',
+  `params` varchar(255) NOT NULL COMMENT '参数数组',
+  `remark` varchar(255) DEFAULT NULL COMMENT '备注',
+  `api_template_id` varchar(63) NOT NULL COMMENT '短信 API 的模板编号',
+  `channel_id` bigint(20) NOT NULL COMMENT '短信渠道编号',
+  `channel_code` varchar(63) NOT NULL COMMENT '短信渠道编码',
+  `creator` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COMMENT='短信模板';
+
+-- ----------------------------
+-- Records of sys_sms_template
+-- ----------------------------
+BEGIN;
+INSERT INTO `sys_sms_template` VALUES (2, 1, 0, 'test_01', '测试验证码短信', '正在进行登录操作{operation},您的验证码是{code}', '[\"operation\",\"code\"]', NULL, '4383920', 1, 'YUN_PIAN', '', '2021-03-31 10:49:38', '', '2021-03-31 12:01:38', b'0');
+INSERT INTO `sys_sms_template` VALUES (3, 1, 0, 'test_02', '公告通知', '您的验证码{code},该验证码5分钟内有效,请勿泄漏于他人!', '[\"code\"]', NULL, 'SMS_207945135', 2, 'ALIYUN', '', '2021-03-31 11:56:30', '', '2021-03-31 11:56:30', b'0');
+COMMIT;
+
 -- ----------------------------
 -- Table structure for sys_user
 -- ----------------------------
@@ -865,8 +990,8 @@ CREATE TABLE `sys_user` (
 -- Records of sys_user
 -- ----------------------------
 BEGIN;
-INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$0acJOIk2D25/oC87nyclE..0lzeu9DtQ/n3geP4fkun/zIVRhHJIO', '芋道', '管理员', 103, '[1]', 'aoteman@126.com', '15612345678', 1, 'http://127.0.0.1:8080/api/system/file/get/add5ec1891a7d97d2cc1d60847e16294.jpg', 0, '127.0.0.1', '2021-01-05 17:03:47', 'admin', '2021-01-05 17:03:47', '1', '2021-03-21 18:16:16', b'0');
-INSERT INTO `sys_user` VALUES (2, 'ry', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '若依', '测试员', 105, '[2]', 'ry@qq.com', '15666666666', 1, '', 0, '127.0.0.1', '2021-01-05 17:03:47', 'admin', '2021-01-05 17:03:47', '', '2021-01-05 17:03:47', b'0');
+INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$0acJOIk2D25/oC87nyclE..0lzeu9DtQ/n3geP4fkun/zIVRhHJIO', '芋道源码', '管理员', 103, '[1]', 'aoteman@126.com', '15612345678', 1, 'http://api-dashboard.yudao.iocoder.cn/api/infra/file/get/5e8609290e915c4fa8b08e67.jpg', 0, '127.0.0.1', '2021-01-05 17:03:47', 'admin', '2021-01-05 17:03:47', '1', '2021-04-05 02:16:10', b'0');
+INSERT INTO `sys_user` VALUES (2, 'ry', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '若依', '测试员', 105, '[2]', 'ry@qq.com', '15666666666', 1, '', 0, '127.0.0.1', '2021-01-05 17:03:47', 'admin', '2021-01-05 17:03:47', '', '2021-04-01 04:50:36', b'1');
 INSERT INTO `sys_user` VALUES (100, 'yudao', '$2a$10$11U48RhyJ5pSBYWSn12AD./ld671.ycSzJHbyrtpeoMeYiw31eo8a', '芋道', '不要吓我', 100, '[1]', 'yudao@iocoder.cn', '15601691300', 1, '', 1, '', NULL, '', '2021-01-07 09:07:17', '1', '2021-03-14 22:35:17', b'0');
 INSERT INTO `sys_user` VALUES (103, 'yuanma', '', '源码', NULL, 100, NULL, 'yuanma@iocoder.cn', '15601701300', 0, '', 0, '', NULL, '', '2021-01-13 23:50:35', '', '2021-01-13 23:50:35', b'0');
 INSERT INTO `sys_user` VALUES (104, 'test', '$2a$10$.TOFpaIiI3PzEwkGrNq0Eu6Cc3rOqJMxTb1DqeSEM8StxaGPBRKoi', '测试号', NULL, 100, '[]', '', '15601691200', 1, '', 0, '', NULL, '', '2021-01-21 02:13:53', '1', '2021-03-14 22:36:38', b'0');
@@ -924,11 +1049,17 @@ CREATE TABLE `sys_user_session` (
 -- Records of sys_user_session
 -- ----------------------------
 BEGIN;
+INSERT INTO `sys_user_session` VALUES ('04c6624c7bf14b1ba1a01cb976a9d876', 1, '2021-04-05 21:40:12', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-04-05 20:21:09', NULL, '2021-04-01 12:25:35', b'1');
+INSERT INTO `sys_user_session` VALUES ('0e235ce5ae7342a09b372a00bd7d1b41', 1, '2021-04-05 01:43:22', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-04-05 00:51:03', NULL, '2021-04-01 04:18:06', b'1');
+INSERT INTO `sys_user_session` VALUES ('0e6943f8ca9b4215a014843eb489ccc7', 1, '2021-04-05 22:53:22', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-04-05 21:43:59', NULL, '2021-04-05 22:23:22', b'0');
+INSERT INTO `sys_user_session` VALUES ('40d532d8900c43b791266429a7911751', 1, '2021-04-05 22:11:34', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-04-05 21:41:34', NULL, '2021-04-01 12:28:20', b'1');
 INSERT INTO `sys_user_session` VALUES ('505b4e7d8b0d4b40aa23bf540da81234', 1, '2021-03-14 01:25:13', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-14 00:31:43', NULL, '2021-03-13 07:35:26', b'1');
 INSERT INTO `sys_user_session` VALUES ('5a7248bf87d14e7e9f0578b05969986c', 1, '2021-03-13 10:42:50', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-13 09:37:36', NULL, '2021-03-12 19:53:07', b'1');
+INSERT INTO `sys_user_session` VALUES ('8b3eac5e4a104a4191c8070e03d553ea', 1, '2021-04-05 02:45:12', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-04-05 02:15:12', NULL, '2021-04-01 11:05:25', b'1');
 INSERT INTO `sys_user_session` VALUES ('9ae27346d8b7491aad1385f51e8aa196', 1, '2021-03-13 14:02:12', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-13 10:43:06', NULL, '2021-03-13 06:40:35', b'1');
 INSERT INTO `sys_user_session` VALUES ('ae9ee7452ee54e4b983d658188c15c4d', 1, '2021-03-14 21:32:57', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-14 20:25:00', NULL, '2021-03-13 15:19:10', b'1');
-INSERT INTO `sys_user_session` VALUES ('d0adf48f82914212b947e5ab04d9fb65', 1, '2021-03-21 19:16:28', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-03-21 18:13:37', NULL, '2021-03-21 18:46:28', b'0');
+INSERT INTO `sys_user_session` VALUES ('d0adf48f82914212b947e5ab04d9fb65', 1, '2021-03-21 19:16:28', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-03-21 18:13:37', NULL, '2021-03-15 05:53:20', b'1');
+INSERT INTO `sys_user_session` VALUES ('e80c2400724042a2ab73732166cde8fc', 1, '2021-03-21 21:17:12', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-03-21 20:47:12', NULL, '2021-03-15 08:18:56', b'1');
 INSERT INTO `sys_user_session` VALUES ('e8872f5192584440a548641b83c877ef', 1, '2021-03-21 18:36:01', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', NULL, '2021-03-21 17:51:48', NULL, '2021-03-15 03:54:20', b'1');
 INSERT INTO `sys_user_session` VALUES ('f1ab99b09b5a475795579ff99d60ac78', 1, '2021-03-14 23:04:31', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-14 21:12:44', NULL, '2021-03-15 03:32:38', b'1');
 INSERT INTO `sys_user_session` VALUES ('f853b50d064340a581e9a49bba9411fc', 1, '2021-03-10 01:55:41', 'admin', '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', NULL, '2021-03-10 01:11:53', NULL, '2021-03-12 18:37:05', b'1');
@@ -964,7 +1095,7 @@ CREATE TABLE `tool_codegen_column` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=389 DEFAULT CHARSET=utf8mb4 COMMENT='代码生成表字段定义';
+) ENGINE=InnoDB AUTO_INCREMENT=418 DEFAULT CHARSET=utf8mb4 COMMENT='代码生成表字段定义';
 
 -- ----------------------------
 -- Records of tool_codegen_column
@@ -1126,6 +1257,35 @@ INSERT INTO `tool_codegen_column` VALUES (385, 33, 'create_time', 'datetime', '
 INSERT INTO `tool_codegen_column` VALUES (386, 33, 'updater', 'varchar(64)', '更新者', b'1', b'0', '0', 6, 'String', 'updater', '', NULL, b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-03-13 09:43:20', '1', '2021-03-13 11:27:12', b'0');
 INSERT INTO `tool_codegen_column` VALUES (387, 33, 'update_time', 'datetime', '更新时间', b'0', b'0', '0', 7, 'Date', 'updateTime', '', NULL, b'0', b'0', b'0', 'BETWEEN', b'0', 'datetime', '1', '2021-03-13 09:43:20', '1', '2021-03-13 11:27:12', b'0');
 INSERT INTO `tool_codegen_column` VALUES (388, 33, 'deleted', 'bit(1)', '是否删除', b'0', b'0', '0', 8, 'Boolean', 'deleted', '', NULL, b'0', b'0', b'0', '=', b'0', 'radio', '1', '2021-03-13 09:43:20', '1', '2021-03-13 11:27:12', b'0');
+INSERT INTO `tool_codegen_column` VALUES (389, 34, 'id', 'bigint(20)', '编号', b'0', b'1', '1', 1, 'Long', 'id', '', '1024', b'0', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (390, 34, 'signature', 'varchar(8)', '短信签名', b'0', b'0', '0', 2, 'String', 'signature', '', '芋道源码', b'1', b'1', b'1', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (391, 34, 'code', 'varchar(63)', '渠道编码', b'0', b'0', '0', 3, 'String', 'code', 'sys_sms_channel_code', 'YUN_PIAN', b'1', b'0', b'0', '=', b'1', 'select', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (392, 34, 'status', 'tinyint(4)', '启用状态', b'0', b'0', '0', 4, 'Integer', 'status', 'sys_common_status', '1', b'1', b'1', b'1', '=', b'1', 'radio', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (393, 34, 'remark', 'varchar(255)', '备注', b'1', b'0', '0', 5, 'String', 'remark', '', '好吃!', b'1', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (394, 34, 'api_key', 'varchar(63)', '短信 API 的账号', b'0', b'0', '0', 6, 'String', 'apiKey', '', 'yudao', b'1', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (395, 34, 'api_secret', 'varchar(63)', '短信 API 的秘钥', b'1', b'0', '0', 7, 'String', 'apiSecret', '', 'yuanma', b'1', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (396, 34, 'callback_url', 'varchar(255)', '短信发送回调 URL', b'1', b'0', '0', 8, 'String', 'callbackUrl', '', 'http://www.iocoder.cn', b'1', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (397, 34, 'creator', 'varchar(64)', '创建者', b'1', b'0', '0', 9, 'String', 'creator', '', NULL, b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (398, 34, 'create_time', 'datetime', '创建时间', b'0', b'0', '0', 10, 'Date', 'createTime', '', NULL, b'0', b'0', b'1', 'BETWEEN', b'1', 'datetime', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (399, 34, 'updater', 'varchar(64)', '更新者', b'1', b'0', '0', 11, 'String', 'updater', '', NULL, b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (400, 34, 'update_time', 'datetime', '更新时间', b'0', b'0', '0', 12, 'Date', 'updateTime', '', NULL, b'0', b'0', b'0', 'BETWEEN', b'0', 'datetime', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (401, 34, 'deleted', 'bit(1)', '是否删除', b'0', b'0', '0', 13, 'Boolean', 'deleted', '', NULL, b'0', b'0', b'0', '=', b'0', 'radio', '1', '2021-04-05 00:51:34', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_column` VALUES (402, 35, 'id', 'bigint(20)', '编号', b'0', b'1', '1', 1, 'Long', 'id', '', '1024', b'0', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (403, 35, 'type', 'tinyint(4)', '短信签名', b'0', b'0', '0', 2, 'Integer', 'type', 'sys_sms_template_type', '1', b'1', b'1', b'1', '=', b'1', 'select', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (404, 35, 'status', 'tinyint(4)', '开启状态', b'0', b'0', '0', 3, 'Integer', 'status', 'sys_common_status', '1', b'1', b'1', b'1', '=', b'1', 'radio', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (405, 35, 'code', 'varchar(63)', '模板编码', b'0', b'0', '0', 4, 'String', 'code', '', 'test_01', b'1', b'1', b'1', 'LIKE', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (406, 35, 'name', 'varchar(63)', '模板名称', b'0', b'0', '0', 5, 'String', 'name', '', 'yudao', b'1', b'1', b'0', 'LIKE', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (407, 35, 'content', 'varchar(255)', '模板内容', b'0', b'0', '0', 6, 'String', 'content', '', '你好,{name}。你长的太{like}啦!', b'1', b'1', b'1', 'LIKE', b'1', 'editor', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (408, 35, 'params', 'varchar(255)', '参数数组', b'0', b'0', '0', 7, 'String', 'params', '', 'name,code', b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (409, 35, 'remark', 'varchar(255)', '备注', b'1', b'0', '0', 8, 'String', 'remark', '', '哈哈哈', b'1', b'1', b'0', '=', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (410, 35, 'api_template_id', 'varchar(63)', '短信 API 的模板编号', b'0', b'0', '0', 9, 'String', 'apiTemplateId', '', '4383920', b'1', b'1', b'1', 'LIKE', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (411, 35, 'channel_id', 'bigint(20)', '短信渠道编号', b'0', b'0', '0', 10, 'Long', 'channelId', '', '10', b'1', b'1', b'1', '=', b'1', 'select', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (412, 35, 'channel_code', 'varchar(63)', '短信渠道编码', b'0', b'0', '0', 11, 'String', 'channelCode', 'sys_sms_channel_code', 'ALIYUN', b'0', b'0', b'0', '=', b'1', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (413, 35, 'creator', 'varchar(64)', '创建者', b'1', b'0', '0', 12, 'String', 'creator', '', NULL, b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (414, 35, 'create_time', 'datetime', '创建时间', b'0', b'0', '0', 13, 'Date', 'createTime', '', NULL, b'0', b'0', b'1', 'BETWEEN', b'1', 'datetime', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (415, 35, 'updater', 'varchar(64)', '更新者', b'1', b'0', '0', 14, 'String', 'updater', '', NULL, b'0', b'0', b'0', '=', b'0', 'input', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (416, 35, 'update_time', 'datetime', '更新时间', b'0', b'0', '0', 15, 'Date', 'updateTime', '', NULL, b'0', b'0', b'0', 'BETWEEN', b'0', 'datetime', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
+INSERT INTO `tool_codegen_column` VALUES (417, 35, 'deleted', 'bit(1)', '是否删除', b'0', b'0', '0', 16, 'Boolean', 'deleted', '', NULL, b'0', b'0', b'0', '=', b'0', 'radio', '1', '2021-04-05 21:42:22', '1', '2021-04-05 22:23:38', b'0');
 COMMIT;
 
 -- ----------------------------
@@ -1151,7 +1311,7 @@ CREATE TABLE `tool_codegen_table` (
   `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
   PRIMARY KEY (`id`) USING BTREE
-) ENGINE=InnoDB AUTO_INCREMENT=34 DEFAULT CHARSET=utf8mb4 COMMENT='代码生成表定义';
+) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb4 COMMENT='代码生成表定义';
 
 -- ----------------------------
 -- Records of tool_codegen_table
@@ -1169,6 +1329,8 @@ INSERT INTO `tool_codegen_table` VALUES (28, 1, 'sys_dict_type', '字典类型
 INSERT INTO `tool_codegen_table` VALUES (29, 1, 'sys_dict_type', '字典类型表', NULL, 'system', 'dict', 'SysDictType', '字典类型', '芋艿', 1, NULL, '', '2021-03-06 03:52:57', '', '2021-03-06 04:03:52', b'0');
 INSERT INTO `tool_codegen_table` VALUES (30, 1, 'sys_dict_data', '字典数据表', NULL, 'system', 'type', 'SysDictData', '字典数据', '芋道源码', 1, NULL, '', '2021-03-06 06:48:28', '', '2021-03-06 06:50:47', b'0');
 INSERT INTO `tool_codegen_table` VALUES (33, 1, 'inf_file', '文件表', NULL, 'infra', 'file', 'InfFile', '文件', '芋艿', 1, 2, '1', '2021-03-13 09:43:20', '1', '2021-03-13 11:27:12', b'0');
+INSERT INTO `tool_codegen_table` VALUES (34, 1, 'sys_sms_channel', '短信渠道', NULL, 'system', 'sms', 'SysSmsChannel', '短信渠道', '芋道源码', 1, 1093, '1', '2021-04-03 13:39:06', '1', '2021-04-05 20:52:09', b'0');
+INSERT INTO `tool_codegen_table` VALUES (35, 1, 'sys_sms_template', '短信模板', NULL, 'system', 'sms', 'SysSmsTemplate', '短信模板', '芋道源码', 1, 1093, '1', '2021-04-03 13:58:55', '1', '2021-04-05 22:23:38', b'0');
 COMMIT;
 
 -- ----------------------------
diff --git a/src/main/java/cn/iocoder/dashboard/common/core/KeyValue.java b/src/main/java/cn/iocoder/dashboard/common/core/KeyValue.java
new file mode 100644
index 000000000..57fe08b5a
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/common/core/KeyValue.java
@@ -0,0 +1,20 @@
+package cn.iocoder.dashboard.common.core;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Key Value 的键值对
+ *
+ * @author 芋道源码
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class KeyValue<K, V> {
+
+    private K key;
+    private V value;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/common/enums/DefaultBitFieldEnum.java b/src/main/java/cn/iocoder/dashboard/common/enums/DefaultBitFieldEnum.java
new file mode 100644
index 000000000..7738d40a2
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/common/enums/DefaultBitFieldEnum.java
@@ -0,0 +1,27 @@
+package cn.iocoder.dashboard.common.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 通用状态枚举
+ *
+ * @author 芋道源码
+ */
+@Getter
+@AllArgsConstructor
+public enum DefaultBitFieldEnum {
+
+    NO(0, "否"),
+    YES(1, "是");
+
+    /**
+     * 状态值
+     */
+    private final Integer val;
+    /**
+     * 状态名
+     */
+    private final String name;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/common/exception/ErrorCode.java b/src/main/java/cn/iocoder/dashboard/common/exception/ErrorCode.java
index 670029504..065aece7c 100644
--- a/src/main/java/cn/iocoder/dashboard/common/exception/ErrorCode.java
+++ b/src/main/java/cn/iocoder/dashboard/common/exception/ErrorCode.java
@@ -1,12 +1,13 @@
 package cn.iocoder.dashboard.common.exception;
 
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
 import cn.iocoder.dashboard.common.exception.enums.ServiceErrorCodeRange;
 import lombok.Data;
 
 /**
  * 错误码对象
  *
- * 全局错误码,占用 [0, 999],参见 {@link GlobalException}
+ * 全局错误码,占用 [0, 999], 参见 {@link GlobalErrorCodeConstants}
  * 业务异常错误码,占用 [1 000 000 000, +∞),参见 {@link ServiceErrorCodeRange}
  *
  * TODO 错误码设计成对象的原因,为未来的 i18 国际化做准备
@@ -21,11 +22,11 @@ public class ErrorCode {
     /**
      * 错误提示
      */
-    private final String message;
+    private final String msg;
 
     public ErrorCode(Integer code, String message) {
         this.code = code;
-        this.message = message;
+        this.msg = message;
     }
 
 }
diff --git a/src/main/java/cn/iocoder/dashboard/common/exception/GlobalException.java b/src/main/java/cn/iocoder/dashboard/common/exception/GlobalException.java
deleted file mode 100644
index d4f9c945e..000000000
--- a/src/main/java/cn/iocoder/dashboard/common/exception/GlobalException.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package cn.iocoder.dashboard.common.exception;
-
-import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * 全局异常 Exception
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class GlobalException extends RuntimeException {
-
-    /**
-     * 全局错误码
-     *
-     * @see GlobalErrorCodeConstants
-     */
-    private Integer code;
-    /**
-     * 错误提示
-     */
-    private String message;
-
-    /**
-     * 空构造方法,避免反序列化问题
-     */
-    public GlobalException() {
-    }
-
-    public GlobalException(ErrorCode errorCode) {
-        this.code = errorCode.getCode();
-        this.message = errorCode.getMessage();
-    }
-
-    public GlobalException(Integer code, String message) {
-        this.code = code;
-        this.message = message;
-    }
-
-}
diff --git a/src/main/java/cn/iocoder/dashboard/common/exception/ServiceException.java b/src/main/java/cn/iocoder/dashboard/common/exception/ServiceException.java
index 83c43ca2e..2e2adec75 100644
--- a/src/main/java/cn/iocoder/dashboard/common/exception/ServiceException.java
+++ b/src/main/java/cn/iocoder/dashboard/common/exception/ServiceException.java
@@ -30,7 +30,7 @@ public final class ServiceException extends RuntimeException {
 
     public ServiceException(ErrorCode errorCode) {
         this.code = errorCode.getCode();
-        this.message = errorCode.getMessage();
+        this.message = errorCode.getMsg();
     }
 
     public ServiceException(Integer code, String message) {
diff --git a/src/main/java/cn/iocoder/dashboard/common/exception/util/ServiceExceptionUtil.java b/src/main/java/cn/iocoder/dashboard/common/exception/util/ServiceExceptionUtil.java
index 6dbfe6ca6..e6367c835 100644
--- a/src/main/java/cn/iocoder/dashboard/common/exception/util/ServiceExceptionUtil.java
+++ b/src/main/java/cn/iocoder/dashboard/common/exception/util/ServiceExceptionUtil.java
@@ -47,12 +47,12 @@ public class ServiceExceptionUtil {
     // ========== 和 ServiceException 的集成 ==========
 
     public static ServiceException exception(ErrorCode errorCode) {
-        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMessage());
+        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg());
         return exception0(errorCode.getCode(), messagePattern);
     }
 
     public static ServiceException exception(ErrorCode errorCode, Object... params) {
-        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMessage());
+        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg());
         return exception0(errorCode.getCode(), messagePattern, params);
     }
 
diff --git a/src/main/java/cn/iocoder/dashboard/common/pojo/CommonResult.java b/src/main/java/cn/iocoder/dashboard/common/pojo/CommonResult.java
index 14e34070d..bb7fbc345 100644
--- a/src/main/java/cn/iocoder/dashboard/common/pojo/CommonResult.java
+++ b/src/main/java/cn/iocoder/dashboard/common/pojo/CommonResult.java
@@ -1,7 +1,6 @@
 package cn.iocoder.dashboard.common.pojo;
 
 import cn.iocoder.dashboard.common.exception.ErrorCode;
-import cn.iocoder.dashboard.common.exception.GlobalException;
 import cn.iocoder.dashboard.common.exception.ServiceException;
 import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
 import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -9,6 +8,7 @@ import lombok.Data;
 import org.springframework.util.Assert;
 
 import java.io.Serializable;
+import java.util.Objects;
 
 /**
  * 通用返回
@@ -16,7 +16,7 @@ import java.io.Serializable;
  * @param <T> 数据泛型
  */
 @Data
-public final class CommonResult<T> implements Serializable {
+public class CommonResult<T> implements Serializable {
 
     /**
      * 错误码
@@ -31,7 +31,7 @@ public final class CommonResult<T> implements Serializable {
     /**
      * 错误提示,用户可阅读
      *
-     * @see ErrorCode#getMessage() ()
+     * @see ErrorCode#getMsg() ()
      */
     private String msg;
 
@@ -57,7 +57,7 @@ public final class CommonResult<T> implements Serializable {
     }
 
     public static <T> CommonResult<T> error(ErrorCode errorCode) {
-        return error(errorCode.getCode(), errorCode.getMessage());
+        return error(errorCode.getCode(), errorCode.getMsg());
     }
 
     public static <T> CommonResult<T> success(T data) {
@@ -68,9 +68,13 @@ public final class CommonResult<T> implements Serializable {
         return result;
     }
 
+    public static boolean isSuccess(Integer code) {
+        return Objects.equals(code, GlobalErrorCodeConstants.SUCCESS.getCode());
+    }
+
     @JsonIgnore // 避免 jackson 序列化
     public boolean isSuccess() {
-        return GlobalErrorCodeConstants.SUCCESS.getCode().equals(code);
+        return isSuccess(code);
     }
 
     @JsonIgnore // 避免 jackson 序列化
@@ -81,16 +85,12 @@ public final class CommonResult<T> implements Serializable {
     // ========= 和 Exception 异常体系集成 =========
 
     /**
-     * 判断是否有异常。如果有,则抛出 {@link GlobalException} 或 {@link ServiceException} 异常
+     * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常
      */
-    public void checkError() throws GlobalException, ServiceException {
+    public void checkError() throws ServiceException {
         if (isSuccess()) {
             return;
         }
-        // 全局异常
-        if (GlobalErrorCodeConstants.isMatch(code)) {
-            throw new GlobalException(code, msg);
-        }
         // 业务异常
         throw new ServiceException(code, msg);
     }
@@ -99,8 +99,4 @@ public final class CommonResult<T> implements Serializable {
         return error(serviceException.getCode(), serviceException.getMessage());
     }
 
-    public static <T> CommonResult<T> error(GlobalException globalException) {
-        return error(globalException.getCode(), globalException.getMessage());
-    }
-
 }
diff --git a/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/DictConvert.java b/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/DictConvert.java
index a332235d3..2bda65d2b 100644
--- a/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/DictConvert.java
+++ b/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/DictConvert.java
@@ -13,6 +13,11 @@ import com.alibaba.excel.metadata.GlobalConfiguration;
 import com.alibaba.excel.metadata.property.ExcelContentProperty;
 import lombok.extern.slf4j.Slf4j;
 
+/**
+ * Excel {@link SysDictDataDO} 数据字典转换器
+ *
+ * @author 芋道源码
+ */
 @Slf4j
 public class DictConvert implements Converter<Object> {
 
diff --git a/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/JsonConvert.java b/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/JsonConvert.java
new file mode 100644
index 000000000..d099d3c98
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/excel/core/convert/JsonConvert.java
@@ -0,0 +1,39 @@
+package cn.iocoder.dashboard.framework.excel.core.convert;
+
+import cn.iocoder.dashboard.util.json.JsonUtils;
+import com.alibaba.excel.converters.Converter;
+import com.alibaba.excel.enums.CellDataTypeEnum;
+import com.alibaba.excel.metadata.CellData;
+import com.alibaba.excel.metadata.GlobalConfiguration;
+import com.alibaba.excel.metadata.property.ExcelContentProperty;
+
+/**
+ * Excel Json 转换器
+ *
+ * @author 芋道源码
+ */
+public class JsonConvert implements Converter<Object> {
+
+    @Override
+    public Class<?> supportJavaTypeKey() {
+        throw new UnsupportedOperationException("暂不支持,也不需要");
+    }
+
+    @Override
+    public CellDataTypeEnum supportExcelTypeKey() {
+        throw new UnsupportedOperationException("暂不支持,也不需要");
+    }
+
+    @Override
+    public Object convertToJavaData(CellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
+        throw new UnsupportedOperationException("暂不支持,也不需要");
+    }
+
+    @Override
+    public CellData<String> convertToExcelData(Object value, ExcelContentProperty contentProperty,
+                                               GlobalConfiguration globalConfiguration) {
+        // 生成 Excel 小表格
+        return new CellData<>(JsonUtils.toJsonString(value));
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java b/src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java
index 432d5618c..52bef8059 100644
--- a/src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java
+++ b/src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java
@@ -48,8 +48,8 @@ public class RedisConfig {
      * 创建 Redis Pub/Sub 广播消费的容器
      */
     @Bean
-    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory factory,
-                                                                       List<AbstractChannelMessageListener<?>> listeners) {
+    public RedisMessageListenerContainer redisMessageListenerContainer(
+            RedisConnectionFactory factory, List<AbstractChannelMessageListener<?>> listeners) {
         // 创建 RedisMessageListenerContainer 对象
         RedisMessageListenerContainer container = new RedisMessageListenerContainer();
         // 设置 RedisConnection 工厂。
@@ -69,8 +69,8 @@ public class RedisConfig {
      * Redis Stream 的 xreadgroup 命令:https://www.geek-book.com/src/docs/redis/redis/redis.io/commands/xreadgroup.html
      */
     @Bean(initMethod = "start", destroyMethod = "stop")
-    public StreamMessageListenerContainer<String, ObjectRecord<String, String>> redisStreamMessageListenerContainer(RedisTemplate<String, Object> redisTemplate,
-                                                                                                                    List<AbstractStreamMessageListener<?>> listeners) {
+    public StreamMessageListenerContainer<String, ObjectRecord<String, String>> redisStreamMessageListenerContainer(
+            RedisTemplate<String, Object> redisTemplate, List<AbstractStreamMessageListener<?>> listeners) {
         // 第一步,创建 StreamMessageListenerContainer 容器
         // 创建 options 配置
         StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, String>> containerOptions =
diff --git a/src/main/java/cn/iocoder/dashboard/framework/security/config/SecurityConfiguration.java b/src/main/java/cn/iocoder/dashboard/framework/security/config/SecurityConfiguration.java
index bb55e1e71..e20431153 100644
--- a/src/main/java/cn/iocoder/dashboard/framework/security/config/SecurityConfiguration.java
+++ b/src/main/java/cn/iocoder/dashboard/framework/security/config/SecurityConfiguration.java
@@ -128,13 +128,13 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
                 // 设置每个请求的权限
                 .authorizeRequests()
                     // 登陆的接口,可匿名访问
-                    .antMatchers(webProperties.getApiPrefix() + "/login").anonymous()
+                    .antMatchers(api("/login")).anonymous()
                     // 通用的接口,可匿名访问
-                    .antMatchers( webProperties.getApiPrefix() + "/system/captcha/**").anonymous()
+                    .antMatchers(api("/system/captcha/**")).anonymous()
                     // 静态资源,可匿名访问
                     .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
                     // 文件的获取接口,可匿名访问
-                    .antMatchers(webProperties.getApiPrefix() + "/infra/file/get/**").anonymous()
+                    .antMatchers(api("/infra/file/get/**")).anonymous()
                     // Swagger 接口文档
                     .antMatchers("/swagger-ui.html").anonymous()
                     .antMatchers("/swagger-resources/**").anonymous()
@@ -148,13 +148,19 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
                     .antMatchers("/actuator/**").anonymous()
                     // Druid 监控
                     .antMatchers("/druid/**").anonymous()
+                    // 短信回调 API
+                    .antMatchers(api("/system/sms/callback/**")).anonymous()
                     // 除上面外的所有请求全部需要鉴权认证
                     .anyRequest().authenticated()
                 .and()
                 .headers().frameOptions().disable();
-        httpSecurity.logout().logoutUrl(webProperties.getApiPrefix() + "/logout").logoutSuccessHandler(logoutSuccessHandler);
+        httpSecurity.logout().logoutUrl(api("/logout")).logoutSuccessHandler(logoutSuccessHandler);
         // 添加 JWT Filter
         httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
     }
 
+    private String api(String url) {
+        return webProperties.getApiPrefix() + url;
+    }
+
 }
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/config/SmsConfiguration.java b/src/main/java/cn/iocoder/dashboard/framework/sms/config/SmsConfiguration.java
new file mode 100644
index 000000000..e5441c91f
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/config/SmsConfiguration.java
@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.framework.sms.config;
+
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.SmsClientFactoryImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 短信配置类
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class SmsConfiguration {
+
+    @Bean
+    public SmsClientFactory smsClientFactory() {
+        return new SmsClientFactoryImpl();
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClient.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClient.java
new file mode 100644
index 000000000..f706e7983
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClient.java
@@ -0,0 +1,54 @@
+package cn.iocoder.dashboard.framework.sms.core.client;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+
+import java.util.List;
+
+/**
+ * 短信客户端接口
+ *
+ * @author zzf
+ * @date 2021/1/25 14:14
+ */
+public interface SmsClient {
+
+    /**
+     * 获得渠道编号
+     *
+     * @return 渠道编号
+     */
+    Long getId();
+
+    /**
+     * 发送消息
+     *
+     * @param logId 日志编号
+     * @param mobile 手机号
+     * @param apiTemplateId 短信 API 的模板编号
+     * @param templateParams 短信模板参数。通过 List 数组,保证参数的顺序
+     * @return 短信发送结果
+     */
+    SmsCommonResult<SmsSendRespDTO> sendSms(Long logId, String mobile, String apiTemplateId,
+                                            List<KeyValue<String, Object>> templateParams);
+
+    /**
+     * 解析接收短信的接收结果
+     *
+     * @param text 结果
+     * @return 结果内容
+     * @throws Throwable 当解析 text 发生异常时,则会抛出异常
+     */
+    List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) throws Throwable;
+
+    /**
+     * 查询指定的短信模板
+     *
+     * @param apiTemplateId 短信 API 的模板编号
+     * @return 短信模板
+     */
+    SmsCommonResult<SmsTemplateRespDTO> getSmsTemplate(String apiTemplateId);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClientFactory.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClientFactory.java
new file mode 100644
index 000000000..83fb88c24
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsClientFactory.java
@@ -0,0 +1,36 @@
+package cn.iocoder.dashboard.framework.sms.core.client;
+
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+
+/**
+ * 短信客户端工厂接口
+ *
+ * @author zzf
+ * @date 2021/1/28 14:01
+ */
+public interface SmsClientFactory {
+
+    /**
+     * 获得短信 Client
+     *
+     * @param channelId 渠道编号
+     * @return 短信 Client
+     */
+    SmsClient getSmsClient(Long channelId);
+
+    /**
+     * 获得短信 Client
+     *
+     * @param channelCode 渠道编码
+     * @return 短信 Client
+     */
+    SmsClient getSmsClient(String channelCode);
+
+    /**
+     * 创建短信 Client
+     *
+     * @param properties 配置对象
+     */
+    void createOrUpdateSmsClient(SmsChannelProperties properties);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCodeMapping.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCodeMapping.java
new file mode 100644
index 000000000..7b6cc51f2
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCodeMapping.java
@@ -0,0 +1,17 @@
+package cn.iocoder.dashboard.framework.sms.core.client;
+
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
+
+import java.util.function.Function;
+
+/**
+ * 将 API 的错误码,转换为通用的错误码
+ *
+ * @see SmsCommonResult
+ * @see SmsFrameworkErrorCodeConstants
+ *
+ * @author 芋道源码
+ */
+public interface SmsCodeMapping extends Function<String, ErrorCode> {
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCommonResult.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCommonResult.java
new file mode 100644
index 000000000..79ebed3a2
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/SmsCommonResult.java
@@ -0,0 +1,68 @@
+package cn.iocoder.dashboard.framework.sms.core.client;
+
+import cn.hutool.core.exceptions.ExceptionUtil;
+import cn.hutool.core.lang.Assert;
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * 短信的 CommonResult 拓展类
+ *
+ * 考虑到不同的平台,返回的 code 和 msg 是不同的,所以统一额外返回 {@link #apiCode} 和 {@link #apiMsg} 字段
+ *
+ * 另外,一些短信平台(例如说阿里云、腾讯云)会返回一个请求编号,用于排查请求失败的问题,我们设置到 {@link #apiRequestId} 字段
+ *
+ * @author 芋道源码
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SmsCommonResult<T> extends CommonResult<T> {
+
+    /**
+     * API 返回错误码
+     *
+     * 由于第三方的错误码可能是字符串,所以使用 String 类型
+     */
+    private String apiCode;
+    /**
+     * API 返回提示
+     */
+    private String apiMsg;
+
+    /**
+     * API 请求编号
+     */
+    private String apiRequestId;
+
+    private SmsCommonResult() {
+    }
+
+    public static <T> SmsCommonResult<T> build(String apiCode, String apiMsg, String apiRequestId,
+                                               T data, SmsCodeMapping codeMapping) {
+        Assert.notNull(codeMapping, "参数 codeMapping 不能为空");
+        SmsCommonResult<T> result = new SmsCommonResult<T>().setApiCode(apiCode).setApiMsg(apiMsg).setApiRequestId(apiRequestId);
+        result.setData(data);
+        // 翻译错误码
+        if (codeMapping != null) {
+            ErrorCode errorCode = codeMapping.apply(apiCode);
+            if (errorCode == null) {
+                errorCode = SmsFrameworkErrorCodeConstants.SMS_UNKNOWN;
+            }
+            result.setCode(errorCode.getCode()).setMsg(errorCode.getMsg());
+        }
+        return result;
+    }
+
+    public static <T> SmsCommonResult<T> error(Throwable ex) {
+        SmsCommonResult<T> result = new SmsCommonResult<>();
+        result.setCode(SmsFrameworkErrorCodeConstants.EXCEPTION.getCode());
+        result.setMsg(ExceptionUtil.getRootCauseMessage(ex));
+        return result;
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsReceiveRespDTO.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsReceiveRespDTO.java
new file mode 100644
index 000000000..ecfdb045c
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsReceiveRespDTO.java
@@ -0,0 +1,48 @@
+package cn.iocoder.dashboard.framework.sms.core.client.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 消息接收 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SmsReceiveRespDTO {
+
+    /**
+     * 是否接收成功
+     */
+    private Boolean success;
+    /**
+     * API 接收结果的编码
+     */
+    private String errorCode;
+    /**
+     * API 接收结果的说明
+     */
+    private String errorMsg;
+
+    /**
+     * 手机号
+     */
+    private String mobile;
+    /**
+     * 用户接收时间
+     */
+    private Date receiveTime;
+
+    /**
+     * 短信 API 发送返回的序号
+     */
+    private String serialNo;
+    /**
+     * 短信日志编号
+     *
+     * 对应 SysSmsLogDO 的编号
+     */
+    private Long logId;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsSendRespDTO.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsSendRespDTO.java
new file mode 100644
index 000000000..c3f6b51ae
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsSendRespDTO.java
@@ -0,0 +1,18 @@
+package cn.iocoder.dashboard.framework.sms.core.client.dto;
+
+import lombok.Data;
+
+/**
+ * 短信发送 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SmsSendRespDTO {
+
+    /**
+     * 短信 API 发送返回的序号
+     */
+    private String serialNo;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsTemplateRespDTO.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsTemplateRespDTO.java
new file mode 100644
index 000000000..938310e71
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/dto/SmsTemplateRespDTO.java
@@ -0,0 +1,33 @@
+package cn.iocoder.dashboard.framework.sms.core.client.dto;
+
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import lombok.Data;
+
+/**
+ * 短信模板 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SmsTemplateRespDTO {
+
+    /**
+     * 模板编号
+     */
+    private String id;
+    /**
+     * 短信内容
+     */
+    private String content;
+    /**
+     * 审核状态
+     *
+     * 枚举 {@link SmsTemplateAuditStatusEnum}
+     */
+    private Integer auditStatus;
+    /**
+     * 审核未通过的理由
+     */
+    private String auditReason;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/AbstractSmsClient.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/AbstractSmsClient.java
new file mode 100644
index 000000000..068c0db7c
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/AbstractSmsClient.java
@@ -0,0 +1,122 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCodeMapping;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+/**
+ * 短信客户端抽象类
+ *
+ * @author zzf
+ * @date 2021/2/1 9:28
+ */
+@Slf4j
+public abstract class AbstractSmsClient implements SmsClient {
+
+    /**
+     * 短信渠道配置
+     */
+    protected volatile SmsChannelProperties properties;
+    /**
+     * 错误码枚举类
+     */
+    protected final SmsCodeMapping codeMapping;
+
+    /**
+     * 短信客户端有参构造函数
+     *
+     * @param properties 短信配置
+     */
+    public AbstractSmsClient(SmsChannelProperties properties, SmsCodeMapping codeMapping) {
+        this.properties = properties;
+        this.codeMapping = codeMapping;
+    }
+
+    /**
+     * 初始化
+     */
+    public final void init() {
+        doInit();
+        log.info("[init][配置({}) 初始化完成]", properties);
+    }
+
+    public final void refresh(SmsChannelProperties properties) {
+        // 判断是否更新
+        if (properties.equals(this.properties)) {
+            return;
+        }
+        log.info("[refresh][配置({})发生变化,重新初始化]", properties);
+        this.properties = properties;
+        // 初始化
+        this.init();
+    }
+
+    /**
+     * 自定义初始化
+     */
+    protected abstract void doInit();
+
+    @Override
+    public Long getId() {
+        return properties.getId();
+    }
+
+    @Override
+    public final SmsCommonResult<SmsSendRespDTO> sendSms(Long logId, String mobile,
+                                                         String apiTemplateId, List<KeyValue<String, Object>> templateParams) {
+        // 执行短信发送
+        SmsCommonResult<SmsSendRespDTO> result;
+        try {
+            result = doSendSms(logId, mobile, apiTemplateId, templateParams);
+        } catch (Throwable ex) {
+            // 打印异常日志
+            log.error("[sendSms][发送短信异常,sendLogId({}) mobile({}) apiTemplateId({}) templateParams({})]",
+                    logId, mobile, apiTemplateId, templateParams, ex);
+            // 封装返回
+            return SmsCommonResult.error(ex);
+        }
+        return result;
+    }
+
+    protected abstract SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId, String mobile,
+                                                                 String apiTemplateId, List<KeyValue<String, Object>> templateParams)
+            throws Throwable;
+
+    @Override
+    public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) throws Throwable {
+        try {
+            return doParseSmsReceiveStatus(text);
+        } catch (Throwable ex) {
+            log.error("[parseSmsReceiveStatus][text({}) 解析发生异常]", text, ex);
+            throw ex;
+        }
+    }
+
+    protected abstract List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable;
+
+    @Override
+    public SmsCommonResult<SmsTemplateRespDTO> getSmsTemplate(String apiTemplateId) {
+        // 执行短信发送
+        SmsCommonResult<SmsTemplateRespDTO> result;
+        try {
+            result = doGetSmsTemplate(apiTemplateId);
+        } catch (Throwable ex) {
+            // 打印异常日志
+            log.error("[getSmsTemplate][获得短信模板({}) 发生异常]", apiTemplateId, ex);
+            // 封装返回
+            return SmsCommonResult.error(ex);
+        }
+        return result;
+    }
+
+    protected abstract SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) throws Throwable;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/SmsClientFactoryImpl.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/SmsClientFactoryImpl.java
new file mode 100644
index 000000000..44f87d7df
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/SmsClientFactoryImpl.java
@@ -0,0 +1,90 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl;
+
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun.AliyunSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.debug.DebugDingTalkSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian.YunpianSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.Assert;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.Arrays;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * 短信客户端工厂接口
+ *
+ * @author zzf
+ */
+@Validated
+@Slf4j
+public class SmsClientFactoryImpl implements SmsClientFactory {
+
+    /**
+     * 短信客户端 Map
+     * key:渠道编号,使用 {@link SmsChannelProperties#getId()}
+     */
+    private final ConcurrentMap<Long, AbstractSmsClient> channelIdClients = new ConcurrentHashMap<>();
+
+    /**
+     * 短信客户端 Map
+     * key:渠道编码,使用 {@link SmsChannelProperties#getCode()} ()}
+     *
+     * 注意,一些场景下,需要获得某个渠道类型的客户端,所以需要使用它。
+     * 例如说,解析短信接收结果,是相对通用的,不需要使用某个渠道编号的 {@link #channelIdClients}
+     */
+    private final ConcurrentMap<String, AbstractSmsClient> channelCodeClients = new ConcurrentHashMap<>();
+
+    public SmsClientFactoryImpl() {
+        // 初始化 channelCodeClients 集合
+        Arrays.stream(SmsChannelEnum.values()).forEach(channel -> {
+            // 创建一个空的 SmsChannelProperties 对象
+            SmsChannelProperties properties = new SmsChannelProperties().setCode(channel.getCode())
+                    .setApiKey("default").setApiSecret("default");
+            // 创建 Sms 客户端
+            AbstractSmsClient smsClient = createSmsClient(properties);
+            channelCodeClients.put(channel.getCode(), smsClient);
+        });
+    }
+
+    @Override
+    public SmsClient getSmsClient(Long channelId) {
+        return channelIdClients.get(channelId);
+    }
+
+    @Override
+    public SmsClient getSmsClient(String channelCode) {
+        return channelCodeClients.get(channelCode);
+    }
+
+    @Override
+    public void createOrUpdateSmsClient(SmsChannelProperties properties) {
+        AbstractSmsClient client = channelIdClients.get(properties.getId());
+        if (client == null) {
+            client = this.createSmsClient(properties);
+            client.init();
+            channelIdClients.put(client.getId(), client);
+        } else {
+            client.refresh(properties);
+        }
+    }
+
+    private AbstractSmsClient createSmsClient(SmsChannelProperties properties) {
+        SmsChannelEnum channelEnum = SmsChannelEnum.getByCode(properties.getCode());
+        Assert.notNull(channelEnum, String.format("渠道类型(%s) 为空", channelEnum));
+        // 创建客户端
+        switch (channelEnum) {
+            case ALIYUN: return new AliyunSmsClient(properties);
+            case YUN_PIAN: return new YunpianSmsClient(properties);
+            case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties);
+        }
+        // 创建失败,错误日志 + 抛出异常
+        log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties);
+        throw new IllegalArgumentException(String.format("配置(%s) 找不到合适的客户端实现", properties));
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java
new file mode 100644
index 000000000..24683bcb0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java
@@ -0,0 +1,212 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun;
+
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.ReflectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.AbstractSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.util.collection.MapUtils;
+import cn.iocoder.dashboard.util.json.JsonUtils;
+import com.aliyuncs.AcsRequest;
+import com.aliyuncs.AcsResponse;
+import com.aliyuncs.DefaultAcsClient;
+import com.aliyuncs.IAcsClient;
+import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest;
+import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
+import com.aliyuncs.exceptions.ClientException;
+import com.aliyuncs.profile.DefaultProfile;
+import com.aliyuncs.profile.IClientProfile;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+import static cn.iocoder.dashboard.util.date.DateUtils.TIME_ZONE_DEFAULT;
+
+/**
+ * 阿里短信客户端的实现类
+ *
+ * @author zzf
+ * @date 2021/1/25 14:17
+ */
+@Slf4j
+public class AliyunSmsClient extends AbstractSmsClient {
+
+    /**
+     * REGION, 使用杭州
+     */
+    private static final String ENDPOINT = "cn-hangzhou";
+
+    /**
+     * 阿里云客户端
+     */
+    private volatile IAcsClient client;
+
+    public AliyunSmsClient(SmsChannelProperties properties) {
+        super(properties, new AliyunSmsCodeMapping());
+        Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
+        Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
+    }
+
+    @Override
+    protected void doInit() {
+        IClientProfile profile = DefaultProfile.getProfile(ENDPOINT, properties.getApiKey(), properties.getApiSecret());
+        client = new DefaultAcsClient(profile);
+    }
+
+    @Override
+    protected SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId, String mobile,
+                                                        String apiTemplateId, List<KeyValue<String, Object>> templateParams) {
+        // 构建参数
+        SendSmsRequest request = new SendSmsRequest();
+        request.setPhoneNumbers(mobile);
+        request.setSignName(properties.getSignature());
+        request.setTemplateCode(apiTemplateId);
+        request.setTemplateParam(JsonUtils.toJsonString(MapUtils.convertMap(templateParams)));
+        request.setOutId(String.valueOf(sendLogId));
+        // 执行请求
+        return invoke(request, response -> new SmsSendRespDTO().setSerialNo(response.getBizId()));
+    }
+
+    @Override
+    protected List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable {
+        List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
+        return statuses.stream().map(status -> {
+            SmsReceiveRespDTO resp = new SmsReceiveRespDTO();
+            resp.setSuccess(status.getSuccess());
+            resp.setErrorCode(status.getErrCode()).setErrorMsg(status.getErrMsg());
+            resp.setMobile(status.getPhoneNumber()).setReceiveTime(status.getReportTime());
+            resp.setSerialNo(status.getBizId()).setLogId(Long.valueOf(status.getOutId()));
+            return resp;
+        }).collect(Collectors.toList());
+    }
+
+    @Override
+    protected SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) {
+        // 构建参数
+        QuerySmsTemplateRequest request = new QuerySmsTemplateRequest();
+        request.setTemplateCode(apiTemplateId);
+        // 执行请求
+        return invoke(request, response -> {
+            SmsTemplateRespDTO data = new SmsTemplateRespDTO();
+            data.setId(response.getTemplateCode()).setContent(response.getTemplateContent());
+            data.setAuditStatus(convertSmsTemplateAuditStatus(response.getTemplateStatus())).setAuditReason(response.getReason());
+            return data;
+        });
+    }
+
+    @VisibleForTesting
+    Integer convertSmsTemplateAuditStatus(Integer templateStatus) {
+        switch (templateStatus) {
+            case 0: return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
+            case 1: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
+            case 2: return SmsTemplateAuditStatusEnum.FAIL.getStatus();
+            default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus));
+        }
+    }
+
+    @VisibleForTesting
+    <T extends AcsResponse, R> SmsCommonResult<R> invoke(AcsRequest<T> request, Function<T, R> responseConsumer) {
+        try {
+            // 执行发送. 由于阿里云 sms 短信没有统一的 Response,但是有统一的 code、message、requestId 属性,所以只好反射
+            T sendResult = client.getAcsResponse(request);
+            String code = (String) ReflectUtil.getFieldValue(sendResult, "code");
+            String message = (String) ReflectUtil.getFieldValue(sendResult, "message");
+            String requestId = (String) ReflectUtil.getFieldValue(sendResult, "requestId");
+            // 解析结果
+            R data = null;
+            if (Objects.equals(code, "OK")) { // 请求成功的情况下
+                data = responseConsumer.apply(sendResult);
+            }
+            // 拼接结果
+            return SmsCommonResult.build(code, message, requestId, data, codeMapping);
+        } catch (ClientException ex) {
+            return SmsCommonResult.build(ex.getErrCode(), formatResultMsg(ex), ex.getRequestId(), null, codeMapping);
+        }
+    }
+
+    private static String formatResultMsg(ClientException ex) {
+        if (StrUtil.isEmpty(ex.getErrorDescription())) {
+            return ex.getErrMsg();
+        }
+        return ex.getErrMsg() + " => " + ex.getErrorDescription();
+    }
+
+    /**
+     * 短信接收状态
+     *
+     * 参见 https://help.aliyun.com/document_detail/101867.html 文档
+     *
+     * @author 芋道源码
+     */
+    @Data
+    public static class SmsReceiveStatus {
+
+        /**
+         * 手机号
+         */
+        @JsonProperty("phone_number")
+        private String phoneNumber;
+        /**
+         * 发送时间
+         */
+        @JsonProperty("send_time")
+        @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
+        private Date sendTime;
+        /**
+         * 状态报告时间
+         */
+        @JsonProperty("report_time")
+        @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
+        private Date reportTime;
+        /**
+         * 是否接收成功
+         */
+        private Boolean success;
+        /**
+         * 状态报告说明
+         */
+        @JsonProperty("err_msg")
+        private String errMsg;
+        /**
+         * 状态报告编码
+         */
+        @JsonProperty("err_code")
+        private String errCode;
+        /**
+         * 发送序列号
+         */
+        @JsonProperty("biz_id")
+        private String bizId;
+        /**
+         * 用户序列号
+         *
+         * 这里我们传递的是 SysSmsLogDO 的日志编号
+         */
+        @JsonProperty("out_id")
+        private String outId;
+        /**
+         * 短信长度,例如说 1、2、3
+         *
+         * 140 字节算一条短信,短信长度超过 140 字节时会拆分成多条短信发送
+         */
+        @JsonProperty("sms_size")
+        private Integer smsSize;
+
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMapping.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMapping.java
new file mode 100644
index 000000000..6319e257b
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMapping.java
@@ -0,0 +1,43 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun;
+
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCodeMapping;
+
+import static cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.*;
+
+/**
+ * 阿里云的 SmsCodeMapping 实现类
+ *
+ * 参见 https://help.aliyun.com/document_detail/101346.htm 文档
+ *
+ * @author 芋道源码
+ */
+public class AliyunSmsCodeMapping implements SmsCodeMapping {
+
+    @Override
+    public ErrorCode apply(String apiCode) {
+        switch (apiCode) {
+            case "OK": return GlobalErrorCodeConstants.SUCCESS;
+            case "isv.ACCOUNT_NOT_EXISTS":
+            case "isv.ACCOUNT_ABNORMAL":
+            case "MissingAccessKeyId": return SMS_ACCOUNT_INVALID;
+            case "isp.RAM_PERMISSION_DENY": return SMS_PERMISSION_DENY;
+            case "isv.INVALID_JSON_PARAM":
+            case "isv.INVALID_PARAMETERS": return SMS_API_PARAM_ERROR;
+            case "isv.BUSINESS_LIMIT_CONTROL": return SMS_SEND_BUSINESS_LIMIT_CONTROL;
+            case "isv.DAY_LIMIT_CONTROL": return SMS_SEND_DAY_LIMIT_CONTROL;
+            case "isv.SMS_CONTENT_ILLEGAL": return SMS_SEND_CONTENT_INVALID;
+            case "isv.SMS_TEMPLATE_ILLEGAL": return SMS_TEMPLATE_INVALID;
+            case "isv.SMS_SIGNATURE_ILLEGAL":
+            case "isv.SIGN_NAME_ILLEGAL":
+            case "isv.SMS_SIGN_ILLEGAL": return SMS_SIGN_INVALID;
+            case "isv.AMOUNT_NOT_ENOUGH":
+            case "isv.OUT_OF_SERVICE": return SMS_ACCOUNT_MONEY_NOT_ENOUGH;
+            case "isv.MOBILE_NUMBER_ILLEGAL": return SMS_MOBILE_INVALID;
+            case "isv.TEMPLATE_MISSING_PARAMETERS": return SMS_TEMPLATE_PARAM_ERROR;
+        }
+        return SMS_UNKNOWN;
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkCodeMapping.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkCodeMapping.java
new file mode 100644
index 000000000..a2aafb7c0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkCodeMapping.java
@@ -0,0 +1,23 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.debug;
+
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCodeMapping;
+
+import java.util.Objects;
+
+import static cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.SMS_UNKNOWN;
+
+/**
+ * 钉钉的 SmsCodeMapping 实现类
+ *
+ * @author 芋道源码
+ */
+public class DebugDingTalkCodeMapping implements SmsCodeMapping {
+
+    @Override
+    public ErrorCode apply(String apiCode) {
+        return Objects.equals(apiCode, "0") ? GlobalErrorCodeConstants.SUCCESS : SMS_UNKNOWN;
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java
new file mode 100644
index 000000000..9215959b7
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java
@@ -0,0 +1,96 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.debug;
+
+import cn.hutool.core.codec.Base64;
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.crypto.digest.HmacAlgorithm;
+import cn.hutool.http.HttpUtil;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.AbstractSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.util.collection.MapUtils;
+import cn.iocoder.dashboard.util.json.JsonUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 基于钉钉 WebHook 实现的调试的短信客户端实现类
+ *
+ * 考虑到省钱,我们使用钉钉 WebHook 模拟发送短信,方便调试。
+ *
+ * @author 芋道源码
+ */
+public class DebugDingTalkSmsClient extends AbstractSmsClient {
+
+    public DebugDingTalkSmsClient(SmsChannelProperties properties) {
+        super(properties, new DebugDingTalkCodeMapping());
+        Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
+        Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
+    }
+
+    @Override
+    protected void doInit() {
+    }
+
+    @Override
+    protected SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId, String mobile,
+                                                        String apiTemplateId, List<KeyValue<String, Object>> templateParams) throws Throwable {
+        // 构建请求
+        String url = buildUrl("robot/send");
+        Map<String, Object> params = new HashMap<>();
+        params.put("msgtype", "text");
+        String content = String.format("【模拟短信】\n手机号:%s\n短信日志编号:%d\n模板参数:%s",
+                mobile, sendLogId, MapUtils.convertMap(templateParams));
+        params.put("text", MapUtil.builder().put("content", content).build());
+        // 执行请求
+        String responseText = HttpUtil.post(url, JsonUtils.toJsonString(params));
+        // 解析结果
+        Map<?, ?> responseObj = JsonUtils.parseObject(responseText, Map.class);
+        return SmsCommonResult.build(MapUtil.getStr(responseObj, "errcode"), MapUtil.getStr(responseObj, "errorMsg"),
+                null, new SmsSendRespDTO().setSerialNo(StrUtil.uuid()), codeMapping);
+    }
+
+    /**
+     * 构建请求地址
+     *
+     * 参见 https://developers.dingtalk.com/document/app/custom-robot-access/title-nfv-794-g71 文档
+     *
+     * @param path 请求路径
+     * @return 请求地址
+     */
+    @SuppressWarnings("SameParameterValue")
+    private String buildUrl(String path) {
+        // 生成 timestamp
+        long timestamp = System.currentTimeMillis();
+        // 生成 sign
+        String secret = properties.getApiSecret();
+        String stringToSign = timestamp + "\n" + secret;
+        byte[] signData = DigestUtil.hmac(HmacAlgorithm.HmacSHA256, StrUtil.bytes(secret)).digest(stringToSign);
+        String sign = Base64.encode(signData);
+        // 构建最终 URL
+        return String.format("https://oapi.dingtalk.com/%s?access_token=%s&timestamp=%d&sign=%s",
+                path, properties.getApiKey(), timestamp, sign);
+    }
+
+    @Override
+    protected List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable {
+        throw new UnsupportedOperationException("模拟短信客户端,暂时无需解析回调");
+    }
+
+    @Override
+    protected SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) {
+        SmsTemplateRespDTO data = new SmsTemplateRespDTO().setId(apiTemplateId).setContent("")
+                .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason("");
+        return SmsCommonResult.build("0", "success", null, data, codeMapping);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClient.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClient.java
new file mode 100644
index 000000000..cef38a548
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClient.java
@@ -0,0 +1,204 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.core.util.URLUtil;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.impl.AbstractSmsClient;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.util.json.JsonUtils;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
+import com.yunpian.sdk.YunpianClient;
+import com.yunpian.sdk.constant.YunpianConstant;
+import com.yunpian.sdk.model.Result;
+import com.yunpian.sdk.model.Template;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+import static cn.iocoder.dashboard.util.date.DateUtils.TIME_ZONE_DEFAULT;
+
+/**
+ * 云片短信客户端的实现类
+ *
+ * @author zzf
+ * @date 9:48 2021/3/5
+ */
+@Slf4j
+public class YunpianSmsClient extends AbstractSmsClient {
+
+    /**
+     * 云信短信客户端
+     */
+    private volatile YunpianClient client;
+
+    public YunpianSmsClient(SmsChannelProperties properties) {
+        super(properties, new YunpianSmsCodeMapping());
+        Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
+    }
+
+    @Override
+    public void doInit() {
+        YunpianClient oldClient = client;
+        // 初始化新的客户端
+        YunpianClient newClient = new YunpianClient(properties.getApiKey());
+        newClient.init();
+        this.client = newClient;
+        // 销毁老的客户端
+        if (oldClient != null) {
+            oldClient.close();
+        }
+    }
+
+    @Override
+    protected SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId, String mobile,
+                                                        String apiTemplateId, List<KeyValue<String, Object>> templateParams) throws Throwable {
+        return invoke(() -> {
+            Map<String, String> request = new HashMap<>();
+            request.put(YunpianConstant.MOBILE, mobile);
+            request.put(YunpianConstant.TPL_ID, apiTemplateId);
+            request.put(YunpianConstant.TPL_VALUE, formatTplValue(templateParams));
+            request.put(YunpianConstant.UID, String.valueOf(sendLogId));
+            request.put(YunpianConstant.CALLBACK_URL, properties.getCallbackUrl());
+            return client.sms().tpl_single_send(request);
+        }, response -> new SmsSendRespDTO().setSerialNo(String.valueOf(response.getSid())));
+    }
+
+    private static String formatTplValue(List<KeyValue<String, Object>> templateParams) {
+        if (CollUtil.isEmpty(templateParams)) {
+            return "";
+        }
+        // 参考 https://www.yunpian.com/official/document/sms/zh_cn/introduction_demos_encode_sample 格式化
+        StringJoiner joiner = new StringJoiner("&");
+        templateParams.forEach(param -> joiner.add(String.format("#%s#=%s", param.getKey(), URLUtil.encode(String.valueOf(param.getValue())))));
+        return joiner.toString();
+    }
+
+    @Override
+    protected List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable {
+        List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
+        return statuses.stream().map(status -> {
+            SmsReceiveRespDTO resp = new SmsReceiveRespDTO();
+            resp.setSuccess(Objects.equals(status.getReportStatus(), "SUCCESS"));
+            resp.setErrorCode(status.getErrorMsg()).setErrorMsg(status.getErrorDetail());
+            resp.setMobile(status.getMobile()).setReceiveTime(status.getUserReceiveTime());
+            resp.setSerialNo(String.valueOf(status.getSid())).setLogId(status.getUid());
+            return resp;
+        }).collect(Collectors.toList());
+    }
+
+    @Override
+    protected SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) throws Throwable {
+        return invoke(() -> {
+            Map<String, String> request = new HashMap<>();
+            request.put(YunpianConstant.APIKEY, properties.getApiKey());
+            request.put(YunpianConstant.TPL_ID, apiTemplateId);
+            return client.tpl().get(request);
+        }, response -> {
+            Template template = response.get(0);
+            return new SmsTemplateRespDTO().setId(String.valueOf(template.getTpl_id())).setContent(template.getTpl_content())
+                   .setAuditStatus(convertSmsTemplateAuditStatus(template.getCheck_status())).setAuditReason(template.getReason());
+        });
+    }
+
+    @VisibleForTesting
+    Integer convertSmsTemplateAuditStatus(String checkStatus) {
+        switch (checkStatus) {
+            case "CHECKING": return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
+            case "SUCCESS": return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
+            case "FAIL": return SmsTemplateAuditStatusEnum.FAIL.getStatus();
+            default: throw new IllegalArgumentException(String.format("未知审核状态(%s)", checkStatus));
+        }
+    }
+
+    @VisibleForTesting
+    <T, R> SmsCommonResult<R> invoke(Supplier<Result<T>> requestConsumer, Function<T, R> responseConsumer) throws Throwable {
+        // 执行请求
+        Result<T> result = requestConsumer.get();
+        if (result.getThrowable() != null) {
+            throw result.getThrowable();
+        }
+        // 解析结果
+        R data = null;
+        if (result.getData() != null) {
+            data = responseConsumer.apply(result.getData());
+        }
+        // 拼接结果
+        return SmsCommonResult.build(String.valueOf(result.getCode()), formatResultMsg(result), null, data, codeMapping);
+    }
+
+    private static String formatResultMsg(Result<?> sendResult) {
+        if (StrUtil.isEmpty(sendResult.getDetail())) {
+            return sendResult.getMsg();
+        }
+        return sendResult.getMsg() + " => " + sendResult.getDetail();
+    }
+
+    /**
+     * 短信接收状态
+     *
+     * 参见 https://www.yunpian.com/official/document/sms/zh_cn/domestic_push_report 文档
+     *
+     * @author 芋道源码
+     */
+    @Data
+    public static class SmsReceiveStatus {
+
+        /**
+         * 接收状态
+         *
+         * 目前仅有 SUCCESS / FAIL,所以使用 Boolean 接收
+         */
+        @JsonProperty("report_status")
+        private String reportStatus;
+        /**
+         * 接收手机号
+         */
+        private String mobile;
+        /**
+         * 运营商返回的代码,如:"DB:0103"
+         *
+         * 由于不同运营商信息不同,此字段仅供参考;
+         */
+        @JsonProperty("error_msg")
+        private String errorMsg;
+        /**
+         * 运营商反馈代码的中文解释
+         *
+         * 默认不推送此字段,如需推送,请联系客服
+         */
+        @JsonProperty("error_detail")
+        private String errorDetail;
+        /**
+         * 短信编号
+         */
+        private Long sid;
+        /**
+         * 用户自定义 id
+         *
+         * 这里我们传递的是 SysSmsLogDO 的日志编号
+         */
+        private Long uid;
+        /**
+         * 用户接收时间
+         */
+        @JsonProperty("user_receive_time")
+        @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
+        private Date userReceiveTime;
+
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMapping.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMapping.java
new file mode 100644
index 000000000..ef980023d
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMapping.java
@@ -0,0 +1,45 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian;
+
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCodeMapping;
+
+import static cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants.SUCCESS;
+import static cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.*;
+import static com.yunpian.sdk.constant.Code.*;
+
+/**
+ * 云片的 SmsCodeMapping 实现类
+ *
+ * 参见 https://www.yunpian.com/official/document/sms/zh_CN/returnvalue_common 文档
+ *
+ * @author 芋道源码
+ */
+public class YunpianSmsCodeMapping implements SmsCodeMapping {
+
+    @Override
+    public ErrorCode apply(String apiCode) {
+        int code = Integer.parseInt(apiCode);
+        switch (code) {
+            case OK: return SUCCESS;
+            case ARGUMENT_MISSING: return SMS_API_PARAM_ERROR;
+            case BAD_ARGUMENT_FORMAT: return SMS_TEMPLATE_PARAM_ERROR;
+            case TPL_NOT_FOUND:
+            case TPL_NOT_VALID: return SMS_TEMPLATE_INVALID;
+            case MONEY_NOT_ENOUGH: return SMS_ACCOUNT_MONEY_NOT_ENOUGH;
+            case BLACK_WORD: return SMS_SEND_CONTENT_INVALID;
+            case DUP_IN_SHORT_TIME:
+            case TOO_MANY_TIME_IN_5:
+            case DAY_LIMIT_PER_MOBILE:
+            case HOUR_LIMIT_PER_MOBILE: return SMS_SEND_BUSINESS_LIMIT_CONTROL;
+            case BLACK_PHONE_FILTER: return SMS_MOBILE_BLACK;
+            case SIGN_NOT_MATCH:
+            case BAD_SIGN_FORMAT:
+            case SIGN_NOT_VALID: return SMS_SIGN_INVALID;
+            case BAD_API_KEY: return SMS_ACCOUNT_INVALID;
+            case API_NOT_ALLOWED: return SMS_PERMISSION_DENY;
+            case IP_NOT_ALLOWED: return SMS_IP_DENY;
+        }
+        return SMS_UNKNOWN;
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsChannelEnum.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsChannelEnum.java
new file mode 100644
index 000000000..ba2615d3d
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsChannelEnum.java
@@ -0,0 +1,37 @@
+package cn.iocoder.dashboard.framework.sms.core.enums;
+
+import cn.hutool.core.util.ArrayUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 短信渠道枚举
+ *
+ * @author zzf
+ * @date 2021/1/25 10:56
+ */
+@Getter
+@AllArgsConstructor
+public enum SmsChannelEnum {
+
+    DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"),
+    YUN_PIAN("YUN_PIAN", "云片"),
+    ALIYUN("ALIYUN", "阿里云"),
+//    TENCENT("TENCENT", "腾讯云"),
+//    HUA_WEI("HUA_WEI", "华为云"),
+    ;
+
+    /**
+     * 编码
+     */
+    private final String code;
+    /**
+     * 名字
+     */
+    private final String name;
+
+    public static SmsChannelEnum getByCode(String code) {
+        return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values());
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java
new file mode 100644
index 000000000..66653df56
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java
@@ -0,0 +1,47 @@
+package cn.iocoder.dashboard.framework.sms.core.enums;
+
+import cn.iocoder.dashboard.common.exception.ErrorCode;
+
+/**
+ * 短信框架的错误码枚举
+ *
+ * 短信框架,使用 2-001-000-000 段
+ *
+ * @author 芋道源码
+ */
+public interface SmsFrameworkErrorCodeConstants {
+
+    ErrorCode SMS_UNKNOWN = new ErrorCode(2001000000, "未知错误,需要解析");
+
+    // ========== 权限 / 限流等相关 2001000100 ==========
+
+    ErrorCode SMS_PERMISSION_DENY = new ErrorCode(2001000100, "没有发送短信的权限");
+    // 云片:可以配置 IP 白名单,只有在白名单中才可以发送短信
+    ErrorCode SMS_IP_DENY = new ErrorCode(2001000100, "IP 不允许发送短信");
+
+    // 阿里云:将短信发送频率限制在正常的业务限流范围内。默认短信验证码:使用同一签名,对同一个手机号验证码,支持 1 条 / 分钟,5 条 / 小时,累计 10 条 / 天。
+    ErrorCode SMS_SEND_BUSINESS_LIMIT_CONTROL = new ErrorCode(2001000102, "指定手机的发送限流");
+    // 阿里云:已经达到您在控制台设置的短信日发送量限额值。在国内消息设置 > 安全设置,修改发送总量阈值。
+    ErrorCode SMS_SEND_DAY_LIMIT_CONTROL = new ErrorCode(2001000103, "每天的发送限流");
+
+    ErrorCode SMS_SEND_CONTENT_INVALID = new ErrorCode(2001000104, "短信内容有敏感词");
+
+    // ========== 模板相关 2001000200 ==========
+    ErrorCode SMS_TEMPLATE_INVALID = new ErrorCode(2001000200, "短信模板不合法"); // 包括短信模板不存在
+    ErrorCode SMS_TEMPLATE_PARAM_ERROR = new ErrorCode(2001000201, "模板参数不正确");
+
+    // ========== 签名相关 2001000300 ==========
+    ErrorCode SMS_SIGN_INVALID = new ErrorCode(2001000300, "短信签名不可用");
+
+    // ========== 账户相关 2001000400 ==========
+    ErrorCode SMS_ACCOUNT_MONEY_NOT_ENOUGH = new ErrorCode(2001000400, "账户余额不足");
+    ErrorCode SMS_ACCOUNT_INVALID = new ErrorCode(2001000401, "apiKey 不存在");
+
+    // ========== 其它相关 2001000900 开头 ==========
+    ErrorCode SMS_API_PARAM_ERROR = new ErrorCode(2001000900, "请求参数缺失");
+    ErrorCode SMS_MOBILE_INVALID = new ErrorCode(2001000901, "手机格式不正确");
+    ErrorCode SMS_MOBILE_BLACK = new ErrorCode(2001000902, "手机号在黑名单中");
+
+    ErrorCode EXCEPTION = new ErrorCode(2001000999, "调用异常");
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java
new file mode 100644
index 000000000..888b2eeb5
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java
@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.framework.sms.core.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 短信模板的审核状态枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum SmsTemplateAuditStatusEnum {
+
+    CHECKING(1),
+    SUCCESS(2),
+    FAIL(3);
+
+    private final Integer status;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/sms/core/property/SmsChannelProperties.java b/src/main/java/cn/iocoder/dashboard/framework/sms/core/property/SmsChannelProperties.java
new file mode 100644
index 000000000..750f2e7b4
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/framework/sms/core/property/SmsChannelProperties.java
@@ -0,0 +1,52 @@
+package cn.iocoder.dashboard.framework.sms.core.property;
+
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import lombok.Data;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 短信渠道配置类
+ *
+ * @author zzf
+ * @date 2021/1/25 17:01
+ */
+@Data
+@Validated
+public class SmsChannelProperties {
+
+    /**
+     * 渠道编号
+     */
+    @NotNull(message = "短信渠道 ID 不能为空")
+    private Long id;
+    /**
+     * 短信签名
+     */
+    @NotEmpty(message = "短信签名不能为空")
+    private String signature;
+    /**
+     * 渠道编码
+     *
+     * 枚举 {@link SmsChannelEnum}
+     */
+    @NotEmpty(message = "渠道编码不能为空")
+    private String code;
+    /**
+     * 短信 API 的账号
+     */
+    @NotEmpty(message = "短信 API 的账号不能为空")
+    private String apiKey;
+    /**
+     * 短信 API 的秘钥
+     */
+    @NotEmpty(message = "短信 API 的秘钥不能为空")
+    private String apiSecret;
+    /**
+     * 短信发送回调 URL
+     */
+    private String callbackUrl;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/framework/web/core/handler/GlobalExceptionHandler.java b/src/main/java/cn/iocoder/dashboard/framework/web/core/handler/GlobalExceptionHandler.java
index 75704145c..faa01c641 100644
--- a/src/main/java/cn/iocoder/dashboard/framework/web/core/handler/GlobalExceptionHandler.java
+++ b/src/main/java/cn/iocoder/dashboard/framework/web/core/handler/GlobalExceptionHandler.java
@@ -3,7 +3,6 @@ package cn.iocoder.dashboard.framework.web.core.handler;
 import cn.hutool.core.exceptions.ExceptionUtil;
 import cn.hutool.core.map.MapUtil;
 import cn.hutool.extra.servlet.ServletUtil;
-import cn.iocoder.dashboard.common.exception.GlobalException;
 import cn.iocoder.dashboard.common.exception.ServiceException;
 import cn.iocoder.dashboard.common.pojo.CommonResult;
 import cn.iocoder.dashboard.framework.logger.apilog.core.service.ApiErrorLogFrameworkService;
@@ -96,9 +95,6 @@ public class GlobalExceptionHandler {
         if (ex instanceof AccessDeniedException) {
             return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
         }
-        if (ex instanceof GlobalException) {
-            return globalExceptionHandler(request, (GlobalException) ex);
-        }
         return defaultExceptionHandler(request, ex);
     }
 
@@ -222,25 +218,6 @@ public class GlobalExceptionHandler {
         return CommonResult.error(ex.getCode(), ex.getMessage());
     }
 
-    /**
-     * 处理全局异常 ServiceException
-     *
-     * 例如说,Dubbo 请求超时,调用的 Dubbo 服务系统异常
-     */
-    @ExceptionHandler(value = GlobalException.class)
-    public CommonResult<?> globalExceptionHandler(HttpServletRequest req, GlobalException ex) {
-        // 系统异常时,才打印异常日志
-        if (INTERNAL_SERVER_ERROR.getCode().equals(ex.getCode())) {
-            // 插入异常日志
-            this.createExceptionLog(req, ex);
-        // 普通全局异常,打印 info 日志即可
-        } else {
-            log.info("[globalExceptionHandler]", ex);
-        }
-        // 返回 ERROR CommonResult
-        return CommonResult.error(ex);
-    }
-
     /**
      * 处理系统异常,兜底处理所有的一切
      */
@@ -250,7 +227,7 @@ public class GlobalExceptionHandler {
         // 插入异常日志
         this.createExceptionLog(req, ex);
         // 返回 ERROR CommonResult
-        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMessage());
+        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
     }
 
     private void createExceptionLog(HttpServletRequest req, Throwable e) {
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictDataController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictDataController.java
index cca874d7e..6eda1162d 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictDataController.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictDataController.java
@@ -61,7 +61,7 @@ public class SysDictDataController {
     @GetMapping("/list-all-simple")
     @ApiOperation(value = "获得全部字典数据列表", notes = "一般用于管理后台缓存字典数据在本地")
     // 无需添加权限认证,因为前端全局都需要
-    public CommonResult<List<SysDictDataSimpleVO>> getSimpleDictDatas() {
+    public CommonResult<List<SysDictDataSimpleRespVO>> getSimpleDictDatas() {
         List<SysDictDataDO> list = dictDataService.getDictDatas();
         return success(SysDictDataConvert.INSTANCE.convertList(list));
     }
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictTypeController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictTypeController.java
index cc53d7c5b..9e6ce7edb 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictTypeController.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/SysDictTypeController.java
@@ -41,7 +41,7 @@ public class SysDictTypeController {
         return success(dictTypeId);
     }
 
-    @PostMapping("update")
+    @PutMapping("/update")
     @ApiOperation("修改字典类型")
     @PreAuthorize("@ss.hasPermission('system:dict:update')")
     public CommonResult<Boolean> updateDictType(@Valid @RequestBody SysDictTypeUpdateReqVO reqVO) {
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleRespVO.java
similarity index 86%
rename from src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleVO.java
rename to src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleRespVO.java
index afddd5fa9..a9e5aae8a 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleVO.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/dict/vo/data/SysDictDataSimpleRespVO.java
@@ -4,9 +4,9 @@ import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
-@ApiModel("数据字典精简 VO")
+@ApiModel("数据字典精简 Response VO")
 @Data
-public class SysDictDataSimpleVO {
+public class SysDictDataSimpleRespVO {
 
     @ApiModelProperty(value = "字典类型", required = true, example = "gender")
     private String dictType;
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SmsCallbackController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SmsCallbackController.java
new file mode 100644
index 000000000..30d75bca5
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SmsCallbackController.java
@@ -0,0 +1,49 @@
+package cn.iocoder.dashboard.modules.system.controller.sms;
+
+import cn.hutool.core.util.URLUtil;
+import cn.hutool.extra.servlet.ServletUtil;
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+
+import static cn.iocoder.dashboard.common.pojo.CommonResult.success;
+
+@Api(tags = "短信回调")
+@RestController
+@RequestMapping("/system/sms/callback")
+public class SmsCallbackController {
+
+    @Resource
+    private SysSmsService smsService;
+
+    @PostMapping("/sms/yunpian")
+    @ApiOperation(value = "云片短信的回调", notes = "参见 https://www.yunpian.com/official/document/sms/zh_cn/domestic_push_report 文档")
+    @ApiImplicitParam(name = "sms_status", value = "发送状态", required = true, example = "[{具体内容}]", dataTypeClass = Long.class)
+    @OperateLog(enable = false)
+    public String receiveYunpianSmsStatus(@RequestParam("sms_status") String smsStatus) throws Throwable {
+        String text = URLUtil.decode(smsStatus); // decode 解码参数,因为它被 encode
+        smsService.receiveSmsStatus(SmsChannelEnum.YUN_PIAN.getCode(), text);
+        return "SUCCESS"; // 约定返回 SUCCESS 为成功
+    }
+
+    @PostMapping("/sms/aliyun")
+    @ApiOperation(value = "阿里云短信的回调", notes = "参见 https://help.aliyun.com/document_detail/120998.html 文档")
+    @OperateLog(enable = false)
+    public CommonResult<Boolean> receiveAliyunSmsStatus(HttpServletRequest request) throws Throwable {
+        String text = ServletUtil.getBody(request);
+        smsService.receiveSmsStatus(SmsChannelEnum.ALIYUN.getCode(), text);
+        return success(true);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsChannelController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsChannelController.java
new file mode 100644
index 000000000..1e1a916c1
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsChannelController.java
@@ -0,0 +1,80 @@
+package cn.iocoder.dashboard.modules.system.controller.sms;
+
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.*;
+import cn.iocoder.dashboard.modules.system.convert.sms.SysSmsChannelConvert;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsChannelService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+import java.util.Comparator;
+import java.util.List;
+
+import static cn.iocoder.dashboard.common.pojo.CommonResult.success;
+
+@Api(tags = "短信渠道")
+@RestController
+@RequestMapping("system/sms-channel")
+public class SysSmsChannelController {
+
+    @Resource
+    private SysSmsChannelService smsChannelService;
+
+    @PostMapping("/create")
+    @ApiOperation("创建短信渠道")
+    @PreAuthorize("@ss.hasPermission('system:sms-channel:create')")
+    public CommonResult<Long> createSmsChannel(@Valid @RequestBody SysSmsChannelCreateReqVO createReqVO) {
+        return success(smsChannelService.createSmsChannel(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @ApiOperation("更新短信渠道")
+    @PreAuthorize("@ss.hasPermission('system:sms-channel:update')")
+    public CommonResult<Boolean> updateSmsChannel(@Valid @RequestBody SysSmsChannelUpdateReqVO updateReqVO) {
+        smsChannelService.updateSmsChannel(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @ApiOperation("删除短信渠道")
+    @ApiImplicitParam(name = "id", value = "编号", required = true)
+    @PreAuthorize("@ss.hasPermission('system:sms-channel:delete')")
+    public CommonResult<Boolean> deleteSmsChannel(@RequestParam("id") Long id) {
+        smsChannelService.deleteSmsChannel(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @ApiOperation("获得短信渠道")
+    @ApiImplicitParam(name = "id", value = "编号", required = true, example = "1024", dataTypeClass = Long.class)
+    @PreAuthorize("@ss.hasPermission('system:sms-channel:query')")
+    public CommonResult<SysSmsChannelRespVO> getSmsChannel(@RequestParam("id") Long id) {
+        SysSmsChannelDO smsChannel = smsChannelService.getSmsChannel(id);
+        return success(SysSmsChannelConvert.INSTANCE.convert(smsChannel));
+    }
+
+    @GetMapping("/page")
+    @ApiOperation("获得短信渠道分页")
+    @PreAuthorize("@ss.hasPermission('system:sms-channel:query')")
+    public CommonResult<PageResult<SysSmsChannelRespVO>> getSmsChannelPage(@Valid SysSmsChannelPageReqVO pageVO) {
+        PageResult<SysSmsChannelDO> pageResult = smsChannelService.getSmsChannelPage(pageVO);
+        return success(SysSmsChannelConvert.INSTANCE.convertPage(pageResult));
+    }
+
+    @GetMapping("/list-all-simple")
+    @ApiOperation(value = "获得短信渠道精简列表", notes = "包含被禁用的短信渠道")
+    public CommonResult<List<SysSmsChannelSimpleRespVO>> getSimpleSmsChannels() {
+        List<SysSmsChannelDO> list = smsChannelService.getSmsChannelList();
+        // 排序后,返回给前端
+        list.sort(Comparator.comparing(SysSmsChannelDO::getId));
+        return success(SysSmsChannelConvert.INSTANCE.convertList03(list));
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsLogController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsLogController.java
new file mode 100644
index 000000000..1d0a2fed0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsLogController.java
@@ -0,0 +1,60 @@
+package cn.iocoder.dashboard.modules.system.controller.sms;
+
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.excel.core.util.ExcelUtils;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExcelVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogPageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogRespVO;
+import cn.iocoder.dashboard.modules.system.convert.sms.SysSmsLogConvert;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsLogService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+
+import static cn.iocoder.dashboard.common.pojo.CommonResult.success;
+import static cn.iocoder.dashboard.framework.logger.operatelog.core.enums.OperateTypeEnum.EXPORT;
+
+@Api(tags = "短信日志")
+@RestController
+@RequestMapping("/system/sms-log")
+@Validated
+public class SysSmsLogController {
+
+    @Resource
+    private SysSmsLogService smsLogService;
+
+    @GetMapping("/page")
+    @ApiOperation("获得短信日志分页")
+    @PreAuthorize("@ss.hasPermission('system:sms-log:query')")
+    public CommonResult<PageResult<SysSmsLogRespVO>> getSmsLogPage(@Valid SysSmsLogPageReqVO pageVO) {
+        PageResult<SysSmsLogDO> pageResult = smsLogService.getSmsLogPage(pageVO);
+        return success(SysSmsLogConvert.INSTANCE.convertPage(pageResult));
+    }
+
+    @GetMapping("/export-excel")
+    @ApiOperation("导出短信日志 Excel")
+    @PreAuthorize("@ss.hasPermission('system:sms-log:export')")
+    @OperateLog(type = EXPORT)
+    public void exportSmsLogExcel(@Valid SysSmsLogExportReqVO exportReqVO,
+                                  HttpServletResponse response) throws IOException {
+        List<SysSmsLogDO> list = smsLogService.getSmsLogList(exportReqVO);
+        // 导出 Excel
+        List<SysSmsLogExcelVO> datas = SysSmsLogConvert.INSTANCE.convertList02(list);
+        ExcelUtils.write(response, "短信日志.xls", "数据", SysSmsLogExcelVO.class, datas);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.http b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.http
new file mode 100644
index 000000000..d5441d057
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.http
@@ -0,0 +1,12 @@
+### 请求 /menu/list 接口 => 成功
+POST {{baseUrl}}/system/sms-template/send-sms
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+  "code": "test_01",
+  "params": {
+    "key01": "value01",
+    "key02": "value02"
+  }
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.java
new file mode 100644
index 000000000..1c442b71c
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/SysSmsTemplateController.java
@@ -0,0 +1,98 @@
+package cn.iocoder.dashboard.modules.system.controller.sms;
+
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.excel.core.util.ExcelUtils;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.*;
+import cn.iocoder.dashboard.modules.system.convert.sms.SysSmsTemplateConvert;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsService;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsTemplateService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+
+import static cn.iocoder.dashboard.common.pojo.CommonResult.success;
+import static cn.iocoder.dashboard.framework.logger.operatelog.core.enums.OperateTypeEnum.EXPORT;
+
+@Api("短信模板")
+@RestController
+@RequestMapping("/system/sms-template")
+public class SysSmsTemplateController {
+
+    @Resource
+    private SysSmsTemplateService smsTemplateService;
+    @Resource
+    private SysSmsService smsService;
+
+    @PostMapping("/create")
+    @ApiOperation("创建短信模板")
+    @PreAuthorize("@ss.hasPermission('system:sms-template:create')")
+    public CommonResult<Long> createSmsTemplate(@Valid @RequestBody SysSmsTemplateCreateReqVO createReqVO) {
+        return success(smsTemplateService.createSmsTemplate(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @ApiOperation("更新短信模板")
+    @PreAuthorize("@ss.hasPermission('system:sms-template:update')")
+    public CommonResult<Boolean> updateSmsTemplate(@Valid @RequestBody SysSmsTemplateUpdateReqVO updateReqVO) {
+        smsTemplateService.updateSmsTemplate(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @ApiOperation("删除短信模板")
+    @ApiImplicitParam(name = "id", value = "编号", required = true)
+    @PreAuthorize("@ss.hasPermission('system:sms-template:delete')")
+    public CommonResult<Boolean> deleteSmsTemplate(@RequestParam("id") Long id) {
+        smsTemplateService.deleteSmsTemplate(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @ApiOperation("获得短信模板")
+    @ApiImplicitParam(name = "id", value = "编号", required = true, example = "1024", dataTypeClass = Long.class)
+    @PreAuthorize("@ss.hasPermission('system:sms-template:query')")
+    public CommonResult<SysSmsTemplateRespVO> getSmsTemplate(@RequestParam("id") Long id) {
+        SysSmsTemplateDO smsTemplate = smsTemplateService.getSmsTemplate(id);
+        return success(SysSmsTemplateConvert.INSTANCE.convert(smsTemplate));
+    }
+
+    @GetMapping("/page")
+    @ApiOperation("获得短信模板分页")
+    @PreAuthorize("@ss.hasPermission('system:sms-template:query')")
+    public CommonResult<PageResult<SysSmsTemplateRespVO>> getSmsTemplatePage(@Valid SysSmsTemplatePageReqVO pageVO) {
+        PageResult<SysSmsTemplateDO> pageResult = smsTemplateService.getSmsTemplatePage(pageVO);
+        return success(SysSmsTemplateConvert.INSTANCE.convertPage(pageResult));
+    }
+
+    @GetMapping("/export-excel")
+    @ApiOperation("导出短信模板 Excel")
+    @PreAuthorize("@ss.hasPermission('system:sms-template:export')")
+    @OperateLog(type = EXPORT)
+    public void exportSmsTemplateExcel(@Valid SysSmsTemplateExportReqVO exportReqVO,
+                                       HttpServletResponse response) throws IOException {
+        List<SysSmsTemplateDO> list = smsTemplateService.getSmsTemplateList(exportReqVO);
+        // 导出 Excel
+        List<SysSmsTemplateExcelVO> datas = SysSmsTemplateConvert.INSTANCE.convertList02(list);
+        ExcelUtils.write(response, "短信模板.xls", "数据", SysSmsTemplateExcelVO.class, datas);
+    }
+
+    @PostMapping("/send-sms")
+    @ApiOperation("发送短信")
+    @PreAuthorize("@ss.hasPermission('system:sms-template:send-sms')")
+    public CommonResult<Long> sendSms(@Valid @RequestBody SysSmsTemplateSendReqVO sendReqVO) {
+        return success(smsService.sendSingleSms(sendReqVO.getMobile(), null, null,
+                sendReqVO.getTemplateCode(), sendReqVO.getTemplateParams()));
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelBaseVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelBaseVO.java
new file mode 100644
index 000000000..9959b8af0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelBaseVO.java
@@ -0,0 +1,38 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotNull;
+
+/**
+* 短信渠道 Base VO,提供给添加、修改、详细的子 VO 使用
+* 如果子 VO 存在差异的字段,请不要添加到这里,影响 Swagger 文档生成
+*/
+@Data
+public class SysSmsChannelBaseVO {
+
+    @ApiModelProperty(value = "短信签名", required = true, example = "芋道源码")
+    @NotNull(message = "短信签名不能为空")
+    private String signature;
+
+    @ApiModelProperty(value = "启用状态", required = true, example = "1")
+    @NotNull(message = "启用状态不能为空")
+    private Integer status;
+
+    @ApiModelProperty(value = "备注", example = "好吃!")
+    private String remark;
+
+    @ApiModelProperty(value = "短信 API 的账号", required = true, example = "yudao")
+    @NotNull(message = "短信 API 的账号不能为空")
+    private String apiKey;
+
+    @ApiModelProperty(value = "短信 API 的秘钥", example = "yuanma")
+    private String apiSecret;
+
+    @ApiModelProperty(value = "短信发送回调 URL", example = "http://www.iocoder.cn")
+    @URL(message = "回调 URL 格式不正确")
+    private String callbackUrl;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelCreateReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelCreateReqVO.java
new file mode 100644
index 000000000..a21cbb71d
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelCreateReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+
+@ApiModel("短信渠道创建 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsChannelCreateReqVO extends SysSmsChannelBaseVO {
+
+    @ApiModelProperty(value = "渠道编码", required = true, example = "YUN_PIAN", notes = "参见 SmsChannelEnum 枚举类")
+    @NotNull(message = "渠道编码不能为空")
+    private String code;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelPageReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelPageReqVO.java
new file mode 100644
index 000000000..523a6c375
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelPageReqVO.java
@@ -0,0 +1,35 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import cn.iocoder.dashboard.common.pojo.PageParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel("短信渠道分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsChannelPageReqVO extends PageParam {
+
+    @ApiModelProperty(value = "任务状态", example = "1")
+    private Integer status;
+
+    @ApiModelProperty(value = "短信签名", example = "芋道源码", notes = "模糊匹配")
+    private String signature;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始创建时间")
+    private Date beginCreateTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束创建时间")
+    private Date endCreateTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelRespVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelRespVO.java
new file mode 100644
index 000000000..20770689a
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelRespVO.java
@@ -0,0 +1,26 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.Date;
+
+@ApiModel("短信渠道 Response VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsChannelRespVO extends SysSmsChannelBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    private Long id;
+
+    @ApiModelProperty(value = "渠道编码", required = true, example = "YUN_PIAN", notes = "参见 SmsChannelEnum 枚举类")
+    private String code;
+
+    @ApiModelProperty(value = "创建时间", required = true)
+    private Date createTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelSimpleRespVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelSimpleRespVO.java
new file mode 100644
index 000000000..b7d84165c
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelSimpleRespVO.java
@@ -0,0 +1,24 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@ApiModel("短信渠道精简 Response VO")
+@Data
+public class SysSmsChannelSimpleRespVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    @NotNull(message = "编号不能为空")
+    private Long id;
+
+    @ApiModelProperty(value = "短信签名", required = true, example = "芋道源码")
+    @NotNull(message = "短信签名不能为空")
+    private String signature;
+
+    @ApiModelProperty(value = "渠道编码", required = true, example = "YUN_PIAN", notes = "参见 SmsChannelEnum 枚举类")
+    private String code;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelUpdateReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelUpdateReqVO.java
new file mode 100644
index 000000000..66ab79412
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/channel/SysSmsChannelUpdateReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.channel;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+
+@ApiModel("短信渠道更新 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsChannelUpdateReqVO extends SysSmsChannelBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    @NotNull(message = "编号不能为空")
+    private Long id;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExcelVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExcelVO.java
new file mode 100644
index 000000000..6a385feba
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExcelVO.java
@@ -0,0 +1,101 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.log;
+
+import cn.iocoder.dashboard.framework.excel.core.annotations.DictFormat;
+import cn.iocoder.dashboard.framework.excel.core.convert.DictConvert;
+import cn.iocoder.dashboard.framework.excel.core.convert.JsonConvert;
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.Map;
+
+import static cn.iocoder.dashboard.modules.system.enums.dict.SysDictTypeEnum.*;
+
+/**
+ * 短信日志 Excel VO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SysSmsLogExcelVO {
+
+    @ExcelProperty("编号")
+    private Long id;
+
+    @ExcelProperty("短信渠道编号")
+    private Long channelId;
+
+    @ExcelProperty("短信渠道编码")
+    private String channelCode;
+
+    @ExcelProperty("模板编号")
+    private Long templateId;
+
+    @ExcelProperty("模板编码")
+    private String templateCode;
+
+    @ExcelProperty(value = "短信类型", converter = DictConvert.class)
+    @DictFormat(SYS_SMS_TEMPLATE_TYPE)
+    private Integer templateType;
+
+    @ExcelProperty("短信内容")
+    private String templateContent;
+
+    @ExcelProperty(value = "短信参数", converter = JsonConvert.class)
+    private Map<String, Object> templateParams;
+
+    @ExcelProperty("短信 API 的模板编号")
+    private String apiTemplateId;
+
+    @ExcelProperty("手机号")
+    private String mobile;
+
+    @ExcelProperty("用户编号")
+    private Long userId;
+
+    @ExcelProperty(value = "用户类型", converter = DictConvert.class)
+    @DictFormat(USER_TYPE)
+    private Integer userType;
+
+    @ExcelProperty(value = "发送状态", converter = DictConvert.class)
+    @DictFormat(SYS_SMS_SEND_STATUS)
+    private Integer sendStatus;
+
+    @ExcelProperty("发送时间")
+    private Date sendTime;
+
+    @ExcelProperty("发送结果的编码")
+    private Integer sendCode;
+
+    @ExcelProperty("发送结果的提示")
+    private String sendMsg;
+
+    @ExcelProperty("短信 API 发送结果的编码")
+    private String apiSendCode;
+
+    @ExcelProperty("短信 API 发送失败的提示")
+    private String apiSendMsg;
+
+    @ExcelProperty("短信 API 发送返回的唯一请求 ID")
+    private String apiRequestId;
+
+    @ExcelProperty("短信 API 发送返回的序号")
+    private String apiSerialNo;
+
+    @ExcelProperty(value = "接收状态", converter = DictConvert.class)
+    @DictFormat(SYS_SMS_RECEIVE_STATUS)
+    private Integer receiveStatus;
+
+    @ExcelProperty("接收时间")
+    private Date receiveTime;
+
+    @ExcelProperty("API 接收结果的编码")
+    private String apiReceiveCode;
+
+    @ExcelProperty("API 接收结果的说明")
+    private String apiReceiveMsg;
+
+    @ExcelProperty("创建时间")
+    private Date createTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExportReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExportReqVO.java
new file mode 100644
index 000000000..89add180a
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogExportReqVO.java
@@ -0,0 +1,47 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.log;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel(value = "短信日志 Excel 导出 Request VO", description = "参数和 SysSmsLogPageReqVO 是一致的")
+@Data
+public class SysSmsLogExportReqVO {
+
+    @ApiModelProperty(value = "短信渠道编号", example = "10")
+    private Long channelId;
+
+    @ApiModelProperty(value = "模板编号", example = "20")
+    private Long templateId;
+
+    @ApiModelProperty(value = "手机号", example = "15601691300")
+    private String mobile;
+
+    @ApiModelProperty(value = "发送状态", example = "1")
+    private Integer sendStatus;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始发送时间")
+    private Date beginSendTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束发送时间")
+    private Date endSendTime;
+
+    @ApiModelProperty(value = "接收状态", example = "0")
+    private Integer receiveStatus;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始接收时间")
+    private Date beginReceiveTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束接收时间")
+    private Date endReceiveTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogPageReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogPageReqVO.java
new file mode 100644
index 000000000..6573e15a7
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogPageReqVO.java
@@ -0,0 +1,52 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.log;
+
+import cn.iocoder.dashboard.common.pojo.PageParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel("短信日志分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsLogPageReqVO extends PageParam {
+
+    @ApiModelProperty(value = "短信渠道编号", example = "10")
+    private Long channelId;
+
+    @ApiModelProperty(value = "模板编号", example = "20")
+    private Long templateId;
+
+    @ApiModelProperty(value = "手机号", example = "15601691300")
+    private String mobile;
+
+    @ApiModelProperty(value = "发送状态", example = "1",  notes = "参见 SysSmsSendStatusEnum 枚举类")
+    private Integer sendStatus;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始发送时间")
+    private Date beginSendTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束发送时间")
+    private Date endSendTime;
+
+    @ApiModelProperty(value = "接收状态", example = "0", notes = "参见 SysSmsReceiveStatusEnum 枚举类")
+    private Integer receiveStatus;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始接收时间")
+    private Date beginReceiveTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束接收时间")
+    private Date endReceiveTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogRespVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogRespVO.java
new file mode 100644
index 000000000..423a57919
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/log/SysSmsLogRespVO.java
@@ -0,0 +1,89 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.log;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.Map;
+
+@ApiModel("短信日志 Response VO")
+@Data
+public class SysSmsLogRespVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    private Long id;
+
+    @ApiModelProperty(value = "短信渠道编号", required = true, example = "10")
+    private Long channelId;
+
+    @ApiModelProperty(value = "短信渠道编码", required = true, example = "ALIYUN")
+    private String channelCode;
+
+    @ApiModelProperty(value = "模板编号", required = true, example = "20")
+    private Long templateId;
+
+    @ApiModelProperty(value = "模板编码", required = true, example = "test-01")
+    private String templateCode;
+
+    @ApiModelProperty(value = "短信类型", required = true, example = "1")
+    private Integer templateType;
+
+    @ApiModelProperty(value = "短信内容", required = true, example = "你好,你的验证码是 1024")
+    private String templateContent;
+
+    @ApiModelProperty(value = "短信参数", required = true, example = "name,code")
+    private Map<String, Object> templateParams;
+
+    @ApiModelProperty(value = "短信 API 的模板编号", required = true, example = "SMS_207945135")
+    private String apiTemplateId;
+
+    @ApiModelProperty(value = "手机号", required = true, example = "15601691300")
+    private String mobile;
+
+    @ApiModelProperty(value = "用户编号", example = "10")
+    private Long userId;
+
+    @ApiModelProperty(value = "用户类型", example = "1")
+    private Integer userType;
+
+    @ApiModelProperty(value = "发送状态", required = true, example = "1")
+    private Integer sendStatus;
+
+    @ApiModelProperty(value = "发送时间")
+    private Date sendTime;
+
+    @ApiModelProperty(value = "发送结果的编码", example = "0")
+    private Integer sendCode;
+
+    @ApiModelProperty(value = "发送结果的提示", example = "成功")
+    private String sendMsg;
+
+    @ApiModelProperty(value = "短信 API 发送结果的编码", example = "SUCCESS")
+    private String apiSendCode;
+
+    @ApiModelProperty(value = "短信 API 发送失败的提示", example = "成功")
+    private String apiSendMsg;
+
+    @ApiModelProperty(value = "短信 API 发送返回的唯一请求 ID", example = "3837C6D3-B96F-428C-BBB2-86135D4B5B99")
+    private String apiRequestId;
+
+    @ApiModelProperty(value = "短信 API 发送返回的序号", example = "62923244790")
+    private String apiSerialNo;
+
+    @ApiModelProperty(value = "接收状态", required = true, example = "0")
+    private Integer receiveStatus;
+
+    @ApiModelProperty(value = "接收时间")
+    private Date receiveTime;
+
+    @ApiModelProperty(value = "API 接收结果的编码", example = "DELIVRD")
+    private String apiReceiveCode;
+
+    @ApiModelProperty(value = "API 接收结果的说明", example = "用户接收成功")
+    private String apiReceiveMsg;
+
+    @ApiModelProperty(value = "创建时间", required = true)
+    private Date createTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateBaseVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateBaseVO.java
new file mode 100644
index 000000000..584050d55
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateBaseVO.java
@@ -0,0 +1,46 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+* 短信模板 Base VO,提供给添加、修改、详细的子 VO 使用
+* 如果子 VO 存在差异的字段,请不要添加到这里,影响 Swagger 文档生成
+*/
+@Data
+public class SysSmsTemplateBaseVO {
+
+    @ApiModelProperty(value = "短信类型", required = true, example = "1", notes = "参见 SysSmsTemplateTypeEnum 枚举类")
+    @NotNull(message = "短信类型不能为空")
+    private Integer type;
+
+    @ApiModelProperty(value = "开启状态", required = true, example = "1", notes = "参见 CommonStatusEnum 枚举类")
+    @NotNull(message = "开启状态不能为空")
+    private Integer status;
+
+    @ApiModelProperty(value = "模板编码", required = true, example = "test_01")
+    @NotNull(message = "模板编码不能为空")
+    private String code;
+
+    @ApiModelProperty(value = "模板名称", required = true, example = "yudao")
+    @NotNull(message = "模板名称不能为空")
+    private String name;
+
+    @ApiModelProperty(value = "模板内容", required = true, example = "你好,{name}。你长的太{like}啦!")
+    @NotNull(message = "模板内容不能为空")
+    private String content;
+
+    @ApiModelProperty(value = "备注", example = "哈哈哈")
+    private String remark;
+
+    @ApiModelProperty(value = "短信 API 的模板编号", required = true, example = "4383920")
+    @NotNull(message = "短信 API 的模板编号不能为空")
+    private String apiTemplateId;
+
+    @ApiModelProperty(value = "短信渠道编号", required = true, example = "10")
+    @NotNull(message = "短信渠道编号不能为空")
+    private Long channelId;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateCreateReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateCreateReqVO.java
new file mode 100644
index 000000000..8f847556d
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateCreateReqVO.java
@@ -0,0 +1,14 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+@ApiModel("短信模板创建 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsTemplateCreateReqVO extends SysSmsTemplateBaseVO {
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExcelVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExcelVO.java
new file mode 100644
index 000000000..3eef8133b
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExcelVO.java
@@ -0,0 +1,56 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import cn.iocoder.dashboard.framework.excel.core.annotations.DictFormat;
+import cn.iocoder.dashboard.framework.excel.core.convert.DictConvert;
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.modules.system.enums.dict.SysDictTypeEnum.*;
+
+/**
+ * 短信模板 Excel VO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SysSmsTemplateExcelVO {
+
+    @ExcelProperty("编号")
+    private Long id;
+
+    @ExcelProperty(value = "短信签名", converter = DictConvert.class)
+    @DictFormat(SYS_SMS_TEMPLATE_TYPE)
+    private Integer type;
+
+    @ExcelProperty(value = "开启状态", converter = DictConvert.class)
+    @DictFormat(SYS_COMMON_STATUS)
+    private Integer status;
+
+    @ExcelProperty("模板编码")
+    private String code;
+
+    @ExcelProperty("模板名称")
+    private String name;
+
+    @ExcelProperty("模板内容")
+    private String content;
+
+    @ExcelProperty("备注")
+    private String remark;
+
+    @ExcelProperty("短信 API 的模板编号")
+    private String apiTemplateId;
+
+    @ExcelProperty("短信渠道编号")
+    private Long channelId;
+
+    @ExcelProperty(value = "短信渠道编码", converter = DictConvert.class)
+    @DictFormat(SYS_SMS_CHANNEL_CODE)
+    private String channelCode;
+
+    @ExcelProperty("创建时间")
+    private Date createTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExportReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExportReqVO.java
new file mode 100644
index 000000000..34f940253
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateExportReqVO.java
@@ -0,0 +1,42 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel(value = "短信模板 Excel 导出 Request VO", description = "参数和 SysSmsTemplatePageReqVO 是一致的")
+@Data
+public class SysSmsTemplateExportReqVO {
+
+    @ApiModelProperty(value = "短信签名", example = "1")
+    private Integer type;
+
+    @ApiModelProperty(value = "开启状态", example = "1")
+    private Integer status;
+
+    @ApiModelProperty(value = "模板编码", example = "test_01", notes = "模糊匹配")
+    private String code;
+
+    @ApiModelProperty(value = "模板内容", example = "你好,{name}。你长的太{like}啦!", notes = "模糊匹配")
+    private String content;
+
+    @ApiModelProperty(value = "短信 API 的模板编号", example = "4383920", notes = "模糊匹配")
+    private String apiTemplateId;
+
+    @ApiModelProperty(value = "短信渠道编号", example = "10")
+    private Long channelId;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始创建时间")
+    private Date beginCreateTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束创建时间")
+    private Date endCreateTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplatePageReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplatePageReqVO.java
new file mode 100644
index 000000000..b5f1e5bfb
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplatePageReqVO.java
@@ -0,0 +1,47 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import cn.iocoder.dashboard.common.pojo.PageParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.dashboard.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel("短信模板分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsTemplatePageReqVO extends PageParam {
+
+    @ApiModelProperty(value = "短信签名", example = "1")
+    private Integer type;
+
+    @ApiModelProperty(value = "开启状态", example = "1")
+    private Integer status;
+
+    @ApiModelProperty(value = "模板编码", example = "test_01", notes = "模糊匹配")
+    private String code;
+
+    @ApiModelProperty(value = "模板内容", example = "你好,{name}。你长的太{like}啦!", notes = "模糊匹配")
+    private String content;
+
+    @ApiModelProperty(value = "短信 API 的模板编号", example = "4383920", notes = "模糊匹配")
+    private String apiTemplateId;
+
+    @ApiModelProperty(value = "短信渠道编号", example = "10")
+    private Long channelId;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始创建时间")
+    private Date beginCreateTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束创建时间")
+    private Date endCreateTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateRespVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateRespVO.java
new file mode 100644
index 000000000..fda58486f
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateRespVO.java
@@ -0,0 +1,30 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.Date;
+import java.util.List;
+
+@ApiModel("短信模板 Response VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsTemplateRespVO extends SysSmsTemplateBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    private Long id;
+
+    @ApiModelProperty(value = "短信渠道编码", required = true, example = "ALIYUN")
+    private String channelCode;
+
+    @ApiModelProperty(value = "参数数组", example = "name,code")
+    private List<String> params;
+
+    @ApiModelProperty(value = "创建时间", required = true)
+    private Date createTime;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateSendReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateSendReqVO.java
new file mode 100644
index 000000000..2857ee5d0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateSendReqVO.java
@@ -0,0 +1,25 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.Map;
+
+@ApiModel("短信模板的发送 Request VO")
+@Data
+public class SysSmsTemplateSendReqVO {
+
+    @ApiModelProperty(value = "手机号", required = true, example = "15601691300")
+    @NotNull(message = "手机号不能为空")
+    private String mobile;
+
+    @ApiModelProperty(value = "模板编码", required = true, example = "test_01")
+    @NotNull(message = "模板编码不能为空")
+    private String templateCode;
+
+    @ApiModelProperty(value = "模板参数")
+    private Map<String, Object> templateParams;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateUpdateReqVO.java b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateUpdateReqVO.java
new file mode 100644
index 000000000..9b3aba840
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/controller/sms/vo/template/SysSmsTemplateUpdateReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.modules.system.controller.sms.vo.template;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+
+@ApiModel("短信模板更新 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsTemplateUpdateReqVO extends SysSmsTemplateBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
+    @NotNull(message = "编号不能为空")
+    private Long id;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/convert/dict/SysDictDataConvert.java b/src/main/java/cn/iocoder/dashboard/modules/system/convert/dict/SysDictDataConvert.java
index 9c73a8c11..af8fda25e 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/convert/dict/SysDictDataConvert.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/convert/dict/SysDictDataConvert.java
@@ -13,7 +13,7 @@ public interface SysDictDataConvert {
 
     SysDictDataConvert INSTANCE = Mappers.getMapper(SysDictDataConvert.class);
 
-    List<SysDictDataSimpleVO> convertList(List<SysDictDataDO> list);
+    List<SysDictDataSimpleRespVO> convertList(List<SysDictDataDO> list);
 
     SysDictDataRespVO convert(SysDictDataDO bean);
 
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsChannelConvert.java b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsChannelConvert.java
new file mode 100644
index 000000000..f8a0e71e2
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsChannelConvert.java
@@ -0,0 +1,39 @@
+package cn.iocoder.dashboard.modules.system.convert.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelRespVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelSimpleRespVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+import java.util.List;
+
+/**
+ * 短信渠道 Convert
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface SysSmsChannelConvert {
+
+    SysSmsChannelConvert INSTANCE = Mappers.getMapper(SysSmsChannelConvert.class);
+
+    SysSmsChannelDO convert(SysSmsChannelCreateReqVO bean);
+
+    SysSmsChannelDO convert(SysSmsChannelUpdateReqVO bean);
+
+    SysSmsChannelRespVO convert(SysSmsChannelDO bean);
+
+    List<SysSmsChannelRespVO> convertList(List<SysSmsChannelDO> list);
+
+    PageResult<SysSmsChannelRespVO> convertPage(PageResult<SysSmsChannelDO> page);
+
+    List<SmsChannelProperties> convertList02(List<SysSmsChannelDO> list);
+
+    List<SysSmsChannelSimpleRespVO> convertList03(List<SysSmsChannelDO> list);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsLogConvert.java b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsLogConvert.java
new file mode 100644
index 000000000..6cb1f650a
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsLogConvert.java
@@ -0,0 +1,30 @@
+package cn.iocoder.dashboard.modules.system.convert.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExcelVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogRespVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+import java.util.List;
+
+/**
+ * 短信日志 Convert
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface SysSmsLogConvert {
+
+    SysSmsLogConvert INSTANCE = Mappers.getMapper(SysSmsLogConvert.class);
+
+    SysSmsLogRespVO convert(SysSmsLogDO bean);
+
+    List<SysSmsLogRespVO> convertList(List<SysSmsLogDO> list);
+
+    PageResult<SysSmsLogRespVO> convertPage(PageResult<SysSmsLogDO> page);
+
+    List<SysSmsLogExcelVO> convertList02(List<SysSmsLogDO> list);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsTemplateConvert.java b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsTemplateConvert.java
new file mode 100644
index 000000000..5d73771eb
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/convert/sms/SysSmsTemplateConvert.java
@@ -0,0 +1,31 @@
+package cn.iocoder.dashboard.modules.system.convert.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateExcelVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateRespVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+import java.util.List;
+
+@Mapper
+public interface SysSmsTemplateConvert {
+
+    SysSmsTemplateConvert INSTANCE = Mappers.getMapper(SysSmsTemplateConvert.class);
+
+    SysSmsTemplateDO convert(SysSmsTemplateCreateReqVO bean);
+
+    SysSmsTemplateDO convert(SysSmsTemplateUpdateReqVO bean);
+
+    SysSmsTemplateRespVO convert(SysSmsTemplateDO bean);
+
+    List<SysSmsTemplateRespVO> convertList(List<SysSmsTemplateDO> list);
+
+    PageResult<SysSmsTemplateRespVO> convertPage(PageResult<SysSmsTemplateDO> page);
+
+    List<SysSmsTemplateExcelVO> convertList02(List<SysSmsTemplateDO> list);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsChannelDO.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsChannelDO.java
new file mode 100644
index 000000000..7b0b3f072
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsChannelDO.java
@@ -0,0 +1,60 @@
+package cn.iocoder.dashboard.modules.system.dal.dataobject.sms;
+
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * 短信渠道 DO
+ *
+ * @author zzf
+ * @since 2021-01-25
+ */
+@TableName(value = "sys_sms_channel", autoResultMap = true)
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsChannelDO extends BaseDO {
+
+    /**
+     * 渠道编号
+     */
+    private Long id;
+    /**
+     * 短信签名
+     */
+    private String signature;
+    /**
+     * 渠道编码
+     *
+     * 枚举 {@link SmsChannelEnum}
+     */
+    private String code;
+    /**
+     * 启用状态
+     *
+     * 枚举 {@link CommonStatusEnum}
+     */
+    private Integer status;
+    /**
+     * 备注
+     */
+    private String remark;
+    /**
+     * 短信 API 的账号
+     */
+    private String apiKey;
+    /**
+     * 短信 API 的秘钥
+     */
+    private String apiSecret;
+    /**
+     * 短信发送回调 URL
+     */
+    private String callbackUrl;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsLogDO.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsLogDO.java
new file mode 100644
index 000000000..076e18f39
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsLogDO.java
@@ -0,0 +1,173 @@
+package cn.iocoder.dashboard.modules.system.dal.dataobject.sms;
+
+import cn.iocoder.dashboard.common.enums.UserTypeEnum;
+import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsReceiveStatusEnum;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsSendStatusEnum;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.*;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 短信日志 DO
+ *
+ * @author zzf
+ * @since 2021-01-25
+ */
+@TableName(value = "sys_sms_log", autoResultMap = true)
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class SysSmsLogDO extends BaseDO {
+
+    /**
+     * 自增编号
+     */
+    private Long id;
+
+    // ========= 渠道相关字段 =========
+
+    /**
+     * 短信渠道编号
+     *
+     * 关联 {@link SysSmsChannelDO#getId()}
+     */
+    private Long channelId;
+    /**
+     * 短信渠道编码
+     *
+     * 冗余 {@link SysSmsChannelDO#getCode()}
+     */
+    private String channelCode;
+
+    // ========= 模板相关字段 =========
+
+    /**
+     * 模板编号
+     *
+     * 关联 {@link SysSmsTemplateDO#getId()}
+     */
+    private Long templateId;
+    /**
+     * 模板编码
+     *
+     * 冗余 {@link SysSmsTemplateDO#getCode()}
+     */
+    private String templateCode;
+    /**
+     * 短信类型
+     *
+     * 冗余 {@link SysSmsTemplateDO#getType()}
+     */
+    private Integer templateType;
+    /**
+     * 基于 {@link SysSmsTemplateDO#getContent()} 格式化后的内容
+     */
+    private String templateContent;
+    /**
+     * 基于 {@link SysSmsTemplateDO#getParams()} 输入后的参数
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private Map<String, Object> templateParams;
+    /**
+     * 短信 API 的模板编号
+     *
+     * 冗余 {@link SysSmsTemplateDO#getApiTemplateId()}
+     */
+    private String apiTemplateId;
+
+    // ========= 手机相关字段 =========
+
+    /**
+     * 手机号
+     */
+    private String mobile;
+    /**
+     * 用户编号
+     */
+    private Long userId;
+    /**
+     * 用户类型
+     *
+     * 枚举 {@link UserTypeEnum}
+     */
+    private Integer userType;
+
+    // ========= 发送相关字段 =========
+
+    /**
+     * 发送状态
+     *
+     * 枚举 {@link SysSmsSendStatusEnum}
+     */
+    private Integer sendStatus;
+    /**
+     * 发送时间
+     */
+    private Date sendTime;
+    /**
+     * 发送结果的编码
+     *
+     * 枚举 {@link SmsFrameworkErrorCodeConstants}
+     */
+    private Integer sendCode;
+    /**
+     * 发送结果的提示
+     *
+     * 一般情况下,使用 {@link SmsFrameworkErrorCodeConstants}
+     * 异常情况下,通过格式化 Exception 的提示存储
+     */
+    private String sendMsg;
+    /**
+     * 短信 API 发送结果的编码
+     *
+     * 由于第三方的错误码可能是字符串,所以使用 String 类型
+     */
+    private String apiSendCode;
+    /**
+     * 短信 API 发送失败的提示
+     */
+    private String apiSendMsg;
+    /**
+     * 短信 API 发送返回的唯一请求 ID
+     *
+     * 用于和短信 API 进行定位于排错
+     */
+    private String apiRequestId;
+    /**
+     * 短信 API 发送返回的序号
+     *
+     * 用于和短信 API 平台的发送记录关联
+     */
+    private String apiSerialNo;
+
+    // ========= 接收相关字段 =========
+
+    /**
+     * 接收状态
+     *
+     * 枚举 {@link SysSmsReceiveStatusEnum}
+     */
+    private Integer receiveStatus;
+    /**
+     * 接收时间
+     */
+    private Date receiveTime;
+    /**
+     * 短信 API 接收结果的编码
+     */
+    private String apiReceiveCode;
+    /**
+     * 短信 API 接收结果的提示
+     */
+    private String apiReceiveMsg;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsTemplateDO.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsTemplateDO.java
new file mode 100644
index 000000000..9316358df
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/dataobject/sms/SysSmsTemplateDO.java
@@ -0,0 +1,89 @@
+package cn.iocoder.dashboard.modules.system.dal.dataobject.sms;
+
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsTemplateTypeEnum;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.List;
+
+/**
+ * 短信模板 DO
+ *
+ * @author zzf
+ * @since 2021-01-25
+ */
+@TableName(value = "sys_sms_template", autoResultMap = true)
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysSmsTemplateDO extends BaseDO {
+
+    /**
+     * 自增编号
+     */
+    private Long id;
+
+    // ========= 模板相关字段 =========
+
+    /**
+     * 短信类型
+     *
+     * 枚举 {@link SysSmsTemplateTypeEnum}
+     */
+    private Integer type;
+    /**
+     * 启用状态
+     *
+     * 枚举 {@link CommonStatusEnum}
+     */
+    private Integer status;
+    /**
+     * 模板编码,保证唯一
+     */
+    private String code;
+    /**
+     * 模板名称
+     */
+    private String name;
+    /**
+     * 模板内容
+     *
+     * 内容的参数,使用 {} 包括,例如说 {name}
+     */
+    private String content;
+    /**
+     * 参数数组(自动根据内容生成)
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<String> params;
+    /**
+     * 备注
+     */
+    private String remark;
+    /**
+     * 短信 API 的模板编号
+     */
+    private String apiTemplateId;
+
+    // ========= 渠道相关字段 =========
+
+    /**
+     * 短信渠道编号
+     *
+     * 关联 {@link SysSmsChannelDO#getId()}
+     */
+    private Long channelId;
+    /**
+     * 短信渠道编码
+     *
+     * 冗余 {@link SysSmsChannelDO#getCode()}
+     */
+    private String channelCode;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsChannelMapper.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsChannelMapper.java
new file mode 100644
index 000000000..69e329b90
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsChannelMapper.java
@@ -0,0 +1,29 @@
+package cn.iocoder.dashboard.modules.system.dal.mysql.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.dashboard.framework.mybatis.core.query.QueryWrapperX;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelPageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Date;
+import java.util.List;
+
+@Mapper
+public interface SysSmsChannelMapper extends BaseMapperX<SysSmsChannelDO> {
+
+    default PageResult<SysSmsChannelDO> selectPage(SysSmsChannelPageReqVO reqVO) {
+        return selectPage(reqVO, new QueryWrapperX<SysSmsChannelDO>()
+                .likeIfPresent("signature", reqVO.getSignature())
+                .eqIfPresent("status", reqVO.getStatus())
+                .betweenIfPresent("create_time", reqVO.getBeginCreateTime(), reqVO.getEndCreateTime())
+                .orderByDesc("id"));
+    }
+
+    @Select("SELECT id FROM sys_sms_channel WHERE update_time > #{maxUpdateTime} LIMIT 1")
+    Long selectExistsByUpdateTimeAfter(Date maxUpdateTime);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsLogMapper.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsLogMapper.java
new file mode 100644
index 000000000..e3345c835
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsLogMapper.java
@@ -0,0 +1,40 @@
+package cn.iocoder.dashboard.modules.system.dal.mysql.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.dashboard.framework.mybatis.core.query.QueryWrapperX;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogPageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface SysSmsLogMapper extends BaseMapperX<SysSmsLogDO> {
+
+    default PageResult<SysSmsLogDO> selectPage(SysSmsLogPageReqVO reqVO) {
+        return selectPage(reqVO, new QueryWrapperX<SysSmsLogDO>()
+                .eqIfPresent("channel_id", reqVO.getChannelId())
+                .eqIfPresent("template_id", reqVO.getTemplateId())
+                .likeIfPresent("mobile", reqVO.getMobile())
+                .eqIfPresent("send_status", reqVO.getSendStatus())
+                .betweenIfPresent("send_time", reqVO.getBeginSendTime(), reqVO.getEndSendTime())
+                .eqIfPresent("receive_status", reqVO.getReceiveStatus())
+                .betweenIfPresent("receive_time", reqVO.getBeginReceiveTime(), reqVO.getEndReceiveTime())
+                .orderByDesc("id"));
+    }
+
+    default List<SysSmsLogDO> selectList(SysSmsLogExportReqVO reqVO) {
+        return selectList(new QueryWrapperX<SysSmsLogDO>()
+                .eqIfPresent("channel_id", reqVO.getChannelId())
+                .eqIfPresent("template_id", reqVO.getTemplateId())
+                .likeIfPresent("mobile", reqVO.getMobile())
+                .eqIfPresent("send_status", reqVO.getSendStatus())
+                .betweenIfPresent("send_time", reqVO.getBeginSendTime(), reqVO.getEndSendTime())
+                .eqIfPresent("receive_status", reqVO.getReceiveStatus())
+                .betweenIfPresent("receive_time", reqVO.getBeginReceiveTime(), reqVO.getEndReceiveTime())
+                .orderByDesc("id"));
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsTemplateMapper.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsTemplateMapper.java
new file mode 100644
index 000000000..a41e38b2d
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/sms/SysSmsTemplateMapper.java
@@ -0,0 +1,53 @@
+package cn.iocoder.dashboard.modules.system.dal.mysql.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.dashboard.framework.mybatis.core.query.QueryWrapperX;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplatePageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Date;
+import java.util.List;
+
+@Mapper
+public interface SysSmsTemplateMapper extends BaseMapperX<SysSmsTemplateDO> {
+
+    default SysSmsTemplateDO selectByCode(String code) {
+        return selectOne("code", code);
+    }
+
+    default PageResult<SysSmsTemplateDO> selectPage(SysSmsTemplatePageReqVO reqVO) {
+        return selectPage(reqVO, new QueryWrapperX<SysSmsTemplateDO>()
+                .eqIfPresent("type", reqVO.getType())
+                .eqIfPresent("status", reqVO.getStatus())
+                .likeIfPresent("code", reqVO.getCode())
+                .likeIfPresent("content", reqVO.getContent())
+                .likeIfPresent("api_template_id", reqVO.getApiTemplateId())
+                .eqIfPresent("channel_id", reqVO.getChannelId())
+                .betweenIfPresent("create_time", reqVO.getBeginCreateTime(), reqVO.getEndCreateTime())
+                .orderByDesc("id"));
+    }
+
+    default List<SysSmsTemplateDO> selectList(SysSmsTemplateExportReqVO reqVO) {
+        return selectList(new QueryWrapperX<SysSmsTemplateDO>()
+                .eqIfPresent("type", reqVO.getType())
+                .eqIfPresent("status", reqVO.getStatus())
+                .likeIfPresent("code", reqVO.getCode())
+                .likeIfPresent("content", reqVO.getContent())
+                .likeIfPresent("api_template_id", reqVO.getApiTemplateId())
+                .eqIfPresent("channel_id", reqVO.getChannelId())
+                .betweenIfPresent("create_time", reqVO.getBeginCreateTime(), reqVO.getEndCreateTime())
+                .orderByDesc("id"));
+    }
+
+    default Integer selectCountByChannelId(Long channelId) {
+        return selectCount("channel_id", channelId);
+    }
+
+    @Select("SELECT id FROM sys_sms_template WHERE update_time > #{maxUpdateTime} LIMIT 1")
+    Long selectExistsByUpdateTimeAfter(Date maxUpdateTime);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/dal/redis/RedisKeyConstants.java b/src/main/java/cn/iocoder/dashboard/modules/system/dal/redis/RedisKeyConstants.java
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/enums/SysErrorCodeConstants.java b/src/main/java/cn/iocoder/dashboard/modules/system/enums/SysErrorCodeConstants.java
index 0197b6c05..19e08e8a8 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/enums/SysErrorCodeConstants.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/enums/SysErrorCodeConstants.java
@@ -30,8 +30,8 @@ public interface SysErrorCodeConstants {
 
     // ========== 角色模块 1002003000 ==========
     ErrorCode ROLE_NOT_EXISTS = new ErrorCode(1002003000, "角色不存在");
-    ErrorCode ROLE_NAME_DUPLICATE = new ErrorCode(1002003001, "已经存在名为【{}}】的角色");
-    ErrorCode ROLE_CODE_DUPLICATE = new ErrorCode(1002003002, "已经存在编码为【{}}】的角色");
+    ErrorCode ROLE_NAME_DUPLICATE = new ErrorCode(1002003001, "已经存在名为【{}】的角色");
+    ErrorCode ROLE_CODE_DUPLICATE = new ErrorCode(1002003002, "已经存在编码为【{}】的角色");
     ErrorCode ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE = new ErrorCode(1002003004, "不能操作类型为系统内置的角色");
 
     // ========== 用户模块 1002004000 ==========
@@ -78,4 +78,18 @@ public interface SysErrorCodeConstants {
     ErrorCode FILE_UPLOAD_FAILED = new ErrorCode(1002009002, "文件上传失败");
     ErrorCode FILE_IS_EMPTY= new ErrorCode(1002009003, "文件为空");
 
+    // ========== 短信渠道 1002011000 ==========
+    ErrorCode SMS_CHANNEL_NOT_EXISTS = new ErrorCode(1002011000, "短信渠道不存在");
+    ErrorCode SMS_CHANNEL_DISABLE = new ErrorCode(1002011001, "短信渠道不处于开启状态,不允许选择");
+    ErrorCode SMS_CHANNEL_HAS_CHILDREN = new ErrorCode(1002011002, "无法删除,该短信渠道还有短信模板");
+
+    // ========== 短信模板 1002011000 ==========
+    ErrorCode SMS_TEMPLATE_NOT_EXISTS = new ErrorCode(1002011000, "短信模板不存在");
+    ErrorCode SMS_TEMPLATE_CODE_DUPLICATE = new ErrorCode(1002011001, "已经存在编码为【{}】的短信模板");
+
+    // ========== 短信发送 1002012000 ==========
+    ErrorCode SMS_SEND_MOBILE_NOT_EXISTS = new ErrorCode(1002012000, "手机号不存在");
+    ErrorCode SMS_SEND_MOBILE_TEMPLATE_PARAM_MISS = new ErrorCode(1002012001, "模板参数({})缺失");
+
+
 }
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/enums/dict/SysDictTypeEnum.java b/src/main/java/cn/iocoder/dashboard/modules/system/enums/dict/SysDictTypeEnum.java
index c31b3ae5f..bb291ac24 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/enums/dict/SysDictTypeEnum.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/enums/dict/SysDictTypeEnum.java
@@ -18,6 +18,10 @@ public enum SysDictTypeEnum {
     SYS_LOGIN_RESULT("sys_login_result"), // 登陆结果
     SYS_CONFIG_TYPE("sys_config_type"), // 参数配置类型
     SYS_BOOLEAN_STRING("sys_boolean_string"), // Boolean 是否类型
+    SYS_SMS_CHANNEL_CODE("sys_sms_channel_code"), // 短信渠道编码
+    SYS_SMS_TEMPLATE_TYPE("sys_sms_template_type"), // 短信模板类型
+    SYS_SMS_SEND_STATUS("sys_sms_send_status"), // 短信发送状态
+    SYS_SMS_RECEIVE_STATUS("sys_sms_receive_status"), // 短信接收状态
 
     INF_REDIS_TIMEOUT_TYPE("inf_redis_timeout_type"),  // Redis 超时类型
     INF_JOB_STATUS("inf_job_status"), // 定时任务状态的枚举
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsReceiveStatusEnum.java b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsReceiveStatusEnum.java
new file mode 100644
index 000000000..880238822
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsReceiveStatusEnum.java
@@ -0,0 +1,23 @@
+package cn.iocoder.dashboard.modules.system.enums.sms;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 短信的接收状态枚举
+ *
+ * @author 芋道源码
+ * @date 2021/2/1 13:39
+ */
+@Getter
+@AllArgsConstructor
+public enum SysSmsReceiveStatusEnum {
+
+    INIT(0), // 初始化
+    SUCCESS(10), // 接收成功
+    FAILURE(20), // 接收失败
+    ;
+
+    private final int status;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsSendStatusEnum.java b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsSendStatusEnum.java
new file mode 100644
index 000000000..1d505ee02
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsSendStatusEnum.java
@@ -0,0 +1,24 @@
+package cn.iocoder.dashboard.modules.system.enums.sms;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 短信的发送状态枚举
+ *
+ * @author zzf
+ * @date 2021/2/1 13:39
+ */
+@Getter
+@AllArgsConstructor
+public enum SysSmsSendStatusEnum {
+
+    INIT(0), // 初始化
+    SUCCESS(10), // 发送成功
+    FAILURE(20), // 发送失败
+    IGNORE(30), // 忽略,即不发送
+    ;
+
+    private final int status;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsTemplateTypeEnum.java b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsTemplateTypeEnum.java
new file mode 100644
index 000000000..8ff9c49b7
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/enums/sms/SysSmsTemplateTypeEnum.java
@@ -0,0 +1,25 @@
+package cn.iocoder.dashboard.modules.system.enums.sms;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 短信的模板类型枚举
+ *
+ * @author 芋道源码
+ */
+@Getter
+@AllArgsConstructor
+public enum SysSmsTemplateTypeEnum {
+
+    VERIFICATION_CODE(1), // 验证码
+    NOTICE(2), // 通知
+    PROMOTION(3), // 营销
+    ;
+
+    /**
+     * 类型
+     */
+    private final int type;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsChannelRefreshConsumer.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsChannelRefreshConsumer.java
new file mode 100644
index 000000000..6105889cb
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsChannelRefreshConsumer.java
@@ -0,0 +1,29 @@
+package cn.iocoder.dashboard.modules.system.mq.consumer.sms;
+
+import cn.iocoder.dashboard.framework.redis.core.pubsub.AbstractChannelMessageListener;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsChannelRefreshMessage;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsChannelService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 针对 {@link SysSmsChannelRefreshMessage} 的消费者
+ *
+ * @author 芋道源码
+ */
+@Component
+@Slf4j
+public class SysSmsChannelRefreshConsumer extends AbstractChannelMessageListener<SysSmsChannelRefreshMessage> {
+
+    @Resource
+    private SysSmsChannelService smsChannelService;
+
+    @Override
+    public void onMessage(SysSmsChannelRefreshMessage message) {
+        log.info("[onMessage][收到 SmsChannel 刷新消息]");
+        smsChannelService.initSmsClients();
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java
index e3b18ca75..70b167168 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java
@@ -2,16 +2,29 @@ package cn.iocoder.dashboard.modules.system.mq.consumer.sms;
 
 import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener;
 import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
+import javax.annotation.Resource;
+
+/**
+ * 针对 {@link SysSmsSendMessage} 的消费者
+ *
+ * @author zzf
+ * @date 2021/3/9 16:35
+ */
 @Component
 @Slf4j
 public class SysSmsSendConsumer extends AbstractStreamMessageListener<SysSmsSendMessage> {
 
+    @Resource
+    private SysSmsService smsService;
+
     @Override
     public void onMessage(SysSmsSendMessage message) {
         log.info("[onMessage][消息内容({})]", message);
+        smsService.doSendSms(message);
     }
 
 }
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsTemplateRefreshConsumer.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsTemplateRefreshConsumer.java
new file mode 100644
index 000000000..c310c48fa
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsTemplateRefreshConsumer.java
@@ -0,0 +1,29 @@
+package cn.iocoder.dashboard.modules.system.mq.consumer.sms;
+
+import cn.iocoder.dashboard.framework.redis.core.pubsub.AbstractChannelMessageListener;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsTemplateRefreshMessage;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsTemplateService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 针对 {@link SysSmsTemplateRefreshMessage} 的消费者
+ *
+ * @author 芋道源码
+ */
+@Component
+@Slf4j
+public class SysSmsTemplateRefreshConsumer extends AbstractChannelMessageListener<SysSmsTemplateRefreshMessage> {
+
+    @Resource
+    private SysSmsTemplateService smsTemplateService;
+
+    @Override
+    public void onMessage(SysSmsTemplateRefreshMessage message) {
+        log.info("[onMessage][收到 SmsTemplate 刷新消息]");
+        smsTemplateService.initLocalCache();
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsChannelRefreshMessage.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsChannelRefreshMessage.java
new file mode 100644
index 000000000..fa2878720
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsChannelRefreshMessage.java
@@ -0,0 +1,17 @@
+package cn.iocoder.dashboard.modules.system.mq.message.sms;
+
+import cn.iocoder.dashboard.framework.redis.core.pubsub.ChannelMessage;
+import lombok.Data;
+
+/**
+ * 短信渠道的数据刷新 Message
+ */
+@Data
+public class SysSmsChannelRefreshMessage implements ChannelMessage {
+
+    @Override
+    public String getChannel() {
+        return "system.sms-channel.refresh";
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java
index f47b52466..9bb30514a 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java
@@ -1,10 +1,11 @@
 package cn.iocoder.dashboard.modules.system.mq.message.sms;
 
+import cn.iocoder.dashboard.common.core.KeyValue;
 import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage;
 import lombok.Data;
 
 import javax.validation.constraints.NotNull;
-import java.util.Map;
+import java.util.List;
 
 /**
  * 短信发送消息
@@ -14,29 +15,30 @@ import java.util.Map;
 @Data
 public class SysSmsSendMessage implements StreamMessage {
 
+    /**
+     * 短信日志编号
+     */
+    @NotNull(message = "短信日志编号不能为空")
+    private Long logId;
     /**
      * 手机号
      */
     @NotNull(message = "手机号不能为空")
     private String mobile;
     /**
-     * 短信模板编号
+     * 短信渠道编号
      */
-    @NotNull(message = "短信模板编号不能为空")
-    private String templateCode;
+    @NotNull(message = "短信渠道编号不能为空")
+    private Long channelId;
+    /**
+     * 短信 API 的模板编号
+     */
+    @NotNull(message = "短信 API 的模板编号不能为空")
+    private String apiTemplateId;
     /**
      * 短信模板参数
      */
-    private Map<String, Object> templateParams;
-
-    /**
-     * 用户编号,允许空
-     */
-    private Integer userId;
-    /**
-     * 用户类型,允许空
-     */
-    private Integer userType;
+    private List<KeyValue<String, Object>> templateParams;
 
     @Override
     public String getStreamKey() {
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsTemplateRefreshMessage.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsTemplateRefreshMessage.java
new file mode 100644
index 000000000..4925b092a
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsTemplateRefreshMessage.java
@@ -0,0 +1,17 @@
+package cn.iocoder.dashboard.modules.system.mq.message.sms;
+
+import cn.iocoder.dashboard.framework.redis.core.pubsub.ChannelMessage;
+import lombok.Data;
+
+/**
+ * 短信模板的数据刷新 Message
+ */
+@Data
+public class SysSmsTemplateRefreshMessage implements ChannelMessage {
+
+    @Override
+    public String getChannel() {
+        return "system.sms-template.refresh";
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/permission/SysRoleProducer.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/permission/SysRoleProducer.java
index e11945dfe..b398a27a1 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/permission/SysRoleProducer.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/permission/SysRoleProducer.java
@@ -9,6 +9,8 @@ import javax.annotation.Resource;
 
 /**
  * Role 角色相关消息的 Producer
+ *
+ * @author 芋道源码
  */
 @Component
 public class SysRoleProducer {
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/sms/SysSmsProducer.java b/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/sms/SysSmsProducer.java
new file mode 100644
index 000000000..d346ef02e
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/mq/producer/sms/SysSmsProducer.java
@@ -0,0 +1,60 @@
+package cn.iocoder.dashboard.modules.system.mq.producer.sms;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.redis.core.util.RedisMessageUtils;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsChannelRefreshMessage;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsTemplateRefreshMessage;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * Sms 短信相关消息的 Producer
+ *
+ * @author zzf
+ * @date 2021/3/9 16:35
+ */
+@Slf4j
+@Component
+public class SysSmsProducer {
+
+    @Resource
+    private StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 发送 {@link SysSmsSendMessage} 消息
+     *
+     * @param logId 短信日志编号
+     * @param mobile 手机号
+     * @param channelId 渠道编号
+     * @param apiTemplateId 短信模板编号
+     * @param templateParams 短信模板参数
+     */
+    public void sendSmsSendMessage(Long logId, String mobile,
+                                   Long channelId, String apiTemplateId, List<KeyValue<String, Object>> templateParams) {
+        SysSmsSendMessage message = new SysSmsSendMessage().setLogId(logId).setMobile(mobile);
+        message.setChannelId(channelId).setApiTemplateId(apiTemplateId).setTemplateParams(templateParams);
+        RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message);
+    }
+
+    /**
+     * 发送 {@link SysSmsChannelRefreshMessage} 消息
+     */
+    public void sendSmsChannelRefreshMessage() {
+        SysSmsChannelRefreshMessage message = new SysSmsChannelRefreshMessage();
+        RedisMessageUtils.sendChannelMessage(stringRedisTemplate, message);
+    }
+
+    /**
+     * 发送 {@link SysSmsTemplateRefreshMessage} 消息
+     */
+    public void sendSmsTemplateRefreshMessage() {
+        SysSmsTemplateRefreshMessage message = new SysSmsTemplateRefreshMessage();
+        RedisMessageUtils.sendChannelMessage(stringRedisTemplate, message);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/SysPermissionService.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/SysPermissionService.java
index 5390d3294..6bb79c446 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/SysPermissionService.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/SysPermissionService.java
@@ -18,7 +18,7 @@ import java.util.Set;
 public interface SysPermissionService extends SecurityPermissionFrameworkService {
 
     /**
-     * 初始化
+     * 初始化权限的本地缓存
      */
     void initLocalCache();
 
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysMenuServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysMenuServiceImpl.java
index 9ed27d085..ec4ea5a9b 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysMenuServiceImpl.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysMenuServiceImpl.java
@@ -15,6 +15,7 @@ import cn.iocoder.dashboard.modules.system.mq.producer.permission.SysMenuProduce
 import cn.iocoder.dashboard.modules.system.service.permission.SysMenuService;
 import cn.iocoder.dashboard.modules.system.service.permission.SysPermissionService;
 import cn.iocoder.dashboard.util.collection.CollectionUtils;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
@@ -168,10 +169,6 @@ public class SysMenuServiceImpl implements SysMenuService {
      */
     @Transactional(rollbackFor = Exception.class)
     public void deleteMenu(Long menuId) {
-        // 校验更新的菜单是否存在
-        if (menuMapper.selectById(menuId) == null) {
-            throw ServiceExceptionUtil.exception(MENU_NOT_EXISTS);
-        }
         // 校验是否还有子菜单
         if (menuMapper.selectCountByParentId(menuId) > 0) {
             throw ServiceExceptionUtil.exception(MENU_EXISTS_CHILDREN);
@@ -250,7 +247,8 @@ public class SysMenuServiceImpl implements SysMenuService {
      * @param parentId 父菜单编号
      * @param childId 当前菜单编号
      */
-    private void checkParentResource(Long parentId, Long childId) {
+    @VisibleForTesting
+    public void checkParentResource(Long parentId, Long childId) {
         if (parentId == null || MenuIdEnum.ROOT.getId().equals(parentId)) {
             return;
         }
@@ -279,7 +277,8 @@ public class SysMenuServiceImpl implements SysMenuService {
      * @param parentId 父菜单编号
      * @param id 菜单编号
      */
-    private void checkResource(Long parentId, String name, Long id) {
+    @VisibleForTesting
+    public void checkResource(Long parentId, String name, Long id) {
         SysMenuDO menu = menuMapper.selectByParentIdAndName(parentId, name);
         if (menu == null) {
             return;
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysPermissionServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysPermissionServiceImpl.java
index ba2a096b6..9116d3e55 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysPermissionServiceImpl.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysPermissionServiceImpl.java
@@ -3,7 +3,6 @@ package cn.iocoder.dashboard.modules.system.service.permission.impl;
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.util.ArrayUtil;
-import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
 import cn.iocoder.dashboard.framework.security.core.util.SecurityFrameworkUtils;
 import cn.iocoder.dashboard.modules.system.dal.mysql.permission.SysRoleMenuMapper;
 import cn.iocoder.dashboard.modules.system.dal.mysql.permission.SysUserRoleMapper;
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysRoleServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysRoleServiceImpl.java
index e79813636..89e51ad5e 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysRoleServiceImpl.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysRoleServiceImpl.java
@@ -18,6 +18,7 @@ import cn.iocoder.dashboard.modules.system.enums.permission.SysRoleTypeEnum;
 import cn.iocoder.dashboard.modules.system.mq.producer.permission.SysRoleProducer;
 import cn.iocoder.dashboard.modules.system.service.permission.SysPermissionService;
 import cn.iocoder.dashboard.modules.system.service.permission.SysRoleService;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.lang.Nullable;
@@ -58,7 +59,7 @@ public class SysRoleServiceImpl implements SysRoleService {
      */
     private volatile Map<Long, SysRoleDO> roleCache;
     /**
-     * 缓存菜单的最大更新时间,用于后续的增量轮询,判断是否有更新
+     * 缓存角色的最大更新时间,用于后续的增量轮询,判断是否有更新
      */
     private volatile Date maxUpdateTime;
 
@@ -77,7 +78,7 @@ public class SysRoleServiceImpl implements SysRoleService {
     @Override
     @PostConstruct
     public void initLocalCache() {
-        // 获取菜单列表,如果有更新
+        // 获取角色列表,如果有更新
         List<SysRoleDO> roleList = this.loadRoleIfUpdate(maxUpdateTime);
         if (CollUtil.isEmpty(roleList)) {
             return;
@@ -98,23 +99,23 @@ public class SysRoleServiceImpl implements SysRoleService {
     }
 
     /**
-     * 如果菜单发生变化,从数据库中获取最新的全量菜单。
+     * 如果角色发生变化,从数据库中获取最新的全量角色。
      * 如果未发生变化,则返回空
      *
-     * @param maxUpdateTime 当前菜单的最大更新时间
-     * @return 菜单列表
+     * @param maxUpdateTime 当前角色的最大更新时间
+     * @return 角色列表
      */
     private List<SysRoleDO> loadRoleIfUpdate(Date maxUpdateTime) {
         // 第一步,判断是否要更新。
         if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据
-            log.info("[loadRoleIfUpdate][首次加载全量菜单]");
-        } else { // 判断数据库中是否有更新的菜单
+            log.info("[loadRoleIfUpdate][首次加载全量角色]");
+        } else { // 判断数据库中是否有更新的角色
             if (!roleMapper.selectExistsByUpdateTimeAfter(maxUpdateTime)) {
                 return null;
             }
-            log.info("[loadRoleIfUpdate][增量加载全量菜单]");
+            log.info("[loadRoleIfUpdate][增量加载全量角色]");
         }
-        // 第二步,如果有更新,则从数据库加载所有菜单
+        // 第二步,如果有更新,则从数据库加载所有角色
         return roleMapper.selectList();
     }
 
@@ -245,7 +246,8 @@ public class SysRoleServiceImpl implements SysRoleService {
      * @param code 角色额编码
      * @param id 角色编号
      */
-    private void checkDuplicateRole(String name, String code, Long id) {
+    @VisibleForTesting
+    public void checkDuplicateRole(String name, String code, Long id) {
         // 1. 该 name 名字被其它角色所使用
         SysRoleDO role = roleMapper.selectByName(name);
         if (role != null && !role.getId().equals(id)) {
@@ -258,7 +260,7 @@ public class SysRoleServiceImpl implements SysRoleService {
         // 该 code 编码被其它角色所使用
         role = roleMapper.selectByCode(code);
         if (role != null && !role.getId().equals(id)) {
-            throw ServiceExceptionUtil.exception(ROLE_CODE_DUPLICATE, name);
+            throw ServiceExceptionUtil.exception(ROLE_CODE_DUPLICATE, code);
         }
     }
 
@@ -267,7 +269,8 @@ public class SysRoleServiceImpl implements SysRoleService {
      *
      * @param id 角色编号
      */
-    private void checkUpdateRole(Long id) {
+    @VisibleForTesting
+    public void checkUpdateRole(Long id) {
         SysRoleDO roleDO = roleMapper.selectById(id);
         if (roleDO == null) {
             throw ServiceExceptionUtil.exception(ROLE_NOT_EXISTS);
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelService.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelService.java
new file mode 100644
index 000000000..c53d85e05
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelService.java
@@ -0,0 +1,79 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelPageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+
+import javax.validation.Valid;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 短信渠道Service接口
+ *
+ * @author zzf
+ * @date 2021/1/25 9:24
+ */
+public interface SysSmsChannelService {
+
+    /**
+     * 初始化短信客户端
+     */
+    void initSmsClients();
+
+    /**
+     * 创建短信渠道
+     *
+     * @param createReqVO 创建信息
+     * @return 编号
+     */
+    Long createSmsChannel(@Valid SysSmsChannelCreateReqVO createReqVO);
+
+    /**
+     * 更新短信渠道
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateSmsChannel(@Valid SysSmsChannelUpdateReqVO updateReqVO);
+
+    /**
+     * 删除短信渠道
+     *
+     * @param id 编号
+     */
+    void deleteSmsChannel(Long id);
+
+    /**
+     * 获得短信渠道
+     *
+     * @param id 编号
+     * @return 短信渠道
+     */
+    SysSmsChannelDO getSmsChannel(Long id);
+
+    /**
+     * 获得短信渠道列表
+     *
+     * @param ids 编号
+     * @return 短信渠道列表
+     */
+    List<SysSmsChannelDO> getSmsChannelList(Collection<Long> ids);
+
+    /**
+     * 获得所有短信渠道列表
+     *
+     * @return 短信渠道列表
+     */
+    List<SysSmsChannelDO> getSmsChannelList();
+
+    /**
+     * 获得短信渠道分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 短信渠道分页
+     */
+    PageResult<SysSmsChannelDO> getSmsChannelPage(SysSmsChannelPageReqVO pageReqVO);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogService.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogService.java
new file mode 100644
index 000000000..52bb4a624
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogService.java
@@ -0,0 +1,77 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogPageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 短信日志 Service 实现类
+ *
+ * @author zzf
+ * @date 13:48 2021/3/2
+ */
+public interface SysSmsLogService {
+
+    /**
+     * 创建短信日志
+     *
+     * @param mobile 手机号
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param isSend 是否发送
+     * @param template 短信模板
+     * @param templateContent 短信内容
+     * @param templateParams 短信参数
+     * @return 发送日志编号
+     */
+    Long createSmsLog(String mobile, Long userId, Integer userType, Boolean isSend,
+                      SysSmsTemplateDO template, String templateContent, Map<String, Object> templateParams);
+
+    /**
+     * 更新日志的发送结果
+     *
+     * @param id 日志编号
+     * @param sendCode 发送结果的编码
+     * @param sendMsg 发送结果的提示
+     * @param apiSendCode 短信 API 发送结果的编码
+     * @param apiSendMsg 短信 API 发送失败的提示
+     * @param apiRequestId 短信 API 发送返回的唯一请求 ID
+     * @param apiSerialNo 短信 API 发送返回的序号
+     */
+    void updateSmsSendResult(Long id, Integer sendCode, String sendMsg,
+                             String apiSendCode, String apiSendMsg, String apiRequestId, String apiSerialNo);
+
+    /**
+     * 更新日志的接收结果
+     *
+     * @param id 日志编号
+     * @param success 是否接收成功
+     * @param receiveTime 用户接收时间
+     * @param apiReceiveCode API 接收结果的编码
+     * @param apiReceiveMsg API 接收结果的说明
+     */
+    void updateSmsReceiveResult(Long id, Boolean success, Date receiveTime, String apiReceiveCode, String apiReceiveMsg);
+
+    /**
+     * 获得短信日志分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 短信日志分页
+     */
+    PageResult<SysSmsLogDO> getSmsLogPage(SysSmsLogPageReqVO pageReqVO);
+
+    /**
+     * 获得短信日志列表, 用于 Excel 导出
+     *
+     * @param exportReqVO 查询条件
+     * @return 短信日志列表
+     */
+    List<SysSmsLogDO> getSmsLogList(SysSmsLogExportReqVO exportReqVO);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsService.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsService.java
new file mode 100644
index 000000000..f568b11a8
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsService.java
@@ -0,0 +1,62 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 短信Service接口
+ * 只支持异步,因此没有返回值
+ *
+ * @author zzf
+ * @date 2021/1/25 9:24
+ */
+public interface SysSmsService {
+
+    /**
+     * 发送单条短信给用户(管理员)
+     *
+     * 在 mobile 为空时,使用 userId 加载对应管理员的手机号
+     *
+     * @param mobile 手机号
+     * @param userId 用户编号
+     * @param templateCode 短信模板编号
+     * @param templateParams 短信模板参数
+     * @return 发送日志编号
+     */
+    Long sendSingleSmsToAdmin(String mobile, Long userId,
+                              String templateCode, Map<String, Object> templateParams);
+
+    /**
+     * 发送单条短信给用户(会员)
+     *
+     * 在 mobile 为空时,使用 userId 加载对应会员的手机号
+     *
+     * @param mobile 手机号
+     * @param userId 用户编号
+     * @param templateCode 短信模板编号
+     * @param templateParams 短信模板参数
+     * @return 发送日志编号
+     */
+    Long sendSingleSmsToMember(String mobile, Long userId,
+                              String templateCode, Map<String, Object> templateParams);
+
+    Long sendSingleSms(String mobile, Long userId, Integer userType,
+                       String templateCode, Map<String, Object> templateParams);
+
+    void sendBatchSms(List<String> mobiles, List<Long> userIds, Integer userType,
+                      String templateCode, Map<String, Object> templateParams);
+
+    void doSendSms(SysSmsSendMessage message);
+
+    /**
+     * 接收短信的接收结果
+     *
+     * @param channelCode 渠道编码
+     * @param text 结果内容
+     * @throws Throwable 处理失败时,抛出异常
+     */
+    void receiveSmsStatus(String channelCode, String text) throws Throwable;
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateService.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateService.java
new file mode 100644
index 000000000..1af5dae12
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateService.java
@@ -0,0 +1,115 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplatePageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+
+import javax.validation.Valid;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 短信模板 Service 接口
+ *
+ * @author zzf
+ * @date 2021/1/25 9:24
+ */
+public interface SysSmsTemplateService {
+
+    /**
+     * 初始化短信模板的本地缓存
+     */
+    void initLocalCache();
+
+    /**
+     * 获得短信模板
+     *
+     * @param code 模板编码
+     * @return 短信模板
+     */
+    SysSmsTemplateDO getSmsTemplateByCode(String code);
+
+    /**
+     * 获得短信模板,从缓存中
+     *
+     * @param code 模板编码
+     * @return 短信模板
+     */
+    SysSmsTemplateDO getSmsTemplateByCodeFromCache(String code);
+
+    /**
+     * 格式化短信内容
+     *
+     * @param content 短信模板的内容
+     * @param params 内容的参数
+     * @return 格式化后的内容
+     */
+    String formatSmsTemplateContent(String content, Map<String, Object> params);
+
+    /**
+     * 创建短信模板
+     *
+     * @param createReqVO 创建信息
+     * @return 编号
+     */
+    Long createSmsTemplate(@Valid SysSmsTemplateCreateReqVO createReqVO);
+
+    /**
+     * 更新短信模板
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateSmsTemplate(@Valid SysSmsTemplateUpdateReqVO updateReqVO);
+
+    /**
+     * 删除短信模板
+     *
+     * @param id 编号
+     */
+    void deleteSmsTemplate(Long id);
+
+    /**
+     * 获得短信模板
+     *
+     * @param id 编号
+     * @return 短信模板
+     */
+    SysSmsTemplateDO getSmsTemplate(Long id);
+
+    /**
+     * 获得短信模板列表
+     *
+     * @param ids 编号
+     * @return 短信模板列表
+     */
+    List<SysSmsTemplateDO> getSmsTemplateList(Collection<Long> ids);
+
+    /**
+     * 获得短信模板分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 短信模板分页
+     */
+    PageResult<SysSmsTemplateDO> getSmsTemplatePage(SysSmsTemplatePageReqVO pageReqVO);
+
+    /**
+     * 获得短信模板列表, 用于 Excel 导出
+     *
+     * @param exportReqVO 查询条件
+     * @return 短信模板分页
+     */
+    List<SysSmsTemplateDO> getSmsTemplateList(SysSmsTemplateExportReqVO exportReqVO);
+
+    /**
+     * 获得指定短信渠道下的短信模板数量
+     *
+     * @param channelId 短信渠道编号
+     * @return 数量
+     */
+    Integer countByChannelId(Long channelId);
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsChannelServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsChannelServiceImpl.java
new file mode 100644
index 000000000..16ecdecff
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsChannelServiceImpl.java
@@ -0,0 +1,172 @@
+package cn.iocoder.dashboard.modules.system.service.sms.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelPageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.convert.sms.SysSmsChannelConvert;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsChannelMapper;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsChannelService;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsTemplateService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+
+import static cn.iocoder.dashboard.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.SMS_CHANNEL_HAS_CHILDREN;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.SMS_CHANNEL_NOT_EXISTS;
+
+/**
+ * 短信渠道Service实现类
+ *
+ * @author zzf
+ * @date 2021/1/25 9:25
+ */
+@Service
+@Slf4j
+public class SysSmsChannelServiceImpl implements SysSmsChannelService {
+
+    /**
+     * 定时执行 {@link #schedulePeriodicRefresh()} 的周期
+     * 因为已经通过 Redis Pub/Sub 机制,所以频率不需要高
+     */
+    private static final long SCHEDULER_PERIOD = 5 * 60 * 1000L;
+
+    /**
+     * 缓存菜单的最大更新时间,用于后续的增量轮询,判断是否有更新
+     */
+    private volatile Date maxUpdateTime;
+
+    @Resource
+    private SmsClientFactory smsClientFactory;
+
+    @Resource
+    private SysSmsChannelMapper smsChannelMapper;
+
+    @Resource
+    private SysSmsTemplateService smsTemplateService;
+
+    @Resource
+    private SysSmsProducer smsProducer;
+
+    @Override
+    @PostConstruct
+    public void initSmsClients() {
+        // 获取短信渠道,如果有更新
+        List<SysSmsChannelDO> smsChannels = this.loadSmsChannelIfUpdate(maxUpdateTime);
+        if (CollUtil.isEmpty(smsChannels)) {
+            return;
+        }
+
+        // 创建或更新短信 Client
+        List<SmsChannelProperties> propertiesList = SysSmsChannelConvert.INSTANCE.convertList02(smsChannels);
+        propertiesList.forEach(properties -> smsClientFactory.createOrUpdateSmsClient(properties));
+
+        // 写入缓存
+        assert smsChannels.size() > 0; // 断言,避免告警
+        maxUpdateTime = smsChannels.stream().max(Comparator.comparing(BaseDO::getUpdateTime)).get().getUpdateTime();
+        log.info("[initSmsClients][初始化 SmsChannel 数量为 {}]", smsChannels.size());
+    }
+
+    @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
+    public void schedulePeriodicRefresh() {
+        initSmsClients();
+    }
+
+    /**
+     * 如果短信渠道发生变化,从数据库中获取最新的全量短信渠道。
+     * 如果未发生变化,则返回空
+     *
+     * @param maxUpdateTime 当前短信渠道的最大更新时间
+     * @return 短信渠道列表
+     */
+    private List<SysSmsChannelDO> loadSmsChannelIfUpdate(Date maxUpdateTime) {
+        // 第一步,判断是否要更新。
+        if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据
+            log.info("[loadSmsChannelIfUpdate][首次加载全量短信渠道]");
+        } else { // 判断数据库中是否有更新的短信渠道
+            if (smsChannelMapper.selectExistsByUpdateTimeAfter(maxUpdateTime) == null) {
+                return null;
+            }
+            log.info("[loadSmsChannelIfUpdate][增量加载全量短信渠道]");
+        }
+        // 第二步,如果有更新,则从数据库加载所有短信渠道
+        return smsChannelMapper.selectList();
+    }
+
+    @Override
+    public Long createSmsChannel(SysSmsChannelCreateReqVO createReqVO) {
+        // 插入
+        SysSmsChannelDO smsChannel = SysSmsChannelConvert.INSTANCE.convert(createReqVO);
+        smsChannelMapper.insert(smsChannel);
+        // 发送刷新消息
+        smsProducer.sendSmsChannelRefreshMessage();
+        // 返回
+        return smsChannel.getId();
+    }
+
+    @Override
+    public void updateSmsChannel(SysSmsChannelUpdateReqVO updateReqVO) {
+        // 校验存在
+        this.validateSmsChannelExists(updateReqVO.getId());
+        // 更新
+        SysSmsChannelDO updateObj = SysSmsChannelConvert.INSTANCE.convert(updateReqVO);
+        smsChannelMapper.updateById(updateObj);
+        // 发送刷新消息
+        smsProducer.sendSmsChannelRefreshMessage();
+    }
+
+    @Override
+    public void deleteSmsChannel(Long id) {
+        // 校验存在
+        this.validateSmsChannelExists(id);
+        // 校验是否有字典数据
+        if (smsTemplateService.countByChannelId(id) > 0) {
+            throw exception(SMS_CHANNEL_HAS_CHILDREN);
+        }
+        // 删除
+        smsChannelMapper.deleteById(id);
+        // 发送刷新消息
+        smsProducer.sendSmsChannelRefreshMessage();
+    }
+
+    private void validateSmsChannelExists(Long id) {
+        if (smsChannelMapper.selectById(id) == null) {
+            throw exception(SMS_CHANNEL_NOT_EXISTS);
+        }
+    }
+
+    @Override
+    public SysSmsChannelDO getSmsChannel(Long id) {
+        return smsChannelMapper.selectById(id);
+    }
+
+    @Override
+    public List<SysSmsChannelDO> getSmsChannelList(Collection<Long> ids) {
+        return smsChannelMapper.selectBatchIds(ids);
+    }
+
+    @Override
+    public List<SysSmsChannelDO> getSmsChannelList() {
+        return smsChannelMapper.selectList();
+    }
+
+    @Override
+    public PageResult<SysSmsChannelDO> getSmsChannelPage(SysSmsChannelPageReqVO pageReqVO) {
+        return smsChannelMapper.selectPage(pageReqVO);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsLogServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsLogServiceImpl.java
new file mode 100644
index 000000000..3145163c0
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsLogServiceImpl.java
@@ -0,0 +1,86 @@
+package cn.iocoder.dashboard.modules.system.service.sms.impl;
+
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogPageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsLogMapper;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsReceiveStatusEnum;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsSendStatusEnum;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 短信日志 Service 实现类
+ *
+ * @author zzf
+ * @date 2021/1/25 9:25
+ */
+@Slf4j
+@Service
+public class SysSmsLogServiceImpl implements SysSmsLogService {
+
+    @Resource
+    private SysSmsLogMapper smsLogMapper;
+
+    @Override
+    public Long createSmsLog(String mobile, Long userId, Integer userType, Boolean isSend,
+                             SysSmsTemplateDO template, String templateContent, Map<String, Object> templateParams) {
+        SysSmsLogDO.SysSmsLogDOBuilder logBuilder = SysSmsLogDO.builder();
+        // 根据是否要发送,设置状态
+        logBuilder.sendStatus(Objects.equals(isSend, true) ? SysSmsSendStatusEnum.INIT.getStatus()
+                : SysSmsSendStatusEnum.IGNORE.getStatus());
+        // 设置手机相关字段
+        logBuilder.mobile(mobile).userId(userId).userType(userType);
+        // 设置模板相关字段
+        logBuilder.templateId(template.getId()).templateCode(template.getCode()).templateType(template.getType());
+        logBuilder.templateContent(templateContent).templateParams(templateParams).apiTemplateId(template.getApiTemplateId());
+        // 设置渠道相关字段
+        logBuilder.channelId(template.getChannelId()).channelCode(template.getChannelCode());
+        // 设置接收相关字段
+        logBuilder.receiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus());
+
+        // 插入数据库
+        SysSmsLogDO logDO = logBuilder.build();
+        smsLogMapper.insert(logDO);
+        return logDO.getId();
+    }
+
+    @Override
+    public void updateSmsSendResult(Long id, Integer sendCode, String sendMsg,
+                                    String apiSendCode, String apiSendMsg, String apiRequestId, String apiSerialNo) {
+        SysSmsSendStatusEnum sendStatus = CommonResult.isSuccess(sendCode) ? SysSmsSendStatusEnum.SUCCESS
+                : SysSmsSendStatusEnum.FAILURE;
+        smsLogMapper.updateById(SysSmsLogDO.builder().id(id).sendStatus(sendStatus.getStatus()).sendTime(new Date())
+                .sendCode(sendCode).sendMsg(sendMsg).apiSendCode(apiSendCode).apiSendMsg(apiSendMsg)
+                .apiRequestId(apiRequestId).apiSerialNo(apiSerialNo).build());
+    }
+
+    @Override
+    public void updateSmsReceiveResult(Long id, Boolean success, Date receiveTime, String apiReceiveCode, String apiReceiveMsg) {
+        SysSmsReceiveStatusEnum receiveStatus = Objects.equals(success, true) ? SysSmsReceiveStatusEnum.SUCCESS
+                : SysSmsReceiveStatusEnum.FAILURE;
+        smsLogMapper.updateById(SysSmsLogDO.builder().id(id).receiveStatus(receiveStatus.getStatus()).receiveTime(receiveTime)
+                .apiReceiveCode(apiReceiveCode).apiReceiveMsg(apiReceiveMsg).build());
+    }
+
+    @Override
+    public PageResult<SysSmsLogDO> getSmsLogPage(SysSmsLogPageReqVO pageReqVO) {
+        return smsLogMapper.selectPage(pageReqVO);
+    }
+
+    @Override
+    public List<SysSmsLogDO> getSmsLogList(SysSmsLogExportReqVO exportReqVO) {
+        return smsLogMapper.selectList(exportReqVO);
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsServiceImpl.java
new file mode 100644
index 000000000..46534d57f
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsServiceImpl.java
@@ -0,0 +1,171 @@
+package cn.iocoder.dashboard.modules.system.service.sms.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.enums.UserTypeEnum;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.user.SysUserDO;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsLogService;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsService;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsTemplateService;
+import cn.iocoder.dashboard.modules.system.service.user.SysUserService;
+import com.google.common.annotations.VisibleForTesting;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.dashboard.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+
+/**
+ * 短信日志Service实现类
+ *
+ * @author zzf
+ * @date 2021/1/25 9:25
+ */
+@Service
+@Slf4j
+public class SysSmsServiceImpl implements SysSmsService {
+
+    @Resource
+    private SysSmsTemplateService smsTemplateService;
+    @Resource
+    private SysSmsLogService smsLogService;
+    @Resource
+    private SysSmsProducer smsProducer;
+    @Resource
+    private SmsClientFactory smsClientFactory;
+
+    @Resource
+    private SysUserService userService;
+
+    @Override
+    public Long sendSingleSmsToAdmin(String mobile, Long userId, String templateCode, Map<String, Object> templateParams) {
+        // 如果 mobile 为空,则加载用户编号对应的手机号
+        if (StrUtil.isEmpty(mobile)) {
+            SysUserDO user = userService.getUser(userId);
+            if (user != null) {
+                mobile = user.getMobile();
+            }
+        }
+        // 执行发送
+        return this.sendSingleSms(mobile, userId, UserTypeEnum.ADMIN.getValue(), templateCode, templateParams);
+    }
+
+    @Override
+    public Long sendSingleSmsToMember(String mobile, Long userId, String templateCode, Map<String, Object> templateParams) {
+        throw new UnsupportedOperationException("暂时不支持该操作,感兴趣可以实现该功能哟!");
+    }
+
+    @Override
+    public Long sendSingleSms(String mobile, Long userId, Integer userType,
+                              String templateCode, Map<String, Object> templateParams) {
+        // 校验短信模板是否合法
+        SysSmsTemplateDO template = this.checkSmsTemplateValid(templateCode);
+        // 校验手机号码是否存在
+        mobile = this.checkMobile(mobile);
+        // 构建有序的模板参数。为什么放在这个位置,是提前保证模板参数的正确性,而不是到了插入发送日志
+        List<KeyValue<String, Object>> newTemplateParams = this.buildTemplateParams(template, templateParams);
+
+        // 创建发送日志
+        Boolean isSend = CommonStatusEnum.ENABLE.getStatus().equals(template.getStatus()); // 如果模板被禁用,则不发送短信,只记录日志
+        String content = smsTemplateService.formatSmsTemplateContent(template.getContent(), templateParams);
+        Long sendLogId = smsLogService.createSmsLog(mobile, userId, userType, isSend, template, content, templateParams);
+
+        // 发送 MQ 消息,异步执行发送短信
+        if (isSend) {
+            smsProducer.sendSmsSendMessage(sendLogId, mobile, template.getChannelId(), template.getApiTemplateId(), newTemplateParams);
+        }
+        return sendLogId;
+    }
+
+    @Override
+    public void sendBatchSms(List<String> mobiles, List<Long> userIds, Integer userType,
+                             String templateCode, Map<String, Object> templateParams) {
+        throw new UnsupportedOperationException("暂时不支持该操作,感兴趣可以实现该功能哟!");
+    }
+
+    @VisibleForTesting
+    public SysSmsTemplateDO checkSmsTemplateValid(String templateCode) {
+        // 获得短信模板。考虑到效率,从缓存中获取
+        SysSmsTemplateDO template = smsTemplateService.getSmsTemplateByCodeFromCache(templateCode);
+        // 短信模板不存在
+        if (template == null) {
+            throw exception(SMS_TEMPLATE_NOT_EXISTS);
+        }
+        return template;
+    }
+
+    /**
+     * 将参数模板,处理成有序的 KeyValue 数组
+     *
+     * 原因是,部分短信平台并不是使用 key 作为参数,而是数组下标,例如说腾讯云 https://cloud.tencent.com/document/product/382/39023
+     *
+     * @param template 短信模板
+     * @param templateParams 原始参数
+     * @return 处理后的参数
+     */
+    @VisibleForTesting
+    public List<KeyValue<String, Object>> buildTemplateParams(SysSmsTemplateDO template, Map<String, Object> templateParams) {
+        return template.getParams().stream().map(key -> {
+            Object value = templateParams.get(key);
+            if (value == null) {
+                throw exception(SMS_SEND_MOBILE_TEMPLATE_PARAM_MISS, key);
+            }
+            return new KeyValue<>(key, value);
+        }).collect(Collectors.toList());
+    }
+
+    @VisibleForTesting
+    public String checkMobile(String mobile) {
+        if (StrUtil.isEmpty(mobile)) {
+            throw exception(SMS_SEND_MOBILE_NOT_EXISTS);
+        }
+        return mobile;
+    }
+
+    @Override
+    public void doSendSms(SysSmsSendMessage message) {
+        // 获得渠道对应的 SmsClient 客户端
+        SmsClient smsClient = smsClientFactory.getSmsClient(message.getChannelId());
+        Assert.notNull(smsClient, String.format("短信客户端(%d) 不存在", message.getChannelId()));
+        // 发送短信
+        SmsCommonResult<SmsSendRespDTO> sendResult = smsClient.sendSms(message.getLogId(), message.getMobile(),
+                message.getApiTemplateId(), message.getTemplateParams());
+        smsLogService.updateSmsSendResult(message.getLogId(), sendResult.getCode(), sendResult.getMsg(),
+                sendResult.getApiCode(), sendResult.getApiMsg(), sendResult.getApiRequestId(),
+                sendResult.getData() != null ? sendResult.getData().getSerialNo() : null);
+    }
+
+    @Override
+    public void receiveSmsStatus(String channelCode, String text) throws Throwable {
+        // 获得渠道对应的 SmsClient 客户端
+        SmsClient smsClient = smsClientFactory.getSmsClient(channelCode);
+        Assert.notNull(smsClient, String.format("短信客户端(%s) 不存在", channelCode));
+        // 解析内容
+        List<SmsReceiveRespDTO> receiveResults = smsClient.parseSmsReceiveStatus(text);
+        if (CollUtil.isEmpty(receiveResults)) {
+            return;
+        }
+        // 更新短信日志的接收结果. 因为量一般不打,所以先使用 for 循环更新
+        receiveResults.forEach(result -> {
+            smsLogService.updateSmsReceiveResult(result.getLogId(), result.getSuccess(), result.getReceiveTime(),
+                    result.getErrorCode(), result.getErrorCode());
+        });
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsTemplateServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsTemplateServiceImpl.java
new file mode 100644
index 000000000..0a070aefb
--- /dev/null
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/sms/impl/SysSmsTemplateServiceImpl.java
@@ -0,0 +1,275 @@
+package cn.iocoder.dashboard.modules.system.service.sms.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ReUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplatePageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.convert.sms.SysSmsTemplateConvert;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsTemplateMapper;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsChannelService;
+import cn.iocoder.dashboard.modules.system.service.sms.SysSmsTemplateService;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.regex.Pattern;
+
+import static cn.iocoder.dashboard.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+
+/**
+ * 短信模板Service实现类
+ *
+ * @author zzf
+ * @date 2021/1/25 9:25
+ */
+@Service
+@Slf4j
+public class SysSmsTemplateServiceImpl implements SysSmsTemplateService {
+
+    /**
+     * 正则表达式,匹配 {} 中的变量
+     */
+    private static final Pattern PATTERN_PARAMS = Pattern.compile("\\{(.*?)}");
+
+    /**
+     * 定时执行 {@link #schedulePeriodicRefresh()} 的周期
+     * 因为已经通过 Redis Pub/Sub 机制,所以频率不需要高
+     */
+    private static final long SCHEDULER_PERIOD = 5 * 60 * 1000L;
+
+    /**
+     * 短信模板缓存
+     * key:短信模板编码 {@link SysSmsTemplateDO#getCode()}
+     *
+     * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向
+     */
+    private volatile Map<String, SysSmsTemplateDO> smsTemplateCache;
+    /**
+     * 缓存短信模板的最大更新时间,用于后续的增量轮询,判断是否有更新
+     */
+    private volatile Date maxUpdateTime;
+
+    @Resource
+    private SysSmsTemplateMapper smsTemplateMapper;
+
+    @Resource
+    private SysSmsChannelService smsChannelService;
+
+    @Resource
+    private SmsClientFactory smsClientFactory;
+
+    @Resource
+    private SysSmsProducer smsProducer;
+
+    /**
+     * 初始化 {@link #smsTemplateCache} 缓存
+     */
+    @Override
+    @PostConstruct
+    public void initLocalCache() {
+        // 获取短信模板列表,如果有更新
+        List<SysSmsTemplateDO> smsTemplateList = this.loadSmsTemplateIfUpdate(maxUpdateTime);
+        if (CollUtil.isEmpty(smsTemplateList)) {
+            return;
+        }
+
+        // 写入缓存
+        ImmutableMap.Builder<String, SysSmsTemplateDO> builder = ImmutableMap.builder();
+        smsTemplateList.forEach(sysSmsTemplateDO -> builder.put(sysSmsTemplateDO.getCode(), sysSmsTemplateDO));
+        smsTemplateCache = builder.build();
+        assert smsTemplateList.size() > 0; // 断言,避免告警
+        maxUpdateTime = smsTemplateList.stream().max(Comparator.comparing(BaseDO::getUpdateTime)).get().getUpdateTime();
+        log.info("[initLocalCache][初始化 SmsTemplate 数量为 {}]", smsTemplateList.size());
+    }
+
+    @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
+    public void schedulePeriodicRefresh() {
+        initLocalCache();
+    }
+
+    /**
+     * 如果短信模板发生变化,从数据库中获取最新的全量短信模板。
+     * 如果未发生变化,则返回空
+     *
+     * @param maxUpdateTime 当前短信模板的最大更新时间
+     * @return 短信模板列表
+     */
+    private List<SysSmsTemplateDO> loadSmsTemplateIfUpdate(Date maxUpdateTime) {
+        // 第一步,判断是否要更新。
+        if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据
+            log.info("[loadSmsTemplateIfUpdate][首次加载全量短信模板]");
+        } else { // 判断数据库中是否有更新的短信模板
+            if (smsTemplateMapper.selectExistsByUpdateTimeAfter(maxUpdateTime) == null) {
+                return null;
+            }
+            log.info("[loadSmsTemplateIfUpdate][增量加载全量短信模板]");
+        }
+        // 第二步,如果有更新,则从数据库加载所有短信模板
+        return smsTemplateMapper.selectList();
+    }
+
+    @Override
+    public SysSmsTemplateDO getSmsTemplateByCode(String code) {
+        return smsTemplateMapper.selectByCode(code);
+    }
+
+    @Override
+    public SysSmsTemplateDO getSmsTemplateByCodeFromCache(String code) {
+        return smsTemplateCache.get(code);
+    }
+
+    @Override
+    public String formatSmsTemplateContent(String content, Map<String, Object> params) {
+        return StrUtil.format(content, params);
+    }
+
+    @VisibleForTesting
+    public List<String> parseTemplateContentParams(String content) {
+        return ReUtil.findAllGroup1(PATTERN_PARAMS, content);
+    }
+
+    @Override
+    public Long createSmsTemplate(SysSmsTemplateCreateReqVO createReqVO) {
+        // 校验短信渠道
+        SysSmsChannelDO channelDO = checkSmsChannel(createReqVO.getChannelId());
+        // 校验短信编码是否重复
+        checkSmsTemplateCodeDuplicate(null, createReqVO.getCode());
+        // 校验短信模板
+        checkApiTemplate(createReqVO.getChannelId(), createReqVO.getApiTemplateId());
+
+        // 插入
+        SysSmsTemplateDO template = SysSmsTemplateConvert.INSTANCE.convert(createReqVO);
+        template.setParams(parseTemplateContentParams(template.getContent()));
+        template.setChannelCode(channelDO.getCode());
+        smsTemplateMapper.insert(template);
+        // 发送刷新消息
+        smsProducer.sendSmsTemplateRefreshMessage();
+        // 返回
+        return template.getId();
+    }
+
+    @Override
+    public void updateSmsTemplate(SysSmsTemplateUpdateReqVO updateReqVO) {
+        // 校验存在
+        this.validateSmsTemplateExists(updateReqVO.getId());
+        // 校验短信渠道
+        SysSmsChannelDO channelDO = checkSmsChannel(updateReqVO.getChannelId());
+        // 校验短信编码是否重复
+        checkSmsTemplateCodeDuplicate(updateReqVO.getId(), updateReqVO.getCode());
+        // 校验短信模板
+        checkApiTemplate(updateReqVO.getChannelId(), updateReqVO.getApiTemplateId());
+
+        // 更新
+        SysSmsTemplateDO updateObj = SysSmsTemplateConvert.INSTANCE.convert(updateReqVO);
+        updateObj.setParams(parseTemplateContentParams(updateObj.getContent()));
+        updateObj.setChannelCode(channelDO.getCode());
+        smsTemplateMapper.updateById(updateObj);
+        // 发送刷新消息
+        smsProducer.sendSmsTemplateRefreshMessage();
+    }
+
+    @Override
+    public void deleteSmsTemplate(Long id) {
+        // 校验存在
+        this.validateSmsTemplateExists(id);
+        // 更新
+        smsTemplateMapper.deleteById(id);
+        // 发送刷新消息
+        smsProducer.sendSmsTemplateRefreshMessage();
+    }
+
+    private void validateSmsTemplateExists(Long id) {
+        if (smsTemplateMapper.selectById(id) == null) {
+            throw exception(SMS_TEMPLATE_NOT_EXISTS);
+        }
+    }
+
+    @Override
+    public SysSmsTemplateDO getSmsTemplate(Long id) {
+        return smsTemplateMapper.selectById(id);
+    }
+
+    @Override
+    public List<SysSmsTemplateDO> getSmsTemplateList(Collection<Long> ids) {
+        return smsTemplateMapper.selectBatchIds(ids);
+    }
+
+    @Override
+    public PageResult<SysSmsTemplateDO> getSmsTemplatePage(SysSmsTemplatePageReqVO pageReqVO) {
+        return smsTemplateMapper.selectPage(pageReqVO);
+    }
+
+    @Override
+    public List<SysSmsTemplateDO> getSmsTemplateList(SysSmsTemplateExportReqVO exportReqVO) {
+        return smsTemplateMapper.selectList(exportReqVO);
+    }
+
+    @Override
+    public Integer countByChannelId(Long channelId) {
+        return smsTemplateMapper.selectCountByChannelId(channelId);
+    }
+
+    @VisibleForTesting
+    public SysSmsChannelDO checkSmsChannel(Long channelId) {
+        SysSmsChannelDO channelDO = smsChannelService.getSmsChannel(channelId);
+        if (channelDO == null) {
+            throw exception(SMS_CHANNEL_NOT_EXISTS);
+        }
+        if (!Objects.equals(channelDO.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
+            throw exception(SMS_CHANNEL_DISABLE);
+        }
+        return channelDO;
+    }
+
+    @VisibleForTesting
+    public void checkSmsTemplateCodeDuplicate(Long id, String code) {
+        SysSmsTemplateDO template = smsTemplateMapper.selectByCode(code);
+        if (template == null) {
+            return;
+        }
+        // 如果 id 为空,说明不用比较是否为相同 id 的字典类型
+        if (id == null) {
+            throw exception(SMS_TEMPLATE_CODE_DUPLICATE, code);
+        }
+        if (!template.getId().equals(id)) {
+            throw exception(SMS_TEMPLATE_CODE_DUPLICATE, code);
+        }
+    }
+
+    /**
+     * 校验 API 短信平台的模板是否有效
+     *
+     * @param channelId 渠道编号
+     * @param apiTemplateId API 模板编号
+     */
+    @VisibleForTesting
+    public void checkApiTemplate(Long channelId, String apiTemplateId) {
+        // 获得短信模板
+        SmsClient smsClient = smsClientFactory.getSmsClient(channelId);
+        Assert.notNull(smsClient, String.format("短信客户端(%d) 不存在", channelId));
+        SmsCommonResult<SmsTemplateRespDTO> templateResult = smsClient.getSmsTemplate(apiTemplateId);
+        // 校验短信模板是否正确
+        templateResult.checkError();
+    }
+
+}
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/user/SysUserServiceImpl.java b/src/main/java/cn/iocoder/dashboard/modules/system/service/user/SysUserServiceImpl.java
index 31f72aedc..6fefbbdb0 100644
--- a/src/main/java/cn/iocoder/dashboard/modules/system/service/user/SysUserServiceImpl.java
+++ b/src/main/java/cn/iocoder/dashboard/modules/system/service/user/SysUserServiceImpl.java
@@ -358,7 +358,7 @@ public class SysUserServiceImpl implements SysUserService {
             }
             // 如果存在,判断是否允许更新
             if (!isUpdateSupport) {
-                respVO.getFailureUsernames().put(importUser.getUsername(), USER_USERNAME_EXISTS.getMessage());
+                respVO.getFailureUsernames().put(importUser.getUsername(), USER_USERNAME_EXISTS.getMsg());
                 return;
             }
             SysUserDO updateUser = SysUserConvert.INSTANCE.convert(importUser);
diff --git a/src/main/java/cn/iocoder/dashboard/util/collection/CollectionUtils.java b/src/main/java/cn/iocoder/dashboard/util/collection/CollectionUtils.java
index 186bf1201..ccd075a96 100644
--- a/src/main/java/cn/iocoder/dashboard/util/collection/CollectionUtils.java
+++ b/src/main/java/cn/iocoder/dashboard/util/collection/CollectionUtils.java
@@ -4,8 +4,10 @@ import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.collection.CollectionUtil;
 
 import java.util.*;
+import java.util.function.BinaryOperator;
 import java.util.function.Function;
 import java.util.function.Predicate;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 /**
@@ -30,6 +32,20 @@ public class CollectionUtils {
         return from.stream().filter(predicate).collect(Collectors.toList());
     }
 
+    public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper) {
+        if (CollUtil.isEmpty(from)) {
+            return new ArrayList<>();
+        }
+        return distinct(from, keyMapper, (t1, t2) -> t1);
+    }
+
+    public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper, BinaryOperator<T> cover) {
+        if (CollUtil.isEmpty(from)) {
+            return new ArrayList<>();
+        }
+        return new ArrayList<>(convertMap(from, keyMapper, Function.identity(), cover).values());
+    }
+
     public static <T, U> List<U> convertList(Collection<T> from, Function<T, U> func) {
         if (CollUtil.isEmpty(from)) {
             return new ArrayList<>();
@@ -48,30 +64,57 @@ public class CollectionUtils {
         if (CollUtil.isEmpty(from)) {
             return new HashMap<>();
         }
-        return from.stream().collect(Collectors.toMap(keyFunc, item -> item));
+        return convertMap(from, keyFunc, Function.identity());
+    }
+
+    public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc, Supplier<? extends Map<K, T>> supplier) {
+        if (CollUtil.isEmpty(from)) {
+            return supplier.get();
+        }
+        return convertMap(from, keyFunc, Function.identity(), supplier);
     }
 
     public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) {
         if (CollUtil.isEmpty(from)) {
             return new HashMap<>();
         }
-        return from.stream().collect(Collectors.toMap(keyFunc, valueFunc));
+        return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1);
+    }
+
+    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction) {
+        if (CollUtil.isEmpty(from)) {
+            return new HashMap<>();
+        }
+        return convertMap(from, keyFunc, valueFunc, mergeFunction, HashMap::new);
+    }
+
+    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, Supplier<? extends Map<K, V>> supplier) {
+        if (CollUtil.isEmpty(from)) {
+            return supplier.get();
+        }
+        return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1, supplier);
+    }
+
+    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction, Supplier<? extends Map<K, V>> supplier) {
+        if (CollUtil.isEmpty(from)) {
+            return new HashMap<>();
+        }
+        return from.stream().collect(Collectors.toMap(keyFunc, valueFunc, mergeFunction, supplier));
     }
 
     public static <T, K> Map<K, List<T>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc) {
         if (CollUtil.isEmpty(from)) {
             return new HashMap<>();
         }
-        return from.stream().collect(Collectors.groupingBy(keyFunc,
-                Collectors.mapping(t -> t, Collectors.toList())));
+        return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(t -> t, Collectors.toList())));
     }
 
     public static <T, K, V> Map<K, List<V>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) {
         if (CollUtil.isEmpty(from)) {
             return new HashMap<>();
         }
-        return from.stream().collect(Collectors.groupingBy(keyFunc,
-                Collectors.mapping(valueFunc, Collectors.toList())));
+        return from.stream()
+                   .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList())));
     }
 
     // 暂时没想好名字,先以 2 结尾噶
diff --git a/src/main/java/cn/iocoder/dashboard/util/collection/MapUtils.java b/src/main/java/cn/iocoder/dashboard/util/collection/MapUtils.java
index ebe29648f..ce5805db8 100644
--- a/src/main/java/cn/iocoder/dashboard/util/collection/MapUtils.java
+++ b/src/main/java/cn/iocoder/dashboard/util/collection/MapUtils.java
@@ -2,6 +2,8 @@ package cn.iocoder.dashboard.util.collection;
 
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.collection.CollectionUtil;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 
 import java.util.ArrayList;
@@ -55,4 +57,10 @@ public class MapUtils {
         consumer.accept(value);
     }
 
+    public static <K, V> Map<K, V> convertMap(List<KeyValue<K, V>> keyValues) {
+        Map<K, V> map = Maps.newLinkedHashMapWithExpectedSize(keyValues.size());
+        keyValues.forEach(keyValue -> map.put(keyValue.getKey(), keyValue.getValue()));
+        return map;
+    }
+
 }
diff --git a/src/main/java/cn/iocoder/dashboard/util/date/DateUtils.java b/src/main/java/cn/iocoder/dashboard/util/date/DateUtils.java
index 3ae810982..78ec48392 100644
--- a/src/main/java/cn/iocoder/dashboard/util/date/DateUtils.java
+++ b/src/main/java/cn/iocoder/dashboard/util/date/DateUtils.java
@@ -9,6 +9,11 @@ import java.util.Date;
  */
 public class DateUtils {
 
+    /**
+     * 时区 - 默认
+     */
+    public static final String TIME_ZONE_DEFAULT = "GMT+8";
+
     public static final String FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND = "yyyy-MM-dd HH:mm:ss";
 
     public static Date addTime(Duration duration) {
diff --git a/src/main/java/cn/iocoder/dashboard/util/json/JsonUtils.java b/src/main/java/cn/iocoder/dashboard/util/json/JsonUtils.java
index f6727459c..2291235dc 100644
--- a/src/main/java/cn/iocoder/dashboard/util/json/JsonUtils.java
+++ b/src/main/java/cn/iocoder/dashboard/util/json/JsonUtils.java
@@ -7,7 +7,8 @@ import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
 import java.io.IOException;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * JSON 工具类
@@ -20,7 +21,7 @@ public class JsonUtils {
 
     /**
      * 初始化 objectMapper 属性
-     *
+     * <p>
      * 通过这样的方式,使用 Spring 创建的 ObjectMapper Bean
      *
      * @param objectMapper ObjectMapper 对象
@@ -59,7 +60,7 @@ public class JsonUtils {
         }
     }
 
-    public static Object parseObject(String text, TypeReference<Set<Long>> typeReference) {
+    public static <T> T parseObject(String text, TypeReference<T> typeReference) {
         try {
             return objectMapper.readValue(text, typeReference);
         } catch (IOException e) {
@@ -67,4 +68,15 @@ public class JsonUtils {
         }
     }
 
+    public static <T> List<T> parseArray(String text, Class<T> clazz) {
+        if (StrUtil.isEmpty(text)) {
+            return new ArrayList<>();
+        }
+        try {
+            return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
 }
diff --git a/src/main/java/cn/iocoder/dashboard/util/string/StrUtils.java b/src/main/java/cn/iocoder/dashboard/util/string/StrUtils.java
index 5e98d915b..e0ca605a4 100644
--- a/src/main/java/cn/iocoder/dashboard/util/string/StrUtils.java
+++ b/src/main/java/cn/iocoder/dashboard/util/string/StrUtils.java
@@ -1,7 +1,10 @@
 package cn.iocoder.dashboard.util.string;
 
+import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 
+import java.util.Map;
+
 /**
  * 字符串工具类
  *
@@ -13,4 +16,22 @@ public class StrUtils {
         return StrUtil.maxLength(str, maxLength - 3); // -3 的原因,是该方法会补充 ... 恰好
     }
 
+    /**
+     * 指定字符串的
+     * @param str
+     * @param replaceMap
+     * @return
+     */
+    public static String replace(String str, Map<String, String> replaceMap) {
+        assert StrUtil.isNotBlank(str);
+        if (ObjectUtil.isEmpty(replaceMap)) {
+            return str;
+        }
+        String result = null;
+        for (String key : replaceMap.keySet()) {
+            result = str.replace(key, replaceMap.get(key));
+        }
+        return result;
+    }
+
 }
diff --git a/src/main/resources/codegen/java/service/service.vm b/src/main/resources/codegen/java/service/service.vm
index 04499314e..17a9b3953 100644
--- a/src/main/resources/codegen/java/service/service.vm
+++ b/src/main/resources/codegen/java/service/service.vm
@@ -63,7 +63,7 @@ public interface ${table.className}Service {
      * 获得${table.classComment}列表, 用于 Excel 导出
      *
      * @param exportReqVO 查询条件
-     * @return ${table.classComment}分页
+     * @return ${table.classComment}列表
      */
     List<${table.className}DO> get${simpleClassName}List(${table.className}ExportReqVO exportReqVO);
 
diff --git a/src/main/resources/codegen/vue/views/index.vue.vm b/src/main/resources/codegen/vue/views/index.vue.vm
index 73115fd77..5456d88ae 100644
--- a/src/main/resources/codegen/vue/views/index.vue.vm
+++ b/src/main/resources/codegen/vue/views/index.vue.vm
@@ -76,7 +76,7 @@
         <template slot-scope="scope">
           <span>{{ getDictDataLabel(DICT_TYPE.$dictType.toUpperCase(), scope.row.${column.javaField}) }}</span>
         </template>
-      </el-table-column>>
+      </el-table-column>
 #else
       <el-table-column label="${comment}" align="center" prop="${javaField}" />
 #end
@@ -137,7 +137,7 @@
           </el-select>
         </el-form-item>
 #elseif($column.htmlType == "checkbox")## 多选框
-        <el-form-item label="${comment}">
+        <el-form-item label="${comment}" prop="${javaField}">
           <el-checkbox-group v-model="form.${javaField}">
     #if ("" != $dictType)## 有数据字典
             <el-checkbox v-for="dict in this.getDictDatas(DICT_TYPE.$dictType.toUpperCase())"
@@ -148,7 +148,7 @@
           </el-checkbox-group>
         </el-form-item>
 #elseif($column.htmlType == "radio")## 单选框
-        <el-form-item label="${comment}">
+        <el-form-item label="${comment}" prop="${javaField}">
           <el-radio-group v-model="form.${javaField}">
     #if ("" != $dictType)## 有数据字典
             <el-radio v-for="dict in this.getDictDatas(DICT_TYPE.$dictType.toUpperCase())"
diff --git a/src/test-integration/java/cn/iocoder/dashboard/BaseDbAndRedisIntegrationTest.java b/src/test-integration/java/cn/iocoder/dashboard/BaseDbAndRedisIntegrationTest.java
new file mode 100644
index 000000000..98e1721c2
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/BaseDbAndRedisIntegrationTest.java
@@ -0,0 +1,37 @@
+package cn.iocoder.dashboard;
+
+import cn.iocoder.dashboard.framework.datasource.config.DataSourceConfiguration;
+import cn.iocoder.dashboard.framework.mybatis.config.MybatisConfiguration;
+import cn.iocoder.dashboard.framework.redis.config.RedisConfig;
+import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure;
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+import org.redisson.spring.starter.RedissonAutoConfiguration;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbAndRedisIntegrationTest.Application.class)
+@ActiveProfiles("integration-test") // 设置使用 application-integration-test 配置文件
+public class BaseDbAndRedisIntegrationTest {
+
+    @Import({
+            // DB 配置类
+            DataSourceConfiguration.class, // 自己的 DB 配置类
+            DataSourceAutoConfiguration.class, // Spring DB 自动配置类
+            DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类
+            DruidDataSourceAutoConfigure.class, // Druid 自动配置类
+            // MyBatis 配置类
+            MybatisConfiguration.class, // 自己的 MyBatis 配置类
+            MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类
+            // Redis 配置类
+            RedisAutoConfiguration.class, // Spring Redis 自动配置类
+            RedisConfig.class, // 自己的 Redis 配置类
+            RedissonAutoConfiguration.class, // Redisson 自动高配置类
+    })
+    public static class Application {
+    }
+
+}
diff --git a/src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java b/src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java
index 1c193bbf2..888357570 100644
--- a/src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java
+++ b/src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java
@@ -43,7 +43,7 @@ public class RedisStreamTest  {
             for (int i = 0; i < 100; i++) {
                 // 创建消息
                 SysSmsSendMessage message = new SysSmsSendMessage();
-                message.setMobile("15601691300").setTemplateCode("test:" + i);
+                message.setMobile("15601691300").setApiTemplateId("test:" + i);
                 // 发送消息
                 RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message);
             }
diff --git a/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientIntegrationTest.java b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientIntegrationTest.java
new file mode 100644
index 000000000..dc1904a3a
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientIntegrationTest.java
@@ -0,0 +1,54 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link AliyunSmsClient} 的集成测试
+ */
+public class AliyunSmsClientIntegrationTest {
+
+    private static AliyunSmsClient smsClient;
+
+    @BeforeAll
+    public static void before() {
+        // 创建配置类
+        SmsChannelProperties properties = new SmsChannelProperties();
+        properties.setId(1L);
+        properties.setSignature("Ballcat");
+        properties.setCode(SmsChannelEnum.ALIYUN.getCode());
+        properties.setApiKey(System.getenv("ALIYUN_ACCESS_KEY"));
+        properties.setApiSecret(System.getenv("ALIYUN_SECRET_KEY"));
+        // 创建客户端
+        smsClient = new AliyunSmsClient(properties);
+        smsClient.init();
+    }
+
+    @Test
+    public void testSendSms() {
+        List<KeyValue<String, Object>> templateParams = new ArrayList<>();
+        templateParams.add(new KeyValue<>("code", "1024"));
+//        templateParams.put("operation", "嘿嘿");
+//        SmsResult result = smsClient.send(1L, "15601691399", "4372216", templateParams);
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.sendSms(1L, "15601691399",
+                "SMS_207945135", templateParams);
+        System.out.println(result);
+    }
+
+    @Test
+    public void testGetSmsTemplate() {
+        String apiTemplateId = "SMS_2079451351";
+        SmsCommonResult<SmsTemplateRespDTO> result = smsClient.getSmsTemplate(apiTemplateId);
+        System.out.println(result);
+    }
+
+}
diff --git a/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClientIntegrationTest.java b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClientIntegrationTest.java
new file mode 100644
index 000000000..5815aa75c
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/debug/DebugDingTalkSmsClientIntegrationTest.java
@@ -0,0 +1,45 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.debug;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link DebugDingTalkSmsClient} 的集成测试
+ */
+public class DebugDingTalkSmsClientIntegrationTest {
+
+    private static DebugDingTalkSmsClient smsClient;
+
+    @BeforeAll
+    public static void init() {
+        // 创建配置类
+        SmsChannelProperties properties = new SmsChannelProperties();
+        properties.setId(1L);
+        properties.setSignature("芋道");
+        properties.setCode(SmsChannelEnum.DEBUG_DING_TALK.getCode());
+        properties.setApiKey("696b5d8ead48071237e4aa5861ff08dbadb2b4ded1c688a7b7c9afc615579859");
+        properties.setApiSecret("SEC5c4e5ff888bc8a9923ae47f59e7ccd30af1f14d93c55b4e2c9cb094e35aeed67");
+        // 创建客户端
+        smsClient = new DebugDingTalkSmsClient(properties);
+        smsClient.init();
+    }
+
+    @Test
+    public void testSendSms() {
+        List<KeyValue<String, Object>> templateParams = new ArrayList<>();
+        templateParams.add(new KeyValue<>("code", "1024"));
+        templateParams.add(new KeyValue<>("operation", "嘿嘿"));
+//        SmsResult result = smsClient.send(1L, "15601691399", "4372216", templateParams);
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.sendSms(1L, "15601691399", "4383920", templateParams);
+        System.out.println(result);
+    }
+
+}
diff --git a/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientIntegrationTest.java b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientIntegrationTest.java
new file mode 100644
index 000000000..73f1a472d
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientIntegrationTest.java
@@ -0,0 +1,52 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian;
+
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsChannelEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link YunpianSmsClient} 的集成测试
+ */
+public class YunpianSmsClientIntegrationTest {
+
+    private static YunpianSmsClient smsClient;
+
+    @BeforeAll
+    public static void init() {
+        // 创建配置类
+        SmsChannelProperties properties = new SmsChannelProperties();
+        properties.setId(1L);
+        properties.setSignature("芋道");
+        properties.setCode(SmsChannelEnum.YUN_PIAN.getCode());
+        properties.setApiKey("1555a14277cb8a608cf45a9e6a80d510");
+        // 创建客户端
+        smsClient = new YunpianSmsClient(properties);
+        smsClient.init();
+    }
+
+    @Test
+    public void testSendSms() {
+        List<KeyValue<String, Object>> templateParams = new ArrayList<>();
+        templateParams.add(new KeyValue<>("code", "1024"));
+        templateParams.add(new KeyValue<>("operation", "嘿嘿"));
+//        SmsResult result = smsClient.send(1L, "15601691399", "4372216", templateParams);
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.sendSms(1L, "15601691399", "4383920", templateParams);
+        System.out.println(result);
+    }
+
+    @Test
+    public void testGetSmsTemplate() {
+        String apiTemplateId = "4383920";
+        SmsCommonResult<SmsTemplateRespDTO> result = smsClient.getSmsTemplate(apiTemplateId);
+        System.out.println(result);
+    }
+
+}
diff --git a/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/package-info.java b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/package-info.java
new file mode 100644
index 000000000..037ce8ca2
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/framework/sms/core/client/package-info.java
@@ -0,0 +1 @@
+package cn.iocoder.dashboard.framework.sms.core.client;
diff --git a/src/main/java/cn/iocoder/dashboard/modules/system/service/package-info.java b/src/test-integration/java/cn/iocoder/dashboard/modules/system/service/package-info.java
similarity index 100%
rename from src/main/java/cn/iocoder/dashboard/modules/system/service/package-info.java
rename to src/test-integration/java/cn/iocoder/dashboard/modules/system/service/package-info.java
diff --git a/src/test-integration/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceIntegrationTest.java b/src/test-integration/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceIntegrationTest.java
new file mode 100644
index 000000000..7ec704b5d
--- /dev/null
+++ b/src/test-integration/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceIntegrationTest.java
@@ -0,0 +1,74 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.iocoder.dashboard.BaseDbAndRedisIntegrationTest;
+import cn.iocoder.dashboard.common.enums.UserTypeEnum;
+import cn.iocoder.dashboard.framework.sms.config.SmsConfiguration;
+import cn.iocoder.dashboard.modules.system.mq.consumer.sms.SysSmsSendConsumer;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsChannelServiceImpl;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsLogServiceImpl;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsServiceImpl;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsTemplateServiceImpl;
+import cn.iocoder.dashboard.modules.system.service.user.SysUserService;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Import({SmsConfiguration.class,
+        SysSmsChannelServiceImpl.class, SysSmsServiceImpl.class, SysSmsTemplateServiceImpl.class, SysSmsLogServiceImpl.class,
+        SysSmsProducer.class, SysSmsSendConsumer.class})
+public class SysSmsServiceIntegrationTest extends BaseDbAndRedisIntegrationTest {
+
+    @Resource
+    private SysSmsServiceImpl smsService;
+    @Resource
+    private SysSmsChannelServiceImpl smsChannelService;
+
+    @MockBean
+    private SysUserService userService;
+
+    @Test
+    public void testSendSingleSms_yunpianSuccess() {
+        // 参数准备
+        String mobile = "15601691399";
+        Long userId = 1L;
+        Integer userType = UserTypeEnum.ADMIN.getValue();
+        String templateCode = "test_01";
+        Map<String, Object> templateParams = MapUtil.<String, Object>builder()
+                .put("operation", "登陆").put("code", "1234").build();
+        // 调用
+        smsService.sendSingleSms(mobile, userId, userType, templateCode, templateParams);
+
+        // 等待 MQ 消费
+        ThreadUtil.sleep(1, TimeUnit.HOURS);
+    }
+
+    @Test
+    public void testSendSingleSms_aliyunSuccess() {
+        // 参数准备
+        String mobile = "15601691399";
+        Long userId = 1L;
+        Integer userType = UserTypeEnum.ADMIN.getValue();
+        String templateCode = "test_02";
+        Map<String, Object> templateParams = MapUtil.<String, Object>builder()
+                .put("code", "1234").build();
+        // 调用
+        smsService.sendSingleSms(mobile, userId, userType, templateCode, templateParams);
+
+        // 等待 MQ 消费
+        ThreadUtil.sleep(1, TimeUnit.HOURS);
+    }
+
+//    @Test
+//    public void testDoSendSms() {
+//        // 等待 MQ 消费
+//        ThreadUtil.sleep(1, TimeUnit.HOURS);
+//    }
+
+}
diff --git a/src/test-integration/resources/application-integration-test.yaml b/src/test-integration/resources/application-integration-test.yaml
index 88b92273c..43a846ee2 100644
--- a/src/test-integration/resources/application-integration-test.yaml
+++ b/src/test-integration/resources/application-integration-test.yaml
@@ -9,19 +9,15 @@ spring:
   # 数据源配置项
   datasource:
     name: ruoyi-vue-pro
-    url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写
-    driver-class-name: org.h2.Driver
-    username: sa
-    password:
-    schema: classpath:sql/create_tables.sql # MySQL 转 H2 的语句,使用 https://www.jooq.org/translate/ 工具
-    druid:
-      async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度
-      initial-size: 1 # 单元测试,配置为 1,提升启动速度
+    url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.name}?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT
+    driver-class-name: com.mysql.jdbc.Driver
+    username: root
+    password: 123456
 
   # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
   redis:
     host: 127.0.0.1 # 地址
-    port: 6379 # 端口(单元测试,使用 16379 端口)
+    port: 6379 # 端口
     database: 0 # 数据库索引
 
 mybatis:
diff --git a/src/test/java/cn/iocoder/dashboard/BaseMockitoUnitTest.java b/src/test/java/cn/iocoder/dashboard/BaseMockitoUnitTest.java
new file mode 100644
index 000000000..4a595b24e
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/BaseMockitoUnitTest.java
@@ -0,0 +1,13 @@
+package cn.iocoder.dashboard;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * 纯 Mockito 的单元测试
+ *
+ * @author 芋道源码
+ */
+@ExtendWith(MockitoExtension.class)
+public class BaseMockitoUnitTest {
+}
diff --git a/src/test/java/cn/iocoder/dashboard/framework/package-info.java b/src/test/java/cn/iocoder/dashboard/framework/package-info.java
new file mode 100644
index 000000000..0274647fb
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/framework/package-info.java
@@ -0,0 +1 @@
+package cn.iocoder.dashboard.framework;
diff --git a/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java
new file mode 100644
index 000000000..a544d03d5
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java
@@ -0,0 +1,224 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun;
+
+import cn.hutool.core.util.ReflectUtil;
+import cn.iocoder.dashboard.BaseMockitoUnitTest;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.util.collection.MapUtils;
+import cn.iocoder.dashboard.util.date.DateUtils;
+import com.aliyuncs.AcsRequest;
+import com.aliyuncs.IAcsClient;
+import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest;
+import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse;
+import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
+import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
+import com.aliyuncs.exceptions.ClientException;
+import com.google.common.collect.Lists;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatcher;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.List;
+import java.util.function.Function;
+
+import static cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.SMS_API_PARAM_ERROR;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static cn.iocoder.dashboard.util.json.JsonUtils.toJsonString;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link AliyunSmsClient} 的单元测试
+ *
+ * @author 芋道源码
+ */
+public class AliyunSmsClientTest extends BaseMockitoUnitTest {
+
+    private final SmsChannelProperties properties = new SmsChannelProperties()
+            .setApiKey(randomString()) // 随机一个 apiKey,避免构建报错
+            .setApiSecret(randomString()) // 随机一个 apiSecret,避免构建报错
+            .setSignature("芋道源码");
+
+    @InjectMocks
+    private final AliyunSmsClient smsClient = new AliyunSmsClient(properties);
+
+    @Mock
+    private IAcsClient client;
+
+    @Test
+    public void testDoInit() {
+        // 准备参数
+        // mock 方法
+
+        // 调用
+        smsClient.doInit();
+        // 断言
+        assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "acsClient"));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testDoSendSms() throws ClientException {
+        // 准备参数
+        Long sendLogId = randomLongId();
+        String mobile = randomString();
+        String apiTemplateId = randomString();
+        List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
+                new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
+        // mock 方法
+        SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> o.setCode("OK"));
+        when(client.getAcsResponse(argThat((ArgumentMatcher<SendSmsRequest>) acsRequest -> {
+            assertEquals(mobile, acsRequest.getPhoneNumbers());
+            assertEquals(properties.getSignature(), acsRequest.getSignName());
+            assertEquals(apiTemplateId, acsRequest.getTemplateCode());
+            assertEquals(toJsonString(MapUtils.convertMap(templateParams)), acsRequest.getTemplateParam());
+            assertEquals(sendLogId.toString(), acsRequest.getOutId());
+            return true;
+        }))).thenReturn(response);
+
+        // 调用
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.doSendSms(sendLogId, mobile,
+                apiTemplateId, templateParams);
+        // 断言
+        assertEquals(response.getCode(), result.getApiCode());
+        assertEquals(response.getMessage(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertEquals(response.getRequestId(), result.getApiRequestId());
+        // 断言结果
+        assertEquals(response.getBizId(), result.getData().getSerialNo());
+    }
+
+    @Test
+    public void testDoTParseSmsReceiveStatus() throws Throwable {
+        // 准备参数
+        String text = "[\n" +
+                "  {\n" +
+                "    \"phone_number\" : \"13900000001\",\n" +
+                "    \"send_time\" : \"2017-01-01 11:12:13\",\n" +
+                "    \"report_time\" : \"2017-02-02 22:23:24\",\n" +
+                "    \"success\" : true,\n" +
+                "    \"err_code\" : \"DELIVERED\",\n" +
+                "    \"err_msg\" : \"用户接收成功\",\n" +
+                "    \"sms_size\" : \"1\",\n" +
+                "    \"biz_id\" : \"12345\",\n" +
+                "    \"out_id\" : \"67890\"\n" +
+                "  }\n" +
+                "]";
+        // mock 方法
+
+        // 调用
+        List<SmsReceiveRespDTO> statuses = smsClient.doParseSmsReceiveStatus(text);
+        // 断言
+        assertEquals(1, statuses.size());
+        assertTrue(statuses.get(0).getSuccess());
+        assertEquals("DELIVERED", statuses.get(0).getErrorCode());
+        assertEquals("用户接收成功", statuses.get(0).getErrorMsg());
+        assertEquals("13900000001", statuses.get(0).getMobile());
+        assertEquals(DateUtils.buildTime(2017, 2, 2, 22, 23, 24), statuses.get(0).getReceiveTime());
+        assertEquals("12345", statuses.get(0).getSerialNo());
+        assertEquals(67890L, statuses.get(0).getLogId());
+    }
+
+    @Test
+    public void testDoGetSmsTemplate() throws ClientException {
+        // 准备参数
+        String apiTemplateId = randomString();
+        // mock 方法
+        QuerySmsTemplateResponse response = randomPojo(QuerySmsTemplateResponse.class, o -> {
+            o.setCode("OK");
+            o.setTemplateStatus(1); // 设置模板通过
+        });
+        when(client.getAcsResponse(argThat((ArgumentMatcher<QuerySmsTemplateRequest>) acsRequest -> {
+            assertEquals(apiTemplateId, acsRequest.getTemplateCode());
+            return true;
+        }))).thenReturn(response);
+
+        // 调用
+        SmsCommonResult<SmsTemplateRespDTO> result = smsClient.doGetSmsTemplate(apiTemplateId);
+        // 断言
+        assertEquals(response.getCode(), result.getApiCode());
+        assertEquals(response.getMessage(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertEquals(response.getRequestId(), result.getApiRequestId());
+        // 断言结果
+        assertEquals(response.getTemplateCode(), result.getData().getId());
+        assertEquals(response.getTemplateContent(), result.getData().getContent());
+        assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getData().getAuditStatus());
+        assertEquals(response.getReason(), result.getData().getAuditReason());
+    }
+
+    @Test
+    public void testConvertSmsTemplateAuditStatus() {
+        assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus(0));
+        assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus(1));
+        assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus(2));
+        assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus(3),
+                "未知审核状态(3)");
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testInvoke_throwable() throws ClientException {
+        // 准备参数
+        QuerySmsTemplateRequest request = new QuerySmsTemplateRequest();
+        // mock 方法
+        ClientException ex = new ClientException("isv.INVALID_PARAMETERS", "参数不正确", randomString());
+        when(client.getAcsResponse(any(AcsRequest.class))).thenThrow(ex);
+
+        // 调用,并断言异常
+        SmsCommonResult<?> result = smsClient.invoke(request,null);
+        // 断言
+        assertEquals(ex.getErrCode(), result.getApiCode());
+        assertEquals(ex.getErrMsg(), result.getApiMsg());
+        assertEquals(SMS_API_PARAM_ERROR.getCode(), result.getCode());
+        assertEquals(SMS_API_PARAM_ERROR.getMsg(), result.getMsg());
+        assertEquals(ex.getRequestId(), result.getApiRequestId());
+    }
+
+    @Test
+    public void testInvoke_success() throws ClientException {
+        // 准备参数
+        QuerySmsTemplateRequest request = new QuerySmsTemplateRequest();
+        Function<QuerySmsTemplateResponse, SmsTemplateRespDTO> responseConsumer = response -> {
+            SmsTemplateRespDTO data = new SmsTemplateRespDTO();
+            data.setId(response.getTemplateCode()).setContent(response.getTemplateContent());
+            data.setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(response.getReason());
+            return data;
+        };
+        // mock 方法
+        QuerySmsTemplateResponse response = randomPojo(QuerySmsTemplateResponse.class, o -> {
+            o.setCode("OK");
+            o.setTemplateStatus(1); // 设置模板通过
+        });
+        when(client.getAcsResponse(any(AcsRequest.class))).thenReturn(response);
+
+        // 调用
+        SmsCommonResult<SmsTemplateRespDTO> result = smsClient.invoke(request, responseConsumer);
+        // 断言
+        assertEquals(response.getCode(), result.getApiCode());
+        assertEquals(response.getMessage(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertEquals(response.getRequestId(), result.getApiRequestId());
+        // 断言结果
+        assertEquals(response.getTemplateCode(), result.getData().getId());
+        assertEquals(response.getTemplateContent(), result.getData().getContent());
+        assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getData().getAuditStatus());
+        assertEquals(response.getReason(), result.getData().getAuditReason());
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMappingTest.java b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMappingTest.java
new file mode 100644
index 000000000..54dba079b
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/aliyun/AliyunSmsCodeMappingTest.java
@@ -0,0 +1,43 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.aliyun;
+
+import cn.iocoder.dashboard.BaseMockitoUnitTest;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link AliyunSmsCodeMapping} 的单元测试
+ *
+ * @author 芋道源码
+ */
+public class AliyunSmsCodeMappingTest extends BaseMockitoUnitTest {
+
+    @InjectMocks
+    private AliyunSmsCodeMapping codeMapping;
+
+    @Test
+    public void testApply() {
+        assertEquals(GlobalErrorCodeConstants.SUCCESS, codeMapping.apply("OK"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_INVALID, codeMapping.apply("MissingAccessKeyId"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_INVALID, codeMapping.apply("isv.ACCOUNT_NOT_EXISTS"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_INVALID, codeMapping.apply("isv.ACCOUNT_ABNORMAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_DAY_LIMIT_CONTROL, codeMapping.apply("isv.DAY_LIMIT_CONTROL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_CONTENT_INVALID, codeMapping.apply("isv.SMS_CONTENT_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply("isv.SMS_SIGN_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply("isv.SIGN_NAME_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply("isp.RAM_PERMISSION_DENY"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_MONEY_NOT_ENOUGH, codeMapping.apply("isv.OUT_OF_SERVICE"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_MONEY_NOT_ENOUGH, codeMapping.apply("isv.AMOUNT_NOT_ENOUGH"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_INVALID, codeMapping.apply("isv.SMS_TEMPLATE_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply("isv.SMS_SIGNATURE_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_API_PARAM_ERROR, codeMapping.apply("isv.INVALID_PARAMETERS"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_API_PARAM_ERROR, codeMapping.apply("isv.INVALID_JSON_PARAM"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_MOBILE_INVALID, codeMapping.apply("isv.MOBILE_NUMBER_ILLEGAL"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_PARAM_ERROR, codeMapping.apply("isv.TEMPLATE_MISSING_PARAMETERS"));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply("isv.BUSINESS_LIMIT_CONTROL"));
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientTest.java b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientTest.java
new file mode 100644
index 000000000..3e4190c0a
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsClientTest.java
@@ -0,0 +1,202 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian;
+
+import cn.hutool.core.util.ReflectUtil;
+import cn.iocoder.dashboard.BaseMockitoUnitTest;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
+import cn.iocoder.dashboard.framework.sms.core.property.SmsChannelProperties;
+import cn.iocoder.dashboard.util.date.DateUtils;
+import com.google.common.collect.Lists;
+import com.yunpian.sdk.YunpianClient;
+import com.yunpian.sdk.api.SmsApi;
+import com.yunpian.sdk.api.TplApi;
+import com.yunpian.sdk.constant.YunpianConstant;
+import com.yunpian.sdk.model.Result;
+import com.yunpian.sdk.model.SmsSingleSend;
+import com.yunpian.sdk.model.Template;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static com.yunpian.sdk.constant.Code.OK;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 对 {@link YunpianSmsClient} 的单元测试
+ *
+ * @author 芋道源码
+ */
+public class YunpianSmsClientTest extends BaseMockitoUnitTest {
+
+    private final SmsChannelProperties properties = new SmsChannelProperties()
+            .setApiKey(randomString()); // 随机一个 apiKey,避免构建报错
+
+    @InjectMocks
+    private final YunpianSmsClient smsClient = new YunpianSmsClient(properties);
+
+    @Mock
+    private YunpianClient client;
+
+    @Test
+    public void testDoInit() {
+        // 准备参数
+        // mock 方法
+
+        // 调用
+        smsClient.doInit();
+        // 断言
+        assertNotEquals(client, ReflectUtil.getFieldValue(smsClient, "client"));
+        verify(client, times(1)).close();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testDoSendSms() throws Throwable {
+        // 准备参数
+        Long sendLogId = randomLongId();
+        String mobile = randomString();
+        String apiTemplateId = randomString();
+        List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
+                new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
+        // mock sms 方法
+        SmsApi smsApi = mock(SmsApi.class);
+        when(client.sms()).thenReturn(smsApi);
+        // mock tpl_single_send 方法
+        Map<String, String> request = new HashMap<>();
+        request.put(YunpianConstant.MOBILE, mobile);
+        request.put(YunpianConstant.TPL_ID, apiTemplateId);
+        request.put(YunpianConstant.TPL_VALUE, "#code#=1234&#op#=login");
+        request.put(YunpianConstant.UID, String.valueOf(sendLogId));
+        request.put(YunpianConstant.CALLBACK_URL, properties.getCallbackUrl());
+        Result<SmsSingleSend> responseResult = randomPojo(Result.class, SmsSingleSend.class,
+                o -> o.setCode(OK)); // API 发送成功的 code
+        when(smsApi.tpl_single_send(eq(request))).thenReturn(responseResult);
+
+        // 调用
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.doSendSms(sendLogId, mobile,
+                apiTemplateId, templateParams);
+        // 断言
+        assertEquals(String.valueOf(responseResult.getCode()), result.getApiCode());
+        assertEquals(responseResult.getMsg() + " => " + responseResult.getDetail(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertNull(result.getApiRequestId());
+        // 断言结果
+        assertEquals(String.valueOf(responseResult.getData().getSid()), result.getData().getSerialNo());
+    }
+
+    @Test
+    public void testDoParseSmsReceiveStatus() throws Throwable {
+        // 准备参数
+        String text = "[{\"sid\":9527,\"uid\":1024,\"user_receive_time\":\"2014-03-17 22:55:21\",\"error_msg\":\"\",\"mobile\":\"15205201314\",\"report_status\":\"SUCCESS\"}]";
+        // mock 方法
+
+        // 调用
+
+        // 断言
+        // 调用
+        List<SmsReceiveRespDTO> statuses = smsClient.doParseSmsReceiveStatus(text);
+        // 断言
+        assertEquals(1, statuses.size());
+        assertTrue(statuses.get(0).getSuccess());
+        assertEquals("", statuses.get(0).getErrorCode());
+        assertNull(statuses.get(0).getErrorMsg());
+        assertEquals("15205201314", statuses.get(0).getMobile());
+        assertEquals(DateUtils.buildTime(2014, 3, 17, 22, 55, 21), statuses.get(0).getReceiveTime());
+        assertEquals("9527", statuses.get(0).getSerialNo());
+        assertEquals(1024L, statuses.get(0).getLogId());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testDoGetSmsTemplate() throws Throwable {
+        // 准备参数
+        String apiTemplateId = randomString();
+        // mock tpl 方法
+        TplApi tplApi = mock(TplApi.class);
+        when(client.tpl()).thenReturn(tplApi);
+        // mock get 方法
+        Map<String, String> request = new HashMap<>();
+        request.put(YunpianConstant.APIKEY, properties.getApiKey());
+        request.put(YunpianConstant.TPL_ID, apiTemplateId);
+        Result<List<Template>> responseResult = randomPojo(Result.class, List.class, o -> {
+            o.setCode(OK); // API 发送成功的 code
+            o.setData(randomPojoList(Template.class, t -> t.setCheck_status("SUCCESS")));
+        });
+        when(tplApi.get(eq(request))).thenReturn(responseResult);
+
+        // 调用
+        SmsCommonResult<SmsTemplateRespDTO> result = smsClient.doGetSmsTemplate(apiTemplateId);
+        // 断言
+        assertEquals(String.valueOf(responseResult.getCode()), result.getApiCode());
+        assertEquals(responseResult.getMsg() + " => " + responseResult.getDetail(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertNull(result.getApiRequestId());
+        // 断言结果
+        Template template = responseResult.getData().get(0);
+        assertEquals(template.getTpl_id().toString(), result.getData().getId());
+        assertEquals(template.getTpl_content(), result.getData().getContent());
+        assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getData().getAuditStatus());
+        assertEquals(template.getReason(), result.getData().getAuditReason());
+    }
+
+    @Test
+    public void testConvertSmsTemplateAuditStatus() {
+        assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus("CHECKING"));
+        assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus("SUCCESS"));
+        assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(),
+                smsClient.convertSmsTemplateAuditStatus("FAIL"));
+        assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus("test"),
+                "未知审核状态(test)");
+    }
+
+    @Test
+    public void testInvoke_throwable() {
+        // 准备参数
+        Supplier<Result<Object>> requestConsumer =
+                () -> new Result<>().setThrowable(new NullPointerException());
+        // mock 方法
+
+        // 调用,并断言异常
+        assertThrows(NullPointerException.class,
+                () -> smsClient.invoke(requestConsumer, null));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testInvoke_success() throws Throwable {
+        // 准备参数
+        Result<SmsSingleSend> responseResult = randomPojo(Result.class, SmsSingleSend.class, o -> o.setCode(OK));
+        Supplier<Result<SmsSingleSend>> requestConsumer = () -> responseResult;
+        Function<SmsSingleSend, SmsSendRespDTO> responseConsumer =
+                smsSingleSend -> new SmsSendRespDTO().setSerialNo(String.valueOf(responseResult.getData().getSid()));
+        // mock 方法
+
+        // 调用
+        SmsCommonResult<SmsSendRespDTO> result = smsClient.invoke(requestConsumer, responseConsumer);
+        // 断言
+        assertEquals(String.valueOf(responseResult.getCode()), result.getApiCode());
+        assertEquals(responseResult.getMsg() + " => " + responseResult.getDetail(), result.getApiMsg());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
+        assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
+        assertNull(result.getApiRequestId());
+        assertEquals(String.valueOf(responseResult.getData().getSid()), result.getData().getSerialNo());
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMappingTest.java b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMappingTest.java
new file mode 100644
index 000000000..de6e46432
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/framework/sms/core/client/impl/yunpian/YunpianSmsCodeMappingTest.java
@@ -0,0 +1,43 @@
+package cn.iocoder.dashboard.framework.sms.core.client.impl.yunpian;
+
+import cn.iocoder.dashboard.BaseMockitoUnitTest;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static com.yunpian.sdk.constant.Code.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * {@link YunpianSmsCodeMapping} 的单元测试
+ *
+ * @author 芋道源码
+ */
+class YunpianSmsCodeMappingTest extends BaseMockitoUnitTest {
+
+    @InjectMocks
+    private YunpianSmsCodeMapping codeMapping;
+
+    @Test
+    public void testApply() {
+        assertEquals(GlobalErrorCodeConstants.SUCCESS, codeMapping.apply(String.valueOf(OK)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_API_PARAM_ERROR, codeMapping.apply(String.valueOf(ARGUMENT_MISSING)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_PARAM_ERROR, codeMapping.apply(String.valueOf(BAD_ARGUMENT_FORMAT)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_MONEY_NOT_ENOUGH, codeMapping.apply(String.valueOf(MONEY_NOT_ENOUGH)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_INVALID, codeMapping.apply(String.valueOf(TPL_NOT_FOUND)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_INVALID, codeMapping.apply(String.valueOf(TPL_NOT_VALID)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply(String.valueOf(DUP_IN_SHORT_TIME)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply(String.valueOf(TOO_MANY_TIME_IN_5)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply(String.valueOf(DAY_LIMIT_PER_MOBILE)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply(String.valueOf(HOUR_LIMIT_PER_MOBILE)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_MOBILE_BLACK, codeMapping.apply(String.valueOf(BLACK_PHONE_FILTER)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply(String.valueOf(SIGN_NOT_MATCH)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply(String.valueOf(SIGN_NOT_VALID)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply(String.valueOf(BAD_SIGN_FORMAT)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_INVALID, codeMapping.apply(String.valueOf(BAD_API_KEY)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply(String.valueOf(API_NOT_ALLOWED)));
+        assertEquals(SmsFrameworkErrorCodeConstants.SMS_IP_DENY, codeMapping.apply(String.valueOf(IP_NOT_ALLOWED)));
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java
index 373665546..48a80056e 100644
--- a/src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java
@@ -270,4 +270,5 @@ class SysDeptServiceTest extends BaseDbUnitTest {
         };
         return randomPojo(SysDeptDO.class, ArrayUtils.append(consumer, consumers));
     }
+
 }
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysMenuServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysMenuServiceTest.java
new file mode 100644
index 000000000..da680a551
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysMenuServiceTest.java
@@ -0,0 +1,366 @@
+package cn.iocoder.dashboard.modules.system.service.permission;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.lang.Assert;
+import cn.iocoder.dashboard.BaseDbUnitTest;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.menu.SysMenuCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.menu.SysMenuListReqVO;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.menu.SysMenuUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.permission.SysMenuDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.permission.SysMenuMapper;
+import cn.iocoder.dashboard.modules.system.enums.permission.MenuTypeEnum;
+import cn.iocoder.dashboard.modules.system.mq.producer.permission.SysMenuProducer;
+import cn.iocoder.dashboard.modules.system.service.permission.impl.SysMenuServiceImpl;
+import cn.iocoder.dashboard.util.AopTargetUtils;
+import cn.iocoder.dashboard.util.RandomUtils;
+import cn.iocoder.dashboard.util.object.ObjectUtils;
+import com.google.common.collect.Multimap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+import static cn.iocoder.dashboard.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.dashboard.util.AssertUtils.assertServiceException;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.verify;
+
+@Import(SysMenuServiceImpl.class)
+public class SysMenuServiceTest extends BaseDbUnitTest {
+
+    @Resource
+    private SysMenuServiceImpl sysMenuService;
+
+    @MockBean
+    private SysPermissionService sysPermissionService;
+
+    @MockBean
+    private SysMenuProducer sysMenuProducer;
+
+    @Resource
+    private SysMenuMapper menuMapper;
+
+    @Test
+    public void testInitLocalCache_success() throws Exception {
+        SysMenuDO menuDO1 = createMenuDO(MenuTypeEnum.MENU, "xxxx", 0L);
+        menuMapper.insert(menuDO1);
+        SysMenuDO menuDO2 = createMenuDO(MenuTypeEnum.MENU, "xxxx", 0L);
+        menuMapper.insert(menuDO2);
+
+        // 调用
+        sysMenuService.initLocalCache();
+
+        // 获取代理对象
+        SysMenuServiceImpl target = (SysMenuServiceImpl) AopTargetUtils.getTarget(sysMenuService);
+
+        Map<Long, SysMenuDO> menuCache =
+                (Map<Long, SysMenuDO>) BeanUtil.getFieldValue(target, "menuCache");
+        Assert.isTrue(menuCache.size() == 2);
+        assertPojoEquals(menuDO1, menuCache.get(menuDO1.getId()));
+        assertPojoEquals(menuDO2, menuCache.get(menuDO2.getId()));
+
+        Multimap<String, SysMenuDO> permissionMenuCache =
+                (Multimap<String, SysMenuDO>) BeanUtil.getFieldValue(target, "permissionMenuCache");
+        Assert.isTrue(permissionMenuCache.size() == 2);
+        assertPojoEquals(menuDO1, permissionMenuCache.get(menuDO1.getPermission()));
+        assertPojoEquals(menuDO2, permissionMenuCache.get(menuDO2.getPermission()));
+
+        Date maxUpdateTime = (Date) BeanUtil.getFieldValue(target, "maxUpdateTime");
+        assertEquals(ObjectUtils.max(menuDO1.getUpdateTime(), menuDO2.getUpdateTime()), maxUpdateTime);
+    }
+
+    @Test
+    public void testCreateMenu_success() {
+        //构造父目录
+        SysMenuDO menuDO = createMenuDO(MenuTypeEnum.MENU, "parent", 0L);
+        menuMapper.insert(menuDO);
+        Long parentId = menuDO.getId();
+
+        //调用
+        SysMenuCreateReqVO vo = randomPojo(SysMenuCreateReqVO.class, o -> {
+            o.setParentId(parentId);
+            o.setName("testSonName");
+            o.setType(MenuTypeEnum.MENU.getType());
+            o.setStatus(RandomUtils.randomCommonStatus());
+        });
+        Long menuId = sysMenuService.createMenu(vo);
+
+        //断言
+        Assertions.assertNotNull(menuId);
+        // 校验记录的属性是否正确
+        SysMenuDO ret = menuMapper.selectById(menuId);
+        assertPojoEquals(vo, ret);
+        // 校验调用
+        verify(sysMenuProducer).sendMenuRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateMenu_success() {
+        //构造父子目录
+        SysMenuDO sonMenuDO = initParentAndSonMenuDO();
+        Long sonId = sonMenuDO.getId();
+        Long parentId = sonMenuDO.getParentId();
+
+        //调用
+        SysMenuUpdateReqVO vo = RandomUtils.randomPojo(SysMenuUpdateReqVO.class, o -> {
+            o.setId(sonId);
+            o.setParentId(parentId);
+            o.setType(MenuTypeEnum.MENU.getType());
+            o.setStatus(RandomUtils.randomCommonStatus());
+            o.setName("pppppp"); //修改名字
+        });
+        sysMenuService.updateMenu(vo);
+
+        //断言
+        // 校验记录的属性是否正确
+        SysMenuDO ret = menuMapper.selectById(sonId);
+        assertPojoEquals(vo, ret);
+        // 校验调用
+        verify(sysMenuProducer).sendMenuRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateMenu_sonIdNotExist() {
+        Long sonId = 99999L;
+        Long parentId = 10000L;
+
+        //调用
+        SysMenuUpdateReqVO vo = RandomUtils.randomPojo(SysMenuUpdateReqVO.class, o -> {
+            o.setId(sonId);
+            o.setParentId(parentId);
+            o.setType(MenuTypeEnum.MENU.getType());
+            o.setStatus(RandomUtils.randomCommonStatus());
+        });
+        //断言
+        assertServiceException(() -> sysMenuService.updateMenu(vo), MENU_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteMenu_success() {
+        SysMenuDO sonMenuDO = initParentAndSonMenuDO();
+        Long sonId = sonMenuDO.getId();
+
+        //调用
+        sysMenuService.deleteMenu(sonId);
+
+        //断言
+        SysMenuDO menuDO = menuMapper.selectById(sonId);
+        Assert.isNull(menuDO);
+        verify(sysPermissionService).processMenuDeleted(sonId);
+        verify(sysMenuProducer).sendMenuRefreshMessage();
+    }
+
+    @Test
+    public void testDeleteMenu_menuNotExist() {
+        Long sonId = 99999L;
+
+        assertServiceException(() -> sysMenuService.deleteMenu(sonId), MENU_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteMenu_existChildren() {
+        SysMenuDO sonMenu = initParentAndSonMenuDO();
+        Long parentId = sonMenu.getParentId();
+
+        assertServiceException(() -> sysMenuService.deleteMenu(parentId), MENU_EXISTS_CHILDREN);
+    }
+
+    @Test
+    public void testGetMenus_success() {
+        Map<Long, SysMenuDO> idMenuMap = new HashMap<>();
+        SysMenuDO menuDO = createMenuDO(MenuTypeEnum.MENU, "parent", 0L);
+        menuMapper.insert(menuDO);
+        idMenuMap.put(menuDO.getId(), menuDO);
+
+        SysMenuDO sonMenu = createMenuDO(MenuTypeEnum.MENU, "son", menuDO.getId());
+        menuMapper.insert(sonMenu);
+        idMenuMap.put(sonMenu.getId(), sonMenu);
+
+        //调用
+        List<SysMenuDO> menuDOS = sysMenuService.getMenus();
+
+        //断言
+        Assert.isTrue(menuDOS.size() == idMenuMap.size());
+        menuDOS.forEach(m -> assertPojoEquals(idMenuMap.get(m.getId()), m));
+    }
+
+    @Test
+    public void testGetMenusReqVo_success() {
+        Map<Long, SysMenuDO> idMenuMap = new HashMap<>();
+        //用于验证可以模糊搜索名称包含"name",状态为1的menu
+        SysMenuDO menu = createMenuDO(MenuTypeEnum.MENU, "name2", 0L, 1);
+        menuMapper.insert(menu);
+        idMenuMap.put(menu.getId(), menu);
+
+        menu = createMenuDO(MenuTypeEnum.MENU, "11name111", 0L, 1);
+        menuMapper.insert(menu);
+        idMenuMap.put(menu.getId(), menu);
+
+        menu = createMenuDO(MenuTypeEnum.MENU, "name", 0L, 1);
+        menuMapper.insert(menu);
+        idMenuMap.put(menu.getId(), menu);
+
+        //以下是不符合搜索条件的的menu
+        menu = createMenuDO(MenuTypeEnum.MENU, "xxxxxx", 0L, 1);
+        menuMapper.insert(menu);
+        menu = createMenuDO(MenuTypeEnum.MENU, "name", 0L, 2);
+        menuMapper.insert(menu);
+
+        //调用
+        SysMenuListReqVO reqVO = new SysMenuListReqVO();
+        reqVO.setStatus(1);
+        reqVO.setName("name");
+        List<SysMenuDO> menuDOS = sysMenuService.getMenus(reqVO);
+
+        //断言
+        Assert.isTrue(menuDOS.size() == idMenuMap.size());
+        menuDOS.forEach(m -> assertPojoEquals(idMenuMap.get(m.getId()), m));
+    }
+
+    @Test
+    public void testListMenusFromCache_success() throws Exception {
+        Map<Long, SysMenuDO> mockCacheMap = new HashMap<>();
+        //获取代理对象
+        SysMenuServiceImpl target = (SysMenuServiceImpl) AopTargetUtils.getTarget(sysMenuService);
+        BeanUtil.setFieldValue(target, "menuCache", mockCacheMap);
+
+        Map<Long, SysMenuDO> idMenuMap = new HashMap<>();
+        //用于验证搜索类型为MENU,状态为1的menu
+        SysMenuDO menuDO = createMenuDO(1L, MenuTypeEnum.MENU, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        idMenuMap.put(menuDO.getId(), menuDO);
+
+        menuDO = createMenuDO(2L, MenuTypeEnum.MENU, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        idMenuMap.put(menuDO.getId(), menuDO);
+
+        //以下是不符合搜索条件的menu
+        menuDO = createMenuDO(3L, MenuTypeEnum.BUTTON, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        menuDO = createMenuDO(4L, MenuTypeEnum.MENU, "name", 0L, 2);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+
+        List<SysMenuDO> menuDOS = sysMenuService.listMenusFromCache(Collections.singletonList(MenuTypeEnum.MENU.getType()),
+                Collections.singletonList(CommonStatusEnum.DISABLE.getStatus()));
+        Assert.isTrue(menuDOS.size() == idMenuMap.size());
+        menuDOS.forEach(m -> assertPojoEquals(idMenuMap.get(m.getId()), m));
+    }
+
+    @Test
+    public void testListMenusFromCache2_success() throws Exception {
+        Map<Long, SysMenuDO> mockCacheMap = new HashMap<>();
+        //获取代理对象
+        SysMenuServiceImpl target = (SysMenuServiceImpl) AopTargetUtils.getTarget(sysMenuService);
+        BeanUtil.setFieldValue(target, "menuCache", mockCacheMap);
+
+        Map<Long, SysMenuDO> idMenuMap = new HashMap<>();
+        //验证搜索id为1, 类型为MENU, 状态为1 的menu
+        SysMenuDO menuDO = createMenuDO(1L, MenuTypeEnum.MENU, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        idMenuMap.put(menuDO.getId(), menuDO);
+
+        //以下是不符合搜索条件的menu
+        menuDO = createMenuDO(2L, MenuTypeEnum.MENU, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        menuDO = createMenuDO(3L, MenuTypeEnum.BUTTON, "name", 0L, 1);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+        menuDO = createMenuDO(4L, MenuTypeEnum.MENU, "name", 0L, 2);
+        mockCacheMap.put(menuDO.getId(), menuDO);
+
+        List<SysMenuDO> menuDOS = sysMenuService.listMenusFromCache(Collections.singletonList(1L),
+                Collections.singletonList(MenuTypeEnum.MENU.getType()), Collections.singletonList(1));
+        Assert.isTrue(menuDOS.size() == idMenuMap.size());
+        menuDOS.forEach(menu -> assertPojoEquals(idMenuMap.get(menu.getId()), menu));
+    }
+
+    @Test
+    public void testCheckParentResource_success() {
+        SysMenuDO menuDO = createMenuDO(MenuTypeEnum.MENU, "parent", 0L);
+        menuMapper.insert(menuDO);
+        Long parentId = menuDO.getId();
+
+        sysMenuService.checkParentResource(parentId, null);
+    }
+
+    @Test
+    public void testCheckParentResource_canNotSetSelfToBeParent() {
+        assertServiceException(() -> sysMenuService.checkParentResource(1L, 1L), MENU_PARENT_ERROR);
+    }
+
+    @Test
+    public void testCheckParentResource_parentNotExist() {
+        assertServiceException(() -> sysMenuService.checkParentResource(randomLongId(), null), MENU_PARENT_NOT_EXISTS);
+    }
+
+    @Test
+    public void testCheckParentResource_parentTypeError() {
+        SysMenuDO menuDO = createMenuDO(MenuTypeEnum.BUTTON, "parent", 0L);
+        menuMapper.insert(menuDO);
+        Long parentId = menuDO.getId();
+
+        assertServiceException(() -> sysMenuService.checkParentResource(parentId, null), MENU_PARENT_NOT_DIR_OR_MENU);
+    }
+
+    @Test
+    public void testCheckResource_success() {
+        SysMenuDO sonMenu = initParentAndSonMenuDO();
+        Long parentId = sonMenu.getParentId();
+
+        Long otherSonMenuId = randomLongId();
+        String otherSonMenuName = randomString();
+
+        sysMenuService.checkResource(parentId, otherSonMenuName, otherSonMenuId);
+    }
+
+    @Test
+    public void testCheckResource_sonMenuNameDuplicate(){
+        SysMenuDO sonMenu=initParentAndSonMenuDO();
+        Long parentId=sonMenu.getParentId();
+
+        Long otherSonMenuId=randomLongId();
+        String otherSonMenuName=sonMenu.getName(); //相同名称
+
+        assertServiceException(() -> sysMenuService.checkResource(parentId, otherSonMenuName, otherSonMenuId), MENU_NAME_DUPLICATE);
+    }
+
+    /**
+     * 构造父子目录,返回子目录
+     *
+     * @return
+     */
+    private SysMenuDO initParentAndSonMenuDO() {
+        //构造父子目录
+        SysMenuDO menuDO = createMenuDO(MenuTypeEnum.MENU, "parent", 0L);
+        menuMapper.insert(menuDO);
+        Long parentId = menuDO.getId();
+
+        SysMenuDO sonMenuDO = createMenuDO(MenuTypeEnum.MENU, "testSonName", parentId);
+        menuMapper.insert(sonMenuDO);
+        return sonMenuDO;
+    }
+
+    private SysMenuDO createMenuDO(MenuTypeEnum typeEnum, String menuName, Long parentId) {
+        return createMenuDO(typeEnum, menuName, parentId, RandomUtils.randomCommonStatus());
+    }
+
+    private SysMenuDO createMenuDO(MenuTypeEnum typeEnum, String menuName, Long parentId, Integer status) {
+        return createMenuDO(null, typeEnum, menuName, parentId, status);
+    }
+
+    private SysMenuDO createMenuDO(Long id, MenuTypeEnum typeEnum, String menuName, Long parentId, Integer status) {
+        return RandomUtils.randomPojo(SysMenuDO.class, o -> {
+            o.setId(id);
+            o.setParentId(parentId);
+            o.setType(typeEnum.getType());
+            o.setStatus(status);
+            o.setName(menuName);
+        });
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysRoleServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysRoleServiceTest.java
new file mode 100644
index 000000000..f180f2b92
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/permission/SysRoleServiceTest.java
@@ -0,0 +1,308 @@
+package cn.iocoder.dashboard.modules.system.service.permission;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.iocoder.dashboard.BaseDbUnitTest;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.security.core.enums.DataScopeEnum;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.role.SysRoleCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.role.SysRolePageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.permission.vo.role.SysRoleUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.permission.SysRoleDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.permission.SysRoleMapper;
+import cn.iocoder.dashboard.modules.system.enums.permission.SysRoleTypeEnum;
+import cn.iocoder.dashboard.modules.system.mq.producer.permission.SysRoleProducer;
+import cn.iocoder.dashboard.modules.system.service.permission.impl.SysRoleServiceImpl;
+import cn.iocoder.dashboard.util.AopTargetUtils;
+import cn.iocoder.dashboard.util.AssertUtils;
+import cn.iocoder.dashboard.util.RandomUtils;
+import cn.iocoder.dashboard.util.object.ObjectUtils;
+import com.google.common.collect.Sets;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+import static cn.iocoder.dashboard.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.dashboard.util.AssertUtils.assertServiceException;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static cn.iocoder.dashboard.util.object.ObjectUtils.max;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.verify;
+
+@Import(SysRoleServiceImpl.class)
+public class SysRoleServiceTest extends BaseDbUnitTest {
+
+    @Resource
+    private SysRoleServiceImpl sysRoleService;
+
+    @Resource
+    private SysRoleMapper roleMapper;
+
+    @MockBean
+    private SysPermissionService sysPermissionService;
+
+    @MockBean
+    private SysRoleProducer sysRoleProducer;
+
+    @Test
+    public void testInitLocalCache_success() throws Exception {
+        SysRoleDO roleDO1 = createRoleDO("role1", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO1);
+        SysRoleDO roleDO2 = createRoleDO("role2", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO2);
+
+        //调用
+        sysRoleService.initLocalCache();
+
+        //断言
+        //获取代理对象
+        SysRoleServiceImpl target = (SysRoleServiceImpl) AopTargetUtils.getTarget(sysRoleService);
+
+        Map<Long, SysRoleDO> roleCache = (Map<Long, SysRoleDO>) BeanUtil.getFieldValue(target, "roleCache");
+        assertPojoEquals(roleDO1, roleCache.get(roleDO1.getId()));
+        assertPojoEquals(roleDO2, roleCache.get(roleDO2.getId()));
+
+        Date maxUpdateTime = (Date) BeanUtil.getFieldValue(target, "maxUpdateTime");
+        assertEquals(max(roleDO1.getUpdateTime(), roleDO2.getUpdateTime()), maxUpdateTime);
+    }
+
+    @Test
+    public void testCreateRole_success() {
+        SysRoleCreateReqVO reqVO = randomPojo(SysRoleCreateReqVO.class, o -> {
+            o.setCode("role_code");
+            o.setName("role_name");
+            o.setRemark("remark");
+            o.setType(SysRoleTypeEnum.CUSTOM.getType());
+            o.setSort(1);
+        });
+        Long roleId = sysRoleService.createRole(reqVO);
+
+        //断言
+        assertNotNull(roleId);
+        SysRoleDO roleDO = roleMapper.selectById(roleId);
+        assertPojoEquals(reqVO, roleDO);
+
+        verify(sysRoleProducer).sendRoleRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateRole_success() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        //调用
+        SysRoleUpdateReqVO reqVO = randomPojo(SysRoleUpdateReqVO.class, o -> {
+            o.setId(roleId);
+            o.setCode("role_code");
+            o.setName("update_name");
+            o.setType(SysRoleTypeEnum.SYSTEM.getType());
+            o.setSort(999);
+        });
+        sysRoleService.updateRole(reqVO);
+
+        //断言
+        SysRoleDO newRoleDO = roleMapper.selectById(roleId);
+        assertPojoEquals(reqVO, newRoleDO);
+
+        verify(sysRoleProducer).sendRoleRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateRoleStatus_success() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, CommonStatusEnum.ENABLE.getStatus());
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        //调用
+        sysRoleService.updateRoleStatus(roleId, CommonStatusEnum.DISABLE.getStatus());
+
+        //断言
+        SysRoleDO newRoleDO = roleMapper.selectById(roleId);
+        assertEquals(CommonStatusEnum.DISABLE.getStatus(), newRoleDO.getStatus());
+
+        verify(sysRoleProducer).sendRoleRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateRoleDataScope_success() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        //调用
+        Set<Long> deptIdSet = Arrays.asList(1L, 2L, 3L, 4L, 5L).stream().collect(Collectors.toSet());
+        sysRoleService.updateRoleDataScope(roleId, DataScopeEnum.DEPT_CUSTOM.getScore(), deptIdSet);
+
+        //断言
+        SysRoleDO newRoleDO = roleMapper.selectById(roleId);
+        assertEquals(DataScopeEnum.DEPT_CUSTOM.getScore(), newRoleDO.getDataScope());
+
+        Set<Long> newDeptIdSet = newRoleDO.getDataScopeDeptIds();
+        assertTrue(deptIdSet.size() == newDeptIdSet.size());
+        deptIdSet.stream().forEach(d -> assertTrue(newDeptIdSet.contains(d)));
+
+        verify(sysRoleProducer).sendRoleRefreshMessage();
+    }
+
+    @Test
+    public void testDeleteRole_success() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        //调用
+        sysRoleService.deleteRole(roleId);
+
+        //断言
+        SysRoleDO newRoleDO = roleMapper.selectById(roleId);
+        assertNull(newRoleDO);
+
+        verify(sysRoleProducer).sendRoleRefreshMessage();
+    }
+
+    @Test
+    public void testGetRoles_success() {
+        Map<Long, SysRoleDO> idRoleMap = new HashMap<>();
+        // 验证查询状态为1的角色
+        SysRoleDO roleDO1 = createRoleDO("role1", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 1);
+        roleMapper.insert(roleDO1);
+        idRoleMap.put(roleDO1.getId(), roleDO1);
+
+        SysRoleDO roleDO2 = createRoleDO("role2", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 1);
+        roleMapper.insert(roleDO2);
+        idRoleMap.put(roleDO2.getId(), roleDO2);
+
+        // 以下是排除的角色
+        SysRoleDO roleDO3 = createRoleDO("role3", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 2);
+        roleMapper.insert(roleDO3);
+
+        //调用
+        List<SysRoleDO> roles = sysRoleService.getRoles(Arrays.asList(1));
+
+        //断言
+        assertEquals(2, roles.size());
+        roles.stream().forEach(r -> assertPojoEquals(idRoleMap.get(r.getId()), r));
+
+    }
+
+    @Test
+    public void testGetRolePage_success() {
+        Map<Long, SysRoleDO> idRoleMap = new HashMap<>();
+        // 验证名称包含"role", 状态为1,code为"code"的角色
+        // 第一页
+        SysRoleDO roleDO = createRoleDO("role1", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 1, "code");
+        roleMapper.insert(roleDO);
+        idRoleMap.put(roleDO.getId(), roleDO);
+        // 第二页
+        roleDO = createRoleDO("role2", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 1, "code");
+        roleMapper.insert(roleDO);
+
+        // 以下是排除的角色
+        roleDO = createRoleDO("role3", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 2, "code");
+        roleMapper.insert(roleDO);
+        roleDO = createRoleDO("role4", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL, 1, "xxxxx");
+        roleMapper.insert(roleDO);
+
+        //调用
+        SysRolePageReqVO reqVO = randomPojo(SysRolePageReqVO.class, o -> {
+            o.setName("role");
+            o.setCode("code");
+            o.setStatus(1);
+            o.setPageNo(1);
+            o.setPageSize(1);
+            o.setBeginTime(null);
+            o.setEndTime(null);
+        });
+        PageResult<SysRoleDO> result = sysRoleService.getRolePage(reqVO);
+        assertEquals(2, result.getTotal());
+        result.getList().stream().forEach(r -> assertPojoEquals(idRoleMap.get(r.getId()), r));
+    }
+
+    @Test
+    public void testCheckDuplicateRole_success() {
+        sysRoleService.checkDuplicateRole(randomString(), randomString(), null);
+    }
+
+    @Test
+    public void testCheckDuplicateRole_nameDuplicate() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+
+        String duplicateName = "role_name";
+
+        assertServiceException(() -> sysRoleService.checkDuplicateRole(duplicateName, randomString(), null), ROLE_NAME_DUPLICATE, duplicateName);
+    }
+
+    @Test
+    public void testCheckDuplicateRole_codeDuplicate() {
+        SysRoleDO roleDO = randomPojo(SysRoleDO.class, o -> {
+            o.setName("role_999");
+            o.setCode("code");
+            o.setType(SysRoleTypeEnum.CUSTOM.getType());
+            o.setStatus(1);
+            o.setDataScope(DataScopeEnum.ALL.getScore());
+        });
+        roleMapper.insert(roleDO);
+
+        String randomName = randomString();
+        String duplicateCode = "code";
+
+        assertServiceException(() -> sysRoleService.checkDuplicateRole(randomName, duplicateCode, null), ROLE_CODE_DUPLICATE, duplicateCode);
+    }
+
+    @Test
+    public void testCheckUpdateRole_success() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.CUSTOM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        sysRoleService.checkUpdateRole(roleId);
+    }
+
+    @Test
+    public void testCheckUpdateRole_roleIdNotExist() {
+        assertServiceException(() -> sysRoleService.checkUpdateRole(randomLongId()), ROLE_NOT_EXISTS);
+    }
+
+    @Test
+    public void testCheckUpdateRole_systemRoleCanNotBeUpdate() {
+        SysRoleDO roleDO = createRoleDO("role_name", SysRoleTypeEnum.SYSTEM, DataScopeEnum.ALL);
+        roleMapper.insert(roleDO);
+        Long roleId = roleDO.getId();
+
+        assertServiceException(() -> sysRoleService.checkUpdateRole(roleId), ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE);
+    }
+
+    private SysRoleDO createRoleDO(String name, SysRoleTypeEnum typeEnum, DataScopeEnum scopeEnum, Integer status) {
+        return createRoleDO( name, typeEnum, scopeEnum, status, randomString());
+    }
+
+    private SysRoleDO createRoleDO(String name, SysRoleTypeEnum typeEnum, DataScopeEnum scopeEnum, Integer status, String code) {
+        return createRoleDO(null, name, typeEnum, scopeEnum, status, code);
+    }
+
+    private SysRoleDO createRoleDO(String name, SysRoleTypeEnum typeEnum, DataScopeEnum scopeEnum) {
+        return createRoleDO(null, name, typeEnum, scopeEnum, randomCommonStatus(), randomString());
+    }
+
+    private SysRoleDO createRoleDO(Long id, String name, SysRoleTypeEnum typeEnum, DataScopeEnum scopeEnum, Integer status, String code) {
+        SysRoleDO roleDO = randomPojo(SysRoleDO.class, o -> {
+            o.setId(id);
+            o.setName(name);
+            o.setType(typeEnum.getType());
+            o.setStatus(status);
+            o.setDataScope(scopeEnum.getScore());
+            o.setCode(code);
+        });
+        return roleDO;
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelServiceTest.java
new file mode 100644
index 000000000..a662b82aa
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsChannelServiceTest.java
@@ -0,0 +1,202 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.iocoder.dashboard.BaseDbUnitTest;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelPageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.channel.SysSmsChannelUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsChannelMapper;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsChannelServiceImpl;
+import cn.iocoder.dashboard.util.collection.ArrayUtils;
+import cn.iocoder.dashboard.util.object.ObjectUtils;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.function.Consumer;
+
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.SMS_CHANNEL_HAS_CHILDREN;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.SMS_CHANNEL_NOT_EXISTS;
+import static cn.iocoder.dashboard.util.AssertUtils.*;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static cn.iocoder.dashboard.util.date.DateUtils.buildTime;
+import static cn.iocoder.dashboard.util.object.ObjectUtils.max;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+* {@link SysSmsChannelServiceImpl} 的单元测试类
+*
+* @author 芋道源码
+*/
+@Import(SysSmsChannelServiceImpl.class)
+public class SysSmsChannelServiceTest extends BaseDbUnitTest {
+
+    @Resource
+    private SysSmsChannelServiceImpl smsChannelService;
+
+    @Resource
+    private SysSmsChannelMapper smsChannelMapper;
+
+    @MockBean
+    private SmsClientFactory smsClientFactory;
+    @MockBean
+    private SysSmsTemplateService smsTemplateService;
+    @MockBean
+    private SysSmsProducer smsProducer;
+
+    @Test
+    public void testInitLocalCache_success() {
+        // mock 数据s
+        SysSmsChannelDO smsChannelDO01 = randomSmsChannelDO();
+        smsChannelMapper.insert(smsChannelDO01);
+        SysSmsChannelDO smsChannelDO02 = randomSmsChannelDO();
+        smsChannelMapper.insert(smsChannelDO02);
+
+        // 调用
+        smsChannelService.initSmsClients();
+        // 校验 maxUpdateTime 属性
+        Date maxUpdateTime = (Date) BeanUtil.getFieldValue(smsChannelService, "maxUpdateTime");
+        assertEquals(max(smsChannelDO01.getUpdateTime(), smsChannelDO02.getUpdateTime()), maxUpdateTime);
+        // 校验调用
+        verify(smsClientFactory, times(1)).createOrUpdateSmsClient(
+                argThat(properties -> isPojoEquals(smsChannelDO01, properties)));
+        verify(smsClientFactory, times(1)).createOrUpdateSmsClient(
+                argThat(properties -> isPojoEquals(smsChannelDO02, properties)));
+    }
+
+    @Test
+    public void testCreateSmsChannel_success() {
+        // 准备参数
+        SysSmsChannelCreateReqVO reqVO = randomPojo(SysSmsChannelCreateReqVO.class, o -> o.setStatus(randomCommonStatus()));
+
+        // 调用
+        Long smsChannelId = smsChannelService.createSmsChannel(reqVO);
+        // 断言
+        assertNotNull(smsChannelId);
+        // 校验记录的属性是否正确
+        SysSmsChannelDO smsChannel = smsChannelMapper.selectById(smsChannelId);
+        assertPojoEquals(reqVO, smsChannel);
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsChannelRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateSmsChannel_success() {
+        // mock 数据
+        SysSmsChannelDO dbSmsChannel = randomSmsChannelDO();
+        smsChannelMapper.insert(dbSmsChannel);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        SysSmsChannelUpdateReqVO reqVO = randomPojo(SysSmsChannelUpdateReqVO.class, o -> {
+            o.setId(dbSmsChannel.getId()); // 设置更新的 ID
+            o.setStatus(randomCommonStatus());
+            o.setCallbackUrl(randomString());
+        });
+
+        // 调用
+        smsChannelService.updateSmsChannel(reqVO);
+        // 校验是否更新正确
+        SysSmsChannelDO smsChannel = smsChannelMapper.selectById(reqVO.getId()); // 获取最新的
+        assertPojoEquals(reqVO, smsChannel);
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsChannelRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateSmsChannel_notExists() {
+        // 准备参数
+        SysSmsChannelUpdateReqVO reqVO = randomPojo(SysSmsChannelUpdateReqVO.class);
+
+        // 调用, 并断言异常
+        assertServiceException(() -> smsChannelService.updateSmsChannel(reqVO), SMS_CHANNEL_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteSmsChannel_success() {
+        // mock 数据
+        SysSmsChannelDO dbSmsChannel = randomSmsChannelDO();
+        smsChannelMapper.insert(dbSmsChannel);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbSmsChannel.getId();
+
+        // 调用
+        smsChannelService.deleteSmsChannel(id);
+       // 校验数据不存在了
+       assertNull(smsChannelMapper.selectById(id));
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsChannelRefreshMessage();
+    }
+
+    @Test
+    public void testDeleteSmsChannel_notExists() {
+        // 准备参数
+        Long id = randomLongId();
+
+        // 调用, 并断言异常
+        assertServiceException(() -> smsChannelService.deleteSmsChannel(id), SMS_CHANNEL_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteSmsChannel_hasChildren() {
+        // mock 数据
+        SysSmsChannelDO dbSmsChannel = randomSmsChannelDO();
+        smsChannelMapper.insert(dbSmsChannel);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbSmsChannel.getId();
+        // mock 方法
+        when(smsTemplateService.countByChannelId(eq(id))).thenReturn(10);
+
+        // 调用, 并断言异常
+        assertServiceException(() -> smsChannelService.deleteSmsChannel(id), SMS_CHANNEL_HAS_CHILDREN);
+    }
+
+    @Test
+    public void testGetSmsChannelPage() {
+       // mock 数据
+       SysSmsChannelDO dbSmsChannel = randomPojo(SysSmsChannelDO.class, o -> { // 等会查询到
+           o.setSignature("芋道源码");
+           o.setStatus(CommonStatusEnum.ENABLE.getStatus());
+           o.setCreateTime(buildTime(2020, 12, 12));
+       });
+       smsChannelMapper.insert(dbSmsChannel);
+       // 测试 signature 不匹配
+       smsChannelMapper.insert(ObjectUtils.clone(dbSmsChannel, o -> o.setSignature("源码")));
+       // 测试 status 不匹配
+       smsChannelMapper.insert(ObjectUtils.clone(dbSmsChannel, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
+       // 测试 createTime 不匹配
+       smsChannelMapper.insert(ObjectUtils.clone(dbSmsChannel, o -> o.setCreateTime(buildTime(2020, 11, 11))));
+       // 准备参数
+       SysSmsChannelPageReqVO reqVO = new SysSmsChannelPageReqVO();
+       reqVO.setSignature("芋道");
+       reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
+       reqVO.setBeginCreateTime(buildTime(2020, 12, 1));
+       reqVO.setEndCreateTime(buildTime(2020, 12, 24));
+
+       // 调用
+       PageResult<SysSmsChannelDO> pageResult = smsChannelService.getSmsChannelPage(reqVO);
+       // 断言
+       assertEquals(1, pageResult.getTotal());
+       assertEquals(1, pageResult.getList().size());
+       assertPojoEquals(dbSmsChannel, pageResult.getList().get(0));
+    }
+
+    // ========== 随机对象 ==========
+
+    @SafeVarargs
+    private static SysSmsChannelDO randomSmsChannelDO(Consumer<SysSmsChannelDO>... consumers) {
+        Consumer<SysSmsChannelDO> consumer = (o) -> {
+            o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
+        };
+        return randomPojo(SysSmsChannelDO.class, ArrayUtils.append(consumer, consumers));
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogServiceTest.java
new file mode 100644
index 000000000..b7152d1ba
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsLogServiceTest.java
@@ -0,0 +1,248 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.hutool.core.map.MapUtil;
+import cn.iocoder.dashboard.BaseDbUnitTest;
+import cn.iocoder.dashboard.common.enums.UserTypeEnum;
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.log.SysSmsLogPageReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsLogDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsLogMapper;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsReceiveStatusEnum;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsSendStatusEnum;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsTemplateTypeEnum;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsLogServiceImpl;
+import cn.iocoder.dashboard.util.collection.ArrayUtils;
+import cn.iocoder.dashboard.util.object.ObjectUtils;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static cn.hutool.core.util.RandomUtil.randomBoolean;
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.iocoder.dashboard.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static cn.iocoder.dashboard.util.date.DateUtils.buildTime;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+* {@link SysSmsLogServiceImpl} 的单元测试类
+*
+* @author 芋道源码
+*/
+@Import(SysSmsLogServiceImpl.class)
+public class SysSmsLogServiceTest extends BaseDbUnitTest {
+
+    @Resource
+    private SysSmsLogServiceImpl smsLogService;
+
+    @Resource
+    private SysSmsLogMapper smsLogMapper;
+
+    @Test
+    public void testCreateSmsLog() {
+        // 准备参数
+        String mobile = randomString();
+        Long userId = randomLongId();
+        Integer userType = randomEle(UserTypeEnum.values()).getValue();
+        Boolean isSend = randomBoolean();
+        SysSmsTemplateDO templateDO = randomPojo(SysSmsTemplateDO.class,
+                o -> o.setType(randomEle(SysSmsTemplateTypeEnum.values()).getType()));
+        String templateContent = randomString();
+        Map<String, Object> templateParams = randomTemplateParams();
+        // mock 方法
+
+        // 调用
+        Long logId = smsLogService.createSmsLog(mobile, userId, userType, isSend,
+                templateDO, templateContent, templateParams);
+        // 断言
+        SysSmsLogDO logDO = smsLogMapper.selectById(logId);
+        assertEquals(isSend ? SysSmsSendStatusEnum.INIT.getStatus() : SysSmsSendStatusEnum.IGNORE.getStatus(),
+                logDO.getSendStatus());
+        assertEquals(mobile, logDO.getMobile());
+        assertEquals(userType, logDO.getUserType());
+        assertEquals(userId, logDO.getUserId());
+        assertEquals(templateDO.getId(), logDO.getTemplateId());
+        assertEquals(templateDO.getCode(), logDO.getTemplateCode());
+        assertEquals(templateDO.getType(), logDO.getTemplateType());
+        assertEquals(templateDO.getChannelId(), logDO.getChannelId());
+        assertEquals(templateDO.getChannelCode(), logDO.getChannelCode());
+        assertEquals(templateContent, logDO.getTemplateContent());
+        assertEquals(templateParams, logDO.getTemplateParams());
+        assertEquals(SysSmsReceiveStatusEnum.INIT.getStatus(), logDO.getReceiveStatus());
+    }
+
+    @Test
+    public void testUpdateSmsSendResult() {
+        // mock 数据
+        SysSmsLogDO dbSmsLog = randomSmsLogDO(
+                o -> o.setSendStatus(SysSmsSendStatusEnum.IGNORE.getStatus()));
+        smsLogMapper.insert(dbSmsLog);
+        // 准备参数
+        Long id = dbSmsLog.getId();
+        Integer sendCode = randomInteger();
+        String sendMsg = randomString();
+        String apiSendCode = randomString();
+        String apiSendMsg = randomString();
+        String apiRequestId = randomString();
+        String apiSerialNo = randomString();
+
+        // 调用
+        smsLogService.updateSmsSendResult(id, sendCode, sendMsg,
+                apiSendCode, apiSendMsg, apiRequestId, apiSerialNo);
+        // 断言
+        dbSmsLog = smsLogMapper.selectById(id);
+        assertEquals(CommonResult.isSuccess(sendCode) ? SysSmsSendStatusEnum.SUCCESS.getStatus()
+                : SysSmsSendStatusEnum.FAILURE.getStatus(), dbSmsLog.getSendStatus());
+        assertNotNull(dbSmsLog.getSendTime());
+        assertEquals(sendMsg, dbSmsLog.getSendMsg());
+        assertEquals(apiSendCode, dbSmsLog.getApiSendCode());
+        assertEquals(apiSendMsg, dbSmsLog.getApiSendMsg());
+        assertEquals(apiRequestId, dbSmsLog.getApiRequestId());
+        assertEquals(apiSerialNo, dbSmsLog.getApiSerialNo());
+    }
+
+    @Test
+    public void testUpdateSmsReceiveResult() {
+        // mock 数据
+        SysSmsLogDO dbSmsLog = randomSmsLogDO(
+                o -> o.setReceiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus()));
+        smsLogMapper.insert(dbSmsLog);
+        // 准备参数
+        Long id = dbSmsLog.getId();
+        Boolean success = randomBoolean();
+        Date receiveTime = randomDate();
+        String apiReceiveCode = randomString();
+        String apiReceiveMsg = randomString();
+
+        // 调用
+        smsLogService.updateSmsReceiveResult(id, success, receiveTime, apiReceiveCode, apiReceiveMsg);
+        // 断言
+        dbSmsLog = smsLogMapper.selectById(id);
+        assertEquals(success ? SysSmsReceiveStatusEnum.SUCCESS.getStatus()
+                : SysSmsReceiveStatusEnum.FAILURE.getStatus(), dbSmsLog.getReceiveStatus());
+        assertEquals(receiveTime, dbSmsLog.getReceiveTime());
+        assertEquals(apiReceiveCode, dbSmsLog.getApiReceiveCode());
+        assertEquals(apiReceiveMsg, dbSmsLog.getApiReceiveMsg());
+    }
+
+    @Test
+    public void testGetSmsLogPage() {
+       // mock 数据
+       SysSmsLogDO dbSmsLog = randomSmsLogDO(o -> { // 等会查询到
+           o.setChannelId(1L);
+           o.setTemplateId(10L);
+           o.setMobile("15601691300");
+           o.setSendStatus(SysSmsSendStatusEnum.INIT.getStatus());
+           o.setSendTime(buildTime(2020, 11, 11));
+           o.setReceiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus());
+           o.setReceiveTime(buildTime(2021, 11, 11));
+       });
+       smsLogMapper.insert(dbSmsLog);
+       // 测试 channelId 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setChannelId(2L)));
+       // 测试 templateId 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setTemplateId(20L)));
+       // 测试 mobile 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setMobile("18818260999")));
+       // 测试 sendStatus 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setSendStatus(SysSmsSendStatusEnum.IGNORE.getStatus())));
+       // 测试 sendTime 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setSendTime(buildTime(2020, 12, 12))));
+       // 测试 receiveStatus 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setReceiveStatus(SysSmsReceiveStatusEnum.SUCCESS.getStatus())));
+       // 测试 receiveTime 不匹配
+       smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setReceiveTime(buildTime(2021, 12, 12))));
+       // 准备参数
+       SysSmsLogPageReqVO reqVO = new SysSmsLogPageReqVO();
+       reqVO.setChannelId(1L);
+       reqVO.setTemplateId(10L);
+       reqVO.setMobile("156");
+       reqVO.setSendStatus(SysSmsSendStatusEnum.INIT.getStatus());
+       reqVO.setBeginSendTime(buildTime(2020, 11, 1));
+       reqVO.setEndSendTime(buildTime(2020, 11, 30));
+       reqVO.setReceiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus());
+       reqVO.setBeginReceiveTime(buildTime(2021, 11, 1));
+       reqVO.setEndReceiveTime(buildTime(2021, 11, 30));
+
+       // 调用
+       PageResult<SysSmsLogDO> pageResult = smsLogService.getSmsLogPage(reqVO);
+       // 断言
+       assertEquals(1, pageResult.getTotal());
+       assertEquals(1, pageResult.getList().size());
+       assertPojoEquals(dbSmsLog, pageResult.getList().get(0));
+    }
+
+    @Test
+    public void testGetSmsLogList() {
+        // mock 数据
+        SysSmsLogDO dbSmsLog = randomSmsLogDO(o -> { // 等会查询到
+            o.setChannelId(1L);
+            o.setTemplateId(10L);
+            o.setMobile("15601691300");
+            o.setSendStatus(SysSmsSendStatusEnum.INIT.getStatus());
+            o.setSendTime(buildTime(2020, 11, 11));
+            o.setReceiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus());
+            o.setReceiveTime(buildTime(2021, 11, 11));
+        });
+        smsLogMapper.insert(dbSmsLog);
+        // 测试 channelId 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setChannelId(2L)));
+        // 测试 templateId 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setTemplateId(20L)));
+        // 测试 mobile 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setMobile("18818260999")));
+        // 测试 sendStatus 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setSendStatus(SysSmsSendStatusEnum.IGNORE.getStatus())));
+        // 测试 sendTime 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setSendTime(buildTime(2020, 12, 12))));
+        // 测试 receiveStatus 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setReceiveStatus(SysSmsReceiveStatusEnum.SUCCESS.getStatus())));
+        // 测试 receiveTime 不匹配
+        smsLogMapper.insert(ObjectUtils.clone(dbSmsLog, o -> o.setReceiveTime(buildTime(2021, 12, 12))));
+        // 准备参数
+        SysSmsLogExportReqVO reqVO = new SysSmsLogExportReqVO();
+        reqVO.setChannelId(1L);
+        reqVO.setTemplateId(10L);
+        reqVO.setMobile("156");
+        reqVO.setSendStatus(SysSmsSendStatusEnum.INIT.getStatus());
+        reqVO.setBeginSendTime(buildTime(2020, 11, 1));
+        reqVO.setEndSendTime(buildTime(2020, 11, 30));
+        reqVO.setReceiveStatus(SysSmsReceiveStatusEnum.INIT.getStatus());
+        reqVO.setBeginReceiveTime(buildTime(2021, 11, 1));
+        reqVO.setEndReceiveTime(buildTime(2021, 11, 30));
+
+       // 调用
+       List<SysSmsLogDO> list = smsLogService.getSmsLogList(reqVO);
+       // 断言
+       assertEquals(1, list.size());
+       assertPojoEquals(dbSmsLog, list.get(0));
+    }
+
+    // ========== 随机对象 ==========
+
+    @SafeVarargs
+    private static SysSmsLogDO randomSmsLogDO(Consumer<SysSmsLogDO>... consumers) {
+        Consumer<SysSmsLogDO> consumer = (o) -> {
+            o.setTemplateParams(randomTemplateParams());
+            o.setTemplateType(randomEle(SysSmsTemplateTypeEnum.values()).getType()); // 保证 templateType 的范围
+            o.setUserType(randomEle(UserTypeEnum.values()).getValue()); // 保证 userType 的范围
+            o.setSendStatus(randomEle(SysSmsSendStatusEnum.values()).getStatus()); // 保证 sendStatus 的范围
+            o.setReceiveStatus(randomEle(SysSmsReceiveStatusEnum.values()).getStatus()); // 保证 receiveStatus 的范围
+        };
+        return randomPojo(SysSmsLogDO.class, ArrayUtils.append(consumer, consumers));
+    }
+
+
+    private static Map<String, Object> randomTemplateParams() {
+        return MapUtil.<String, Object>builder().put(randomString(), randomString())
+                .put(randomString(), randomString()).build();
+    }
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceTest.java
new file mode 100644
index 000000000..f84e8753f
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsServiceTest.java
@@ -0,0 +1,201 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.hutool.core.map.MapUtil;
+import cn.iocoder.dashboard.BaseMockitoUnitTest;
+import cn.iocoder.dashboard.common.core.KeyValue;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.enums.UserTypeEnum;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsReceiveRespDTO;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsSendRespDTO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsServiceImpl;
+import org.assertj.core.util.Lists;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+import static cn.iocoder.dashboard.util.AssertUtils.assertServiceException;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link SysSmsServiceImpl} 的单元测试类
+ *
+ * @author 芋道源码
+ */
+public class SysSmsServiceTest extends BaseMockitoUnitTest {
+
+    @InjectMocks
+    private SysSmsServiceImpl smsService;
+
+    @Mock
+    private SysSmsTemplateService smsTemplateService;
+    @Mock
+    private SysSmsLogService smsLogService;
+    @Mock
+    private SysSmsProducer smsProducer;
+    @Mock
+    private SmsClientFactory smsClientFactory;
+
+    /**
+     * 发送成功,当短信模板开启时
+     */
+    @Test
+    public void testSendSingleSms_successWhenSmsTemplateEnable() {
+        // 准备参数
+        String mobile = randomString();
+        Long userId = randomLongId();
+        Integer userType = randomEle(UserTypeEnum.values()).getValue();
+        String templateCode = randomString();
+        Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
+                .put("op", "login").build();
+        // mock SmsTemplateService 的方法
+        SysSmsTemplateDO template = randomPojo(SysSmsTemplateDO.class, o -> {
+            o.setStatus(CommonStatusEnum.ENABLE.getStatus());
+            o.setContent("验证码为{code}, 操作为{op}");
+            o.setParams(Lists.newArrayList("code", "op"));
+        });
+        when(smsTemplateService.getSmsTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
+        String content = randomString();
+        when(smsTemplateService.formatSmsTemplateContent(eq(template.getContent()), eq(templateParams)))
+                .thenReturn(content);
+        // mock SmsLogService 的方法
+        Long smsLogId = randomLongId();
+        when(smsLogService.createSmsLog(eq(mobile), eq(userId), eq(userType), eq(Boolean.TRUE), eq(template),
+                eq(content), eq(templateParams))).thenReturn(smsLogId);
+
+        // 调用
+        Long resultSmsLogId = smsService.sendSingleSms(mobile, userId, userType, templateCode, templateParams);
+        // 断言
+        assertEquals(smsLogId, resultSmsLogId);
+        // 断言调用
+        verify(smsProducer, times(1)).sendSmsSendMessage(eq(smsLogId), eq(mobile),
+                eq(template.getChannelId()), eq(template.getApiTemplateId()),
+                eq(Lists.newArrayList(new KeyValue<>("code", "1234"), new KeyValue<>("op", "login"))));
+    }
+
+    /**
+     * 发送成功,当短信模板关闭时
+     */
+    @Test
+    public void testSendSingleSms_successWhenSmsTemplateDisable() {
+        // 准备参数
+        String mobile = randomString();
+        Long userId = randomLongId();
+        Integer userType = randomEle(UserTypeEnum.values()).getValue();
+        String templateCode = randomString();
+        Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
+                .put("op", "login").build();
+        // mock SmsTemplateService 的方法
+        SysSmsTemplateDO template = randomPojo(SysSmsTemplateDO.class, o -> {
+            o.setStatus(CommonStatusEnum.DISABLE.getStatus());
+            o.setContent("验证码为{code}, 操作为{op}");
+            o.setParams(Lists.newArrayList("code", "op"));
+        });
+        when(smsTemplateService.getSmsTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
+        String content = randomString();
+        when(smsTemplateService.formatSmsTemplateContent(eq(template.getContent()), eq(templateParams)))
+                .thenReturn(content);
+        // mock SmsLogService 的方法
+        Long smsLogId = randomLongId();
+        when(smsLogService.createSmsLog(eq(mobile), eq(userId), eq(userType), eq(Boolean.FALSE), eq(template),
+                eq(content), eq(templateParams))).thenReturn(smsLogId);
+
+        // 调用
+        Long resultSmsLogId = smsService.sendSingleSms(mobile, userId, userType, templateCode, templateParams);
+        // 断言
+        assertEquals(smsLogId, resultSmsLogId);
+        // 断言调用
+        verify(smsProducer, times(0)).sendSmsSendMessage(anyLong(), anyString(),
+                anyLong(), any(), anyList());
+    }
+
+    @Test
+    public void testCheckSmsTemplateValid_notExists() {
+        // 准备参数
+        String templateCode = randomString();
+        // mock 方法
+
+        // 调用,并断言异常
+        assertServiceException(() -> smsService.checkSmsTemplateValid(templateCode),
+                SMS_TEMPLATE_NOT_EXISTS);
+    }
+
+    @Test
+    public void testBuildTemplateParams_paramMiss() {
+        // 准备参数
+        SysSmsTemplateDO template = randomPojo(SysSmsTemplateDO.class,
+                o -> o.setParams(Lists.newArrayList("code")));
+        Map<String, Object> templateParams = new HashMap<>();
+        // mock 方法
+
+        // 调用,并断言异常
+        assertServiceException(() -> smsService.buildTemplateParams(template, templateParams),
+                SMS_SEND_MOBILE_TEMPLATE_PARAM_MISS, "code");
+    }
+
+    @Test
+    public void testCheckMobile_notExists() {
+        // 准备参数
+        // mock 方法
+
+        // 调用,并断言异常
+        assertServiceException(() -> smsService.checkMobile(null),
+                SMS_SEND_MOBILE_NOT_EXISTS);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testDoSendSms() {
+        // 准备参数
+        SysSmsSendMessage message = randomPojo(SysSmsSendMessage.class);
+        // mock SmsClientFactory 的方法
+        SmsClient smsClient = spy(SmsClient.class);
+        when(smsClientFactory.getSmsClient(eq(message.getChannelId()))).thenReturn(smsClient);
+        // mock SmsClient 的方法
+        SmsCommonResult<SmsSendRespDTO> sendResult = randomPojo(SmsCommonResult.class, SmsSendRespDTO.class);
+        when(smsClient.sendSms(eq(message.getLogId()), eq(message.getMobile()), eq(message.getApiTemplateId()),
+                eq(message.getTemplateParams()))).thenReturn(sendResult);
+
+        // 调用
+        smsService.doSendSms(message);
+        // 断言
+        verify(smsLogService, times(1)).updateSmsSendResult(eq(message.getLogId()),
+                eq(sendResult.getCode()), eq(sendResult.getMsg()), eq(sendResult.getApiCode()),
+                eq(sendResult.getApiMsg()), eq(sendResult.getApiRequestId()), eq(sendResult.getData().getSerialNo()));
+    }
+
+    @Test
+    public void testReceiveSmsStatus() throws Throwable {
+        // 准备参数
+        String channelCode = randomString();
+        String text = randomString();
+        // mock SmsClientFactory 的方法
+        SmsClient smsClient = spy(SmsClient.class);
+        when(smsClientFactory.getSmsClient(eq(channelCode))).thenReturn(smsClient);
+        // mock SmsClient 的方法
+        List<SmsReceiveRespDTO> receiveResults = randomPojoList(SmsReceiveRespDTO.class);
+
+        // 调用
+        smsService.receiveSmsStatus(channelCode, text);
+        // 断言
+        receiveResults.forEach(result -> {
+            smsLogService.updateSmsReceiveResult(eq(result.getLogId()), eq(result.getSuccess()),
+                    eq(result.getReceiveTime()), eq(result.getErrorCode()), eq(result.getErrorCode()));
+        });
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateServiceTest.java b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateServiceTest.java
new file mode 100644
index 000000000..f4bd9efa6
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/modules/system/service/sms/SysSmsTemplateServiceTest.java
@@ -0,0 +1,380 @@
+package cn.iocoder.dashboard.modules.system.service.sms;
+
+import cn.iocoder.dashboard.BaseDbUnitTest;
+import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
+import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.dashboard.common.pojo.PageResult;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClient;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsClientFactory;
+import cn.iocoder.dashboard.framework.sms.core.client.SmsCommonResult;
+import cn.iocoder.dashboard.framework.sms.core.client.dto.SmsTemplateRespDTO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateCreateReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateExportReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplatePageReqVO;
+import cn.iocoder.dashboard.modules.system.controller.sms.vo.template.SysSmsTemplateUpdateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsChannelDO;
+import cn.iocoder.dashboard.modules.system.dal.dataobject.sms.SysSmsTemplateDO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.sms.SysSmsTemplateMapper;
+import cn.iocoder.dashboard.modules.system.enums.sms.SysSmsTemplateTypeEnum;
+import cn.iocoder.dashboard.modules.system.mq.producer.sms.SysSmsProducer;
+import cn.iocoder.dashboard.modules.system.service.sms.impl.SysSmsTemplateServiceImpl;
+import cn.iocoder.dashboard.util.collection.ArrayUtils;
+import cn.iocoder.dashboard.util.object.ObjectUtils;
+import com.google.common.collect.Lists;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static cn.hutool.core.bean.BeanUtil.getFieldValue;
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.iocoder.dashboard.modules.system.enums.SysErrorCodeConstants.*;
+import static cn.iocoder.dashboard.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.dashboard.util.AssertUtils.assertServiceException;
+import static cn.iocoder.dashboard.util.RandomUtils.*;
+import static cn.iocoder.dashboard.util.date.DateUtils.buildTime;
+import static cn.iocoder.dashboard.util.object.ObjectUtils.max;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+* {@link SysSmsTemplateServiceImpl} 的单元测试类
+*
+* @author 芋道源码
+*/
+@Import(SysSmsTemplateServiceImpl.class)
+public class SysSmsTemplateServiceTest extends BaseDbUnitTest {
+
+    @Resource
+    private SysSmsTemplateServiceImpl smsTemplateService;
+
+    @Resource
+    private SysSmsTemplateMapper smsTemplateMapper;
+
+    @MockBean
+    private SysSmsChannelService smsChannelService;
+    @MockBean
+    private SmsClientFactory smsClientFactory;
+    @MockBean
+    private SmsClient smsClient;
+    @MockBean
+    private SysSmsProducer smsProducer;
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void testInitLocalCache() {
+        // mock 数据
+        SysSmsTemplateDO smsTemplate01 = randomSmsTemplateDO();
+        smsTemplateMapper.insert(smsTemplate01);
+        SysSmsTemplateDO smsTemplate02 = randomSmsTemplateDO();
+        smsTemplateMapper.insert(smsTemplate02);
+
+        // 调用
+        smsTemplateService.initLocalCache();
+        // 断言 deptCache 缓存
+        Map<String, SysSmsTemplateDO> smsTemplateCache = (Map<String, SysSmsTemplateDO>) getFieldValue(smsTemplateService, "smsTemplateCache");
+        assertEquals(2, smsTemplateCache.size());
+        assertPojoEquals(smsTemplate01, smsTemplateCache.get(smsTemplate01.getCode()));
+        assertPojoEquals(smsTemplate02, smsTemplateCache.get(smsTemplate02.getCode()));
+        // 断言 maxUpdateTime 缓存
+        Date maxUpdateTime = (Date) getFieldValue(smsTemplateService, "maxUpdateTime");
+        assertEquals(max(smsTemplate01.getUpdateTime(), smsTemplate02.getUpdateTime()), maxUpdateTime);
+    }
+
+    @Test
+    public void testParseTemplateContentParams() {
+        // 准备参数
+        String content = "正在进行登录操作{operation},您的验证码是{code}";
+        // mock 方法
+
+        // 调用
+        List<String> params = smsTemplateService.parseTemplateContentParams(content);
+        // 断言
+        assertEquals(Lists.newArrayList("operation", "code"), params);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testCreateSmsTemplate_success() {
+        // 准备参数
+        SysSmsTemplateCreateReqVO reqVO = randomPojo(SysSmsTemplateCreateReqVO.class, o -> {
+            o.setContent("正在进行登录操作{operation},您的验证码是{code}");
+            o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
+            o.setType(randomEle(SysSmsTemplateTypeEnum.values()).getType()); // 保证 type 的 范围
+        });
+        // mock Channel 的方法
+        SysSmsChannelDO channelDO = randomPojo(SysSmsChannelDO.class, o -> {
+            o.setId(reqVO.getChannelId());
+            o.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 保证 status 开启,创建必须处于这个状态
+        });
+        when(smsChannelService.getSmsChannel(eq(channelDO.getId()))).thenReturn(channelDO);
+        // mock 获得 API 短信模板成功
+        when(smsClientFactory.getSmsClient(eq(reqVO.getChannelId()))).thenReturn(smsClient);
+        when(smsClient.getSmsTemplate(eq(reqVO.getApiTemplateId()))).thenReturn(randomPojo(SmsCommonResult.class, SmsTemplateRespDTO.class,
+                o -> o.setCode(GlobalErrorCodeConstants.SUCCESS.getCode())));
+
+        // 调用
+        Long smsTemplateId = smsTemplateService.createSmsTemplate(reqVO);
+        // 断言
+        assertNotNull(smsTemplateId);
+        // 校验记录的属性是否正确
+        SysSmsTemplateDO smsTemplate = smsTemplateMapper.selectById(smsTemplateId);
+        assertPojoEquals(reqVO, smsTemplate);
+        assertEquals(Lists.newArrayList("operation", "code"), smsTemplate.getParams());
+        assertEquals(channelDO.getCode(), smsTemplate.getChannelCode());
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsTemplateRefreshMessage();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testUpdateSmsTemplate_success() {
+        // mock 数据
+        SysSmsTemplateDO dbSmsTemplate = randomSmsTemplateDO();
+        smsTemplateMapper.insert(dbSmsTemplate);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        SysSmsTemplateUpdateReqVO reqVO = randomPojo(SysSmsTemplateUpdateReqVO.class, o -> {
+            o.setId(dbSmsTemplate.getId()); // 设置更新的 ID
+            o.setContent("正在进行登录操作{operation},您的验证码是{code}");
+            o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
+            o.setType(randomEle(SysSmsTemplateTypeEnum.values()).getType()); // 保证 type 的 范围
+        });
+        // mock 方法
+        SysSmsChannelDO channelDO = randomPojo(SysSmsChannelDO.class, o -> {
+            o.setId(reqVO.getChannelId());
+            o.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 保证 status 开启,创建必须处于这个状态
+        });
+        when(smsChannelService.getSmsChannel(eq(channelDO.getId()))).thenReturn(channelDO);
+        // mock 获得 API 短信模板成功
+        when(smsClientFactory.getSmsClient(eq(reqVO.getChannelId()))).thenReturn(smsClient);
+        when(smsClient.getSmsTemplate(eq(reqVO.getApiTemplateId()))).thenReturn(randomPojo(SmsCommonResult.class, SmsTemplateRespDTO.class,
+                o -> o.setCode(GlobalErrorCodeConstants.SUCCESS.getCode())));
+
+        // 调用
+        smsTemplateService.updateSmsTemplate(reqVO);
+        // 校验是否更新正确
+        SysSmsTemplateDO smsTemplate = smsTemplateMapper.selectById(reqVO.getId()); // 获取最新的
+        assertPojoEquals(reqVO, smsTemplate);
+        assertEquals(Lists.newArrayList("operation", "code"), smsTemplate.getParams());
+        assertEquals(channelDO.getCode(), smsTemplate.getChannelCode());
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsTemplateRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateSmsTemplate_notExists() {
+        // 准备参数
+        SysSmsTemplateUpdateReqVO reqVO = randomPojo(SysSmsTemplateUpdateReqVO.class);
+
+        // 调用, 并断言异常
+        assertServiceException(() -> smsTemplateService.updateSmsTemplate(reqVO), SMS_TEMPLATE_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteSmsTemplate_success() {
+        // mock 数据
+        SysSmsTemplateDO dbSmsTemplate = randomSmsTemplateDO();
+        smsTemplateMapper.insert(dbSmsTemplate);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbSmsTemplate.getId();
+
+        // 调用
+        smsTemplateService.deleteSmsTemplate(id);
+       // 校验数据不存在了
+       assertNull(smsTemplateMapper.selectById(id));
+        // 校验调用
+        verify(smsProducer, times(1)).sendSmsTemplateRefreshMessage();
+    }
+
+    @Test
+    public void testDeleteSmsTemplate_notExists() {
+        // 准备参数
+        Long id = randomLongId();
+
+        // 调用, 并断言异常
+        assertServiceException(() -> smsTemplateService.deleteSmsTemplate(id), SMS_TEMPLATE_NOT_EXISTS);
+    }
+
+    @Test
+    public void testGetSmsTemplatePage() {
+       // mock 数据
+       SysSmsTemplateDO dbSmsTemplate = randomPojo(SysSmsTemplateDO.class, o -> { // 等会查询到
+           o.setType(SysSmsTemplateTypeEnum.PROMOTION.getType());
+           o.setStatus(CommonStatusEnum.ENABLE.getStatus());
+           o.setCode("yudaoyuanma");
+           o.setContent("芋道源码");
+           o.setApiTemplateId("yunai");
+           o.setChannelId(1L);
+           o.setCreateTime(buildTime(2021, 11, 11));
+       });
+       smsTemplateMapper.insert(dbSmsTemplate);
+       // 测试 type 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setType(SysSmsTemplateTypeEnum.VERIFICATION_CODE.getType())));
+       // 测试 status 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
+       // 测试 code 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setCode("yuanma")));
+       // 测试 content 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setContent("源码")));
+       // 测试 apiTemplateId 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setApiTemplateId("nai")));
+       // 测试 channelId 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setChannelId(2L)));
+       // 测试 createTime 不匹配
+       smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setCreateTime(buildTime(2021, 12, 12))));
+       // 准备参数
+       SysSmsTemplatePageReqVO reqVO = new SysSmsTemplatePageReqVO();
+       reqVO.setType(SysSmsTemplateTypeEnum.PROMOTION.getType());
+       reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
+       reqVO.setCode("yudao");
+       reqVO.setContent("芋道");
+       reqVO.setApiTemplateId("yu");
+       reqVO.setChannelId(1L);
+       reqVO.setBeginCreateTime(buildTime(2021, 11, 1));
+       reqVO.setEndCreateTime(buildTime(2021, 12, 1));
+
+       // 调用
+       PageResult<SysSmsTemplateDO> pageResult = smsTemplateService.getSmsTemplatePage(reqVO);
+       // 断言
+       assertEquals(1, pageResult.getTotal());
+       assertEquals(1, pageResult.getList().size());
+       assertPojoEquals(dbSmsTemplate, pageResult.getList().get(0));
+    }
+
+    @Test
+    public void testGetSmsTemplateList() {
+        // mock 数据
+        SysSmsTemplateDO dbSmsTemplate = randomPojo(SysSmsTemplateDO.class, o -> { // 等会查询到
+            o.setType(SysSmsTemplateTypeEnum.PROMOTION.getType());
+            o.setStatus(CommonStatusEnum.ENABLE.getStatus());
+            o.setCode("yudaoyuanma");
+            o.setContent("芋道源码");
+            o.setApiTemplateId("yunai");
+            o.setChannelId(1L);
+            o.setCreateTime(buildTime(2021, 11, 11));
+        });
+        smsTemplateMapper.insert(dbSmsTemplate);
+        // 测试 type 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setType(SysSmsTemplateTypeEnum.VERIFICATION_CODE.getType())));
+        // 测试 status 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
+        // 测试 code 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setCode("yuanma")));
+        // 测试 content 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setContent("源码")));
+        // 测试 apiTemplateId 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setApiTemplateId("nai")));
+        // 测试 channelId 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setChannelId(2L)));
+        // 测试 createTime 不匹配
+        smsTemplateMapper.insert(ObjectUtils.clone(dbSmsTemplate, o -> o.setCreateTime(buildTime(2021, 12, 12))));
+        // 准备参数
+        SysSmsTemplateExportReqVO reqVO = new SysSmsTemplateExportReqVO();
+        reqVO.setType(SysSmsTemplateTypeEnum.PROMOTION.getType());
+        reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
+        reqVO.setCode("yudao");
+        reqVO.setContent("芋道");
+        reqVO.setApiTemplateId("yu");
+        reqVO.setChannelId(1L);
+        reqVO.setBeginCreateTime(buildTime(2021, 11, 1));
+        reqVO.setEndCreateTime(buildTime(2021, 12, 1));
+
+       // 调用
+       List<SysSmsTemplateDO> list = smsTemplateService.getSmsTemplateList(reqVO);
+       // 断言
+       assertEquals(1, list.size());
+       assertPojoEquals(dbSmsTemplate, list.get(0));
+    }
+
+    @Test
+    public void testCheckSmsChannel_success() {
+        // 准备参数
+        Long channelId = randomLongId();
+        // mock 方法
+        SysSmsChannelDO channelDO = randomPojo(SysSmsChannelDO.class, o -> {
+            o.setId(channelId);
+            o.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 保证 status 开启,创建必须处于这个状态
+        });
+        when(smsChannelService.getSmsChannel(eq(channelId))).thenReturn(channelDO);
+
+        // 调用
+        SysSmsChannelDO returnChannelDO = smsTemplateService.checkSmsChannel(channelId);
+        // 断言
+        assertPojoEquals(returnChannelDO, channelDO);
+    }
+
+    @Test
+    public void testCheckSmsChannel_notExists() {
+        // 准备参数
+        Long channelId = randomLongId();
+
+        // 调用,校验异常
+        assertServiceException(() -> smsTemplateService.checkSmsChannel(channelId),
+                SMS_CHANNEL_NOT_EXISTS);
+    }
+
+    @Test
+    public void testCheckSmsChannel_disable() {
+        // 准备参数
+        Long channelId = randomLongId();
+        // mock 方法
+        SysSmsChannelDO channelDO = randomPojo(SysSmsChannelDO.class, o -> {
+            o.setId(channelId);
+            o.setStatus(CommonStatusEnum.DISABLE.getStatus()); // 保证 status 禁用,触发失败
+        });
+        when(smsChannelService.getSmsChannel(eq(channelId))).thenReturn(channelDO);
+
+        // 调用,校验异常
+        assertServiceException(() -> smsTemplateService.checkSmsChannel(channelId),
+                SMS_CHANNEL_DISABLE);
+    }
+
+    @Test
+    public void testCheckDictDataValueUnique_success() {
+        // 调用,成功
+        smsTemplateService.checkSmsTemplateCodeDuplicate(randomLongId(), randomString());
+    }
+
+    @Test
+    public void testCheckSmsTemplateCodeDuplicate_valueDuplicateForCreate() {
+        // 准备参数
+        String code = randomString();
+        // mock 数据
+        smsTemplateMapper.insert(randomSmsTemplateDO(o -> o.setCode(code)));
+
+        // 调用,校验异常
+        assertServiceException(() -> smsTemplateService.checkSmsTemplateCodeDuplicate(null, code),
+                SMS_TEMPLATE_CODE_DUPLICATE, code);
+    }
+
+    @Test
+    public void testCheckDictDataValueUnique_valueDuplicateForUpdate() {
+        // 准备参数
+        Long id = randomLongId();
+        String code = randomString();
+        // mock 数据
+        smsTemplateMapper.insert(randomSmsTemplateDO(o -> o.setCode(code)));
+
+        // 调用,校验异常
+        assertServiceException(() -> smsTemplateService.checkSmsTemplateCodeDuplicate(id, code),
+                SMS_TEMPLATE_CODE_DUPLICATE, code);
+    }
+
+    // ========== 随机对象 ==========
+
+    @SafeVarargs
+    private static SysSmsTemplateDO randomSmsTemplateDO(Consumer<SysSmsTemplateDO>... consumers) {
+        Consumer<SysSmsTemplateDO> consumer = (o) -> {
+            o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
+            o.setType(randomEle(SysSmsTemplateTypeEnum.values()).getType()); // 保证 type 的 范围
+        };
+        return randomPojo(SysSmsTemplateDO.class, ArrayUtils.append(consumer, consumers));
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/util/AopTargetUtils.java b/src/test/java/cn/iocoder/dashboard/util/AopTargetUtils.java
new file mode 100644
index 000000000..89a0d93f5
--- /dev/null
+++ b/src/test/java/cn/iocoder/dashboard/util/AopTargetUtils.java
@@ -0,0 +1,46 @@
+package cn.iocoder.dashboard.util;
+
+import cn.hutool.core.bean.BeanUtil;
+import org.springframework.aop.framework.AdvisedSupport;
+import org.springframework.aop.framework.AopProxy;
+import org.springframework.aop.support.AopUtils;
+
+/**
+ * Spring AOP 工具类
+ *
+ * 参考波克尔 http://www.bubuko.com/infodetail-3471885.html 实现
+ */
+public class AopTargetUtils {
+
+    /**
+     * 获取代理的目标对象
+     *
+     * @param proxy 代理对象
+     * @return 目标对象
+     */
+    public static Object getTarget(Object proxy) throws Exception {
+        // 不是代理对象
+        if (!AopUtils.isAopProxy(proxy)) {
+            return proxy;
+        }
+        // Jdk 代理
+        if (AopUtils.isJdkDynamicProxy(proxy)) {
+            return getJdkDynamicProxyTargetObject(proxy);
+        }
+        // Cglib 代理
+        return getCglibProxyTargetObject(proxy);
+    }
+
+    private static Object getCglibProxyTargetObject(Object proxy) throws Exception {
+        Object dynamicAdvisedInterceptor = BeanUtil.getFieldValue(proxy, "CGLIB$CALLBACK_0");
+        AdvisedSupport advisedSupport = (AdvisedSupport) BeanUtil.getFieldValue(dynamicAdvisedInterceptor, "advised");
+        return advisedSupport.getTargetSource().getTarget();
+    }
+
+    private static Object getJdkDynamicProxyTargetObject(Object proxy) throws Exception {
+        AopProxy aopProxy = (AopProxy) BeanUtil.getFieldValue(proxy, "h");
+        AdvisedSupport advisedSupport = (AdvisedSupport) BeanUtil.getFieldValue(aopProxy, "advised");
+        return advisedSupport.getTargetSource().getTarget();
+    }
+
+}
diff --git a/src/test/java/cn/iocoder/dashboard/util/AssertUtils.java b/src/test/java/cn/iocoder/dashboard/util/AssertUtils.java
index 042208530..0d6987549 100644
--- a/src/test/java/cn/iocoder/dashboard/util/AssertUtils.java
+++ b/src/test/java/cn/iocoder/dashboard/util/AssertUtils.java
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.function.Executable;
 
 import java.lang.reflect.Field;
 import java.util.Arrays;
+import java.util.Objects;
 
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -50,6 +51,33 @@ public class AssertUtils {
         });
     }
 
+    /**
+     * 比对两个对象的属性是否一致
+     *
+     * 注意,如果 expected 存在的属性,actual 不存在的时候,会进行忽略
+     *
+     * @param expected 期望对象
+     * @param actual 实际对象
+     * @param ignoreFields 忽略的属性数组
+     * @return 是否一致
+     */
+    public static boolean isPojoEquals(Object expected, Object actual, String... ignoreFields) {
+        Field[] expectedFields = ReflectUtil.getFields(expected.getClass());
+        return Arrays.stream(expectedFields).allMatch(expectedField -> {
+            // 如果是忽略的属性,则不进行比对
+            if (ArrayUtil.contains(ignoreFields, expectedField.getName())) {
+                return true;
+            }
+            // 忽略不存在的属性
+            Field actualField = ReflectUtil.getField(actual.getClass(), expectedField.getName());
+            if (actualField == null) {
+                return true;
+            }
+            return Objects.equals(ReflectUtil.getFieldValue(expected, expectedField),
+                    ReflectUtil.getFieldValue(actual, actualField));
+        });
+    }
+
     /**
      * 执行方法,校验抛出的 Service 是否符合条件
      *
@@ -62,7 +90,7 @@ public class AssertUtils {
         ServiceException serviceException = assertThrows(ServiceException.class, executable);
         // 校验错误码
         Assertions.assertEquals(errorCode.getCode(), serviceException.getCode(), "错误码不匹配");
-        String message = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMessage(), messageParams);
+        String message = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), messageParams);
         Assertions.assertEquals(message, serviceException.getMessage(), "错误提示不匹配");
     }
 
diff --git a/src/test/java/cn/iocoder/dashboard/util/RandomUtils.java b/src/test/java/cn/iocoder/dashboard/util/RandomUtils.java
index c668f980c..717f6d490 100644
--- a/src/test/java/cn/iocoder/dashboard/util/RandomUtils.java
+++ b/src/test/java/cn/iocoder/dashboard/util/RandomUtils.java
@@ -7,9 +7,8 @@ import cn.iocoder.dashboard.modules.system.dal.dataobject.user.SysUserDO;
 import uk.co.jemos.podam.api.PodamFactory;
 import uk.co.jemos.podam.api.PodamFactoryImpl;
 
-import java.util.Arrays;
-import java.util.Date;
-import java.util.Set;
+import java.lang.reflect.Type;
+import java.util.*;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -87,4 +86,21 @@ public class RandomUtils {
         return pojo;
     }
 
+    @SafeVarargs
+    public static <T> T randomPojo(Class<T> clazz, Type type, Consumer<T>... consumers) {
+        T pojo = PODAM_FACTORY.manufacturePojo(clazz, type);
+        // 非空时,回调逻辑。通过它,可以实现 Pojo 的进一步处理
+        if (ArrayUtil.isNotEmpty(consumers)) {
+            Arrays.stream(consumers).forEach(consumer -> consumer.accept(pojo));
+        }
+        return pojo;
+    }
+
+    @SafeVarargs
+    public static <T> List<T> randomPojoList(Class<T> clazz, Consumer<T>... consumers) {
+        int size = RandomUtil.randomInt(0, RANDOM_COLLECTION_LENGTH);
+        return Stream.iterate(0, i -> i).limit(size).map(o -> randomPojo(clazz, consumers))
+                .collect(Collectors.toList());
+    }
+
 }
diff --git a/src/test/resources/sql/clean.sql b/src/test/resources/sql/clean.sql
index c2d1d63d8..2eedb0ed7 100644
--- a/src/test/resources/sql/clean.sql
+++ b/src/test/resources/sql/clean.sql
@@ -19,3 +19,6 @@ DELETE FROM "sys_post";
 DELETE FROM "sys_login_log";
 DELETE FROM "sys_operate_log";
 DELETE FROM "sys_user";
+DELETE FROM "sys_sms_channel";
+DELETE FROM "sys_sms_template";
+DELETE FROM "sys_sms_log";
diff --git a/src/test/resources/sql/create_tables.sql b/src/test/resources/sql/create_tables.sql
index 23bf9c07b..963d18fa5 100644
--- a/src/test/resources/sql/create_tables.sql
+++ b/src/test/resources/sql/create_tables.sql
@@ -47,8 +47,7 @@ CREATE TABLE IF NOT EXISTS "inf_job" (
     PRIMARY KEY ("id")
 ) COMMENT='定时任务表';
 
-DROP TABLE IF EXISTS "inf_job_log";
-CREATE TABLE "inf_job_log" (
+CREATE TABLE IF NOT EXISTS "inf_job_log" (
     "id" bigint(20) NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '日志编号',
     "job_id" bigint(20) NOT NULL COMMENT '任务编号',
     "handler_name" varchar(64) NOT NULL COMMENT '处理器的名字',
@@ -192,8 +191,7 @@ CREATE TABLE IF NOT EXISTS `sys_user_session` (
     PRIMARY KEY (`id`)
 ) COMMENT '用户在线 Session';
 
-CREATE TABLE IF NOT EXISTS "sys_post"
-(
+CREATE TABLE IF NOT EXISTS "sys_post" (
     "id"          bigint      NOT NULL GENERATED BY DEFAULT AS IDENTITY,
     "code"        varchar(64) NOT NULL,
     "name"        varchar(50) NOT NULL,
@@ -208,7 +206,6 @@ CREATE TABLE IF NOT EXISTS "sys_post"
     PRIMARY KEY ("id")
 ) COMMENT '岗位信息表';
 
-
 CREATE TABLE IF NOT EXISTS "sys_notice" (
 	"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
 	"title" varchar(50) NOT NULL COMMENT '公告标题',
@@ -223,7 +220,6 @@ CREATE TABLE IF NOT EXISTS "sys_notice" (
 	PRIMARY KEY("id")
 ) COMMENT '通知公告表';
 
-
 CREATE TABLE IF NOT EXISTS `sys_login_log` (
     `id`          bigint(20)   NOT NULL GENERATED BY DEFAULT AS IDENTITY,
     `log_type`    bigint(4)    NOT NULL,
@@ -240,7 +236,6 @@ CREATE TABLE IF NOT EXISTS `sys_login_log` (
     PRIMARY KEY (`id`)
 ) COMMENT ='系统访问记录';
 
-
 CREATE TABLE IF NOT EXISTS `sys_operate_log` (
     `id`               bigint(20)    NOT NULL GENERATED BY DEFAULT AS IDENTITY,
     `trace_id`         varchar(64)   NOT NULL DEFAULT '',
@@ -346,3 +341,73 @@ CREATE TABLE IF NOT EXISTS "inf_api_error_log" (
  "deleted" bit not null default false,
  primary key ("id")
 ) COMMENT '系统异常日志';
+
+CREATE TABLE IF NOT EXISTS "sys_sms_channel" (
+   "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+   "signature" varchar(10) NOT NULL,
+   "code" varchar(63) NOT NULL,
+   "status" tinyint NOT NULL,
+   "remark" varchar(255) DEFAULT NULL,
+   "api_key" varchar(63) NOT NULL,
+   "api_secret" varchar(63) DEFAULT NULL,
+   "callback_url" varchar(255) DEFAULT NULL,
+   "creator" varchar(64) DEFAULT '',
+   "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+   "updater" varchar(64) DEFAULT '',
+   "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+   "deleted" bit NOT NULL DEFAULT FALSE,
+   PRIMARY KEY ("id")
+) COMMENT '短信渠道';
+
+CREATE TABLE "sys_sms_template" (
+    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+    "type" tinyint NOT NULL,
+    "status" tinyint NOT NULL,
+    "code" varchar(63) NOT NULL,
+    "name" varchar(63) NOT NULL,
+    "content" varchar(255) NOT NULL,
+    "params" varchar(255) NOT NULL,
+    "remark" varchar(255) DEFAULT NULL,
+    "api_template_id" varchar(63) NOT NULL,
+    "channel_id" bigint NOT NULL,
+    "channel_code" varchar(63) NOT NULL,
+    "creator" varchar(64) DEFAULT '',
+    "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updater" varchar(64) DEFAULT '',
+    "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "deleted" bit NOT NULL DEFAULT FALSE,
+    PRIMARY KEY ("id")
+) COMMENT '短信模板';
+
+CREATE TABLE "sys_sms_log" (
+   "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+   "channel_id" bigint NOT NULL,
+   "channel_code" varchar(63) NOT NULL,
+   "template_id" bigint NOT NULL,
+   "template_code" varchar(63) NOT NULL,
+   "template_type" tinyint NOT NULL,
+   "template_content" varchar(255) NOT NULL,
+   "template_params" varchar(255) NOT NULL,
+   "api_template_id" varchar(63) NOT NULL,
+   "mobile" varchar(11) NOT NULL,
+   "user_id" bigint DEFAULT '0',
+   "user_type" tinyint DEFAULT '0',
+   "send_status" tinyint NOT NULL DEFAULT '0',
+   "send_time" timestamp DEFAULT NULL,
+   "send_code" int DEFAULT NULL,
+   "send_msg" varchar(255) DEFAULT NULL,
+   "api_send_code" varchar(63) DEFAULT NULL,
+   "api_send_msg" varchar(255) DEFAULT NULL,
+   "api_request_id" varchar(255) DEFAULT NULL,
+   "api_serial_no" varchar(255) DEFAULT NULL,
+   "receive_status" tinyint NOT NULL DEFAULT '0',
+   "receive_time" timestamp DEFAULT NULL,
+   "api_receive_code" varchar(63) DEFAULT NULL,
+   "api_receive_msg" varchar(255) DEFAULT NULL,
+   "creator" varchar(64) DEFAULT '',
+   "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+   "updater" varchar(64) DEFAULT '',
+   "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+   "deleted" bit NOT NULL DEFAULT FALSE,
+   PRIMARY KEY ("id")
+) COMMENT '短信日志';