feat: add hosted message

This commit is contained in:
engigu
2025-01-01 17:30:54 +08:00
parent 344ace9020
commit 42eb0b2946
18 changed files with 506 additions and 19 deletions
+1
View File
@@ -19,3 +19,4 @@
19. 支持微信测试公众号模板消息发送 19. 支持微信测试公众号模板消息发送
20. 支持设置自定义的定时消息推送 20. 支持设置自定义的定时消息推送
21. 支持sqlite部署,支持不同版本mysql 21. 支持sqlite部署,支持不同版本mysql
22. 增加托管消息,现在可以将站点作为消息的接受,登录站点查看消息
+1
View File
@@ -65,6 +65,7 @@ func Setup() {
&models.SendTasksIns{}, &models.SendTasksIns{},
&models.Settings{}, &models.Settings{},
&models.CronMessages{}, &models.CronMessages{},
&models.HostedMessage{},
} }
for _, table := range tables { for _, table := range tables {
+81
View File
@@ -0,0 +1,81 @@
package models
import (
"fmt"
"message-nest/pkg/util"
)
type HostedMessage struct {
ID int `gorm:"primaryKey" json:"id" `
Title string `json:"title" gorm:"type:text ;"`
Content string `json:"content" gorm:"type:text ;"`
Type string `json:"type" gorm:"type:varchar(100) ;default:'';index"`
CreatedAt util.Time `json:"created_on" gorm:"column:created_on;autoCreateTime "`
UpdatedAt util.Time `json:"modified_on" gorm:"column:modified_on;autoUpdateTime ;"`
}
// Add 添加托管消息
func (message *HostedMessage) Add() error {
if err := db.Create(&message).Error; err != nil {
return err
}
return nil
}
// 托管消息的结果
type HostMessageResult struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Type string `json:"type"`
CreatedOn util.Time `json:"created_on"`
ModifiedOn util.Time `json:"modified_on"`
}
// GetHostMessages 获取所有托管消息记录
func GetHostMessages(pageNum int, pageSize int, text string, maps map[string]interface{}) ([]HostMessageResult, error) {
var datas []HostMessageResult
hostMessageT := GetSchema(HostedMessage{})
query := db.Table(hostMessageT)
//dayVal, ok := maps["day_created_on"]
//if ok {
// delete(maps, "day_created_on")
// query = query.Where(fmt.Sprintf("DATE(%s.created_on) = ?", logt), dayVal)
//}
query = query.Where(maps)
if text != "" {
query = query.Where("title like ? or content like ?", fmt.Sprintf("%%%s%%", text), fmt.Sprintf("%%%s%%", text))
}
query = query.Order("created_on DESC")
if pageSize > 0 || pageNum > 0 {
query = query.Offset(pageNum).Limit(pageSize)
}
query.Scan(&datas)
return datas, nil
}
// GetHostMessagesTotal 获取托管消息总数
func GetHostMessagesTotal(text string, maps map[string]interface{}) (int64, error) {
var total int64
hostMessageT := GetSchema(HostedMessage{})
query := db.Table(hostMessageT)
//dayVal, ok := maps["day_created_on"]
//if ok {
// delete(maps, "day_created_on")
// query = query.Where(fmt.Sprintf("DATE(%s.created_on) = ?", logt), dayVal)
//}
query = query.Where(maps)
if text != "" {
query = query.Where("title like ? or content like ?", fmt.Sprintf("%%%s%%", text), fmt.Sprintf("%%%s%%", text))
}
query.Count(&total)
return total, nil
}
+4
View File
@@ -34,6 +34,10 @@ type InsQyWeiXinConfig struct {
type InsCustomConfig struct { type InsCustomConfig struct {
} }
// InsMessageNestConfig 实例里面的托管消息config
type InsMessageNestConfig struct {
}
// ManyAddTaskIns 批量添加实例 // ManyAddTaskIns 批量添加实例
func ManyAddTaskIns(taskIns []SendTasksIns) error { func ManyAddTaskIns(taskIns []SendTasksIns) error {
tx := db.Begin() tx := db.Begin()
+10 -4
View File
@@ -119,10 +119,11 @@ func DeleteOutDateLogs(keepNum int) (int, error) {
} }
type StatisticData struct { type StatisticData struct {
TodaySuccNum int `json:"today_succ_num"` TodaySuccNum int `json:"today_succ_num"`
TodayFailedNum int `json:"today_failed_num"` TodayFailedNum int `json:"today_failed_num"`
TodayTotalNum int `json:"today_total_num"` TodayTotalNum int `json:"today_total_num"`
MessageTotalNum int `json:"message_total_num"` MessageTotalNum int `json:"message_total_num"`
HostedMessageTotalNum int `json:"hosted_message_total_num"`
LatestSendData []LatestSendData `json:"latest_send_data" gorm:"many2many:latest_send_data;"` LatestSendData []LatestSendData `json:"latest_send_data" gorm:"many2many:latest_send_data;"`
WayCateData []WayCateData `json:"way_cate_data" gorm:"many2many:way_cate_data;"` WayCateData []WayCateData `json:"way_cate_data" gorm:"many2many:way_cate_data;"`
@@ -148,6 +149,7 @@ func GetStatisticData() (StatisticData, error) {
var wayCateData []WayCateData var wayCateData []WayCateData
logt := GetSchema(SendTasksLogs{}) logt := GetSchema(SendTasksLogs{})
inst := GetSchema(SendTasksIns{}) inst := GetSchema(SendTasksIns{})
hostedt := GetSchema(HostedMessage{})
wayst := GetSchema(SendWays{}) wayst := GetSchema(SendWays{})
currDay := util.GetNowTimeStr()[:10] currDay := util.GetNowTimeStr()[:10]
@@ -166,6 +168,10 @@ func GetStatisticData() (StatisticData, error) {
totalQuery := db.Table(logt).Select(`COUNT(*) AS message_total_num`) totalQuery := db.Table(logt).Select(`COUNT(*) AS message_total_num`)
totalQuery.Take(&statistic) totalQuery.Take(&statistic)
// 托管消息统计数据
hostedMessageTotalQuery := db.Table(hostedt).Select(`COUNT(*) AS hosted_message_total_num`)
hostedMessageTotalQuery.Take(&statistic)
// 最近30天数据 // 最近30天数据
days := 30 days := 30
now := util.GetNowTime() now := util.GetNowTime()
+38
View File
@@ -0,0 +1,38 @@
package v1
import (
"github.com/gin-gonic/gin"
"message-nest/pkg/app"
"message-nest/pkg/util"
"message-nest/service/hosted_message_service"
"net/http"
)
// GetHostMessageList 获取托管消息列表
func GetHostMessageList(c *gin.Context) {
appG := app.Gin{C: c}
text := c.Query("text")
offset, limit := util.GetPageSize(c)
messageService := hosted_message_service.HostMessageService{
Text: text,
PageNum: offset,
PageSize: limit,
}
ways, err := messageService.GetAll()
if err != nil {
appG.CResponse(http.StatusInternalServerError, "获取托管消息失败!", nil)
return
}
count, err := messageService.Count()
if err != nil {
appG.CResponse(http.StatusInternalServerError, "获取托管消息总数失败!", nil)
return
}
appG.CResponse(http.StatusOK, "获取托管消息成功", map[string]interface{}{
"lists": ways,
"total": count,
})
}
+1 -1
View File
@@ -97,7 +97,7 @@ func GetMsgSendWayList(c *gin.Context) {
type AddMsgSendWayReq struct { type AddMsgSendWayReq struct {
Name string `json:"name" validate:"required,max=100,min=1" label:"渠道名"` Name string `json:"name" validate:"required,max=100,min=1" label:"渠道名"`
Type string `json:"type" validate:"required,max=100,min=1" label:"渠道类型"` Type string `json:"type" validate:"required,max=100,min=1" label:"渠道类型"`
Auth string `json:"auth" validate:"required,max=2048,min=6" label:"渠道认证方式"` Auth string `json:"auth" label:"渠道认证方式"`
} }
// AddMsgSendWay 添加发送渠道 // AddMsgSendWay 添加发送渠道
+3
View File
@@ -91,6 +91,9 @@ func InitRouter(f embed.FS) *gin.Engine {
apiV1.POST("/cronmessages/delete", v1.DeleteCronMsgTask) apiV1.POST("/cronmessages/delete", v1.DeleteCronMsgTask)
apiV1.POST("/cronmessages/edit", v1.EditCronMsgTask) apiV1.POST("/cronmessages/edit", v1.EditCronMsgTask)
// hostedMessage
apiV1.GET("/hostedmessages/list", v1.GetHostMessageList)
} }
return app return app
@@ -0,0 +1,54 @@
package hosted_message_service
import (
"message-nest/models"
)
type HostMessageService struct {
ID int
Text string
Title string
Type string
Content string
PageNum int
PageSize int
}
func (st *HostMessageService) Add() error {
model := models.HostedMessage{
Title: st.Title,
Content: st.Content,
Type: st.Type,
}
return model.Add()
}
func (st *HostMessageService) Count() (int64, error) {
return models.GetHostMessagesTotal(st.Text, st.getMaps())
}
func (st *HostMessageService) GetAll() ([]models.HostMessageResult, error) {
tasks, err := models.GetHostMessages(st.PageNum, st.PageSize, st.Text, st.getMaps())
if err != nil {
return nil, err
}
return tasks, nil
}
func (st *HostMessageService) getMaps() map[string]interface{} {
maps := make(map[string]interface{})
//if len(st.Query) > 0 {
// decodedString, err := url.QueryUnescape(st.Query)
// if err != nil {
// logrus.Errorf("queryUrl编码解码失败: %s", err)
// return maps
// }
// err = json.Unmarshal([]byte(decodedString), &maps)
// if err != nil {
// logrus.Errorf("queryJson反序列化失败: %s", err)
// return maps
// }
//}
return maps
}
+4
View File
@@ -39,6 +39,10 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string,
var Config models.InsQyWeiXinConfig var Config models.InsQyWeiXinConfig
return "", Config return "", Config
} }
if ins.WayType == "MessageNest" {
var Config models.InsQyWeiXinConfig
return "", Config
}
if ins.WayType == "Custom" { if ins.WayType == "Custom" {
var Config models.InsCustomConfig var Config models.InsCustomConfig
err := json.Unmarshal([]byte(ins.Config), &Config) err := json.Unmarshal([]byte(ins.Config), &Config)
@@ -0,0 +1,36 @@
package send_message_service
import (
"message-nest/models"
"message-nest/service/hosted_message_service"
"message-nest/service/send_way_service"
)
type HostMessageService struct {
}
// SendHostMessage 执行托管消息记录
func (s *HostMessageService) SendHostMessage(
auth send_way_service.MessageNest,
ins models.SendTasksIns,
typeC string,
title string,
content string) (string, string) {
errMsg := ""
var res string
var err error
messageService := hosted_message_service.HostMessageService{
Title: title,
Content: content,
Type: typeC,
}
err = messageService.Add()
if err != nil {
errMsg = err.Error()
res = "托管消息创建失败!"
} else {
res = "托管消息创建成功!"
}
return string(res), errMsg
}
@@ -194,6 +194,15 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
continue continue
} }
// 托管消息的实例发送
mnt, ok := msgObj.(send_way_service.MessageNest)
if ok {
cs := HostMessageService{}
res, errMsg := cs.SendHostMessage(mnt, ins.SendTasksIns, typeC, sm.Title, content)
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
continue
}
sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道的发信实例: %s\n", ins.ID), SendFail) sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道的发信实例: %s\n", ins.ID), SendFail)
} }
+16 -4
View File
@@ -56,6 +56,10 @@ type WeChatOFAccount struct {
TempID string `json:"tempid" validate:"max=2000" label:"模板消息id"` TempID string `json:"tempid" validate:"max=2000" label:"模板消息id"`
} }
// MessageNest 自托管消息
type MessageNest struct {
}
func (sw *SendWay) GetByID() (interface{}, error) { func (sw *SendWay) GetByID() (interface{}, error) {
return models.GetWayByID(sw.ID) return models.GetWayByID(sw.ID)
} }
@@ -170,6 +174,14 @@ func (sw *SendWay) ValidateDiffWay() (string, interface{}) {
} }
_, Msg := app.CommonPlaygroundValid(wca) _, Msg := app.CommonPlaygroundValid(wca)
return Msg, wca return Msg, wca
} else if sw.Type == "MessageNest" {
var wca MessageNest
err := json.Unmarshal([]byte(sw.Auth), &wca)
if err != nil {
return "自托管消息反序列化失败!", empty
}
_, Msg := app.CommonPlaygroundValid(wca)
return Msg, wca
} }
return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), empty return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), empty
} }
@@ -209,11 +221,11 @@ func (sw *SendWay) TestSendWay(msgObj interface{}) (string, string) {
} }
_, ok = msgObj.(WeChatOFAccount) _, ok = msgObj.(WeChatOFAccount)
if ok { if ok {
//var cli = message.WeChatOFAccount{
// AppID: wca.AppID,
// AppSecret: wca.APPSecret,
//}
return "微信公众号模板消息不用测试运行,请直接添加", "" return "微信公众号模板消息不用测试运行,请直接添加", ""
} }
_, ok = msgObj.(MessageNest)
if ok {
return "自托管消息不用测试运行,请直接添加", ""
}
return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), "" return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), ""
} }
+16 -1
View File
@@ -69,7 +69,7 @@ const CONSTANT = {
}, },
{ {
type: 'Custom', type: 'Custom',
label: '自定义', label: '自定义推送',
inputs: [ inputs: [
{ subLabel: 'webhook地址', value: '', col: 'webhook', desc: "自定义webhook地址" }, { subLabel: 'webhook地址', value: '', col: 'webhook', desc: "自定义webhook地址" },
{ subLabel: '请求体', value: '', col: 'body', desc: "请求体, text内容请使用 TEXT 进行占位\n例如:{\"message\": \"TEXT\", \"foo\": \"bar\"}", isTextArea: true }, { subLabel: '请求体', value: '', col: 'body', desc: "请求体, text内容请使用 TEXT 进行占位\n例如:{\"message\": \"TEXT\", \"foo\": \"bar\"}", isTextArea: true },
@@ -103,6 +103,21 @@ const CONSTANT = {
{ value: '', col: 'to_account', desc: "要发送的OpenId(登录微信公众号后台查看)" }, { value: '', col: 'to_account', desc: "要发送的OpenId(登录微信公众号后台查看)" },
], ],
}, },
{
type: 'MessageNest',
label: '自托管消息',
inputs: [
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
],
tips: {
text: "自托管消息说明", desc: "站点本身会作为消息接收站点,接收、展示推送过来的消息。"
},
taskInsRadios: [
{ subLabel: 'text', content: 'text' },
],
taskInsInputs: [
],
},
], ],
API_VIEW_DATA: [ API_VIEW_DATA: [
{ label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString }, { label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString },
+5
View File
@@ -41,6 +41,11 @@ const router = createRouter({
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
component: () => import('../views/tabsTools/settings/settings.vue') component: () => import('../views/tabsTools/settings/settings.vue')
},
{
path: '/hostedMessage',
name: 'hostedmessage',
component: () => import('../views/tabsTools/hostedMessage/hostedMessage.vue')
}, },
{ {
path: '/:catchAll(.*)', path: '/:catchAll(.*)',
+5 -4
View File
@@ -35,10 +35,11 @@ export default {
const menuData = reactive([ const menuData = reactive([
{ id: '0', title: '数据统计', path: '/statistic' }, { id: '0', title: '数据统计', path: '/statistic' },
{ id: '1', title: '发信日志', path: '/sendlogs' }, { id: '1', title: '发信日志', path: '/sendlogs' },
{ id: '2', title: '定时发信', path: '/cronmessages' }, { id: '2', title: '托管消息', path: '/hostedmessage' },
{ id: '3', title: '发信任务', path: '/sendtasks' }, { id: '3', title: '定时发信', path: '/cronmessages' },
{ id: '4', title: '发信渠道', path: '/sendways' }, { id: '4', title: '发信任务', path: '/sendtasks' },
{ id: '5', title: '设置', path: '/settings' }, { id: '5', title: '发信渠道', path: '/sendways' },
{ id: '6', title: '设置', path: '/settings' },
]); ]);
const checkIsLogin = () => { const checkIsLogin = () => {
@@ -0,0 +1,206 @@
<template>
<div class="main-center-body">
<div class="container">
<div class="search-input-sendways">
<el-input v-model="search" size="small" placeholder="根据关键字搜索相应托管消息" @change="filterFunc()" />
</div>
<div class="search-box">
<el-text class="refresh-time" v-if="refreshText" size="small">{{ refreshText }}</el-text>
<el-input-number class="refresh-box" v-model="refreshSec" size="small" :step="2" :min="5"
controls-position="right" />
<el-switch v-model="refreshSwitch" class="ml-2" width="80" inline-prompt active-text="自动刷新" inactive-text="自动刷新"
@click="clickFreshSwitch" />
</div>
<hr />
<div ref="refContainer">
<el-table :data="tableData" stripe empty-text="托管消息为空" :row-style="rowStyle()">
<el-table-column label="ID" prop="id" width="85px" />
<el-table-column label="消息标题" prop="title" show-overflow-tooltip width="150px" />
<el-table-column label="托管消息" prop="content">
<template #default="scope">
<el-tooltip enterable placement="top">
<template #content>
<div v-html="formatLogDisplayHtml(scope)"></div>
</template>
<span class="log-overflow">{{ scope.row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="发送时间" prop="created_on" width="160px" />
<el-table-column label="详情" prop="status" width="120px" fixed="right">
<template #default="scope">
<el-button link size="small" style="margin-right: 10px;" type="primary"
@click="drawer = true; logText = formatLogDisplayHtml(scope)">查看消息</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-drawer v-model="drawer" :with-header="false">
<el-text v-html="logText" size="small"></el-text>
</el-drawer>
<div class="pagination-block">
<el-pagination layout="prev, pager, next" :total="total" :page-size="pageSize"
@current-change="handPageChange" />
<el-text class="total-tip" size="small">每页{{ pageSize }}{{ total }}</el-text>
</div>
</div>
</div>
</template>
<script>
import { reactive, toRefs, onMounted, watch, ref } from 'vue'
import { request } from '../../../api/api'
import { copyToClipboard } from '../../../util/clipboard.js';
import { useRoute } from 'vue-router';
import { CONSTANT } from '@/constant'
import { usePageState } from '@/store/page_sate.js';
import { CommonUtils } from "@/util/commonUtils.js";
export default {
components: {
},
setup() {
const pageState = usePageState();
const router = useRoute();
const state = reactive({
search: '',
refreshText: '',
refreshSwitch: false,
refreshSec: 20,
refreshIntervalFuncList: [],
optionValue: '',
logText: '',
drawer: false,
tableData: [],
total: CONSTANT.TOTAL,
pageSize: pageState.siteConfigData.pagesize,
currPage: CONSTANT.PAGE,
});
const TransHtml = (raw) => {
if (raw) {
return raw.replace(/\n/g, '<br />')
}
return ''
}
const formatLogDisplayHtml = (scope) => {
let content = TransHtml(scope.row.content);
return content;
}
const handPageChange = async (pageNum) => {
state.currPage = pageNum;
await queryListData(pageNum, state.pageSize);
}
const rowStyle = () => {
return {
'font-size': '13px',
}
}
const clickFreshSwitch = () => {
if (state.refreshSwitch) {
let flag = setInterval(async function () {
await filterFunc();
state.refreshText = `自动刷新于:${CommonUtils.getCurrentTimeStr()}`;
}, state.refreshSec * 1000);
state.refreshIntervalFuncList.push(flag);
} else {
state.refreshIntervalFuncList.forEach(intervalId => {
clearInterval(intervalId);
});
state.refreshIntervalFuncList = [];
state.refreshText = '';
}
}
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
const queryListData = async (page, size, name = '', query = '') => {
let params = { page: page, size: size, text: name, query: query };
const rsp = await request.get('/hostedmessages/list', { params: params });
state.tableData = await rsp.data.data.lists;
state.total = await rsp.data.data.total;
}
onMounted(async () => {
state.search = router.query.name;
await queryListData(1, state.pageSize, router.query.name, router.query.taskid, router.query.query);
});
return {
...toRefs(state), TransHtml, clickFreshSwitch,
rowStyle, handPageChange, filterFunc, copyToClipboard, formatLogDisplayHtml
};
}
}
</script>
<style scoped>
hr {
color: #FAFCFF;
background-color: #FAFCFF;
border-color: #FAFCFF;
}
.container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
max-width: 1000px;
width: 100%;
/* margin-top: -10vh; */
}
.search-box {
float: right;
margin-top: -2px;
}
.refresh-box {
width: 80px;
margin-right: 5px;
}
.refresh-time {
margin-right: 10px;
}
.pagination-block {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
.total-tip {
display: inline-block;
}
.search-input-sendways {
width: 200px;
display: inline-flex;
}
.log-overflow {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
</style>
@@ -1,8 +1,8 @@
<template> <template>
<div class="main-center-body"> <div class="main-center-body">
<div class="container"> <div class="container">
<el-row :gutter="16"> <el-row :gutter="18">
<el-col :span="6"> <el-col :span="4">
<div class="statistic-card"> <div class="statistic-card">
<el-statistic :value="data.message_total_num"> <el-statistic :value="data.message_total_num">
<template #title> <template #title>
@@ -13,7 +13,18 @@
</el-statistic> </el-statistic>
</div> </div>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="5">
<div class="statistic-card">
<el-statistic :value="data.hosted_message_total_num">
<template #title>
<div style="display: inline-flex; align-items: center">
托管消息数
</div>
</template>
</el-statistic>
</div>
</el-col>
<el-col :span="5">
<div class="statistic-card"> <div class="statistic-card">
<el-statistic :value="data.today_total_num"> <el-statistic :value="data.today_total_num">
<template #title> <template #title>
@@ -24,7 +35,7 @@
</el-statistic> </el-statistic>
</div> </div>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="5">
<div class="statistic-card"> <div class="statistic-card">
<el-statistic :value="data.today_succ_num"> <el-statistic :value="data.today_succ_num">
<template #title> <template #title>
@@ -35,7 +46,7 @@
</el-statistic> </el-statistic>
</div> </div>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="5">
<div class="statistic-card"> <div class="statistic-card">
<el-statistic :value="data.today_failed_num" :value-style="formatFailedNumStyle()"> <el-statistic :value="data.today_failed_num" :value-style="formatFailedNumStyle()">
<template #title> <template #title>