feat: 管理端推送 Webhook 配置与出站推送链路接入

1、修复了管理端设置在推送开关/配置项上的联动与保存问题。

2、新增 OutboundPushHookService、PushHook/InternalPushHook 接口、前端 PushHookConfigPanel 与小程序 pushHook 工具。

3、优化 Analyze/Auth/Test/支付回调触发路径,补充去重迁移脚本与实施文档。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-15 15:07:36 +08:00
parent c946584a7d
commit cf835ea585
29 changed files with 2964 additions and 111 deletions

View File

@@ -11,6 +11,9 @@ class FeishuLeadWebhookService
{
public const CONFIG_KEY = 'feishu_lead_webhook';
/** 去重表 scene飞书获客与 OutboundPushHookService::DEDUP_SCENE_OUTBOUND 区分) */
private const DEDUP_SCENE = 'feishu_lead';
public static function getConfig(): array
{
$def = [
@@ -140,7 +143,10 @@ class FeishuLeadWebhookService
}
}
private static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string
/**
* 订单来源文案(飞书「来源」与 HTTP 出站 Hook `sourceLabel` 共用)
*/
public static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string
{
if ($productType === 'recharge') {
return '企业余额·充值支付成功';
@@ -148,6 +154,7 @@ class FeishuLeadWebhookService
$map = [
'face' => '面相测试',
'mbti' => 'MBTI测试',
'sbti' => 'SBTI测试',
'disc' => 'DISC测试',
'pdp' => 'PDP测试',
'resume' => '简历分析',
@@ -259,7 +266,8 @@ class FeishuLeadWebhookService
private static function beginDedup(string $dedupKey): bool
{
try {
Db::name('feishu_lead_dedup')->insert([
Db::name('delivery_dedup')->insert([
'scene' => self::DEDUP_SCENE,
'dedupKey' => $dedupKey,
'createdAt' => date('Y-m-d H:i:s'),
]);
@@ -272,7 +280,10 @@ class FeishuLeadWebhookService
private static function rollbackDedup(string $dedupKey): void
{
try {
Db::name('feishu_lead_dedup')->where('dedupKey', $dedupKey)->delete();
Db::name('delivery_dedup')
->where('scene', self::DEDUP_SCENE)
->where('dedupKey', $dedupKey)
->delete();
} catch (\Throwable $e) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ namespace app\controller\admin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\OutboundPushHookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use think\facade\Request;
@@ -459,6 +460,157 @@ class Settings extends BaseController
return success(null, '已保存');
}
/**
* 当前管理员可编辑的作用域:有企业则读写 enterprise_id=本企业否则读写全平台默认0与超管维护同一条
* GET /api/v1/admin/settings/push-hook
*/
public function getPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = $this->resolveAdminPushHookEnterpriseId();
$cfg = OutboundPushHookService::getConfig($eid);
$events = $cfg['events'] ?? [];
if (!is_array($events)) {
$events = [];
}
$enterpriseName = null;
if ($eid > 0) {
$n = Db::name('enterprises')->where('id', $eid)->value('name');
$enterpriseName = ($n !== null && $n !== '') ? (string) $n : null;
}
return success([
'scope' => $eid > 0 ? 'enterprise' : 'platform',
'configEnterpriseId' => $eid,
'enterpriseName' => $enterpriseName,
'enabled' => !empty($cfg['enabled']),
'url' => (string) ($cfg['url'] ?? ''),
'secret' => (string) ($cfg['secret'] ?? ''),
'events' => $events,
]);
}
/**
* PUT /api/v1/admin/settings/push-hook
*/
public function updatePushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$url = trim((string) ($input['url'] ?? ''));
$secret = (string) ($input['secret'] ?? '');
$eventsRaw = $input['events'] ?? null;
$events = [];
if (is_array($eventsRaw)) {
foreach ($eventsRaw as $ev) {
$ev = trim((string) $ev);
if ($ev !== '') {
$events[] = $ev;
}
}
}
if ($enabled && $url !== '' && stripos($url, 'http') !== 0) {
return error('URL 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'url' => $url,
'secret' => $secret,
'events' => $events,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = OutboundPushHookService::CONFIG_KEY;
$eid = $this->resolveAdminPushHookEnterpriseId();
$desc = $eid > 0 ? '通用 HTTP 出站推送 Hook企业专属' : '通用 HTTP 出站推送 Hook全平台默认';
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', $eid)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $eid)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => $eid,
'value' => $json,
'description' => $desc,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* POST /api/v1/admin/settings/push-hook/test
* 向当前解析到的 URL 发送 hook.ping不写去重表
*/
public function testPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$ctx = $this->resolveAdminPushHookEnterpriseId();
$r = OutboundPushHookService::sendTestPing($ctx);
return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败'));
}
/**
* POST /api/v1/admin/settings/push-hook/test-result
* 按真实业务数据重放一条 test.result_completed支持 force=1 强制清去重。
*/
public function testPushHookTestResult()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$testResultId = (int) Request::param('testResultId', 0);
$force = (int) Request::param('force', 0) === 1;
$r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force);
return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败'));
}
/**
* 出站 Hook 可编辑行JWT 或 users 表解析到的本企业 ID无企业则为 0与超管共用全平台默认行
*/
private function resolveAdminPushHookEnterpriseId(): int
{
$user = $this->request->user ?? null;
if (!$user) {
return 0;
}
$eid = (int) ($user['enterpriseId'] ?? 0);
if ($eid > 0) {
return $eid;
}
$adminId = (int) ($user['userId'] ?? 0);
if ($adminId > 0) {
$v = Db::name('users')->where('id', $adminId)->value('enterpriseId');
if ($v !== null && $v !== '') {
$x = (int) $v;
if ($x > 0) {
return $x;
}
}
}
return 0;
}
/**
* 更新小程序配置
* 写入 text_config 行enterprise_id={eid}(有企业)或 0无企业

View File

@@ -215,6 +215,12 @@ class Analyze extends BaseController
} catch (\Throwable $e) {
// 第三方开放平台失败不阻断
}
// 出站 Webhook与问卷 submit 一致,异步投递内部接口触发 test.result_completed小程序亦会调 push-hook去重表防双发
try {
\app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted((int) $testResultId);
} catch (\Throwable $e) {
}
}
} catch (\Throwable $e) {
// 写入失败不影响返回分析结果
@@ -396,6 +402,10 @@ class Analyze extends BaseController
try {
\app\controller\api\Distribution::settleTestCommission($testResultId, $userId, 'resume');
} catch (\Throwable $e) {}
try {
\app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted($testResultId);
} catch (\Throwable $e) {
}
}
$responseData = $resultStruct;

View File

@@ -7,6 +7,7 @@ use app\model\WechatUser;
use app\common\service\JwtService;
use app\common\service\WechatService;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\OutboundPushHookService;
use app\common\service\ThirdPartyChannelService;
use think\facade\Request;
use think\facade\Db;
@@ -542,6 +543,10 @@ class Auth extends BaseController
FeishuLeadWebhookService::onPhoneBound($userId, $phone);
} catch (\Throwable $e) {
}
try {
OutboundPushHookService::onPhoneBound($userId, $phone);
} catch (\Throwable $e) {
}
}
$row = Db::name('wechat_users')->where('id', $userId)->find();
unset($row['sessionKey'], $row['openid']);

View File

@@ -0,0 +1,66 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\OutboundPushHookService;
use think\facade\Log;
use think\facade\Request;
/**
* 服务内部异步推送入口:主业务只投递,不等待外部 Webhook 完成。
*/
class InternalPushHook extends BaseController
{
/**
* POST /api/internal/outbound-push/dispatch
*/
public function dispatch()
{
$body = (string) file_get_contents('php://input');
$timestamp = trim((string) Request::header('X-MBTI-Internal-Timestamp', ''));
$signature = trim((string) Request::header('X-MBTI-Internal-Signature', ''));
if (!OutboundPushHookService::verifyAsyncInternalDispatch($body, $timestamp, $signature)) {
Log::warning('OutboundPushHook async dispatch rejected', [
'ip' => Request::ip(),
'hasSignature' => $signature !== '',
'timestamp' => $timestamp,
]);
return error('forbidden', 403);
}
$payload = json_decode($body, true);
if (!is_array($payload)) {
return error('invalid payload', 400);
}
$job = (string) ($payload['job'] ?? '');
try {
switch ($job) {
case 'lead.order_paid':
OutboundPushHookService::onOrderPaid(
(int) ($payload['orderId'] ?? 0),
(int) ($payload['userId'] ?? 0)
);
break;
case 'test.result_completed':
OutboundPushHookService::onTestResultCompleted((int) ($payload['testResultId'] ?? 0));
break;
default:
return error('unsupported job', 400);
}
} catch (\Throwable $e) {
Log::error('OutboundPushHook async dispatch failed', [
'job' => $job,
'payload' => $payload,
'error' => $e->getMessage(),
]);
return error('dispatch failed', 500);
}
return success(['accepted' => true], 'accepted');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\OutboundPushHookService;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
* 小程序主动触发出站推送。
* 目的:将第三方 Webhook 推送从主业务链路解耦,改由前端在关键节点显式调用。
*/
class PushHook extends BaseController
{
/**
* POST /api/push-hook/trigger
*/
public function trigger()
{
$user = $this->request->user ?? null;
$user = is_array($user) ? $user : (array) $user;
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
$source = (string) ($user['source'] ?? '');
if ($userId <= 0 || $source !== 'wechat') {
return error('未登录', 401);
}
$event = trim((string) Request::post('event', ''));
if ($event === '') {
return error('event 不能为空', 400);
}
try {
switch ($event) {
case 'test.result_completed':
return $this->triggerTestResultCompleted($userId);
case 'lead.order_paid':
return $this->triggerOrderPaid($userId);
default:
return error('暂不支持的事件类型', 400);
}
} catch (\Throwable $e) {
Log::error('PushHook trigger failed', [
'event' => $event,
'userId' => $userId,
'error' => $e->getMessage(),
]);
return error('触发推送失败', 500);
}
}
private function triggerTestResultCompleted(int $userId)
{
$testResultId = (int) Request::post('testResultId', 0);
if ($testResultId <= 0) {
return error('testResultId 不能为空', 400);
}
$row = Db::name('test_results')
->where('id', $testResultId)
->field('id,userId,testType')
->find();
if (!$row) {
return error('测试记录不存在', 404);
}
if ((int) ($row['userId'] ?? 0) !== $userId) {
return error('无权触发该测试记录推送', 403);
}
$rawDedupKey = 'test.result_completed:' . $testResultId;
if (OutboundPushHookService::hasPushHookDedup($rawDedupKey)) {
return success([
'accepted' => false,
'event' => 'test.result_completed',
'testResultId' => $testResultId,
'status' => 'duplicate',
'dedupKey' => 'push_hook:' . $rawDedupKey,
], '该测试记录已推送过,已按去重规则跳过');
}
OutboundPushHookService::onTestResultCompleted($testResultId);
return success([
'accepted' => true,
'event' => 'test.result_completed',
'status' => 'dispatched',
'testResultId' => $testResultId,
], '已触发推送');
}
private function triggerOrderPaid(int $userId)
{
$orderNo = trim((string) Request::post('orderId', ''));
if ($orderNo === '') {
return error('orderId 不能为空', 400);
}
$order = Db::name('orders')
->where('orderNo', $orderNo)
->where('userId', $userId)
->field('id,userId,status')
->find();
if (!$order) {
return error('订单不存在', 404);
}
if (!in_array((string) ($order['status'] ?? ''), ['paid', 'completed'], true)) {
return error('订单尚未支付成功', 409);
}
$rawDedupKey = 'lead.order_paid:' . (int) $order['id'];
if (OutboundPushHookService::hasPushHookDedup($rawDedupKey)) {
return success([
'accepted' => false,
'event' => 'lead.order_paid',
'orderId' => $orderNo,
'status' => 'duplicate',
'dedupKey' => 'push_hook:' . $rawDedupKey,
], '该订单已推送过,已按去重规则跳过');
}
OutboundPushHookService::onOrderPaid((int) $order['id'], $userId);
return success([
'accepted' => true,
'event' => 'lead.order_paid',
'status' => 'dispatched',
'orderId' => $orderNo,
], '已触发推送');
}
}

View File

@@ -842,6 +842,11 @@ class Test extends BaseController
} catch (\Throwable $e) {
// 对接失败不阻断
}
try {
\app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted((int) $id);
} catch (\Throwable $e) {
}
}
} catch (\Throwable $e) {
return error('保存测试结果失败', 500);

View File

@@ -3,6 +3,7 @@ namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\OutboundPushHookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use app\model\Enterprise as EnterpriseModel;
@@ -393,6 +394,122 @@ class Settings extends BaseController
return success(null, '已保存');
}
/**
* 通用 HTTP 出站推送 Hook仅维护全平台默认 enterprise_id=0企业专属由企业管理端维护
* GET /api/v1/superadmin/settings/push-hook
*/
public function getPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$cfg = OutboundPushHookService::getConfig(0);
$events = $cfg['events'] ?? [];
if (!is_array($events)) {
$events = [];
}
return success([
'scope' => 'platform',
'configEnterpriseId' => 0,
'enterpriseName' => null,
'enabled' => !empty($cfg['enabled']),
'url' => (string) ($cfg['url'] ?? ''),
'secret' => (string) ($cfg['secret'] ?? ''),
'events' => $events,
]);
}
/**
* PUT /api/v1/superadmin/settings/push-hook
*/
public function updatePushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$url = trim((string) ($input['url'] ?? ''));
$secret = (string) ($input['secret'] ?? '');
$eventsRaw = $input['events'] ?? null;
$events = [];
if (is_array($eventsRaw)) {
foreach ($eventsRaw as $ev) {
$ev = trim((string) $ev);
if ($ev !== '') {
$events[] = $ev;
}
}
}
if ($enabled && $url !== '' && stripos($url, 'http') !== 0) {
return error('URL 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'url' => $url,
'secret' => $secret,
'events' => $events,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = OutboundPushHookService::CONFIG_KEY;
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => 0,
'value' => $json,
'description' => '通用 HTTP 出站推送 Hook全平台默认',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* POST /api/v1/superadmin/settings/push-hook/test
* 按全平台默认配置发送 hook.pingcontextEnterpriseId=0
*/
public function testPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$r = OutboundPushHookService::sendTestPing(0);
return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败'));
}
/**
* POST /api/v1/superadmin/settings/push-hook/test-result
* 按真实业务数据重放一条 test.result_completed支持 force=1 强制清去重。
*/
public function testPushHookTestResult()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$testResultId = (int) Request::param('testResultId', 0);
$force = (int) Request::param('force', 0) === 1;
$r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force);
return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败'));
}
/**
* 更新超管账户信息
* @return \think\response\Json