文件
yjyf/server/application/common/library/AgreementService.php

1219 行
50 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\common\library;
use think\Config;
use think\Db;
/**
* 平台协议与隐私政策管理服务
*
* 职责:
* 1. 统一读取 fa_agreement 表中的平台协议(用户服务协议、隐私保护指引、师傅入驻协议、商家协同协议)
* 2. 提供默认协议元数据定义与安全兜底(兼容旧 site.agreement_* 配置)
* 3. 提供严格的白名单 HTML 安全过滤与协议保存校验逻辑
*/
class AgreementService
{
/** 允许的富文本安全标签白名单 */
const ALLOWED_TAGS = [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'b', 'em', 'i', 'u', 's', 'del',
'ul', 'ol', 'li', 'blockquote', 'hr', 'div', 'span', 'a',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
];
/** 危险标签(需连同标签内文本一并剥离) */
const DANGEROUS_TAGS = [
'script', 'style', 'iframe', 'frame', 'frameset',
'object', 'embed', 'applet', 'meta', 'link', 'base',
'form', 'input', 'button', 'textarea', 'select', 'option',
'svg', 'math',
];
/** 核心合规协议编码(不可停用,确保合规与注册登录可用) */
const CORE_CODES = ['user_service', 'privacy'];
/**
* 将前端传入的协议类型别名规范化为标准编码
*/
public static function canonicalCode(string $type): string
{
$type = strtolower(trim($type));
$aliasMap = [
'' => 'user_service',
'service' => 'user_service',
'user_service' => 'user_service',
'privacy' => 'privacy',
'master' => 'master',
'merchant' => 'merchant',
];
if (isset($aliasMap[$type])) {
return $aliasMap[$type];
}
// 支持自定义合法编码(仅允许小写字母/数字/下划线)
return preg_match('/^[a-z0-9_]{2,32}$/', $type) ? $type : 'user_service';
}
/**
* 返回默认协议字典定义(按权重降序)
*/
public static function defaultDefinitions(): array
{
$today = date('Y-m-d');
return [
'user_service' => [
'code' => 'user_service',
'legacy_key' => 'agreement_service',
'tab_name' => '用户服务协议',
'desc' => '在小程序登录页、个人中心「协议与规则」中展示',
'title' => '优匠优服用户服务协议',
'version' => 'v1.0',
'effective_date' => $today,
'weigh' => 100,
'status' => 'normal',
'content' => '<h3>一、平台定位与服务范围</h3><p>1. 优匠优服(以下简称“本平台”)依法为个人业主、企业商家与认证技能师傅提供上门安装、维修、拆旧及维保信息撮合与交易资金托管服务。</p><p>2. 您在注册、登录或下单使用本平台服务前,应当仔细阅读并充分理解本协议全部条款。</p><h3>二、下单与计费准则</h3><p>1. 一口价项目按平台页面公示标准计费;师傅报价订单由接单师傅根据现场工况公开报价,经客户确认并支付后生效;自主定价订单按客户确认的订单金额执行。</p><p>2. 如施工现场存在特殊工况需增项补差,须经双方提前沟通一致,并由师傅在工单内发起规范补差申请,客户核实无误后通过平台支付。</p><h3>三、验收、质保与资金结算</h3><p>1. 订单支付资金由平台合规托管,师傅完成施工并上传现场完工照片后,经客户验收确认(或向师傅提供 6 位核销码完成核验)后触发结算。</p><p>2. 为保障您的售后权益、施工质保与资金安全,请全程在平台内完成沟通、下单、补差与支付,切勿脱离平台私下转账交易。</p>',
],
'privacy' => [
'code' => 'privacy',
'legacy_key' => 'agreement_privacy',
'tab_name' => '隐私保护指引',
'desc' => '在小程序登录页、个人中心「协议与规则」中展示',
'title' => '优匠优服隐私保护指引',
'version' => 'v1.0',
'effective_date' => $today,
'weigh' => 90,
'status' => 'normal',
'content' => '<h3>一、信息收集范围与使用目的</h3><p>1. 本平台遵循“合法、正当、必要、诚信”原则收集和使用您的个人信息,仅在为您提供同城上门安装、维修服务所必需的场景下收集相关数据。</p><p>2. <strong>账号与联系信息</strong>:在您授权登录或下单时,我们需要收集您的手机号码及联系人称呼,用于账号识别、订单通知及师傅上门沟通。</p><p>3. <strong>服务地址与位置信息</strong>:在您创建工单或管理地址簿时,我们需要收集上门服务地址及定位信息,以便为您匹配就近认证师傅并核验服务范围。</p><h3>二、最小必要与脱敏保护原则</h3><p>1. 您的真实手机号码、门牌号等敏感联系信息,仅对已正式接单履约的当单认证师傅可见;订单未被接单前及非当单人员均无法获取,平台全程采用脱敏展示。</p><h3>三、数据安全与第三方共享限制</h3><p>1. 本平台全链路采用加密传输与存储技术,严格管控内部数据访问权限,绝不向无关第三方共享或出售您的个人信息。</p><h3>四、您的个人权利</h3><p>您可随时在小程序「个人中心」查询、修改个人资料及管理服务地址,或联系平台客服咨询、撤回授权。</p>',
],
'master' => [
'code' => 'master',
'legacy_key' => 'agreement_master',
'tab_name' => '师傅入驻协议',
'desc' => '在师傅实名入驻申请页中展示',
'title' => '师傅入驻与履约服务协议',
'version' => 'v1.0',
'effective_date' => $today,
'weigh' => 80,
'status' => 'normal',
'content' => '<h3>一、实名认证与技能核验</h3><p>1. 申请入驻的师傅须提交本人真实有效身份证件及服务技能信息,严禁冒用他人资质。</p><h3>二、接单与上门履约规范</h3><p>1. 接单后须在平台规定时效内联系客户确认上门时间,准时到达现场并规范施工。</p><h3>三、收费透明与严禁私单</h3><p>1. 如需现场增项补差,必须提前向客户说明并在工单内发起补差申请,严禁私自索要额外费用。</p><h3>四、完工留痕与结算提现</h3><p>1. 施工完成后须上传真实清晰的现场完工照片,凭客户验收或核销码触发系统结算。</p>',
],
'merchant' => [
'code' => 'merchant',
'legacy_key' => 'agreement_merchant',
'tab_name' => '商家协同协议',
'desc' => '在企业商家用户的「协议与规则」中展示',
'title' => '企业商家入驻与协同服务协议',
'version' => 'v1.0',
'effective_date' => $today,
'weigh' => 70,
'status' => 'normal',
'content' => '<h3>一、企业主体与资质真实性</h3><p>1. 申请入驻的企业或商户须提供真实有效的营业执照与主体信息。</p><h3>二、账号协同与权限管理</h3><p>1. 主账号可绑定企业协同成员共同发起批量工单与跟进履约,并对企业关联工单承担管理责任。</p><h3>三、对账结算与发票服务</h3><p>1. 平台按实际支付与完工订单汇总企业消费明细,支持在线申请正规增值税普通发票或专用发票。</p>',
],
];
}
/**
* 读取后台管理页面所需的全部协议集合(按默认顺序与数据库合并)
*/
public static function getAllForAdmin(): array
{
$defs = self::defaultDefinitions();
$dbRows = [];
try {
$rows = Db::name('agreement')
->field('id,code,title,version,effective_date,content,revision,prev_content,diff_content,diff_summary,status,weigh,createtime,updatetime')
->order('weigh desc, id asc')
->select();
if (is_array($rows)) {
foreach ($rows as $row) {
$dbRows[$row['code']] = $row;
}
}
} catch (\Throwable $e) {
$dbRows = [];
}
$result = [];
foreach ($defs as $code => $def) {
$row = $dbRows[$code] ?? null;
$legacyContent = '';
if (!empty($def['legacy_key'])) {
$cfgVal = Config::get('site.' . $def['legacy_key']);
if (is_string($cfgVal) && trim($cfgVal) !== '') {
$legacyContent = self::plainTextToHtml(trim($cfgVal));
}
}
$content = $row && trim((string)$row['content']) !== ''
? (string)$row['content']
: ($legacyContent !== '' ? $legacyContent : $def['content']);
$updatetime = $row && !empty($row['updatetime']) ? (int)$row['updatetime'] : time();
$revision = $row && !empty($row['revision']) ? max(1, (int)$row['revision']) : 1;
$diffContent = $row ? (string)($row['diff_content'] ?? '') : '';
$diffSummary = $row ? (string)($row['diff_summary'] ?? '') : '';
$hasDiff = $revision > 1 && trim($diffContent) !== '';
$result[] = [
'id' => $row ? (int)$row['id'] : 0,
'code' => $code,
'tab_name' => $def['tab_name'],
'desc' => $def['desc'],
'is_core' => in_array($code, self::CORE_CODES, true),
'title' => $row && trim((string)$row['title']) !== '' ? (string)$row['title'] : $def['title'],
'version' => $row && trim((string)$row['version']) !== '' ? (string)$row['version'] : $def['version'],
'effective_date' => $row && trim((string)$row['effective_date']) !== '' ? (string)$row['effective_date'] : $def['effective_date'],
'content' => $content,
'revision' => $revision,
'has_diff' => $hasDiff,
'diff_summary' => $diffSummary,
'diff_content' => $hasDiff ? $diffContent : '',
'status' => $row ? (string)$row['status'] : 'normal',
'updatetime' => $updatetime,
'update_time_text' => date('Y-m-d H:i', $updatetime),
];
}
return $result;
}
/**
* 按前端请求的 type 读取单条启用协议(API 使用,含变更首看高亮判断)
*/
public static function getByType(string $type, int $userId = 0): ?array
{
$rawType = trim($type);
$code = self::canonicalCode($rawType);
$defs = self::defaultDefinitions();
$row = null;
try {
$row = Db::name('agreement')
->field('id,code,title,version,effective_date,content,revision,prev_content,diff_content,diff_summary,status,updatetime')
->where('code', $code)
->find();
} catch (\Throwable $e) {
$row = null;
}
if ($row) {
if (($row['status'] ?? 'normal') !== 'normal') {
return null;
}
$updatetime = (int)($row['updatetime'] ?? 0);
$revision = max(1, (int)($row['revision'] ?? 1));
$content = (string)$row['content'];
$prevContent = (string)($row['prev_content'] ?? '');
$diffContent = (string)($row['diff_content'] ?? '');
$diffSummary = (string)($row['diff_summary'] ?? '');
// 若存在上一版本正文但尚无预计算高亮,即时补充生成一次
if ($revision > 1 && trim($prevContent) !== '' && trim($diffContent) === '') {
$computed = self::buildChangeHighlightedHtml($prevContent, $content);
if ($computed['has_changes']) {
$diffContent = $computed['html'];
$diffSummary = $computed['summary'];
}
}
$hasDiff = ($revision > 1 && trim($diffContent) !== '');
$showDiff = false;
if ($hasDiff) {
if ($userId > 0) {
$readRevision = self::getUserReadRevision($userId, $code);
$showDiff = ($readRevision < $revision);
} else {
// 未登录游客由前端结合本机已阅修订版本进行首看门控
$showDiff = true;
}
}
return [
'type' => $rawType !== '' ? $rawType : $code,
'code' => $code,
'title' => (string)$row['title'],
'version' => (string)($row['version'] ?? 'v1.0'),
'effective_date' => (string)($row['effective_date'] ?? ''),
'update_time' => $updatetime > 0 ? date('Y-m-d H:i', $updatetime) : '',
'content' => $content,
'revision' => $revision,
'has_diff' => $hasDiff,
'show_diff' => $showDiff,
'diff_summary' => $showDiff ? $diffSummary : '',
'diff_content' => $showDiff ? $diffContent : '',
];
}
// 数据库尚未入库该协议时,仅兼容历史 site 配置;均未配置则明确返回 null,禁用静态默认文案兜底
if (!isset($defs[$code])) {
return null;
}
$def = $defs[$code];
$cfgVal = !empty($def['legacy_key']) ? Config::get('site.' . $def['legacy_key']) : '';
if (!is_string($cfgVal) || trim($cfgVal) === '') {
return null;
}
$content = self::plainTextToHtml(trim($cfgVal));
return [
'type' => $rawType !== '' ? $rawType : $code,
'code' => $code,
'title' => $def['title'],
'version' => $def['version'],
'effective_date' => $def['effective_date'],
'update_time' => '',
'content' => $content,
'revision' => 1,
'has_diff' => false,
'show_diff' => false,
'diff_summary' => '',
'diff_content' => '',
];
}
/**
* 查询登录用户已查看过的协议最高修订序号
*/
public static function getUserReadRevision(int $userId, string $code): int
{
if ($userId <= 0 || $code === '') {
return 0;
}
try {
$rev = Db::name('agreement_read')
->where('user_id', $userId)
->where('agreement_code', $code)
->value('read_revision');
return $rev ? (int)$rev : 0;
} catch (\Throwable $e) {
return 0;
}
}
/**
* 标记登录用户已首次查看当前变更版协议,防止后续打开重复高亮
*/
public static function markUserRead(int $userId, string $type, int $revision = 0): array
{
$code = self::canonicalCode($type);
$currentRev = 1;
try {
$dbRev = Db::name('agreement')->where('code', $code)->value('revision');
if ($dbRev) {
$currentRev = max(1, (int)$dbRev);
}
} catch (\Throwable $e) {
$currentRev = 1;
}
$targetRevision = ($revision > 0 && $revision <= $currentRev) ? $revision : $currentRev;
if ($userId <= 0) {
return [
'recorded' => false,
'code' => $code,
'read_revision' => 0,
];
}
$now = time();
$prefix = (string)Config::get('database.prefix');
$table = $prefix . 'agreement_read';
Db::execute(
"INSERT INTO `{$table}` (`user_id`, `agreement_code`, `read_revision`, `read_time`, `createtime`, `updatetime`) "
. "VALUES (:uid, :code, :rev, :rtime, :ctime, :utime) "
. "ON DUPLICATE KEY UPDATE `read_revision` = GREATEST(`read_revision`, VALUES(`read_revision`)), "
. "`read_time` = VALUES(`read_time`), `updatetime` = VALUES(`updatetime`)",
[
'uid' => $userId,
'code' => $code,
'rev' => $targetRevision,
'rtime' => $now,
'ctime' => $now,
'utime' => $now,
]
);
return [
'recorded' => true,
'code' => $code,
'read_revision' => self::getUserReadRevision($userId, $code),
];
}
/**
* 保存后台编辑的协议内容(含白名单校验、XSS 净化及自动生成变更背景色高亮)
*
* @throws \InvalidArgumentException
*/
public static function saveByCode(string $code, array $input): array
{
$defs = self::defaultDefinitions();
$code = self::canonicalCode($code);
if (!isset($defs[$code])) {
throw new \InvalidArgumentException('不支持的协议类型编码');
}
$title = trim(strip_tags((string)($input['title'] ?? '')));
if ($title === '') {
throw new \InvalidArgumentException('请填写协议标题');
}
if (mb_strlen($title, 'UTF-8') > 100) {
throw new \InvalidArgumentException('协议标题不能超过 100 个字符');
}
$version = trim(strip_tags((string)($input['version'] ?? 'v1.0')));
if ($version === '') {
$version = 'v1.0';
}
if (mb_strlen($version, 'UTF-8') > 32) {
throw new \InvalidArgumentException('版本号不能超过 32 个字符');
}
$effectiveDate = trim(strip_tags((string)($input['effective_date'] ?? '')));
if ($effectiveDate === '') {
$effectiveDate = date('Y-m-d');
} elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $effectiveDate)) {
throw new \InvalidArgumentException('生效日期格式须为 YYYY-MM-DD');
}
$status = trim((string)($input['status'] ?? 'normal'));
if (!in_array($status, ['normal', 'hidden'], true)) {
$status = 'normal';
}
if (in_array($code, self::CORE_CODES, true)) {
// 用户服务协议与隐私保护指引为平台合规必选,始终保持启用状态
$status = 'normal';
}
$rawContent = (string)($input['content'] ?? '');
$cleanContent = self::sanitizeHtml($rawContent);
$plainCheck = trim(html_entity_decode(strip_tags($cleanContent), ENT_QUOTES, 'UTF-8'));
$plainCheck = preg_replace('/[\s\x{00A0}]+/u', '', $plainCheck);
if ($plainCheck === '') {
throw new \InvalidArgumentException('协议正文不能为空,请填写具体条款内容');
}
$now = time();
$existing = Db::name('agreement')->where('code', $code)->find();
if ($existing) {
$oldContent = (string)($existing['content'] ?? '');
$revision = max(1, (int)($existing['revision'] ?? 1));
$prevContent = (string)($existing['prev_content'] ?? '');
$diffContent = (string)($existing['diff_content'] ?? '');
$diffSummary = (string)($existing['diff_summary'] ?? '');
// 仅当正文实质文本发生变更时才递增修订号并重建变更高亮,避免仅修改元数据或编辑器空标签微调覆盖历史差异
$oldPlainNorm = self::normalizeComparableText(strip_tags(html_entity_decode($oldContent, ENT_QUOTES, 'UTF-8')));
$newPlainNorm = self::normalizeComparableText(strip_tags(html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8')));
$isSubstantiveChange = ($oldPlainNorm !== $newPlainNorm);
if ($isSubstantiveChange) {
$diffRes = self::buildChangeHighlightedHtml($oldContent, $cleanContent);
$revision++;
$prevContent = $oldContent;
$diffContent = $diffRes['has_changes'] ? $diffRes['html'] : '';
$diffSummary = $diffRes['has_changes'] ? $diffRes['summary'] : '';
}
Db::name('agreement')->where('id', (int)$existing['id'])->update([
'title' => $title,
'version' => $version,
'effective_date' => $effectiveDate,
'content' => $cleanContent,
'revision' => $revision,
'prev_content' => $prevContent,
'diff_content' => $diffContent,
'diff_summary' => $diffSummary,
'status' => $status,
'updatetime' => $now,
]);
$id = (int)$existing['id'];
} else {
$revision = 1;
$diffContent = '';
$diffSummary = '';
$id = (int)Db::name('agreement')->insertGetId([
'code' => $code,
'title' => $title,
'version' => $version,
'effective_date' => $effectiveDate,
'content' => $cleanContent,
'revision' => $revision,
'prev_content' => '',
'diff_content' => '',
'diff_summary' => '',
'status' => $status,
'weigh' => (int)$defs[$code]['weigh'],
'createtime' => $now,
'updatetime' => $now,
]);
}
$hasDiff = ($revision > 1 && trim($diffContent) !== '');
return [
'id' => $id,
'code' => $code,
'title' => $title,
'version' => $version,
'effective_date' => $effectiveDate,
'content' => $cleanContent,
'revision' => $revision,
'has_diff' => $hasDiff,
'diff_summary' => $diffSummary,
'diff_content' => $hasDiff ? $diffContent : '',
'status' => $status,
'updatetime' => $now,
'update_time_text' => date('Y-m-d H:i', $now),
];
}
/**
* 将纯文本换行内容转为结构化 HTML 段落
*/
public static function plainTextToHtml(string $text): string
{
$text = str_replace(["\r\n", "\r"], "\n", trim($text));
if ($text === '') {
return '';
}
// 若本身已包含 HTML 块标签则直接进行安全清洗后返回
if (preg_match('/<\s*(p|h[1-6]|div|ul|ol|li|blockquote|br)\b/i', $text)) {
return self::sanitizeHtml($text);
}
$blocks = preg_split("/\n{2,}/", $text);
$html = '';
foreach ($blocks as $block) {
$block = trim($block);
if ($block === '') {
continue;
}
$escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8');
$html .= '<p>' . str_replace("\n", '<br>', $escaped) . '</p>';
}
return $html;
}
/**
* 对富文本 HTML 执行严格白名单安全过滤(防止 XSS 注入)
*/
public static function sanitizeHtml(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
// 先整体移除危险标签及其内部脚本/样式体
foreach (self::DANGEROUS_TAGS as $tag) {
$html = preg_replace('/<' . $tag . '\b[^>]*>[\s\S]*?<\/' . $tag . '>/i', '', $html);
$html = preg_replace('/<' . $tag . '\b[^>]*\/?>/i', '', $html);
}
$loaded = self::loadHtmlFragmentDom($html);
if ($loaded !== null) {
[$dom, $root] = $loaded;
self::sanitizeDomNode($root);
return trim(self::innerHtmlOfNode($dom, $root));
}
// DOM 不可用时的严格退化清理
$allowed = '<' . implode('><', self::ALLOWED_TAGS) . '>';
$stripped = strip_tags($html, $allowed);
$stripped = preg_replace('/\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $stripped);
$stripped = preg_replace('/(?:javascript|vbscript|data)\s*:/i', '', $stripped);
return trim($stripped);
}
/**
* 基于前后两版 HTML 正文生成带背景色标识的差异 HTML(段落块级 + 句内词字级双粒度)
*
* @return array{has_changes:bool,changed_blocks:int,summary:string,html:string}
*/
public static function buildChangeHighlightedHtml(string $prevHtml, string $currHtml): array
{
$currHtml = trim($currHtml);
if ($currHtml === '') {
return ['has_changes' => false, 'changed_blocks' => 0, 'summary' => '', 'html' => ''];
}
$prevHtml = trim($prevHtml);
if ($prevHtml === '') {
return ['has_changes' => false, 'changed_blocks' => 0, 'summary' => '', 'html' => $currHtml];
}
$prevBlocks = self::extractHtmlBlocks($prevHtml);
$currBlocks = self::extractHtmlBlocks($currHtml);
if (empty($currBlocks)) {
return ['has_changes' => false, 'changed_blocks' => 0, 'summary' => '', 'html' => $currHtml];
}
// 前后完全一致时直接原样输出
$prevNorms = array_column($prevBlocks, 'norm');
$currNorms = array_column($currBlocks, 'norm');
if ($prevNorms === $currNorms) {
return ['has_changes' => false, 'changed_blocks' => 0, 'summary' => '', 'html' => $currHtml];
}
$anchors = self::computeLcsAnchors($prevNorms, $currNorms);
$anchors[] = [count($prevBlocks), count($currBlocks)];
$outParts = [];
$changedBlocks = 0;
$oCursor = 0;
$nCursor = 0;
foreach ($anchors as [$oIdx, $nIdx]) {
if ($oIdx > $oCursor || $nIdx > $nCursor) {
$oldSlice = array_slice($prevBlocks, $oCursor, max(0, $oIdx - $oCursor));
$newSlice = array_slice($currBlocks, $nCursor, max(0, $nIdx - $nCursor));
[$sliceParts, $sliceChanges] = self::highlightUnmatchedBlockSlice($oldSlice, $newSlice);
foreach ($sliceParts as $p) {
$outParts[] = $p;
}
$changedBlocks += $sliceChanges;
}
if ($nIdx < count($currBlocks)) {
$outParts[] = $currBlocks[$nIdx]['html'];
}
$oCursor = $oIdx + 1;
$nCursor = $nIdx + 1;
}
if ($changedBlocks <= 0) {
return ['has_changes' => false, 'changed_blocks' => 0, 'summary' => '', 'html' => $currHtml];
}
return [
'has_changes' => true,
'changed_blocks' => $changedBlocks,
'summary' => '共 ' . $changedBlocks . ' 处条款发生新增或修订',
'html' => implode('', $outParts),
];
}
/**
* 对未精确匹配的旧/新块区间做最相似配对并生成背景色标识
*
* @return array{0:array<int,string>,1:int}
*/
protected static function highlightUnmatchedBlockSlice(array $oldSlice, array $newSlice): array
{
$out = [];
$changes = 0;
$usedOld = [];
foreach ($newSlice as $nb) {
// 分割线等非文本占位块不标记变更
if ($nb['norm'] === '---hr---' || $nb['norm'] === '') {
$out[] = $nb['html'];
continue;
}
$bestOldIdx = -1;
$bestSim = 0.0;
foreach ($oldSlice as $oi => $ob) {
if (isset($usedOld[$oi]) || $ob['norm'] === '' || $ob['norm'] === '---hr---') {
continue;
}
$sim = self::calculateTokenSimilarity($ob['raw_text'], $nb['raw_text']);
if ($sim > $bestSim) {
$bestSim = $sim;
$bestOldIdx = $oi;
}
}
if ($bestOldIdx >= 0 && $bestSim >= 0.30) {
$usedOld[$bestOldIdx] = true;
[$modHtml, $modified] = self::highlightModifiedDomBlock(
$oldSlice[$bestOldIdx]['raw_text'],
$nb['html'],
$nb['tag']
);
$out[] = $modHtml;
if ($modified) {
$changes++;
}
} else {
$out[] = self::wrapElementWithBlockStyle($nb['html'], $nb['tag'], self::getAddedBlockStyle($nb['tag']));
$changes++;
}
}
return [$out, $changes];
}
/**
* 在已配对的新段落 DOM 中,对修改/新增文字节点精准包裹背景色 <span>,并补充行标
*
* @return array{0:string,1:bool}
*/
protected static function highlightModifiedDomBlock(string $oldText, string $newBlockHtml, string $tag): array
{
$loaded = self::loadHtmlFragmentDom($newBlockHtml);
if ($loaded === null) {
return [self::wrapElementWithBlockStyle($newBlockHtml, $tag, self::getAddedBlockStyle($tag)), true];
}
[$dom, $root] = $loaded;
$textNodes = [];
$cursorChars = 0;
self::collectDomTextNodes($root, $textNodes, $cursorChars);
if (empty($textNodes)) {
return [$newBlockHtml, false];
}
$newText = '';
foreach ($textNodes as $item) {
$newText .= $item['text'];
}
$oldTokens = self::tokenizeForDiff($oldText);
$newTokens = self::tokenizeForDiff($newText);
$oldCount = count($oldTokens);
$newCount = count($newTokens);
// 超长块安全退化,防止大矩阵运算开销
if ($oldCount * $newCount > 360000 || $newCount === 0) {
return [self::wrapElementWithBlockStyle($newBlockHtml, $tag, self::getAddedBlockStyle($tag)), true];
}
$anchors = self::computeLcsAnchors($oldTokens, $newTokens);
$matchedNew = [];
foreach ($anchors as [, $nj]) {
$matchedNew[$nj] = true;
}
$changedFlags = array_fill(0, $newCount, false);
$totalNonWs = 0;
$changedNonWs = 0;
for ($i = 0; $i < $newCount; $i++) {
$isWs = (trim($newTokens[$i]) === '');
if (!$isWs) {
$totalNonWs++;
}
if (!isset($matchedNew[$i])) {
$changedFlags[$i] = true;
if (!$isWs) {
$changedNonWs++;
}
}
}
// 若未检测到新增字符但纯文本确实有差异(例如句内精简删字),标示整段温和修订底色
if ($changedNonWs === 0) {
if (self::normalizeComparableText($oldText) !== self::normalizeComparableText($newText)) {
return [self::wrapElementWithBlockStyle($newBlockHtml, $tag, self::getRevisedContainerStyle($tag, true)), true];
}
return [$newBlockHtml, false];
}
// 若该段落超过 78% 非空白词均为新写入(大幅重写),采用整块浅橙高亮更清爽
if ($totalNonWs > 0 && ($changedNonWs / $totalNonWs) >= 0.78) {
return [self::wrapElementWithBlockStyle($newBlockHtml, $tag, self::getAddedBlockStyle($tag)), true];
}
$ranges = self::buildSmoothedHighlightRanges($changedFlags, $newTokens);
if (empty($ranges)) {
return [$newBlockHtml, false];
}
foreach ($textNodes as $item) {
$nStart = $item['start'];
$nEnd = $item['end'];
$overlaps = [];
foreach ($ranges as [$rStart, $rEnd]) {
$s = max($nStart, $rStart);
$e = min($nEnd, $rEnd);
if ($e > $s) {
$overlaps[] = [$s - $nStart, $e - $nStart];
}
}
if (empty($overlaps)) {
continue;
}
self::replaceDomTextWithHighlights($dom, $item['node'], $item['text'], $item['len'], $overlaps);
}
$rendered = trim(self::innerHtmlOfNode($dom, $root));
$rendered = self::wrapElementWithBlockStyle($rendered, $tag, self::getRevisedContainerStyle($tag, false));
return [$rendered, true];
}
/**
* 平滑合并相邻高亮 Token 为连续字符区间,跳过首尾纯空白并桥接被单个标点/空格隔断的高亮片段
*
* @return array<int,array{0:int,1:int}>
*/
protected static function buildSmoothedHighlightRanges(array $flags, array $tokens): array
{
$n = count($tokens);
// 若中间仅隔 1 个短分隔Token(空白、顿号、逗号、冒号等),则连成连续高光块
for ($i = 1; $i < $n - 1; $i++) {
if (!$flags[$i] && $flags[$i - 1] && $flags[$i + 1]) {
$t = trim($tokens[$i]);
if ($t === '' || mb_strlen($t, 'UTF-8') <= 1 && preg_match('/^[\s,,、;;::\.。\-—]+$/u', $tokens[$i])) {
$flags[$i] = true;
}
}
}
$ranges = [];
$charOffset = 0;
$curStart = null;
$curEnd = null;
$lastNonWsEnd = null;
for ($i = 0; $i < $n; $i++) {
$tok = $tokens[$i];
$tokLen = mb_strlen($tok, 'UTF-8');
$isWs = (trim($tok) === '');
if ($flags[$i]) {
if ($curStart === null) {
if ($isWs) {
$charOffset += $tokLen;
continue;
}
$curStart = $charOffset;
$curEnd = $charOffset + $tokLen;
$lastNonWsEnd = $curEnd;
} else {
$curEnd = $charOffset + $tokLen;
if (!$isWs) {
$lastNonWsEnd = $curEnd;
}
}
} else {
if ($curStart !== null && $lastNonWsEnd !== null && $lastNonWsEnd > $curStart) {
$ranges[] = [$curStart, $lastNonWsEnd];
}
$curStart = null;
$curEnd = null;
$lastNonWsEnd = null;
}
$charOffset += $tokLen;
}
if ($curStart !== null && $lastNonWsEnd !== null && $lastNonWsEnd > $curStart) {
$ranges[] = [$curStart, $lastNonWsEnd];
}
return $ranges;
}
/**
* 将 DOMText 节点按字符区间切分为普通文本与带背景色的 <span> 高亮节点
*/
protected static function replaceDomTextWithHighlights(\DOMDocument $dom, \DOMText $textNode, string $text, int $len, array $overlaps): void
{
$parent = $textNode->parentNode;
if (!$parent) {
return;
}
$frag = $dom->createDocumentFragment();
$cursor = 0;
$spanStyle = 'background-color:#FFE2B0;color:#7C2D12;padding:1px 3px;border-radius:3px;';
foreach ($overlaps as [$s, $e]) {
if ($s > $cursor) {
$frag->appendChild($dom->createTextNode(mb_substr($text, $cursor, $s - $cursor, 'UTF-8')));
}
$seg = mb_substr($text, $s, $e - $s, 'UTF-8');
if ($seg !== '') {
$span = $dom->createElement('span');
$span->setAttribute('style', $spanStyle);
$span->appendChild($dom->createTextNode($seg));
$frag->appendChild($span);
}
$cursor = $e;
}
if ($cursor < $len) {
$frag->appendChild($dom->createTextNode(mb_substr($text, $cursor, $len - $cursor, 'UTF-8')));
}
$parent->replaceChild($frag, $textNode);
}
/**
* 抽取 HTML 顶层语义块(保留嵌套标签结构,提取归一化文本用于对齐)
*
* @return array<int,array{tag:string,norm:string,raw_text:string,html:string}>
*/
protected static function extractHtmlBlocks(string $html): array
{
$loaded = self::loadHtmlFragmentDom($html);
if ($loaded === null) {
$plain = trim(strip_tags($html));
if ($plain === '') {
return [];
}
return [[
'tag' => 'p',
'norm' => self::normalizeComparableText($plain),
'raw_text' => $plain,
'html' => '<p>' . htmlspecialchars($plain, ENT_QUOTES, 'UTF-8') . '</p>',
]];
}
[$dom, $root] = $loaded;
$blocks = [];
foreach ($root->childNodes as $child) {
if ($child instanceof \DOMText) {
$txt = trim($child->nodeValue);
if ($txt === '') {
continue;
}
$blocks[] = [
'tag' => 'p',
'norm' => self::normalizeComparableText($txt),
'raw_text' => $txt,
'html' => '<p>' . htmlspecialchars($txt, ENT_QUOTES, 'UTF-8') . '</p>',
];
continue;
}
if (!($child instanceof \DOMElement)) {
continue;
}
$tag = strtolower($child->tagName);
$nodeHtml = trim($dom->saveHTML($child));
if ($tag === 'hr') {
$blocks[] = [
'tag' => 'hr',
'norm' => '---hr---',
'raw_text' => '',
'html' => $nodeHtml,
];
continue;
}
$rawText = str_replace("\xC2\xA0", ' ', $child->textContent);
$norm = self::normalizeComparableText($rawText);
if ($norm === '') {
continue;
}
$blocks[] = [
'tag' => $tag,
'norm' => $norm,
'raw_text' => trim($rawText),
'html' => $nodeHtml,
];
}
return $blocks;
}
/**
* 递归收集容器下的所有非空 DOMText 节点并记录字符下标映射
*/
protected static function collectDomTextNodes(\DOMNode $node, array &$out, int &$cursor): void
{
if (!$node->hasChildNodes()) {
return;
}
foreach ($node->childNodes as $child) {
if ($child instanceof \DOMText) {
$val = (string)$child->nodeValue;
$len = mb_strlen($val, 'UTF-8');
if ($len > 0) {
$out[] = [
'node' => $child,
'text' => $val,
'len' => $len,
'start' => $cursor,
'end' => $cursor + $len,
];
$cursor += $len;
}
} elseif ($child instanceof \DOMElement) {
self::collectDomTextNodes($child, $out, $cursor);
}
}
}
/**
* 为顶层元素注入/追加块级高亮背景样式
*/
protected static function wrapElementWithBlockStyle(string $elementHtml, string $tag, string $styleAdd): string
{
if ($styleAdd === '') {
return $elementHtml;
}
$loaded = self::loadHtmlFragmentDom($elementHtml);
if ($loaded !== null) {
[$dom, $root] = $loaded;
foreach ($root->childNodes as $child) {
if ($child instanceof \DOMElement) {
$old = trim((string)$child->getAttribute('style'));
$sep = ($old !== '' && substr($old, -1) !== ';') ? ';' : '';
$child->setAttribute('style', $old . $sep . $styleAdd);
return trim(self::innerHtmlOfNode($dom, $root));
}
}
}
return '<' . $tag . ' style="' . $styleAdd . '">' . $elementHtml . '</' . $tag . '>';
}
/**
* 新增段落/区块浅橙高亮背景样式(针对列表项保留左缩进)
*/
protected static function getAddedBlockStyle(string $tag): string
{
$leftPad = in_array($tag, ['ul', 'ol'], true) ? '26px' : '10px';
return "background-color:#FFF5E6;border-left:3px solid #F0710D;padding:8px 10px 8px {$leftPad};border-radius:6px;";
}
/**
* 修改段落外层定位标识样式
*/
protected static function getRevisedContainerStyle(string $tag, bool $subtleTint): string
{
$leftPad = in_array($tag, ['ul', 'ol'], true) ? '24px' : '10px';
if ($subtleTint) {
return "background-color:#FFF9F2;border-left:3px solid #FDBA74;padding:6px 10px 6px {$leftPad};border-radius:6px;";
}
return "border-left:3px solid #FDBA74;padding-left:{$leftPad};";
}
/**
* 规范化纯文本用于语义等价比对(忽略全角空白/多余空格)
*/
public static function normalizeComparableText(string $text): string
{
$text = str_replace("\xC2\xA0", ' ', $text);
$text = preg_replace('/[\s\r\n\t]+/u', '', trim($text));
return (string)$text;
}
/**
* 中文按单字、英文与数字按连续单词、其余标点/空白按单符切分,适合精确协议文本对比
*
* @return array<int,string>
*/
protected static function tokenizeForDiff(string $text): array
{
if ($text === '') {
return [];
}
preg_match_all('/[a-zA-Z0-9_.\-]+|\s+|./us', $text, $m);
return isset($m[0]) && is_array($m[0]) ? $m[0] : [];
}
/**
* 计算两个 Token 序列的 Jaccard/Dice 相似度,用于配对修订前后的对应段落
*/
protected static function calculateTokenSimilarity(string $textA, string $textB): float
{
$tA = array_values(array_filter(self::tokenizeForDiff($textA), static function ($t) {
return trim($t) !== '';
}));
$tB = array_values(array_filter(self::tokenizeForDiff($textB), static function ($t) {
return trim($t) !== '';
}));
if (empty($tA) || empty($tB)) {
return 0.0;
}
$freqA = array_count_values($tA);
$common = 0;
foreach ($tB as $tok) {
if (!empty($freqA[$tok])) {
$common++;
$freqA[$tok]--;
}
}
return (2.0 * $common) / (count($tA) + count($tB));
}
/**
* 计算两段序列的最长公共子序列匹配下标对 [[idxA, idxB], ...]
*
* @return array<int,array{0:int,1:int}>
*/
protected static function computeLcsAnchors(array $seqA, array $seqB): array
{
$m = count($seqA);
$n = count($seqB);
if ($m === 0 || $n === 0) {
return [];
}
// 跳过公共前后缀缩小 DP 表规模
$prefix = 0;
while ($prefix < $m && $prefix < $n && $seqA[$prefix] === $seqB[$prefix]) {
$prefix++;
}
$suffix = 0;
while ($suffix < ($m - $prefix) && $suffix < ($n - $prefix) && $seqA[$m - 1 - $suffix] === $seqB[$n - 1 - $suffix]) {
$suffix++;
}
$anchors = [];
for ($i = 0; $i < $prefix; $i++) {
$anchors[] = [$i, $i];
}
$midM = $m - $prefix - $suffix;
$midN = $n - $prefix - $suffix;
if ($midM > 0 && $midN > 0 && ($midM * $midN) <= 360000) {
$dp = array_fill(0, $midM + 1, array_fill(0, $midN + 1, 0));
for ($i = $midM - 1; $i >= 0; $i--) {
$valA = $seqA[$prefix + $i];
for ($j = $midN - 1; $j >= 0; $j--) {
if ($valA === $seqB[$prefix + $j]) {
$dp[$i][$j] = $dp[$i + 1][$j + 1] + 1;
} else {
$dp[$i][$j] = ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) ? $dp[$i + 1][$j] : $dp[$i][$j + 1];
}
}
}
$i = 0;
$j = 0;
while ($i < $midM && $j < $midN) {
if ($seqA[$prefix + $i] === $seqB[$prefix + $j]) {
$anchors[] = [$prefix + $i, $prefix + $j];
$i++;
$j++;
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
$i++;
} else {
$j++;
}
}
}
for ($k = 0; $k < $suffix; $k++) {
$anchors[] = [$m - $suffix + $k, $n - $suffix + $k];
}
return $anchors;
}
/**
* 安全加载 HTML 片段到 UTF-8 DOMDocument,返回 [$dom, $root]
*
* @return array{0:\DOMDocument,1:\DOMElement}|null
*/
protected static function loadHtmlFragmentDom(string $html): ?array
{
if (!class_exists('\DOMDocument')) {
return null;
}
$prevErrors = libxml_use_internal_errors(true);
$dom = new \DOMDocument('1.0', 'UTF-8');
$wrapped = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><div id="__ag_root__">' . $html . '</div></body></html>';
$loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($prevErrors);
if (!$loaded) {
return null;
}
$root = $dom->getElementById('__ag_root__');
if (!$root) {
return null;
}
return [$dom, $root];
}
/**
* 序列化 DOM 容器下所有子节点 HTML
*/
protected static function innerHtmlOfNode(\DOMDocument $dom, \DOMNode $root): string
{
$out = '';
foreach ($root->childNodes as $child) {
$out .= $dom->saveHTML($child);
}
return $out;
}
/**
* 递归清理 DOM 节点,只保留白名单标签及安全属性
*/
protected static function sanitizeDomNode(\DOMNode $node): void
{
if (!$node->hasChildNodes()) {
return;
}
$children = [];
foreach ($node->childNodes as $child) {
$children[] = $child;
}
foreach ($children as $child) {
if ($child instanceof \DOMComment) {
$node->removeChild($child);
continue;
}
if (!($child instanceof \DOMElement)) {
continue;
}
$tagName = strtolower($child->tagName);
if (in_array($tagName, self::DANGEROUS_TAGS, true)) {
$node->removeChild($child);
continue;
}
if (!in_array($tagName, self::ALLOWED_TAGS, true)) {
// 非白名单标签:先递归净化其子节点,再提升子节点并移除外层标签
self::sanitizeDomNode($child);
while ($child->firstChild) {
$node->insertBefore($child->firstChild, $child);
}
$node->removeChild($child);
continue;
}
// 清理元素属性(仅保留安全属性)
if ($child->hasAttributes()) {
$attrsToRemove = [];
foreach ($child->attributes as $attr) {
$attrName = strtolower($attr->nodeName);
$attrVal = trim($attr->nodeValue);
if (strpos($attrName, 'on') === 0) {
$attrsToRemove[] = $attr->nodeName;
continue;
}
if ($attrName === 'href' && $tagName === 'a') {
if (!preg_match('/^(https?:\/\/|mailto:|tel:|#)/i', $attrVal)) {
$attrsToRemove[] = $attr->nodeName;
}
continue;
}
if ($attrName === 'style') {
$safeStyle = self::filterInlineStyle($attrVal);
if ($safeStyle === '') {
$attrsToRemove[] = $attr->nodeName;
} else {
$child->setAttribute('style', $safeStyle);
}
continue;
}
if (!in_array($attrName, ['colspan', 'rowspan'], true)) {
$attrsToRemove[] = $attr->nodeName;
}
}
foreach ($attrsToRemove as $rm) {
$child->removeAttribute($rm);
}
}
self::sanitizeDomNode($child);
}
}
/**
* 过滤内联样式,仅允许安全的基础排版属性
*/
protected static function filterInlineStyle(string $style): string
{
if (preg_match('/expression|javascript|vbscript|url\s*\(/i', $style)) {
return '';
}
$allowedProps = ['text-align', 'font-weight', 'font-style', 'text-decoration', 'color'];
$safe = [];
foreach (explode(';', $style) as $decl) {
if (strpos($decl, ':') === false) {
continue;
}
[$prop, $val] = explode(':', $decl, 2);
$prop = strtolower(trim($prop));
$val = trim($val);
if (in_array($prop, $allowedProps, true) && $val !== '' && !preg_match('/[<>"]/', $val)) {
$safe[] = $prop . ': ' . $val;
}
}
return implode('; ', $safe);
}
}