first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\qywx\CustomerLogic;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use think\facade\Db;
/**
* 企业微信客户列表
*/
class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
{
/**
* @notes 搜索条件
*/
public function setSearch(): array
{
// 表无 follow_user 列,跟进人在 follow_users(JSON);跟进人筛选在 baseQuery() 中处理
return [
'%like%' => ['name'],
];
}
/**
* @return int[] 入参 tag_ids 规范化后的非空字符串数组(实际为 string[])
*/
private function normalizeTagIds(): array
{
$raw = $this->params['tag_ids'] ?? null;
if ($raw === null || $raw === '') {
return [];
}
if (is_string($raw)) {
$raw = explode(',', $raw);
}
if (!is_array($raw)) {
return [];
}
$ids = [];
foreach ($raw as $v) {
$s = trim((string) $v);
if ($s !== '') {
$ids[] = $s;
}
}
return array_values(array_unique($ids));
}
/**
* 去重筛选:
* - first(默认):按客户首次添加时间筛选(客户去重,系统原口径)
* - any:按添加事件流水筛选(含老客被其他员工重加,对齐企微「当天有添加动作」)
*/
private function dedupeMode(): string
{
$mode = strtolower(trim((string) ($this->params['dedupe_mode'] ?? 'first')));
return $mode === 'any' ? 'any' : 'first';
}
/**
* @return string[] 跟进人关键词对应的企微 userid(含已软删 admin
*/
private function resolveFollowStaffWxUserids(): array
{
$kw = trim((string) ($this->params['follow_user'] ?? ''));
if ($kw === '') {
return [];
}
$escaped = addcslashes($kw, '%_\\');
$rows = Db::name('admin')
->whereLike('name', '%' . $escaped . '%')
->where('work_wechat_userid', '<>', '')
->whereNotNull('work_wechat_userid')
->column('work_wechat_userid');
$wxUserids = [];
foreach ($rows as $uid) {
$uid = trim((string) $uid);
if ($uid !== '') {
$wxUserids[$uid] = true;
}
}
// 关键词本身也可能是企微 userid(通常无中文);中文名不能当 userid 去筛事件流水
if ($kw !== '' && !preg_match('/\p{Han}/u', $kw)) {
$wxUserids[$kw] = true;
}
return array_keys($wxUserids);
}
/**
* 跟进人筛选:列表展示优先用 admin.name(见 lists() 补 admin_name),
* 但库内 follow_users JSON 通常只有企微 userid,直接 LIKE 中文名会搜不到。
* 策略:admin.name 模糊匹配 → work_wechat_userid / admin.id,再按 userid / follow_admin_ids 过滤;
* 同时保留对 follow_users 原文的 LIKE(兼容直接搜 userid、备注等)。
*/
private function applyFollowUserFilter($query)
{
$kw = trim((string) ($this->params['follow_user'] ?? ''));
if ($kw === '') {
return;
}
$escaped = addcslashes($kw, '%_\\');
// 含已软删账号,避免离职后跟进人姓名映射丢失导致搜不到历史客户
$adminRows = Db::name('admin')
->whereLike('name', '%' . $escaped . '%')
->where('work_wechat_userid', '<>', '')
->whereNotNull('work_wechat_userid')
->field(['id', 'work_wechat_userid'])
->select()
->toArray();
$wxUserids = [];
$adminIds = [];
foreach ($adminRows as $row) {
$uid = trim((string) ($row['work_wechat_userid'] ?? ''));
if ($uid !== '') {
$wxUserids[$uid] = true;
}
$aid = (int) ($row['id'] ?? 0);
if ($aid > 0) {
$adminIds[$aid] = true;
}
}
$wxUserids = array_keys($wxUserids);
$adminIds = array_keys($adminIds);
$query->where(function ($q) use ($escaped, $wxUserids, $adminIds) {
$q->whereLike('follow_users', '%' . $escaped . '%');
foreach ($wxUserids as $uid) {
$uidEsc = addcslashes($uid, '%_\\');
$q->whereOr('follow_users', 'like', '%"userid":"' . $uidEsc . '"%');
}
foreach ($adminIds as $aid) {
// JSON 数组精确包含,避免 id=12 误命中 123
$q->whereOrRaw(
'JSON_CONTAINS(IFNULL(follow_admin_ids, \'[]\'), ?)',
[json_encode($aid)]
);
}
});
}
/**
* @return array{0:int,1:int} [startTs, endTs],未设置则为 0
*/
private function parseAddTimeBounds(): array
{
$addStart = trim((string) ($this->params['add_time_start'] ?? ''));
$addEnd = trim((string) ($this->params['add_time_end'] ?? ''));
$startTs = 0;
$endTs = 0;
if ($addStart !== '') {
$t = strtotime($addStart . ' 00:00:00');
if ($t !== false) {
$startTs = (int) $t;
}
}
if ($addEnd !== '') {
$t = strtotime($addEnd . ' 23:59:59');
if ($t !== false) {
$endTs = (int) $t;
}
}
return [$startTs, $endTs];
}
/**
* 添加时间筛选。
* firstexternal_first_add_time(客户去重)
* any:事件流水 add_external_contact(含重加;可叠加跟进人 userid)
*
* @return bool true=已按事件+跟进人收窄,调用方勿再 applyFollowUserFilter
*/
private function applyAddTimeFilter($query): bool
{
[$startTs, $endTs] = $this->parseAddTimeBounds();
if ($startTs <= 0 && $endTs <= 0) {
return false;
}
if ($this->dedupeMode() === 'any') {
$eventQuery = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('external_userid', '<>', '');
if ($startTs > 0) {
$eventQuery->where('event_time', '>=', $startTs);
}
if ($endTs > 0) {
$eventQuery->where('event_time', '<=', $endTs);
}
$followKw = trim((string) ($this->params['follow_user'] ?? ''));
$staffScoped = false;
if ($followKw !== '') {
$staffIds = $this->resolveFollowStaffWxUserids();
// 去掉「关键词本身」这一项后若仍有 admin 映射,或关键词像 userid,则按事件员工收窄
$staffIds = array_values(array_filter($staffIds, static fn (string $id): bool => $id !== ''));
if ($staffIds !== []) {
$eventQuery->whereIn('user_id', $staffIds);
$staffScoped = true;
}
}
$extIds = $eventQuery->group('external_userid')->column('external_userid');
if ($extIds === []) {
$query->whereRaw('1=0');
} else {
$query->whereIn('external_userid', $extIds);
}
return $staffScoped;
}
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
if ($startTs > 0) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$startTs]);
}
if ($endTs > 0) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
}
return false;
}
private function baseQuery()
{
$query = QywxExternalContact::where($this->searchWhere);
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
$followAlreadyScoped = $this->applyAddTimeFilter($query);
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
$tagIds = $this->normalizeTagIds();
if ($tagIds !== []) {
$matchedExtIds = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->group('external_userid')
->column('external_userid');
if ($matchedExtIds === []) {
// 没人命中:直接给一个不可能成立的条件,避免下面命中所有客户
$query->whereRaw('1=0');
} else {
$query->whereIn('external_userid', $matchedExtIds);
}
}
return $query;
}
/**
* @notes 获取列表
*/
public function lists(): array
{
// 按首次添加时间倒序:库字段 external_first_add_time;未回填(0)时回退 create_time(与列表「添加时间」展示一致)
$lists = $this->baseQuery()
->orderRaw(
'(COALESCE(NULLIF(external_first_add_time, 0), create_time) = 0) ASC, '
. 'COALESCE(NULLIF(external_first_add_time, 0), create_time) DESC, id DESC'
)
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
$wxUserids = [];
foreach ($lists as $item) {
$raw = json_decode($item['follow_users'] ?? '[]', true);
if (!is_array($raw)) {
continue;
}
foreach ($raw as $fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '') {
$wxUserids[$wx] = true;
}
}
}
$wxUserids = array_keys($wxUserids);
$adminNameByWx = [];
if ($wxUserids !== []) {
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
}
foreach ($lists as &$item) {
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
$followUsers = is_array($followUsers) ? $followUsers : [];
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
}
unset($fu);
$item['follow_users'] = $followUsers;
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
$item['follow_admin_ids'] = is_array($followAdminIds) ? $followAdminIds : [];
// 解析标签 JSON 数组(值由 CustomerLogic::extractFollowUserTags 写入;按 tag_id 去重)
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
$item['tags'] = is_array($tags) ? $tags : [];
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
}
unset($item);
return $lists;
}
/**
* @notes 获取数量
*/
public function count(): int
{
return $this->baseQuery()->count();
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMsgArchive;
use app\common\model\QywxMsgArchiveMedia;
/**
* 单会话消息历史
*
* 两种查询模式,二选一:
* 1) session_id 传 QywxMsgSession.id(推荐)
* 2) staff_userid + external_userid(单聊)/ roomid(群聊)
*
* 消息默认按 send_time 升序,便于前端从下往上追加。
* 支持 before_time / after_time 游标翻页。
*/
class MsgArchiveLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['roomid', 'msgtype'],
];
}
private function resolveScope(): array
{
$sessionId = (int) ($this->params['session_id'] ?? 0);
if ($sessionId > 0) {
$session = \app\common\model\QywxMsgSession::find($sessionId);
if ($session) {
return [
'staff_userid' => (string) $session['staff_userid'],
'external_userid' => (string) $session['external_userid'],
'roomid' => (string) $session['roomid'],
];
}
}
return [
'staff_userid' => trim((string) ($this->params['staff_userid'] ?? '')),
'external_userid' => trim((string) ($this->params['external_userid'] ?? '')),
'roomid' => trim((string) ($this->params['roomid'] ?? '')),
];
}
private function baseQuery()
{
$scope = $this->resolveScope();
$query = QywxMsgArchive::where($this->searchWhere);
if ($scope['roomid'] !== '') {
$query->where('roomid', $scope['roomid']);
} else {
// 单聊:两端顺序不定,from_user/tolist 都可能为员工或客户
$staff = $scope['staff_userid'];
$external = $scope['external_userid'];
if ($staff === '' && $external === '') {
$query->where('id', 0);
} else {
$query->where('roomid', '');
if ($staff !== '' && $external !== '') {
$query->where(function ($q) use ($staff, $external) {
$q->where(function ($sub) use ($staff, $external) {
$sub->where('from_user', $staff)->whereLike('to_list', '%"' . $external . '"%');
})->whereOr(function ($sub) use ($staff, $external) {
$sub->where('from_user', $external)->whereLike('to_list', '%"' . $staff . '"%');
});
});
} elseif ($staff !== '') {
$query->where(function ($q) use ($staff) {
$q->where('from_user', $staff)->whereOr('to_list', 'like', '%"' . $staff . '"%');
});
} else {
$query->where(function ($q) use ($external) {
$q->where('from_user', $external)->whereOr('to_list', 'like', '%"' . $external . '"%');
});
}
}
}
if (!empty($this->params['before_time'])) {
$query->where('send_time', '<', (int) $this->params['before_time']);
}
if (!empty($this->params['after_time'])) {
$query->where('send_time', '>', (int) $this->params['after_time']);
}
return $query;
}
public function lists(): array
{
$rows = $this->baseQuery()
->order('send_time', 'desc')
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
if (empty($rows)) {
return [];
}
$rows = array_reverse($rows); // 前端按时间升序渲染
$users = [];
foreach ($rows as $r) {
$users[$r['from_user']] = true;
$toList = $r['to_list'];
if (is_array($toList)) {
foreach ($toList as $to) {
$users[$to] = true;
}
}
}
$users = array_keys(array_filter($users, fn ($_, $k) => $k !== '', ARRAY_FILTER_USE_BOTH));
$adminMap = [];
$extMap = [];
if ($users) {
$adminMap = Admin::whereIn('work_wechat_userid', $users)
->column('id,name,avatar,work_wechat_userid', 'work_wechat_userid');
$extMap = QywxExternalContact::whereIn('external_userid', $users)
->column('external_userid,name,avatar,type,unionid', 'external_userid');
}
$msgIds = array_column($rows, 'msgid');
$mediaMap = [];
if ($msgIds) {
$mediaList = QywxMsgArchiveMedia::whereIn('msgid', $msgIds)->select()->toArray();
foreach ($mediaList as $m) {
$mediaMap[$m['msgid']][] = $m;
}
}
foreach ($rows as &$r) {
$fromId = (string) $r['from_user'];
$r['from_is_staff'] = isset($adminMap[$fromId]);
$r['from_profile'] = $adminMap[$fromId] ?? ($extMap[$fromId] ?? null);
$r['media'] = $mediaMap[$r['msgid']] ?? [];
}
unset($r);
return $rows;
}
public function count(): int
{
return $this->baseQuery()->count();
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\QywxMsgSendTask;
class MsgSendTaskLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['sender_userid', 'status', 'admin_id'],
];
}
public function lists(): array
{
return QywxMsgSendTask::where($this->searchWhere)
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
}
public function count(): int
{
return QywxMsgSendTask::where($this->searchWhere)->count();
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMsgSession;
/**
* 企微消息会话列表
*
* 查询参数:
* staff_userid - 过滤某员工的会话(支持传 admin_id 反查)
* external_userid- 过滤某客户
* keyword - 按客户名 / 摘要 模糊搜索
* only_unread - 仅未读
*/
class MsgSessionLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['staff_userid', 'external_userid', 'roomid', 'session_type'],
];
}
private function baseQuery()
{
$query = QywxMsgSession::where($this->searchWhere);
$adminId = (int) ($this->params['admin_id'] ?? 0);
if ($adminId > 0) {
$wxId = Admin::where('id', $adminId)->value('work_wechat_userid');
if ($wxId) {
$query->where('staff_userid', $wxId);
} else {
// 找不到对应企微 userid 时不返回任何会话
$query->where('id', 0);
}
}
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$kw = addcslashes($keyword, '%_\\');
$extIds = QywxExternalContact::whereLike('name', '%' . $kw . '%')->column('external_userid');
$query->where(function ($q) use ($kw, $extIds) {
$q->whereLike('last_msg_summary', '%' . $kw . '%');
if ($extIds) {
$q->whereOr('external_userid', 'in', $extIds);
}
});
}
if (!empty($this->params['only_unread'])) {
$query->where('unread_staff', '>', 0);
}
return $query->order('last_msg_time', 'desc');
}
public function lists(): array
{
$rows = $this->baseQuery()
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
if (empty($rows)) {
return [];
}
$extIds = array_values(array_unique(array_filter(array_map(
fn ($r) => (string) ($r['external_userid'] ?? ''),
$rows
))));
$extMap = [];
if ($extIds) {
$extMap = QywxExternalContact::whereIn('external_userid', $extIds)
->column('name,avatar,type,gender,corp_name,unionid', 'external_userid');
}
$staffIds = array_values(array_unique(array_filter(array_map(
fn ($r) => (string) ($r['staff_userid'] ?? ''),
$rows
))));
$staffMap = [];
if ($staffIds) {
$staffMap = Admin::whereIn('work_wechat_userid', $staffIds)
->column('id,name,avatar', 'work_wechat_userid');
}
foreach ($rows as &$r) {
$r['customer'] = $extMap[$r['external_userid']] ?? null;
$r['staff'] = $staffMap[$r['staff_userid']] ?? null;
}
unset($r);
return $rows;
}
public function count(): int
{
return $this->baseQuery()->count();
}
}