From 9151939ba5e1f6762a86e9ef650a0e53020a7968 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Wed, 17 Jun 2026 06:55:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(push):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E4=BB=8E=E5=8F=8B=E7=9B=9F=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=20DooPush=20=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新移动端 (Expo App) 使用 doopush-react-native-sdk 完成设备注册;服务端 按 userid 标签 OR 并集定向推送,替代原友盟 alias 链路。 - 新增 app/Services/DooPushClient.php:DooPush 服务端 HTTP 客户端 POST /apps/{appId}/push(按 tags 定向) POST /apps/{appId}/device-tags/{deviceToken}(绑定 userid 标签) DELETE 同上(解绑) X-API-Key 鉴权 - 新增 app/Module/Push/PushService.php:推送业务编排 按 badge(未读数)分组合并下发,减少 HTTP 次数 - 新增 app/Http/Controllers/Api/PushController.php:设备注册/解绑接口 api/push/register、api/push/unregister - 修改 app/Models/UmengAlias.php:pushMsgToUserid 入口在 DooPushClient::enabled() 时委托新通道,否则保留旧友盟链路 - 修改 config/dootask.php:新增 doopush 段(appId/apiKey/baseURL/tag_name) - 修改 routes/web.php:注册 api/push/{method} 路由 - 同步 routes/api-map.md(doc:api-map 自动生成) 旧 EEUI App 推送链路完全不动;新 App 通过 .env 的 DOOPUSH_ENABLED=yes 启用。 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/Api/PushController.php | 71 +++++++++++++ app/Models/UmengAlias.php | 7 ++ app/Module/Push/PushService.php | 58 ++++++++++ app/Services/DooPushClient.php | 112 ++++++++++++++++++++ config/dootask.php | 9 ++ routes/api-map.md | 37 ++----- routes/web.php | 4 + 7 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Api/PushController.php create mode 100644 app/Module/Push/PushService.php create mode 100644 app/Services/DooPushClient.php diff --git a/app/Http/Controllers/Api/PushController.php b/app/Http/Controllers/Api/PushController.php new file mode 100644 index 000000000..f7a2929ba --- /dev/null +++ b/app/Http/Controllers/Api/PushController.php @@ -0,0 +1,71 @@ +successful()) { + return Base::retError('设置标签失败: ' . $resp->status()); + } + } catch (\Throwable $e) { + return Base::retError('设置标签异常: ' . $e->getMessage()); + } + return Base::retSuccess('success'); + } + + /** + * 解绑推送设备(解除 DooPush userid 标签)。 + * + * @apiParam {String} device_token DooPush SDK 注册返回的 deviceToken + */ + public function unregister__index() + { + $userid = Doo::userId(); + $token = trim(Request::input('device_token', '')); + if ($token === '') { + return Base::retError('device_token 不能为空'); + } + if (!DooPushClient::enabled()) { + return Base::retError('推送服务未启用'); + } + try { + $resp = DooPushClient::unsetUserTag($token); + if (!$resp->successful()) { + return Base::retError('解绑标签失败: ' . $resp->status()); + } + } catch (\Throwable $e) { + return Base::retError('解绑标签异常: ' . $e->getMessage()); + } + unset($userid); // 当前实现按 token 解绑;保留 userid 拉取以约束鉴权语义 + return Base::retSuccess('success'); + } +} diff --git a/app/Models/UmengAlias.php b/app/Models/UmengAlias.php index 7dd3cf376..2f9a6e466 100644 --- a/app/Models/UmengAlias.php +++ b/app/Models/UmengAlias.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Module\Base; +use App\Module\Push\PushService; +use App\Services\DooPushClient; use Carbon\Carbon; use Hedeqiang\UMeng\Android; use Hedeqiang\UMeng\IOS; @@ -253,6 +255,11 @@ class UmengAlias extends AbstractModel */ public static function pushMsgToUserid($userid, $array) { + // DooPush 启用时走新通道(按 userid 标签 OR 并集定向);否则保留旧 Umeng 链路。 + if (DooPushClient::enabled()) { + PushService::pushToUsers($userid, $array); + return; + } $builder = self::select(['id', 'platform', 'alias', 'userid'])->where('updated_at', '>', Carbon::now()->subMonth()); if (is_array($userid)) { $builder->whereIn('userid', $userid); diff --git a/app/Module/Push/PushService.php b/app/Module/Push/PushService.php new file mode 100644 index 000000000..7e62b9c1a --- /dev/null +++ b/app/Module/Push/PushService.php @@ -0,0 +1,58 @@ +|int $userid + * @param array{title?:string, body?:string, extra?:array} $array + */ + public static function pushToUsers($userid, array $array): void + { + if (empty($userid) || empty($array)) { + return; + } + + $userids = is_array($userid) ? array_values(array_filter($userid)) : [(int) $userid]; + if (empty($userids)) { + return; + } + + // userid -> badge 数;按 badge 分组合并下发 + $byBadge = []; + foreach ($userids as $uid) { + $badge = WebSocketDialogMsgRead::whereUserid($uid) + ->whereSilence(0) + ->whereReadAt(null) + ->count(); + $byBadge[$badge][] = (int) $uid; + } + + foreach ($byBadge as $badge => $batch) { + try { + $msg = $array; + $msg['badge'] = (int) $badge; + $resp = DooPushClient::pushToUserids($batch, $msg); + if (!$resp->successful()) { + info('[DooPush] non-2xx: ' . $resp->status() . ' ' . $resp->body()); + } + } catch (\Throwable $e) { + info('[DooPush] exception: ' . $e->getMessage()); + } + } + } +} diff --git a/app/Services/DooPushClient.php b/app/Services/DooPushClient.php new file mode 100644 index 000000000..834309ab3 --- /dev/null +++ b/app/Services/DooPushClient.php @@ -0,0 +1,112 @@ + $userids + * @param array{title:string, body?:string, badge?:int, extra?:array} $msg + */ + public static function pushToUserids(array $userids, array $msg, ?string $platform = null): Response + { + $cfg = (array) config('dootask.doopush', []); + $tagName = (string) ($cfg['tag_name'] ?? 'userid'); + + $tags = []; + foreach (array_unique($userids) as $uid) { + $tags[] = ['tag_name' => $tagName, 'tag_value' => (string) $uid]; + } + + $target = ['type' => 'tags', 'tags' => $tags]; + if ($platform !== null) { + $target['platform'] = $platform; + } + + $payload = [ + 'title' => (string) ($msg['title'] ?? ''), + 'content' => (string) ($msg['body'] ?? ''), + 'target' => $target, + ]; + + if (isset($msg['badge'])) { + $payload['badge'] = (int) $msg['badge']; + } + + // 自定义载荷:保留 extra 中的 dialog_id / msg_id 等点击跳转字段 + $extra = (array) ($msg['extra'] ?? []); + if (!empty($extra)) { + $payload['payload'] = [ + 'action' => 'open_dialog', + 'data' => json_encode($extra, JSON_UNESCAPED_UNICODE), + ]; + } + + return self::http()->post(self::url('/push'), $payload); + } + + /** + * 给指定设备 token 绑定 userid 标签(App SDK 注册后由客户端→DooTask 后端→本接口完成)。 + */ + public static function setUserTag(string $deviceToken, int $userid): Response + { + $cfg = (array) config('dootask.doopush', []); + $tagName = (string) ($cfg['tag_name'] ?? 'userid'); + + return self::http()->post(self::url("/device-tags/{$deviceToken}"), [ + 'tag_name' => $tagName, + 'tag_value' => (string) $userid, + ]); + } + + /** + * 解除设备的 userid 标签(用户登出/换号时调用)。 + */ + public static function unsetUserTag(string $deviceToken): Response + { + $cfg = (array) config('dootask.doopush', []); + $tagName = (string) ($cfg['tag_name'] ?? 'userid'); + + return self::http()->delete(self::url("/device-tags/{$deviceToken}/{$tagName}")); + } + + private static function http() + { + $cfg = (array) config('dootask.doopush', []); + return Http::withHeaders([ + 'X-API-Key' => (string) ($cfg['api_key'] ?? ''), + 'Content-Type' => 'application/json', + ])->timeout(10); + } + + private static function url(string $path): string + { + $cfg = (array) config('dootask.doopush', []); + $baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://doopush.com/api/v1'), '/'); + $appId = (string) ($cfg['app_id'] ?? ''); + return $baseUrl . '/apps/' . $appId . $path; + } +} diff --git a/config/dootask.php b/config/dootask.php index f5ff4f0f9..cebdd1f25 100644 --- a/config/dootask.php +++ b/config/dootask.php @@ -32,4 +32,13 @@ return [ // 临时文件自动清理天数(DeleteTmpTask) 'auto_empty_temp_file' => env('AUTO_EMPTY_TEMP_FILE', 30), + // DooPush 推送服务配置(替代旧友盟 umeng;空值表示未启用,会回退到旧 umeng 链路) + 'doopush' => [ + 'enabled' => env('DOOPUSH_ENABLED') === 'yes', + 'app_id' => env('DOOPUSH_APP_ID'), + 'api_key' => env('DOOPUSH_API_KEY'), + 'base_url' => env('DOOPUSH_BASE_URL', 'https://doopush.com/api/v1'), + 'tag_name' => env('DOOPUSH_USER_TAG', 'userid'), + ], + ]; diff --git a/routes/api-map.md b/routes/api-map.md index f6e58e07f..c33512c11 100644 --- a/routes/api-map.md +++ b/routes/api-map.md @@ -2,7 +2,7 @@ > 此文件由 `php artisan doc:api-map` 生成,勿手改。 -接口总数:319 +接口总数:300 ## 路由规则 @@ -224,6 +224,7 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方 | api/dialog/msg/sendtext | msg__sendtext() | post | 发送消息 | | api/dialog/msg/sendnotice | msg__sendnotice() | post | 发送通知 | | api/dialog/msg/sendtemplate | msg__sendtemplate() | post | 发送模板消息 | +| api/dialog/msg/sendapprove | msg__sendapprove() | post | 发送审批通知卡片 | | api/dialog/msg/sendrecord | msg__sendrecord() | post | 发送语音 | | api/dialog/msg/convertrecord | msg__convertrecord() | post | 录音转文字 | | api/dialog/msg/sendfile | msg__sendfile() | post | 文件上传 | @@ -326,33 +327,6 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方 | api/public/checkin/install | checkin__install() | any | | | api/public/checkin/report | checkin__report() | any | | -## approve(ApproveController) - -| URL | 方法名 | HTTP | 说明 | -| --- | --- | --- | --- | -| api/approve/verifyToken | verifyToken() | get | 验证APi登录 | -| api/approve/procdef/all | procdef__all() | post | 查询流程定义 | -| api/approve/procdef/del | procdef__del() | get | 删除流程定义 | -| api/approve/process/start | process__start() | post | 启动流程(审批中) | -| api/approve/process/addGlobalComment | process__addGlobalComment() | post | 添加全局评论 | -| api/approve/task/complete | task__complete() | post | 审批 | -| api/approve/task/withdraw | task__withdraw() | post | 撤回 | -| api/approve/process/delById | process__delById() | post | 删除审批(流程实例) | -| api/approve/process/findTask | process__findTask() | post | 查询需要我审批的流程(审批中) | -| api/approve/process/startByMyselfAll | process__startByMyselfAll() | post | 查询我启动的流程(全部) | -| api/approve/process/startByMyself | process__startByMyself() | post | 查询我启动的流程(审批中) | -| api/approve/process/findProcNotify | process__findProcNotify() | post | 查询抄送我的流程(审批中) | -| api/approve/identitylink/findParticipant | identitylink__findParticipant() | get | 查询流程实例的参与者(审批中) | -| api/approve/procHistory/findTask | procHistory__findTask() | post | 查询需要我审批的流程(已结束) | -| api/approve/procHistory/startByMyself | procHistory__startByMyself() | post | 查询我启动的流程(已结束) | -| api/approve/procHistory/findProcNotify | procHistory__findProcNotify() | post | 查询抄送我的流程(已结束) | -| api/approve/identitylinkHistory/findParticipant | identitylinkHistory__findParticipant() | get | 查询流程实例的参与者(已结束) | -| api/approve/process/detail | process__detail() | get | 根据流程ID查询流程详情 | -| api/approve/export | export() | post | 导出数据 | -| api/approve/down | down() | get | 下载导出的审批数据 | -| api/approve/user/status | user__status() | get | 获取用户审批状态 | -| api/approve/process/doto | process__doto() | get | 查询需要我审批的流程数量 | - ## assistant(AssistantController) | URL | 方法名 | HTTP | 说明 | @@ -390,3 +364,10 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方 | URL | 方法名 | HTTP | 说明 | | --- | --- | --- | --- | + +## push(PushController) + +| URL | 方法名 | HTTP | 说明 | +| --- | --- | --- | --- | +| api/push/register/index | register__index() | any | | +| api/push/unregister/index | unregister__index() | any | | diff --git a/routes/web.php b/routes/web.php index 012c65197..04a0f9c47 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\AssistantController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ComplaintController; use App\Http\Controllers\Api\SearchController; +use App\Http\Controllers\Api\PushController; /* |-------------------------------------------------------------------------- @@ -63,6 +64,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () { // 测试 Route::any('test/{method}', TestController::class); Route::any('test/{method}/{action}', TestController::class); + // 推送(DooPush 设备注册/解绑) + Route::any('push/{method}', PushController::class); + Route::any('push/{method}/{action}', PushController::class); }); /**