feat(push): 移动端推送从友盟替换为 DooPush 通道

新移动端 (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) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-17 06:55:07 +00:00
parent a19822a617
commit 9151939ba5
7 changed files with 270 additions and 28 deletions

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api;
use App\Module\Base;
use App\Module\Doo;
use App\Services\DooPushClient;
use Request;
/**
* 推送相关接口DooPush 通道)。
*
* 路由api/push/{method}(见 routes/web.php
* - registerApp 端登录后上报 deviceToken + userid后端调 DooPush userid 标签
* - unregisterApp 端登出/换号时调用,后端调 DooPush 解除标签
*/
class PushController extends AbstractController
{
/**
* 绑定推送设备到当前会员(设置 DooPush userid 标签)。
*
* @apiParam {String} device_token DooPush SDK 注册返回的 deviceToken不是平台 token
*/
public function register__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::setUserTag($token, $userid);
if (!$resp->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');
}
}

View File

@ -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);

View File

@ -0,0 +1,58 @@
<?php
namespace App\Module\Push;
use App\Models\WebSocketDialogMsgRead;
use App\Services\DooPushClient;
/**
* 推送业务编排DooPush 通道)。
*
* UmengAlias::pushMsgToUserid DooPushClient::enabled() 时委托到此处;
* Umeng 链路仍在 UmengAlias 内部保留,不动 private 方法的可见性。
*/
class PushService
{
/**
* DooPush userid 标签 OR 并集定向推送badge 按每个 user 单独取未读数。
*
* DooPush /push 一次只能下发一个 badge需按 badge 分组合并下发以减少 HTTP 次数。
*
* @param array<int>|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());
}
}
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
/**
* DooPush 服务端 HTTP 客户端封装:
*
* - 发推送POST /apps/{appId}/push
* - 设设备标签POST /apps/{appId}/device-tags/{deviceToken}
* - 删设备标签DELETE /apps/{appId}/device-tags/{deviceToken}/{tagName}
*
* 鉴权使用 X-API-KeyDooPush 文档推荐 Header 方式)。
* 配置来源config('dootask.doopush')。未启用或未配置时由调用方判断;本类不做静默降级。
*/
class DooPushClient
{
public static function enabled(): bool
{
$cfg = (array) config('dootask.doopush', []);
return ($cfg['enabled'] ?? false)
&& !empty($cfg['app_id'])
&& !empty($cfg['api_key']);
}
/**
* 推送给若干 userid userid 标签 OR 并集定向)。
*
* @param array<int> $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;
}
}

View File

@ -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'),
],
];

View File

@ -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 | |
## approveApproveController
| 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 | 查询需要我审批的流程数量 |
## assistantAssistantController
| URL | 方法名 | HTTP | 说明 |
@ -390,3 +364,10 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
## pushPushController
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/push/register/index | register__index() | any | |
| api/push/unregister/index | unregister__index() | any | |

View File

@ -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);
});
/**