苹果cms多域名站群的需求非常常见。默认情况下,站群模式虽然可以轻松展示不同域名,但后台留言、播放进度、用户数据完全共享,容易暴露所有站点都使用同一套数据库,这在 SEO 优化和用户体验上都有一定局限。
本文将分享一套完整方案,实现 多站点留言表独立存储,支持前台留言和后台管理按站点切换,既方便维护,又能在一定程度上降低站群数据混乱的风险。
接下来,我会按照 数据库表复制、模型和控制器修改、后台前台展示调整 的顺序进行讲解,让你一步步实现功能。
注:在开始之前请备份数据库和需要修改的文件
本文以域名 aaa.pro 和 bbb.pro 作为示例
主站 aaa.pro 留言存储表:mac_gbook
子站 bbb.pro 留言存储表:mac_gbook_bbb
代码中不用填写表前缀 mac_
数据库表准备
- 复制默认留言表
mac_gbook为mac_gbook_bbb - 保留字段一致,保证模型操作兼容
- 执行 SQL 示例:
CREATE TABLE `mac_gbook_bbb` LIKE `mac_gbook`;修改模型(Gbook.php)
文件路径:
application/common/model/Gbook.php
完整代码
<?php
namespace app\common\model;
use think\Db;
use think\Request;
class Gbook extends Base {
// 设置数据表(不含前缀)
protected $name = 'gbook';
// 定义时间戳字段名
protected $createTime = '';
protected $updateTime = '';
// 自动完成
protected $auto = [];
protected $insert = [];
protected $update = [];
// 初始化方法,动态设置表名(用于前端提交)
public function initialize()
{
parent::initialize();
// 修复:用 request()->host() 更可靠
$host = Request::instance()->host();
$domain_to_table = [
'aaa.pro' => 'gbook',
'www.aaa.pro' => 'gbook',
'bbb.pro' => 'gbook_bbb',
'www.bbb.pro' => 'gbook_bbb',
];
$this->name = $domain_to_table[$host] ?? 'gbook';
// 可选调试:error_log("Host: $host, Table: " . $this->name); // 生产移除
}
// 静态方法,根据站点返回表名(用于后台跨表)
public static function getTableBySite($site)
{
$domain_to_table = [
'all' => null,
'aaa' => 'gbook',
'bbb' => 'gbook_bbb',
];
return $domain_to_table[$site] ?? 'gbook';
}
public function getGbookStatusTextAttr($val,$data)
{
$arr = [0=>lang('disable'),1=>lang('enable')];
return $arr[$data['gbook_status']];
}
public function listData($where,$order,$page=1,$limit=20,$start=0, $table = null)
{
if(!is_array($where)){
$where = json_decode($where,true);
}
$queryTable = $table ?: $this->name; // 不含前缀
$total = Db::name($queryTable)->where($where)->count();
$limit_str = ($limit * ($page-1) + $start) .",".$limit;
$list = Db::name($queryTable)->where($where)->order($order)->limit($limit_str)->select();
foreach ($list as $k=>$v){
$list[$k]['user_portrait'] = mac_get_user_portrait($v['user_id']);
$list[$k]['gbook_content'] = mac_restore_htmlfilter($list[$k]['gbook_content']);
$list[$k]['gbook_reply'] = mac_restore_htmlfilter($list[$k]['gbook_reply']);
}
return ['code'=>1,'msg'=>lang('data_list'),'page'=>$page,'limit'=>$limit,'total'=>$total,'list'=>$list];
}
public function listCacheData($lp)
{
if (!is_array($lp)) {
$lp = json_decode($lp, true);
}
$order = $lp['order'];
$by = $lp['by'];
$paging = $lp['paging'];
$start = intval(abs($lp['start']));
$num = intval(abs($lp['num']));
$rid = intval(abs($lp['rid']));
$uid = intval(abs($lp['uid']));
$half = intval(abs($lp['half']));
$pageurl = $lp['pageurl'];
$page = 1;
$where = [];
if(empty($num)){
$num = 20;
}
if($start>1){
$start--;
}
if (!in_array($paging, ['yes', 'no'])) {
$paging = 'no';
}
if($paging=='yes') {
$param = mac_param_url();
if(!empty($param['rid'])){
$rid = $param['rid'];
}
if(!empty($param['by'])){
$by = $param['by'];
}
if(!empty($param['order'])){
$order = $param['order'];
}
if(!empty($param['page'])){
$page = intval($param['page']);
}
foreach($param as $k=>$v){
if(empty($v)){
unset($param[$k]);
}
}
if(empty($pageurl)){
$pageurl = 'gbook/index';
}
$param['page'] = 'PAGELINK';
$pageurl = mac_url($pageurl,$param);
}
$where['gbook_status'] = ['eq',1];
if(!empty($rid)){
$where['gbook_rid'] = ['eq',$rid];
}
if(!empty($uid)){
$where['user_id'] = ['eq',$uid];
}
if(!in_array($by, ['id', 'time','reply_time'])) {
$by = 'time';
}
if(!in_array($order, ['asc', 'desc'])) {
$order = 'desc';
}
$order= 'gbook_'.$by .' ' . $order;
$cach_name = $GLOBALS['config']['app']['cache_flag']. '_' .md5('gbook_listcache_'.join('&',$where).'_'.$order.'_'.$page.'_'.$num.'_'.$start);
$res = $this->listData($where,$order,$page,$num,$start);
$res['pageurl'] = $pageurl;
$res['half'] = $half;
return $res;
}
public function infoData($where,$field='*', $table = null)
{
if(empty($where) || !is_array($where)){
return ['code'=>1001,'msg'=>lang('param_err')];
}
$queryTable = $table ?: $this->name; // 不含前缀
$info = Db::name($queryTable)->field($field)->where($where)->find();
if(empty($info)){
return ['code'=>1002,'msg'=>lang('obtain_err')];
}
return ['code'=>1,'msg'=>lang('obtain_ok'),'info'=>$info];
}
public function saveData($data, $table = null)
{
$validate = \think\Loader::validate('Gbook');
if(!$validate->check($data)){
return ['code'=>1001,'msg'=>lang('param_err').':'.$validate->getError() ];
}
// xss过滤
$filter_fields = [
'gbook_name',
'gbook_content',
'gbook_reply',
];
foreach ($filter_fields as $filter_field) {
if (!isset($data[$filter_field])) {
continue;
}
$data[$filter_field] = mac_filter_xss($data[$filter_field]);
}
$queryTable = $table ?: $this->name; // 不含前缀
if(!empty($data['gbook_id'])){
if(!empty($data['gbook_reply'])){
$data['gbook_reply_time'] = time();
}
$where=[];
$where['gbook_id'] = ['eq',$data['gbook_id']];
$res = Db::name($queryTable)->where($where)->update($data);
}
else{
$data['gbook_time'] = time();
$res = Db::name($queryTable)->insert($data);
}
if(false === $res){
return ['code'=>1002,'msg'=>lang('save_err').':'.$this->getError() ];
}
return ['code'=>1,'msg'=>lang('save_ok')];
}
public function delData($where, $table = null)
{
$queryTable = $table ?: $this->name; // 不含前缀
$res = Db::name($queryTable)->where($where)->delete();
if($res===false){
return ['code'=>1001,'msg'=>lang('del_err').':'.$this->getError() ];
}
return ['code'=>1,'msg'=>lang('del_ok')];
}
public function fieldData($where,$col,$val, $table = null)
{
if(!isset($col) || !isset($val)){
return ['code'=>1001,'msg'=>lang('param_err')];
}
$data = [];
$data[$col] = $val;
$queryTable = $table ?: $this->name; // 不含前缀
$res = Db::name($queryTable)->where($where)->update($data);
if($res===false){
return ['code'=>1001,'msg'=>lang('set_err').':'.$this->getError() ];
}
return ['code'=>1,'msg'=>lang('set_ok')];
}
}修改后台控制器(Gbook.php)
文件路径:
application/admin/controller/Gbook.php
完整代码
<?php
namespace app\admin\controller;
use think\Db;
class Gbook extends Base
{
public function __construct()
{
parent::__construct();
// 后台统一用默认表,后续方法中覆盖
}
// 获取站点列表
private function getSiteList()
{
return [
'all' => '所有站点',
'aaa' => '主站 (aaa.pro)',
'bbb' => '子站 (bbb.pro)',
];
}
// 辅助方法,数组排序
private function array_sort($array, $keys, $type='asc')
{
$keysvalue = [];
foreach ($array as $k => $v) {
$keysvalue[$k] = $v[$keys];
}
if ($type == 'asc') {
asort($keysvalue);
} else {
arsort($keysvalue);
}
reset($keysvalue);
foreach ($keysvalue as $k => $v) {
$return_array[$k] = $array[$k];
}
return $return_array;
}
public function data()
{
$param = input();
$param['page'] = intval($param['page']) <1 ? 1 : $param['page'];
$param['limit'] = intval($param['limit']) <1 ? $this->_pagesize : $param['limit'];
$param['site'] = $param['site'] ?? 'all';
$where = [];
if(in_array($param['status'],['0','1'],true)){
$where['gbook_status'] = ['eq',$param['status']];
}
if(in_array($param['reply'],['1','2'])){
if($param['reply'] == 2){
$where['gbook_reply_time'] = ['gt', 0];
}
}
if(in_array($param['genre'],['1','2'])){
if($param['genre'] == 1){
$where['gbook_rid'] = 0;
} elseif($param['genre'] ==2){
$where['gbook_rid'] = ['gt',0];
}
}
if(!empty($param['uid'])){
$where['user_id'] = ['eq',$param['uid'] ];
}
if(!empty($param['wd'])){
$param['wd'] = htmlspecialchars(urldecode($param['wd']));
$where['gbook_name|gbook_content'] = ['like','%'.$param['wd'].'%'];
}
$order = 'gbook_time desc';
$siteList = $this->getSiteList();
$tableName = model('Gbook')->getTableBySite($param['site']);
if ($param['site'] == 'all' || $tableName === null) {
$allList = [];
$total = 0;
foreach ($siteList as $siteKey => $siteName) {
if ($siteKey == 'all') continue;
$tempTableNoPrefix = model('Gbook')->getTableBySite($siteKey);
if (empty($tempTableNoPrefix)) continue;
$tempTotal = Db::name($tempTableNoPrefix)->where($where)->count();
$total += $tempTotal;
$tempList = Db::name($tempTableNoPrefix)->where($where)->order($order)->select();
$tempList = $tempList ?: [];
foreach ($tempList as &$v) {
$v['site'] = $siteName;
$v['table'] = $tempTableNoPrefix;
$v['user_portrait'] = mac_get_user_portrait($v['user_id']);
$v['gbook_content'] = mac_restore_htmlfilter($v['gbook_content']);
$v['gbook_reply'] = mac_restore_htmlfilter($v['gbook_reply']);
}
$allList = array_merge($allList, $tempList);
}
$allList = $this->array_sort($allList, 'gbook_time', 'desc');
$list = array_slice($allList, ($param['page']-1)*$param['limit'], $param['limit']);
$res = ['code'=>1,'msg'=>lang('data_list'),'page'=>$param['page'],'limit'=>$param['limit'],'total'=>$total,'list'=>$list];
} else {
// 单表:用 listData with $table (不含前缀)
$res = model('Gbook')->listData($where, $order, $param['page'], $param['limit'], 0, $tableName);
foreach ($res['list'] as &$v) {
$v['site'] = $siteList[$param['site']] ?? $param['site'];
$v['table'] = $tableName;
}
}
$this->assign('list',$res['list']);
$this->assign('total',$res['total']);
$this->assign('page',$res['page']);
$this->assign('limit',$res['limit']);
$this->assign('siteList', $siteList);
$this->assign('curSite', $param['site']);
$param['page'] = '{page}';
$param['limit'] = '{limit}';
$this->assign('param',$param);
$this->assign('title',lang('admin/gbook/title'));
return $this->fetch('admin@gbook/index');
}
public function info()
{
if (Request()->isPost()) {
$param = input();
$tableName = $param['table'] ?? '';
$res = model('Gbook')->saveData($param, $tableName);
if($res['code']>1){
return $this->error($res['msg']);
}
return $this->success($res['msg']);
}
$id = input('id');
$table = input('table', '');
$where=[];
$where['gbook_id'] = ['eq',$id];
$res = model('Gbook')->infoData($where, '*', $table);
$this->assign('info',$res['info']);
$this->assign('table', $table);
$this->assign('title',lang('admin/gbook/title'));
return $this->fetch('admin@gbook/info');
}
public function del()
{
$param = input();
$ids = $param['ids'];
$all = $param['all'];
$table = $param['table'] ?? ''; // 从 POST/GET 获取
$site = $param['site'] ?? ''; // 从 POST/GET 获取 site
// 修复:如果 site 为空,从 Referer 解析
if (empty($site)) {
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (!empty($referer)) {
$parsed_url = parse_url($referer);
parse_str($parsed_url['query'] ?? '', $query_params);
$site = $query_params['site'] ?? '';
}
}
// 推断 table
if (empty($table) && !empty($site)) {
$table = model('Gbook')->getTableBySite($site);
}
if(!empty($ids) || !empty($all)){
$where=[];
$where['gbook_id'] = ['in',$ids];
if($all==1){
$where['gbook_id'] = ['gt',0];
}
$res = model('Gbook')->delData($where, $table);
if($res['code']>1){
return $this->error($res['msg']);
}
return $this->success($res['msg']);
}
return $this->error(lang('param_err'));
}
public function field()
{
$param = input();
$ids = $param['ids'];
$col = $param['col'];
$val = $param['val'];
$table = $param['table'] ?? ''; // 从 POST/GET 获取
$site = $param['site'] ?? ''; // 从 POST/GET 获取 site
// 修复:如果 site 为空,从 Referer 解析
if (empty($site)) {
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (!empty($referer)) {
$parsed_url = parse_url($referer);
parse_str($parsed_url['query'] ?? '', $query_params);
$site = $query_params['site'] ?? '';
}
}
// 推断 table
if (empty($table) && !empty($site)) {
$table = model('Gbook')->getTableBySite($site);
}
if(!empty($ids) && in_array($col,['gbook_status']) ){
$where=[];
$where['gbook_id'] = ['in',$ids];
$res = model('Gbook')->fieldData($where,$col,$val, $table);
if($res['code']>1){
return $this->error($res['msg']);
}
return $this->success($res['msg']);
}
return $this->error(lang('param_err'));
}
}修改后台留言管理模板
后台默认入口是
application/admin/view/gbook/index.html
完整代码
{include file="../../../application/admin/view/public/head" /}
<div class="page-container p10">
<div class="my-toolbar-box" >
<div class="center mb10">
<form class="layui-form " method="post" action="{:url('data')}">
<!-- 站点选择下拉框 -->
<div class="layui-input-inline w120">
<select name="site">
<option value="">{:lang('select_site')}</option>
{volist name="siteList" id="vo"}
<option value="{$key}" {if condition="$key eq $curSite"}selected{/if}>{$vo}</option>
{/volist}
</select>
</div>
<div class="layui-input-inline w100">
<select name="status">
<option value="">{:lang('select_status')}</option>
<option value="0" {if condition="$param['status'] == '0'"}selected {/if}>{:lang('reviewed_not')}</option>
<option value="1" {if condition="$param['status'] == '1'"}selected {/if}>{:lang('reviewed')}</option>
</select>
</div>
<!-- 回复状态 name="reply" -->
<div class="layui-input-inline w100">
<select name="reply">
<option value="">{:lang('select_reply_status')}</option>
<option value="1" {if condition="$param['reply'] eq '1'"}selected {/if}>{:lang('reply_not')}</option>
<option value="2" {if condition="$param['reply'] eq '2'"}selected {/if}>{:lang('reply_yes')}</option>
</select>
</div>
<!-- 类型 name="genre" -->
<div class="layui-input-inline w100">
<select name="genre">
<option value="">{:lang('select_genre')}</option>
<option value="1" {if condition="$param['genre'] eq '1'"}selected {/if}>{:lang('gbook')}</option>
<option value="2" {if condition="$param['genre'] eq '2'"}selected {/if}>{:lang('report')}</option>
</select>
</div>
<div class="layui-input-inline">
<input type="text" autocomplete="off" placeholder="{:lang('wd')}" class="layui-input" name="wd" value="{$param['wd']|mac_filter_xss}">
</div>
<button class="layui-btn mgl-20 j-search" >{:lang('btn_search')}</button>
</form>
</div>
<div class="layui-btn-group">
<!-- 批量删除按钮:URL 已带 site,但隐藏字段确保 POST 带 -->
<a data-href="{:url('del')}?site={$curSite}" class="layui-btn layui-btn-primary j-page-btns confirm"><i class="layui-icon"></i>{:lang('del')}</a>
<!-- 批量状态按钮:URL 已带 site,但隐藏字段确保 POST 带 -->
<a data-href="{:url('index/select')}?tab=gbook&col=gbook_status&tpl=select_status&url=gbook/field&site={$curSite}" data-width="470" data-height="100" data-checkbox="1" class="layui-btn layui-btn-primary j-select"><i class="layui-icon"></i>{:lang('status')}</a>
<!-- 清空按钮:URL 已带 site -->
<a data-href="{:url('del')}?all=1&site={$curSite}" class="layui-btn layui-btn-primary j-ajax" confirm="{:lang('clear_confirm')}"><i class="layui-icon"></i>{:lang('clear')}</a>
</div>
</div>
<!-- 修复:表单添加隐藏 site 字段,确保批量 POST 带 site 参数 -->
<form class="layui-form" method="post" id="pageListForm" >
<input type="hidden" name="site" value="{$curSite}">
<table class="layui-table" lay-size="sm">
<thead>
<tr>
<th width="25"><input type="checkbox" lay-skin="primary" lay-filter="allChoose"></th>
<th width="60">{:lang('id')}</th>
<th width="80">站点</th>
<th width="60">{:lang('status')}</th>
<th width="60">{:lang('genre')}</th>
<th >{:lang('gbook')}</th>
<th >{:lang('report')}</th>
<th width="100">{:lang('opt')}</th>
</tr>
</thead>
{volist name="list" id="vo"}
<tr>
<td><input type="checkbox" name="ids[]" value="{$vo.gbook_id}" class="layui-checkbox checkbox-ids" lay-skin="primary"></td>
<td>{$vo.gbook_id}</td>
<td>{$vo.site}</td>
<td>{if condition="$vo.gbook_status eq 0"}<span class="layui-badge">{:lang('reviewed_not')}</span>{else}<span class="layui-badge layui-bg-green">{:lang('reviewed')}</span>{/if}</td>
<td>{if condition="$vo.gbook_rid eq 0"}{:lang('gbook')}{else/}{:lang('report')}{/if}</td>
<td>
<div class="c-999 f-12">
<u style="cursor:pointer" class="text-primary">{$vo.gbook_name|htmlspecialchars}:</u>
<time>【{$vo.gbook_time|mac_day='color'}】</time>
<span class="ml-20">ip:【{$vo.gbook_ip|long2ip}】</span>
</div>
<div class="f-12 c-999">
<span class="ml-20">{:lang('status')}:</span>
{:lang('gbook')}:{$vo.gbook_content|htmlspecialchars}
</div>
</td>
<td>
<div class="c-999 f-12">
{:lang('reply_time')}:{$vo.gbook_reply_time|mac_day='color'}
</div>
<div class="f-12 c-999">
{:lang('reply')}:{$vo.gbook_reply|htmlspecialchars}
</div>
<div> </div>
</td>
<td>
<a class="layui-badge-rim j-iframe" data-href="{:url('info?id='.$vo['gbook_id'].'&table='.$vo['table'])}" href="javascript:;" title="{:lang('reply')}">{:lang('reply')}</a>
<a class="layui-badge-rim j-tr-del" data-href="{:url('del?ids='.$vo['gbook_id'].'&table='.$vo['table'])}" href="javascript:;" title="{:lang('del')}">{:lang('del')}</a>
</td>
</tr>
{/volist}
</tbody>
</table>
<div id="pages" class="center"></div>
</form>
</div>
{include file="../../../application/admin/view/public/foot" /}
<script type="text/javascript">
var curUrl="{:url('gbook/data',$param)}";
layui.use(['laypage', 'layer','form'], function() {
var laypage = layui.laypage
, layer = layui.layer,
form = layui.form;
laypage.render({
elem: 'pages'
,count: {$total}
,limit: {$limit}
,curr: {$page}
,layout: ['count', 'prev', 'page', 'next', 'limit', 'skip']
,jump: function(obj,first){
if(!first){
location.href = curUrl.replace('%7Bpage%7D',obj.curr).replace('%7Blimit%7D',obj.limit);
}
}
});
});
</script>
</body>
</html>前台查看不同站点留言板内容是否已经更改,此时 bbb.pro 留言板应该为空,再通过不同网站提交留言测试,后台留言管理查看是否正常。