更新
This commit is contained in:
@@ -6,6 +6,7 @@ namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
|
||||
class WecomPromotionController extends BaseAdminController
|
||||
@@ -15,7 +16,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法访问企业微信推广助手');
|
||||
return $this->fail('权限不足,无法访问企业微信获客助手');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::overview(
|
||||
@@ -25,33 +26,6 @@ class WecomPromotionController extends BaseAdminController
|
||||
)));
|
||||
}
|
||||
|
||||
public function authorizationUrl()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::authorizationUrl(
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$this->request->domain()
|
||||
)));
|
||||
}
|
||||
|
||||
public function verifyAccount()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(fn () => $this->success('凭证验证成功', WecomPromotionLogic::verifyAccount(
|
||||
$id,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function savePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
@@ -85,13 +59,90 @@ class WecomPromotionController extends BaseAdminController
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('推广链接已保存', WecomPromotionLogic::saveLink(
|
||||
return $this->run(fn () => $this->success('获客助手链接已保存', WecomPromotionLogic::saveLink(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function checkApiPermission()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission()));
|
||||
}
|
||||
|
||||
public function syncRemoteLinks()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$poolId = (int) $this->request->post('pool_id', 0);
|
||||
|
||||
return $this->run(fn () => $this->success('企业微信获客链接同步完成', WecomPromotionLogic::syncRemoteLinks(
|
||||
$poolId,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function remoteLinkDetail()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->get('id', 0);
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::remoteLinkDetail(
|
||||
$id,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function deleteRemoteLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deleteRemoteLink($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('企业微信获客链接已永久删除,本地审计记录已保留');
|
||||
});
|
||||
}
|
||||
|
||||
public function syncCustomers()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('获客客户同步完成', WecomAcquisitionCustomerLogic::sync(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function customerStatistics()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomAcquisitionCustomerLogic::statistics(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function toggleLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
@@ -117,7 +168,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deleteLink($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('推广链接已删除');
|
||||
return $this->success('获客助手链接已删除');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
@@ -61,11 +62,13 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
|
||||
{
|
||||
$query = $this->buildQuery();
|
||||
$pendingQuery = clone $query;
|
||||
$effectiveAmountQuery = clone $query;
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($effectiveAmountQuery, 'po');
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'orders' => (int) (clone $query)->count('po.id'),
|
||||
'amount' => round((float) (clone $query)->sum('po.amount'), 2),
|
||||
'amount' => round((float) $effectiveAmountQuery->sum('po.amount'), 2),
|
||||
'pending' => (int) $pendingQuery
|
||||
->where(function ($q) {
|
||||
$q->where('po.prescription_audit_status', 0)
|
||||
@@ -294,6 +297,18 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
|
||||
$row['prescription_audit_text'] = $this->auditStatusText((int) ($row['prescription_audit_status'] ?? 0));
|
||||
$row['payment_slip_audit_text'] = $this->auditStatusText((int) ($row['payment_slip_audit_status'] ?? 0));
|
||||
$row['fulfillment_text'] = $this->fulfillmentStatusText((int) ($row['fulfillment_status'] ?? 0));
|
||||
$fulfillmentStatus = (int) ($row['fulfillment_status'] ?? 0);
|
||||
$refundAmount = round((float) ($row['refund_amount'] ?? 0), 2);
|
||||
$amountIncluded = !in_array(
|
||||
$fulfillmentStatus,
|
||||
YejiStatsLogic::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE,
|
||||
true
|
||||
) && $refundAmount <= 0;
|
||||
$row['amount_included'] = $amountIncluded;
|
||||
$row['effective_amount'] = $amountIncluded ? round((float) ($row['amount'] ?? 0), 2) : 0.0;
|
||||
$row['amount_exclusion_text'] = $amountIncluded
|
||||
? ''
|
||||
: ($refundAmount > 0 || $fulfillmentStatus === 10 ? '退款不计入' : $row['fulfillment_text'] . '不计入');
|
||||
}
|
||||
unset($row);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\ConversionLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
@@ -156,10 +157,15 @@ class FirstVisitConversionLogic
|
||||
private static function resolveTimeRange(string $timeType): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$timeType = in_array($timeType, ['today', 'week', 'month', 'quarter', 'year'], true)
|
||||
$timeType = in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year'], true)
|
||||
? $timeType
|
||||
: 'today';
|
||||
|
||||
if ($timeType === 'yesterday') {
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
|
||||
return [$yesterday, $yesterday, $timeType, '昨天'];
|
||||
}
|
||||
if ($timeType === 'week') {
|
||||
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
|
||||
}
|
||||
@@ -451,6 +457,7 @@ class FirstVisitConversionLogic
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po');
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$actualQuery->whereIn('rx.assistant_id', $effectiveAdminIds);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class FirstVisitDoctorDashboardLogic
|
||||
'selected_doctor_name' => $selectedDoctorName,
|
||||
'doctor_count' => count($rows),
|
||||
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
|
||||
'performance_rule' => '诊单按订单创建时间统计,排除履约已取消、拒收和退款,金额归属处方开方医生',
|
||||
'performance_rule' => '诊单按订单创建时间统计,排除已取消、拒收、全额退款及部分退款,金额归属处方开方医生',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
@@ -396,7 +396,7 @@ class FirstVisitDoctorDashboardLogic
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'o');
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'o');
|
||||
if ($assistantIds !== null) {
|
||||
$query->whereIn('o.creator_id', $assistantIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客客户同步与数据权限统计。 */
|
||||
class WecomAcquisitionCustomerLogic
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public static function sync(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? $params['id'] ?? 0));
|
||||
$query = Db::name('qywx_promotion_link')->alias('l')
|
||||
->whereNull('l.delete_time')
|
||||
->where('l.remote_link_id', '<>', '')
|
||||
->where('l.remote_status', 1);
|
||||
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('l.id', $localLinkId);
|
||||
}
|
||||
$links = $query->field('l.id,l.remote_link_id')->order('l.id', 'asc')->limit(200)->select()->toArray();
|
||||
if ($localLinkId > 0 && $links === []) {
|
||||
throw new RuntimeException('获客链接不存在、已失效,或超出当前权限范围');
|
||||
}
|
||||
if ($links === []) {
|
||||
throw new RuntimeException('当前数据范围内没有可同步的有效官方获客链接;已删除和历史手工链接不会参与客户同步,请先创建官方获客链接');
|
||||
}
|
||||
$service = new QywxCustomerAcquisitionCustomerService();
|
||||
$result = ['links' => count($links), 'scanned' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0, 'errors' => []];
|
||||
foreach ($links as $link) {
|
||||
try {
|
||||
$one = $service->syncLink((string) $link['remote_link_id']);
|
||||
$result['scanned'] += $one['scanned'];
|
||||
$result['created'] += $one['created'];
|
||||
$result['updated'] += $one['updated'];
|
||||
} catch (\Throwable $e) {
|
||||
$result['failed']++;
|
||||
if (count($result['errors']) < 10) {
|
||||
$result['errors'][] = (string) $link['remote_link_id'] . ':' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function statistics(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
|
||||
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$base = self::customerQuery($params, $visibleIds);
|
||||
$total = (int) (clone $base)->count();
|
||||
$rows = $base
|
||||
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
|
||||
->order('c.last_chat_time', 'desc')->order('c.id', 'desc')
|
||||
->page($page, $pageSize)->select()->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['external_userid_masked'] = self::maskIdentifier((string) ($row['external_userid'] ?? ''));
|
||||
unset($row['external_userid']);
|
||||
$row['has_messaged'] = (int) ($row['chat_status'] ?? 0) === 1;
|
||||
$row['message_count_known'] = (int) ($row['message_count_known'] ?? 0);
|
||||
$row['received_message_count'] = (int) ($row['recv_msg_cnt'] ?? 0);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds);
|
||||
$summaryRow = $summaryQuery->fieldRaw(
|
||||
'COUNT(*) AS customer_count, '
|
||||
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
|
||||
. 'SUM(CASE WHEN c.chat_status = 1 THEN 1 ELSE 0 END) AS started_chat_count, '
|
||||
. 'SUM(CASE WHEN c.message_count_known = 1 THEN 1 ELSE 0 END) AS message_count_known_count'
|
||||
)->find() ?: [];
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'summary' => [
|
||||
'customer_count' => (int) ($summaryRow['customer_count'] ?? 0),
|
||||
'started_chat_count' => (int) ($summaryRow['started_chat_count'] ?? 0),
|
||||
'recv_msg_cnt' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
|
||||
'received_message_count' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
|
||||
'message_count_known_count' => (int) ($summaryRow['message_count_known_count'] ?? 0),
|
||||
],
|
||||
'lists' => $rows,
|
||||
'count' => $total,
|
||||
'page_no' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerQuery(array $params, ?array $visibleIds)
|
||||
{
|
||||
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
|
||||
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
|
||||
->leftJoin('dept d', 'd.id = c.dept_id')
|
||||
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
|
||||
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
|
||||
self::applyScope($query, 'c', $visibleIds);
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('c.promotion_link_id', $localLinkId);
|
||||
}
|
||||
$userId = trim((string) ($params['userid'] ?? ''));
|
||||
if ($userId !== '') {
|
||||
$query->where('c.userid', $userId);
|
||||
}
|
||||
if (isset($params['chat_status']) && $params['chat_status'] !== '') {
|
||||
$query->where('c.chat_status', max(0, (int) $params['chat_status']));
|
||||
}
|
||||
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->whereLike('c.external_userid|c.userid|a.name|l.name', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
|
||||
}
|
||||
|
||||
private static function maskIdentifier(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$length = mb_strlen($value);
|
||||
if ($length <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if ($length <= 4) {
|
||||
return mb_substr($value, 0, 1) . '***';
|
||||
}
|
||||
if ($length <= 8) {
|
||||
return mb_substr($value, 0, 2) . '***' . mb_substr($value, -1);
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, 4) . '****' . mb_substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,17 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 一诊 / 企业微信推广助手管理逻辑。 */
|
||||
/** 一诊 / 企业微信获客助手管理逻辑。 */
|
||||
class WecomPromotionLogic
|
||||
{
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$accountsQuery = Db::name('qywx_promotion_account')->alias('a')
|
||||
->leftJoin('admin u', 'u.id = a.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = a.dept_id')
|
||||
->whereNull('a.delete_time');
|
||||
$accounts = $accountsQuery
|
||||
->field('a.id,a.corp_id,a.corp_name,a.agent_id,a.auth_status,a.owner_admin_id,a.dept_id,a.authorized_at,a.last_refresh_at,a.create_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('a.auth_status', 'desc')
|
||||
->order('a.id', 'desc')
|
||||
->select()->toArray();
|
||||
foreach ($accounts as &$account) {
|
||||
$account['corp_id_masked'] = self::mask((string) ($account['corp_id'] ?? ''));
|
||||
unset($account['corp_id']);
|
||||
}
|
||||
unset($account);
|
||||
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = p.dept_id')
|
||||
@@ -44,10 +30,9 @@ class WecomPromotionLogic
|
||||
$links = [];
|
||||
if ($poolIds !== []) {
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->whereNull('l.delete_time')
|
||||
->whereIn('l.pool_id', $poolIds)
|
||||
->field('l.id,l.pool_id,l.account_id,l.name,l.group_name,l.wecom_url,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time,a.corp_name,a.auth_status')
|
||||
->field('l.id,l.pool_id,l.name,l.group_name,l.wecom_url,l.remote_link_id,l.remote_status,l.remote_create_time,l.range_user_json,l.range_department_json,l.skip_verify,l.priority_option_json,l.last_sync_time,l.sync_error,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time')
|
||||
->order('l.status', 'desc')
|
||||
->order('l.weight', 'desc')
|
||||
->order('l.id', 'desc')
|
||||
@@ -69,61 +54,42 @@ class WecomPromotionLogic
|
||||
$today = date('Y-m-d');
|
||||
$todayClicks = 0;
|
||||
$onlineLinks = 0;
|
||||
foreach ($links as $link) {
|
||||
if ((int) ($link['status'] ?? 0) === 1) {
|
||||
foreach ($links as &$link) {
|
||||
$link['range_userids'] = self::decodeStringList($link['range_user_json'] ?? null);
|
||||
$link['range_department_ids'] = self::decodeStringList($link['range_department_json'] ?? null);
|
||||
$link['priority_option'] = self::decodeObject($link['priority_option_json'] ?? null);
|
||||
$link['is_official'] = trim((string) ($link['remote_link_id'] ?? '')) !== '';
|
||||
$link['valid_customer_acquisition_link'] = QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''));
|
||||
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
|
||||
$onlineLinks++;
|
||||
}
|
||||
if ((string) ($link['today_date'] ?? '') === $today) {
|
||||
$todayClicks += (int) ($link['today_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
$config = QywxPromotionOpenWorkService::configurationStatus();
|
||||
$config['provider_callback_url'] = $domain . '/api/qywx-promotion/provider/callback';
|
||||
$config['auth_callback_url'] = QywxPromotionOpenWorkService::configuredRedirectUri(
|
||||
$domain . '/api/qywx-promotion/auth/callback'
|
||||
);
|
||||
$config = self::internalApplicationStatus($domain);
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'can_authorize' => self::canAuthorize($adminId, $adminInfo),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'config' => $config,
|
||||
'summary' => [
|
||||
'authorized_accounts' => count(array_filter($accounts, static fn (array $row): bool => (int) ($row['auth_status'] ?? 0) === 1)),
|
||||
'configured_apps' => $config['ready'] ? 1 : 0,
|
||||
'pool_count' => count($pools),
|
||||
'online_links' => $onlineLinks,
|
||||
'today_clicks' => $todayClicks,
|
||||
],
|
||||
'accounts' => $accounts,
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'allowed_link_hosts' => array_values((array) config('qywx_promotion.allowed_link_hosts', [])),
|
||||
'member_options' => self::memberOptions($adminId, $adminInfo),
|
||||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function authorizationUrl(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以发起企业微信应用授权');
|
||||
}
|
||||
$redirectUri = rtrim($domain, '/') . '/api/qywx-promotion/auth/callback';
|
||||
|
||||
return ['url' => QywxPromotionOpenWorkService::authorizationUrl($adminId, $redirectUri)];
|
||||
}
|
||||
|
||||
public static function verifyAccount(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以验证企业微信授权凭证');
|
||||
}
|
||||
self::assertAuthorizedAccount($id, false);
|
||||
|
||||
return QywxPromotionOpenWorkService::verifyAccount($id);
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
@@ -132,8 +98,8 @@ class WecomPromotionLogic
|
||||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||||
}
|
||||
$fallback = trim((string) ($params['fallback_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是已允许的 HTTPS 企业微信链接');
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
@@ -174,17 +140,10 @@ class WecomPromotionLogic
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 80) {
|
||||
throw new RuntimeException('请输入 1-80 个字符的推广链接名称');
|
||||
}
|
||||
$url = trim((string) ($params['wecom_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($url)) {
|
||||
throw new RuntimeException('推广链接必须是已允许的 HTTPS 企业微信链接');
|
||||
}
|
||||
$accountId = max(0, (int) ($params['account_id'] ?? 0));
|
||||
if ($accountId > 0) {
|
||||
self::assertAuthorizedAccount($accountId, true);
|
||||
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
@@ -194,10 +153,9 @@ class WecomPromotionLogic
|
||||
$now = time();
|
||||
$data = [
|
||||
'pool_id' => $poolId,
|
||||
'account_id' => $accountId,
|
||||
'account_id' => 0,
|
||||
'name' => $name,
|
||||
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
|
||||
'wecom_url' => $url,
|
||||
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
@@ -206,8 +164,43 @@ class WecomPromotionLogic
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
|
||||
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
|
||||
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
|
||||
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
|
||||
}
|
||||
$data['wecom_url'] = $url;
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
|
||||
return ['id' => $id, 'mode' => 'legacy'];
|
||||
}
|
||||
|
||||
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
$payload = [
|
||||
'link_name' => $name,
|
||||
'range' => ['user_list' => $userIds],
|
||||
'skip_verify' => $skipVerify === 1,
|
||||
];
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
if ($existing !== null) {
|
||||
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
|
||||
$payload['link_id'] = $remoteLinkId;
|
||||
$api->updateLink($payload);
|
||||
} else {
|
||||
$created = $api->createLink($payload);
|
||||
$remoteLinkId = self::extractRemoteLinkId($created);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
|
||||
}
|
||||
}
|
||||
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$data += self::remoteColumns($remote, $now);
|
||||
if ($existing !== null) {
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
@@ -219,15 +212,142 @@ class WecomPromotionLogic
|
||||
'last_click_time' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
try {
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable) {
|
||||
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
|
||||
}
|
||||
|
||||
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
|
||||
public static function checkApiPermission(): array
|
||||
{
|
||||
return (new QywxCustomerAcquisitionApiService())->checkPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将企业微信端获客链接同步进指定分流方案。
|
||||
* 非全量权限账号仅导入 range.user_list 与其可见成员有交集的链接,未知部门映射时严格隐藏。
|
||||
*/
|
||||
public static function syncRemoteLinks(int $poolId, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$legacyCount = (int) Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->whereRaw("(remote_link_id IS NULL OR remote_link_id = '')")
|
||||
->count();
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$visibleUserIds = null;
|
||||
if ($visibleAdminIds !== null) {
|
||||
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
}
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
$cursor = '';
|
||||
$seen = 0;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
do {
|
||||
$page = $api->listLinks($cursor, 100);
|
||||
foreach ($page['link_id_list'] as $remoteLinkId) {
|
||||
if ($seen >= 500) {
|
||||
break 2;
|
||||
}
|
||||
$seen++;
|
||||
try {
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$result = self::upsertRemoteLink($remote, $pool, $adminId, $adminInfo);
|
||||
$result === 'created' ? $created++ : $updated++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
if (count($errors) < 5) {
|
||||
$errors[] = $remoteLinkId . ':' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
$cursor = (string) ($page['next_cursor'] ?? '');
|
||||
} while ($cursor !== '');
|
||||
|
||||
return [
|
||||
'scanned' => $seen,
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'failed' => $failed,
|
||||
'legacy_count' => $legacyCount,
|
||||
'empty_reason' => $seen === 0
|
||||
? '当前获客助手可调用应用没有通过 API 创建的官方获客链接;历史手工链接及其他应用创建的链接不会出现在该应用的同步列表中。'
|
||||
: '',
|
||||
'suggestion' => $seen === 0
|
||||
? '请点击“创建官方获客链接”通过当前应用创建。历史手工链接仍可参与本地分流,但无法同步官方 link_id 和官方获客数据。'
|
||||
: '',
|
||||
'truncated' => $cursor !== '',
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取并刷新单条企业微信官方详情。 */
|
||||
public static function remoteLinkDetail(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('这是历史手工链接,没有企业微信 link_id');
|
||||
}
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$visibleUserIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
|
||||
? null
|
||||
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
|
||||
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
|
||||
}
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update(self::remoteColumns($remote, time()));
|
||||
|
||||
return self::remotePublicPayload($remote);
|
||||
}
|
||||
|
||||
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
|
||||
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('历史手工链接只能从本地移除');
|
||||
}
|
||||
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => 0,
|
||||
'remote_status' => 2,
|
||||
'last_sync_time' => time(),
|
||||
'sync_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
if ($status === 1 && (int) ($row['remote_status'] ?? 0) === 2) {
|
||||
throw new RuntimeException('企业微信端已永久删除该链接,不能重新上线');
|
||||
}
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => $status === 1 ? 1 : 0,
|
||||
'update_time' => time(),
|
||||
@@ -243,6 +363,251 @@ class WecomPromotionLogic
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function memberOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->whereNull('a.delete_time')
|
||||
->where('a.work_wechat_userid', '<>', '');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
|
||||
if ($admins === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = array_map('intval', array_column($admins, 'id'));
|
||||
$deptRows = Db::name('admin_dept')->alias('ad')
|
||||
->leftJoin('dept d', 'd.id = ad.dept_id')
|
||||
->whereIn('ad.admin_id', $adminIds)
|
||||
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
|
||||
->order('ad.dept_id', 'asc')->select()->toArray();
|
||||
$departments = [];
|
||||
foreach ($deptRows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
|
||||
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
|
||||
$departments[$aid]['names'][] = (string) $row['dept_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$seenUserIds = [];
|
||||
foreach ($admins as $admin) {
|
||||
$userId = trim((string) ($admin['work_wechat_userid'] ?? ''));
|
||||
if ($userId === '' || isset($seenUserIds[$userId])) {
|
||||
continue;
|
||||
}
|
||||
$seenUserIds[$userId] = true;
|
||||
$aid = (int) $admin['id'];
|
||||
$result[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($admin['name'] ?? $userId),
|
||||
'userid' => $userId,
|
||||
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
|
||||
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
|
||||
if ($requested === []) {
|
||||
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
|
||||
}
|
||||
$available = [];
|
||||
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
|
||||
$available[$member['id']] = $member['userid'];
|
||||
}
|
||||
$userIds = [];
|
||||
foreach ($requested as $requestedId) {
|
||||
if (!isset($available[$requestedId])) {
|
||||
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
|
||||
}
|
||||
$userIds[] = $available[$requestedId];
|
||||
}
|
||||
if (count($userIds) > 500) {
|
||||
throw new RuntimeException('单个获客链接最多配置 500 名成员');
|
||||
}
|
||||
|
||||
return array_values(array_unique($userIds));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
|
||||
{
|
||||
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
|
||||
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
|
||||
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
|
||||
if ($linkId === '') {
|
||||
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
|
||||
}
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
|
||||
}
|
||||
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
|
||||
|
||||
return [
|
||||
'link_id' => $linkId,
|
||||
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
|
||||
'url' => $url,
|
||||
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
|
||||
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
|
||||
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
|
||||
'skip_verify' => !empty($link['skip_verify']),
|
||||
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
|
||||
'snapshot' => $link,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function remoteColumns(array $remote, int $now): array
|
||||
{
|
||||
return [
|
||||
'name' => mb_substr((string) ($remote['link_name'] ?? ''), 0, 80),
|
||||
'wecom_url' => (string) ($remote['url'] ?? ''),
|
||||
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
|
||||
'remote_status' => 1,
|
||||
'remote_create_time' => (int) ($remote['create_time'] ?? 0),
|
||||
'range_user_json' => self::encodeJson($remote['range_userids'] ?? []),
|
||||
'range_department_json' => self::encodeJson($remote['range_department_ids'] ?? []),
|
||||
'skip_verify' => !empty($remote['skip_verify']) ? 1 : 0,
|
||||
'priority_option_json' => self::encodeJson($remote['priority_option'] ?? []),
|
||||
'remote_snapshot' => self::encodeJson($remote['snapshot'] ?? []),
|
||||
'last_sync_time' => $now,
|
||||
'sync_error' => '',
|
||||
'update_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private static function upsertRemoteLink(array $remote, array $pool, int $adminId, array $adminInfo): string
|
||||
{
|
||||
$remoteLinkId = (string) $remote['link_id'];
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
|
||||
$remoteData = self::remoteColumns($remote, $now);
|
||||
if ($existing) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
|
||||
throw new RuntimeException('该链接已归属其他数据范围');
|
||||
}
|
||||
$remoteData['delete_time'] = null;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
|
||||
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
Db::name('qywx_promotion_link')->insert($remoteData + [
|
||||
'pool_id' => (int) $pool['id'],
|
||||
'account_id' => 0,
|
||||
'group_name' => '企业微信同步',
|
||||
'weight' => 1,
|
||||
'status' => 1,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
'click_count' => 0,
|
||||
'last_click_time' => 0,
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'remark' => '',
|
||||
'create_time' => $now,
|
||||
'delete_time' => null,
|
||||
]);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
private static function canSeeRemoteLink(array $remote, ?array $visibleUserIds): bool
|
||||
{
|
||||
if ($visibleUserIds === null) {
|
||||
return true;
|
||||
}
|
||||
foreach ((array) ($remote['range_userids'] ?? []) as $userId) {
|
||||
if (isset($visibleUserIds[(string) $userId])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function remotePublicPayload(array $remote): array
|
||||
{
|
||||
return [
|
||||
'link_id' => (string) ($remote['link_id'] ?? ''),
|
||||
'link_name' => (string) ($remote['link_name'] ?? ''),
|
||||
'url' => (string) ($remote['url'] ?? ''),
|
||||
'create_time' => (int) ($remote['create_time'] ?? 0),
|
||||
'range_userids' => (array) ($remote['range_userids'] ?? []),
|
||||
'range_department_ids' => (array) ($remote['range_department_ids'] ?? []),
|
||||
'skip_verify' => !empty($remote['skip_verify']),
|
||||
'priority_option' => (array) ($remote['priority_option'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
private static function extractRemoteLinkId(array $response): string
|
||||
{
|
||||
if (isset($response['link']) && is_array($response['link'])) {
|
||||
return trim((string) ($response['link']['link_id'] ?? ''));
|
||||
}
|
||||
|
||||
return trim((string) ($response['link_id'] ?? ''));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function normaliseScalarList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $item): string => trim((string) $item),
|
||||
$value
|
||||
), static fn (string $item): bool => $item !== '')));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function decodeStringList(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return self::normaliseScalarList(is_array($decoded) ? $decoded : []);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function decodeObject(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private static function encodeJson(mixed $value): string
|
||||
{
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $encoded === false ? '[]' : $encoded;
|
||||
}
|
||||
|
||||
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
@@ -264,23 +629,6 @@ class WecomPromotionLogic
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function assertAuthorizedAccount(int $id, bool $requireActive): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('授权企业不存在');
|
||||
}
|
||||
$query = Db::name('qywx_promotion_account')->where('id', $id)->whereNull('delete_time');
|
||||
if ($requireActive) {
|
||||
$query->where('auth_status', 1);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException($requireActive ? '授权企业无效或已取消授权' : '授权企业不存在');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
@@ -293,19 +641,6 @@ class WecomPromotionLogic
|
||||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
}
|
||||
|
||||
private static function canAuthorize(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Db::name('admin_role')->alias('ar')
|
||||
->join('system_role r', 'r.id = ar.role_id AND r.delete_time IS NULL')
|
||||
->where('ar.admin_id', $adminId)
|
||||
->where('r.name', '管理员')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
@@ -333,4 +668,34 @@ class WecomPromotionLogic
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function internalApplicationStatus(string $domain): array
|
||||
{
|
||||
$corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
|
||||
$agentId = trim((string) env('WECHAT_WORK_AGENT_ID', ''));
|
||||
if ($agentId === '') {
|
||||
$agentId = trim((string) env('work_wechat.agent_id', ''));
|
||||
}
|
||||
$apiStatus = QywxCustomerAcquisitionApiService::configurationStatus();
|
||||
$callbackTokenConfigured = trim((string) config('pay.wechat_work.contact_callback_token', '')) !== '';
|
||||
$callbackAesConfigured = trim((string) config('pay.wechat_work.contact_callback_aes_key', '')) !== '';
|
||||
|
||||
return [
|
||||
'mode' => 'internal',
|
||||
'configured' => $apiStatus['configured'],
|
||||
'ready' => $apiStatus['configured'],
|
||||
'missing' => $apiStatus['missing'],
|
||||
'corp_id_masked' => self::mask($corpId),
|
||||
'agent_id' => $agentId,
|
||||
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
|
||||
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
|
||||
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
|
||||
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,7 +1068,9 @@ class ConversionLogic
|
||||
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.payment_slip_audit_status', 1)
|
||||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($completedQuery, 'po');
|
||||
$completedQuery
|
||||
->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount")
|
||||
->group($completedSourceExpr);
|
||||
|
||||
@@ -1104,7 +1106,7 @@ class ConversionLogic
|
||||
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($businessQuery, 'po');
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($businessQuery, 'po');
|
||||
$businessQuery
|
||||
->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
|
||||
->group($businessSourceExpr);
|
||||
|
||||
@@ -433,7 +433,7 @@ class DoctorDailyStatsLogic
|
||||
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
|
||||
->whereNull('o.delete_time')
|
||||
->whereBetween('o.create_time', [$startTs, $endTs]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($q, 'o');
|
||||
$q->whereIn('rx.creator_id', $doctorIds)
|
||||
->where('o.diagnosis_id', '>', 0);
|
||||
|
||||
|
||||
@@ -3893,6 +3893,18 @@ class YejiStatsLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效金额统计:在业绩履约状态口径上,再排除任何已发生退款(含部分退款)的订单。
|
||||
*
|
||||
* @param \think\db\BaseQuery|\think\Model $query
|
||||
*/
|
||||
public static function applyPrescriptionOrderEffectiveAmountQuery($query, string $tableAlias = ''): void
|
||||
{
|
||||
self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias);
|
||||
$refundField = $tableAlias !== '' ? "{$tableAlias}.refund_amount" : 'refund_amount';
|
||||
$query->whereRaw("({$refundField} IS NULL OR {$refundField} <= 0)");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \think\db\BaseQuery|\think\Model $query
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Work\Application;
|
||||
use EasyWeChat\Work\Message;
|
||||
@@ -17,7 +18,7 @@ use think\facade\Log;
|
||||
*
|
||||
* ⚠️ 关于"员工↔客户消息内容"接收:
|
||||
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
|
||||
* 这里只处理"添加 / 编辑 / 删除客户"等业务事件(change_external_contact 变体)。
|
||||
* 这里只处理客户关系事件,以及 customer_acquisition 回调中的累计收消息次数;不保存消息正文。
|
||||
* 实时消息接收走「会话内容存档」独立通道:
|
||||
* - 命令: php think qywx:sync-msg-archive
|
||||
* - 服务: app\common\service\wechat\QywxMsgArchiveService
|
||||
@@ -33,15 +34,18 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
{
|
||||
$corpId = (string) config('pay.wechat_work.corp_id', '');
|
||||
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
|
||||
$acquisitionSecret = (string) config('qywx_customer_acquisition.secret', '');
|
||||
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
|
||||
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret,
|
||||
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
|
||||
$secret = $customerSecret !== '' ? $customerSecret : $payContactSecret;
|
||||
$secret = $customerSecret !== ''
|
||||
? $customerSecret
|
||||
: ($acquisitionSecret !== '' ? $acquisitionSecret : $payContactSecret);
|
||||
$token = (string) config('pay.wechat_work.contact_callback_token', '');
|
||||
$aesKey = (string) config('pay.wechat_work.contact_callback_aes_key', '');
|
||||
|
||||
if ($corpId === '' || $secret === '' || $token === '' || $aesKey === '') {
|
||||
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或 external_pay_secret) / contact_callback_token / contact_callback_aes_key');
|
||||
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或获客助手应用 secret) / contact_callback_token / contact_callback_aes_key');
|
||||
|
||||
return response('config error', 503, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
@@ -67,6 +71,17 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->addEventListener('customer_acquisition', function (Message $message, \Closure $next) {
|
||||
try {
|
||||
(new QywxCustomerAcquisitionCustomerService())->handleCallback($message->toArray());
|
||||
} catch (\Throwable $e) {
|
||||
// 服务已将失败事件与 next_retry 落库,定时命令会在 ChatKey 30 分钟内继续重试。
|
||||
Log::error('qywx customer acquisition callback: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$psr = $server->serve();
|
||||
$body = $psr->getBody();
|
||||
$body->rewind();
|
||||
|
||||
@@ -4,15 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use app\common\service\qywx\QywxPromotionRedirectService;
|
||||
use think\facade\Log;
|
||||
|
||||
/** 企业微信推广公开端点:服务商回调、授权回跳、JS 与随机跳转。 */
|
||||
/** 企业微信获客助手公开端点:JS 与随机跳转。 */
|
||||
class QywxPromotionPublicController extends BaseApiController
|
||||
{
|
||||
/** 公开安装代码、随机跳转与企业微信服务商回调均不依赖前台用户登录。 */
|
||||
public array $notNeedLogin = ['script', 'redirect', 'providerCallback', 'authCallback'];
|
||||
/** 公开安装代码与随机跳转不依赖前台用户登录。 */
|
||||
public array $notNeedLogin = ['script', 'redirect'];
|
||||
|
||||
public function script(string $key)
|
||||
{
|
||||
@@ -58,7 +56,7 @@ JS;
|
||||
'ip' => (string) $this->request->ip(),
|
||||
]);
|
||||
if (!$picked) {
|
||||
return response('当前暂无可用的企业微信推广链接,请稍后再试。', 503, [
|
||||
return response('当前暂无可用的企业微信获客助手链接,请稍后再试。', 503, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
@@ -70,40 +68,4 @@ JS;
|
||||
]);
|
||||
}
|
||||
|
||||
public function providerCallback()
|
||||
{
|
||||
try {
|
||||
$psr = QywxPromotionOpenWorkService::serveProviderCallback();
|
||||
$body = $psr->getBody();
|
||||
$body->rewind();
|
||||
$headers = [];
|
||||
if ($psr->getHeaderLine('Content-Type') !== '') {
|
||||
$headers['Content-Type'] = $psr->getHeaderLine('Content-Type');
|
||||
}
|
||||
|
||||
return response($body->getContents(), $psr->getStatusCode(), $headers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广服务商回调失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
}
|
||||
|
||||
public function authCallback()
|
||||
{
|
||||
$fallback = rtrim($this->request->domain(), '/') . '/admin/first_visit/wecom_promotion';
|
||||
$returnUrl = QywxPromotionOpenWorkService::configuredAdminReturnUrl($fallback);
|
||||
try {
|
||||
$result = QywxPromotionOpenWorkService::consumeAuthorizationCallback(
|
||||
trim((string) $this->request->get('auth_code', '')),
|
||||
trim((string) $this->request->get('state', ''))
|
||||
);
|
||||
$query = ['wecom_auth' => 'success', 'account_id' => (int) $result['id']];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广授权回跳失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
$query = ['wecom_auth' => 'failed', 'message' => mb_substr($e->getMessage(), 0, 160)];
|
||||
}
|
||||
|
||||
return redirect($returnUrl . (str_contains($returnUrl, '?') ? '&' : '?') . http_build_query($query), 302);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ use think\facade\Route;
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
// 企业微信推广助手:服务商应用指令、授权回跳、公开 JS 与随机分流。
|
||||
Route::rule('qywx-promotion/provider/callback', 'QywxPromotionPublic/providerCallback', 'GET|POST');
|
||||
Route::get('qywx-promotion/auth/callback', 'QywxPromotionPublic/authCallback');
|
||||
// 企业微信内部应用推广助手:公开 JS 与服务端随机分流。
|
||||
Route::get('qywx-promotion/js/:key', 'QywxPromotionPublic/script');
|
||||
Route::get('qywx-promotion/go/:key', 'QywxPromotionPublic/redirect');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/** 每分钟重试获客助手 message_from_customer/customer_start_chat 回调。 */
|
||||
class QywxRetryCustomerAcquisitionEvents extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:retry-customer-acquisition-events')
|
||||
->setDescription('重试 30 分钟有效期内失败的企业微信获客会话回调');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxCustomerAcquisitionCustomerService())->retryPending(100);
|
||||
$output->writeln(sprintf(
|
||||
'QYWX_CUSTOMER_ACQUISITION_RETRY selected=%d success=%d failed=%d expired=%d',
|
||||
$result['selected'],
|
||||
$result['success'],
|
||||
$result['failed'],
|
||||
$result['expired']
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use RuntimeException;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 企业微信内部应用获客链接 API。
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/97297
|
||||
*/
|
||||
class QywxCustomerAcquisitionApiService
|
||||
{
|
||||
private const TOKEN_INVALID_CODES = [40001, 40014, 42001];
|
||||
|
||||
private string $corpId;
|
||||
private string $secret;
|
||||
private Client $client;
|
||||
/** @var null|callable():string */
|
||||
private $accessTokenResolver;
|
||||
|
||||
/** @param null|callable():string $accessTokenResolver 仅用于测试或托管 token 场景。 */
|
||||
public function __construct(?Client $client = null, ?callable $accessTokenResolver = null)
|
||||
{
|
||||
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
|
||||
$this->secret = trim((string) config('qywx_customer_acquisition.secret', ''));
|
||||
$this->client = $client ?? new Client([
|
||||
'base_uri' => rtrim((string) config('qywx_customer_acquisition.base_uri', 'https://qyapi.weixin.qq.com'), '/') . '/',
|
||||
'timeout' => max(5, (int) config('qywx_customer_acquisition.timeout', 20)),
|
||||
'connect_timeout' => 8,
|
||||
'http_errors' => false,
|
||||
'verify' => config('qywx_customer_acquisition.verify', true),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
$this->accessTokenResolver = $accessTokenResolver;
|
||||
}
|
||||
|
||||
/** @return array{configured:bool,missing:list<string>} */
|
||||
public static function configurationStatus(): array
|
||||
{
|
||||
$missing = [];
|
||||
if (trim((string) config('qywx_customer_acquisition.corp_id', '')) === '') {
|
||||
$missing[] = 'work_wechat.corp_id';
|
||||
}
|
||||
if (trim((string) config('qywx_customer_acquisition.secret', '')) === '') {
|
||||
$missing[] = 'work_wechat.customer_acquisition_secret / secret';
|
||||
}
|
||||
|
||||
return ['configured' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
|
||||
/** @return array{link_id_list:list<string>,next_cursor:string} */
|
||||
public function listLinks(string $cursor = '', int $limit = 100): array
|
||||
{
|
||||
$body = ['limit' => min(100, max(1, $limit))];
|
||||
if ($cursor !== '') {
|
||||
$body['cursor'] = $cursor;
|
||||
}
|
||||
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/list_link', $body);
|
||||
|
||||
return [
|
||||
'link_id_list' => array_values(array_filter(array_map('strval', (array) ($response['link_id_list'] ?? [])))),
|
||||
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function getLink(string $linkId): array
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/get', ['link_id' => $linkId]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function createLink(array $payload): array
|
||||
{
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/create_link', $payload);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function updateLink(array $payload): array
|
||||
{
|
||||
$this->assertLinkId((string) ($payload['link_id'] ?? ''));
|
||||
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/update_link', $payload);
|
||||
}
|
||||
|
||||
public function deleteLink(string $linkId): void
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
$this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/delete_link', ['link_id' => $linkId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定获客链接添加的客户。单页最多 1000 条。
|
||||
*
|
||||
* @return array{customer_list:list<array<string,mixed>>,next_cursor:string}
|
||||
*/
|
||||
public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
$body = [
|
||||
'link_id' => $linkId,
|
||||
'limit' => min(1000, max(1, $limit)),
|
||||
];
|
||||
if ($cursor !== '') {
|
||||
$body['cursor'] = $cursor;
|
||||
}
|
||||
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/customer', $body);
|
||||
$customers = array_values(array_filter(
|
||||
(array) ($response['customer_list'] ?? []),
|
||||
static fn (mixed $row): bool => is_array($row)
|
||||
));
|
||||
|
||||
return [
|
||||
'customer_list' => $customers,
|
||||
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function getChatInfo(string $chatKey): array
|
||||
{
|
||||
$chatKey = trim($chatKey);
|
||||
if ($chatKey === '' || strlen($chatKey) > 512) {
|
||||
throw new RuntimeException('获客会话 ChatKey 不正确');
|
||||
}
|
||||
|
||||
return $this->request(
|
||||
'POST',
|
||||
'cgi-bin/externalcontact/customer_acquisition/get_chat_info',
|
||||
['chat_key' => $chatKey]
|
||||
);
|
||||
}
|
||||
|
||||
/** 通过只读列表接口验证 token、可信 IP、获客助手开通状态与应用权限。 */
|
||||
public function checkPermission(): array
|
||||
{
|
||||
$result = $this->listLinks('', 1);
|
||||
$hasLink = $result['link_id_list'] !== [];
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => $hasLink
|
||||
? '获客助手 API 权限验证通过,当前应用已有官方获客链接'
|
||||
: '获客助手 API 权限验证通过,但当前应用尚未通过 API 创建官方获客链接',
|
||||
'has_link' => $hasLink,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function request(string $method, string $path, array $body = [], bool $retried = false): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
$cacheKey = $this->tokenCacheKey();
|
||||
$token = $this->accessToken();
|
||||
try {
|
||||
$options = ['query' => ['access_token' => $token]];
|
||||
if (strtoupper($method) === 'POST') {
|
||||
$options['json'] = $body;
|
||||
}
|
||||
$response = $this->client->request($method, ltrim($path, '/'), $options);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('企业微信获客助手接口连接失败,请检查服务器网络与可信 IP 配置', 0, $e);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('企业微信获客助手接口返回了无法解析的数据');
|
||||
}
|
||||
$errcode = (int) ($decoded['errcode'] ?? 0);
|
||||
if ($errcode === 0) {
|
||||
return $decoded;
|
||||
}
|
||||
if (!$retried && in_array($errcode, self::TOKEN_INVALID_CODES, true)) {
|
||||
Cache::delete($cacheKey);
|
||||
|
||||
return $this->request($method, $path, $body, true);
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'企业微信获客助手接口失败[%d]:%s',
|
||||
$errcode,
|
||||
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
|
||||
));
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->accessTokenResolver !== null) {
|
||||
$token = trim((string) call_user_func($this->accessTokenResolver));
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('托管 access_token 为空');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
$cacheKey = $this->tokenCacheKey();
|
||||
$cached = trim((string) Cache::get($cacheKey, ''));
|
||||
if ($cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
|
||||
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('获取企业微信 access_token 失败,请检查服务器网络', 0, $e);
|
||||
}
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($decoded) || (int) ($decoded['errcode'] ?? 0) !== 0 || empty($decoded['access_token'])) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'获取企业微信 access_token 失败[%d]:%s',
|
||||
(int) ($decoded['errcode'] ?? -1),
|
||||
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
|
||||
));
|
||||
}
|
||||
$token = (string) $decoded['access_token'];
|
||||
Cache::set($cacheKey, $token, max(60, (int) ($decoded['expires_in'] ?? 7200) - 300));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function tokenCacheKey(): string
|
||||
{
|
||||
return 'qywx_customer_acquisition_token:' . hash('sha256', $this->corpId . '|' . $this->secret);
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['configured']) {
|
||||
throw new RuntimeException('获客助手应用配置不完整:缺少 ' . implode('、', $status['missing']));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertLinkId(string $linkId): void
|
||||
{
|
||||
if ($linkId === '' || strlen($linkId) > 128) {
|
||||
throw new RuntimeException('获客链接 ID 不正确');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客客户归因、会话统计与回调幂等落库。 */
|
||||
class QywxCustomerAcquisitionCustomerService
|
||||
{
|
||||
private QywxCustomerAcquisitionApiService $api;
|
||||
|
||||
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
|
||||
{
|
||||
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步一个远端获客链接的全部客户,远端列表字段采用覆盖语义。
|
||||
* recv_msg_cnt 不在列表接口中返回,因此同步时保留本地值。
|
||||
*
|
||||
* @return array{scanned:int,created:int,updated:int,pages:int,truncated:bool}
|
||||
*/
|
||||
public function syncLink(string $remoteLinkId, int $maxCustomers = 20000): array
|
||||
{
|
||||
$remoteLinkId = trim($remoteLinkId);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('获客链接 ID 不能为空');
|
||||
}
|
||||
$cursor = '';
|
||||
$scanned = 0;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$pages = 0;
|
||||
do {
|
||||
$page = $this->api->listCustomers($remoteLinkId, $cursor, 1000);
|
||||
$pages++;
|
||||
foreach ($page['customer_list'] as $customer) {
|
||||
if ($scanned >= $maxCustomers) {
|
||||
break 2;
|
||||
}
|
||||
$scanned++;
|
||||
$result = self::upsertCustomer($remoteLinkId, $customer, false);
|
||||
$result === 'created' ? $created++ : $updated++;
|
||||
}
|
||||
$cursor = (string) ($page['next_cursor'] ?? '');
|
||||
} while ($cursor !== '');
|
||||
|
||||
return compact('scanned', 'created', 'updated', 'pages') + ['truncated' => $cursor !== ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 customer_acquisition 回调。相同事件只成功处理一次;失败事件保留审计并允许企微重试。
|
||||
*
|
||||
* @return array{duplicate:bool,status:string}
|
||||
*/
|
||||
public function handleCallback(array $message): array
|
||||
{
|
||||
$changeType = trim((string) ($message['ChangeType'] ?? $message['change_type'] ?? ''));
|
||||
if (!in_array($changeType, ['customer_start_chat', 'message_from_customer'], true)) {
|
||||
return ['duplicate' => false, 'status' => 'ignored'];
|
||||
}
|
||||
$chatKey = trim((string) ($message['ChatKey'] ?? $message['Chatkey'] ?? $message['chat_key'] ?? ''));
|
||||
$eventTime = (int) ($message['CreateTime'] ?? $message['create_time'] ?? 0);
|
||||
$eventKey = self::eventKey($message, $changeType, $chatKey, $eventTime);
|
||||
$event = self::beginEvent($eventKey, $changeType, $chatKey, $eventTime, $message);
|
||||
if (($event['duplicate'] ?? false) === true) {
|
||||
return ['duplicate' => true, 'status' => 'success'];
|
||||
}
|
||||
|
||||
$eventId = (int) ($event['id'] ?? 0);
|
||||
try {
|
||||
// customer_start_chat 仅能确认“客户已发起会话”,企业微信不保证该事件携带 ChatKey。
|
||||
// 此时先落归因与聊天状态,精确消息数等待 message_from_customer 回调补齐。
|
||||
if ($changeType === 'customer_start_chat' && $chatKey === '') {
|
||||
$remoteLinkId = trim((string) (
|
||||
$message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
|
||||
));
|
||||
$externalUserId = trim((string) (
|
||||
$message['ExternalUserID'] ?? $message['ExternalUserId'] ?? $message['external_userid'] ?? ''
|
||||
));
|
||||
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? $message['userid'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('customer_start_chat 回调缺少 link_id / external_userid / userid');
|
||||
}
|
||||
self::upsertCustomer($remoteLinkId, [
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'chat_status' => 1,
|
||||
'state' => (string) ($message['State'] ?? $message['state'] ?? ''),
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $message,
|
||||
], false);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
}
|
||||
if ($chatKey === '') {
|
||||
self::finishEvent($eventId, 3, 'failed_invalid: message_from_customer 回调缺少 ChatKey');
|
||||
throw new RuntimeException('message_from_customer 回调缺少 ChatKey');
|
||||
}
|
||||
$now = time();
|
||||
if ($eventTime > 0 && ($now - $eventTime) >= 1800) {
|
||||
throw new RuntimeException('获客回调 ChatKey 已超过 30 分钟有效期');
|
||||
}
|
||||
$chat = $this->api->getChatInfo($chatKey);
|
||||
$chatInfo = is_array($chat['chat_info'] ?? null) ? $chat['chat_info'] : [];
|
||||
$remoteLinkId = trim((string) (
|
||||
$chatInfo['link_id'] ?? $message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
|
||||
));
|
||||
$externalUserId = trim((string) (
|
||||
$chat['external_userid'] ?? $message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''
|
||||
));
|
||||
$userId = trim((string) ($chat['userid'] ?? $message['UserID'] ?? $message['UserId'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('get_chat_info 未返回完整的 link_id / external_userid / userid');
|
||||
}
|
||||
self::upsertCustomer($remoteLinkId, [
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'chat_status' => max(1, (int) ($message['ChatStatus'] ?? 1)),
|
||||
'recv_msg_cnt' => max(0, (int) ($chatInfo['recv_msg_cnt'] ?? 0)),
|
||||
'state' => (string) ($chatInfo['state'] ?? $message['State'] ?? ''),
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $chat,
|
||||
], true);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
} catch (\Throwable $e) {
|
||||
if ($eventId > 0 && str_contains($e->getMessage(), 'message_from_customer 回调缺少 ChatKey')) {
|
||||
throw $e;
|
||||
}
|
||||
self::scheduleRetryOrExpire($eventId, $eventTime, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试仍在 ChatKey 30 分钟有效期内的失败回调,并把到期记录明确标记 failed_expired。
|
||||
*
|
||||
* @return array{selected:int,success:int,failed:int,expired:int}
|
||||
*/
|
||||
public function retryPending(int $limit = 100): array
|
||||
{
|
||||
$now = time();
|
||||
// 进程在 beginEvent 后异常退出时,处理中事件会卡在 status=0;一分钟后自动回收再试。
|
||||
Db::name('qywx_customer_acquisition_event')
|
||||
->where('status', 0)
|
||||
->where('update_time', '<=', $now - 60)
|
||||
->where('expire_time', '>', $now)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'next_retry' => $now,
|
||||
'error_message' => 'watchdog_recovered: 上次处理未正常结束',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$expired = (int) Db::name('qywx_customer_acquisition_event')
|
||||
->whereIn('status', [0, 2])
|
||||
->where('expire_time', '>', 0)
|
||||
->where('expire_time', '<=', $now)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'next_retry' => 0,
|
||||
'error_message' => 'failed_expired: ChatKey 已超过 30 分钟有效期',
|
||||
'chat_key' => '',
|
||||
'raw_json' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$rows = Db::name('qywx_customer_acquisition_event')
|
||||
->where('status', 2)
|
||||
->where('next_retry', '<=', $now)
|
||||
->where('expire_time', '>', $now)
|
||||
->order('next_retry', 'asc')
|
||||
->limit(min(500, max(1, $limit)))
|
||||
->select()->toArray();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
foreach ($rows as $row) {
|
||||
$message = json_decode((string) ($row['raw_json'] ?? ''), true);
|
||||
if (!is_array($message)) {
|
||||
self::scheduleRetryOrExpire(
|
||||
(int) $row['id'],
|
||||
(int) ($row['event_time'] ?? 0),
|
||||
'回调原始数据无法解析'
|
||||
);
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->handleCallback($message);
|
||||
$success++;
|
||||
} catch (\Throwable) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['selected' => count($rows), 'success' => $success, 'failed' => $failed, 'expired' => $expired];
|
||||
}
|
||||
|
||||
public static function eventKey(array $message, string $changeType, string $chatKey, int $eventTime): string
|
||||
{
|
||||
$parts = [
|
||||
(string) ($message['MsgId'] ?? $message['MsgID'] ?? ''),
|
||||
$changeType,
|
||||
$chatKey,
|
||||
(string) $eventTime,
|
||||
(string) ($message['LinkID'] ?? $message['LinkId'] ?? ''),
|
||||
(string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''),
|
||||
(string) ($message['UserID'] ?? $message['UserId'] ?? ''),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/** @return array{expire_time:int,next_retry:int,expired:bool} */
|
||||
public static function retryDecision(int $eventTime, int $now, int $storedExpireTime = 0): array
|
||||
{
|
||||
$expireTime = $storedExpireTime > 0
|
||||
? $storedExpireTime
|
||||
: ($eventTime > 0 ? $eventTime + 1800 : $now + 1800);
|
||||
$expired = $expireTime <= $now;
|
||||
|
||||
return [
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => $expired ? 0 : min($expireTime - 1, $now + 30),
|
||||
'expired' => $expired,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{id:int,duplicate:bool} */
|
||||
private static function beginEvent(
|
||||
string $eventKey,
|
||||
string $changeType,
|
||||
string $chatKey,
|
||||
int $eventTime,
|
||||
array $message
|
||||
): array {
|
||||
$now = time();
|
||||
$raw = self::encodeJson($message);
|
||||
$expireTime = self::retryDecision($eventTime, $now)['expire_time'];
|
||||
try {
|
||||
$id = (int) Db::name('qywx_customer_acquisition_event')->insertGetId([
|
||||
'event_key' => $eventKey,
|
||||
'change_type' => $changeType,
|
||||
'chat_key' => $chatKey,
|
||||
'status' => 0,
|
||||
'attempts' => 1,
|
||||
'event_time' => max(0, $eventTime),
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => 0,
|
||||
'error_message' => '',
|
||||
'raw_json' => $raw,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
return ['id' => $id, 'duplicate' => false];
|
||||
} catch (\Throwable $e) {
|
||||
$existing = Db::name('qywx_customer_acquisition_event')->where('event_key', $eventKey)->find();
|
||||
if (!$existing) {
|
||||
throw $e;
|
||||
}
|
||||
if ((int) ($existing['status'] ?? 0) === 1) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
if ((int) ($existing['status'] ?? 0) !== 2) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
$claimed = Db::name('qywx_customer_acquisition_event')
|
||||
->where('id', (int) $existing['id'])
|
||||
->where('status', 2)
|
||||
->update([
|
||||
'status' => 0,
|
||||
'attempts' => (int) ($existing['attempts'] ?? 0) + 1,
|
||||
'error_message' => '',
|
||||
'raw_json' => $raw,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($claimed <= 0) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => false];
|
||||
}
|
||||
}
|
||||
|
||||
private static function finishEvent(
|
||||
int $id,
|
||||
int $status,
|
||||
string $error = '',
|
||||
string $remoteLinkId = '',
|
||||
string $externalUserId = '',
|
||||
string $userId = ''
|
||||
): void {
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'link_id' => $remoteLinkId,
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'next_retry' => 0,
|
||||
// ChatKey 是短时敏感凭证,终态后不再保留;原始回调也随之清理。
|
||||
'chat_key' => '',
|
||||
'raw_json' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function scheduleRetryOrExpire(int $id, int $eventTime, string $error): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
$expireTime = (int) (Db::name('qywx_customer_acquisition_event')
|
||||
->where('id', $id)->value('expire_time') ?? 0);
|
||||
$decision = self::retryDecision($eventTime, $now, $expireTime);
|
||||
$expireTime = $decision['expire_time'];
|
||||
$expired = $decision['expired'];
|
||||
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
|
||||
'status' => $expired ? 3 : 2,
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => $decision['next_retry'],
|
||||
'error_message' => mb_substr(
|
||||
$expired ? 'failed_expired: ' . $error : $error,
|
||||
0,
|
||||
1000
|
||||
),
|
||||
'chat_key' => $expired ? '' : Db::raw('chat_key'),
|
||||
'raw_json' => $expired ? null : Db::raw('raw_json'),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return 'created'|'updated' */
|
||||
private static function upsertCustomer(string $remoteLinkId, array $customer, bool $messageCountKnown): string
|
||||
{
|
||||
$externalUserId = trim((string) ($customer['external_userid'] ?? ''));
|
||||
$userId = trim((string) ($customer['userid'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('获客客户数据缺少 link_id / external_userid / userid');
|
||||
}
|
||||
[$ownerAdminId, $deptId] = self::resolveOwner($userId);
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_customer_acquisition_customer')
|
||||
->where('link_id', $remoteLinkId)
|
||||
->where('external_userid', $externalUserId)
|
||||
->where('userid', $userId)
|
||||
->find();
|
||||
$snapshot = $customer['snapshot'] ?? $customer;
|
||||
$incomingChatStatus = max(0, min(2, (int) ($customer['chat_status'] ?? 0)));
|
||||
$data = [
|
||||
'promotion_link_id' => (int) (Db::name('qywx_promotion_link')
|
||||
->where('remote_link_id', $remoteLinkId)->value('id') ?? 0),
|
||||
'owner_admin_id' => $ownerAdminId,
|
||||
'dept_id' => $deptId,
|
||||
'state' => mb_substr((string) ($customer['state'] ?? ''), 0, 255),
|
||||
// 已确认发过消息后,列表同步返回的“未发/未知”不得把状态回退。
|
||||
'chat_status' => $existing
|
||||
? Db::raw('CASE WHEN chat_status = 1 OR ' . $incomingChatStatus . ' = 1 THEN 1 ELSE ' . $incomingChatStatus . ' END')
|
||||
: $incomingChatStatus,
|
||||
'last_sync_time' => $now,
|
||||
'raw_snapshot' => self::encodeJson($snapshot),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($messageCountKnown) {
|
||||
$remoteCount = max(0, (int) ($customer['recv_msg_cnt'] ?? 0));
|
||||
// get_chat_info 返回累计值,必须 max/覆盖,绝不按回调次数累加。
|
||||
$data['recv_msg_cnt'] = $existing
|
||||
? Db::raw('GREATEST(recv_msg_cnt,' . $remoteCount . ')')
|
||||
: $remoteCount;
|
||||
$data['message_count_known'] = 1;
|
||||
}
|
||||
if ($incomingChatStatus === 1 || $messageCountKnown) {
|
||||
$eventTime = max(0, (int) ($customer['event_time'] ?? $now));
|
||||
$data['last_chat_time'] = $existing
|
||||
? Db::raw('GREATEST(last_chat_time,' . $eventTime . ')')
|
||||
: $eventTime;
|
||||
}
|
||||
if ($existing) {
|
||||
Db::name('qywx_customer_acquisition_customer')->where('id', (int) $existing['id'])->update($data);
|
||||
|
||||
return 'updated';
|
||||
}
|
||||
$data += [
|
||||
'link_id' => $remoteLinkId,
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'recv_msg_cnt' => $messageCountKnown ? max(0, (int) ($customer['recv_msg_cnt'] ?? 0)) : 0,
|
||||
'message_count_known' => $messageCountKnown ? 1 : 0,
|
||||
'first_acquired_time' => max(0, (int) ($customer['create_time'] ?? $customer['event_time'] ?? $now)),
|
||||
'last_chat_time' => ($incomingChatStatus === 1 || $messageCountKnown)
|
||||
? max(0, (int) ($customer['event_time'] ?? $now))
|
||||
: 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
Db::name('qywx_customer_acquisition_customer')->insert($data);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int} */
|
||||
private static function resolveOwner(string $userId): array
|
||||
{
|
||||
$adminId = (int) (Db::name('admin')->where('work_wechat_userid', $userId)
|
||||
->whereNull('delete_time')->value('id') ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return [0, 0];
|
||||
}
|
||||
$deptId = (int) (Db::name('admin_dept')->where('admin_id', $adminId)
|
||||
->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
|
||||
return [$adminId, $deptId];
|
||||
}
|
||||
|
||||
private static function encodeJson(mixed $value): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json === false ? '{}' : $json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
/** 企业微信获客助手链接校验。 */
|
||||
class QywxCustomerAcquisitionLinkService
|
||||
{
|
||||
private const HOST = 'work.weixin.qq.com';
|
||||
|
||||
/**
|
||||
* 只接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。
|
||||
*/
|
||||
public static function isAllowed(string $url, bool $allowEmpty = false): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return $allowEmpty;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts)
|
||||
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|
||||
|| strtolower((string) ($parts['host'] ?? '')) !== self::HOST
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| (isset($parts['port']) && (int) $parts['port'] !== 443)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
|
||||
return preg_match('#^/ca/[A-Za-z0-9_-]+/?$#', $path) === 1;
|
||||
}
|
||||
|
||||
public static function example(): string
|
||||
{
|
||||
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
class QywxPromotionRedirectService
|
||||
{
|
||||
/** @return array{url:string,link_id:int}|null */
|
||||
@@ -30,11 +30,9 @@ class QywxPromotionRedirectService
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->where('l.pool_id', (int) $pool['id'])
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
|
||||
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
|
||||
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
|
||||
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
|
||||
@@ -43,10 +41,16 @@ class QywxPromotionRedirectService
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 兼容历史数据:旧的普通外链或客户群链接即使仍在库中,也不能参与分流。
|
||||
$links = array_values(array_filter(
|
||||
$links,
|
||||
static fn (array $link): bool => QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''))
|
||||
));
|
||||
|
||||
$selected = self::weightedRandom($links);
|
||||
if (!$selected) {
|
||||
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
|
||||
if (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
|
||||
if (QywxCustomerAcquisitionLinkService::isAllowed($fallback, true) && $fallback !== '') {
|
||||
return ['url' => $fallback, 'link_id' => 0];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user