feat(push): 移除友盟推送,APP 推送改为系统配置 / 自定义 / 关闭三档

- 删除友盟别名接口、模型、日志清理及 hedeqiang/umeng 依赖,迁移删除 umeng_alias / umeng_logs 表
- 系统配置(默认):通过官方 DooPush 共享通道自助注册租户,按 App 上报的推送 Token 绑定设备
- 自定义:沿用自有 DooPush 应用凭证;关闭:不推送也不记录设备
- 老站点升级后统一为系统配置,原自定义凭证保留
- PushUmengMsg 更名为 PushAppMsg;同步语言包、API 对照表与 ai-kb
This commit is contained in:
kuaifan 2026-10-19 15:46:16 +00:00
parent 9bdf23bec1
commit 531129f48d
75 changed files with 719 additions and 794 deletions

View File

@ -16,7 +16,7 @@ class PushController extends InvokeController
/**
* @api {post} api/push/device 更新或移除 DooPush 设备
*
* @apiDescription 同时支持 GET 和 POST。update 需要有效 token 和 ios/android 平台;remove 仅按请求头 token 的 MD5 匹配解绑,允许 token 已失效。
* @apiDescription 同时支持 GET 和 POST。update 需要有效 token 和 ios/android 平台,推送关闭时不记录;remove 仅按请求头 token 的 MD5 匹配解绑,允许 token 已失效。
* @apiVersion 1.0.0
* @apiGroup push
* @apiName device
@ -24,7 +24,7 @@ class PushController extends InvokeController
* @apiParam {String="update","remove"} [action=update] 操作类型
* @apiParam {String} device_id DooPush 设备ID,1至64位纯数字字符串
* @apiParam {String="apns","fcm","hms","honor","xiaomi","oppo","vivo","meizu"} [vendor] update 时必填的厂商通道
* @apiParam {String} [unique_id] 壳的唯一标识,至少5个字符时清理同后缀旧友盟别名
* @apiParam {String} [push_token] 设备推送Token,系统配置模式下 update 必填(用于绑定到本站租户)
* @apiParam {String} [device_model] 设备型号,最多255个字符
* @apiParam {String} [app_version] 应用版本代码
* @apiParam {String} [app_version_name] 应用版本名称,与代码组合后最多100个字符
@ -59,7 +59,7 @@ class PushController extends InvokeController
if (!in_array($data['vendor'] ?? '', ['apns', 'fcm', 'hms', 'honor', 'xiaomi', 'oppo', 'vivo', 'meizu'], true)) {
return Base::retError('推送厂商错误');
}
foreach (['unique_id' => 255, 'device_model' => 255, 'app_version' => 100, 'app_version_name' => 100, 'user_agent' => 65535] as $field => $limit) {
foreach (['push_token' => 500, 'device_model' => 255, 'app_version' => 100, 'app_version_name' => 100, 'user_agent' => 65535] as $field => $limit) {
if (isset($data[$field]) && (!is_string($data[$field]) || mb_strlen($data[$field]) > $limit)) {
return Base::retError('参数错误');
}
@ -76,7 +76,7 @@ class PushController extends InvokeController
'version' => $version,
'ua' => $data['user_agent'] ?? '',
'is_notified' => in_array($data['is_notified'] ?? false, [true, 'true', 1, '1'], true) ? 1 : 0,
], $data['unique_id'] ?? '');
], $data['push_token'] ?? '');
return Base::retSuccess('保存成功');
}
}

View File

@ -13,6 +13,7 @@ use Response;
use Carbon\Carbon;
use App\Module\Doo;
use App\Models\User;
use App\Module\AppPush\AppPush;
use App\Module\Base;
use App\Module\OnlineLicense;
use App\Module\SystemUpdate;
@ -560,7 +561,7 @@ class SystemController extends AbstractController
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:['push', 'ios_key', 'ios_secret', 'android_key', 'android_secret', 'doopush', 'doopush_app_id', 'doopush_app_secret', 'doopush_base_url'])
* - save: 保存设置(参数:['doopush'(system 系统配置、custom 自定义、close 关闭), 'doopush_app_id', 'doopush_app_secret', 'doopush_base_url'])
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@ -577,11 +578,6 @@ class SystemController extends AbstractController
$all = Request::input();
foreach ($all as $key => $value) {
if (!in_array($key, [
'push',
'ios_key',
'ios_secret',
'android_key',
'android_secret',
'doopush',
'doopush_app_id',
'doopush_app_secret',
@ -590,13 +586,15 @@ class SystemController extends AbstractController
unset($all[$key]);
}
}
if (!in_array($all['doopush'] ?? '', ['system', 'custom', 'close'], true)) {
return Base::retError('推送模式错误');
}
$setting = Base::setting('appPushSetting', Base::newTrim($all));
} else {
$setting = Base::setting('appPushSetting');
}
//
$setting['push'] = $setting['push'] ?: 'close';
$setting['doopush'] = ($setting['doopush'] ?? '') ?: 'close';
$setting['doopush'] = AppPush::mode();
$setting['doopush_app_id'] = $setting['doopush_app_id'] ?? '';
$setting['doopush_app_secret'] = $setting['doopush_app_secret'] ?? '';
$setting['doopush_base_url'] = $setting['doopush_base_url'] ?? '';

View File

@ -19,7 +19,6 @@ use App\Models\ProjectTask;
use App\Models\ProjectTaskFile;
use App\Models\UserBot;
use App\Models\WebSocket;
use App\Models\UmengAlias;
use App\Models\UserDelete;
use App\Models\UserDevice;
use App\Models\UserTransfer;
@ -1514,85 +1513,6 @@ class UsersController extends AbstractController
return Base::retSuccess('绑定邮箱成功');
}
/**
* @api {get} api/users/umeng/alias 设置友盟别名
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName umeng__alias
*
* @apiParam {String} action
* - update: 更新(默认)
* - remove: 删除
* @apiParam {String} alias 别名
* @apiParam {String} [userAgent] 浏览器信息
* @apiParam {String} [deviceModel] 设备型号
* @apiParam {String} [isNotified] 是否有通知权限(0不通知、1通知)
* @apiParam {Number} [isDebug] 是否调试(0不调试、1调试)
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function umeng__alias()
{
$data = Request::input();
// 判断是否调试
if (intval($data['isDebug'])) {
return Base::retError('调试模式下不允许使用');
}
// 表单验证
Base::validator($data, [
'alias.required' => '别名不能为空',
'alias.between:2,20' => '别名的长度在2-20个字符',
]);
//
if ($data['action'] === 'remove') {
if ($data['alias']) {
UmengAlias::whereAlias($data['alias'])->delete();
}
return Base::retSuccess('删除成功');
}
//
if (!in_array(Base::platform(), ['ios', 'android'])) {
return Base::retError('设备类型错误');
}
//
$user = User::auth();
$inArray = [
'userid' => $user->userid,
'alias' => $data['alias'],
'platform' => Base::platform(),
];
$version = $data['appVersion'] ? ($data['appVersionName'] . " ({$data['appVersion']})") : '';
$isNotified = trim($data['isNotified']) === 'true' || $data['isNotified'] === true ? 1 : intval($data['isNotified']);
$row = UmengAlias::where($inArray);
if ($row->exists()) {
$row->update([
'ua' => $data['userAgent'],
'device' => $data['deviceModel'],
'device_hash' => UserDevice::check(),
'version' => $version,
'is_notified' => $isNotified,
'updated_at' => Carbon::now()
]);
return Base::retSuccess('别名已存在');
}
$row = UmengAlias::createInstance(array_merge($inArray, [
'ua' => $data['userAgent'],
'device' => $data['deviceModel'],
'device_hash' => UserDevice::check(),
'version' => $version,
'is_notified' => $isNotified,
]));
if ($row->save()) {
return Base::retSuccess('添加成功');
} else {
return Base::retError('添加错误');
}
}
/**
* @api {get} api/users/meeting/open 【会议】创建会议、加入会议
*

View File

@ -264,7 +264,6 @@ class IndexController extends InvokeController
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务

View File

@ -1,280 +0,0 @@
<?php
namespace App\Models;
use App\Module\Base;
use Carbon\Carbon;
use Hedeqiang\UMeng\Android;
use Hedeqiang\UMeng\IOS;
/**
* App\Models\UmengAlias
*
* @property int $id
* @property int|null $userid 会员ID
* @property string|null $alias 别名
* @property string|null $platform 平台类型
* @property string|null $device 设备类型
* @property string|null $device_hash 设备哈希值,用于关联UserDevice表
* @property string|null $version 应用版本号
* @property string|null $ua userAgent
* @property int|null $is_notified 通知权限
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDevice($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDeviceHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereIsNotified($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUa($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereVersion($value)
* @mixin \Eloquent
*/
class UmengAlias extends AbstractModel
{
protected $table = 'umeng_alias';
private static $waitSend = [];
/**
* 推送消息
* @param $push
* @return void
*/
private static function sendTask($push = null)
{
if ($push) {
self::$waitSend[] = $push;
}
if (!self::$waitSend) {
return;
}
$first = array_shift(self::$waitSend);
if (empty($first)) {
return;
}
$instance = null;
$responsePayload = null;
try {
switch ($first['platform']) {
case 'ios':
$instance = new IOS($first['config']);
break;
case 'android':
$instance = new Android($first['config']);
break;
default:
return;
}
$responsePayload = $instance->send($first['data']);
} catch (\Exception $e) {
$responsePayload = [
'error' => $e->getMessage(),
];
$first['retry'] = intval($first['retry'] ?? 0) + 1;
if ($first['retry'] > 3) {
info("[PushMsg] fail: " . $e->getMessage());
} else {
info("[PushMsg] retry ({$first['retry']}): " . $e->getMessage());
self::$waitSend[] = $first;
}
} finally {
if ($instance !== null) {
UmengLog::create([
'request' => Base::array2json($first['data']),
'response' => Base::array2json($responsePayload),
]);
}
self::sendTask();
}
}
/**
* 推送内容处理
* @param $string
* @return string
*/
private static function specialCharacters($string)
{
return str_replace(["\r\n", "\r", "\n"], '', $string);
}
/**
* 获取推送配置
* @return array|false
*/
public static function getPushConfig()
{
$setting = Base::setting('appPushSetting');
if ($setting['push'] !== 'open') {
return false;
}
$config = [];
if ($setting['ios_key']) {
$config['iOS'] = [
'appKey' => $setting['ios_key'],
'appMasterSecret' => $setting['ios_secret'],
'production_mode' => true,
];
}
if ($setting['android_key']) {
$config['Android'] = [
'appKey' => $setting['android_key'],
'appMasterSecret' => $setting['android_secret'],
'production_mode' => true,
];
}
return $config;
}
/**
* 推送消息
* @param string $alias
* @param string $platform
* @param array $array [title, subtitle, body, description, extra, seconds, badge]
* @return void
*/
private static function pushMsgToAlias($alias, $platform, $array)
{
$config = self::getPushConfig();
if ($config === false) {
return;
}
//
$title = self::specialCharacters($array['title'] ?: ''); // 标题
$subtitle = self::specialCharacters($array['subtitle'] ?: ''); // 副标题(iOS)
$body = self::specialCharacters($array['body'] ?: ''); // 通知内容
$description = $array['description'] ?: 'no description'; // 描述
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
$badge = intval($array['badge']) ?: 0; // 角标数
//
switch ($platform) {
case 'ios':
if (!isset($config['iOS'])) {
return;
}
self::sendTask([
'platform' => $platform,
'config' => $config,
'data' => [
'description' => $description,
'payload' => array_merge([
'aps' => [
'alert' => [
'title' => $title,
'subtitle' => $subtitle,
'body' => $body,
],
'sound' => 'default',
'badge' => $badge,
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
]
]);
break;
case 'android':
if (!isset($config['Android'])) {
return;
}
self::sendTask([
'platform' => $platform,
'config' => $config,
'data' => [
'description' => $description,
'payload' => array_merge([
'display_type' => 'notification',
'body' => [
'ticker' => $title,
'text' => $body,
'title' => $title,
'after_open' => 'go_app',
'play_sound' => true,
'set_badge' => min(99, $badge),
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'mipush' => true,
'mi_activity' => 'app.eeui.umeng.activity.MfrMessageActivity',
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'category' => 1,
'channel_properties' => [
'main_activity' => 'com.dootask.task.WelcomeActivity',
'oppo_channel_id' => 'dootask',
'vivo_category' => 'IM',
'huawei_channel_importance' => 'NORMAL',
'huawei_channel_category' => 'IM',
'channel_fcm' => 0,
],
'local_properties' => [
'importance' => 'IMPORTANCE_DEFAULT',
'category' => 'CATEGORY_MESSAGE',
]
]
]);
break;
}
}
/**
* 推送给指定会员
* @param array|int $userid
* @param array $array
* @return void
*/
public static function pushMsgToUserid($userid, $array)
{
$builder = self::select(['id', 'platform', 'alias', 'userid'])->where('updated_at', '>', Carbon::now()->subMonth());
if (is_array($userid)) {
$builder->whereIn('userid', $userid);
} elseif (Base::isNumber($userid)) {
$builder->whereUserid($userid);
}
$builder
->orderByDesc('updated_at')
->chunkById(100, function ($datas) use ($array) {
$uids = $datas->groupBy('userid');
foreach ($uids as $uid => $rows) {
$array['badge'] = WebSocketDialogMsgRead::whereUserid($uid)->whereSilence(0)->whereReadAt(null)->count();
$lists = $rows->take(5)->groupBy('platform'); // 每个会员最多推送5个别名
foreach ($lists as $platform => $list) {
$alias = $list->pluck('alias')->implode(',');
try {
self::pushMsgToAlias($alias, $platform, $array);
} catch (\Exception $e) {
info("[PushMsg] fail: " . $e->getMessage());
}
}
}
});
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\UmengLog
*
* @property int $id
* @property string|null $request 请求参数
* @property string|null $response 推送返回
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
* @mixin \Eloquent
*/
class UmengLog extends AbstractModel
{
protected $guarded = [];
}

View File

@ -306,7 +306,6 @@ class UserDevice extends AbstractModel
}
if (isset($hash)) {
Cache::forget(self::ck($hash));
UmengAlias::whereDeviceHash($hash)->delete();
AppPushDevice::whereDeviceHash($hash)->delete();
}
}

View File

@ -2,15 +2,41 @@
namespace App\Module\AppPush;
use App\Models\UmengAlias;
use App\Module\Base;
class AppPush
{
/** 系统配置:官方 DooPush 共享通道(租户),设备 ID 由租户绑定返回 */
public const CHANNEL_SYSTEM = 'doopush_system';
/** 自定义:管理员自行配置的 DooPush 应用 */
public const CHANNEL_CUSTOM = 'doopush';
/**
* 推送模式:system(系统配置,默认)、custom(自定义)、close(关闭)
*/
public static function mode(): string
{
$mode = Base::setting('appPushSetting')['doopush'] ?? '';
return match ($mode) {
'custom' => 'custom',
'close' => 'close',
default => 'system',
};
}
public static function channel(): ?string
{
return match (self::mode()) {
'system' => self::CHANNEL_SYSTEM,
'custom' => self::CHANNEL_CUSTOM,
default => null,
};
}
public static function enabled(): bool
{
$setting = Base::setting('appPushSetting');
return ($setting['push'] ?? 'close') === 'open' || ($setting['doopush'] ?? 'close') === 'open';
return self::mode() !== 'close';
}
/**
@ -19,14 +45,6 @@ class AppPush
*/
public static function pushToUsers(array|int $userids, array $msg): void
{
$setting = Base::setting('appPushSetting');
if (($setting['push'] ?? 'close') === 'open') {
try {
UmengAlias::pushMsgToUserid($userids, $msg);
} catch (\Throwable $e) {
info('[AppPush] Umeng failed: ' . $e->getMessage());
}
}
app(DooPushClient::class)->pushToUsers($userids, $msg);
}
}

View File

@ -4,37 +4,38 @@ namespace App\Module\AppPush;
use App\Exceptions\ApiException;
use App\Models\AppPushDevice;
use App\Models\UmengAlias;
use App\Models\UserDevice;
class DeviceRegistry
{
/**
* 绑定当前登录设备,并在同一事务中清理旧壳别名。
* 绑定当前登录设备;系统配置下先按推送 Token 绑定到本站租户,以 DooPush 返回的设备ID为准。
*/
public static function update(int $userid, array $attributes, string $uniqueId = ''): void
public static function update(int $userid, array $attributes, string $pushToken = ''): void
{
$hash = UserDevice::check();
if (!$hash) {
throw new ApiException('身份已失效,请重新登录', [], -1);
}
AppPushDevice::transaction(function () use ($userid, $attributes, $uniqueId, $hash) {
$device = AppPushDevice::createInstance(array_merge($attributes, [
'userid' => $userid,
'channel' => 'doopush',
'device_hash' => $hash,
]));
// 唯一键原子 upsert,设备切换帐号或并发上报时保留一条绑定。
AppPushDevice::upsert([$device->getAttributes()], ['channel', 'device_id'], [
'userid', 'vendor', 'platform', 'device_hash', 'device', 'version', 'ua', 'is_notified', 'updated_at',
]);
UmengAlias::whereDeviceHash($hash)->delete();
if (mb_strlen($uniqueId) >= 5) {
// 显式使用 ! 转义,避免 %、_ 或反斜杠扩大别名匹配范围。
$suffix = strtr($uniqueId, ['!' => '!!', '%' => '!%', '_' => '!_']);
UmengAlias::whereRaw("alias LIKE ? ESCAPE '!'", ['%-' . $suffix])->delete();
$channel = AppPush::channel();
if ($channel === null) {
return;
}
if ($channel === AppPush::CHANNEL_SYSTEM) {
if ($pushToken === '') {
throw new ApiException('推送Token不能为空');
}
});
$attributes['device_id'] = app(DooPushClient::class)->bindDevice($pushToken);
}
$device = AppPushDevice::createInstance(array_merge($attributes, [
'userid' => $userid,
'channel' => $channel,
'device_hash' => $hash,
]));
// 唯一键原子 upsert,设备切换帐号或并发上报时保留一条绑定。
AppPushDevice::upsert([$device->getAttributes()], ['channel', 'device_id'], [
'userid', 'vendor', 'platform', 'device_hash', 'device', 'version', 'ua', 'is_notified', 'updated_at',
]);
}
/**
@ -46,6 +47,14 @@ class DeviceRegistry
return;
}
// 与 UserDevice::check()/forget() 共用 TOKEN MD5 算法,不依赖 token 解码。
AppPushDevice::whereChannel('doopush')->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->delete();
$channels = AppPushDevice::whereIn('channel', [AppPush::CHANNEL_SYSTEM, AppPush::CHANNEL_CUSTOM])
->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->pluck('channel')->all();
if (!$channels) {
return;
}
AppPushDevice::whereIn('channel', $channels)->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->delete();
if (in_array(AppPush::CHANNEL_SYSTEM, $channels, true)) {
app(DooPushClient::class)->unbindDevice($deviceId);
}
}
}

View File

@ -2,21 +2,26 @@
namespace App\Module\AppPush;
use App\Exceptions\ApiException;
use App\Models\AppPushDevice;
use App\Models\Setting;
use App\Models\WebSocketDialogMsgRead;
use App\Module\Base;
use App\Module\Ihttp;
use Cache;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Carbon\Carbon;
class DooPushClient
{
/** 系统配置的租户凭证(不经推送设置接口下发前端) */
private const TENANT_SETTING = 'appPushTenant';
public function pushToUsers(array|int $userids, array $msg): void
{
try {
$setting = Base::setting('appPushSetting');
$appId = trim((string)($setting['doopush_app_id'] ?? ''));
$secret = trim((string)($setting['doopush_app_secret'] ?? ''));
if (($setting['doopush'] ?? 'close') !== 'open' || $appId === '' || $secret === '') {
$channel = AppPush::channel();
if ($channel === null) {
return;
}
$content = str_replace(["\r\n", "\r", "\n"], '', $msg['body'] ?? '');
@ -25,6 +30,23 @@ class DooPushClient
if ($title === '' || trim($content) === '') {
return;
}
if ($channel === AppPush::CHANNEL_CUSTOM) {
$setting = Base::setting('appPushSetting');
$appId = trim((string)($setting['doopush_app_id'] ?? ''));
$secret = trim((string)($setting['doopush_app_secret'] ?? ''));
if ($appId === '' || $secret === '') {
return;
}
$baseUrl = rtrim(trim((string)($setting['doopush_base_url'] ?? '')), '/') ?: 'https://doopush.com/api/v1';
$url = $baseUrl . '/apps/' . rawurlencode($appId) . '/push';
} else {
$tenant = $this->tenant();
if (!$tenant) {
return;
}
$secret = $tenant['tenant_secret'];
$url = $this->systemUrl('/tenant/push');
}
$extra = [];
foreach (is_array($msg['extra'] ?? null) ? $msg['extra'] : [] as $key => $value) {
$extra[$key] = is_scalar($value) || $value === null ? (string)$value : Base::array2json($value);
@ -37,8 +59,6 @@ class DooPushClient
// DooPush 的 vivo 通道使用 classification,而非 category。
'vivo' => ['classification' => 1],
]);
$baseUrl = rtrim(trim((string)($setting['doopush_base_url'] ?? '')), '/') ?: 'https://doopush.com/api/v1';
$url = $baseUrl . '/apps/' . rawurlencode($appId) . '/push';
$headers = ['Authorization' => 'Bearer ' . $secret, 'Content-Type' => 'application/json'];
$userids = array_unique(array_map('intval', (array)$userids));
foreach ($userids as $userid) {
@ -47,7 +67,7 @@ class DooPushClient
}
try {
// 按用户单独取最新5台,避免跨查询分块时重复发送或突破设备数限制。
$deviceIds = AppPushDevice::whereChannel('doopush')->whereUserid($userid)
$deviceIds = AppPushDevice::whereChannel($channel)->whereUserid($userid)
->where('updated_at', '>', Carbon::now()->subMonth())
->orderByDesc('updated_at')->orderByDesc('id')->limit(5)
->pluck('device_id')
@ -58,13 +78,31 @@ class DooPushClient
continue;
}
$badge = WebSocketDialogMsgRead::whereUserid($userid)->whereSilence(0)->whereReadAt(null)->count();
$this->send($url, $headers, [
$body = [
'title' => mb_substr($title, 0, 200),
'content' => $content,
'badge' => $badge,
'payload' => $payload,
'target' => ['type' => 'devices', 'device_ids' => $deviceIds],
], $userid);
];
if ($channel === AppPush::CHANNEL_CUSTOM) {
$this->send('POST', $url, $headers, $body + ['target' => ['type' => 'devices', 'device_ids' => $deviceIds]], $userid);
continue;
}
[$status, $result] = $this->send('POST', $url, $headers, $body + ['device_ids' => $deviceIds], $userid);
if ($status === 200) {
// 设备已被其他租户(其他服务器)绑定,本地不再推送。
$unbound = array_map('strval', (array)($result['data']['unbound_device_ids'] ?? []));
if ($unbound) {
AppPushDevice::whereChannel($channel)->whereIn('device_id', $unbound)->delete();
}
} elseif ($status === 401) {
// 租户已失效,下次绑定时重新注册;本轮不再继续请求。
$this->forgetTenant($secret);
return;
} elseif ($status === 403 || $status === 429) {
// 租户被禁用或超出今日配额,本轮其余用户同样会失败。
return;
}
} catch (\Throwable $e) {
info('[DooPush] user push failed: ' . $e->getMessage(), ['userid' => $userid]);
}
@ -74,16 +112,140 @@ class DooPushClient
}
}
private function send(string $url, array $headers, array $body, int $userid): void
/**
* 系统配置:按推送 Token 将设备绑定到本站租户,返回 DooPush 设备ID
*/
public function bindDevice(string $pushToken): string
{
// 首次发送 + 最多2次重试;不在长生命周期进程中保存待发送状态。
for ($attempt = 1; $attempt <= 3; $attempt++) {
for ($attempt = 1; $attempt <= 2; $attempt++) {
$tenant = $this->tenant(true);
if (!$tenant) {
throw new ApiException('推送服务注册失败,请稍后再试');
}
[$status, $result] = $this->send('POST', $this->systemUrl('/tenant/devices'), [
'Authorization' => 'Bearer ' . $tenant['tenant_secret'],
'Content-Type' => 'application/json',
], ['token' => $pushToken]);
$deviceId = (string)($result['data']['device_id'] ?? '');
if ($status === 200 && preg_match('/^[1-9][0-9]{0,63}$/D', $deviceId)) {
return $deviceId;
}
if ($status === 401) {
// 租户已被删除或密钥失效:重新注册后再试一次。
$this->forgetTenant($tenant['tenant_secret']);
continue;
}
if ($status === 404) {
throw new ApiException('推送设备未注册,请稍后再试');
}
if ($status === 403) {
throw new ApiException('推送服务已被停用');
}
break;
}
throw new ApiException('推送设备绑定失败,请稍后再试');
}
/**
* 系统配置:解除设备与本站租户的绑定(失败不影响退出登录)
*/
public function unbindDevice(string $deviceId): void
{
try {
$tenant = $this->tenant(false);
if ($tenant) {
$this->send('DELETE', $this->systemUrl('/tenant/devices/' . rawurlencode($deviceId)), [
'Authorization' => 'Bearer ' . $tenant['tenant_secret'],
], [], 0, 1);
}
} catch (\Throwable $e) {
info('[DooPush] unbind failed: ' . $e->getMessage());
}
}
private function systemUrl(string $path): string
{
$config = config('dootask.doopush_system');
return rtrim($config['base_url'], '/') . '/apps/' . rawurlencode((string)$config['app_id']) . $path;
}
/**
* 读取租户凭证;切换官方地址或应用后视为未注册。$register 为 true 时按需自助注册。
*/
private function tenant(bool $register = false): ?array
{
$tenant = $this->storedTenant();
if ($tenant || !$register) {
return $tenant;
}
// 多进程并发绑定时只注册一次,其余进程等待后复用。
try {
return Cache::lock('doopush:tenant-register', 30)->block(15, function () {
return $this->storedTenant() ?: $this->registerTenant();
});
} catch (LockTimeoutException) {
return $this->storedTenant();
}
}
private function storedTenant(): ?array
{
// 直接读库:凭证可能刚被其他进程更新,不使用请求级缓存。
$tenant = Base::string2array(Setting::whereName(self::TENANT_SETTING)->value('setting'));
$config = config('dootask.doopush_system');
if (empty($tenant['tenant_secret'])
|| ($tenant['base_url'] ?? '') !== $config['base_url']
|| ($tenant['app_id'] ?? '') !== (string)$config['app_id']) {
return null;
}
return $tenant;
}
private function registerTenant(): ?array
{
$config = config('dootask.doopush_system');
[$status, $result] = $this->send('POST', $this->systemUrl('/tenants'), [
'X-App-Key' => $config['app_key'],
'Content-Type' => 'application/json',
], ['name' => 'DooTask', 'site_url' => mb_substr(Base::getSchemeAndHost(), 0, 255)]);
$secret = (string)($result['data']['tenant_secret'] ?? '');
if ($status !== 201 || $secret === '') {
info('[DooPush] tenant register failed', ['status' => $status, 'message' => $result['message'] ?? '']);
return null;
}
$tenant = [
'base_url' => $config['base_url'],
'app_id' => (string)$config['app_id'],
'tenant_id' => (int)($result['data']['tenant_id'] ?? 0),
'tenant_secret' => $secret,
];
Base::setting(self::TENANT_SETTING, $tenant);
return $tenant;
}
private function forgetTenant(string $secret): void
{
$tenant = $this->storedTenant();
// 仅清理失效的那份凭证,避免覆盖其他进程刚注册的新凭证。
if ($tenant && $tenant['tenant_secret'] === $secret) {
Base::setting(self::TENANT_SETTING, []);
}
}
/**
* 发送请求,网络错误或 5xx 时最多重试2次;返回 [HTTP 状态码(网络失败为 0), 响应 JSON]
*/
private function send(string $method, string $url, array $headers, array $body, int $userid = 0, int $attempts = 3): array
{
$status = 0;
$result = [];
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
$response = $this->request($url, $headers, $body);
$response = $this->request($url, $headers, $body, $method);
$result = Base::json2array($response['data'] ?? '');
$status = (int)($response['msg'] ?? 0);
if (Base::isSuccess($response) && $status >= 200 && $status < 300 && ($result['code'] ?? 0) === 200) {
return;
$status = Base::isSuccess($response) ? (int)($response['msg'] ?? 0) : 0;
if ($status >= 200 && $status < 300 && in_array($result['code'] ?? 0, [200, 201], true)) {
return [$status, $result];
}
info('[DooPush] send failed', [
'userid' => $userid,
@ -91,17 +253,29 @@ class DooPushClient
'status' => $status,
'message' => mb_substr((string)($result['message'] ?? $response['msg'] ?? 'Invalid response'), 0, 500),
]);
if ($status >= 400 && $status < 500 && isset($result['code'])) {
// 业务拒绝(鉴权、参数、配额等)重试无意义。
return [$status, $result];
}
} catch (\Throwable $e) {
$status = 0;
info('[DooPush] send failed: ' . $e->getMessage(), ['userid' => $userid, 'attempt' => $attempt]);
}
}
// 2xx 但响应无效时按网络失败处理。
return [$status >= 200 && $status < 300 ? 0 : $status, $result];
}
protected function request(string $url, array $headers, array $body): array
protected function request(string $url, array $headers, array $body, string $method = 'POST'): array
{
return Ihttp::ihttp_request($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), array_merge($headers, [
$post = $method === 'POST' ? json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) : '';
$options = [
'CURLOPT_SSL_VERIFYPEER' => true,
'CURLOPT_SSL_VERIFYHOST' => 2,
]), 10);
];
if ($method !== 'POST') {
$options['CURLOPT_CUSTOMREQUEST'] = $method;
}
return Ihttp::ihttp_request($url, $post, array_merge($headers, $options), 10);
}
}

View File

@ -6,7 +6,6 @@ use App\Models\File;
use App\Models\TaskWorker;
use App\Models\Tmp;
use App\Models\UserDevice;
use App\Models\UmengLog;
use App\Models\WebSocketTmpMsg;
use App\Module\Base;
use Carbon\Carbon;
@ -125,17 +124,6 @@ class DeleteTmpTask extends AbstractTask
}
});
break;
case 'umeng_log':
UmengLog::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($logs) {
/** @var UmengLog $log */
foreach ($logs as $log) {
$log->delete();
}
});
break;
}
}

View File

@ -7,9 +7,9 @@ use Cache;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* 推送 APP 消息(友盟 / DooPush)
* 推送 APP 消息(DooPush)
*/
class PushUmengMsg extends AbstractTask
class PushAppMsg extends AbstractTask
{
protected $userid = 0;
protected $array = [];
@ -93,7 +93,7 @@ class PushUmengMsg extends AbstractTask
return;
}
foreach ($this->endPush as $item) {
$task = new PushUmengMsg($item['userid'], $item['array']);
$task = new PushAppMsg($item['userid'], $item['array']);
$task->delay(10);
Task::deliver($task);
}

View File

@ -165,7 +165,7 @@ class WebSocketDialogMsgTask extends AbstractTask
]);
}
// 开始推送消息
$umengUserid = [];
$appUserid = [];
foreach ($array as $item) {
$this->endPush[] = [
'userid' => $item['userid'],
@ -183,25 +183,25 @@ class WebSocketDialogMsgTask extends AbstractTask
]
];
if ($item['userid'] != $msg->userid && !$item['silence'] && !$this->silence) {
$umengUserid[] = $item['userid'];
$appUserid[] = $item['userid'];
}
}
// APP 推送(友盟 / DooPush)
if ($umengUserid) {
// APP 推送(DooPush)
if ($appUserid) {
if (AppPush::enabled()) {
if ($msg->userid == -1) {
// AI 助手虚拟用户没有会员记录,取自定义昵称或默认名称
$umengTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');
$appTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');
} else {
$umengTitle = User::userid2nickname($msg->userid);
$appTitle = User::userid2nickname($msg->userid);
}
$umengBody = WebSocketDialogMsg::previewMsg($msg);
$appBody = WebSocketDialogMsg::previewMsg($msg);
if ($dialog->type == 'group') {
$umengBody = $umengTitle . ': ' . $umengBody;
$umengTitle = $dialog->getGroupName();
$appBody = $appTitle . ': ' . $appBody;
$appTitle = $dialog->getGroupName();
}
$langs = User::select(['userid', 'lang'])
->whereIn('userid', $umengUserid)
->whereIn('userid', $appUserid)
->whereDisableAt(null)
->get()
->groupBy('lang')
@ -210,9 +210,9 @@ class WebSocketDialogMsgTask extends AbstractTask
});
foreach ($langs as $lang => $uids) {
Doo::setLanguage($lang);
$umengMsg = [
'title' => $umengTitle,
'body' => $umengBody,
$appMsg = [
'title' => $appTitle,
'body' => $appBody,
'description' => "MID:{$msg->id}",
'seconds' => 3600,
'badge' => 1,
@ -221,7 +221,7 @@ class WebSocketDialogMsgTask extends AbstractTask
'msg_id' => $msg->id,
]
];
$this->endArray[] = new PushUmengMsg($uids->toArray(), $umengMsg);
$this->endArray[] = new PushAppMsg($uids->toArray(), $appMsg);
}
}
}

View File

@ -23,7 +23,6 @@
"directorytree/ldaprecord-laravel": "^4.0",
"firebase/php-jwt": "^7.1",
"guzzlehttp/guzzle": "^7.3.0",
"hedeqiang/umeng": "^2.1",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"laravolt/avatar": "^6.5",

68
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9563bf2796d1179ba8740789be1ef49b",
"content-hash": "3ce4ce992d9f646b9ad6173c4f0d8605",
"packages": [
{
"name": "brick/math",
@ -1481,72 +1481,6 @@
],
"time": "2026-05-23T22:00:21+00:00"
},
{
"name": "hedeqiang/umeng",
"version": "v2.1.6",
"source": {
"type": "git",
"url": "https://github.com/hedeqiang/UMeng-Push.git",
"reference": "f7556dd390e94d908fae715ed2978c7265f5f957"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hedeqiang/UMeng-Push/zipball/f7556dd390e94d908fae715ed2978c7265f5f957",
"reference": "f7556dd390e94d908fae715ed2978c7265f5f957",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.3|^7.0",
"php": ">=7.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19|^3.8"
},
"type": "library",
"extra": {
"hyperf": {
"config": "Hedeqiang\\UMeng\\ConfigProvider"
},
"laravel": {
"providers": [
"Hedeqiang\\UMeng\\PushServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Hedeqiang\\UMeng\\": "./src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "hedeqiang",
"email": "laravel_code@163.com"
},
{
"name": "housemecn",
"email": "houseme@outlook.com"
}
],
"description": "友盟推送",
"keywords": [
"U-Push",
"php",
"sdk",
"umeng",
"友盟"
],
"support": {
"issues": "https://github.com/hedeqiang/UMeng-Push/issues",
"source": "https://github.com/hedeqiang/UMeng-Push/tree/v2.1.6"
},
"time": "2022-09-28T08:52:46+00:00"
},
{
"name": "intervention/gif",
"version": "5.0.1",

View File

@ -44,6 +44,13 @@ return [
// 临时文件自动清理天数(DeleteTmpTask)
'auto_empty_temp_file' => env('AUTO_EMPTY_TEMP_FILE', 30),
// APP 推送「系统配置」使用的官方 DooPush 应用(App Key 随客户端公开,不是密钥)
'doopush_system' => [
'base_url' => env('DOOPUSH_SYSTEM_BASE_URL', 'https://doopush.com/api/v1'),
'app_id' => env('DOOPUSH_SYSTEM_APP_ID', '10'),
'app_key' => env('DOOPUSH_SYSTEM_APP_KEY', 'dp_ak_eSqQPRXcjSR1xXtOrXGcHvjKGVEKarBA'),
],
// WebDAV 协议硬限制;用户可配置值不能超过这里的上限
'webdav' => [
'max_file_bytes' => 1024 * 1024 * 1024,

View File

@ -0,0 +1,35 @@
<?php
use App\Models\Setting;
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 移除友盟推送:删除别名、日志表及推送设置中的友盟参数,DooPush 开关迁移为三档模式
*/
public function up(): void
{
Schema::dropIfExists('umeng_alias');
Schema::dropIfExists('umeng_logs');
$row = Setting::whereName('appPushSetting')->first();
if ($row) {
$setting = Base::string2array($row->setting);
// DooPush 改为三档,升级后统一使用系统配置;原自定义凭证保留,需要时可切回「自定义」。
$setting['doopush'] = 'system';
foreach (['push', 'ios_key', 'ios_secret', 'android_key', 'android_secret'] as $key) {
unset($setting[$key]);
}
$row->updateInstance(['setting' => $setting]);
$row->save();
}
}
public function down(): void
{
// 删除表无法恢复
}
};

View File

@ -1094,3 +1094,9 @@ WebDAV 未启用或你没有使用权限
解除关联任务
推送设备ID必须为1至64位数字
推送厂商错误
推送Token不能为空
推送服务注册失败,请稍后再试
推送设备未注册,请稍后再试
推送服务已被停用
推送设备绑定失败,请稍后再试
推送模式错误

View File

@ -410,7 +410,6 @@ SMTP服务器
参数为空
参数配置
参数错误
友盟推送
发布成功
发送图片
发送成功
@ -2848,8 +2847,11 @@ DooPush 推送
App ID
App Secret
服务地址
新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。
DooTask 新版本 (*) 已发布,建议更新以获得更好的使用体验。
已是最新版本
检查更新失败,请稍后再试
当前服务器版本较低,请等待服务器升级后再更新
推送服务
系统配置
使用官方推送服务,无需配置。
使用自己的 DooPush 应用,需搭配自行打包的 App。

View File

@ -5903,18 +5903,6 @@
"id": "Kesalahan parameter",
"ru": "Ошибка параметра"
},
{
"key": "友盟推送",
"zh": "",
"zh-CHT": "友盟推送",
"en": "UMeng push",
"ko": "UMeng 푸시",
"ja": "UMengプッシュ",
"de": "UMeng-Push",
"fr": "Push UMeng",
"id": "Push UMeng",
"ru": "UMeng push"
},
{
"key": "发布成功",
"zh": "",
@ -41015,18 +41003,6 @@
"id": "URL layanan",
"ru": "URL сервиса"
},
{
"key": "新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。",
"zh": "",
"zh-CHT": "新版 App 使用 DooPush,舊版 App 使用友盟,兩者可同時開啟。",
"en": "New app versions use DooPush, and older versions use Umeng. Both can be enabled at the same time.",
"ko": "새 버전 앱은 DooPush를, 이전 버전 앱은 Umeng을 사용합니다. 두 서비스를 동시에 활성화할 수 있습니다.",
"ja": "新しいバージョンのアプリはDooPush、旧バージョンはUmengを使用します。両方を同時に有効にできます。",
"de": "Neue App-Versionen verwenden DooPush, ältere Versionen Umeng. Beide können gleichzeitig aktiviert werden.",
"fr": "Les nouvelles versions de l’application utilisent DooPush et les anciennes Umeng. Les deux peuvent être activés simultanément.",
"id": "Versi aplikasi baru menggunakan DooPush, sedangkan versi lama menggunakan Umeng. Keduanya dapat diaktifkan bersamaan.",
"ru": "Новые версии приложения используют DooPush, а старые — Umeng. Оба сервиса можно включить одновременно."
},
{
"key": "DooTask 新版本 (%T1) 已发布,建议更新以获得更好的使用体验。",
"zh": "",
@ -41098,5 +41074,125 @@
"fr": "La version du serveur est obsolète. Veuillez mettre à jour après la mise à niveau du serveur.",
"id": "Versi server saat ini lebih lama. Silakan perbarui setelah server ditingkatkan.",
"ru": "Версия сервера устарела. Обновите приложение после обновления сервера."
},
{
"key": "推送服务",
"zh": "",
"zh-CHT": "推送服務",
"en": "Push service",
"ko": "푸시 서비스",
"ja": "プッシュサービス",
"de": "Push-Dienst",
"fr": "Service de notifications",
"id": "Layanan push",
"ru": "Служба push-уведомлений"
},
{
"key": "系统配置",
"zh": "",
"zh-CHT": "系統配置",
"en": "System default",
"ko": "시스템 기본값",
"ja": "システム既定",
"de": "Systemstandard",
"fr": "Configuration système",
"id": "Bawaan sistem",
"ru": "Системная настройка"
},
{
"key": "使用官方推送服务,无需配置。",
"zh": "",
"zh-CHT": "使用官方推送服務,無需配置。",
"en": "Uses the official push service. No setup required.",
"ko": "공식 푸시 서비스를 사용하며 별도 설정이 필요 없습니다.",
"ja": "公式のプッシュサービスを使用します。設定は不要です。",
"de": "Verwendet den offiziellen Push-Dienst. Keine Einrichtung erforderlich.",
"fr": "Utilise le service de notifications officiel. Aucune configuration requise.",
"id": "Menggunakan layanan push resmi. Tidak perlu konfigurasi.",
"ru": "Используется официальная служба push-уведомлений. Настройка не требуется."
},
{
"key": "使用自己的 DooPush 应用,需搭配自行打包的 App。",
"zh": "",
"zh-CHT": "使用自己的 DooPush 應用,需搭配自行打包的 App。",
"en": "Uses your own DooPush app. Requires a self-built App.",
"ko": "자체 DooPush 앱을 사용하며, 직접 빌드한 App과 함께 사용해야 합니다.",
"ja": "独自の DooPush アプリを使用します。自前でビルドした App が必要です。",
"de": "Verwendet Ihre eigene DooPush-App. Erfordert eine selbst erstellte App.",
"fr": "Utilise votre propre application DooPush. Nécessite une App compilée par vos soins.",
"id": "Menggunakan aplikasi DooPush Anda sendiri. Memerlukan App yang dibangun sendiri.",
"ru": "Используется собственное приложение DooPush. Требуется самостоятельно собранное App."
},
{
"key": "推送Token不能为空",
"zh": "",
"zh-CHT": "推送Token不能為空",
"en": "Push token cannot be empty",
"ko": "푸시 토큰은 비워 둘 수 없습니다",
"ja": "プッシュトークンは必須です",
"de": "Push-Token darf nicht leer sein",
"fr": "Le jeton de notification ne peut pas être vide",
"id": "Token push tidak boleh kosong",
"ru": "Push-токен не может быть пустым"
},
{
"key": "推送服务注册失败,请稍后再试",
"zh": "",
"zh-CHT": "推送服務註冊失敗,請稍後再試",
"en": "Failed to register with the push service. Please try again later.",
"ko": "푸시 서비스 등록에 실패했습니다. 잠시 후 다시 시도하세요.",
"ja": "プッシュサービスへの登録に失敗しました。しばらくしてから再試行してください。",
"de": "Registrierung beim Push-Dienst fehlgeschlagen. Bitte später erneut versuchen.",
"fr": "Échec de l'inscription au service de notifications. Veuillez réessayer plus tard.",
"id": "Gagal mendaftar ke layanan push. Silakan coba lagi nanti.",
"ru": "Не удалось зарегистрироваться в службе push-уведомлений. Повторите попытку позже."
},
{
"key": "推送设备未注册,请稍后再试",
"zh": "",
"zh-CHT": "推送設備未註冊,請稍後再試",
"en": "Push device is not registered yet. Please try again later.",
"ko": "푸시 기기가 아직 등록되지 않았습니다. 잠시 후 다시 시도하세요.",
"ja": "プッシュデバイスが未登録です。しばらくしてから再試行してください。",
"de": "Push-Gerät ist noch nicht registriert. Bitte später erneut versuchen.",
"fr": "L'appareil n'est pas encore enregistré. Veuillez réessayer plus tard.",
"id": "Perangkat push belum terdaftar. Silakan coba lagi nanti.",
"ru": "Устройство ещё не зарегистрировано. Повторите попытку позже."
},
{
"key": "推送服务已被停用",
"zh": "",
"zh-CHT": "推送服務已被停用",
"en": "Push service has been disabled",
"ko": "푸시 서비스가 비활성화되었습니다",
"ja": "プッシュサービスは停止されています",
"de": "Push-Dienst wurde deaktiviert",
"fr": "Le service de notifications a été désactivé",
"id": "Layanan push telah dinonaktifkan",
"ru": "Служба push-уведомлений отключена"
},
{
"key": "推送设备绑定失败,请稍后再试",
"zh": "",
"zh-CHT": "推送設備綁定失敗,請稍後再試",
"en": "Failed to bind push device. Please try again later.",
"ko": "푸시 기기 연결에 실패했습니다. 잠시 후 다시 시도하세요.",
"ja": "プッシュデバイスの紐付けに失敗しました。しばらくしてから再試行してください。",
"de": "Push-Gerät konnte nicht verknüpft werden. Bitte später erneut versuchen.",
"fr": "Échec de l'association de l'appareil. Veuillez réessayer plus tard.",
"id": "Gagal menautkan perangkat push. Silakan coba lagi nanti.",
"ru": "Не удалось привязать устройство. Повторите попытку позже."
},
{
"key": "推送模式错误",
"zh": "",
"zh-CHT": "推送模式錯誤",
"en": "Invalid push mode",
"ko": "잘못된 푸시 모드입니다",
"ja": "プッシュモードが正しくありません",
"de": "Ungültiger Push-Modus",
"fr": "Mode de notification invalide",
"id": "Mode push tidak valid",
"ru": "Неверный режим push-уведомлений"
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -414,7 +414,7 @@ tests:
q: "手机端收不到推送通知"
locale: zh
expected_chunk_ids: [mobile-client.push-fail.faq]
expected_answer: "检查:App 通知权限是否开启、系统省电模式是否限制后台、UMENG 推送是否配置完成、网络是否受限。"
expected_answer: "检查:App 通知权限是否开启、系统省电模式是否限制后台、管理员是否关闭了 APP 推送(默认系统配置)、网络是否受限。"
must_say: [推送, 移动端, App]
must_not_say: []
bucket: medium
@ -472,7 +472,7 @@ tests:
- task.notify.concept
- mobile-client.notify.concept
- electron-client.notify.concept
expected_answer: "到期前/超期后约 1 小时,task-alert 机器人私聊发送提醒(依赖管理员开启「APP 推送」设置):桌面端收到站内消息并弹系统通知(依赖系统通知权限)、移动端走 UMENG 推送;任务到期提醒不发邮件——邮件通知仅覆盖未读聊天消息(文本/文件/语音/会议类),不含此类模板提醒。"
expected_answer: "到期前/超期后约 1 小时,task-alert 机器人私聊发送提醒(依赖管理员开启「APP 推送」设置):桌面端收到站内消息并弹系统通知(依赖系统通知权限)、移动端走 DooPush 推送;任务到期提醒不发邮件——邮件通知仅覆盖未读聊天消息(文本/文件/语音/会议类),不含此类模板提醒。"
must_say: [桌面, 移动, 推送, 提醒]
must_not_say: []
bucket: hard

View File

@ -81,7 +81,7 @@ tests:
q: "任务到截止时间会怎么提醒我?"
locale: zh
expected_chunk_ids: [task.deadline.concept, notification.task-deadline.howto, mobile-notify.task.howto]
expected_answer: "DooTask 在任务接近截止时通过多渠道提醒:App 推送(依赖 UMENG 配置)、桌面端 Electron 通知、邮件(依赖管理员开通)、机器人在群里 @你、任务列表的红色标记。可在个人设置关闭部分通道。"
expected_answer: "DooTask 在任务接近截止时通过多渠道提醒:App 推送(DooPush,默认系统配置)、桌面端 Electron 通知、邮件(依赖管理员开通)、机器人在群里 @你、任务列表的红色标记。可在个人设置关闭部分通道。"
must_say: [提醒]
must_not_say: []
bucket: hard

View File

@ -390,7 +390,7 @@ features:
- email-notice.user-opt.howto
- id: push-notice
name: APP 推送(DooPush / UMENG)
name: APP 推送(DooPush)
scope: admin
batch: B3
priority: P1

View File

@ -30,7 +30,7 @@ last_verified: v1.9.66
## 当前内置 6 个
- **LDAP**:第三方目录服务接入与同步
- **邮件通知**:SMTP 服务器与通知开关配置
- **APP 推送**:移动端 DooPush / 友盟推送配置
- **APP 推送**:移动端 DooPush 推送配置
- **举报管理**:处理用户举报记录
- **数据导出**:任务/超期/审批/签到等 4 种数据导出
- **团队管理**:成员列表 / 部门管理 / 邀请加入

View File

@ -18,7 +18,7 @@ prerequisites:
- 使用 DooTask 桌面端(Electron 客户端)或允许了浏览器通知的 Web 版
negative:
- Web 版需用户在浏览器对话框中点「允许」才能弹出通知,否则只能在 APP 内提示
- 桌面通知不走 DooPush / 友盟,与 APP 推送是完全独立的通道
- 桌面通知不走 DooPush,与 APP 推送是完全独立的通道
- 关闭 DooTask 进程后不会有通知(与移动端不同,没有后台守护服务)
last_verified: v1.9.66
---
@ -43,7 +43,7 @@ last_verified: v1.9.66
| Linux | libnotify | 仅通知 |
## 与其他通知的关系
- [[push-notice.concept]] DooPush / 友盟推送只走移动 APP,桌面端不参与
- [[push-notice.concept]] DooPush 推送只走移动 APP,桌面端不参与
- [[email-notice.concept]] 邮件只用于汇总未读或系统验证,与桌面通知并行
- 浏览器 Web 版桌面通知由浏览器实现,关闭浏览器即失效

View File

@ -18,7 +18,7 @@ prerequisites: []
negative:
- 桌面端通知不依赖浏览器权限,但需要系统级允许 DooTask 发通知
- 系统勿扰模式 / 专注模式开启时,不会弹出但会进通知中心
- 桌面端通知与移动端 DooPush / UMENG 推送是两套独立通道,见 [[mobile-client.notify.concept]]
- 桌面端通知与移动端 DooPush 推送是两套独立通道,见 [[mobile-client.notify.concept]]
last_verified: v1.9.66
---

View File

@ -39,7 +39,7 @@ last_verified: v1.9.66
具体场景见 [[email-notice.scenarios.concept]]。
## 与其他通知的关系
- **APP 推送**(DooPush / 友盟):见 [[push-notice.concept]],独立通道
- **APP 推送**(DooPush):见 [[push-notice.concept]],独立通道
- **桌面通知**(Electron):本地系统通知,不走邮件
- **移动端通知**:iOS/Android 推送,不走邮件

View File

@ -36,7 +36,7 @@ last_verified: v1.9.66
## 移动端独有
- 底部 Tabbar 原生导航
- 系统级推送(新版 DooPush / 旧版 UMENG,见 [[mobile-client.notify.concept]])
- 系统级推送(DooPush,见 [[mobile-client.notify.concept]])
- 手势操作(左滑会话项 / 长按消息等,见 [[mobile-client.gesture.concept]])
- 移动键盘的「发送按钮」配置(切换 Enter 是发送还是换行)
- 二维码扫码 / 调起相机拍摄上传

View File

@ -9,7 +9,7 @@ aliases:
- 移动端推送
- 手机推送
- App 通知
- 友盟推送
- DooPush 推送
- DooPush 推送
- 锁屏推送
related_tools: []
@ -26,16 +26,16 @@ last_verified: v1.9.66
# 移动端推送通知
## 定义
移动端推送通知是 DooTask 向已登录手机发送的系统提醒。新版 App 通过 DooPush,旧版通过友盟 UMENG,再下发到 APNs 或 Android 厂商通道。桌面端原生通知使用另一套链路。
移动端推送通知是 DooTask 向已登录手机发送的系统提醒。App 通过 DooPush 下发到 APNs 或 Android 厂商通道。桌面端原生通知使用另一套链路。
## 推送链路
1. 登录后,新版 App 调用 `api/push/device` 上报设备 ID,旧版调用 `api/users/umeng/alias` 注册别名。
1. 登录后,App 调用 `api/push/device` 上报推送设备(系统配置模式同时上报推送 Token 用于绑定)。旧版 App 的友盟推送已移除。
2. 收到他人发送的非静默会话消息时,后端向用户的活跃设备发送通知。
3. 私聊、群聊、任务分配及任务超时等提醒通过对应会话消息进入推送,详见 [[task.notify.concept]]。
4. PC 最近 60 秒活跃会延迟手机通知 10 秒,延迟期间读过则跳过。
## 收不到推送
- 检查服务器对应通道开关和凭证:[[push-notice.config.howto]]。
- 检查服务器推送模式(系统配置 / 自定义 / 关闭):[[push-notice.config.howto]]。
- 检查手机通知权限、会话免打扰、Android 自启动和电池限制。
- 退出登录会解绑设备;超过一个月未更新的绑定不再用于推送。
- 重新登录可刷新设备绑定;排查步骤见 [[mobile-client.push-fail.faq]]。

View File

@ -48,7 +48,7 @@ last_verified: v1.9.66
- **发送失败的消息**:不会自动重发,用户需手动点重试
## 离线推送
即使设备完全离线,服务端仍会通过 DooPush 或 UMENG 通道下发推送([[mobile-client.notify.concept]]),iOS 设备会在恢复网络后由 APNs 投递。Android 则取决于厂商通道是否能在弱网下唤醒。
即使设备完全离线,服务端仍会通过 DooPush 下发推送([[mobile-client.notify.concept]]),iOS 设备会在恢复网络后由 APNs 投递。Android 则取决于厂商通道是否能在弱网下唤醒。
## 弱网体验优化
- App 会自动重试关键请求(消息发送)

View File

@ -38,12 +38,12 @@ DooTask 移动端是 iOS 与 Android 双平台的原生 App,基于 EEUI 框架
## 与桌面 / 网页端的差异
- 导航形态:移动端用底部 Tabbar(消息 / 任务 / 应用 / 我的),桌面端用左侧栏
- 输入习惯:发送按钮 / 换行按钮在移动端可切换(键盘上的「发送」是发还是换行)
- 推送通道:新版走 DooPush、旧版走 UMENG 系统推送([[mobile-client.notify.concept]]),不是 WebSocket 自送
- 推送通道:走 DooPush 系统推送([[mobile-client.notify.concept]]),不是 WebSocket 自送
- 富文本编辑器在小屏上做了简化
## 设备识别
- 前端通过 UA `/eeui/i` 检测 `$isEEUIApp`,据此走移动端专属逻辑
- App 向服务端注册推送绑定:新版调用 `/api/push/device` 上报 DooPush 设备 ID,旧版调用 `/api/users/umeng/alias` 上报友盟别名
- App 向服务端注册推送绑定:调用 `/api/push/device` 上报推送设备
## 何时选移动端
- 出差 / 通勤时收消息和处理任务

View File

@ -17,7 +17,7 @@ related_pages: []
prerequisites:
- 已下载并登录 DooTask 移动端 APP(iOS / Android)
negative:
- 移动端通知由两部分组成:离线 DooPush / 友盟推送 + 前台 APP 内浮层;二者不能任选其一
- 移动端通知由两部分组成:离线 DooPush 推送 + 前台 APP 内浮层;二者不能任选其一
- 未授予系统通知权限时,连前台浮层通知都不显示(依赖 setVibrate 系统调用会被拒)
- DooTask 不内嵌任何「不推送某类消息」的细粒度开关,免打扰只能按会话或时段
last_verified: v1.9.66
@ -26,12 +26,12 @@ last_verified: v1.9.66
# 移动端通知是什么
## 定义
移动端通知是 DooTask iOS / Android APP 收到新消息的两种提醒形式:APP 在后台或被关闭时通过 DooPush 或友盟下发系统通知栏推送([[push-notice.concept]]),APP 在前台运行时则在屏幕顶部展示一段浮层通知。
移动端通知是 DooTask iOS / Android APP 收到新消息的两种提醒形式:APP 在后台或被关闭时通过 DooPush 下发系统通知栏推送([[push-notice.concept]]),APP 在前台运行时则在屏幕顶部展示一段浮层通知。
## 两种形态
| 形态 | 触发场景 | 实现 |
|---|---|---|
| 系统通知栏推送 | APP 后台 / 退出 / 锁屏 | DooPush / 友盟通道,详见 [[push-notice.concept]] |
| 系统通知栏推送 | APP 后台 / 退出 / 锁屏 | DooPush 通道,详见 [[push-notice.concept]] |
| APP 内浮层 | APP 前台运行 + 不在该会话 | `MobileNotification` 组件,屏幕顶部下拉横幅,点击进入会话 |
## APP 内浮层属性
@ -41,7 +41,7 @@ last_verified: v1.9.66
- 点击会话直接打开
## 与桌面端通知的区别
- 移动端有「APP 后台 → 系统推送」(DooPush / 友盟),桌面端没有
- 移动端有「APP 后台 → 系统推送」(DooPush),桌面端没有
- 桌面端通知可在通知里直接快速回复,移动端浮层不支持快捷回复
- 移动端推送会触发振动 + 角标,桌面端只闪烁/角标,不振动

View File

@ -6,11 +6,11 @@ feature: push-notice
scope: end-user
locale: zh
aliases:
- 友盟别名
- 设备绑定
- 推送目标
- DooPush 设备上报
- 升级后重复推送
- 换服务器后还收到推送
- 退出登录还收到推送
related_tools: []
related_pages: []
prerequisites:
@ -18,31 +18,30 @@ prerequisites:
negative:
- Web 端和桌面端不注册 APP 推送设备
- 超过一个月未更新的设备记录不再参与推送
- remove 不能删除其他登录 token 绑定的 DooPush 设备
- remove 不能删除其他登录 token 绑定的推送设备
- 推送模式为「关闭」时不记录设备
last_verified: v1.9.66
---
# APP 推送设备绑定
## 定义
DooTask 按用户绑定移动设备:新版 App 上报到 `app_push_devices` 的 DooPush 通道;旧版 App 继续使用 `umeng_alias` 的友盟别名。Web 端和桌面端不注册这两类设备。
DooTask 按用户绑定移动设备,记录在 `app_push_devices`。系统配置与自定义模式的设备分开记录,切换模式后只向当前模式登记的设备推送。Web 端和桌面端不注册推送设备。
## 新版 App 上报
## App 上报
`api/push/device` 同时支持 GET 和 POST,返回标准的 ret、msg、data。
1. action 默认 update;需要有效 token,平台必须是 ios / android。
1. action 默认 update;需要有效 token,平台必须是 ios / android;推送模式为「关闭」时直接返回成功,不记录设备。
2. device_id 必须是 1 至 64 位数字字符串;vendor 为 apns、fcm、hms、honor、xiaomi、oppo、vivo、meizu 之一。
3. 同 channel + device_id 唯一;设备换账号登录时,归属更新为当前用户。
4. 保存设备型号、版本 name (code)、UA、通知权限和登录 token 的 MD5。is_notified 为 true、字符串 true、1、字符串 1 时记为已授权。
5. 清理相同登录 token 哈希的旧友盟记录;unique_id 至少 5 个字符时,还会清理以该标识为后缀的旧别名,避免升级后重复推送。
## 旧版 App 上报
调用 `api/users/umeng/alias` 更新 alias,格式为 platform-userid-uniqueId;调试模式不注册友盟别名。
3. 系统配置模式还需上报 push_token(设备推送 Token):服务器首次使用时自动在官方推送服务注册为租户,再按 Token 绑定设备,以推送服务返回的设备 ID 为准。
4. 同模式 + 设备 ID 唯一;设备换账号登录时,归属更新为当前用户。
5. 保存设备型号、版本 name (code)、UA、通知权限和登录 token 的 MD5。is_notified 为 true、字符串 true、1、字符串 1 时记为已授权。
## 活跃设备与解绑
- 推送只选择一个月内更新的记录;DooPush 按用户取最新 5 台设备,每个用户一次请求。
- 推送只选择一个月内更新的记录,按用户取最新 5 台设备,每个用户一次请求。
- 角标取该用户未读且非静默的会话消息数。
- 新版退出登录调用 action=remove,传 device_id 和原请求头 token;token 可以已失效,但只能移除与其 MD5 匹配的 DooPush 记录。
- 服务端忘记登录设备时,也同步清理相同 token 哈希的两类推送绑定。
- 退出登录调用 action=remove,传 device_id 和原请求头 token;token 可以已失效,但只能移除与其 MD5 匹配的记录。系统配置模式同时解除设备与本站租户的绑定。
- 系统配置模式下,同一台手机登录到另一台 DooTask 服务器后,设备改绑到新服务器,原服务器不再向它推送并自动清理该记录。
- 服务端忘记登录设备时,也同步清理相同 token 哈希的推送绑定。
## 不支持
超过一个月未更新的记录不会自动物理删除;remove 不能删除其他登录 token 绑定的 DooPush 设备。
超过一个月未更新的记录不会自动物理删除;remove 不能删除其他登录 token 绑定的推送设备。

View File

@ -8,30 +8,30 @@ locale: zh
aliases:
- APP 推送
- 手机推送
- 友盟推送
- UMENG
- DooPush 推送
- 后台推送消息
- 友盟推送还能用吗
related_tools: []
related_pages: []
prerequisites:
- 管理员已开启并配置与 App 版本匹配的推送通道
- 管理员未关闭 APP 推送(默认使用系统配置)
- 用户在 iOS / Android 客户端登录并允许通知
negative:
- Web 端、桌面端不走 APP 推送,使用 WebSocket 和本地通知
- 两个通道都关闭或对应凭证缺失时无法发送离线推送
- 推送模式为「关闭」或自定义凭证缺失时无法发送离线推送
- 没有按消息类型订阅推送的开关
- 已不再支持友盟(UMENG)推送
last_verified: v1.9.66
---
# APP 推送是什么
## 定义
APP 推送是 DooTask 向 iOS / Android 移动端发送的系统通知。新版 App 使用 DooPush,旧版 App 使用友盟 UMENG;管理员可以同时开启两个通道,让不同版本的设备继续接收消息。
APP 推送是 DooTask 向 iOS / Android 移动端发送的系统通知,统一通过 DooPush 下发到 APNs 或 Android 厂商通道。旧版 App 使用的友盟推送已移除。
## 关键属性
- **通道配置**:友盟使用 iOS / Android 两组 Appkey、App Master Secret;DooPush 使用 App ID、App Secret 和服务地址,详见 [[push-notice.config.howto]]。
- **设备绑定**:新版 App 登录后上报 DooPush 设备 ID,旧版上报友盟 alias;升级上报会清理同设备的旧别名,详见 [[push-notice.alias.concept]]。
- **推送模式**:系统配置(默认,使用官方推送服务,无需配置)、自定义(自己的 DooPush 应用与自行打包的 App)、关闭,详见 [[push-notice.config.howto]]。
- **设备绑定**:App 登录后上报推送设备,退出登录时解绑,详见 [[push-notice.alias.concept]]。
- **触发条件**:收到他人发送的非静默会话消息;任务提醒通过会话消息进入相同链路。
- **PC 在线优化**:PC 端 60 秒内活跃时,APP 推送延迟 10 秒,延迟期间已读则跳过。
- **角标**:取该用户未读且非静默的会话消息数。
@ -42,4 +42,5 @@ APP 推送是 DooTask 向 iOS / Android 移动端发送的系统通知。新版
## 不支持
- Web 端和桌面端不注册 APP 推送设备。
- 没有按消息类型订阅推送的开关。
- 两个通道都关闭或对应凭证缺失时无法发送离线推送,站内消息仍正常。
- 推送模式为「关闭」或自定义凭证缺失时无法发送离线推送,站内消息仍正常。
- 旧版 App(友盟推送)不再接收推送,需升级到新版。

View File

@ -24,10 +24,10 @@ last_verified: v1.9.66
# APP 推送触发场景
## 定义
DooTask 收到新会话消息时,会通过 DooPush 或友盟向相应版本的移动设备发送通知。内容为发送者或群名称及消息预览,点击通知进入对应会话。
DooTask 收到新会话消息时,会通过 DooPush 向用户的移动设备发送通知。内容为发送者或群名称及消息预览,点击通知进入对应会话。
## 触发条件
1. 对应通道已开启并填好凭证:新版 App 使用 DooPush,旧版使用友盟。
1. 推送模式不是「关闭」(默认系统配置;自定义需填好凭证)。
2. 用户存在一个月内更新的对应通道设备记录。
3. 消息发送者不是收件人本人,收件人未被禁用。
4. 会话对收件人非免打扰,消息也没有静默标记。

View File

@ -43,7 +43,7 @@ last_verified: v1.9.66
## 推送渠道
- **站内消息**:「消息」列表中 task-alert「任务提醒」机器人的私聊会话
- **桌面端**:经 Electron 系统通知弹出
- **移动端**:离线 / 后台用户经 DooPush(新版 App)或 UMENG(旧版 App)推送触达,见 [[mobile-client.notify.concept]]
- **移动端**:离线 / 后台用户经 DooPush 推送触达,见 [[mobile-client.notify.concept]]
## 不支持
- 不能为单条任务自定义提醒时间偏移(如「提前 2 小时」),固定为到期前约 1 小时、超期后约 1 小时各提醒一次

View File

@ -37,7 +37,7 @@ DooTask v1.7.90 还没有实现 iCal / webcal / .ics 文件相关接口:
1. **看任务列表**:用 [[task.field.deadline.concept]] 字段在 DooTask 内的视图里看
2. **任务导出 Excel**:项目级 [[project.export.howto]],得到 .xlsx 表,再自行转格式
3. **桌面端通知**:用 DooTask 自己的桌面提醒([[task.notify.concept]])替代外部日历提醒
4. **移动端 APP 推送**:DooPush / UMENG 推送同样可用于任务提醒
4. **移动端 APP 推送**:DooPush 推送同样可用于任务提醒
## 未来支持?
这块在 P1 / P2 路线图(issues 可能跟踪),但 v1.7.90 主线版本无计划。

View File

@ -18,7 +18,7 @@ prerequisites: []
negative:
- PC 在线时 APP 推送会延迟 10 秒;这期间被读则跳过推送,不是故障
- 静默消息(silence=1)和自己发的消息本来不推
- DooPush 设备和友盟别名超过一个月未更新时不参与推送,需重新登录刷新
- 推送设备超过一个月未更新时不参与推送,需重新登录刷新
last_verified: v1.9.66
---
@ -33,14 +33,14 @@ last_verified: v1.9.66
2. **系统通知权限**:iOS / Android 是否给 DooTask 通知权限,详见 [[mobile-notify.permission.faq]]
3. **国产 ROM 后台杀进程**:华为 / 小米 / OPPO / VIVO 需手动允许 DooTask「自启动 + 后台运行」
4. **PC 端 60 秒内活跃过**:DooTask 推送规则会延迟手机 10 秒,期间消息被 PC 读完会跳过 APP 推送(设计行为)
5. **管理员有没有配置对应通道**:系统设置 → APP 推送 → 新版检查 DooPush App ID / App Secret,旧版检查友盟凭证
6. **设备绑定过期**:超过一个月未更新 → 重新登录 APP,刷新 DooPush 设备或友盟别名
5. **管理员有没有配置对应通道**:系统设置 → APP 推送 → 推送服务不能是「关闭」;自定义模式检查 App ID / App Secret
6. **设备绑定过期**:超过一个月未更新 → 重新登录 APP,刷新推送设备
7. **会话级 / 项目级免打扰**:在 APP 内对应会话 / 项目设置里检查
## 解决
1. 退出 APP 重新登录刷新 token 和推送设备绑定
2. 关掉 PC 端临时测试(断网或退出账号)→ 验证 APP 是否能即时收
3. 找系统管理员看 `[DooPush]` 应用日志及 DooPush 投递结果,或 `umeng_logs` 表的最新返回;详见 [[push-notice.troubleshoot.faq]]
3. 找系统管理员看 `[DooPush]` 应用日志及 DooPush 投递结果;详见 [[push-notice.troubleshoot.faq]]
4. 国产 ROM 详细步骤见 [[mobile-notify.permission.faq]]
## 不支持

View File

@ -32,7 +32,7 @@ DooTask 有四条独立的通知通道,依次自检:
1. **应用内红点**:左侧聊天列表、任务列表是否有红点。没有 → 后端根本没产生通知,从源头排查(消息真的发给你了?被设了可见用户排除?)
2. **桌面 / Web 通知**:桌面端 / Web 是否拿到系统通知权限,详见 [[desktop-notify.permission.faq]]
3. **移动端 APP 推送**:手机是否给 DooTask 通知权限 + 后台运行;管理员是否配了对应的 DooPush / 友盟通道 [[mobile-notify.permission.faq]] / [[push-notice.troubleshoot.faq]]
3. **移动端 APP 推送**:手机是否给 DooTask 通知权限 + 后台运行;管理员是否关闭了 APP 推送 [[mobile-notify.permission.faq]] / [[push-notice.troubleshoot.faq]]
4. **邮件通知**:管理员是否配 SMTP;该用户是否在「忽略邮箱」列表里,详见 [[email-notice.troubleshoot.faq]]
## 解决
@ -40,7 +40,7 @@ DooTask 有四条独立的通知通道,依次自检:
2. 检查个人是否开了「全局免打扰」「勿扰时间段」
3. 检查目标会话 / 项目是否被你单独设了免打扰
4. 重新登录刷新 socket 连接
5. 全公司都没通知 → 让管理员检查 swoole 进程、Redis、SMTP、DooPush / 友盟配置
5. 全公司都没通知 → 让管理员检查 swoole 进程、Redis、SMTP、DooPush 推送配置
## 不支持
- 没有「通知历史」可回放所有过去通知的全局界面(仅站内红点 + 邮件)

View File

@ -49,7 +49,7 @@ DooTask 是「在线优先」架构:
| 任务变更 | 是,进入任务 / 项目即拉最新 |
| 实时通知(弹窗) | 否,已错过不会重发 |
| 未读数 | 是,重新计算 |
| 推送通知(手机) | 是,由 DooPush / UMENG 经 APNs 或厂商通道离线推送 |
| 推送通知(手机) | 是,由 DooPush 经 APNs 或厂商通道离线推送 |
## 解决
1. 重新上线后**手动进各页面**走一遍 → 自动拉新

View File

@ -24,18 +24,18 @@ last_verified: v1.9.66
# 移动端推送收不到怎么办
## 问题
DooTask App 在后台或锁屏时收不到新消息。新版 App 使用 DooPush,旧版使用友盟,需检查对应链路:[[mobile-client.notify.concept]]。
DooTask App 在后台或锁屏时收不到新消息。推送统一经 DooPush 下发,旧版 App(友盟推送)需升级到新版,链路见[[mobile-client.notify.concept]]。
## 排查步骤
1. 联系管理员检查「系统设置 → APP 推送」。新版需开启 DooPush 并填好 App ID、App Secret、服务地址;旧版需开启友盟并填好 iOS / Android 凭证。
1. 联系管理员检查「系统设置 → APP 推送」。推送服务不能是「关闭」;官方 App 选「系统配置」,「自定义」仅用于自行打包的 App 且凭证需正确。
2. 手机系统设置 → DooTask → 通知,打开允许通知、锁屏和横幅等选项。
3. 检查聊天是否免打扰;PC 最近活跃时手机会延迟 10 秒通知,期间已读则跳过。
4. Android 检查允许自启动、后台运行和电池不受限,网络需能访问对应推送服务。
5. 重新登录 App 刷新设备绑定。新版上报到 `api/push/device`,旧版上报到 `api/users/umeng/alias`。退出登录后设备解绑,不再接收该账号推送。
5. 重新登录 App 刷新设备绑定。App 上报到 `api/push/device`。退出登录后设备解绑,不再接收该账号推送。
6. 让其他人发送一条新消息验证锁屏通知,不要用自己发送的消息测试。
## 仍未解决
管理员检查 DooPush 的 `[DooPush]` 应用日志及 DooPush 控制台投递结果,或友盟 `umeng_logs` 返回信息,详见 [[push-notice.troubleshoot.faq]]。
管理员检查服务器 `[DooPush]` 应用日志;自定义模式再看 DooPush 控制台投递结果,详见 [[push-notice.troubleshoot.faq]]。
## 不支持
推送送达率受手机系统和网络影响,无法完全保证。离线推送失败不影响登录后同步站内消息。

View File

@ -32,7 +32,7 @@ DooTask 移动端 APP 收不到通知 / 没振动 / 锁屏后没推送。
- 系统通知权限未授予
- 国产 ROM 后台杀进程严格,APP 被关闭
- DooTask 时段免打扰打开
- 网络异常导致推送未达(DooPush / 友盟通道无法触达设备)
- 网络异常导致推送未达(DooPush 通道无法触达设备)
## 解决(iOS)
1. 「设置 → DooTask」
@ -69,5 +69,5 @@ DooTask 移动端 APP 收不到通知 / 没振动 / 锁屏后没推送。
1. 检查 DooTask 内时段免打扰是否打开([[mobile-notify.silent.howto]])
2. 检查会话是否被免打扰([[push-notice.silent.howto]])
3. 检查 APP 内是否已登录(退出登录后推送设备解绑)
4. 联系管理员查 `[DooPush]` 应用日志及 DooPush 投递结果,或 `umeng_logs` 的友盟返回信息
4. 联系管理员查 `[DooPush]` 应用日志及 DooPush 投递结果
5. 详见 [[push-notice.troubleshoot.faq]]

View File

@ -9,7 +9,7 @@ aliases:
- 收不到推送
- 手机没消息
- DooPush 推送失败
- 友盟没声音
- 推送设备绑定失败
- 后台推送不到
related_tools: []
related_pages: []
@ -18,25 +18,26 @@ negative:
- DooTask 没有重发推送按钮
- 退出登录后设备解绑,不再接收该账号推送
- 已读、静默和自己发给自己的消息不推送
- 旧版 App(友盟推送)不再接收推送
last_verified: v1.9.66
---
# APP 收不到推送怎么办
## 问题
DooTask 移动 App 在后台或锁屏时没有收到新消息通知。新版使用 DooPush,旧版使用友盟,需要先确认当前 App 对应的通道。
DooTask 移动 App 在后台或锁屏时没有收到新消息通知。
## 排查顺序
1. 管理员进入「系统设置 → APP 推送」:新版检查「DooPush 推送」已开启,App ID、App Secret 和服务地址正确;旧版检查友盟开关及 iOS / Android 凭证。两通道可同时开启。
2. 在手机系统设置中允许 DooTask 通知,检查会话是否免打扰。Android 可检查自启动、后台运行和电池限制。
3. PC 端 60 秒内有活动会延迟手机推送 10 秒;期间读过消息则跳过,这是正常行为。
4. 设备超过一个月未更新则不参与推送,重新登录 App 刷新绑定。退出登录会解绑设备,需要先登录才能接收账号消息。
5. 新版升级上报会清理同设备旧友盟别名;新旧账号切换后,设备归属应为当前账号。
1. 确认使用的是新版 App;旧版 App 依赖的友盟推送已移除,需升级。
2. 管理员进入「系统设置 → APP 推送」:推送服务不能是「关闭」。官方发布的 App 应选「系统配置」;「自定义」只适用于自行打包的 App,且 App ID、App Secret 和服务地址需正确。
3. 在手机系统设置中允许 DooTask 通知,检查会话是否免打扰。Android 可检查自启动、后台运行和电池限制。
4. PC 端 60 秒内有活动会延迟手机推送 10 秒;期间读过消息则跳过,这是正常行为。
5. 设备超过一个月未更新、切换过推送模式,或同一手机登录过另一台 DooTask 服务器,都需要重新打开或登录 App 刷新绑定。
## 管理员排查
- DooPush:检查服务器应用日志的 `[DooPush]` 记录,核对 App Secret 是否属于当前 App ID 且有 push:send 权限;每次发送失败最多重试 2 次。
- DooPush 接口受理成功后,实际投递在 DooPush 后台异步执行,应在 DooPush 控制台继续检查厂商投递结果。
- 友盟:检查 `umeng_logs` 的 request / response 及厂商凭证。
- 检查服务器应用日志的 `[DooPush]` 记录;每次发送失败最多重试 2 次。
- 系统配置模式:需要服务器能访问 `https://doopush.com`。登录时提示「推送服务注册失败」「推送设备绑定失败」多为网络问题,可稍后重试;提示「推送服务已被停用」需联系官方。
- 自定义模式:核对 App Secret 是否属于当前 App ID 且有 push:send 权限;接口受理成功后,应在 DooPush 控制台继续检查厂商投递结果。
## 不支持
DooTask 没有重发推送按钮;已读、静默和自己发给自己的消息不推送。退出登录后设备解绑,不再接收该账号推送。

View File

@ -8,9 +8,9 @@ locale: zh
aliases:
- APP 推送怎么打开
- 手机推送配置
- UMENG 设置
- 推送服务设置
- DooPush 设置
- 友盟推送
- DooPush 推送
related_tools: []
related_pages: [application]
prerequisites:
@ -25,15 +25,15 @@ last_verified: v1.9.66
# APP 推送入口
## 是什么
APP 推送应用让管理员配置 DooTask 移动端通知:新版 App 使用 DooPush,旧版 App 使用友盟 UMENG,两者可同时开启。任务和审批等提醒通过会话消息进入推送链路。
APP 推送应用让管理员配置 DooTask 移动端通知:统一使用 DooPush,可选系统配置(默认)、自定义或关闭。任务和审批等提醒通过会话消息进入推送链路。
## 入口
- 桌面端:左侧「应用」→「管理员」分区 →「APP 推送」卡片,打开配置抽屉。
- 也可从左上角头像 →「系统设置」→ 左侧「APP 推送」进入相同表单。
## 配置项
- 「友盟推送」区:独立开关、iOS / Android 各自的 Appkey 和 App Master Secret。
- 「DooPush 推送」区:独立开关、App ID、密码形式输入的 App Secret、服务地址。
- 「推送服务」:系统配置(默认,使用官方推送服务,无需配置)/ 自定义 / 关闭。
- 选择「自定义」时填写 App ID、密码形式输入的 App Secret、服务地址,需搭配自行打包的 App。
- 服务地址留空使用 https://doopush.com/api/v1。
- 点击底部「提交」保存;详细字段及设备绑定见 [[push-notice.config.howto]]。

View File

@ -19,8 +19,8 @@ prerequisites:
negative:
- DooTask APP 内没有「全局通知总开关」按钮,要靠系统通知权限或会话级免打扰
- 卸载重装会重新申请通知权限;以前的免打扰会话状态仍保留在云端
- 关闭通知权限不会取消 UmengAlias 注册,重新打开权限即刻恢复
last_verified: v1.7.90
- 关闭通知权限不会取消推送设备注册,重新打开权限即刻恢复
last_verified: v1.9.66
---
# 移动端通知开关

View File

@ -1,52 +1,53 @@
---
id: push-notice.config.howto
title: 配置 APP 推送(DooPush / 友盟)
title: 配置 APP 推送(DooPush)
type: howto
feature: push-notice
scope: admin
locale: zh
aliases:
- 配置友盟
- UMENG 配置
- APP 推送配置
- 怎么开推送
- 推送服务选哪个
- 系统配置推送
- DooPush 怎么配置
- DooPush 服务地址怎么填
related_tools: []
related_pages: []
prerequisites:
- 需要系统管理员权限
- 已获得对应推送服务的应用凭证
- 选择「自定义」时需已有自己的 DooPush 应用及自行打包的 App
negative:
- 当前环境禁止修改系统设置时无法保存推送配置
- App Secret 是服务端发送凭证,不是客户端 App Key
- 页面没有测试推送或事件订阅按钮
- 已不再支持友盟(UMENG)推送
last_verified: v1.9.66
---
# 配置 APP 推送(DooPush / 友盟)
# 配置 APP 推送(DooPush)
## 入口
桌面端:左上角头像 →「系统设置」→ 左侧「APP 推送」。也可通过管理员应用「APP 推送」打开配置。
## 操作步骤
1. 新版 App:在「DooPush 推送」区开启推送,填写 App ID、App Secret;服务地址留空时使用 `https://doopush.com/api/v1`,自部署 DooPush 可填写自己的 API 地址。
2. 旧版 App:在「友盟推送」区开启推送,按需填写 iOS / Android 各自的 Appkey 和 App Master Secret。
3. 两个通道可以同时开启;底部点击「提交」保存,点击「重置」恢复本次修改前的数据。
4. App 登录后自动注册对应通道的设备,无需管理员手工绑定用户。旧版升级到新版上报时会清理同设备的旧友盟别名。
1. 在「推送服务」选择模式:
- **系统配置**(默认):使用官方推送服务,无需填写任何凭证,适用于官方发布的 App。
- **自定义**:使用自己的 DooPush 应用,需搭配自行打包的 App;填写 App ID、App Secret,服务地址留空时使用 `https://doopush.com/api/v1`,自部署 DooPush 可填写自己的 API 地址。
- **关闭**:不发送 APP 推送,App 登录时也不记录推送设备。
2. 底部点击「提交」保存,点击「重置」恢复本次修改前的数据。
3. App 登录后自动注册推送设备,无需管理员手工绑定用户。切换模式后,用户需重新打开或登录 App 才会按新模式注册设备。
## 字段说明
| 字段 | 默认 | 说明 |
|---|---|---|
| push | close | 友盟通道开关 |
| ios_key / ios_secret | 空 | 友盟 iOS 凭证 |
| android_key / android_secret | 空 | 友盟 Android 凭证 |
| doopush | close | DooPush 通道开关 |
| doopush_app_id | 空 | DooPush 应用 ID |
| doopush_app_secret | 空 | 服务端 App Secret,密码输入框显示,至少需要 push:send 权限 |
| doopush_base_url | 空 | DooPush API 服务地址,默认 https://doopush.com/api/v1 |
| doopush | system | 推送模式:system 系统配置 / custom 自定义 / close 关闭 |
| doopush_app_id | 空 | 自定义模式的 DooPush 应用 ID |
| doopush_app_secret | 空 | 自定义模式的服务端 App Secret,密码输入框显示,至少需要 push:send 权限 |
| doopush_base_url | 空 | 自定义模式的 DooPush API 服务地址,默认 https://doopush.com/api/v1 |
## 不支持
- 关闭某个通道只停止该通道发送,另一个已开启通道仍可工作。
- 系统配置模式没有可填写的凭证,服务器首次有设备登录时自动接入官方推送服务。
- App Secret 是服务端凭证,不应填成客户端 App Key,也不要放进客户端或版本库。
- 已不再支持友盟(UMENG)推送,旧版 App 需升级到新版才能接收推送。
- 页面没有测试推送或事件订阅按钮;排查见 [[push-notice.troubleshoot.faq]]。

View File

@ -36,7 +36,7 @@ last_verified: v1.9.66
## 工作原理
- 后端记录 `web_socket_dialog_users.silence` = 1
- 该用户对该会话的所有新消息:
- 不触发 DooPush / 友盟 APP 推送(`silence=1` 会被 [[push-notice.scenarios.concept]] 过滤)
- 不触发 DooPush APP 推送(`silence=1` 会被 [[push-notice.scenarios.concept]] 过滤)
- 不汇总进未读邮件(EmailNoticeTask 跳过 silence=1 的记录)
- 角标计数也不算它(badge 未读统计排除 silence)
- 但消息**仍会**到达 WebSocket,仍出现在消息列表和会话内

View File

@ -36,7 +36,7 @@ DooTask 的通知分多个通道,没有集中的「通知偏好」设置页,
## 移动端推送(iOS / Android)
- 入口:手机系统设置 → DooTask → 通知;APP 内只跟随系统权限
- APP 登录后自动绑定推送设备,新版使用 DooPush,旧版使用 UMENG
- APP 登录后自动绑定推送设备(DooPush)
- 关掉手机系统通知后,APP 内只剩前台徽标,没有推送
## 会话级免打扰 / 静音

View File

@ -38,7 +38,7 @@ last_verified: v1.9.66
点击卡片直接弹出右侧抽屉,不跳页面:
- 「LDAP」→ LDAP 设置抽屉(700 宽)
- 「邮件通知」→ 邮件设置抽屉
- 「APP 推送」→ DooPush / 友盟推送配置抽屉
- 「APP 推送」→ DooPush 推送配置抽屉
- 「举报管理」→ 举报记录抽屉
- 「数据导出」→ 下拉菜单选导出类型(任务/超期/审批/签到)
- 「团队管理」→ 跳转到团队管理页

View File

@ -39,7 +39,7 @@ last_verified: v1.9.66
- **AI 设置** — 配置 AI 模型供应商:[[system-setting.ai-model.howto]]
- **AI 机器人** — 关联机器人与默认模型:[[system-setting.ai-bot.howto]]
- **签到** — 打卡规则、地理围栏、人脸:[[system-setting.checkin.howto]]
- **APP 推送** — DooPush / 友盟推送开关与凭证配置:[[push-notice.config.howto]]
- **APP 推送** — DooPush 推送模式(系统配置 / 自定义 / 关闭)配置:[[push-notice.config.howto]]
- **第三方接入** — LDAP / OAuth:[[system-setting.third-access.howto]]
- **文件** — 文件打包下载权限:[[system-setting.file.howto]]
- **任务优先级** — 自定义颜色与天数:[[system-setting.priority.howto]]

@ -1 +1 @@
Subproject commit 6dd1887a36da755f1bcefc8c8cbff4830d2519de
Subproject commit 17ef48e1a9e7b06b828bd2856a038cf70f7a2758

View File

@ -222,7 +222,7 @@ export default {
this.$store.dispatch("websocketConnection");
//
if (this.$isEEUIApp) {
this.umengAliasTimer && clearTimeout(this.umengAliasTimer)
this.pushDeviceTimer && clearTimeout(this.pushDeviceTimer)
if (this.userId > 0) {
// 给 APP 发送初始化消息
$A.eeuiAppSendMessage({
@ -255,19 +255,17 @@ export default {
})
}).catch(console.log)
// 设置友盟别名
this.umengAliasTimer = setTimeout(_ => {
this.umengAliasTimer = null;
// 注册推送设备(消息名沿用旧壳协议)
this.pushDeviceTimer = setTimeout(_ => {
this.pushDeviceTimer = null;
$A.eeuiAppSendMessage({
action: 'setUmengAlias',
url: $A.apiUrl('users/umeng/alias')
});
}, 6000)
} else {
// 删除友盟别名
// 移除推送设备
$A.eeuiAppSendMessage({
action: 'delUmengAlias',
url: $A.apiUrl('users/umeng/alias')
});
}
}

View File

@ -6,44 +6,19 @@
:rules="ruleData"
v-bind="formOptions"
@submit.native.prevent>
<div class="block-setting-box">
<h3>{{ $L('友盟推送') }}</h3>
<div class="form-box">
<FormItem :label="$L('开启推送')" prop="push">
<RadioGroup v-model="formData.push">
<Radio label="open">{{ $L('开启') }}</Radio>
<Radio label="close">{{ $L('关闭') }}</Radio>
</RadioGroup>
</FormItem>
<template v-if="formData.push === 'open'">
<Divider orientation="left">iOS {{$L('参数配置')}}</Divider>
<FormItem label="Appkey" prop="ios_appkey">
<Input :maxlength="255" v-model="formData.ios_key"/>
</FormItem>
<FormItem label="App Master Secret" prop="secret">
<Input :maxlength="255" v-model="formData.ios_secret" type="password"/>
</FormItem>
<Divider orientation="left">Android {{$L('参数配置')}}</Divider>
<FormItem label="Appkey" prop="android_appkey">
<Input :maxlength="255" v-model="formData.android_key"/>
</FormItem>
<FormItem label="App Master Secret" prop="secret">
<Input :maxlength="255" v-model="formData.android_secret" type="password"/>
</FormItem>
</template>
</div>
</div>
<div class="block-setting-box">
<h3>{{ $L('DooPush 推送') }}</h3>
<div class="form-box">
<FormItem :label="$L('开启推送')" prop="doopush">
<FormItem :label="$L('推送服务')" prop="doopush">
<RadioGroup v-model="formData.doopush">
<Radio label="open">{{ $L('开启') }}</Radio>
<Radio label="system">{{ $L('系统配置') }}</Radio>
<Radio label="custom">{{ $L('自定义') }}</Radio>
<Radio label="close">{{ $L('关闭') }}</Radio>
</RadioGroup>
<div class="form-tip">{{ $L('新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。') }}</div>
<div v-if="formData.doopush === 'system'" class="form-tip">{{ $L('使用官方推送服务,无需配置。') }}</div>
<div v-else-if="formData.doopush === 'custom'" class="form-tip">{{ $L('使用自己的 DooPush 应用,需搭配自行打包的 App。') }}</div>
</FormItem>
<template v-if="formData.doopush === 'open'">
<template v-if="formData.doopush === 'custom'">
<FormItem :label="$L('App ID')" prop="doopush_app_id">
<Input :maxlength="255" v-model="formData.doopush_app_id"/>
</FormItem>
@ -73,12 +48,7 @@ export default {
return {
loadIng: 0,
formData: {
push: '',
ios_key: '',
ios_secret: '',
android_key: '',
android_secret: '',
doopush: 'close',
doopush: 'system',
doopush_app_id: '',
doopush_app_secret: '',
doopush_base_url: '',

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:335
接口总数:334
## 路由规则
@ -40,7 +40,6 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
| api/users/import | import() | post | 批量导入用户(管理员) |
| api/users/import/template | import__template() | get | 下载批量导入模板(管理员) |
| api/users/email/verification | email__verification() | get | 邮箱验证 |
| api/users/umeng/alias | umeng__alias() | get | 设置友盟别名 |
| api/users/meeting/open | meeting__open() | get | 【会议】创建会议、加入会议 |
| api/users/tags/lists | tags__lists() | get | 获取个性标签列表 |
| api/users/tags/add | tags__add() | post | 新增个性标签 |

View File

@ -3,7 +3,6 @@
namespace Tests\Feature;
use App\Models\AppPushDevice;
use App\Models\UmengAlias;
use App\Models\User;
use App\Models\UserDevice;
use App\Models\WebSocketDialogMsgRead;
@ -12,7 +11,7 @@ use App\Module\AppPush\DooPushClient;
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
use App\Tasks\PushUmengMsg;
use App\Tasks\PushAppMsg;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -46,8 +45,7 @@ class AppPushTest extends TestCase
private function settings(array $overrides = []): void
{
Base::setting('appPushSetting', array_merge([
'push' => 'close',
'doopush' => 'open',
'doopush' => 'custom',
'doopush_app_id' => '123',
'doopush_app_secret' => 'test-secret',
'doopush_base_url' => '',
@ -78,22 +76,11 @@ class AppPushTest extends TestCase
return $device;
}
private function alias(string $alias, string $hash): UmengAlias
{
$row = UmengAlias::createInstance(['userid' => 1, 'alias' => $alias, 'platform' => 'ios', 'device_hash' => $hash]);
$row->save();
return $row;
}
public function test_get_and_post_update_transfer_device_and_escape_legacy_alias_suffix(): void
public function test_get_and_post_update_transfer_device(): void
{
[$first, $token] = $this->userToken();
$uniqueId = 'a%_!\\b';
$sameSuffix = $this->alias('iOS-1-' . $uniqueId, md5('older-token'));
$sameHash = $this->alias('android-1-old001', md5($token));
$unrelated = $this->alias('iOS-1-aXX!\\b', md5('other-token'));
$data = [
'device_id' => '94001', 'vendor' => 'apns', 'unique_id' => $uniqueId,
'device_id' => '94001', 'vendor' => 'apns',
'device_model' => 'iPhone', 'app_version' => '245', 'app_version_name' => '1.9.66',
'user_agent' => 'Push test UA', 'is_notified' => 'true',
];
@ -107,9 +94,6 @@ class AppPushTest extends TestCase
$this->assertSame('iPhone', $row->device);
$this->assertSame('Push test UA', $row->ua);
$this->assertSame(1, $row->is_notified);
$this->assertNull($sameSuffix->fresh());
$this->assertNull($sameHash->fresh());
$this->assertNotNull($unrelated->fresh());
[$second, $secondToken] = $this->userToken();
$this->getJson('/api/push/device?' . http_build_query([
@ -125,17 +109,6 @@ class AppPushTest extends TestCase
$this->assertSame(1, $updated->is_notified);
}
public function test_short_unique_id_does_not_delete_other_legacy_bindings(): void
{
[, $token] = $this->userToken();
$alias = $this->alias('iOS-1-abcd', md5('other-token'));
$this->postJson('/api/push/device', [
'device_id' => '94002', 'vendor' => 'apns', 'unique_id' => 'abcd', 'is_notified' => true,
], ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 1);
$this->assertNotNull($alias->fresh());
$this->assertSame(1, AppPushDevice::whereDeviceId('94002')->sole()->is_notified);
}
public function test_remove_accepts_invalid_token_but_requires_matching_hash_id_and_channel(): void
{
[, $token] = $this->userToken();
@ -172,32 +145,30 @@ class AppPushTest extends TestCase
$this->assertFalse(AppPushDevice::whereDeviceId('94005')->exists());
}
public function test_logout_cleans_both_device_bindings(): void
public function test_logout_cleans_device_bindings(): void
{
$token = 'logout-test-token';
$row = $this->device(1, '94006', ['device_hash' => md5($token)]);
$alias = $this->alias('ios-1-logout', md5($token));
$other = $this->device(1, '94007');
UserDevice::forget($token);
$this->assertNull($row->fresh());
$this->assertNull($alias->fresh());
$this->assertNotNull($other->fresh());
}
public function test_settings_default_and_save_preserve_both_channels_and_filter_unknown_keys(): void
public function test_settings_default_and_save_filter_unknown_keys(): void
{
Base::setting('appPushSetting', ['push' => 'close']);
Base::setting('appPushSetting', []);
[, $token] = $this->userToken(true);
$this->getJson('/api/system/setting/apppush', ['token' => $token])
->assertJsonPath('ret', 1)->assertJsonPath('data.doopush', 'close');
->assertJsonPath('ret', 1)->assertJsonPath('data.doopush', 'system');
config(['dootask.system_setting' => 'enabled']);
$this->postJson('/api/system/setting/apppush', [
'type' => 'save', 'push' => 'open', 'ios_key' => 'old-key', 'ios_secret' => 'old-secret',
'doopush' => 'open', 'doopush_app_id' => '123', 'doopush_app_secret' => 'new-secret',
'type' => 'save', 'push' => 'open', 'ios_key' => 'old-key',
'doopush' => 'custom', 'doopush_app_id' => '123', 'doopush_app_secret' => 'new-secret',
'doopush_base_url' => 'https://push.test/api/v1/', 'unexpected' => 'ignored',
], ['token' => $token])->assertJsonPath('ret', 1)
->assertJsonPath('data.push', 'open')->assertJsonPath('data.ios_key', 'old-key')
->assertJsonPath('data.doopush', 'open')->assertJsonPath('data.doopush_app_id', '123')
->assertJsonMissingPath('data.push')->assertJsonMissingPath('data.ios_key')
->assertJsonPath('data.doopush', 'custom')->assertJsonPath('data.doopush_app_id', '123')
->assertJsonPath('data.doopush_app_secret', 'new-secret')
->assertJsonPath('data.doopush_base_url', 'https://push.test/api/v1/')->assertJsonMissingPath('data.unexpected');
}
@ -293,20 +264,133 @@ class AppPushTest extends TestCase
$this->assertTrue(AppPush::enabled());
$msg = ['body' => '消息', 'extra' => ['msg_id' => 98001]];
Cache::put("user_pc_active:{$userid}", time(), 60);
(new PushUmengMsg($userid, $msg))->start();
(new PushAppMsg($userid, $msg))->start();
$this->assertCount(0, $client->requests);
Cache::forget("user_pc_active:{$userid}");
(new PushUmengMsg($userid, $msg))->start();
(new PushAppMsg($userid, $msg))->start();
$this->assertCount(1, $client->requests);
WebSocketDialogMsgRead::createInstance(['userid' => $userid, 'msg_id' => 98001, 'read_at' => now()])->save();
$delayed = new PushUmengMsg($userid, $msg);
$delayed = new PushAppMsg($userid, $msg);
$delayed->delay(10);
$delayed->start();
$this->assertCount(1, $client->requests);
$this->settings(['doopush' => 'close']);
$this->assertFalse(AppPush::enabled());
}
public function test_settings_reject_unknown_mode(): void
{
[, $token] = $this->userToken(true);
config(['dootask.system_setting' => 'enabled']);
$this->postJson('/api/system/setting/apppush', ['type' => 'save', 'doopush' => 'open'], ['token' => $token])
->assertJsonPath('ret', 0);
}
private function systemMode(?string $secret = null): RecordingDooPushClient
{
$this->settings(['doopush' => 'system']);
config(['dootask.doopush_system' => ['base_url' => 'https://push.test/api/v1', 'app_id' => '10', 'app_key' => 'dp_ak_test']]);
Base::setting('appPushTenant', $secret ? [
'base_url' => 'https://push.test/api/v1', 'app_id' => '10', 'tenant_id' => 1, 'tenant_secret' => $secret,
] : []);
$client = new RecordingDooPushClient();
$this->app->instance(DooPushClient::class, $client);
return $client;
}
private static function reply(int $status, array $json): array
{
return ['ret' => 1, 'msg' => (string)$status, 'data' => json_encode($json)];
}
public function test_system_mode_registers_tenant_and_binds_by_push_token(): void
{
$client = $this->systemMode();
[$user, $token] = $this->userToken();
$data = ['device_id' => '94101', 'vendor' => 'apns', 'is_notified' => 1];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 0);
$this->assertCount(0, $client->requests);
$client->responses = [
self::reply(201, ['code' => 201, 'data' => ['tenant_id' => 7, 'tenant_secret' => 'dp_ts_new']]),
self::reply(200, ['code' => 200, 'data' => ['device_id' => 77001]]),
];
$this->postJson('/api/push/device', $data + ['push_token' => 'apns-token'], ['token' => $token, 'platform' => 'ios'])
->assertJsonPath('ret', 1);
$this->assertSame('https://push.test/api/v1/apps/10/tenants', $client->requests[0]['url']);
$this->assertSame('dp_ak_test', $client->requests[0]['headers']['X-App-Key']);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/devices', $client->requests[1]['url']);
$this->assertSame('Bearer dp_ts_new', $client->requests[1]['headers']['Authorization']);
$this->assertSame(['token' => 'apns-token'], $client->requests[1]['body']);
// 以 DooPush 返回的设备ID为准,不信任客户端自报。
$row = AppPushDevice::whereChannel(AppPush::CHANNEL_SYSTEM)->sole();
$this->assertSame('77001', $row->device_id);
$this->assertSame($user->userid, $row->userid);
$this->assertFalse(AppPushDevice::whereDeviceId('94101')->exists());
}
public function test_system_mode_reregisters_on_401_but_not_on_403(): void
{
$client = $this->systemMode('dp_ts_old');
[, $token] = $this->userToken();
$data = ['device_id' => '94102', 'vendor' => 'apns', 'push_token' => 'apns-token'];
$client->responses = [
self::reply(401, ['code' => 401, 'message' => 'invalid']),
self::reply(201, ['code' => 201, 'data' => ['tenant_id' => 8, 'tenant_secret' => 'dp_ts_renew']]),
self::reply(200, ['code' => 200, 'data' => ['device_id' => 77002]]),
];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 1);
$this->assertCount(3, $client->requests);
$this->assertSame('Bearer dp_ts_renew', $client->requests[2]['headers']['Authorization']);
$client->requests = [];
$client->responses = [self::reply(403, ['code' => 403, 'message' => 'disabled'])];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 0);
$this->assertCount(1, $client->requests);
$this->assertSame('dp_ts_renew', Base::setting('appPushTenant')['tenant_secret']);
}
public function test_system_mode_push_cleans_unbound_devices_and_remove_unbinds(): void
{
$client = $this->systemMode('dp_ts_live');
$this->device(910101, '77003', ['channel' => AppPush::CHANNEL_SYSTEM]);
$this->device(910101, '77004', ['channel' => AppPush::CHANNEL_SYSTEM, 'updated_at' => now()->subMinute()]);
$this->device(910101, '96101');
$client->responses = [self::reply(200, ['code' => 200, 'data' => ['count' => 1, 'unbound_device_ids' => [77004]]])];
$client->pushToUsers(910101, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/push', $client->requests[0]['url']);
$this->assertSame('Bearer dp_ts_live', $client->requests[0]['headers']['Authorization']);
$this->assertSame([77003, 77004], $client->requests[0]['body']['device_ids']);
$this->assertArrayNotHasKey('target', $client->requests[0]['body']);
$this->assertFalse(AppPushDevice::whereChannel(AppPush::CHANNEL_SYSTEM)->whereDeviceId('77004')->exists());
// 401 时清除失效凭证,不重试。
$client->requests = [];
$client->responses = [self::reply(401, ['code' => 401, 'message' => 'invalid'])];
$client->pushToUsers(910101, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame([], Base::setting('appPushTenant'));
$client = $this->systemMode('dp_ts_live');
[$user, $token] = $this->userToken();
$this->device($user->userid, '77005', ['channel' => AppPush::CHANNEL_SYSTEM, 'device_hash' => md5($token)]);
$this->postJson('/api/push/device', ['action' => 'remove', 'device_id' => '77005'], ['token' => $token])
->assertJsonPath('ret', 1);
$this->assertFalse(AppPushDevice::whereDeviceId('77005')->exists());
$this->assertSame('DELETE', $client->requests[0]['method']);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/devices/77005', $client->requests[0]['url']);
}
public function test_close_mode_does_not_record_devices(): void
{
$this->settings(['doopush' => 'close']);
[, $token] = $this->userToken();
$this->postJson('/api/push/device', ['device_id' => '94103', 'vendor' => 'apns'], ['token' => $token, 'platform' => 'ios'])
->assertJsonPath('ret', 1);
$this->assertFalse(AppPushDevice::whereDeviceId('94103')->exists());
}
}
/** 用可控响应替换外部网络,验证实际数据库选出的发送目标与载荷。 */
@ -314,11 +398,11 @@ class RecordingDooPushClient extends DooPushClient
{
public array $requests = [];
public function __construct(private array $responses = []) {}
public function __construct(public array $responses = []) {}
protected function request(string $url, array $headers, array $body): array
protected function request(string $url, array $headers, array $body, string $method = 'POST'): array
{
$this->requests[] = compact('url', 'headers', 'body');
$this->requests[] = compact('url', 'headers', 'body', 'method');
$response = array_shift($this->responses);
if ($response instanceof \Throwable) {
throw $response;