feat(群级配置): 新增MySQL+Redis持久缓存并接入进群欢迎差异化配置
新增群级插件配置表与服务层,采用MySQL持久化+Redis长期缓存(TTL=-1);后台新增群级插件配置管理页面与API,支持按群按插件维护JSON配置并在修改后同步回填MySQL和刷新Redis;已将群成员变更监控插件接入该配置,支持欢迎文案与卡片URL等按群差异化。
This commit is contained in:
103
admin/dashboard/blueprints/group_plugin_config.py
Normal file
103
admin/dashboard/blueprints/group_plugin_config.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from .auth import login_required
|
||||
|
||||
group_plugin_config_bp = Blueprint("group_plugin_config", __name__, url_prefix="/group_plugin_config")
|
||||
|
||||
|
||||
def _normalize_datetime_text(value):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
text = str(value)
|
||||
if "T" in text:
|
||||
return text.replace("T", " ")[:19]
|
||||
return text
|
||||
|
||||
|
||||
@group_plugin_config_bp.route("/")
|
||||
@login_required
|
||||
def page_group_plugin_config():
|
||||
return render_template("group_plugin_config.html")
|
||||
|
||||
|
||||
@group_plugin_config_bp.route("/api/list", methods=["GET"])
|
||||
@login_required
|
||||
def api_list_group_plugin_config():
|
||||
server = current_app.dashboard_server
|
||||
service = server.group_plugin_config_service
|
||||
group_id = str(request.args.get("group_id", "") or "").strip()
|
||||
plugin_name = str(request.args.get("plugin_name", "") or "").strip()
|
||||
rows = service.list_configs(group_id=group_id, plugin_name=plugin_name)
|
||||
for row in rows:
|
||||
row["created_at"] = _normalize_datetime_text(row.get("created_at"))
|
||||
row["updated_at"] = _normalize_datetime_text(row.get("updated_at"))
|
||||
return jsonify({"success": True, "data": rows})
|
||||
|
||||
|
||||
@group_plugin_config_bp.route("/api/plugins", methods=["GET"])
|
||||
@login_required
|
||||
def api_list_plugins():
|
||||
server = current_app.dashboard_server
|
||||
plugin_names = sorted([str(p.name) for p in server.plugin_manager.plugins.values()])
|
||||
return jsonify({"success": True, "data": plugin_names})
|
||||
|
||||
|
||||
@group_plugin_config_bp.route("/api/upsert", methods=["POST"])
|
||||
@login_required
|
||||
def api_upsert_group_plugin_config():
|
||||
server = current_app.dashboard_server
|
||||
service = server.group_plugin_config_service
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
group_id = str(payload.get("group_id") or "").strip()
|
||||
plugin_name = str(payload.get("plugin_name") or "").strip()
|
||||
config_key = str(payload.get("config_key") or "default").strip() or "default"
|
||||
enabled = bool(payload.get("enabled", True))
|
||||
config_data = payload.get("config_json")
|
||||
updated_by = str(payload.get("updated_by") or "dashboard").strip() or "dashboard"
|
||||
|
||||
if not group_id or not plugin_name:
|
||||
return jsonify({"success": False, "message": "group_id 或 plugin_name 不能为空"}), 400
|
||||
|
||||
if isinstance(config_data, str):
|
||||
try:
|
||||
config_data = json.loads(config_data)
|
||||
except Exception:
|
||||
return jsonify({"success": False, "message": "config_json 不是合法 JSON"}), 400
|
||||
if not isinstance(config_data, dict):
|
||||
return jsonify({"success": False, "message": "config_json 必须是对象"}), 400
|
||||
|
||||
ok = service.upsert_config(
|
||||
group_id=group_id,
|
||||
plugin_name=plugin_name,
|
||||
config_key=config_key,
|
||||
config_data=config_data,
|
||||
enabled=enabled,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "message": "保存失败"}), 500
|
||||
return jsonify({"success": True, "message": "保存成功(MySQL + Redis已刷新)"})
|
||||
|
||||
|
||||
@group_plugin_config_bp.route("/api/delete", methods=["POST"])
|
||||
@login_required
|
||||
def api_delete_group_plugin_config():
|
||||
server = current_app.dashboard_server
|
||||
service = server.group_plugin_config_service
|
||||
payload = request.get_json(silent=True) or {}
|
||||
group_id = str(payload.get("group_id") or "").strip()
|
||||
plugin_name = str(payload.get("plugin_name") or "").strip()
|
||||
config_key = str(payload.get("config_key") or "default").strip() or "default"
|
||||
if not group_id or not plugin_name:
|
||||
return jsonify({"success": False, "message": "group_id 或 plugin_name 不能为空"}), 400
|
||||
|
||||
ok = service.delete_config(group_id=group_id, plugin_name=plugin_name, config_key=config_key)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "message": "删除失败"}), 500
|
||||
return jsonify({"success": True, "message": "删除成功(MySQL + Redis已同步)"})
|
||||
@@ -50,6 +50,8 @@ class DashboardServer:
|
||||
self.system_job_loader = robot_instance.system_job_loader
|
||||
self.plugin_schedule_db = robot_instance.plugin_schedule_db
|
||||
self.plugin_schedule_manager = robot_instance.plugin_schedule_manager
|
||||
self.group_plugin_config_db = robot_instance.group_plugin_config_db
|
||||
self.group_plugin_config_service = robot_instance.group_plugin_config_service
|
||||
# 获取联系人管理器实例
|
||||
self.contact_manager = robot_instance.contact_manager
|
||||
self.plugin_manager = robot_instance.plugin_manager
|
||||
@@ -154,6 +156,7 @@ class DashboardServer:
|
||||
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
|
||||
from admin.dashboard.blueprints.system_jobs import system_jobs_bp
|
||||
from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp
|
||||
from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp
|
||||
|
||||
# 在app.register_blueprint部分添加
|
||||
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
||||
@@ -170,6 +173,7 @@ class DashboardServer:
|
||||
app.register_blueprint(friend_circle_bp)
|
||||
app.register_blueprint(system_jobs_bp)
|
||||
app.register_blueprint(plugin_schedules_bp)
|
||||
app.register_blueprint(group_plugin_config_bp)
|
||||
|
||||
self.LOG.info("所有蓝图已注册")
|
||||
|
||||
|
||||
@@ -842,6 +842,7 @@
|
||||
{ label: '插件统计', path: '/plugins' },
|
||||
{ label: '插件管理', path: '/plugins_manage' },
|
||||
{ label: '插件定时任务', path: '/plugin_schedules' },
|
||||
{ label: '群级插件配置', path: '/group_plugin_config' },
|
||||
{ label: '接口文档', path: '/api_docs' }
|
||||
]
|
||||
},
|
||||
|
||||
223
admin/dashboard/templates/group_plugin_config.html
Normal file
223
admin/dashboard/templates/group_plugin_config.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}群级插件配置 - 机器人管理后台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-shell">
|
||||
<div class="page-hero">
|
||||
<div class="page-hero-copy">
|
||||
<div class="page-eyebrow">Group Plugin Config</div>
|
||||
<h1>群级插件配置</h1>
|
||||
<p>后台维护按群差异化配置(文案、URL、发送内容)。保存时写入 MySQL 并刷新 Redis 缓存(TTL 永久)。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button type="success" @click="loadRows">刷新</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" style="margin-bottom: 14px;">
|
||||
<el-form inline>
|
||||
<el-form-item label="群">
|
||||
<el-select v-model="filters.group_id" filterable clearable placeholder="全部群" style="width:280px" @change="loadRows">
|
||||
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="插件">
|
||||
<el-select v-model="filters.plugin_name" filterable clearable placeholder="全部插件" style="width:220px" @change="loadRows">
|
||||
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="rows" style="width:100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||
<el-table-column prop="group_id" label="群ID" min-width="190"></el-table-column>
|
||||
<el-table-column prop="plugin_name" label="插件" min-width="140"></el-table-column>
|
||||
<el-table-column prop="config_key" label="配置键" width="120"></el-table-column>
|
||||
<el-table-column label="启用" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{% raw %}{{ scope.row.enabled ? '是' : '否' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="90"></el-table-column>
|
||||
<el-table-column prop="updated_by" label="更新人" width="120"></el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180"></el-table-column>
|
||||
<el-table-column label="配置预览" min-width="260">
|
||||
<template slot-scope="scope">
|
||||
<pre class="detail-pre">{% raw %}{{ JSON.stringify(scope.row.config_json || {}, null, 2) }}{% endraw %}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="danger" plain @click="removeRow(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="editing ? '编辑配置' : '新增配置'" :visible.sync="dialogVisible" width="720px">
|
||||
<el-form label-width="95px">
|
||||
<el-form-item label="群ID">
|
||||
<el-select v-model="form.group_id" filterable placeholder="请选择群" style="width:100%">
|
||||
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="插件">
|
||||
<el-select v-model="form.plugin_name" filterable placeholder="请选择插件" style="width:100%">
|
||||
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="配置键">
|
||||
<el-input v-model="form.config_key" placeholder="default"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item label="JSON配置">
|
||||
<el-input type="textarea" :rows="12" v-model="form.config_json_text"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveForm">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
rows: [],
|
||||
groupOptions: [],
|
||||
pluginOptions: [],
|
||||
filters: { group_id: '', plugin_name: '' },
|
||||
dialogVisible: false,
|
||||
editing: false,
|
||||
form: {
|
||||
group_id: '',
|
||||
plugin_name: '',
|
||||
config_key: 'default',
|
||||
enabled: true,
|
||||
config_json_text: '{}'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadGroups()
|
||||
this.loadPlugins()
|
||||
this.loadRows()
|
||||
},
|
||||
methods: {
|
||||
async loadGroups() {
|
||||
const resp = await axios.get('/contacts/api/groups')
|
||||
const groups = (resp.data && resp.data.data && resp.data.data.groups) || {}
|
||||
this.groupOptions = Object.entries(groups).map(([wxid, name]) => ({ wxid, name: String(name || wxid) }))
|
||||
},
|
||||
async loadPlugins() {
|
||||
const resp = await axios.get('/group_plugin_config/api/plugins')
|
||||
if (resp.data && resp.data.success) {
|
||||
this.pluginOptions = resp.data.data || []
|
||||
}
|
||||
},
|
||||
async loadRows() {
|
||||
this.loading = true
|
||||
try {
|
||||
const resp = await axios.get('/group_plugin_config/api/list', { params: this.filters })
|
||||
if (resp.data && resp.data.success) {
|
||||
this.rows = resp.data.data || []
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
openCreate() {
|
||||
this.editing = false
|
||||
this.form = {
|
||||
group_id: '',
|
||||
plugin_name: '',
|
||||
config_key: 'default',
|
||||
enabled: true,
|
||||
config_json_text: '{}'
|
||||
}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
openEdit(row) {
|
||||
this.editing = true
|
||||
this.form = {
|
||||
group_id: row.group_id || '',
|
||||
plugin_name: row.plugin_name || '',
|
||||
config_key: row.config_key || 'default',
|
||||
enabled: !!row.enabled,
|
||||
config_json_text: JSON.stringify(row.config_json || {}, null, 2)
|
||||
}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async saveForm() {
|
||||
let parsed = {}
|
||||
try {
|
||||
parsed = JSON.parse(this.form.config_json_text || '{}')
|
||||
} catch (e) {
|
||||
this.$message.error('JSON 配置格式错误')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
group_id: this.form.group_id,
|
||||
plugin_name: this.form.plugin_name,
|
||||
config_key: this.form.config_key || 'default',
|
||||
enabled: !!this.form.enabled,
|
||||
config_json: parsed,
|
||||
updated_by: 'dashboard'
|
||||
}
|
||||
const resp = await axios.post('/group_plugin_config/api/upsert', payload)
|
||||
if (resp.data && resp.data.success) {
|
||||
this.$message.success(resp.data.message || '保存成功')
|
||||
this.dialogVisible = false
|
||||
await this.loadRows()
|
||||
return
|
||||
}
|
||||
this.$message.error((resp.data && resp.data.message) || '保存失败')
|
||||
},
|
||||
async removeRow(row) {
|
||||
try {
|
||||
await this.$confirm('确认删除该配置吗?', '提示', { type: 'warning' })
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
const resp = await axios.post('/group_plugin_config/api/delete', {
|
||||
group_id: row.group_id,
|
||||
plugin_name: row.plugin_name,
|
||||
config_key: row.config_key
|
||||
})
|
||||
if (resp.data && resp.data.success) {
|
||||
this.$message.success(resp.data.message || '删除成功')
|
||||
await this.loadRows()
|
||||
return
|
||||
}
|
||||
this.$message.error((resp.data && resp.data.message) || '删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}
|
||||
.page-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}
|
||||
.page-hero-actions{display:flex;align-items:center;gap:12px}
|
||||
.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}
|
||||
.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
|
||||
.page-hero-copy p{color:#64748b;font-size:14px}
|
||||
.detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155;max-height:180px;overflow:auto}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user