问题背景

在 iCMS 内容管理系统中,我们对一批文章进行了栏目调整。文章本身已经成功移动到新的栏目,但是在标签管理和前台标签页面中,TAG 并没有按照文章的新栏目重新绑定,导致部分标签仍显示为旧栏目,或者通过栏目筛选标签时出现不准确的情况。

这类问题容易被误判为“标签丢失”或“文章标签损坏”,但实际情况通常是:文章的 cid 已更新,文章的 tags 字段也还在,只是 TAG 表和 TAG 映射表没有同步重建。

问题现象

  • 文章已经转移到新栏目,但标签管理中的标签栏目仍然是旧栏目。

  • 前台 TAG 页面或按栏目调用 TAG 时,标签归属不准确。

  • 重新保存单篇文章可能只能恢复部分映射,不能稳定批量修复。

  • 直接删除标签存在风险,可能导致文章和标签的关系被清掉。

原因分析

iCMS 中 TAG 通常涉及三类数据:

数据位置作用
文章表的 tags 字段记录文章自身有哪些标签名称,例如 旅游,攻略,签证
tag保存标签主体信息,包括标签名称、栏目 ID、点击数、状态、拼音 key 等。
tag_map保存标签 ID 和文章 ID 的对应关系。

文章改栏目时,系统一般只更新文章的 cid。但已有 TAG 的 cid 和文章-TAG 映射未必会自动重新生成。尤其在旧版 iCMS 中,标签表结构可能没有 fieldappid 等字段,直接套用新版逻辑会失败。

处理思路

最终采用的方案不是手动删除标签,而是通过脚本从文章当前数据反向重建 TAG 关系:

  1. 读取文章表中当前有效的 idciduseridtags

  2. 完整备份文章标签数据、标签表、标签映射表。

  3. 清理旧的文章-TAG 映射关系。

  4. 根据文章当前 tags 字段重新生成标签映射。

  5. 复用已有标签,并更新标签的栏目、数量和更新时间。

  6. 缺失的标签才自动新建。

关键原则:文章里的 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 表没有 fieldappid 字段。最终方案改为不删除 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;
}