feat: 管理端推送 Webhook 配置与出站推送链路接入
1、修复了管理端设置在推送开关/配置项上的联动与保存问题。 2、新增 OutboundPushHookService、PushHook/InternalPushHook 接口、前端 PushHookConfigPanel 与小程序 pushHook 工具。 3、优化 Analyze/Auth/Test/支付回调触发路径,补充去重迁移脚本与实施文档。 Made-with: Cursor
This commit is contained in:
@@ -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(无企业)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
66
api/app/controller/api/InternalPushHook.php
Normal file
66
api/app/controller/api/InternalPushHook.php
Normal 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');
|
||||
}
|
||||
}
|
||||
133
api/app/controller/api/PushHook.php
Normal file
133
api/app/controller/api/PushHook.php
Normal 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,
|
||||
], '已触发推送');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.ping(contextEnterpriseId=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
|
||||
|
||||
Reference in New Issue
Block a user