分配客户群二维码

This commit is contained in:
Dev
2026-09-29 16:24:19 +08:00
parent a4b0aaa661
commit 34177dfe86
10 changed files with 445 additions and 84 deletions
+1 -1
View File
@@ -20,5 +20,5 @@ return [
'app\common\command\Test',
'app\common\command\BiddingDeal',
'app\common\command\DemandDeal',
'app\common\command\GroupChatFetch',
'app\common\command\GroupChat',
];
@@ -0,0 +1,173 @@
<?php
namespace app\common\command;
use app\common\service\DemandGroupChatService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Argument;
/**
* 企业微信沟通群管理
*
* 交互模式:php think groupChat:manage
* 直接执行:php think groupChat:manage stat|sync|refresh|refreshOne|recycle
*
* 新增功能:在 $actions 里加一项,并实现同名方法即可
*/
class GroupChat extends Command
{
/**
* 功能菜单 功能名 => [菜单说明, 处理方法]
* @var array
*/
protected $actions = [
'stat' => ['查看群池状态', 'stat'],
'sync' => ['同步企业微信客户群,补充群池', 'syncList'],
'refresh' => ['刷新所有已分配群的二维码', 'refreshAll'],
'refreshOne' => ['刷新指定需求群的二维码', 'refreshOne'],
'recycle' => ['回收指定需求占用的群,归还群池', 'recycle'],
];
protected function configure()
{
$this->setName('groupChat:manage')
->addArgument('action', Argument::OPTIONAL, '要执行的功能:' . implode('|', array_keys($this->actions)))
->setDescription('企业微信沟通群管理:群池同步 / 二维码刷新 / 回收 / 状态查看');
}
protected function execute(Input $input, Output $output)
{
$action = trim((string)$input->getArgument('action'));
if ($action === '') {
$this->showMenu($input, $output);
return;
}
$this->dispatch($action, $input, $output);
}
/**
* 交互菜单
*/
protected function showMenu(Input $input, Output $output)
{
$choices = array_merge(array_keys($this->actions), ['exit']);
while (true) {
$output->writeln('');
$output->writeln('<info>企业微信沟通群管理</info>');
foreach ($this->actions as $key => $item) {
$output->writeln(sprintf(' %-12s%s', $key, $item[0]));
}
$output->writeln(sprintf(' %-12s%s', 'exit', '退出'));
$action = $output->choice($input, '请选择要执行的功能', $choices, 'stat');
if ($action === 'exit') {
$output->writeln('<info>已退出</info>');
return;
}
$this->dispatch($action, $input, $output);
}
}
/**
* 调度到具体处理方法
*/
protected function dispatch($action, Input $input, Output $output)
{
if (!isset($this->actions[$action])) {
$output->writeln("<error>未知功能:{$action}</error>");
$output->writeln('可用功能:' . implode('、', array_keys($this->actions)));
return;
}
$method = $this->actions[$action][1];
try {
$this->$method($input, $output);
} catch (\Exception $e) {
$output->writeln('<error>执行失败:' . $e->getMessage() . '</error>');
}
}
/**
* 查看群池状态
*/
protected function stat(Input $input, Output $output)
{
$stat = DemandGroupChatService::stat();
$output->writeln(sprintf(
'群总数:%d 空闲:%d 已分配:%d 无二维码:%d',
$stat['total'],
$stat['free'],
$stat['used'],
$stat['no_qrcode']
));
if ($stat['free'] < 3) {
$output->writeln('<comment>提示:空闲群不足 3 个,请先在企微建群,再执行 sync 补充</comment>');
}
}
/**
* 同步企业微信客户群
*/
protected function syncList(Input $input, Output $output)
{
$output->writeln('<comment>正在从企业微信拉取客户群列表...</comment>');
$num = DemandGroupChatService::syncList();
$output->writeln($num > 0
? "<info>已新入库 {$num} 个群</info>"
: '<comment>没有新的群需要入库(群主名下暂无新增客户群)</comment>');
$this->stat($input, $output);
}
/**
* 刷新所有已分配群的二维码
*/
protected function refreshAll(Input $input, Output $output)
{
$output->writeln('<comment>正在刷新已分配群的二维码...</comment>');
$res = DemandGroupChatService::refreshAll();
$output->writeln(sprintf('共 %d 个:成功 %d,失败 %d', $res['total'], $res['success'], $res['fail']));
if ($res['fail']) {
$output->writeln('<error>失败的需求ID:' . implode(', ', $res['failDemandIds']) . ',详情见日志</error>');
}
}
/**
* 刷新指定需求群的二维码
*/
protected function refreshOne(Input $input, Output $output)
{
$demandId = (int)$output->ask($input, '请输入需求ID', '');
if (!$demandId) {
$output->writeln('<error>需求ID不能为空</error>');
return;
}
if (!DemandGroupChatService::refreshByDemand($demandId)) {
$output->writeln("<error>需求 {$demandId} 没有已分配的沟通群,或刷新失败,详情见日志</error>");
return;
}
$output->writeln("<info>需求 {$demandId} 的群二维码已刷新</info>");
}
/**
* 回收指定需求占用的群
*/
protected function recycle(Input $input, Output $output)
{
$demandId = (int)$output->ask($input, '请输入要回收的需求ID', '');
if (!$demandId) {
$output->writeln('<error>需求ID不能为空</error>');
return;
}
if (!$output->confirm($input, "确认回收需求 {$demandId} 占用的沟通群?", false)) {
$output->writeln('<comment>已取消</comment>');
return;
}
if (!DemandGroupChatService::recycle($demandId)) {
$output->writeln("<error>需求 {$demandId} 没有已分配的沟通群</error>");
return;
}
$output->writeln("<info>需求 {$demandId} 的沟通群已归还群池</info>");
$this->stat($input, $output);
}
}
@@ -1,24 +0,0 @@
<?php
namespace app\common\command;
use app\common\service\DemandGroupChatService;
use app\common\service\WorkWeChatService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class GroupChatFetch extends Command
{
protected function configure()
{
$this->setName('groupChat:fetch')->setDescription('groupChat fetch command!');
}
protected function execute(Input $input, Output $output)
{
$output->writeln("groupChat fetch...");
DemandGroupChatService::updateQrCode(8);
}
}
@@ -6,6 +6,7 @@ use app\common\model\demand\group\Chat;
use app\common\traits\ServiceTrait;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use think\Log;
class DemandGroupChatService
{
@@ -43,19 +44,22 @@ class DemandGroupChatService
];
/**
* 保存客户群列表
* @return void
* 保存客户群列表(只补充未入库的群,可重复执行)
* @return int 新增数量
* @throws GuzzleException
*/
public static function createList()
public static function syncList(): int
{
$scene = 2;
$state = '';
$group_chat_list = WorkWeChatService::groupChatList();
$dataList = [];
foreach ($group_chat_list as $chat) {
$chat_id = $chat['chat_id'] ?? 0;
$chat_id = $chat['chat_id'] ?? '';
if (!$chat_id) {
continue;
}
$groupChatInfo = WorkWeChatService::groupChatInfo($chat_id);
// $config_id = WorkWeChatService::groupChatAddJoinWay([$chat_id], $scene, $state);
$dataList[] = [
'demand_id' => 0,
'chat_id' => $chat_id,
@@ -66,18 +70,27 @@ class DemandGroupChatService
'state' => $state,
];
}
usort($dataList, function($a, $b) {
//群名为空时不再排序,避免空列表导致报错
if (!$dataList) {
Log::warning(__METHOD__ . ' 企业微信未返回任何客户群,请检查群主配置与接口权限');
return 0;
}
usort($dataList, function ($a, $b) {
return (int)filter_var($a['name'], FILTER_SANITIZE_NUMBER_INT) <=> (int)filter_var($b['name'], FILTER_SANITIZE_NUMBER_INT);
});
$chatIdList = self::where('chat_id', '<>', '')->column('chat_id');
$dataList = array_filter($dataList, function($value) use($chatIdList) {
$dataList = array_filter($dataList, function ($value) use ($chatIdList) {
return !in_array($value['chat_id'], $chatIdList);
});
$res = (new self::$Model)->saveAll($dataList);
if (!$dataList) {
return 0;
}
(new self::$Model)->saveAll($dataList);
return count($dataList);
}
/**
* 更新二维码
* 分配一个空闲群给需求,并生成(或刷新)二维码
* @param $demand_id
* @param bool $refresh
* @return mixed
@@ -88,17 +101,113 @@ class DemandGroupChatService
//分配一个未使用的群
$info = self::getOne(['demand_id' => 0], [], ['id']);
if (!$info) {
Log::error(__METHOD__ . ' 群池已空,无法为需求分配沟通群,demand_id:' . $demand_id);
return false;
}
$info->demand_id = $demand_id;
if (!$info->qr_code || $refresh) {
//更新config_id,防止设置群聊邀请确认后不生效
$config_id = WorkWeChatService::groupChatAddJoinWay([$info['chat_id']]);
$join_way = WorkWeChatService::groupChatGetJoinWay($config_id);
$info->config_id = $config_id;
$info->qr_code = $join_way['qr_code'] ?? '';
}
self::refreshQrCode($info, $refresh);
return $info->save();
}
/**
* 重新生成群的入群二维码
* 失败只记录日志并返回 false,不抛异常,避免影响确认接单等主流程
* @param Chat|\think\Model $info
* @param bool $refresh 已有二维码时是否强制刷新
* @return bool
* @throws GuzzleException
*/
public static function refreshQrCode($info, bool $refresh = false): bool
{
if ($info->qr_code && !$refresh) {
return true;
}
//更新config_id,防止设置群聊邀请确认后不生效
$config_id = WorkWeChatService::groupChatAddJoinWay([$info['chat_id']]);
if (!$config_id) {
Log::error(__METHOD__ . ' 生成入群方式失败,chat_id:' . $info['chat_id']);
return false;
}
$join_way = WorkWeChatService::groupChatGetJoinWay($config_id);
if (empty($join_way['qr_code'])) {
Log::error(__METHOD__ . ' 获取群二维码失败,config_id:' . $config_id);
return false;
}
$info->config_id = $config_id;
$info->qr_code = $join_way['qr_code'];
return true;
}
/**
* 刷新所有已分配群的二维码
* @return array
* @throws GuzzleException
*/
public static function refreshAll(): array
{
$list = self::where('demand_id', '>', 0)->select();
$result = ['total' => 0, 'success' => 0, 'fail' => 0, 'failDemandIds' => []];
foreach ($list as $item) {
$result['total']++;
if (self::refreshQrCode($item, true) && $item->save()) {
$result['success']++;
} else {
$result['fail']++;
$result['failDemandIds'][] = $item->demand_id;
}
}
return $result;
}
/**
* 刷新指定需求群的二维码
* @param $demand_id
* @return bool
* @throws GuzzleException
*/
public static function refreshByDemand($demand_id)
{
$info = self::getOne(['demand_id' => $demand_id]);
if (!$info) {
Log::error(__METHOD__ . ' 该需求没有已分配的沟通群,demand_id:' . $demand_id);
return false;
}
if (!self::refreshQrCode($info, true)) {
return false;
}
return (bool)$info->save();
}
/**
* 回收需求占用的群,归还群池
* @param $demand_id
* @return bool
*/
public static function recycle($demand_id)
{
$info = self::getOne(['demand_id' => $demand_id]);
if (!$info) {
return false;
}
$info->demand_id = 0;
$info->config_id = '';
//旧二维码随需求作废,下次分配时重新生成
$info->qr_code = '';
return (bool)$info->save();
}
/**
* 群池状态
* @return array
*/
public static function stat(): array
{
return [
'total' => self::$Model::count(),
'free' => self::where('demand_id', 0)->count(),
'used' => self::where('demand_id', '>', 0)->count(),
'no_qrcode' => self::where('qr_code', '')->count(),
];
}
}
+3
View File
@@ -13,3 +13,6 @@ PHP=/usr/local/bin/php
# 确认收货超时(7 天自动收货)+ 订单收益解冻
*/10 * * * * nginx cd ${APP_ROOT} && flock -n /tmp/cb-demand.lock ${PHP} think demand:deal >> ${APP_ROOT}/runtime/cron/demand-deal-$(date +\%Y\%m).log 2>&1
# 沟通群二维码续期(刷新所有已分配群的二维码,不会占用新的群)
0 3 * * * nginx cd ${APP_ROOT} && flock -n /tmp/cb-groupchat.lock ${PHP} think groupChat:manage refresh >> ${APP_ROOT}/runtime/cron/groupchat-manage-$(date +\%Y\%m).log 2>&1
+38 -19
View File
@@ -17,6 +17,7 @@ Page({
freshPage: false,
fromPreview: false, // 预览锁
hasMore: true, // 新增字段,标记是否还有更多数据
refreshing: false, // 刷新按钮是否处于加载中
tabList: [
{ text: '全部', key: -1 },
{ text: '招标中', key: 1 },
@@ -59,6 +60,13 @@ Page({
}
this.loadDemandList(true);
},
// 点击右侧刷新按钮
async onRefreshTap() {
if (this.data.refreshing) return;
this.setData({ refreshing: true });
await this.loadDemandList(true);
this.setData({ refreshing: false });
},
//图片预览
onImageView(e) {
const { images, index } = e.currentTarget.dataset;
@@ -77,7 +85,8 @@ Page({
},
// 加载需求列表数据
async loadDemandList(fresh = false) {
async loadDemandList(fresh = false, options = {}) {
const { silent = false } = options;
if (fresh) {
this.setData({
page: 1,
@@ -95,14 +104,19 @@ Page({
try {
const { page, status, keywords, opType } = this.data;
let params = { page, status, keywords, opType };
wx.showLoading({
title: '加载中',
});
// 下拉刷新自带 loading 指示,不再叠加 showLoading
if (!silent) {
wx.showLoading({
title: '加载中',
});
}
const res = await fetchDemandList(params);
const nextList = res.data.data;
console.log(res)
console.log(nextList)
wx.hideLoading();
if (!silent) {
wx.hideLoading();
}
// 判断是否还有更多数据
const { current_page, last_page } = res.data;
@@ -218,22 +232,26 @@ Page({
return;
}
const bidDemandId = wx.getStorageSync('bidDemandId');
console.log('bidDemandId:', bidDemandId);
if (bidDemandId) {
const index = this.data.demandList.findIndex(item => item.id === Number(bidDemandId));
console.log('bidDemandId-index:', index);
index >= 0 && this.setData({
[`demandList[${index}].is_bid`]: true,
[`demandList[${index}].btnName`]: '已投标',
[`demandList[${index}].demand_biddings_count`]: this.data.demandList[index].demand_biddings_count + 1
});
// 先清除标记,避免同一条被重复处理
wx.removeStorageSync('bidDemandId');
} else {
if (this.data.freshPage) {
this.setData({ freshPage: false });
console.log('freshing');
const index = this.data.demandList.findIndex(item => item.id === Number(bidDemandId));
if (index < 0) {
// 当前列表里没有这条(例如「我投标的需求」页),交给接口重新拉取
this.loadDemandList(true);
} else {
this.setData({
[`demandList[${index}].is_bid`]: true,
// 按钮渲染的是 item.button.name,只改 btnName 不会刷新文案
[`demandList[${index}].button`]: { name: '已投标', theme: 'light' },
[`demandList[${index}].demand_biddings_count`]: this.data.demandList[index].demand_biddings_count + 1,
});
}
return;
}
if (this.data.freshPage) {
this.setData({ freshPage: false });
this.loadDemandList(true);
}
},
@@ -254,8 +272,9 @@ Page({
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
this.loadDemandList(true);
async onPullDownRefresh() {
await this.loadDemandList(true, { silent: true });
wx.stopPullDownRefresh();
},
/**
@@ -1,4 +1,7 @@
{
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark",
"onReachBottomDistance": 20,
"usingComponents": {
"t-search": "tdesign-miniprogram/search/search",
"t-loading": "tdesign-miniprogram/loading/loading",
+18 -4
View File
@@ -1,11 +1,25 @@
<!--pages/demand/list/index.wxml-->
<!-- <t-divider /> -->
<view class="home-page-container">
<view class="search" style="margin-bottom: 5rpx;" bind:tap="navToSearchPage">
<t-search t-class-input="t-search__input" t-class-left="t-search__left-icon" placeholder="请输入关键词搜索" value="{{keywords}}" left-icon="{{null}}" bind:blur="onChangeInput" bind:change="onChangeInput" bind:submit="onChangeSearch">
<t-icon slot="left-icon" prefix="" name="search" size="40rpx" color="#bbb" />
<t-icon slot="action" bind:tap="onChangeSearch" prefix="" name="refresh" size="40rpx" color="#787878" />
<view class="search-bar">
<t-search
t-class="search-bar__search"
t-class-input-container="search-bar__box"
t-class-input="search-bar__input"
t-class-left="search-bar__left"
shape="round"
placeholder="搜索需求关键词"
value="{{keywords}}"
left-icon="{{null}}"
bind:change="onChangeInput"
bind:blur="onChangeInput"
bind:submit="onChangeSearch"
>
<t-icon slot="left-icon" prefix="" name="search" size="36rpx" color="#b9bcc4" />
</t-search>
<view class="refresh-btn {{refreshing ? 'refresh-btn--loading' : ''}}" hover-class="refresh-btn--hover" bind:tap="onRefreshTap">
<t-icon prefix="" name="refresh" size="38rpx" color="#787878" />
</view>
</view>
<!-- <t-divider /> -->
<view class="home-page-tabs">
+61 -6
View File
@@ -22,15 +22,70 @@ page {
padding: 0 24rpx;
}
.home-page-header .t-search__input-box {
border-radius: 32rpx !important;
height: 64rpx !important;
margin-bottom: 8rpx;
/* ===== 搜索区 ===== */
.search-bar {
display: flex;
align-items: center;
padding: 18rpx 0 12rpx;
gap: 16rpx;
}
.home-page-header .t-search__input {
.search-bar__search {
flex: 1;
min-width: 0;
padding: 0 !important;
background: transparent !important;
}
.search-bar__box {
height: 68rpx !important;
border-radius: 34rpx !important;
background: #fff !important;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
}
.search-bar__input {
font-size: 28rpx !important;
color: rgb(116, 116, 116) !important;
color: var(--text-primary) !important;
}
.search-bar__left {
display: flex;
align-items: center;
margin-right: 4rpx;
}
/* ===== 刷新按钮 ===== */
.refresh-btn {
flex-shrink: 0;
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background: #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.refresh-btn--hover {
transform: scale(0.92);
opacity: 0.85;
}
.refresh-btn--loading {
animation: refresh-spin 0.9s linear infinite;
}
@keyframes refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.home-page-header .swiper-wrap {
+23 -14
View File
@@ -38,24 +38,14 @@ Page({
onShow() {
this.getTabBar().init();
setTimeout(() => {
fetchPerson().then((res) => {
if (res.code === 1) {
let { role } = res.data;
this.setData({
role,
});
}
});
}, 3000);
this.refreshRole();
},
onLoad() {
// 先用本地缓存渲染入口按钮,避免接口返回前闪烁;随后由 onShow 用接口数据校正
const role = (wx.getStorageSync('userInfo') || {}).role || '';
this.setData({ role });
this.init();
let role = wx.getStorageSync('userInfo')?.role;
if (role == 'dev') {
this.toDemandHall();
}
},
onReachBottom() {
@@ -65,12 +55,31 @@ Page({
onPullDownRefresh() {
this.init();
this.refreshRole();
},
init() {
this.loadHomePage();
},
/**
* 拉取最新角色并写回本地缓存
* 入驻审核通过后,用户回到小程序触发 onShow 即可看到「进入接单大厅」入口
*/
refreshRole() {
return fetchPerson().then((res) => {
if (res.code === 1 && res.data) {
const role = res.data.role || '';
this.setData({ role });
const userInfo = wx.getStorageSync('userInfo');
if (userInfo && userInfo.role !== role) {
userInfo.role = role;
wx.setStorageSync('userInfo', userInfo);
}
}
});
},
loadHomePage() {
wx.stopPullDownRefresh();
this.setData({