1219 行
50 KiB
PHP
1219 行
50 KiB
PHP
<?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);
|
||
}
|
||
}
|