702 lines
30 KiB
PHP
702 lines
30 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\logic\firstvisit;
|
||
|
||
use app\common\service\DataScope\DataScopeService;
|
||
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);
|
||
$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')
|
||
->whereNull('p.delete_time');
|
||
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
|
||
$pools = $poolsQuery
|
||
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
|
||
->order('p.id', 'desc')
|
||
->select()->toArray();
|
||
|
||
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
|
||
$links = [];
|
||
if ($poolIds !== []) {
|
||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||
->whereNull('l.delete_time')
|
||
->whereIn('l.pool_id', $poolIds)
|
||
->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')
|
||
->select()->toArray();
|
||
}
|
||
|
||
$domain = rtrim($domain, '/');
|
||
foreach ($pools as &$pool) {
|
||
$key = (string) $pool['public_key'];
|
||
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
|
||
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||
$pool['script_url'] = $scriptUrl;
|
||
$pool['go_url'] = $goUrl;
|
||
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
|
||
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||
}
|
||
unset($pool);
|
||
|
||
$today = date('Y-m-d');
|
||
$todayClicks = 0;
|
||
$onlineLinks = 0;
|
||
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 = self::internalApplicationStatus($domain);
|
||
|
||
return [
|
||
'meta' => [
|
||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||
'generated_at' => date('Y-m-d H:i:s'),
|
||
],
|
||
'config' => $config,
|
||
'summary' => [
|
||
'configured_apps' => $config['ready'] ? 1 : 0,
|
||
'pool_count' => count($pools),
|
||
'online_links' => $onlineLinks,
|
||
'today_clicks' => $todayClicks,
|
||
],
|
||
'pools' => $pools,
|
||
'links' => $links,
|
||
'member_options' => self::memberOptions($adminId, $adminInfo),
|
||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||
];
|
||
}
|
||
|
||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||
{
|
||
$id = max(0, (int) ($params['id'] ?? 0));
|
||
$name = trim((string) ($params['name'] ?? ''));
|
||
if ($name === '' || mb_strlen($name) > 60) {
|
||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||
}
|
||
$fallback = trim((string) ($params['fallback_url'] ?? ''));
|
||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||
}
|
||
$now = time();
|
||
$data = [
|
||
'name' => $name,
|
||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||
'fallback_url' => $fallback,
|
||
'update_time' => $now,
|
||
];
|
||
if ($id > 0) {
|
||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
|
||
} else {
|
||
$data += [
|
||
'public_key' => bin2hex(random_bytes(16)),
|
||
'owner_admin_id' => $adminId,
|
||
'dept_id' => self::primaryDeptId($adminId),
|
||
'click_count' => 0,
|
||
'create_time' => $now,
|
||
];
|
||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
|
||
}
|
||
|
||
return ['id' => $id];
|
||
}
|
||
|
||
public static function deletePool(int $id, int $adminId, array $adminInfo): void
|
||
{
|
||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||
$now = time();
|
||
Db::transaction(function () use ($id, $now): void {
|
||
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
|
||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
|
||
});
|
||
}
|
||
|
||
public static function saveLink(array $params, int $adminId, array $adminInfo): array
|
||
{
|
||
$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 个字符的获客链接名称');
|
||
}
|
||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
|
||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||
}
|
||
$now = time();
|
||
$data = [
|
||
'pool_id' => $poolId,
|
||
'account_id' => 0,
|
||
'name' => $name,
|
||
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
|
||
'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))),
|
||
'active_start' => $startAt,
|
||
'active_end' => $endAt,
|
||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||
'update_time' => $now,
|
||
];
|
||
|
||
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
|
||
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 += [
|
||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||
'click_count' => 0,
|
||
'today_count' => 0,
|
||
'today_date' => null,
|
||
'last_click_time' => 0,
|
||
'create_time' => $now,
|
||
];
|
||
try {
|
||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||
} catch (\Throwable $e) {
|
||
try {
|
||
$api->deleteLink($remoteLinkId);
|
||
} catch (\Throwable) {
|
||
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
|
||
}
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
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
|
||
{
|
||
$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(),
|
||
]);
|
||
}
|
||
|
||
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
|
||
{
|
||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||
'delete_time' => time(),
|
||
'update_time' => time(),
|
||
]);
|
||
}
|
||
|
||
/** @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) {
|
||
throw new RuntimeException('数据不存在');
|
||
}
|
||
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
|
||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||
if ($visibleIds !== null) {
|
||
if ($visibleIds === []) {
|
||
throw new RuntimeException('无权访问该数据');
|
||
}
|
||
$query->whereIn('owner_admin_id', $visibleIds);
|
||
}
|
||
$row = $query->find();
|
||
if (!$row) {
|
||
throw new RuntimeException('数据不存在或超出当前权限范围');
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||
{
|
||
if ($visibleIds === null) {
|
||
return;
|
||
}
|
||
if ($visibleIds === []) {
|
||
$query->whereRaw('1 = 0');
|
||
return;
|
||
}
|
||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private static function parseTime(mixed $value): int
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return 0;
|
||
}
|
||
if (is_numeric($value)) {
|
||
return max(0, (int) $value);
|
||
}
|
||
$time = strtotime((string) $value);
|
||
|
||
return $time === false ? 0 : $time;
|
||
}
|
||
|
||
private static function mask(string $value): string
|
||
{
|
||
$length = strlen($value);
|
||
if ($length <= 8) {
|
||
return $value === '' ? '' : str_repeat('*', $length);
|
||
}
|
||
|
||
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',
|
||
];
|
||
}
|
||
}
|