feat(群级配置): 新增MySQL+Redis持久缓存并接入进群欢迎差异化配置

新增群级插件配置表与服务层,采用MySQL持久化+Redis长期缓存(TTL=-1);后台新增群级插件配置管理页面与API,支持按群按插件维护JSON配置并在修改后同步回填MySQL和刷新Redis;已将群成员变更监控插件接入该配置,支持欢迎文案与卡片URL等按群差异化。
This commit is contained in:
liuwei
2026-04-20 10:42:46 +08:00
parent 6cf90c02e5
commit d4b7cb32f6
8 changed files with 650 additions and 6 deletions

View 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已同步"})

View File

@@ -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("所有蓝图已注册")

View File

@@ -842,6 +842,7 @@
{ label: '插件统计', path: '/plugins' },
{ label: '插件管理', path: '/plugins_manage' },
{ label: '插件定时任务', path: '/plugin_schedules' },
{ label: '群级插件配置', path: '/group_plugin_config' },
{ label: '接口文档', path: '/api_docs' }
]
},

View 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 %}

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
import json
from typing import Any, Dict, List, Optional
from loguru import logger
from db.base import BaseDBOperator
from db.connection import DBConnectionManager
class GroupPluginConfigDBOperator(BaseDBOperator):
"""群级插件配置数据库操作器。"""
def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager)
def init_tables(self) -> bool:
"""初始化群级插件配置表。"""
try:
return self.execute_update(
"""
CREATE TABLE IF NOT EXISTS t_group_plugin_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id VARCHAR(100) NOT NULL,
plugin_name VARCHAR(128) NOT NULL,
config_key VARCHAR(128) NOT NULL DEFAULT 'default',
config_json JSON NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 1,
updated_by VARCHAR(100) NOT NULL DEFAULT 'system',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_plugin_key (group_id, plugin_name, config_key),
INDEX idx_group_plugin (group_id, plugin_name),
INDEX idx_plugin_name (plugin_name)
)
"""
)
except Exception as e:
logger.error(f"初始化群级插件配置表失败: {e}")
return False
@staticmethod
def _parse_json_field(row: Dict[str, Any], key: str) -> None:
value = row.get(key)
if isinstance(value, str):
try:
row[key] = json.loads(value)
except Exception:
row[key] = {}
elif value is None:
row[key] = {}
def get_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> Optional[Dict[str, Any]]:
"""查询单条群级插件配置。"""
row = self.execute_query(
"""
SELECT * FROM t_group_plugin_config
WHERE group_id = %s AND plugin_name = %s AND config_key = %s
LIMIT 1
""",
(group_id, plugin_name, config_key),
fetch_one=True,
)
if not row:
return None
self._parse_json_field(row, "config_json")
return row
def list_configs(self, group_id: str = "", plugin_name: str = "") -> List[Dict[str, Any]]:
"""按条件列出配置。"""
where_sql = []
params = []
if group_id:
where_sql.append("group_id = %s")
params.append(group_id)
if plugin_name:
where_sql.append("plugin_name = %s")
params.append(plugin_name)
where_clause = f"WHERE {' AND '.join(where_sql)}" if where_sql else ""
rows = self.execute_query(
f"""
SELECT * FROM t_group_plugin_config
{where_clause}
ORDER BY updated_at DESC, id DESC
""",
tuple(params) if params else None,
) or []
for row in rows:
self._parse_json_field(row, "config_json")
return rows
def upsert_config(
self,
group_id: str,
plugin_name: str,
config_key: str,
config_data: Dict[str, Any],
enabled: bool = True,
updated_by: str = "system",
) -> bool:
"""新增或更新配置。"""
return self.execute_update(
"""
INSERT INTO t_group_plugin_config (
group_id, plugin_name, config_key, config_json, enabled, version, updated_by
) VALUES (%s, %s, %s, %s, %s, 1, %s)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
enabled = VALUES(enabled),
updated_by = VALUES(updated_by),
version = version + 1
""",
(
group_id,
plugin_name,
config_key,
json.dumps(config_data or {}, ensure_ascii=False),
1 if enabled else 0,
updated_by,
),
)
def delete_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> bool:
"""删除指定配置。"""
return self.execute_update(
"""
DELETE FROM t_group_plugin_config
WHERE group_id = %s AND plugin_name = %s AND config_key = %s
""",
(group_id, plugin_name, config_key),
)

View File

@@ -6,10 +6,11 @@ from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus
from db.connection import DBConnectionManager
from db.contacts_db import ContactsDBOperator
from utils.group_plugin_config_service import GroupPluginConfigService
from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager
from utils.wechat.contact_manager import ContactManager
from wechat_ipad import WechatAPIClient
from wechat_ipad.models.appmsg_xml import LINK_XML_WELCOME
from wechat_ipad.models.appmsg_xml import LINK_XML_NORMAL, LINK_XML_WELCOME
class GroupMemberChangePlugin(MessagePluginInterface):
@@ -62,6 +63,10 @@ class GroupMemberChangePlugin(MessagePluginInterface):
def initialize(self, context: Dict[str, Any]) -> bool:
"""初始化插件"""
self.LOG.debug(f"正在初始化 {self.name} 插件...")
# 注入群级插件配置服务(由机器人系统上下文提供):
# 1. 优先通过该服务读取“按群差异化”的欢迎文案与卡片配置;
# 2. 未配置时保持原有默认欢迎行为,确保兼容老群。
self.group_plugin_config_service: Optional[GroupPluginConfigService] = context.get("group_plugin_config_service")
self.LOG.debug(f"{self.name} 插件初始化完成")
return True
@@ -157,9 +162,23 @@ class GroupMemberChangePlugin(MessagePluginInterface):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
member_wxids = [wxid]
await bot.send_at_message(roomid, f"👏欢迎 {nickname} 加入群聊!🎉", member_wxids)
members = await bot.get_chatroom_member_detail(wxid, roomid)
head_url = members.get("SmallHeadImgUrl") or members.get("BigHeadImgUrl") or ""
welcome_cfg = self._get_group_welcome_config(roomid)
variables = {
"nickname": nickname,
"wxid": wxid,
"group_id": roomid,
"now": now,
"head_url": head_url,
}
# 文本欢迎:支持后台按群关闭和模板自定义。
if bool(welcome_cfg.get("welcome_text_enabled", True)):
welcome_text = self._safe_format(
welcome_cfg.get("welcome_text_template", "👏欢迎 {nickname} 加入群聊!🎉"),
variables
)
await bot.send_at_message(roomid, welcome_text, member_wxids)
try:
# 更新联系人信息
ContactManager.get_instance().update_head_image(wxid, head_url)
@@ -170,9 +189,14 @@ class GroupMemberChangePlugin(MessagePluginInterface):
contact_db.save_chatroom_member_simple(roomid, member_details)
except Exception as e:
self.LOG.warning(f"新增群员信息失败: {e}")
xml_content = f"{LINK_XML_WELCOME}".format(nickname=nickname, now=now, head_url=head_url)
await bot.send_link_xml_message(xml_content, roomid)
# 欢迎卡片:支持后台按群关闭,且可配置标题/描述/URL/缩略图。
if bool(welcome_cfg.get("welcome_card_enabled", True)):
if self.group_plugin_config_service:
xml_content = self._build_custom_welcome_card_xml(welcome_cfg, variables)
else:
# 老流程兼容:当未接入配置服务时沿用原模板。
xml_content = f"{LINK_XML_WELCOME}".format(nickname=nickname, now=now, head_url=head_url)
await bot.send_link_xml_message(xml_content, roomid)
return True, "已发送进群欢迎语"
return False, "无需执行"
@@ -254,3 +278,52 @@ class GroupMemberChangePlugin(MessagePluginInterface):
self.LOG.warning(f"解析新成员信息失败: {e}")
return new_members
@staticmethod
def _safe_format(template: str, variables: Dict[str, Any]) -> str:
"""安全格式化模板,缺失变量时保留原占位符。"""
text = str(template or "")
for key, value in (variables or {}).items():
text = text.replace(f"{{{key}}}", str(value or ""))
return text
def _get_group_welcome_config(self, group_id: str) -> Dict[str, Any]:
"""读取群级欢迎配置,未配置时返回默认值。"""
default_cfg = {
"welcome_text_enabled": True,
"welcome_text_template": "👏欢迎 {nickname} 加入群聊!🎉",
"welcome_card_enabled": True,
"card_title_template": "👏欢迎 {nickname} 加入群聊!🎉",
"card_desc_template": "⌚时间:{now}",
"card_url": "https://newsnow.busiyi.world/",
"card_thumb_url": "{head_url}",
}
if not self.group_plugin_config_service:
return default_cfg
try:
cfg = self.group_plugin_config_service.get_config(
group_id=group_id,
plugin_name=self.name,
config_key="welcome",
default=default_cfg,
)
if not isinstance(cfg, dict):
return default_cfg
# 默认值兜底,避免后台只配置部分字段时缺项。
return {**default_cfg, **cfg}
except Exception as e:
self.LOG.warning(f"读取群级欢迎配置失败,回退默认配置: group={group_id}, error={e}")
return default_cfg
def _build_custom_welcome_card_xml(self, cfg: Dict[str, Any], variables: Dict[str, Any]) -> str:
"""根据群级配置构建欢迎卡片 XML。"""
title = self._safe_format(cfg.get("card_title_template", ""), variables)
desc = self._safe_format(cfg.get("card_desc_template", ""), variables)
url = self._safe_format(cfg.get("card_url", ""), variables)
thumb_url = self._safe_format(cfg.get("card_thumb_url", ""), variables)
return LINK_XML_NORMAL.format(
title=title,
des=desc,
url=url,
thumburl=thumb_url,
)

View File

@@ -17,10 +17,12 @@ from base.plugin_common.plugin_registry import PluginRegistry
from configuration import Config
from db.connection import DBConnectionManager
from db.contacts_db import ContactsDBOperator
from db.group_plugin_config_db import GroupPluginConfigDBOperator
from db.plugin_schedule_db import PluginScheduleDBOperator
from db.system_job_db import SystemJobDBOperator
from utils.system_jobs import SystemJobLoader
from utils.email_util import EmailSender
from utils.group_plugin_config_service import GroupPluginConfigService
from utils.plugin_schedule_manager import PluginScheduleManager
from utils.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
@@ -68,8 +70,14 @@ class Robot:
self.redis_pool = self.db_manager.redis_pool
self.contacts_db = ContactsDBOperator(self.db_manager)
self.group_plugin_config_db = GroupPluginConfigDBOperator(self.db_manager)
self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager)
self.system_job_db = SystemJobDBOperator(self.db_manager)
self.group_plugin_config_db.init_tables()
self.group_plugin_config_service = GroupPluginConfigService(
db_operator=self.group_plugin_config_db,
redis_client=self.db_manager.get_redis_connection(),
)
# 初始化联系人管理器
self.contact_manager = ContactManager.get_instance()
self.allContacts = {} # 将在登录后填充
@@ -89,7 +97,8 @@ class Robot:
"plugin_registry": self.plugin_registry,
"db_manager": self.db_manager,
"db_pool": self.db_pool,
"redis_pool": self.redis_pool
"redis_pool": self.redis_pool,
"group_plugin_config_service": self.group_plugin_config_service,
}
self.plugin_manager = PluginManager(plugin_dir=getattr(self.config, "plugin_dir", "plugins"))

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import json
from typing import Any, Dict, List, Optional
from loguru import logger
from db.group_plugin_config_db import GroupPluginConfigDBOperator
class GroupPluginConfigService:
"""群级插件配置服务MySQL 持久化 + Redis 永久缓存)。"""
REDIS_KEY_PREFIX = "group:plugin:cfg"
def __init__(self, db_operator: GroupPluginConfigDBOperator, redis_client):
self.db = db_operator
self.redis = redis_client
@classmethod
def _build_cache_key(cls, group_id: str, plugin_name: str, config_key: str = "default") -> str:
return f"{cls.REDIS_KEY_PREFIX}:{group_id}:{plugin_name}:{config_key}"
def _write_cache(self, key: str, payload: Dict[str, Any]) -> None:
"""写入 Redis 缓存TTL=-1永久有效"""
# 需求明确要求长期缓存,这里不设置过期时间,保持 TTL=-1。
self.redis.set(key, json.dumps(payload, ensure_ascii=False))
def _delete_cache(self, key: str) -> None:
"""删除 Redis 缓存。"""
self.redis.delete(key)
def get_config(
self,
group_id: str,
plugin_name: str,
config_key: str = "default",
default: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""读取配置:先 Redis未命中再查 MySQL 并回填 Redis。"""
cache_key = self._build_cache_key(group_id, plugin_name, config_key)
cached = self.redis.get(cache_key)
if cached:
try:
payload = json.loads(cached)
if isinstance(payload, dict):
return payload
except Exception as e:
logger.warning(f"群插件配置缓存解析失败,将回源数据库: key={cache_key}, error={e}")
row = self.db.get_config(group_id, plugin_name, config_key)
if not row:
result = dict(default or {})
# 缓存空结果可减少热点穿透;后续后台更新会主动刷新。
self._write_cache(cache_key, result)
return result
result = row.get("config_json") or {}
if not isinstance(result, dict):
result = {}
self._write_cache(cache_key, result)
return result
def list_configs(self, group_id: str = "", plugin_name: str = "") -> List[Dict[str, Any]]:
"""列出配置(后台展示用)。"""
return self.db.list_configs(group_id=group_id, plugin_name=plugin_name)
def upsert_config(
self,
group_id: str,
plugin_name: str,
config_data: Dict[str, Any],
config_key: str = "default",
enabled: bool = True,
updated_by: str = "system",
) -> bool:
"""写配置:先落 MySQL再刷新 Redis。"""
ok = self.db.upsert_config(
group_id=group_id,
plugin_name=plugin_name,
config_key=config_key,
config_data=config_data or {},
enabled=enabled,
updated_by=updated_by,
)
if not ok:
return False
# 需求要求“修改后刷新 redis 内容”,这里直接回填最新值。
cache_key = self._build_cache_key(group_id, plugin_name, config_key)
self._write_cache(cache_key, config_data or {})
return True
def delete_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> bool:
"""删除配置:删 MySQL 后同步清理 Redis。"""
ok = self.db.delete_config(group_id, plugin_name, config_key)
if ok:
cache_key = self._build_cache_key(group_id, plugin_name, config_key)
self._delete_cache(cache_key)
return ok