如何根据相关 keywords 搜索多个 databases 并匹配用户
结论
$matches 应使用 user id 作为数组键,匹配次数作为值。当前代码使用 array_push() 创建数字索引数组,而 in_array() 检查的是数组值。这两种方式都不适合按用户编号累计匹配次数。
最直接的改法是:
$matches[$uid] = ($matches[$uid] ?? 0) + 1;
统计完成后,可以使用 arsort() 按匹配次数降序排列,再判断最高分是否足以确定用户。搜索多个表时,最好合并查询,并保证同一个 keyword 对同一个用户最多计分一次。
当前累计逻辑为什么不正确
原来的代码是:
if (in_array($uid, $matches)) {
$matches[$uid]++;
} else {
array_push($matches, $uid);
}
第一次找到用户 3 时,得到的数组其实是:
[
0 => 3
]
这里的 3 是值,不是键。之后执行 $matches[3]++,修改的会是另一个尚不存在的数组元素,数据结构也就乱了。
正确的结构应该是:
[
3 => 2,
8 => 1
]
这表示用户 3 匹配了两次,用户 8 匹配了一次。
如果只改原来的循环,可以这样写:
while ($row = mysql_fetch_assoc($get)) {
$uid = (int) $row['user_id'];
if (!isset($matches[$uid])) {
$matches[$uid] = 0;
}
$matches[$uid]++;
}
也可以简化为:
while ($row = mysql_fetch_assoc($get)) {
$uid = (int) $row['user_id'];
$matches[$uid] = ($matches[$uid] ?? 0) + 1;
}
不过,旧的 mysql_* API 已经废弃,并已从现代 PHP 中移除。新代码应改用 PDO 或 MySQLi,并通过预处理语句传递参数。
一个可用的 PDO 实现
下面的示例假设:
$a是交易类型;$b、$c是付款描述或参考信息;$d是付款金额;- 用户信息保存在
users表; - 房产地址保存在
properties表; - 租赁和租金信息保存在
tenancies表; properties.user_id和tenancies.user_id指向用户。
实际使用时,需要根据现有数据库调整表名和字段名。
<?php
function normalizeKeywords(string ...$parts): array
{
$text = mb_strtoupper(implode(' ', $parts), 'UTF-8');
// 只删除完整的无意义单词,避免把 TO 从其他单词中间删掉。
$stopWords = [
'A/C',
'TO',
'CALL',
'REF',
'NO',
'BAC',
'TRS',
'RENT'
];
$text = str_replace(
['AVENUE', 'ROAD', 'STREET'],
['AVE', 'RD', 'ST'],
$text
);
// 保留字母和数字,其余字符转换为空格。
$text = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text);
$tokens = preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
$tokens = array_filter(
$tokens,
static function (string $token) use ($stopWords): bool {
return mb_strlen($token, 'UTF-8') >= 3
&& !in_array($token, $stopWords, true);
}
);
return array_values(array_unique($tokens));
}
function normalizeAmount(string|float|int $amount): ?string
{
$value = preg_replace('/[^\d,.-]/', '', (string) $amount);
if ($value === '' || $value === '-' || $value === null) {
return null;
}
// 这里只适用于逗号作为千位分隔符的输入,例如 1,250.00。
$value = str_replace(',', '', $value);
if (!is_numeric($value)) {
return null;
}
return number_format((float) $value, 2, '.', '');
}
function reconcile(
PDO $pdo,
string $a,
string $b,
string $c,
string|float|int $d
): array {
$keywords = normalizeKeywords($a, $b, $c);
if ($keywords === []) {
return [
'suggested_user_id' => null,
'matches' => [],
'ambiguous' => false
];
}
/*
* UNION ALL 将不同来源统一为:
*
* user_id | search_text
*
* 外层 SELECT DISTINCT 保证一个 keyword 对同一用户最多加一次,
* 避免一个用户拥有多条房产或租赁记录时重复计分。
*/
$sql = <<<'SQL'
SELECT DISTINCT candidate.user_id
FROM (
SELECT
u.id AS user_id,
CONCAT_WS(' ', u.first_name, u.last_name) AS search_text
FROM users AS u
UNION ALL
SELECT
p.user_id,
CONCAT_WS(
' ',
p.house_number,
p.address_line_1,
p.address_line_2,
p.city,
p.postcode
) AS search_text
FROM properties AS p
UNION ALL
SELECT
t.user_id,
t.reference AS search_text
FROM tenancies AS t
) AS candidate
WHERE UPPER(candidate.search_text) LIKE :pattern
SQL;
$statement = $pdo->prepare($sql);
$matches = [];
foreach ($keywords as $keyword) {
$statement->execute([
'pattern' => '%' . $keyword . '%'
]);
while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
$uid = (int) $row['user_id'];
$matches[$uid] = ($matches[$uid] ?? 0) + 1;
}
}
/*
* 金额不适合用字符串 keyword 模糊匹配。
* 对 DECIMAL 类型的 rent_amount 做精确比较更可靠。
*/
$amount = normalizeAmount($d);
if ($amount !== null) {
$amountStatement = $pdo->prepare(
'SELECT DISTINCT user_id
FROM tenancies
WHERE rent_amount = :amount
AND status = :status'
);
$amountStatement->execute([
'amount' => $amount,
'status' => 'active'
]);
while ($row = $amountStatement->fetch(PDO::FETCH_ASSOC)) {
$uid = (int) $row['user_id'];
// 金额通常比“Avenue”等普通词更有识别力,因此赋予更高权重。
$matches[$uid] = ($matches[$uid] ?? 0) + 3;
}
}
arsort($matches, SORT_NUMERIC);
$userIds = array_keys($matches);
$bestUserId = $userIds[0] ?? null;
$bestScore = $bestUserId !== null ? $matches[$bestUserId] : 0;
$secondScore = isset($userIds[1]) ? $matches[$userIds[1]] : 0;
/*
* 下面的阈值只是业务示例:
* 至少得到 3 分,而且第一名必须明显高于第二名。
* 应根据真实付款数据调整。
*/
$isConfident = $bestScore >= 3 && $bestScore > $secondScore;
return [
'suggested_user_id' => $isConfident ? $bestUserId : null,
'best_candidate_id' => $bestUserId,
'best_score' => $bestScore,
'matches' => $matches,
'ambiguous' => $bestScore > 0 && !$isConfident
];
}
调用后可能得到以下结果:
$result = reconcile(
$pdo,
'BAC',
'37 KINGS AVENUE RENT',
'REFERENCE 1234',
'£500.00'
);
print_r($result['matches']);
输出类似:
[
3 => 6,
8 => 1
]
这表示用户 3 的地址、参考信息或租金得到了较高的总分,而用户 8 只有一次匹配。
不要只根据最高分自动核对
下面的代码可以找出得分最高的用户:
arsort($matches);
$uid = array_key_first($matches);
但最高分不一定代表结果可靠。例如:
[
3 => 2,
8 => 2
]
两名用户得分相同,系统不应直接选择用户 3。判断结果是否可靠,至少要考虑:
- 第一名是否达到最低分;
- 第一名是否高于第二名;
- 是否匹配到租金、完整邮编或付款参考号等可信度较高的字段;
- 当前租约是否有效;
- 同一金额是否对应大量用户。
因此,只有结果足够明确时,Accept 按钮才应显示为推荐操作。如果出现并列或分数过低,应让操作人员从候选列表中手动选择。
一次查询多个 keyword
数据量较大时,不适合为每个 keyword 单独执行一次 SQL。可以建立专门的搜索词索引表:
CREATE TABLE user_search_terms (
user_id BIGINT NOT NULL,
term VARCHAR(100) NOT NULL,
source VARCHAR(30) NOT NULL,
weight SMALLINT NOT NULL DEFAULT 1,
UNIQUE KEY uq_user_term_source (user_id, term, source),
KEY idx_term (term),
KEY idx_user_id (user_id)
);
表中保存统一格式化后的搜索词:
user_id | term | source | weight
--------+--------+----------+-------
3 | KINGS | address | 1
3 | AVE | address | 1
3 | AB1234 | reference| 4
3 | SMITH | name | 2
这样就能一次查询所有 keyword:
$placeholders = implode(',', array_fill(0, count($keywords), '?'));
$sql = "
SELECT
user_id,
COUNT(DISTINCT term) AS matched_terms,
SUM(weight) AS score
FROM user_search_terms
WHERE term IN ($placeholders)
GROUP BY user_id
ORDER BY score DESC, matched_terms DESC
";
$statement = $pdo->prepare($sql);
$statement->execute($keywords);
$matches = [];
while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
$matches[(int) $row['user_id']] = [
'matched_terms' => (int) $row['matched_terms'],
'score' => (int) $row['score']
];
}
与在姓名、地址和房产表中反复执行 LIKE '%keyword%' 相比,这种设计更容易维护,也更适合频繁处理银行流水。
用户姓名、地址或租约发生变化时,必须同步更新 user_search_terms。可以在应用保存数据时更新,也可以通过队列或定时任务重建索引。
搜索多个数据库时的组织方式
如果这里所说的多个 databases 其实是同一个 MySQL 实例中的多个表,应优先使用 JOIN、UNION ALL 或搜索索引表统一查询。
如果数据确实来自多个独立的数据库连接,可以让每个数据库返回相同格式的结果:
[
['user_id' => 3, 'match_key' => 'address:KINGS', 'weight' => 1],
['user_id' => 3, 'match_key' => 'rent:500.00', 'weight' => 3]
]
再由应用层合并:
$matches = [];
$seen = [];
foreach ($allDatabaseResults as $row) {
$uid = (int) $row['user_id'];
$matchKey = $row['match_key'];
$uniqueKey = $uid . ':' . $matchKey;
if (isset($seen[$uniqueKey])) {
continue;
}
$seen[$uniqueKey] = true;
$matches[$uid] = ($matches[$uid] ?? 0) + (int) $row['weight'];
}
arsort($matches, SORT_NUMERIC);
$matchKey 用来去重。如果同一个地址词同时出现在房产表和租赁表中,它不会被重复计分。
需要特别注意的细节
不要用下面的方式删除停用词:
$keywords = str_replace('TO', '', $keywords);
这种写法会删除所有单词内部的 TO,可能破坏姓名、地名或参考号。应该先分词,再比较完整的 token。
也不要同时使用:
addslashes($keywords);
mysql_real_escape_string($keywords);
预处理语句已经负责安全传参,手动转义可能导致内容被重复转义。
金额应使用 DECIMAL 存储,不要保存为浮点字符串,也不要使用 LIKE 匹配。写入索引和处理查询输入时,地址缩写还应采用同一套规范。例如,两边都把 AVENUE 转换为 AVE,否则格式不同仍会导致匹配失败。
数据库的职责只是生成候选用户和分数。真正写入“已核对”状态时,服务端还要再次验证付款记录、用户编号和操作权限,不能只信任前端 Accept 按钮提交的内容。
备注:内容仅供参考。
