202 lines
9.0 KiB
PHP
202 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\qywx;
|
|
|
|
use RuntimeException;
|
|
use think\facade\Db;
|
|
|
|
/** 把当前全部可用医助同步到同一条企业微信官方获客链接,由企微执行原生多人路由。 */
|
|
class QywxPromotionRangeSyncService
|
|
{
|
|
private QywxCustomerAcquisitionApiService $api;
|
|
|
|
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
|
|
{
|
|
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
|
|
}
|
|
|
|
/** @return array{status:string,pool_id:int,member_id:int} */
|
|
public function syncPool(int $poolId): array
|
|
{
|
|
$claim = Db::transaction(function () use ($poolId): ?array {
|
|
$row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
$status = (int) ($row['status'] ?? 0);
|
|
$now = time();
|
|
if ($status === 0 || $status === 4 || $status === 5 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) {
|
|
return null;
|
|
}
|
|
if ($status === 3 && (int) ($row['next_retry'] ?? 0) > $now) {
|
|
return null;
|
|
}
|
|
$token = bin2hex(random_bytes(16));
|
|
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
|
|
'status' => 2,
|
|
'lock_token' => $token,
|
|
'lock_until' => $now + 90,
|
|
'attempts' => (int) ($row['attempts'] ?? 0) + 1,
|
|
'last_error' => '',
|
|
'update_time' => $now,
|
|
]);
|
|
$row['lock_token'] = $token;
|
|
|
|
return $row;
|
|
});
|
|
if ($claim === null) {
|
|
return ['status' => 'noop', 'pool_id' => $poolId, 'member_id' => 0];
|
|
}
|
|
|
|
$token = (string) $claim['lock_token'];
|
|
$desiredVersion = (int) ($claim['desired_version'] ?? 0);
|
|
try {
|
|
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->find();
|
|
$link = Db::name('qywx_promotion_link')
|
|
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
|
|
->whereNull('delete_time')->find();
|
|
$members = Db::name('qywx_promotion_pool_member')
|
|
->where('pool_id', $poolId)->whereNull('delete_time')->order('id', 'asc')->select()->toArray();
|
|
if (!$pool || !$link || $members === []) {
|
|
throw new RuntimeException('分流方案、官方链接或方案成员已失效');
|
|
}
|
|
$remoteLinkId = trim((string) ($link['remote_link_id'] ?? ''));
|
|
if ($remoteLinkId === '') {
|
|
throw new RuntimeException('官方链接 ID 为空');
|
|
}
|
|
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
|
|
$desiredUserIds = $range['userids'];
|
|
if ($desiredUserIds === []) {
|
|
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
|
|
Db::name('qywx_promotion_range_sync')
|
|
->where('pool_id', $poolId)->where('lock_token', $token)->update([
|
|
'status' => 4,
|
|
'desired_member_id' => 0,
|
|
'applied_member_id' => 0,
|
|
'next_retry' => strtotime('tomorrow'),
|
|
'attempts' => 0,
|
|
'lock_token' => '',
|
|
'lock_until' => 0,
|
|
'last_error' => $message,
|
|
'update_time' => time(),
|
|
]);
|
|
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
|
|
'sync_error' => $message,
|
|
'update_time' => time(),
|
|
]);
|
|
|
|
return ['status' => 'blocked', 'pool_id' => $poolId, 'member_id' => 0];
|
|
}
|
|
|
|
$this->api->updateLink([
|
|
'link_id' => $remoteLinkId,
|
|
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
|
|
'range' => ['user_list' => $desiredUserIds, 'department_list' => []],
|
|
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
|
|
]);
|
|
$response = $this->api->getLink($remoteLinkId);
|
|
$remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId);
|
|
$actualUserIds = $remote['range_userids'];
|
|
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) {
|
|
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
|
|
}
|
|
$url = $remote['url'];
|
|
$snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
|
|
'wecom_url' => $url,
|
|
'remote_status' => 1,
|
|
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'remote_snapshot' => $snapshot === false ? null : $snapshot,
|
|
'last_sync_time' => time(),
|
|
'sync_error' => '',
|
|
'update_time' => time(),
|
|
]);
|
|
|
|
$freshVersion = (int) (Db::name('qywx_promotion_range_sync')
|
|
->where('pool_id', $poolId)->value('desired_version') ?? 0);
|
|
Db::name('qywx_promotion_range_sync')
|
|
->where('pool_id', $poolId)
|
|
->where('lock_token', $token)
|
|
->update([
|
|
'status' => $freshVersion === $desiredVersion ? 0 : 1,
|
|
'desired_member_id' => 0,
|
|
'applied_member_id' => 0,
|
|
'applied_version' => $desiredVersion,
|
|
'next_retry' => $freshVersion === $desiredVersion ? 0 : time(),
|
|
'attempts' => 0,
|
|
'lock_token' => '',
|
|
'lock_until' => 0,
|
|
'last_error' => '',
|
|
'update_time' => time(),
|
|
]);
|
|
|
|
return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0];
|
|
} catch (\Throwable $e) {
|
|
$attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1);
|
|
Db::name('qywx_promotion_range_sync')
|
|
->where('pool_id', $poolId)
|
|
->where('lock_token', $token)
|
|
->update([
|
|
'status' => 3,
|
|
'next_retry' => time() + min(300, 15 * $attempts),
|
|
'lock_token' => '',
|
|
'lock_until' => 0,
|
|
'last_error' => mb_substr($e->getMessage(), 0, 500),
|
|
'update_time' => time(),
|
|
]);
|
|
Db::name('qywx_promotion_link')
|
|
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
|
|
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/** @return array{selected:int,synced:int,failed:int} */
|
|
public function syncPending(int $limit = 100): array
|
|
{
|
|
// 每分钟分页重算全部方案,避免有效期、跨日上限变化漏同步,也避免方案数超过批次后饥饿。
|
|
$lastPoolId = 0;
|
|
do {
|
|
$reconcilePoolIds = Db::name('qywx_promotion_range_sync')
|
|
->where('pool_id', '>', $lastPoolId)
|
|
->order('pool_id', 'asc')
|
|
->limit(500)
|
|
->column('pool_id');
|
|
foreach ($reconcilePoolIds as $reconcilePoolId) {
|
|
$lastPoolId = (int) $reconcilePoolId;
|
|
QywxPromotionMemberSchedulerService::reconcilePool($lastPoolId);
|
|
}
|
|
} while (count($reconcilePoolIds) === 500);
|
|
|
|
$now = time();
|
|
$poolIds = Db::name('qywx_promotion_range_sync')
|
|
->where(function ($query) use ($now): void {
|
|
$query->whereIn('status', [1, 3])->where('next_retry', '<=', $now)
|
|
->whereOr(function ($running) use ($now): void {
|
|
$running->where('status', 2)->where('lock_until', '<=', $now);
|
|
});
|
|
})
|
|
->order('next_retry', 'asc')
|
|
->limit(min(500, max(1, $limit)))
|
|
->column('pool_id');
|
|
$synced = 0;
|
|
$failed = 0;
|
|
foreach ($poolIds as $poolId) {
|
|
try {
|
|
$result = $this->syncPool((int) $poolId);
|
|
if ($result['status'] === 'synced') {
|
|
$synced++;
|
|
}
|
|
} catch (\Throwable) {
|
|
$failed++;
|
|
}
|
|
}
|
|
|
|
return ['selected' => count($poolIds), 'synced' => $synced, 'failed' => $failed];
|
|
}
|
|
}
|