问题背景
在 iCMS 内容管理系统中,我们对一批文章进行了栏目调整。文章本身已经成功移动到新的栏目,但是在标签管理和前台标签页面中,TAG 并没有按照文章的新栏目重新绑定,导致部分标签仍显示为旧栏目,或者通过栏目筛选标签时出现不准确的情况。
这类问题容易被误判为“标签丢失”或“文章标签损坏”,但实际情况通常是:文章的 cid 已更新,文章的 tags 字段也还在,只是 TAG 表和 TAG 映射表没有同步重建。
问题现象
文章已经转移到新栏目,但标签管理中的标签栏目仍然是旧栏目。
前台 TAG 页面或按栏目调用 TAG 时,标签归属不准确。
重新保存单篇文章可能只能恢复部分映射,不能稳定批量修复。
直接删除标签存在风险,可能导致文章和标签的关系被清掉。
原因分析
iCMS 中 TAG 通常涉及三类数据:
| 数据位置 | 作用 |
|---|---|
文章表的 tags 字段 | 记录文章自身有哪些标签名称,例如 旅游,攻略,签证。 |
tag 表 | 保存标签主体信息,包括标签名称、栏目 ID、点击数、状态、拼音 key 等。 |
tag_map 表 | 保存标签 ID 和文章 ID 的对应关系。 |
文章改栏目时,系统一般只更新文章的 cid。但已有 TAG 的 cid 和文章-TAG 映射未必会自动重新生成。尤其在旧版 iCMS 中,标签表结构可能没有 field、appid 等字段,直接套用新版逻辑会失败。
处理思路
最终采用的方案不是手动删除标签,而是通过脚本从文章当前数据反向重建 TAG 关系:
读取文章表中当前有效的
id、cid、userid、tags。完整备份文章标签数据、标签表、标签映射表。
清理旧的文章-TAG 映射关系。
根据文章当前
tags字段重新生成标签映射。复用已有标签,并更新标签的栏目、数量和更新时间。
缺失的标签才自动新建。
关键原则:文章里的 tags 字段是恢复依据,不能先把文章标签清空;同时不要直接裸删 tag 表,否则可能造成不可逆的关联丢失。
执行方式
脚本建议放在网站目录之外,或放在临时工具目录中,执行完成后立即删除。不要通过浏览器访问,应使用服务器命令行执行。
1. 先进行 dry-run 检查
php /path/to/icms_rebuild_article_tags.php \ --host=数据库地址 \ --db=数据库名 \ --user=数据库用户名 \ --pass='数据库密码' \ --prefix=icms_
dry-run 模式不会写入数据库,只会统计需要处理的文章数量和标签数量。例如:
Articles with tags: 864 Unique tags to rebuild: 1588 DRY-RUN mode: no database writes were performed.
2. 确认无误后执行修复
php /path/to/icms_rebuild_article_tags.php \ --host=数据库地址 \ --db=数据库名 \ --user=数据库用户名 \ --pass='数据库密码' \ --prefix=icms_ \ --execute
执行成功后,会看到类似输出:
Backup tables created: - icms_backup_article_tags_YYYYMMDD_HHMMSS - icms_backup_tag_YYYYMMDD_HHMMSS - icms_backup_tag_map_YYYYMMDD_HHMMSS Old article tag-map rows cleared. Tag rows rebuilt: 1588 Tag-map rows rebuilt: xxxx Done. Changes committed.
过程中遇到的问题
PHP 版本兼容问题
服务器 PHP 版本较旧时,脚本中如果使用 ?string、??、箭头函数等新语法,会出现解析错误。解决方法是将脚本改为 PHP 5.6/7.0 兼容写法。
PHP Parse error: syntax error, unexpected '?', expecting variable
阿里云 RDS GTID 限制
阿里云 RDS 开启 GTID 一致性后,不允许使用 CREATE TABLE ... SELECT。解决方法是改成两步:
CREATE TABLE backup_table LIKE source_table; INSERT INTO backup_table SELECT * FROM source_table;
旧版 iCMS 表结构差异
部分旧版 iCMS 的 tag 表没有 field 和 appid 字段。最终方案改为不删除 tag 表,而是清理并重建 tag_map,同时复用和更新已有标签。
修复后的收尾工作
进入 iCMS 后台清理系统缓存。
如果网站使用静态页,重新生成文章页、栏目页和 TAG 页。
检查几篇已转移栏目的文章,确认标签栏目和前台 TAG 页面正常。
确认无误后,将修复脚本从网站目录删除或移到非 Web 可访问目录。
如果执行命令时暴露过数据库密码,应及时重置数据库密码。
总结
这次问题的本质不是文章标签丢失,而是文章栏目变更后,TAG 的栏目归属和映射关系没有自动跟随重建。正确处理方式是以文章当前的 tags 字段为依据,先备份,再重建标签映射,并根据文章当前栏目更新 TAG 归属。
对于使用较久的 iCMS 站点,修复脚本需要兼容旧版表结构和服务器环境,不能简单照搬新版代码逻辑。执行前 dry-run、执行时备份、执行后清缓存,是这类数据库修复工作的基本安全流程。
程序代码详情
<?php
/**
* Rebuild iCMS article tags from the article.tags field.
*
* Default mode is dry-run. Add --execute to commit changes.
*
* Example:
* php icms_rebuild_article_tags.php --host=127.0.0.1 --db=icms --user=root --pass=secret --prefix=icms_ --appid=1 --execute
*/
$options = getopt('', [
'host::',
'port::',
'db:',
'user:',
'pass::',
'prefix::',
'article-table::',
'tag-table::',
'tag-map-table::',
'appid::',
'field::',
'charset::',
'execute',
'help',
]);
if (isset($options['help'])) {
usage();
exit(0);
}
$config = [
'host' => isset($options['host']) ? $options['host'] : '127.0.0.1',
'port' => isset($options['port']) ? $options['port'] : '3306',
'db' => isset($options['db']) ? $options['db'] : null,
'user' => isset($options['user']) ? $options['user'] : null,
'pass' => isset($options['pass']) ? $options['pass'] : '',
'prefix' => isset($options['prefix']) ? $options['prefix'] : 'icms_',
'article_table' => isset($options['article-table']) ? $options['article-table'] : 'article',
'tag_table' => isset($options['tag-table']) ? $options['tag-table'] : 'tag',
'tag_map_table' => isset($options['tag-map-table']) ? $options['tag-map-table'] : 'tag_map',
'appid' => (int)(isset($options['appid']) ? $options['appid'] : 1),
'field' => isset($options['field']) ? $options['field'] : 'tags',
'charset' => isset($options['charset']) ? $options['charset'] : 'utf8mb4',
'execute' => isset($options['execute']),
];
if (!$config['db'] || !$config['user']) {
usage();
fail('Missing required --db or --user.');
}
$articleTable = tableName($config['prefix'], $config['article_table']);
$tagTable = tableName($config['prefix'], $config['tag_table']);
$tagMapTable = tableName($config['prefix'], $config['tag_map_table']);
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['db'],
$config['charset']
);
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$suffix = date('Ymd_His');
$articleBackupTable = tableName($config['prefix'], 'backup_article_tags_' . $suffix);
$tagBackupTable = tableName($config['prefix'], 'backup_tag_' . $suffix);
$tagMapBackupTable = tableName($config['prefix'], 'backup_tag_map_' . $suffix);
try {
ensureColumns($pdo, $articleTable, ['id', 'cid', 'userid', $config['field']]);
ensureColumns($pdo, $tagTable, ['id', 'name', 'cid']);
ensureColumns($pdo, $tagMapTable, ['node', 'iid', 'field', 'appid']);
$articleRows = selectArticleRows($pdo, $articleTable, $config['field']);
if (!$articleRows) {
echo "No article rows with non-empty {$config['field']} were found.\n";
$pdo->rollBack();
exit(0);
}
echo 'Articles with tags: ' . count($articleRows) . "\n";
$tagUsage = collectTagUsage($articleRows, $config['field']);
if (!$tagUsage) {
echo "No valid tag names were parsed from article rows.\n";
$pdo->rollBack();
exit(0);
}
echo 'Unique tags to rebuild: ' . count($tagUsage) . "\n";
if (!$config['execute']) {
echo "DRY-RUN mode: no database writes were performed.\n";
echo "Add --execute to create backups, clear old article tags, and rebuild tag mappings.\n";
echo "Backup tables that would be created:\n";
echo " - {$articleBackupTable}\n";
echo " - {$tagBackupTable}\n";
echo " - {$tagMapBackupTable}\n";
exit(0);
}
echo "EXECUTE mode: backup tables will be created and changes will be committed.\n";
createBackups($pdo, $articleTable, $tagTable, $tagMapTable, $articleBackupTable, $tagBackupTable, $tagMapBackupTable, $config['field']);
echo "Backup tables created:\n";
echo " - {$articleBackupTable}\n";
echo " - {$tagBackupTable}\n";
echo " - {$tagMapBackupTable}\n";
$oldTagsByName = loadOldTagsByName($pdo, $tagTable, array_keys($tagUsage));
$tagColumns = getColumns($pdo, $tagTable);
$mapColumns = getColumns($pdo, $tagMapTable);
clearOldArticleTags($pdo, $tagMapTable, $config['appid'], $config['field']);
echo "Old article tag-map rows cleared.\n";
$tagIdsByName = rebuildTagRows($pdo, $tagTable, $tagColumns, $tagUsage, $oldTagsByName, $config['appid'], $config['field']);
echo 'Tag rows rebuilt: ' . count($tagIdsByName) . "\n";
$mapCount = rebuildTagMapRows($pdo, $tagMapTable, $mapColumns, $articleRows, $tagIdsByName, $config['appid'], $config['field']);
echo "Tag-map rows rebuilt: {$mapCount}\n";
echo "Done. Changes committed.\n";
} catch (Exception $e) {
fail($e->getMessage());
}
function usage()
{
echo <<<TXT
Usage:
php icms_rebuild_article_tags.php --db=DB_NAME --user=DB_USER [options]
Options:
--host=127.0.0.1
--port=3306
--pass=DB_PASS
--prefix=icms_
--article-table=article
--tag-table=tag
--tag-map-table=tag_map
--appid=1
--field=tags
--charset=utf8mb4
--execute Commit changes. Without this flag the script rolls back.
TXT;
}
function fail($message)
{
fwrite(STDERR, "ERROR: {$message}\n");
exit(1);
}
function tableName($prefix, $name)
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $prefix . $name)) {
fail('Unsafe table name or prefix.');
}
return $prefix . $name;
}
function q($identifier)
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
function getColumns(PDO $pdo, $table)
{
$stmt = $pdo->query('DESCRIBE ' . q($table));
$columns = [];
foreach ($stmt->fetchAll() as $row) {
$columns[$row['Field']] = $row;
}
return $columns;
}
function ensureColumns(PDO $pdo, $table, array $required)
{
$columns = getColumns($pdo, $table);
foreach ($required as $column) {
if (!isset($columns[$column])) {
fail("Table {$table} is missing required column {$column}.");
}
}
}
function selectArticleRows(PDO $pdo, $articleTable, $field)
{
$sql = sprintf(
'SELECT %s, %s, %s, %s FROM %s WHERE %s IS NOT NULL AND %s <> ""',
q('id'),
q('cid'),
q('userid'),
q($field),
q($articleTable),
q($field),
q($field)
);
return $pdo->query($sql)->fetchAll();
}
function parseTags($value)
{
$value = trim((string)$value);
if ($value === '') {
return [];
}
$value = str_replace(["\r", "\n", "\t", ',', '、', ';', ';'], ',', $value);
$parts = array_map('trim', explode(',', $value));
$parts = array_filter($parts, function ($tag) {
return $tag !== '';
});
$parts = array_map(function ($tag) {
return html_entity_decode(strip_tags($tag), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}, $parts);
return array_values(array_unique($parts));
}
function collectTagUsage(array $articleRows, $field)
{
$usage = [];
foreach ($articleRows as $article) {
foreach (parseTags(isset($article[$field]) ? $article[$field] : '') as $tag) {
$usage[$tag][] = [
'id' => (int)$article['id'],
'cid' => (int)$article['cid'],
'userid' => (int)$article['userid'],
];
}
}
ksort($usage, SORT_NATURAL);
return $usage;
}
function createBackups(
PDO $pdo,
$articleTable,
$tagTable,
$tagMapTable,
$articleBackupTable,
$tagBackupTable,
$tagMapBackupTable,
$field
) {
createBackupLike($pdo, $articleTable, $articleBackupTable);
$pdo->exec(sprintf(
'INSERT INTO %s SELECT * FROM %s WHERE %s IS NOT NULL AND %s <> ""',
q($articleBackupTable),
q($articleTable),
q($field),
q($field)
));
createBackupLike($pdo, $tagTable, $tagBackupTable);
$pdo->exec(sprintf('INSERT INTO %s SELECT * FROM %s', q($tagBackupTable), q($tagTable)));
createBackupLike($pdo, $tagMapTable, $tagMapBackupTable);
$pdo->exec(sprintf('INSERT INTO %s SELECT * FROM %s', q($tagMapBackupTable), q($tagMapTable)));
}
function createBackupLike(PDO $pdo, $sourceTable, $backupTable)
{
$pdo->exec(sprintf('CREATE TABLE %s LIKE %s', q($backupTable), q($sourceTable)));
}
function loadOldTagsByName(PDO $pdo, $tagTable, array $names)
{
$rows = [];
foreach (array_chunk($names, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $pdo->prepare(sprintf('SELECT * FROM %s WHERE %s IN (%s)', q($tagTable), q('name'), $placeholders));
$stmt->execute(array_values($chunk));
foreach ($stmt->fetchAll() as $row) {
$rows[$row['name']] = $row;
}
}
return $rows;
}
function clearOldArticleTags(PDO $pdo, $tagMapTable, $appid, $field)
{
$stmt = $pdo->prepare(sprintf(
'DELETE FROM %s WHERE %s = ? AND %s = ?',
q($tagMapTable),
q('field'),
q('appid')
));
$stmt->execute([$field, $appid]);
}
function rebuildTagRows(
PDO $pdo,
$tagTable,
array $tagColumns,
array $tagUsage,
array $oldTagsByName,
$appid,
$field
) {
$tagIdsByName = [];
$usedTkeys = [];
foreach ($tagUsage as $name => $articles) {
$old = isset($oldTagsByName[$name]) ? $oldTagsByName[$name] : [];
$cid = dominantCid($articles);
$userid = firstUserid($articles);
$now = time();
$row = [];
foreach ($tagColumns as $column => $meta) {
if ((isset($meta['Extra']) ? $meta['Extra'] : '') === 'auto_increment') {
continue;
}
if (array_key_exists($column, $old)) {
$row[$column] = $old[$column];
}
}
if (isset($tagColumns['id']) && isset($old['id'])) {
$row['id'] = $old['id'];
}
$row['cid'] = $cid;
if (isset($tagColumns['tcid'])) {
$row['tcid'] = isset($old['tcid']) ? $old['tcid'] : 0;
}
if (isset($tagColumns['userid'])) {
$row['userid'] = $userid;
}
if (isset($tagColumns['appid'])) {
$row['appid'] = (string)$appid;
}
$row['name'] = $name;
if (isset($tagColumns['title'])) {
$row['title'] = isset($old['title']) ? $old['title'] : $name;
}
if (isset($tagColumns['count'])) {
$row['count'] = count($articles);
}
if (isset($tagColumns['pubdate'])) {
$row['pubdate'] = $now;
}
if (isset($tagColumns['postime'])) {
$row['postime'] = isset($old['postime']) ? $old['postime'] : $now;
}
if (isset($tagColumns['status'])) {
$row['status'] = isset($old['status']) ? $old['status'] : 1;
}
if (isset($tagColumns['field'])) {
$row['field'] = $field;
}
if (isset($tagColumns['tkey'])) {
$baseTkey = (string)(isset($old['tkey']) ? $old['tkey'] : makeTkey($name));
$row['tkey'] = uniqueTkey($baseTkey, $usedTkeys);
}
if (isset($row['id']) && $row['id']) {
$id = (int)$row['id'];
unset($row['id']);
updateTagRow($pdo, $tagTable, $row, $id);
$tagIdsByName[$name] = $id;
} else {
$columns = array_keys($row);
$placeholders = implode(',', array_fill(0, count($columns), '?'));
$sql = sprintf(
'INSERT INTO %s (%s) VALUES (%s)',
q($tagTable),
implode(',', array_map('q', $columns)),
$placeholders
);
$stmt = $pdo->prepare($sql);
$stmt->execute(array_values($row));
$tagIdsByName[$name] = (int)$pdo->lastInsertId();
}
}
return $tagIdsByName;
}
function updateTagRow(PDO $pdo, $tagTable, array $row, $id)
{
$sets = [];
foreach (array_keys($row) as $column) {
$sets[] = q($column) . ' = ?';
}
$values = array_values($row);
$values[] = $id;
$sql = sprintf(
'UPDATE %s SET %s WHERE %s = ?',
q($tagTable),
implode(', ', $sets),
q('id')
);
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
}
function rebuildTagMapRows(
PDO $pdo,
$tagMapTable,
array $mapColumns,
array $articleRows,
array $tagIdsByName,
$appid,
$field
) {
$insertColumns = ['node', 'iid', 'field', 'appid'];
foreach ($insertColumns as $column) {
if (!isset($mapColumns[$column])) {
fail("Tag map table is missing {$column}.");
}
}
$sql = sprintf(
'INSERT INTO %s (%s) VALUES (?,?,?,?)',
q($tagMapTable),
implode(',', array_map('q', $insertColumns))
);
$stmt = $pdo->prepare($sql);
$count = 0;
$seen = [];
foreach ($articleRows as $article) {
$iid = (int)$article['id'];
foreach (parseTags(isset($article[$field]) ? $article[$field] : '') as $name) {
if (!isset($tagIdsByName[$name])) {
continue;
}
$key = $iid . ':' . $tagIdsByName[$name];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$stmt->execute([$tagIdsByName[$name], $iid, $field, $appid]);
$count++;
}
}
return $count;
}
function dominantCid(array $articles)
{
$counts = [];
foreach ($articles as $article) {
$cid = (int)$article['cid'];
$counts[$cid] = (isset($counts[$cid]) ? $counts[$cid] : 0) + 1;
}
arsort($counts, SORT_NUMERIC);
reset($counts);
return (int)key($counts);
}
function firstUserid(array $articles)
{
foreach ($articles as $article) {
if (!empty($article['userid'])) {
return (int)$article['userid'];
}
}
return 0;
}
function makeTkey($name)
{
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name);
$ascii = strtolower((string)$ascii);
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii);
$ascii = trim((string)$ascii, '-');
if ($ascii === '') {
$ascii = 'tag-' . substr(md5($name), 0, 12);
}
return $ascii;
}
function uniqueTkey($tkey, array &$used)
{
$base = $tkey !== '' ? $tkey : 'tag';
$candidate = $base;
$i = 2;
while (isset($used[$candidate])) {
$candidate = $base . '-' . $i;
$i++;
}
$used[$candidate] = true;
return $candidate;
}