feat(shopro_huifu): 完善银行支行搜索功能

- 添加建设银行等更多银行支行数据到本地数据库
- 优化支行搜索算法,支持省市范围匹配和关键词分词搜索
- 新增支行联行号手动输入功能,提供更好的用户体验
- 改进搜索提示文案,明确说明搜索和匹配规则
- 修复支行搜索验证逻辑,支持多种匹配模式
```
这个提交包含在:
可乐加冰
2026-09-29 16:17:48 +08:00
父节点 38c66b8f5e
当前提交 1061d1f604
共有 7 个文件被更改,包括 262 次插入 和 46 次删除

查看文件

@@ -389,17 +389,23 @@
</el-col>
<el-col :span="24">
<el-form-item label="开户银行支行" required>
<el-input v-model="branchKeyword" class="form-input" placeholder="输入支行名称搜索,并从下方结果中点击选择" @input="onBranchInput"></el-input>
<div class="field-tip">须从搜索结果中点击选择支行,仅输入文字无法保存</div>
<el-input v-model="branchKeyword" class="form-input" placeholder="例如:建设银行中山高科技支行" @input="onBranchInput"></el-input>
<div class="field-tip">搜索支行名称,系统将自动匹配联行号(与小程序一致);也可从下方结果点击选择</div>
<div class="field-tip" v-if="branchSearched && !branchResults.length && branchKeyword" style="color:#E6A23C;">本地库未搜到,保存时服务端会再次自动匹配;仍失败请手动填写联行号</div>
<div class="field-tip" v-if="form.branch_code" style="color:#7536D0;">已选:{{ form.branch_name }}(联行号 {{ form.branch_code }})</div>
<div class="branch-panel" v-if="branchResults.length">
<div class="branch-item" v-for="(item, idx) in branchResults" :key="idx" @click="selectBranch(item)">
<div>{{ item.branch_name }}</div>
<div style="color:#909399;font-size:12px;">{{ item.branch_code }}</div>
<div style="color:#909399;font-size:12px;">{{ item.branch_code }}<span v-if="item.area_id"> · {{ item.area_id }}</span></div>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="支行联行号">
<el-input v-model="form.branch_code" maxlength="20" class="form-input" placeholder="12位联行号,可手动填写" @input="onBranchCodeInput"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="银行卡正面照">
<el-upload :action="uploadUrl" :show-file-list="false" accept="image/*" :on-success="(res) => onUploadSuccess('bank_card_url', res)">

查看文件

@@ -18,5 +18,10 @@
{"branch_code":"103124075623","branch_name":"中国农业银行股份有限公司铜仁分行","bank_name":"农业银行","prov_id":"520000","area_id":"520600"},
{"branch_code":"103124075624","branch_name":"中国农业银行股份有限公司黔西南分行","bank_name":"农业银行","prov_id":"520000","area_id":"522300"},
{"branch_code":"103124075625","branch_name":"中国农业银行股份有限公司黔东南分行","bank_name":"农业银行","prov_id":"520000","area_id":"522600"},
{"branch_code":"103124075626","branch_name":"中国农业银行股份有限公司黔南分行","bank_name":"农业银行","prov_id":"520000","area_id":"522700"}
{"branch_code":"103124075626","branch_name":"中国农业银行股份有限公司黔南分行","bank_name":"农业银行","prov_id":"520000","area_id":"522700"},
{"branch_code":"105603000311","branch_name":"中国建设银行股份有限公司中山高科技支行","bank_name":"建设银行","prov_id":"440000","area_id":"442000"},
{"branch_code":"105582000010","branch_name":"中国建设银行股份有限公司韶关市分行","bank_name":"建设银行","prov_id":"440000","area_id":"440200"},
{"branch_code":"105582000315","branch_name":"中国建设银行股份有限公司韶关曲江支行","bank_name":"建设银行","prov_id":"440000","area_id":"440200"},
{"branch_code":"105582000028","branch_name":"中国建设银行股份有限公司韶关武江支行","bank_name":"建设银行","prov_id":"440000","area_id":"440200"},
{"branch_code":"105582000132","branch_name":"中国建设银行股份有限公司韶关浈江支行","bank_name":"建设银行","prov_id":"440000","area_id":"440200"}
]

查看文件

@@ -19,13 +19,29 @@ class HuifuOnboardBankBranch
}
$limit = max(1, min(50, (int) $limit));
$cityCode = HuifuOnboardAreaHelper::toCode($cityCode);
$provCode = self::provinceFromCity($cityCode);
$rows = self::searchDatabase($keyword, $cityCode, $limit);
$rows = self::collectSearch($keyword, $cityCode, $provCode, $limit);
if ($rows) {
return $rows;
}
if ($cityCode !== '') {
$rows = self::collectSearch($keyword, '', $provCode, $limit);
if ($rows) {
return $rows;
}
}
return self::collectSearch($keyword, '', '', $limit);
}
protected static function collectSearch($keyword, $cityCode, $provCode, $limit)
{
$rows = self::searchDatabase($keyword, $cityCode, $provCode, $limit);
if (count($rows) >= $limit) {
return $rows;
}
$jsonRows = self::searchJson($keyword, $cityCode, $limit - count($rows));
$jsonRows = self::searchJson($keyword, $cityCode, $provCode, $limit - count($rows));
$existing = array_column($rows, 'branch_code');
foreach ($jsonRows as $row) {
if (in_array($row['branch_code'], $existing, true)) {
@@ -37,22 +53,18 @@ class HuifuOnboardBankBranch
}
}
return $rows;
return self::rankMatches($rows, $keyword);
}
protected static function searchDatabase($keyword, $cityCode, $limit)
protected static function searchDatabase($keyword, $cityCode, $provCode, $limit)
{
try {
$query = Db::name('shopro_huifu_bank_branch')->where('status', 1);
if ($cityCode !== '') {
$query->where('area_id', $cityCode);
}
self::applyAreaScope($query, $cityCode, $provCode);
$query->where(function ($q) use ($keyword) {
$q->whereLike('branch_name', '%' . $keyword . '%')
->whereOr('branch_code', 'like', '%' . $keyword . '%')
->whereOr('bank_name', 'like', '%' . $keyword . '%');
self::applyKeywordScope($q, $keyword);
});
$list = $query->limit($limit)->select();
$list = $query->limit($limit * 3)->select();
if (!$list) {
return [];
}
@@ -66,41 +78,192 @@ class HuifuOnboardBankBranch
'area_id' => (string) ($item['area_id'] ?? ''),
];
}
return $rows;
return array_slice(self::rankMatches($rows, $keyword), 0, $limit);
} catch (\Throwable $e) {
return [];
}
}
protected static function searchJson($keyword, $cityCode = '', $limit = 20)
protected static function searchJson($keyword, $cityCode = '', $provCode = '', $limit = 20)
{
$limit = max(0, (int) $limit);
if ($limit <= 0) {
return [];
}
$cityCode = HuifuOnboardAreaHelper::toCode($cityCode);
$provCode = HuifuOnboardAreaHelper::toCode($provCode);
$tokens = self::keywordTokens($keyword);
$all = self::loadJson();
$keywordLower = mb_strtolower($keyword, 'UTF-8');
$rows = [];
foreach ($all as $item) {
if ($cityCode !== '' && HuifuOnboardAreaHelper::toCode($item['area_id'] ?? '') !== $cityCode) {
if (!self::matchAreaScope($item, $cityCode, $provCode)) {
continue;
}
$hay = mb_strtolower(
($item['branch_name'] ?? '') . ' ' . ($item['bank_name'] ?? '') . ' ' . ($item['branch_code'] ?? ''),
'UTF-8'
);
if (mb_strpos($hay, $keywordLower, 0, 'UTF-8') === false) {
if (!self::matchKeywordTokens($item, $tokens, $keyword)) {
continue;
}
$rows[] = $item;
if (count($rows) >= $limit) {
break;
}
return array_slice(self::rankMatches($rows, $keyword), 0, $limit);
}
protected static function applyAreaScope($query, $cityCode, $provCode)
{
$cityCode = HuifuOnboardAreaHelper::toCode($cityCode);
$provCode = HuifuOnboardAreaHelper::toCode($provCode);
if ($cityCode !== '') {
$query->where('area_id', $cityCode);
return;
}
if ($provCode !== '') {
$query->where('prov_id', $provCode);
}
}
protected static function matchAreaScope(array $item, $cityCode, $provCode)
{
$cityCode = HuifuOnboardAreaHelper::toCode($cityCode);
$provCode = HuifuOnboardAreaHelper::toCode($provCode);
if ($cityCode !== '') {
return HuifuOnboardAreaHelper::toCode($item['area_id'] ?? '') === $cityCode;
}
if ($provCode !== '') {
return HuifuOnboardAreaHelper::toCode($item['prov_id'] ?? '') === $provCode;
}
return true;
}
protected static function applyKeywordScope($query, $keyword)
{
$tokens = self::keywordTokens($keyword);
if (!$tokens) {
$query->whereLike('branch_name', '%' . $keyword . '%');
return;
}
foreach ($tokens as $token) {
$query->where(function ($q) use ($token) {
$q->whereLike('branch_name', '%' . $token . '%')
->whereOr('branch_code', 'like', '%' . $token . '%')
->whereOr('bank_name', 'like', '%' . $token . '%');
});
}
}
protected static function normalizeKeyword($keyword)
{
$keyword = trim((string) $keyword);
$keyword = str_replace(['理行', '支理'], '支行', $keyword);
$keyword = preg_replace('/([\x{4e00}-\x{9fff}])支行/u', '$1 支行', $keyword);
$keyword = preg_replace('/([\x{4e00}-\x{9fff}])分行/u', '$1 分行', $keyword);
return trim(preg_replace('/\s+/u', ' ', $keyword));
}
protected static function keywordTokens($keyword)
{
$keyword = self::normalizeKeyword($keyword);
if ($keyword === '') {
return [];
}
if (preg_match('/^\d{8,20}$/', $keyword)) {
return [$keyword];
}
$parts = preg_split('/[\s,,、\/]+/u', $keyword, -1, PREG_SPLIT_NO_EMPTY);
$tokens = [];
foreach ($parts ?: [$keyword] as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
$tokens[] = $part;
$expanded = self::expandKeywordAliases($part);
foreach ($expanded as $alias) {
if (!in_array($alias, $tokens, true)) {
$tokens[] = $alias;
}
}
}
return array_values(array_unique($tokens));
}
protected static function expandKeywordAliases($token)
{
$map = [
'建行' => ['建设银行', '建设'],
'农行' => ['农业银行', '农业'],
'工行' => ['工商银行', '工商'],
'中行' => ['中国银行', '中国'],
'交行' => ['交通银行', '交通'],
'理行' => ['支行'],
];
$aliases = [];
foreach ($map as $short => $fullList) {
if (mb_strpos($token, $short, 0, 'UTF-8') !== false) {
$aliases = array_merge($aliases, $fullList);
}
}
return $aliases;
}
protected static function matchKeywordTokens(array $item, array $tokens, $keyword)
{
if (preg_match('/^\d{8,20}$/', $keyword)) {
return strpos((string) ($item['branch_code'] ?? ''), $keyword) !== false;
}
$hay = mb_strtolower(
($item['branch_name'] ?? '') . ' ' . ($item['bank_name'] ?? '') . ' ' . ($item['branch_code'] ?? ''),
'UTF-8'
);
if (!$tokens) {
return mb_strpos($hay, mb_strtolower($keyword, 'UTF-8'), 0, 'UTF-8') !== false;
}
foreach ($tokens as $token) {
if (mb_strpos($hay, mb_strtolower($token, 'UTF-8'), 0, 'UTF-8') === false) {
return false;
}
}
return true;
}
protected static function rankMatches(array $rows, $keyword)
{
if (!$rows) {
return [];
}
$keywordLower = mb_strtolower(trim((string) $keyword), 'UTF-8');
usort($rows, function ($a, $b) use ($keywordLower) {
return self::matchScore($b, $keywordLower) <=> self::matchScore($a, $keywordLower);
});
return $rows;
}
protected static function matchScore(array $item, $keywordLower)
{
$name = mb_strtolower((string) ($item['branch_name'] ?? ''), 'UTF-8');
$code = (string) ($item['branch_code'] ?? '');
if ($keywordLower !== '' && $name === $keywordLower) {
return 1000;
}
if ($keywordLower !== '' && mb_strpos($name, $keywordLower, 0, 'UTF-8') === 0) {
return 900;
}
if ($keywordLower !== '' && mb_strpos($name, $keywordLower, 0, 'UTF-8') !== false) {
return 800;
}
if ($keywordLower !== '' && strpos($code, $keywordLower) !== false) {
return 700;
}
return 100;
}
protected static function provinceFromCity($cityCode)
{
$cityCode = HuifuOnboardAreaHelper::toCode($cityCode);
if (strlen($cityCode) < 2) {
return '';
}
return substr($cityCode, 0, 2) . '0000';
}
protected static function loadJson()
{
if (self::$jsonCache !== null) {

查看文件

@@ -91,10 +91,10 @@ class HuifuOnboardValidator
throw new \InvalidArgumentException('银行卡号格式不正确');
}
if (empty($data['branch_code']) && empty($data['branch_name'])) {
throw new \InvalidArgumentException('请选择开户银行支行');
throw new \InvalidArgumentException('请填写开户银行支行');
}
if (empty($data['branch_code']) && !empty($data['branch_name'])) {
throw new \InvalidArgumentException('请从搜索结果中点击选择开户银行支行(需联行号)');
if (empty($data['branch_code'])) {
throw new \InvalidArgumentException('未能自动匹配支行联行号,请检查支行名称或手动填写联行号');
}
if (empty($data['prov_id']) || empty($data['area_id'])) {
throw new \InvalidArgumentException('请选择所在区域');
@@ -150,9 +150,13 @@ class HuifuOnboardValidator
if ($name === '') {
return;
}
$city = $data['area_id'] ?? '';
$matches = HuifuOnboardBankBranch::search($name, $city, 1);
$city = $data['area_id'] ?? ($data['shopro_city_id'] ?? '');
$matches = HuifuOnboardBankBranch::search($name, $city, 5);
if (!$matches) {
$matches = HuifuOnboardBankBranch::search($name, '', 5);
}
if ($matches) {
$matches = [$matches[0]];
$data['branch_code'] = $matches[0]['branch_code'];
$data['branch_name'] = $matches[0]['branch_name'];
if (empty($data['prov_id']) && !empty($matches[0]['prov_id'])) {

查看文件

@@ -90,6 +90,7 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
areaProps: { value: 'id', label: 'name', children: 'children' },
branchKeyword: '',
branchResults: [],
branchSearched: false,
uploadUrl: Fast.api.fixurl('ajax/upload'),
saving: false
};
@@ -242,6 +243,7 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
branchTimer = setTimeout(function () {
if (!keyword) {
vm.branchResults = [];
vm.branchSearched = false;
vm.form.branch_code = '';
vm.form.branch_name = '';
return;
@@ -255,10 +257,21 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
}
}, function (ret, res) {
vm.branchResults = res.data || [];
vm.branchSearched = true;
if (vm.branchResults.length === 1 && !vm.form.branch_code) {
vm.selectBranch(vm.branchResults[0]);
}
return false;
});
}, 300);
},
onBranchCodeInput: function () {
var code = (this.form.branch_code || '').replace(/\D/g, '');
this.form.branch_code = code;
if (code && !this.form.branch_name && this.branchKeyword) {
this.form.branch_name = this.branchKeyword.trim();
}
},
prepareFormBeforeSubmit: function () {
var keyword = (this.branchKeyword || '').trim();
if (keyword) {
@@ -286,12 +299,15 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
Toastr.error('请填写银行预留手机号');
return false;
}
if (!form.branch_code) {
if ((this.branchKeyword || '').trim() || form.branch_name) {
Toastr.error('请从支行搜索结果中点击选择开户银行(需联行号)');
} else {
Toastr.error('请搜索并选择开户银行支行');
}
if (!form.branch_code && !(this.branchKeyword || '').trim() && !form.branch_name) {
Toastr.error('请填写开户银行支行');
return false;
}
if (!form.branch_name && this.branchKeyword) {
form.branch_name = this.branchKeyword.trim();
}
if (form.branch_code && !/^\d{8,20}$/.test(form.branch_code)) {
Toastr.error('联行号格式不正确,应为8-20位数字');
return false;
}
if (!form.shopro_province_id && !form.prov_id) {

查看文件

@@ -389,17 +389,23 @@
</el-col>
<el-col :span="24">
<el-form-item label="开户银行支行" required>
<el-input v-model="branchKeyword" class="form-input" placeholder="输入支行名称搜索,并从下方结果中点击选择" @input="onBranchInput"></el-input>
<div class="field-tip">须从搜索结果中点击选择支行,仅输入文字无法保存</div>
<el-input v-model="branchKeyword" class="form-input" placeholder="例如:建设银行中山高科技支行" @input="onBranchInput"></el-input>
<div class="field-tip">搜索支行名称,系统将自动匹配联行号(与小程序一致);也可从下方结果点击选择</div>
<div class="field-tip" v-if="branchSearched && !branchResults.length && branchKeyword" style="color:#E6A23C;">本地库未搜到,保存时服务端会再次自动匹配;仍失败请手动填写联行号</div>
<div class="field-tip" v-if="form.branch_code" style="color:#7536D0;">已选:{{ form.branch_name }}(联行号 {{ form.branch_code }})</div>
<div class="branch-panel" v-if="branchResults.length">
<div class="branch-item" v-for="(item, idx) in branchResults" :key="idx" @click="selectBranch(item)">
<div>{{ item.branch_name }}</div>
<div style="color:#909399;font-size:12px;">{{ item.branch_code }}</div>
<div style="color:#909399;font-size:12px;">{{ item.branch_code }}<span v-if="item.area_id"> · {{ item.area_id }}</span></div>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="支行联行号">
<el-input v-model="form.branch_code" maxlength="20" class="form-input" placeholder="12位联行号,可手动填写" @input="onBranchCodeInput"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="银行卡正面照">
<el-upload :action="uploadUrl" :show-file-list="false" accept="image/*" :on-success="(res) => onUploadSuccess('bank_card_url', res)">

查看文件

@@ -90,6 +90,7 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
areaProps: { value: 'id', label: 'name', children: 'children' },
branchKeyword: '',
branchResults: [],
branchSearched: false,
uploadUrl: Fast.api.fixurl('ajax/upload'),
saving: false
};
@@ -242,6 +243,7 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
branchTimer = setTimeout(function () {
if (!keyword) {
vm.branchResults = [];
vm.branchSearched = false;
vm.form.branch_code = '';
vm.form.branch_name = '';
return;
@@ -255,10 +257,21 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
}
}, function (ret, res) {
vm.branchResults = res.data || [];
vm.branchSearched = true;
if (vm.branchResults.length === 1 && !vm.form.branch_code) {
vm.selectBranch(vm.branchResults[0]);
}
return false;
});
}, 300);
},
onBranchCodeInput: function () {
var code = (this.form.branch_code || '').replace(/\D/g, '');
this.form.branch_code = code;
if (code && !this.form.branch_name && this.branchKeyword) {
this.form.branch_name = this.branchKeyword.trim();
}
},
prepareFormBeforeSubmit: function () {
var keyword = (this.branchKeyword || '').trim();
if (keyword) {
@@ -286,12 +299,15 @@ define(['jquery', 'bootstrap', 'backend'], function ($, undefined, Backend) {
Toastr.error('请填写银行预留手机号');
return false;
}
if (!form.branch_code) {
if ((this.branchKeyword || '').trim() || form.branch_name) {
Toastr.error('请从支行搜索结果中点击选择开户银行(需联行号)');
} else {
Toastr.error('请搜索并选择开户银行支行');
}
if (!form.branch_code && !(this.branchKeyword || '').trim() && !form.branch_name) {
Toastr.error('请填写开户银行支行');
return false;
}
if (!form.branch_name && this.branchKeyword) {
form.branch_name = this.branchKeyword.trim();
}
if (form.branch_code && !/^\d{8,20}$/.test(form.branch_code)) {
Toastr.error('联行号格式不正确,应为8-20位数字');
return false;
}
if (!form.shopro_province_id && !form.prov_id) {