feat(search): 大神关键词搜索走服务端(昵称/品类/段位/标签)
- 仓储 getCompanions(keyword?):参数化 LIKE 搜索 name/track/rank_name/tags 及 id 精确匹配 - 接口 /api/companions 支持 ?keyword=(长度截断 40) - 小程序 api.js getCompanions(keyword) 带 encodeURIComponent;首页监听 searchKeyword 防抖 350ms 走服务端搜索,搜索态以服务端结果为准(含空结果不展示陈旧数据) - 回归 95 套件/668 用例全绿;冒烟 4/4(昵称/品类命中、无匹配空数组、无关键词向后兼容)
这个提交包含在:
@@ -191,10 +191,12 @@ export async function getMyActivities(userId) {
|
|||||||
/**
|
/**
|
||||||
* 获取同城大神列表
|
* 获取同城大神列表
|
||||||
*/
|
*/
|
||||||
export async function getCompanions() {
|
export async function getCompanions(keyword) {
|
||||||
try {
|
try {
|
||||||
const list = await http.get('/companions');
|
const kw = String(keyword == null ? '' : keyword).trim();
|
||||||
if (Array.isArray(list) && list.length > 0) {
|
const query = kw ? `?keyword=${encodeURIComponent(kw)}` : '';
|
||||||
|
const list = await http.get(`/companions${query}`);
|
||||||
|
if (Array.isArray(list)) {
|
||||||
return list
|
return list
|
||||||
.filter((c) => !c.auditStatus || c.auditStatus === '已通过')
|
.filter((c) => !c.auditStatus || c.auditStatus === '已通过')
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import AppTabBar from '@/components/CustomTabBar.vue';
|
import AppTabBar from '@/components/CustomTabBar.vue';
|
||||||
import SkeletonScreen from '@/components/SkeletonScreen.vue';
|
import SkeletonScreen from '@/components/SkeletonScreen.vue';
|
||||||
@@ -352,17 +352,32 @@ const loadInitialData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchLiveCompanions = async () => {
|
const fetchLiveCompanions = async (keyword = '') => {
|
||||||
try {
|
try {
|
||||||
const list = await getCompanions();
|
const kw = String(keyword || '').trim();
|
||||||
if (Array.isArray(list) && list.length > 0) {
|
const list = await getCompanions(kw);
|
||||||
experts.value = list;
|
if (Array.isArray(list)) {
|
||||||
|
if (kw) {
|
||||||
|
// 服务端搜索:以服务端结果为准(含空结果),避免仅过滤已加载的一页数据
|
||||||
|
experts.value = list;
|
||||||
|
} else if (list.length > 0) {
|
||||||
|
experts.value = list;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Index] 获取大神数据失败:', err);
|
console.warn('[Index] 获取大神数据失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 关键词变化时防抖走服务端搜索,覆盖超出当前加载范围的候选
|
||||||
|
let searchTimer = null;
|
||||||
|
watch(searchKeyword, (val) => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
fetchLiveCompanions(String(val || '').trim());
|
||||||
|
}, 350);
|
||||||
|
});
|
||||||
|
|
||||||
const fetchLiveBanners = async () => {
|
const fetchLiveBanners = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await getBanners();
|
const list = await getBanners();
|
||||||
|
|||||||
@@ -742,11 +742,11 @@ export class MysqlRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompanions(): Promise<any[]> {
|
async getCompanions(keyword?: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
const [rows]: any = await pool.query(`
|
let sql = `
|
||||||
SELECT
|
SELECT
|
||||||
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
||||||
hourly_rate as hourlyRate, voice_duration as voiceDuration,
|
hourly_rate as hourlyRate, voice_duration as voiceDuration,
|
||||||
@@ -754,8 +754,17 @@ export class MysqlRepository {
|
|||||||
status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
|
status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
|
||||||
id_card_masked as idCardMasked, tags, apply_time as applyTime
|
id_card_masked as idCardMasked, tags, apply_time as applyTime
|
||||||
FROM companions
|
FROM companions
|
||||||
ORDER BY id ASC
|
`;
|
||||||
`);
|
const params: any[] = [];
|
||||||
|
const kw = String(keyword == null ? '' : keyword).trim();
|
||||||
|
if (kw) {
|
||||||
|
// 关键词搜索:昵称 / 陪玩品类 / 段位 / 技能标签(参数化,避免注入)
|
||||||
|
sql += ' WHERE name LIKE ? OR track LIKE ? OR rank_name LIKE ? OR tags LIKE ? OR CAST(id AS CHAR) = ?';
|
||||||
|
const like = `%${kw}%`;
|
||||||
|
params.push(like, like, like, like, kw);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY id ASC';
|
||||||
|
const [rows]: any = await pool.query(sql, params);
|
||||||
return rows.map((r: any) => {
|
return rows.map((r: any) => {
|
||||||
let tagsArr = [];
|
let tagsArr = [];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1073,7 +1073,8 @@ app.get('/api/users/:id/my-activities', async (req: Request, res: Response) => {
|
|||||||
// 4. 陪玩与大神管理接口 (落盘 MySQL)
|
// 4. 陪玩与大神管理接口 (落盘 MySQL)
|
||||||
app.get('/api/companions', async (req: Request, res: Response) => {
|
app.get('/api/companions', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companions = await repo.getCompanions();
|
const keyword = req.query.keyword ? String(req.query.keyword).slice(0, 40) : undefined;
|
||||||
|
const companions = await repo.getCompanions(keyword);
|
||||||
// 管理端可见全量(含待审核/已驳回及内部备注);公开目录仅展示已通过审核的大神,并剥离内部审核备注
|
// 管理端可见全量(含待审核/已驳回及内部备注);公开目录仅展示已通过审核的大神,并剥离内部审核备注
|
||||||
if (isVerifiedAdminRequest(req)) {
|
if (isVerifiedAdminRequest(req)) {
|
||||||
return res.json({ code: 20000, data: companions });
|
return res.json({ code: 20000, data: companions });
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第七十九批:大神关键词搜索(服务端)。
|
||||||
|
*/
|
||||||
|
describe('第七十九批大神搜索闸门', () => {
|
||||||
|
const root = path.resolve(__dirname, '../../');
|
||||||
|
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||||
|
const repo = fs.readFileSync(path.join(root, 'server/src/db/repository.ts'), 'utf-8');
|
||||||
|
const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8');
|
||||||
|
const home = fs.readFileSync(path.join(root, 'pwuniapp/pages/index/index.vue'), 'utf-8');
|
||||||
|
|
||||||
|
it('后端支持关键词搜索(参数化,避免注入)', () => {
|
||||||
|
expect(indexCode).toContain('req.query.keyword');
|
||||||
|
expect(repo).toContain('async getCompanions(keyword?: string)');
|
||||||
|
expect(repo).toContain('WHERE name LIKE ? OR track LIKE ? OR rank_name LIKE ?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小程序搜索走服务端并做防抖', () => {
|
||||||
|
expect(api).toContain('export async function getCompanions(keyword)');
|
||||||
|
expect(api).toContain('encodeURIComponent(kw)');
|
||||||
|
expect(home).toContain('watch(searchKeyword');
|
||||||
|
expect(home).toContain('fetchLiveCompanions');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户