This commit is contained in:
Your Name
2026-08-31 15:17:34 +08:00
parent ed48f8be31
commit 456dd667df
439 changed files with 5720 additions and 422 deletions
@@ -8,11 +8,48 @@ use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
class WecomPromotionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public function tagOptions()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
}
public function createTag()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 创建标签');
}
$name = $this->request->post('name', '');
if (!is_string($name)) {
return $this->fail('标签名称格式不正确');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
}
public function uploadWelcomeMedia()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
$this->request->file('file'),
(string) $this->request->post('type', ''),
$this->adminId
)));
}
public function overview()
{
if (!$this->hasPagePermission()) {
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('关联支付单成功', $result);
}
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
public function unlinkPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('收款关联已移除,金额已同步更新', $result);
}
/**
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
*/
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$allOids = array_values(array_unique($allOids));
$amountByOid = [];
if ($allOids !== []) {
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
->whereIn('status', [2, 5])->column('amount', 'id');
}
foreach ($poIds as $pid) {
$s = 0.0;
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
->join('order o', 'l.pay_order_id = o.id')
->whereIn('l.prescription_order_id', $poIds)
->whereNull('o.delete_time')
->whereIn('o.status', [2, 5])
->sum('o.amount');
return round($sum, 2);
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
}
unset($row);
}
return [
return self::withDeletedFansVisibility([
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
],
'rows' => $rows,
'target' => $target,
];
], $adminInfo);
}
/** @return array<string,mixed> */
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
$empty = [
$empty = self::withDeletedFansVisibility([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$context['start_date'], $context['end_date']],
'entity' => null,
];
], $adminInfo, true);
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
if (!in_array($entityType, ['dept', 'member'], true)) {
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
];
unset($result['deleted_count']);
return self::withDeletedFansVisibility($result, $adminInfo, true);
}
/**
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
*/
private static function canViewDeletedFans(array $adminInfo): bool
{
return ($adminInfo['account'] ?? null) === 'admin';
}
/**
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
*/
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
{
$canView = self::canViewDeletedFans($adminInfo);
if (!$canView) {
$fields = $detail
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
: ['deleted_fans_count', 'deleted_count'];
$result = self::removeDeletedFansFields($result, $fields);
}
if ($detail) {
$result['can_view_deleted_fans'] = $canView;
} else {
$result['meta']['can_view_deleted_fans'] = $canView;
}
return $result;
}
/** @param string[] $fields */
private static function removeDeletedFansFields(array $value, array $fields): array
{
foreach ($fields as $field) {
unset($value[$field]);
}
foreach ($value as &$item) {
if (is_array($item)) {
$item = self::removeDeletedFansFields($item, $fields);
}
}
unset($item);
return $value;
}
/**
* Resolve only the clicked entity and its authorized target range. This is
* deliberately structural: it avoids recomputing all overview metrics,
@@ -9,6 +9,9 @@ use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionMemberRange;
use app\common\service\qywx\QywxPromotionConfig;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionWidgetService;
@@ -49,6 +52,7 @@ class WecomPromotionLogic
$domain = self::publicDomain($domain);
foreach ($pools as &$pool) {
$pool['automation_config'] = QywxPromotionConfig::forPool((int) $pool['id']);
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
@@ -133,7 +137,11 @@ class WecomPromotionLogic
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
$sync = $syncByPool[(int) $pool['id']] ?? [];
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
$reception = QywxPromotionMemberRange::evaluate($memberRules, $today, time(), $pool['automation_config']);
$availableUserIds = array_fill_keys($reception['userids'], true);
foreach ($memberRules as &$memberRule) {
$memberRule['is_backup'] = in_array((int) ($memberRule['admin_id'] ?? 0), $pool['automation_config']['backup_member_admin_ids'], true);
$memberRule['reception_available'] = isset($availableUserIds[(string) ($memberRule['userid'] ?? '')]);
$memberRule['is_current'] = false;
$memberRule['is_applied'] = false;
$memberRule['is_in_remote_range'] = isset($remoteUserIds[(string) ($memberRule['userid'] ?? '')]);
@@ -157,6 +165,7 @@ class WecomPromotionLogic
$pool['official_link_count'] = count($officialLinks);
$pool['legacy_link_count'] = $legacyCount;
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
$pool['member_admin_ids'] = array_values(array_diff($pool['member_admin_ids'], $pool['automation_config']['backup_member_admin_ids']));
$pool['member_rules'] = $memberRules;
$pool['operators'] = $operatorsByPool[(int) $pool['id']] ?? [];
$pool['operator_admin_ids'] = array_values(array_map(
@@ -168,6 +177,7 @@ class WecomPromotionLogic
$pool['can_manage_access'] = self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds);
$pool['can_delete'] = $pool['can_manage_access'];
$pool['dispatch_sync'] = $sync;
$pool['using_backup'] = $reception['using_backup'];
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
$pool['migration_state'] = count($officialLinks) > 1
? 'needs_resolution'
@@ -201,6 +211,7 @@ class WecomPromotionLogic
'member_options' => $memberOptions,
'operator_options' => $operatorOptions,
'department_options' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'automation_installed' => QywxPromotionConfig::installed(),
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
@@ -220,9 +231,49 @@ class WecomPromotionLogic
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo, $id);
$automation = null;
if (array_key_exists('automation_config', $params)) {
QywxPromotionConfig::assertInstalled();
if (!is_array($params['automation_config'])) {
throw new RuntimeException('获客配置格式不正确');
}
$automation = QywxPromotionConfig::normalize($params['automation_config']);
$automation = (new QywxPromotionMediaService())->validateConfig(
$automation, $adminId, $id > 0 ? QywxPromotionConfig::forPool($id) : []
);
if ($automation['tags_enabled']) {
$knownTags = [];
foreach ((new QywxPromotionContactApiService())->tagOptions()['tag_groups'] as $group) {
foreach ($group['tag'] as $tag) {
$knownTags[] = (string) $tag['id'];
}
}
if (array_diff($automation['tag_ids'], $knownTags) !== []) {
throw new RuntimeException('所选企业微信标签已删除或不在应用可用范围,请刷新标签后重新选择');
}
}
} elseif ($id > 0 && QywxPromotionConfig::installed()) {
$automation = QywxPromotionConfig::forPool($id);
}
$primaryIds = self::normalizePositiveIds((array) ($params['member_admin_ids'] ?? []));
$backupIds = $automation['backup_member_admin_ids'] ?? [];
if ($primaryIds === [] || array_intersect($primaryIds, $backupIds) !== []) {
throw new RuntimeException('请选择接待成员,且备用员工不能与接待成员重复');
}
$members = self::resolveMembers(array_merge($primaryIds, $backupIds), $adminId, $adminInfo, $id);
if ($automation !== null) {
$userIdByAdmin = array_column($members, 'userid', 'id');
$automation['backup_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $backupIds));
foreach ($automation['reception_schedule'] as &$slot) {
if (array_diff($slot['member_admin_ids'], $primaryIds) !== []) {
throw new RuntimeException('接待时段只能选择方案内的接待成员');
}
$slot['member_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $slot['member_admin_ids']));
}
unset($slot);
}
$userIds = array_values(array_column($members, 'userid'));
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members, $automation ?? []);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
@@ -288,6 +339,7 @@ class WecomPromotionLogic
$status,
$existingPool,
$members,
$automation,
$skipVerify,
$createdRemote,
$now,
@@ -333,6 +385,9 @@ class WecomPromotionLogic
]);
}
self::persistPoolMembers($id, $members, $now);
if ($automation !== null) {
QywxPromotionConfig::save($id, $automation);
}
if ($createdRemote) {
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
}
@@ -1061,7 +1116,7 @@ class WecomPromotionLogic
}
/** @param list<array{id:int,userid:string}> $members @return list<string> */
private static function eligibleSelectedUserIds(int $poolId, array $members): array
private static function eligibleSelectedUserIds(int $poolId, array $members, array $config = []): array
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
@@ -1089,7 +1144,7 @@ class WecomPromotionLogic
$rule['userid'] = $userId;
$candidates[] = $rule;
}
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now);
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now, $config);
if ($range['userids'] === []) {
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
}
@@ -1110,6 +1165,7 @@ class WecomPromotionLogic
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
'admin_id' => (int) $member['id'],
'userid' => $userId,
'enabled' => $existing['delete_time'] !== null ? 1 : (int) $existing['enabled'],
'delete_time' => null,
'update_time' => $now,
]);
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
return null;
}
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
private static function mutatePayOrderLinks(int $id, callable $mutation)
{
self::$error = '';
try {
return Db::transaction(static function () use ($id, $mutation) {
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
$result = $mutation();
if ($result === false) {
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
}
return $result;
});
} catch (\Throwable $e) {
if (self::$error === '') {
self::$error = $e->getMessage();
}
return false;
}
}
/**
* @param int[] $payOrderIds
*/
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
*/
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限移除收款关联';
return false;
}
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
$id = (int) $params['id'];
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作此订单';
return false;
}
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
self::$error = '已完成或已取消的订单不允许移除收款关联';
return false;
}
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
self::$error = '该收款记录未关联当前订单,请刷新后重试';
return false;
}
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
return false;
}
$oldPaidCents = (int) round((float) $order->paid * 100);
$removedCents = (int) round((float) $payOrder->amount * 100);
if ($removedCents < 0) {
self::$error = '收款金额异常,请先核对金额';
return false;
}
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
->where('pay_order_id', $payOrderId)->delete();
if ($deleted !== 1) {
throw new \RuntimeException('收款关联已变化,请刷新后重试');
}
$remainingIds = self::linkedPayOrderIdList($id);
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
$order->linked_pay_order_id = $remainingIds[0] ?? null;
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
$newPaidCents = $hasRefund
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
: $remainingPaidCents;
$order->paid = $newPaidCents / 100;
// 与详情的关联已付总额口径一致;订单 amount 不变。
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
$order->save();
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
), true);
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
*
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
$log->save();
} catch (\Throwable $e) {
if ($strict) {
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
}
// 非关键日志沿用历史容错行为
}
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'linkPayOrder' => ['id', 'pay_order_id'],
'unlinkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'],
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
];
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
{
return $this->only(['id', 'pay_order_id'])
->append('id', 'require|integer|gt:0')
->append('pay_order_id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])
@@ -8,6 +8,8 @@ use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionAutomationService;
use app\common\service\qywx\QywxPromotionEnqueueException;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
@@ -64,6 +66,9 @@ class QywxExternalContactCallbackController extends BaseApiController
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
try {
$this->handleChangeExternalContact($message);
} catch (QywxPromotionEnqueueException $e) {
// 未持久化不能假应答成功:外层返回500,让企微重新投递。
throw $e;
} catch (\Throwable $e) {
Log::error('qywx external contact callback: ' . $e->getMessage(), [
'exception' => $e,
@@ -96,6 +101,11 @@ class QywxExternalContactCallbackController extends BaseApiController
}
return response($content, 200, $headers);
} catch (QywxPromotionEnqueueException) {
// 异常调用栈可能携带原始事件参数;这里只记固定信息,不记录WelcomeCode。
Log::error('qywx external contact callback: promotion event persistence failed');
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
} catch (BadRequestException $e) {
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
@@ -120,6 +130,13 @@ class QywxExternalContactCallbackController extends BaseApiController
$failReason = (string) ($message['FailReason'] ?? '');
$eventTime = (int) ($message['CreateTime'] ?? 0);
// 只接管已保存新配置且可核验方案/成员的推广事件;这里无任何远端请求。
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
$event = $message instanceof Message ? $message->toArray() : (array) $message;
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event);
$auditEvent = $event;
unset($auditEvent['WelcomeCode']);
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
CustomerLogic::recordExternalContactEvent([
@@ -130,9 +147,14 @@ class QywxExternalContactCallbackController extends BaseApiController
'fail_reason' => $failReason,
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
'event_time' => $eventTime,
'raw' => $message,
'raw' => $auditEvent,
]);
if ($queued) {
// 常驻worker先发欢迎语,分钟补偿完成标签/备注、成员记账、范围与客户资料同步。
return;
}
if ($extId === '') {
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionMediaService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class QywxRefreshPromotionMedia extends Command
{
protected function configure()
{
$this->setName('qywx:refresh-promotion-media')->setDescription('预热/刷新已保存欢迎语使用的三天临时素材');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxPromotionMediaService())->refreshReferenced(100);
$output->writeln('QYWX_PROMOTION_MEDIA ' . json_encode($result));
return $result['failed'] > 0 ? 1 : 0;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionAutomationService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class QywxRetryPromotionAutomation extends Command
{
protected function configure()
{
$this->setName('qywx:retry-promotion-automation')
->setDescription('补偿推广标签/备注/资料同步;过期欢迎语仅记过期,不补发');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxPromotionAutomationService())->retryPending(100);
$output->writeln('QYWX_PROMOTION_RETRY ' . json_encode($result));
return $result['failed'] > 0 ? 1 : 0;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionAutomationService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
/** Supervisor/systemd常驻:只消费欢迎语,不运行慢速客户同步或素材上传。 */
class QywxWorkPromotionAutomation extends Command
{
protected function configure()
{
$this->setName('qywx:work-promotion-automation')
->setDescription('秒级消费推广欢迎语(需常驻;欢迎码仅20秒有效)')
->addOption('once', null, Option::VALUE_NONE, '只消费一轮');
}
protected function execute(Input $input, Output $output): int
{
$running = true;
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
pcntl_signal(SIGTERM, static function () use (&$running): void { $running = false; });
pcntl_signal(SIGINT, static function () use (&$running): void { $running = false; });
}
$service = new QywxPromotionAutomationService();
do {
try {
$result = $service->processWelcomes(100);
if ($result['selected'] > 0 || $input->getOption('once')) {
$output->writeln('QYWX_PROMOTION_WELCOME ' . json_encode($result));
}
} catch (\Throwable) {
// 不输出异常堆栈/SQL/请求,避免把短期凭证带进守护进程日志。
$output->writeln('QYWX_PROMOTION_WELCOME worker storage unavailable');
if ($input->getOption('once')) {
return 1;
}
}
if (!$input->getOption('once') && $running) {
usleep(250000);
}
} while (!$input->getOption('once') && $running);
return 0;
}
}
@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 推广客户自动化:短时欢迎语与可补偿关系动作分开消费。 */
class QywxPromotionAutomationService
{
private QywxPromotionContactApiService $api;
private QywxPromotionMediaService $media;
private QywxPromotionAutomationStore $store;
private QywxPromotionCodeCipher $cipher;
private $clock;
private const TERMINAL = ['sent', 'success', 'skipped', 'expired', 'uncertain', 'failed'];
public function __construct(
?QywxPromotionContactApiService $api = null,
?QywxPromotionMediaService $media = null,
?QywxPromotionAutomationStore $store = null,
?QywxPromotionCodeCipher $cipher = null,
?callable $clock = null
) {
$this->api = $api ?? new QywxPromotionContactApiService();
$this->media = $media ?? new QywxPromotionMediaService($this->api);
$this->store = $store ?? new QywxPromotionAutomationStore();
$this->cipher = $cipher ?? new QywxPromotionCodeCipher();
$this->clock = $clock ?? static fn (): int => time();
}
/**
* 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。
* 此处无网络请求,保证回调不等待客户详情、范围更新或素材上传。
*/
public function enqueueVerifiedEvent(array $event): bool
{
$change = (string) ($event['ChangeType'] ?? '');
if (!in_array($change, ['add_external_contact', 'add_half_external_contact'], true)) {
return false;
}
$state = trim((string) ($event['State'] ?? ''));
$linkId = trim((string) ($event['LinkId'] ?? $event['LinkID'] ?? ''));
$userid = trim((string) ($event['UserID'] ?? $event['UserId'] ?? ''));
$external = trim((string) ($event['ExternalUserID'] ?? $event['ExternalUserId'] ?? ''));
if (($state === '' && $linkId === '') || $userid === '' || $external === '') {
return false;
}
try {
if (!$this->store->installed()) {
return false;
}
$attribution = $this->store->attribution($state, $linkId, $userid);
if ($attribution === null) {
return false;
}
$now = $this->now();
$eventTime = max(0, (int) ($event['CreateTime'] ?? 0));
$code = (string) ($event['WelcomeCode'] ?? '');
$config = $attribution['config'];
$half = $change === 'add_half_external_contact';
$welcomeStatus = 'pending';
$reason = '';
if (($config['welcome_mode'] ?? 'default') !== 'channel') {
$welcomeStatus = 'skipped';
$reason = 'mode_' . ($config['welcome_mode'] ?? 'default');
} elseif ($code === '') {
$welcomeStatus = 'skipped';
$reason = 'missing_welcome_code';
} elseif (strlen($code) > 1024) {
$welcomeStatus = 'failed';
$reason = 'invalid_welcome_code';
} elseif ($eventTime <= 0 || $eventTime > $now + 5 || $eventTime + 20 <= $now) {
$welcomeStatus = 'expired';
$reason = 'welcome_window_elapsed_or_invalid_event_time';
}
$actions = [
'welcome' => self::action($welcomeStatus, $reason),
'tags' => self::action(!$half && !empty($config['tags_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'remark' => self::action(!$half && !empty($config['remark_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'description' => self::action(!$half && !empty($config['description_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'dispatch' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
'range' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
];
$corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', ''));
$this->store->enqueue([
'event_key' => hash('sha256', implode('|', [$corp, $change, $userid, $external, (string) $eventTime])),
'pool_id' => $attribution['pool_id'], 'member_admin_id' => $attribution['member_admin_id'],
'change_type' => $change, 'userid' => $userid, 'external_userid' => $external,
'event_time' => $eventTime, 'received_at' => $now,
'config_json' => self::json($config), 'actions_json' => self::json($actions),
'welcome_cipher' => $welcomeStatus === 'pending' ? $this->cipher->encrypt($code) : '',
'welcome_code_hash' => $code !== '' ? hash('sha256', $code) : '',
'welcome_expires_at' => $eventTime > 0 ? min($eventTime + 20, $now + 20) : 0,
'welcome_status' => $welcomeStatus, 'welcome_next_retry' => 0,
'status' => self::allTerminal($actions) ? 'done' : 'pending', 'next_retry' => 0,
'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, 'update_time' => $now,
]);
return true;
} catch (\Throwable) {
// 不附原异常,入库SQL可能包含密文和配置;回调层返回500触发企微重试。
throw new QywxPromotionEnqueueException('推广自动化事件未能持久化,请检查数据库迁移和私有存储');
}
}
/** 常驻秒级worker仅处理欢迎语,不被范围/客户同步或大文件上传阻塞。 */
public function processWelcomes(int $limit = 100): array
{
return $this->consume('welcome', $limit);
}
/** 分钟补偿:过期欢迎语只记过期,绝不尝试补发。 */
public function retryPending(int $limit = 100): array
{
return $this->consume('metadata', $limit);
}
public static function selectWelcome(array $config, int $eventTime): array
{
if (!empty($config['welcome_schedule_enabled'])) {
foreach ((array) ($config['welcome_schedule'] ?? []) as $slot) {
if (QywxPromotionConfig::matches($slot, $eventTime)) {
return ['text' => (string) ($slot['text'] ?? ''), 'attachments' => (array) ($slot['attachments'] ?? [])];
}
}
}
return ['text' => (string) ($config['welcome']['text'] ?? ''), 'attachments' => (array) ($config['welcome']['attachments'] ?? [])];
}
private function consume(string $lane, int $limit): array
{
$result = ['selected' => 0, 'processed' => 0, 'failed' => 0];
foreach ($this->store->due($lane, $this->now(), $limit) as $id) {
++$result['selected'];
try {
$row = $this->store->claim($id, $lane, $this->now());
if ($row === null) {
continue;
}
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
$config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR);
if (!self::terminal($actions['welcome']['status'])) {
if ($lane === 'welcome') {
$this->welcome($row, $actions, $config);
} else {
$running = $actions['welcome']['status'] === 'running';
$this->transition($row, $actions, 'welcome', $running ? 'uncertain' : 'expired',
$running ? 'worker_interrupted_after_send_started' : 'welcome_worker_not_available_in_window');
}
}
if ($lane !== 'welcome') {
$this->metadata($row, $actions, $config);
}
$row['lock_until'] = 0;
$row['update_time'] = $this->now();
$this->store->save($row);
++$result['processed'];
} catch (\Throwable) {
// 失去DB/租约时保留running状态;欢迎语恢复时视为不确定,防止重复推送。
++$result['failed'];
}
}
return $result;
}
private function welcome(array &$row, array &$actions, array $config): void
{
if ($actions['welcome']['status'] === 'running') {
$this->transition($row, $actions, 'welcome', 'uncertain', 'worker_interrupted_after_send_started');
return;
}
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed');
return;
}
$sendStarted = false;
try {
$message = self::selectWelcome($config, (int) $row['event_time']);
$text = $message['text'];
if (str_contains($text, '{customer_name}') || str_contains($text, '{employee_name}') || str_contains($text, '{add_time}')) {
$names = $this->names($row, $text, true);
$text = QywxPromotionConfig::render($text, $names['customer'], $names['employee'], (int) $row['event_time'], 1200);
}
$truncated = strlen($text) > 4000;
$text = mb_strcut($text, 0, 4000, 'UTF-8');
$attachments = $this->media->materialize($message['attachments'], $config);
$code = $this->cipher->decrypt($row['welcome_cipher']);
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed_during_prepare');
return;
}
// running先持久化:如果HTTP成功后进程/DB断开,恢复时绝不再次使用同一code。
$this->transition($row, $actions, 'welcome', 'running', 'send_started');
$sendStarted = true;
try {
$this->api->sendWelcome($code, $text, $attachments);
$this->transition($row, $actions, 'welcome', 'sent', $truncated ? 'sent_text_truncated_4000_bytes' : 'sent');
} catch (QywxPromotionContactApiException $e) {
if ($e->uncertain) {
$this->transition($row, $actions, 'welcome', 'uncertain', 'network_result_unknown_do_not_resend', $e->getCode());
} elseif ($e->getCode() === 41051) {
$this->transition($row, $actions, 'welcome', 'skipped', 'welcome_code_already_consumed', 41051);
} else {
$this->welcomeRetry($row, $actions, 'explicit_api_rejection', $e->getCode());
}
} catch (\Throwable) {
$this->transition($row, $actions, 'welcome', 'uncertain', 'send_or_persist_result_unknown_do_not_resend');
} finally {
unset($code);
}
} catch (\Throwable $e) {
// 准备阶段没有执行发送,可以安全重试,且不会把错误原文/欢迎码写日志。
if ($sendStarted || $actions['welcome']['status'] === 'running') {
throw $e;
}
$this->welcomeRetry($row, $actions, 'prepare_failed_check_media_credentials_or_key', (int) $e->getCode());
}
}
private function welcomeRetry(array &$row, array &$actions, string $reason, int $code): void
{
$expired = (int) $row['welcome_expires_at'] <= $this->now() + 2;
$this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1);
}
private function metadata(array &$row, array &$actions, array $config): void
{
$names = null;
foreach (['tags', 'remark', 'description', 'dispatch', 'range', 'sync'] as $name) {
if (self::terminal($actions[$name]['status']) || (int) ($actions[$name]['next_retry'] ?? 0) > $this->now()) {
continue;
}
$this->transition($row, $actions, $name, 'running', 'started');
try {
switch ($name) {
case 'tags':
$this->api->markTags($row['userid'], $row['external_userid'], (array) $config['tag_ids']);
break;
case 'remark':
$names = $names ?? $this->names($row, (string) $config['remark_template'], false);
$remark = QywxPromotionConfig::render($config['remark_template'], $names['customer'], $names['employee'], (int) $row['event_time'], 20);
$this->api->remark($row['userid'], $row['external_userid'], ['remark' => $remark]);
break;
case 'description':
$this->api->remark($row['userid'], $row['external_userid'], ['description' => (string) $config['description']]);
break;
case 'dispatch':
$this->store->dispatch($row);
break;
case 'range':
$this->store->syncRange($row);
break;
case 'sync':
$this->store->syncCustomer($row);
break;
}
$this->transition($row, $actions, $name, 'success', 'completed');
} catch (\Throwable $e) {
$attempt = (int) $actions[$name]['attempts'];
$failed = $attempt >= 10;
$this->transition($row, $actions, $name, $failed ? 'failed' : 'retry',
$failed ? 'retry_limit_reached' : 'action_failed', (int) $e->getCode(),
$this->now() + min(3600, 15 * (2 ** min(8, $attempt))));
}
}
}
private function names(array $row, string $template, bool $welcome): array
{
$names = ['customer' => '', 'employee' => ''];
try {
$names = $this->store->localNames($row);
} catch (\Throwable) {
// 本地资料失败不妨碍欢迎语使用明确的文案兜底。
}
$budget = fn (): bool => !$welcome || (int) $row['welcome_expires_at'] > $this->now() + 7;
if (str_contains($template, '{customer_name}') && $names['customer'] === ''
&& $row['change_type'] !== 'add_half_external_contact' && $budget()) {
try {
$detail = $this->api->getExternalContact($row['external_userid']);
$names['customer'] = (string) ($detail['external_contact']['name'] ?? '');
} catch (\Throwable) {
}
}
if (str_contains($template, '{employee_name}') && $budget()) {
try {
$user = $this->api->getUser($row['userid']);
$names['employee'] = trim((string) ($user['name'] ?? '')) ?: $names['employee'];
} catch (\Throwable) {
// 通讯录姓名接口权限不足时回退后台成员称呼。
}
}
$names['customer'] = $names['customer'] !== '' ? $names['customer'] : '您';
$names['employee'] = $names['employee'] !== '' ? $names['employee'] : '客户顾问';
return $names;
}
private function transition(array &$row, array &$actions, string $name, string $status, string $reason, int $code = 0, int $retryAt = 0): void
{
$now = $this->now();
$action = $actions[$name];
if ($status === 'running' || ($name === 'welcome' && $status === 'retry' && $action['status'] !== 'running')) {
++$action['attempts'];
}
$action = array_replace($action, ['status' => $status, 'reason' => $reason, 'error_code' => $code,
'next_retry' => $retryAt, 'update_time' => $now]);
if (self::terminal($status)) {
$action['finished_at'] = $now;
}
$actions[$name] = $action;
if ($name === 'welcome') {
$row['welcome_status'] = $status;
$row['welcome_next_retry'] = $retryAt;
if (self::terminal($status)) {
$row['welcome_cipher'] = '';
}
}
$row['status'] = self::allTerminal($actions) ? 'done' : 'pending';
$retry = [];
foreach ($actions as $key => $value) {
if ($key !== 'welcome' && !self::terminal($value['status'])) {
$retry[] = (int) ($value['next_retry'] ?? 0);
}
}
$row['next_retry'] = $retry === [] ? 0 : min($retry);
$row['actions_json'] = self::json($actions);
$row['update_time'] = $now;
$this->store->save($row, ['action' => $name, 'status' => $status, 'attempt' => $action['attempts'],
'reason' => $reason, 'error_code' => $code, 'create_time' => $now]);
}
private static function action(string $status, string $reason = ''): array
{
return ['status' => $status, 'reason' => $status === 'pending' ? '' : $reason, 'attempts' => 0, 'error_code' => 0, 'next_retry' => 0];
}
private static function allTerminal(array $actions): bool
{
foreach ($actions as $action) {
if (!self::terminal($action['status'])) {
return false;
}
}
return true;
}
private static function terminal(string $status): bool
{
return in_array($status, self::TERMINAL, true);
}
private static function json(array $value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
private function now(): int
{
return (int) ($this->clock)();
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use app\adminapi\logic\qywx\CustomerLogic;
use RuntimeException;
use think\facade\Db;
/** DB 存储与既有同步边界;单测替换此类后不初始化业务数据库。 */
class QywxPromotionAutomationStore
{
public function installed(): bool
{
return QywxPromotionConfig::installed();
}
/** State只能定位,必须再核验真实方案、正式官方链接与实际成员关系。 */
public function attribution(string $state, string $linkId, string $userId): ?array
{
if ($state !== '') {
if (!preg_match('/^zyt_pool:([1-9][0-9]{0,9})$/', $state, $match)) {
return null;
}
$poolId = (int) $match[1];
} elseif ($linkId !== '') {
$poolId = (int) Db::name('qywx_promotion_link')->where('remote_link_id', $linkId)
->where('remote_status', 1)->whereNull('delete_time')->value('pool_id');
} else {
return null;
}
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->where('status', 1)->whereNull('delete_time')->find();
$member = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', $userId)
->whereNull('delete_time')->find();
$links = Db::name('qywx_promotion_link')->where('pool_id', $poolId)->where('remote_status', 1)
->where('remote_link_id', '<>', '')->whereNull('delete_time');
if ($linkId !== '') {
$links->where('remote_link_id', $linkId);
}
// 不用 enabled/当日额度验证:真实回调可能比排班切换晚到,不能漏掉已归属该方案的成员。
if (!$pool || !$member || !$links->find()) {
return null;
}
$configRow = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
if (!$configRow) {
// 尚未保存新增配置的旧方案仍保持原同步链路,不强制依赖新worker。
return null;
}
return ['pool_id' => $poolId, 'member_admin_id' => (int) $member['admin_id'],
'config' => QywxPromotionConfig::decode($configRow['config_json'])];
}
public function enqueue(array $row): int
{
$row['welcome_code_hash'] = $row['welcome_code_hash'] ?: null;
// 同一code可能同时出现在half/add:唯一索引把欢迎语消费权固定在第一次任务。
for ($attempt = 0; $attempt < 2; $attempt++) {
if ($row['welcome_code_hash'] !== null
&& Db::name('qywx_promotion_automation_task')->where('welcome_code_hash', $row['welcome_code_hash'])->find()) {
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
$actions['welcome']['status'] = 'skipped';
$actions['welcome']['reason'] = 'same_welcome_code_already_queued';
$row['actions_json'] = json_encode($actions, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$row['welcome_status'] = 'skipped';
$row['welcome_cipher'] = '';
$row['welcome_code_hash'] = null;
$pending = array_filter($actions, static fn (array $a): bool => in_array($a['status'], ['pending', 'retry', 'running'], true));
$row['status'] = $pending === [] ? 'done' : 'pending';
}
try {
return (int) Db::name('qywx_promotion_automation_task')->insertGetId($row);
} catch (\Throwable $e) {
$existing = Db::name('qywx_promotion_automation_task')->where('event_key', $row['event_key'])->value('id');
if ($existing) {
return (int) $existing;
}
if ($attempt === 1 || $row['welcome_code_hash'] === null) {
throw $e;
}
}
}
throw new RuntimeException('推广任务入队失败');
}
/** 两条消费通道:常驻worker只发欢迎语,分钟任务不锁住尚有时效的欢迎语任务。 */
public function due(string $lane, int $now, int $limit): array
{
$query = Db::name('qywx_promotion_automation_task')->where('status', '<>', 'done')
->where('lock_until', '<=', $now);
if ($lane === 'welcome') {
$query->whereIn('welcome_status', ['pending', 'retry', 'running'])->where('welcome_next_retry', '<=', $now)
->order('welcome_expires_at', 'asc');
} else {
$query->where('next_retry', '<=', $now)->where(function ($q) use ($now) {
$q->whereNotIn('welcome_status', ['pending', 'retry', 'running'])
->whereOr('welcome_expires_at', '<=', $now);
})->order('id', 'asc');
}
return array_map('intval', $query->limit(max(1, min(500, $limit)))->column('id'));
}
public function claim(int $id, string $lane, int $now): ?array
{
return Db::transaction(function () use ($id, $lane, $now): ?array {
$row = Db::name('qywx_promotion_automation_task')->where('id', $id)->lock(true)->find();
if (!$row || $row['status'] === 'done' || (int) $row['lock_until'] > $now) {
return null;
}
$pendingWelcome = in_array($row['welcome_status'], ['pending', 'retry', 'running'], true);
if (($lane === 'welcome' && (!$pendingWelcome || (int) $row['welcome_next_retry'] > $now))
|| ($lane !== 'welcome' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))) {
return null;
}
$row['lock_token'] = bin2hex(random_bytes(16));
$row['lock_until'] = $now + ($lane === 'welcome' ? 30 : 600);
Db::name('qywx_promotion_automation_task')->where('id', $id)->update([
'lock_token' => $row['lock_token'], 'lock_until' => $row['lock_until'], 'update_time' => $now,
]);
return $row;
});
}
public function save(array $row, ?array $log = null): void
{
Db::transaction(function () use ($row, $log): void {
$fields = array_intersect_key($row, array_flip([
'actions_json', 'welcome_status', 'welcome_cipher', 'welcome_next_retry', 'status',
'next_retry', 'lock_until', 'update_time',
]));
// 租约令牌校验不能依赖affected rows:同秒同值更新在MySQL可能返回0。
$current = Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->lock(true)->find();
if (!$current || !hash_equals((string) $current['lock_token'], (string) $row['lock_token'])) {
throw new RuntimeException('推广任务处理租约已失效');
}
Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->update($fields);
if ($log !== null) {
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
}
});
}
public function localNames(array $task): array
{
return [
'customer' => (string) (Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('name') ?? ''),
'employee' => (string) (Db::name('admin')->where('id', $task['member_admin_id'])->value('name') ?? ''),
];
}
public function dispatch(array $task): void
{
$result = QywxPromotionMemberSchedulerService::recordFromState('zyt_pool:' . $task['pool_id'],
$task['userid'], $task['external_userid'], (int) $task['event_time'], 'external_contact');
if (!in_array($result['status'] ?? '', ['counted', 'counted_blocked', 'counted_stale', 'duplicate'], true)) {
throw new RuntimeException('推广成员记账未完成');
}
}
public function syncRange(array $task): void
{
// range服务自身有持久重试与版本保护;此调用负责触发。
(new QywxPromotionRangeSyncService())->syncPool((int) $task['pool_id']);
}
public function syncCustomer(array $task): void
{
$started = time();
CustomerLogic::upsertSingleExternalContactFromApi($task['external_userid']);
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
if ($updated < $started) {
throw new RuntimeException('推广客户资料尚未同步到本地');
}
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 一次性欢迎码仅加密短存;密钥不写数据库。多节点须显式共享环境密钥。 */
class QywxPromotionCodeCipher
{
private ?string $key;
public function __construct(?string $key = null)
{
$this->key = $key;
}
public function encrypt(string $code): string
{
$iv = random_bytes(12);
$tag = '';
$encrypted = openssl_encrypt($code, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($encrypted === false) {
throw new RuntimeException('无法加密欢迎码');
}
return base64_encode($iv . $tag . $encrypted);
}
public function decrypt(string $cipher): string
{
$value = base64_decode($cipher, true);
if ($value === false || strlen($value) <= 28) {
throw new RuntimeException('欢迎码密文无效');
}
$code = openssl_decrypt(substr($value, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($value, 0, 12), substr($value, 12, 16));
if ($code === false) {
throw new RuntimeException('欢迎码解密失败,请核对工作进程密钥');
}
return $code;
}
private function key(): string
{
if ($this->key !== null) {
if (strlen($this->key) < 32) {
throw new RuntimeException('欢迎码加密密钥至少32字符');
}
return hash('sha256', $this->key, true);
}
$configured = (string) config('qywx_promotion_automation.encryption_key', '');
if ($configured !== '') {
$this->key = $configured;
return $this->key();
}
$directory = root_path('runtime') . 'qywx_promotion_private';
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('无法创建欢迎码私有密钥目录');
}
$path = $directory . DIRECTORY_SEPARATOR . 'welcome.key';
$stream = @fopen($path, 'c+b');
if ($stream === false) {
throw new RuntimeException('无法读取欢迎码私有密钥');
}
try {
// 首次回调和多个worker可能同时启动;读写均持锁,避免读取尚未写完的密钥。
if (!flock($stream, LOCK_EX)) {
throw new RuntimeException('无法锁定欢迎码私有密钥');
}
@chmod($path, 0600);
$key = trim((string) stream_get_contents($stream));
if ($key === '') {
$key = bin2hex(random_bytes(32));
rewind($stream);
if (fwrite($stream, $key) !== strlen($key) || !fflush($stream)) {
throw new RuntimeException('无法保存欢迎码私有密钥');
}
}
if (!preg_match('/^[0-9a-f]{64}$/', $key)) {
throw new RuntimeException('欢迎码私有密钥损坏,请恢复原密钥');
}
$this->key = $key;
} finally {
flock($stream, LOCK_UN);
fclose($stream);
}
return $this->key();
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
use think\facade\Db;
/** 获客方案配置。时间规则统一使用 Asia/Shanghai,结束时间不包含在时段内。 */
class QywxPromotionConfig
{
public static function defaults(): array
{
return [
'reception_mode' => 'always', 'reception_schedule' => [],
'backup_member_admin_ids' => [], 'backup_userids' => [],
'tags_enabled' => false, 'tag_ids' => [],
'remark_enabled' => false, 'remark_template' => '{customer_name}',
'description_enabled' => false, 'description' => '',
'welcome_mode' => 'default', 'welcome' => ['text' => '', 'attachments' => []],
'welcome_schedule_enabled' => false, 'welcome_schedule' => [],
];
}
public static function installed(): bool
{
try {
return Db::name('qywx_promotion_config')->getFields() !== [];
} catch (\Throwable $error) {
// 仅旧部署未建表时回退。数据库故障不能退回全天路由、忽略排班配置。
if (str_contains($error->getMessage(), '42S02')
|| str_contains($error->getMessage(), '1146')
|| str_contains($error->getMessage(), 'no such table')) {
return false;
}
throw $error;
}
}
public static function assertInstalled(): void
{
if (!self::installed()) {
throw new RuntimeException('请先执行 server/sql/1.9.20260831/add_wecom_promotion_automation.sql 安装获客配置与任务表');
}
}
public static function decode(mixed $json): array
{
$value = is_array($json) ? $json : json_decode((string) $json, true);
return array_replace(self::defaults(), is_array($value) ? $value : []);
}
public static function forPool(int $poolId): array
{
if (!self::installed()) {
return self::defaults();
}
return self::decode(Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json'));
}
public static function save(int $poolId, array $config): void
{
self::assertInstalled();
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
$data = ['config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'update_time' => time()];
if ($row) {
Db::name('qywx_promotion_config')->where('pool_id', $poolId)->update($data);
} else {
Db::name('qywx_promotion_config')->insert($data + ['pool_id' => $poolId, 'create_time' => time()]);
}
}
/** 不接受浏览器提供的 userid;成员归属必须经过现有后台数据权限校验后再绑定。 */
public static function normalize(array $input): array
{
$config = self::defaults();
foreach (['tags_enabled', 'remark_enabled', 'description_enabled', 'welcome_schedule_enabled'] as $key) {
$value = $input[$key] ?? false;
if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) {
throw new RuntimeException('配置开关格式不正确');
}
$config[$key] = in_array($value, [true, 1, '1'], true);
}
$config['reception_mode'] = self::choice($input['reception_mode'] ?? 'always', ['always', 'scheduled']);
$config['welcome_mode'] = self::choice($input['welcome_mode'] ?? 'default', ['default', 'channel', 'none']);
$config['backup_member_admin_ids'] = self::ids($input['backup_member_admin_ids'] ?? []);
$config['reception_schedule'] = self::schedule($input['reception_schedule'] ?? [], true);
if ($config['reception_mode'] === 'scheduled' && $config['reception_schedule'] === []) {
throw new RuntimeException('自动上下线模式至少需要一个接待时段');
}
if ($config['reception_mode'] === 'scheduled' && $config['backup_member_admin_ids'] === []) {
throw new RuntimeException('自动上下线须配置备用员工,避免非接待时段官方链接仍路由给原成员');
}
if (!is_array($input['tag_ids'] ?? [])) {
throw new RuntimeException('客户标签格式不正确');
}
if (count($input['tag_ids'] ?? []) > 1) {
// 兼容旧数组字段,但不能默默截断旧方案多选;编辑时须由用户重新确认单个标签。
throw new RuntimeException('推广方案仅支持单个客户标签,请重新选择一个标签');
}
$tags = [];
foreach ($input['tag_ids'] ?? [] as $tag) {
if (!is_string($tag) || trim($tag) === '' || strlen($tag) > 128) {
throw new RuntimeException('企业微信标签 ID 不正确');
}
$tags[] = trim($tag);
}
$config['tag_ids'] = array_values(array_unique($tags));
if ($config['tags_enabled'] && count($config['tag_ids']) !== 1) {
throw new RuntimeException('启用客户标签时请选择一个企业微信标签');
}
$config['remark_template'] = self::text($input['remark_template'] ?? '{customer_name}', 200, '客户备注模板');
$config['description'] = self::text($input['description'] ?? '', 150, '客户描述');
if ($config['remark_enabled'] && $config['remark_template'] === '') {
throw new RuntimeException('请填写客户备注模板');
}
if ($config['description_enabled'] && $config['description'] === '') {
throw new RuntimeException('请填写客户描述');
}
$config['welcome'] = self::message($input['welcome'] ?? []);
$config['welcome_schedule'] = self::schedule($input['welcome_schedule'] ?? [], false);
if ($config['welcome_mode'] === 'channel') {
self::assertMessage($config['welcome']);
if ($config['welcome_schedule_enabled'] && $config['welcome_schedule'] === []) {
throw new RuntimeException('请添加分时段欢迎语');
}
}
return $config;
}
public static function matches(array $slot, int $timestamp): bool
{
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
$minute = $date->format('H:i');
$start = (string) ($slot['start'] ?? '');
$end = (string) ($slot['end'] ?? '');
$weekdays = array_map('intval', (array) ($slot['weekdays'] ?? []));
$day = (int) $date->format('N');
if ($start < $end) {
return in_array($day, $weekdays, true) && $minute >= $start && $minute < $end;
}
// 跨午夜时段归属于开始日期,例如周一 22:00—02:00 包含周二凌晨。
return ($minute >= $start && in_array($day, $weekdays, true))
|| ($minute < $end && in_array($day === 1 ? 7 : $day - 1, $weekdays, true));
}
public static function render(string $template, string $customer, string $employee, int $timestamp, int $limit): string
{
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
return mb_substr(strtr($template, [
'{customer_name}' => $customer, '{employee_name}' => $employee,
'{add_time}' => $date->format('Y-m-d'),
]), 0, $limit);
}
private static function schedule(mixed $value, bool $reception): array
{
if (!is_array($value) || count($value) > 30) {
throw new RuntimeException('每类时间规则最多配置 30 条');
}
$rows = [];
foreach ($value as $row) {
if (!is_array($row)) {
throw new RuntimeException('时间规则格式不正确');
}
$days = self::ids($row['weekdays'] ?? []);
if ($days === [] || max($days) > 7) {
throw new RuntimeException('请选择星期一至星期日');
}
$start = (string) ($row['start'] ?? '');
$end = (string) ($row['end'] ?? '');
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $start)
|| !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $end) || $start === $end) {
throw new RuntimeException('时段起止时间必须不同,格式为 HH:mm;全天在线请使用全天模式');
}
$clean = ['weekdays' => $days, 'start' => $start, 'end' => $end];
if ($reception) {
$clean['member_admin_ids'] = self::ids($row['member_admin_ids'] ?? []);
if ($clean['member_admin_ids'] === []) {
throw new RuntimeException('每个接待时段至少选择一名接待成员');
}
} else {
$clean += self::message($row);
self::assertMessage($clean);
}
$rows[] = $clean;
}
if (!$reception) {
// 分时欢迎语不可重叠,避免靠数组顺序决定发送内容。
$occupied = [];
foreach ($rows as $row) {
[$sh, $sm] = array_map('intval', explode(':', $row['start']));
[$eh, $em] = array_map('intval', explode(':', $row['end']));
$from = $sh * 60 + $sm;
$to = $eh * 60 + $em;
$duration = ($to - $from + 1440) % 1440;
foreach ($row['weekdays'] as $day) {
for ($i = 0; $i < $duration; $i++) {
$key = (($day - 1) * 1440 + $from + $i) % 10080;
if (isset($occupied[$key])) {
throw new RuntimeException('分时段欢迎语的时间范围不能重叠');
}
$occupied[$key] = true;
}
}
}
}
return $rows;
}
public static function message(mixed $value): array
{
if (!is_array($value) || !is_array($value['attachments'] ?? [])) {
throw new RuntimeException('欢迎语格式不正确');
}
$attachments = array_values($value['attachments'] ?? []);
if (count($attachments) > 9) {
throw new RuntimeException('欢迎语最多添加 9 个附件');
}
// 附件的详细格式与素材权限由 API/素材服务进一步验证。
foreach ($attachments as $attachment) {
if (!is_array($attachment) || !in_array($attachment['msgtype'] ?? '', ['image', 'link', 'miniprogram', 'video', 'file'], true)) {
throw new RuntimeException('不支持的欢迎语附件类型');
}
}
$text = self::text($value['text'] ?? '', 1200, '欢迎语');
if (strlen($text) > 4000) {
throw new RuntimeException('欢迎语不能超过 4000 个 UTF-8 字节(表情通常占 4 字节)');
}
return ['text' => $text, 'attachments' => $attachments];
}
private static function assertMessage(array $message): void
{
if (trim($message['text']) === '' && $message['attachments'] === []) {
throw new RuntimeException('渠道欢迎语必须包含文字或附件');
}
}
private static function choice(mixed $value, array $choices): string
{
if (!is_string($value) || !in_array($value, $choices, true)) {
throw new RuntimeException('不支持的配置模式');
}
return $value;
}
private static function ids(mixed $value): array
{
if (!is_array($value) || count($value) > 500) {
throw new RuntimeException('成员或星期列表格式不正确');
}
$result = [];
foreach ($value as $id) {
if ((!is_int($id) && !(is_string($id) && ctype_digit($id))) || (int) $id <= 0) {
throw new RuntimeException('成员或星期 ID 必须是正整数');
}
$result[] = (int) $id;
}
return array_values(array_unique($result));
}
private static function text(mixed $value, int $limit, string $label): string
{
if (!is_string($value) || mb_strlen($value) > $limit) {
throw new RuntimeException($label . '不能超过 ' . $limit . ' 个字符');
}
return trim($value);
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 不保存 Guzzle 原异常,避免请求 URL / token / welcome_code 进入日志。 */
class QywxPromotionContactApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, public bool $uncertain = false)
{
parent::__construct($message, $code);
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use RuntimeException;
use think\facade\Cache;
/** 客户联系可调用自建应用;不使用对外收款应用 Secret。 */
class QywxPromotionContactApiService
{
private const PROMOTION_TAG_GROUP = '推广渠道';
private Client $client;
private string $corpId;
private string $secret;
private $tokenResolver;
public function __construct(?Client $client = null, ?callable $tokenResolver = null)
{
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''))
?: trim((string) config('pay.wechat_work.corp_id', ''));
// 获客回调的 WelcomeCode 应交由相同的可调用应用发送。专用覆盖仅用于明确配置的同应用。
$this->secret = trim((string) config('qywx_promotion_automation.contact_secret', ''))
?: (trim((string) config('qywx_customer_acquisition.secret', ''))
?: trim((string) config('pay.wechat_work.customer_contact_secret', '')));
$caPath = dirname(__DIR__, 4) . '/cacert.pem';
$this->client = $client ?? new Client([
'base_uri' => 'https://qyapi.weixin.qq.com/',
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
'verify' => is_file($caPath) ? $caPath : true, 'allow_redirects' => false,
'headers' => ['Accept' => 'application/json'],
]);
$this->tokenResolver = $tokenResolver;
}
public function credentialFingerprint(): string
{
return hash('sha256', $this->corpId . '|' . $this->secret);
}
public function tagOptions(): array
{
$result = $this->request('POST', 'externalcontact/get_corp_tag_list', []);
$groups = [];
foreach ((array) ($result['tag_group'] ?? []) as $group) {
if (!is_array($group) || !empty($group['deleted'])) {
continue;
}
$tags = [];
foreach ((array) ($group['tag'] ?? []) as $tag) {
if (is_array($tag) && empty($tag['deleted']) && !empty($tag['id'])) {
$tags[] = ['id' => (string) $tag['id'], 'name' => (string) ($tag['name'] ?? '')];
}
}
$groups[] = ['group_id' => (string) ($group['group_id'] ?? ''),
'group_name' => (string) ($group['group_name'] ?? ''), 'tag' => $tags];
}
return ['tag_groups' => $groups];
}
/**
* 自定义企业客户标签:只写固定分组,先查重;创建结果不确定时只读回,不再次创建。
* @return array{tag:array{id:string,name:string},group_id:string,group_name:string,reused:bool}
* @see https://developer.work.weixin.qq.com/document/path/92117
*/
public function createTag(string $name): array
{
if (!mb_check_encoding($name, 'UTF-8') || preg_match('/[\p{C}\x{2028}\x{2029}]/u', $name)) {
throw new RuntimeException('标签名称不能包含控制字符或不可见格式字符');
}
$name = preg_replace('/^\p{Z}+|\p{Z}+$/u', '', trim($name)) ?? '';
if ($name === '' || mb_strlen($name, 'UTF-8') > 30) {
throw new RuntimeException('标签名称须为 1-30 个字符');
}
$groups = $this->tagOptions()['tag_groups'];
$existing = $this->findPromotionTag($groups, $name, true);
if ($existing !== null) {
return $existing;
}
$body = ['tag' => [['name' => $name]]];
foreach ($groups as $group) {
if (($group['group_name'] ?? '') === self::PROMOTION_TAG_GROUP && ($group['group_id'] ?? '') !== '') {
$body['group_id'] = $group['group_id'];
break;
}
}
if (!isset($body['group_id'])) {
// 官方保证同名分组存在时向该组添加,不额外创建同名分组;空分组不受支持。
$body['group_name'] = self::PROMOTION_TAG_GROUP;
}
$failure = null;
try {
$response = $this->request('POST', 'externalcontact/add_corp_tag', $body, true);
$created = $this->findPromotionTag([(array) ($response['tag_group'] ?? [])], $name, false);
if ($created !== null) {
return $created;
}
} catch (QywxPromotionContactApiException $error) {
$failure = $error;
}
// 同名并发、上游缺失返回ID或网络中断,均只读回一次。永不构造本地伪标签ID。
try {
$confirmed = $this->findPromotionTag($this->tagOptions()['tag_groups'], $name, true);
if ($confirmed !== null) {
return $confirmed;
}
} catch (\Throwable) {
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
}
if ($failure !== null && !$failure->uncertain) {
throw new RuntimeException('企业微信标签创建失败[' . $failure->getCode() . '],请检查客户联系应用权限或标签额度', $failure->getCode());
}
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
}
private function findPromotionTag(array $groups, string $name, bool $reused): ?array
{
foreach ($groups as $group) {
if (!is_array($group) || !empty($group['deleted'])
|| ($group['group_name'] ?? '') !== self::PROMOTION_TAG_GROUP
|| !is_string($group['group_id'] ?? null) || $group['group_id'] === '') {
continue;
}
foreach ((array) ($group['tag'] ?? []) as $tag) {
if (is_array($tag) && empty($tag['deleted']) && ($tag['name'] ?? '') === $name
&& is_string($tag['id'] ?? null) && $tag['id'] !== '') {
return ['tag' => ['id' => $tag['id'], 'name' => $name],
'group_id' => $group['group_id'], 'group_name' => self::PROMOTION_TAG_GROUP, 'reused' => $reused];
}
}
}
return null;
}
public function getExternalContact(string $externalUserId, string $cursor = ''): array
{
$query = ['external_userid' => $externalUserId];
if ($cursor !== '') {
$query['cursor'] = $cursor;
}
return $this->request('GET', 'externalcontact/get', $query);
}
public function getUser(string $userId): array
{
return $this->request('GET', 'user/get', ['userid' => $userId]);
}
public function markTags(string $userId, string $externalUserId, array $tagIds): void
{
if ($tagIds === []) {
throw new RuntimeException('企业标签不能为空');
}
$this->request('POST', 'externalcontact/mark_tag', [
'userid' => $userId, 'external_userid' => $externalUserId,
'add_tag' => array_values(array_unique($tagIds)),
]);
}
public function remark(string $userId, string $externalUserId, array $fields): void
{
$body = ['userid' => $userId, 'external_userid' => $externalUserId];
foreach (['remark' => 20, 'description' => 150] as $field => $limit) {
if (isset($fields[$field]) && $fields[$field] !== '') {
if (!is_string($fields[$field]) || mb_strlen($fields[$field]) > $limit) {
throw new RuntimeException('客户备注或描述长度不正确');
}
$body[$field] = $fields[$field];
}
}
if (count($body) === 2) {
throw new RuntimeException('没有启用需要修改的备注字段');
}
$this->request('POST', 'externalcontact/remark', $body);
}
public function sendWelcome(string $code, string $text, array $attachments): void
{
if ($code === '' || strlen($code) > 1024 || strlen($text) > 4000
|| count($attachments) > 9 || ($text === '' && $attachments === [])) {
throw new RuntimeException('欢迎语内容或欢迎码格式不正确');
}
$body = ['welcome_code' => $code];
if ($text !== '') {
$body['text'] = ['content' => $text];
}
if ($attachments !== []) {
$body['attachments'] = array_values($attachments);
}
$this->request('POST', 'externalcontact/send_welcome_msg', $body, true);
}
/** 仅由私有素材服务传入受控文件流,不接受 URL 或请求提供的任意路径。 */
public function uploadMedia($stream, string $type, string $filename): array
{
if (!is_resource($stream) || !in_array($type, ['image', 'video', 'file'], true)) {
throw new RuntimeException('临时素材类型或文件流不正确');
}
return $this->request('POST', 'media/upload', ['type' => $type], false, [
'multipart' => [['name' => 'media', 'contents' => Utils::streamFor($stream), 'filename' => $filename]],
'timeout' => 45,
]);
}
/** 仅明确的 token 失效响应允许重取一次;欢迎语/标签创建的网络异常不能直接重发。 */
private function request(string $method, string $path, array $body, bool $nonIdempotent = false, array $extra = [], bool $retried = false): array
{
$token = $this->accessToken();
$options = $extra + ['query' => ['access_token' => $token]];
if ($method === 'GET' || isset($extra['multipart'])) {
$options['query'] += $body;
} else {
$options['json'] = $body === [] ? (object) [] : $body;
}
try {
$response = $this->client->request($method, 'cgi-bin/' . $path, $options);
} catch (GuzzleException) {
throw new QywxPromotionContactApiException('企业微信客户联系接口网络异常', 0, $nonIdempotent);
}
$decoded = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300
|| !is_array($decoded) || !array_key_exists('errcode', $decoded)) {
// media/upload 成功返回可没有 errcode。
if ($path === 'media/upload' && $response->getStatusCode() === 200 && is_array($decoded) && !empty($decoded['media_id'])) {
return $decoded;
}
throw new QywxPromotionContactApiException('企业微信客户联系接口响应无法确认', 0, $nonIdempotent);
}
$code = (int) $decoded['errcode'];
if ($code === 0) {
return $decoded;
}
if (!$retried && in_array($code, [40001, 40014, 42001], true)) {
if ($this->tokenResolver === null) {
Cache::delete('qywx_promotion_contact_token:' . $this->credentialFingerprint());
}
if (isset($extra['multipart'])) {
$extra['multipart'][0]['contents']->rewind();
}
return $this->request($method, $path, $body, $nonIdempotent, $extra, true);
}
// 不回显上游 errmsg;部分错误会包含请求参数与一次性凭证。
throw new QywxPromotionContactApiException('企业微信客户联系接口失败[' . $code . ']', $code);
}
private function accessToken(): string
{
if ($this->tokenResolver !== null) {
$token = (string) ($this->tokenResolver)();
if ($token === '') {
throw new RuntimeException('客户联系托管 token 为空');
}
return $token;
}
if ($this->corpId === '' || $this->secret === '') {
throw new RuntimeException('请配置客户联系可调用自建应用的 corp_id 和 Secret');
}
$key = 'qywx_promotion_contact_token:' . $this->credentialFingerprint();
$token = (string) Cache::get($key, '');
if ($token !== '') {
return $token;
}
try {
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
]);
} catch (GuzzleException) {
throw new QywxPromotionContactApiException('获取客户联系 token 网络异常');
}
$data = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() !== 200 || !is_array($data)
|| (int) ($data['errcode'] ?? 0) !== 0 || empty($data['access_token'])) {
throw new QywxPromotionContactApiException('获取客户联系 token 失败', (int) ($data['errcode'] ?? 0));
}
$token = (string) $data['access_token'];
Cache::set($key, $token, max(60, (int) ($data['expires_in'] ?? 7200) - 300));
return $token;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
class QywxPromotionEnqueueException extends \RuntimeException
{
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\file\UploadedFile;
/** 私有源文件 + 可刷新三天临时素材。欢迎语关键路径仅使用缓存,不下载/上传文件。 */
class QywxPromotionMediaService
{
private QywxPromotionContactApiService $api;
private QywxPromotionMediaStore $store;
private string $root;
public function __construct(?QywxPromotionContactApiService $api = null, ?QywxPromotionMediaStore $store = null, ?string $root = null)
{
$this->api = $api ?? new QywxPromotionContactApiService();
$this->store = $store ?? new QywxPromotionMediaStore();
// runtime_path()在adminapi/api/CLI间不同;使用项目级私有目录保证上传与worker共享。
$this->root = rtrim($root ?? (root_path('runtime') . 'qywx_promotion_private' . DIRECTORY_SEPARATOR . 'media'), '/\\');
}
/** @return array{asset_id:string,name:string,type:string} */
public function upload($file, string $type, int $adminId): array
{
if ($adminId <= 0 || !$file instanceof UploadedFile || !$file->isValid()) {
throw new RuntimeException('请上传有效文件');
}
if (!in_array($type, ['image', 'video', 'file'], true)) {
throw new RuntimeException('素材类型仅支持 image、video、file');
}
$size = (int) $file->getSize();
$limit = ($type === 'file' ? 20 : 10) * 1024 * 1024;
if ($size <= 5 || $size > $limit) {
throw new RuntimeException($type === 'file' ? '文件须大于5字节且不超过20MB' : '图片/视频须大于5字节且不超过10MB');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getPathname());
$name = str_replace('\\', '/', $file->getOriginalName());
$name = mb_substr(preg_replace('/[\x00-\x1f\x7f]/u', '', basename($name)) ?? '', 0, 180);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($type === 'image') {
$info = @getimagesize($file->getPathname());
if (!in_array($mime, ['image/jpeg', 'image/png'], true) || $info === false
|| !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
throw new RuntimeException('图片仅支持真实 JPG/PNG 文件');
}
$extension = $mime === 'image/png' ? 'png' : 'jpg';
} elseif ($type === 'video') {
if ($mime !== 'video/mp4' || $extension !== 'mp4') {
throw new RuntimeException('视频仅支持 MP4');
}
} else {
// 私有存储也拒绝可执行内容/HTML/SVG;按实际 MIME 与扩展名双重检查。
$allowed = [
'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'],
'doc' => ['application/msword', 'application/x-ole-storage', 'application/CDFV2'],
'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2'],
'ppt' => ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/CDFV2'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'],
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'],
'zip' => ['application/zip'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'],
'mp4' => ['video/mp4'],
];
if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) {
throw new RuntimeException('不支持该文件格式,请上传PDF、Office、文本、ZIP、JPG/PNG或MP4');
}
}
if ($name === '') {
$name = '素材.' . $extension;
}
$this->ensureRoot();
$assetId = bin2hex(random_bytes(24));
$storageName = $assetId . '.' . $extension;
$hash = hash_file('sha256', $file->getPathname());
$file->move($this->root, $storageName);
@chmod($this->root . DIRECTORY_SEPARATOR . $storageName, 0600);
try {
$this->store->insert([
'asset_id' => $assetId, 'admin_id' => $adminId, 'name' => $name, 'type' => $type,
'mime' => $mime, 'size' => $size, 'sha256' => $hash, 'storage_name' => $storageName,
'media_id' => '', 'media_expires_at' => 0, 'credential_hash' => '',
'last_error' => '', 'create_time' => time(), 'update_time' => time(),
]);
} catch (\Throwable $e) {
@unlink($this->root . DIRECTORY_SEPARATOR . $storageName);
throw new RuntimeException('素材入库失败,请确认已安装推广自动化数据表', 0, $e);
}
// 配置阶段就上传企微素材。失败保留私有文件供后续排障,不对外提供文件路径。
$this->mediaId($assetId, $type, true);
return ['asset_id' => $assetId, 'name' => $name, 'type' => $type];
}
/** 旧方案授权由上层完成;只白名单旧配置实际已有资产,不接受请求单独声明的白名单。 */
public function validateConfig(array $config, int $adminId, array $existingConfig = []): array
{
$allowed = self::assetIds($existingConfig);
$config['welcome']['attachments'] = $this->validateAttachments((array) ($config['welcome']['attachments'] ?? []), $adminId, $allowed);
foreach ((array) ($config['welcome_schedule'] ?? []) as $index => $slot) {
$config['welcome_schedule'][$index]['attachments'] = $this->validateAttachments((array) ($slot['attachments'] ?? []), $adminId, $allowed);
}
return $config;
}
public function validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array
{
if (count($attachments) > 9) {
throw new RuntimeException('欢迎语最多9个附件');
}
$clean = [];
foreach ($attachments as $attachment) {
if (!is_array($attachment)) {
throw new RuntimeException('附件格式不正确');
}
$type = (string) ($attachment['msgtype'] ?? '');
$body = $attachment[$type] ?? null;
if (!is_array($body)) {
throw new RuntimeException('附件内容类型不匹配');
}
if (in_array($type, ['image', 'video', 'file'], true)) {
// image.pic_url 限企微 uploadimg URL;本服务仅接受私有资产,避免伪装任意外部地址。
$asset = $this->authorizedAsset((string) ($body['asset_id'] ?? ''), $type, $adminId, $allowedAssetIds);
$body = ['asset_id' => $asset['asset_id']];
} elseif ($type === 'link') {
$body = [
'title' => self::bytes($body['title'] ?? '', 128, '链接标题', true),
'url' => self::url($body['url'] ?? ''),
'desc' => self::bytes($body['desc'] ?? '', 512, '链接描述'),
] + (!empty($body['picurl']) ? ['picurl' => self::url($body['picurl'])] : []);
} elseif ($type === 'miniprogram') {
$asset = $this->authorizedAsset((string) ($body['pic_asset_id'] ?? ''), 'image', $adminId, $allowedAssetIds);
$appid = (string) ($body['appid'] ?? '');
$page = self::bytes($body['page'] ?? '', 1024, '小程序页面', true);
if (!preg_match('/^wx[0-9a-fA-F]{16}$/', $appid) || str_contains($page, '://')
|| str_contains($page, '..') || preg_match('/[\x00-\x1f]/', $page)) {
throw new RuntimeException('小程序 appid 或页面路径不正确');
}
$body = ['title' => self::bytes($body['title'] ?? '', 64, '小程序标题', true),
'appid' => $appid, 'page' => $page, 'pic_asset_id' => $asset['asset_id']];
} else {
throw new RuntimeException('不支持的附件类型');
}
$clean[] = ['msgtype' => $type, $type => $body];
}
return $clean;
}
/** 仅处理已授权并持久化的配置快照;绝不在欢迎语发送时进行网络文件上传。 */
public function materialize(array $attachments, array $config): array
{
$attachments = $this->validateAttachments($attachments, 0, self::assetIds($config));
foreach ($attachments as &$attachment) {
$type = $attachment['msgtype'];
if (in_array($type, ['image', 'video', 'file'], true)) {
$attachment[$type] = ['media_id' => $this->mediaId($attachment[$type]['asset_id'], $type, false)];
} elseif ($type === 'miniprogram') {
$attachment[$type]['pic_media_id'] = $this->mediaId($attachment[$type]['pic_asset_id'], 'image', false);
unset($attachment[$type]['pic_asset_id']);
}
}
unset($attachment);
return $attachments;
}
public function refreshReferenced(int $limit = 100): array
{
$result = ['selected' => 0, 'refreshed' => 0, 'failed' => 0];
foreach ($this->store->referencedAssetIds() as $id) {
$asset = $this->store->find($id);
if (!$asset || ($this->cacheValid($asset, 3600))) {
continue;
}
if ($result['selected'] >= max(1, $limit)) {
break;
}
++$result['selected'];
try {
$this->mediaId($id, $asset['type'], true, 3600);
++$result['refreshed'];
} catch (\Throwable) {
++$result['failed'];
}
}
return $result;
}
public static function assetIds(array $config): array
{
$ids = [];
$messages = array_merge([(array) ($config['welcome'] ?? [])], (array) ($config['welcome_schedule'] ?? []));
foreach ($messages as $message) {
foreach ((array) ($message['attachments'] ?? []) as $attachment) {
$type = $attachment['msgtype'] ?? '';
$key = $type === 'miniprogram' ? 'pic_asset_id' : 'asset_id';
$id = (string) ($attachment[$type][$key] ?? '');
if (preg_match('/^[0-9a-f]{48}$/', $id)) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
private function authorizedAsset(string $id, string $type, int $adminId, array $allowed): array
{
if (!preg_match('/^[0-9a-f]{48}$/', $id)) {
throw new RuntimeException('请先上传欢迎语素材');
}
$asset = $this->store->find($id);
if (!$asset || $asset['type'] !== $type || ((int) $asset['admin_id'] !== $adminId && !in_array($id, $allowed, true))) {
throw new RuntimeException('素材不存在、类型不匹配或无权使用');
}
return $asset;
}
private function mediaId(string $id, string $type, bool $allowUpload, int $margin = 300): string
{
$asset = $this->store->find($id);
if (!$asset || $asset['type'] !== $type) {
throw new RuntimeException('欢迎语素材不存在');
}
if ($this->cacheValid($asset, $margin)) {
return (string) $asset['media_id'];
}
if (!$allowUpload) {
throw new RuntimeException('欢迎语素材未预热或已过期,请检查素材刷新任务');
}
$stream = null;
try {
$path = $this->privatePath((string) $asset['storage_name']);
if (!is_file($path) || hash_file('sha256', $path) !== $asset['sha256']) {
throw new RuntimeException('欢迎语源文件缺失或完整性检查失败');
}
$stream = fopen($path, 'rb');
$result = $this->api->uploadMedia($stream, $type, (string) $asset['name']);
if (empty($result['media_id'])) {
throw new RuntimeException('企微素材接口未返回 media_id');
}
$created = min(time(), (int) ($result['created_at'] ?? time()));
$this->store->update($id, ['media_id' => (string) $result['media_id'],
'media_expires_at' => $created + 3 * 86400, 'credential_hash' => $this->api->credentialFingerprint(),
'last_error' => '', 'update_time' => time()]);
return (string) $result['media_id'];
} catch (\Throwable $e) {
$this->store->update($id, ['last_error' => '素材预热失败[' . (int) $e->getCode() . ']', 'update_time' => time()]);
throw $e;
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
private function cacheValid(array $asset, int $margin): bool
{
return !empty($asset['media_id']) && (int) $asset['media_expires_at'] > time() + $margin
&& hash_equals((string) $asset['credential_hash'], $this->api->credentialFingerprint());
}
private function privatePath(string $name): string
{
if (!preg_match('/^[0-9a-f]{48}\.[a-z0-9]{1,8}$/', $name)) {
throw new RuntimeException('素材存储标识不正确');
}
$root = realpath($this->root);
$path = realpath($this->root . DIRECTORY_SEPARATOR . $name);
if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR)) {
throw new RuntimeException('素材文件不在私有存储目录');
}
return $path;
}
private function ensureRoot(): void
{
if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) {
throw new RuntimeException('无法创建私有素材目录');
}
}
private static function bytes(mixed $value, int $limit, string $label, bool $required = false): string
{
if (!is_string($value) || strlen($value) > $limit || ($required && trim($value) === '')) {
throw new RuntimeException($label . '须' . ($required ? '非空且' : '') . '不超过' . $limit . '字节');
}
return trim($value);
}
private static function url(mixed $value): string
{
if (!is_string($value) || strlen($value) > 2048 || filter_var($value, FILTER_VALIDATE_URL) === false) {
throw new RuntimeException('链接地址不正确');
}
$parts = parse_url($value);
if (!in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|| isset($parts['user']) || isset($parts['pass'])) {
throw new RuntimeException('链接仅支持不含账号密码的HTTP(S)地址');
}
// 仅向企微传递链接;服务端永远不会抓取这些URL。
return $value;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
/** 独立存储边界,测试可使用内存替身,禁止连接业务数据库。 */
class QywxPromotionMediaStore
{
public function find(string $assetId): ?array
{
return Db::name('qywx_promotion_media')->where('asset_id', $assetId)->find() ?: null;
}
public function insert(array $row): void
{
Db::name('qywx_promotion_media')->insert($row);
}
public function update(string $assetId, array $fields): void
{
Db::name('qywx_promotion_media')->where('asset_id', $assetId)->update($fields);
}
/** 只预热已保存方案引用的素材;未使用上传不永久续期。 */
public function referencedAssetIds(): array
{
$ids = [];
foreach (Db::name('qywx_promotion_config')->alias('cfg')
->join('qywx_promotion_pool pool', 'pool.id = cfg.pool_id')
->whereNull('pool.delete_time')->column('cfg.config_json') as $json) {
$ids = array_merge($ids, QywxPromotionMediaService::assetIds(QywxPromotionConfig::decode($json)));
}
return array_values(array_unique($ids));
}
}
@@ -11,9 +11,22 @@ class QywxPromotionMemberRange
* @param list<array<string,mixed>> $members
* @return array{userids:list<string>,members:list<array<string,mixed>>,eligible_count:int}
*/
public static function evaluate(array $members, string $today, int $now): array
public static function evaluate(array $members, string $today, int $now, array $config = []): array
{
$userIds = [];
$backups = array_fill_keys((array) ($config['backup_userids'] ?? []), true);
$backupIds = [];
$scheduled = ($config['reception_mode'] ?? 'always') === 'scheduled';
$scheduledUsers = [];
if ($scheduled) {
foreach ((array) ($config['reception_schedule'] ?? []) as $slot) {
if (QywxPromotionConfig::matches($slot, $now)) {
foreach ((array) ($slot['member_userids'] ?? []) as $userId) {
$scheduledUsers[$userId] = true;
}
}
}
}
foreach ($members as &$member) {
if ((string) ($member['today_date'] ?? '') !== $today) {
$member['today_date'] = $today;
@@ -25,15 +38,24 @@ class QywxPromotionMemberRange
}
$userId = trim((string) ($member['userid'] ?? ''));
if ($userId !== '') {
$userIds[$userId] = true;
if (isset($backups[$userId])) {
$backupIds[$userId] = true;
} elseif (!$scheduled || isset($scheduledUsers[$userId])) {
$userIds[$userId] = true;
}
}
}
unset($member);
$usingBackup = $userIds === [] && $backupIds !== [];
if ($usingBackup) {
$userIds = $backupIds;
}
return [
'userids' => array_keys($userIds),
'members' => array_values($members),
'eligible_count' => count($userIds),
'using_backup' => $usingBackup,
];
}
@@ -253,7 +253,7 @@ class QywxPromotionMemberSchedulerService
string $today,
int $now
): array {
$range = QywxPromotionMemberRange::evaluate($members, $today, $now);
$range = QywxPromotionMemberRange::evaluate($members, $today, $now, QywxPromotionConfig::forPool($poolId));
self::persistMemberCursors($range['members'], $now);
if ($range['userids'] === []) {
self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
@@ -66,7 +66,7 @@ class QywxPromotionRangeSyncService
if ($remoteLinkId === '') {
throw new RuntimeException('官方链接 ID 为空');
}
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time());
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
$desiredUserIds = $range['userids'];
if ($desiredUserIds === []) {
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
@@ -93,7 +93,7 @@ class QywxPromotionRangeSyncService
$this->api->updateLink([
'link_id' => $remoteLinkId,
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
'range' => ['user_list' => $desiredUserIds],
'range' => ['user_list' => $desiredUserIds, 'department_list' => []],
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
]);
$response = $this->api->getLink($remoteLinkId);