feat(push): 新增 DooPush 推送通道,与友盟并存

- 新增 app_push_devices 表与 api/push/device 接口(新版 App 上报/删除 DooPush 设备)
- app/Module/AppPush 统一分发友盟与 DooPush,业务字段经 payload.data 下发
- 系统设置「App 推送」新增 DooPush 配置;点击通知打开对应会话
- 更新 Expo 壳子模块(接入 DooPush)、语言包与 ai-kb
This commit is contained in:
kuaifan 2026-10-19 10:33:19 +00:00
parent 0f2f165940
commit a49ee608a5
57 changed files with 1029 additions and 352 deletions

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\InvokeController;
use App\Models\User;
use App\Module\AppPush\DeviceRegistry;
use App\Module\Base;
use Request;
/**
* @apiDefine push APP 推送
*/
class PushController extends InvokeController
{
/**
* @api {post} api/push/device 更新或移除 DooPush 设备
*
* @apiDescription 同时支持 GET 和 POST。update 需要有效 token 和 ios/android 平台;remove 仅按请求头 token 的 MD5 匹配解绑,允许 token 已失效。
* @apiVersion 1.0.0
* @apiGroup push
* @apiName device
*
* @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} [device_model] 设备型号,最多255个字符
* @apiParam {String} [app_version] 应用版本代码
* @apiParam {String} [app_version_name] 应用版本名称,与代码组合后最多100个字符
* @apiParam {String} [user_agent] 浏览器信息
* @apiParam {String} [is_notified] true、字符串true、1、字符串1表示有通知权限
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Array} data 返回数据(空数组)
*/
public function device()
{
$data = Request::input();
$action = $data['action'] ?? 'update';
$deviceId = $data['device_id'] ?? '';
if (!is_string($deviceId) || !preg_match('/^[0-9]{1,64}$/D', $deviceId)) {
return Base::retError('推送设备ID必须为1至64位数字');
}
if (!in_array($action, ['update', 'remove'], true)) {
return Base::retError('参数错误');
}
if ($action === 'remove') {
DeviceRegistry::remove($deviceId, (string)Request::header('token', ''));
return Base::retSuccess('删除成功');
}
$user = User::auth();
$platform = Base::platform();
if (!in_array($platform, ['ios', 'android'], true)) {
return Base::retError('设备类型错误');
}
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) {
if (isset($data[$field]) && (!is_string($data[$field]) || mb_strlen($data[$field]) > $limit)) {
return Base::retError('参数错误');
}
}
$version = empty($data['app_version']) ? '' : (($data['app_version_name'] ?? '') . " ({$data['app_version']})");
if (mb_strlen($version) > 100 || strlen($data['user_agent'] ?? '') > 65535) {
return Base::retError('参数错误');
}
DeviceRegistry::update($user->userid, [
'device_id' => $deviceId,
'vendor' => $data['vendor'],
'platform' => $platform,
'device' => $data['device_model'] ?? '',
'version' => $version,
'ua' => $data['user_agent'] ?? '',
'is_notified' => in_array($data['is_notified'] ?? false, [true, 'true', 1, '1'], true) ? 1 : 0,
], $data['unique_id'] ?? '');
return Base::retSuccess('保存成功');
}
}

View File

@ -560,7 +560,7 @@ class SystemController extends AbstractController
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:['push', 'ios_key', 'ios_secret', 'android_key', 'android_secret'])
* - save: 保存设置(参数:['push', 'ios_key', 'ios_secret', 'android_key', 'android_secret', 'doopush', 'doopush_app_id', 'doopush_app_secret', 'doopush_base_url'])
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@ -582,6 +582,10 @@ class SystemController extends AbstractController
'ios_secret',
'android_key',
'android_secret',
'doopush',
'doopush_app_id',
'doopush_app_secret',
'doopush_base_url',
])) {
unset($all[$key]);
}
@ -592,6 +596,10 @@ class SystemController extends AbstractController
}
//
$setting['push'] = $setting['push'] ?: 'close';
$setting['doopush'] = ($setting['doopush'] ?? '') ?: 'close';
$setting['doopush_app_id'] = $setting['doopush_app_id'] ?? '';
$setting['doopush_app_secret'] = $setting['doopush_app_secret'] ?? '';
$setting['doopush_base_url'] = $setting['doopush_base_url'] ?? '';
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
/**
* App\Models\AppPushDevice
*
* @property int $id
* @property int $userid 用户ID
* @property string $channel 推送通道
* @property string $device_id 推送服务设备ID
* @property string $vendor 厂商通道
* @property string $platform 平台类型
* @property string $device_hash 登录TOKEN MD5,关联UserDevice
* @property string $device 设备型号
* @property string $version 应用版本号
* @property string|null $ua userAgent
* @property int $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|AppPushDevice newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereChannel($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereDevice($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereDeviceHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereDeviceId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereIsNotified($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereUa($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereVendor($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppPushDevice whereVersion($value)
* @mixin \Eloquent
*/
class AppPushDevice extends AbstractModel
{
protected $table = 'app_push_devices';
}

View File

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

View File

@ -0,0 +1,32 @@
<?php
namespace App\Module\AppPush;
use App\Models\UmengAlias;
use App\Module\Base;
class AppPush
{
public static function enabled(): bool
{
$setting = Base::setting('appPushSetting');
return ($setting['push'] ?? 'close') === 'open' || ($setting['doopush'] ?? 'close') === 'open';
}
/**
* @param array|int $userids
* @param array $msg [title, subtitle, body, description, extra, seconds, badge]
*/
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

@ -0,0 +1,51 @@
<?php
namespace App\Module\AppPush;
use App\Exceptions\ApiException;
use App\Models\AppPushDevice;
use App\Models\UmengAlias;
use App\Models\UserDevice;
class DeviceRegistry
{
/**
* 绑定当前登录设备,并在同一事务中清理旧壳别名。
*/
public static function update(int $userid, array $attributes, string $uniqueId = ''): 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();
}
});
}
/**
* 退出时 token 可以已失效,但必须与绑定时的 token 相同。
*/
public static function remove(string $deviceId, string $token): void
{
if ($token === '') {
return;
}
// 与 UserDevice::check()/forget() 共用 TOKEN MD5 算法,不依赖 token 解码。
AppPushDevice::whereChannel('doopush')->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->delete();
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Module\AppPush;
use App\Models\AppPushDevice;
use App\Models\WebSocketDialogMsgRead;
use App\Module\Base;
use App\Module\Ihttp;
use Carbon\Carbon;
class DooPushClient
{
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 === '') {
return;
}
$content = str_replace(["\r\n", "\r", "\n"], '', $msg['body'] ?? '');
$title = trim(str_replace(["\r\n", "\r", "\n"], '', $msg['title'] ?? ''));
$title = $title === '' ? trim($content) : $title;
if ($title === '' || trim($content) === '') {
return;
}
$extra = [];
foreach (is_array($msg['extra'] ?? null) ? $msg['extra'] : [] as $key => $value) {
$extra[$key] = is_scalar($value) || $value === null ? (string)$value : Base::array2json($value);
}
// DooPush 只接受 action/url/data 与厂商参数,自定义字段须以 JSON 字符串放在 data(客户端解析)。
$payload = array_merge($extra ? ['data' => Base::array2json($extra)] : [], [
'huawei' => ['importance' => 'NORMAL', 'category' => 'IM'],
'honor' => ['importance' => 'NORMAL'],
'oppo' => ['category' => 'IM'],
// 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) {
if ($userid <= 0) {
continue;
}
try {
// 按用户单独取最新5台,避免跨查询分块时重复发送或突破设备数限制。
$deviceIds = AppPushDevice::whereChannel('doopush')->whereUserid($userid)
->where('updated_at', '>', Carbon::now()->subMonth())
->orderByDesc('updated_at')->orderByDesc('id')->limit(5)
->pluck('device_id')
// API 要求整数 ID,避免超长数字强转后意外指向另一台设备。
->map(fn ($id) => filter_var(ltrim($id, '0'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))
->filter(fn ($id) => $id !== false)->values()->all();
if (!$deviceIds) {
continue;
}
$badge = WebSocketDialogMsgRead::whereUserid($userid)->whereSilence(0)->whereReadAt(null)->count();
$this->send($url, $headers, [
'title' => mb_substr($title, 0, 200),
'content' => $content,
'badge' => $badge,
'payload' => $payload,
'target' => ['type' => 'devices', 'device_ids' => $deviceIds],
], $userid);
} catch (\Throwable $e) {
info('[DooPush] user push failed: ' . $e->getMessage(), ['userid' => $userid]);
}
}
} catch (\Throwable $e) {
info('[DooPush] push failed: ' . $e->getMessage());
}
}
private function send(string $url, array $headers, array $body, int $userid): void
{
// 首次发送 + 最多2次重试;不在长生命周期进程中保存待发送状态。
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->request($url, $headers, $body);
$result = Base::json2array($response['data'] ?? '');
$status = (int)($response['msg'] ?? 0);
if (Base::isSuccess($response) && $status >= 200 && $status < 300 && ($result['code'] ?? 0) === 200) {
return;
}
info('[DooPush] send failed', [
'userid' => $userid,
'attempt' => $attempt,
'status' => $status,
'message' => mb_substr((string)($result['message'] ?? $response['msg'] ?? 'Invalid response'), 0, 500),
]);
} catch (\Throwable $e) {
info('[DooPush] send failed: ' . $e->getMessage(), ['userid' => $userid, 'attempt' => $attempt]);
}
}
}
protected function request(string $url, array $headers, array $body): array
{
return Ihttp::ihttp_request($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), array_merge($headers, [
'CURLOPT_SSL_VERIFYPEER' => true,
'CURLOPT_SSL_VERIFYHOST' => 2,
]), 10);
}
}

View File

@ -3,7 +3,7 @@
namespace App\Tasks;
use App\Models\ProjectTask;
use App\Module\Base;
use App\Module\AppPush\AppPush;
use Carbon\Carbon;
class AppPushTask extends AbstractTask
@ -15,8 +15,7 @@ class AppPushTask extends AbstractTask
public function start()
{
$setting = Base::setting('appPushSetting');
if ($setting['push'] !== 'open') {
if (!AppPush::enabled()) {
return;
}
ProjectTask::whereNull("complete_at")

View File

@ -1,14 +1,13 @@
<?php
namespace App\Tasks;
use App\Models\UmengAlias;
use App\Models\WebSocketDialogMsgRead;
use App\Module\Base;
use App\Module\AppPush\AppPush;
use Cache;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* 推送友盟消息
* 推送 APP 消息(友盟 / DooPush)
*/
class PushUmengMsg extends AbstractTask
{
@ -32,8 +31,7 @@ class PushUmengMsg extends AbstractTask
if (empty($this->userid) || empty($this->array)) {
return;
}
$setting = Base::setting('appPushSetting');
if ($setting['push'] !== 'open') {
if (!AppPush::enabled()) {
return;
}
@ -77,7 +75,7 @@ class PushUmengMsg extends AbstractTask
// 直接推送
if ($directPushUsers) {
UmengAlias::pushMsgToUserid($directPushUsers, $this->array);
AppPush::pushToUsers($directPushUsers, $this->array);
}
// 创建延迟推送任务

View File

@ -6,6 +6,7 @@ use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgRead;
use App\Module\AppPush\AppPush;
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
@ -185,10 +186,9 @@ class WebSocketDialogMsgTask extends AbstractTask
$umengUserid[] = $item['userid'];
}
}
// umeng推送app
// APP 推送(友盟 / DooPush)
if ($umengUserid) {
$setting = Base::setting('appPushSetting');
if ($setting['push'] === 'open') {
if (AppPush::enabled()) {
if ($msg->userid == -1) {
// AI 助手虚拟用户没有会员记录,取自定义昵称或默认名称
$umengTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAppPushDevicesTable extends Migration
{
public function up(): void
{
Schema::create('app_push_devices', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->comment('用户ID');
$table->string('channel', 20)->comment('推送通道');
$table->string('device_id', 64)->comment('推送服务设备ID');
$table->string('vendor', 20)->default('')->comment('厂商通道');
$table->string('platform', 20)->default('')->comment('平台类型');
$table->string('device_hash', 64)->index()->comment('登录TOKEN MD5,关联UserDevice');
$table->string('device', 255)->default('')->comment('设备型号');
$table->string('version', 100)->default('')->comment('应用版本号');
$table->text('ua')->nullable()->comment('userAgent');
$table->tinyInteger('is_notified')->default(0)->comment('通知权限');
$table->timestamps();
$table->unique(['channel', 'device_id']);
});
}
public function down(): void
{
Schema::dropIfExists('app_push_devices');
}
}

View File

@ -1092,3 +1092,5 @@ WebDAV 未启用或你没有使用权限
已开始更新系统
关联任务
解除关联任务
推送设备ID必须为1至64位数字
推送厂商错误

View File

@ -2844,3 +2844,8 @@ APP 新版本
更新内容
按字母显示
按部门显示
DooPush 推送
App ID
App Secret
服务地址
新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。

View File

@ -40966,5 +40966,65 @@
"fr": "Dissocier la tâche",
"id": "Putuskan tautan tugas",
"ru": "Отвязать задачу"
},
{
"key": "DooPush 推送",
"zh": "",
"zh-CHT": "DooPush 推送",
"en": "DooPush Notifications",
"ko": "DooPush 푸시 알림",
"ja": "DooPush プッシュ通知",
"de": "DooPush-Benachrichtigungen",
"fr": "Notifications DooPush",
"id": "Notifikasi DooPush",
"ru": "Уведомления DooPush"
},
{
"key": "App ID",
"zh": "",
"zh-CHT": "App ID",
"en": "App ID",
"ko": "앱 ID",
"ja": "App ID",
"de": "App ID",
"fr": "App ID",
"id": "App ID",
"ru": "App ID"
},
{
"key": "App Secret",
"zh": "",
"zh-CHT": "App Secret",
"en": "App Secret",
"ko": "앱 시크릿",
"ja": "App Secret",
"de": "App Secret",
"fr": "App Secret",
"id": "App Secret",
"ru": "App Secret"
},
{
"key": "服务地址",
"zh": "",
"zh-CHT": "服務地址",
"en": "Service URL",
"ko": "서비스 주소",
"ja": "サービスURL",
"de": "Dienst-URL",
"fr": "URL du service",
"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. Оба сервиса можно включить одновременно."
}
]
]

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

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

View File

@ -19,7 +19,7 @@ negative:
- 普通成员看不到管理员应用区,整块都不会渲染
- 管理员应用不通过应用市场安装,是主程序内置
- 超级管理员专属功能(如 License Key、合规设置)不属于这里,需走系统设置
last_verified: v1.7.90
last_verified: v1.9.66
---
# 管理员应用全集
@ -30,7 +30,7 @@ last_verified: v1.7.90
## 当前内置 6 个
- **LDAP**:第三方目录服务接入与同步
- **邮件通知**:SMTP 服务器与通知开关配置
- **APP 推送**:移动端 UMENG 推送配置
- **APP 推送**:移动端 DooPush / 友盟推送配置
- **举报管理**:处理用户举报记录
- **数据导出**:任务/超期/审批/签到等 4 种数据导出
- **团队管理**:成员列表 / 部门管理 / 邀请加入

View File

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

View File

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

View File

@ -20,7 +20,7 @@ negative:
- 未配置 SMTP 时所有邮件功能都不发邮件,但不会报错给用户
- 邮件通道只用于系统通知(注册验证 / 改邮箱 / 未读消息 / 删除账号验证),不能用作客户营销邮件
- 邮件发送依赖第三方 SMTP,DooTask 自身不内置邮件服务器
last_verified: v1.7.90
last_verified: v1.9.66
---
# 邮件通知是什么
@ -39,7 +39,7 @@ last_verified: v1.7.90
具体场景见 [[email-notice.scenarios.concept]]。
## 与其他通知的关系
- **APP 推送**(友盟):见 [[push-notice.concept]],独立通道
- **APP 推送**(DooPush / 友盟):见 [[push-notice.concept]],独立通道
- **桌面通知**(Electron):本地系统通知,不走邮件
- **移动端通知**:iOS/Android 推送,不走邮件

View File

@ -20,7 +20,7 @@ negative:
- 桌面端独有的能力(截图、全局快捷键、托盘)由原生 OS 集成,移动端无对应概念
- 微应用 / 插件部分能在移动端打开(走 WebView),但触屏不一定可用
- 部署环境可通过 APP_AI_HIDDEN=true 隐藏移动 APP 的 AI 界面,但不会关闭 Web、Electron 或服务端 AI 接口
last_verified: v1.9.18
last_verified: v1.9.66
---
# 移动端与桌面端功能差异
@ -36,7 +36,7 @@ last_verified: v1.9.18
## 移动端独有
- 底部 Tabbar 原生导航
- 系统级推送(UMENG,见 [[mobile-client.notify.concept]])
- 系统级推送(新版 DooPush / 旧版 UMENG,见 [[mobile-client.notify.concept]])
- 手势操作(左滑会话项 / 长按消息等,见 [[mobile-client.gesture.concept]])
- 移动键盘的「发送按钮」配置(切换 Enter 是发送还是换行)
- 二维码扫码 / 调起相机拍摄上传

View File

@ -9,59 +9,36 @@ aliases:
- 移动端推送
- 手机推送
- App 通知
- UMENG 推送
- 友盟推送
- 收不到手机通知
- DooPush 推送
- 锁屏推送
- 后台推送
related_tools: []
related_pages: []
prerequisites:
- 已登录过 App 且设备别名已注册到友盟
- 已登录移动 App 并完成设备绑定
negative:
- 推送通道走 UMENG(友盟),需服务端配置 AppKey / Master Secret,自部署默认未开启
- 国内 Android 推送依赖各厂商通道(华为 / 小米 / OPPO / vivo / 魅族),后台杀进程后到达率与设备绑定
- 推送内容受隐私设置影响:锁屏隐藏详情时只显示「您有一条新消息」
last_verified: v1.7.90
- 服务端需配置与 App 版本匹配的推送通道
- 到达率受厂商通道、网络和手机电池策略影响
- 不支持脱离推送服务的点对点离线通知
last_verified: v1.9.66
---
# 移动端推送通知
## 定义
移动端推送通知是 DooTask 服务端通过 UMENG(友盟)推送平台向已登录设备投递的消息提醒,即使 App 处于后台 / 锁屏 / 杀进程也能到达(取决于厂商通道)。区别于桌面端原生通知([[electron-client.notify.concept]]),属于完全不同的通道。
移动端推送通知是 DooTask 向已登录手机发送的系统提醒。新版 App 通过 DooPush,旧版通过友盟 UMENG,再下发到 APNs 或 Android 厂商通道。桌面端原生通知使用另一套链路。
## 推送通道架构
- 用户登录后 App 调 `/api/users/umeng/alias` 把设备 token 与用户 ID 绑定
- 服务端在 `WebSocketDialogMsgTask` 中向离线 / 后台用户额外发起 UMENG 推送
- UMENG 把推送下发到 APNs(iOS)或各厂商通道(Android),最终弹到设备
## 推送链路
1. 登录后,新版 App 调用 `api/push/device` 上报设备 ID,旧版调用 `api/users/umeng/alias` 注册别名。
2. 收到他人发送的非静默会话消息时,后端向用户的活跃设备发送通知。
3. 私聊、群聊、任务分配及任务超时等提醒通过对应会话消息进入推送,详见 [[task.notify.concept]]。
4. PC 最近 60 秒活跃会延迟手机通知 10 秒,延迟期间读过则跳过。
## 触发推送的事件
- 新私聊 / 群聊消息
- @ 你的消息
- 任务相关:被分配、被 @、状态变更、任务即将超时 / 已超时定时提醒(由 task-alert「任务提醒」机器人私聊发送,详见 [[task.notify.concept]])
- 项目重要变更
- 系统公告 / 管理员推送
## 收不到推送的原因(排序)
1. **服务端未配置 UMENG**:自部署版本默认未填 AppKey,推送整体不可用
2. **App 未授权通知**:手机系统设置 → 「通知」→「DooTask」必须打开「允许通知」
3. **后台被系统杀死**:Android 厂商电池优化会把 App 列入限制,导致离线推送无法激活
4. **应用内免打扰**:DooTask 个人设置或单会话开了免打扰
5. **网络问题**:UMENG 服务器与手机的连接断开
## 提高到达率(Android)
- 「电池」→ 把 DooTask 加入「不限制 / 后台运行白名单」
- 「自启动管理」→ 允许 DooTask 自启动
- 「电池优化」→ 把 DooTask 设为「不优化」
- 部分品牌(华为)需在「应用启动管理」里手动启用所有开关
## 推送内容显示
- 通常显示「发送人 + 消息预览」
- 锁屏隐藏详情或群聊不显示发送人,可在系统通知设置调整
- 详情显示策略以服务端推送结构为准,App 不能单独配置
## 收不到推送
- 检查服务器对应通道开关和凭证:[[push-notice.config.howto]]。
- 检查手机通知权限、会话免打扰、Android 自启动和电池限制。
- 退出登录会解绑设备;超过一个月未更新的绑定不再用于推送。
- 重新登录可刷新设备绑定;排查步骤见 [[mobile-client.push-fail.faq]]。
## 不支持
- 不支持脱离 UMENG 的 P2P 推送(架构上依赖)
- 不支持「按场景拆开关」(只能整体开 / 关)
- 见到推送但点开 App 没新消息,通常是 WebSocket 已经在线时推送延迟
- 详细收不到处理见 [[mobile-client.push-fail.faq]]
不支持脱离推送服务的点对点离线通知;到达率受厂商通道、网络和手机电池策略影响,不能保证所有设备即时送达。

View File

@ -21,7 +21,7 @@ negative:
- 不支持「完全离线编辑后联网同步」(不是离线优先架构)
- 离线时新发消息不会本地排队稍后投递,会直接报失败需用户重试
- 离线下文件不能预览(下载完成的文件可在系统文件管理器看)
last_verified: v1.7.90
last_verified: v1.9.66
---
# 移动端离线行为
@ -48,7 +48,7 @@ last_verified: v1.7.90
- **发送失败的消息**:不会自动重发,用户需手动点重试
## 离线推送
即使设备完全离线,服务端仍会通过 UMENG 通道下发推送([[mobile-client.notify.concept]]),iOS 设备会在恢复网络后由 APNs 投递。Android 则取决于厂商通道是否能在弱网下唤醒。
即使设备完全离线,服务端仍会通过 DooPush 或 UMENG 通道下发推送([[mobile-client.notify.concept]]),iOS 设备会在恢复网络后由 APNs 投递。Android 则取决于厂商通道是否能在弱网下唤醒。
## 弱网体验优化
- App 会自动重试关键请求(消息发送)

View File

@ -21,7 +21,7 @@ negative:
- 移动端不是网页直接套壳,是基于 EEUI 框架的原生混合 App
- 移动端不支持桌面端的全局快捷键 / 截图工具 / 多窗口
- 移动端不能改服务器地址(首次登录设置后存住,需重置才能切换)
last_verified: v1.7.90
last_verified: v1.9.66
---
# 移动端是什么
@ -38,12 +38,12 @@ DooTask 移动端是 iOS 与 Android 双平台的原生 App,基于 EEUI 框架
## 与桌面 / 网页端的差异
- 导航形态:移动端用底部 Tabbar(消息 / 任务 / 应用 / 我的),桌面端用左侧栏
- 输入习惯:发送按钮 / 换行按钮在移动端可切换(键盘上的「发送」是发还是换行)
- 推送通道:走 UMENG 系统推送([[mobile-client.notify.concept]]),不是 WebSocket 自送
- 推送通道:新版走 DooPush、旧版走 UMENG 系统推送([[mobile-client.notify.concept]]),不是 WebSocket 自送
- 富文本编辑器在小屏上做了简化
## 设备识别
- 前端通过 UA `/eeui/i` 检测 `$isEEUIApp`,据此走移动端专属逻辑
- 服务端调用别名注册接口(`/api/users/umeng/alias`)记录该设备 ID,用于推送路由
- App 向服务端注册推送绑定:新版调用 `/api/push/device` 上报 DooPush 设备 ID,旧版调用 `/api/users/umeng/alias` 上报友盟别名
## 何时选移动端
- 出差 / 通勤时收消息和处理任务

View File

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

View File

@ -1,62 +1,48 @@
---
id: push-notice.alias.concept
title: 友盟 Alias 用户绑定
title: APP 推送设备绑定
type: concept
feature: push-notice
scope: end-user
locale: zh
aliases:
- UmengAlias
- 友盟别名
- 设备绑定
- 推送绑定
- 怎么收到推送
- 推送目标
- DooPush 设备上报
- 升级后重复推送
related_tools: []
related_pages: []
prerequisites:
- 管理员已开启 APP 推送
- 用户在移动端 APP 已登录
- 用户在 iOS / Android App 登录
negative:
- Web 端 / 桌面端不写 UmengAlias 表,因此不通过友盟推送
- 别名 30 天未活跃即不再用于推送(updated_at 过期)
- 调试模式(isDebug=1)的 APP 不会注册别名,避免污染生产推送
last_verified: v1.7.90
- Web 端和桌面端不注册 APP 推送设备
- 超过一个月未更新的设备记录不再参与推送
- remove 不能删除其他登录 token 绑定的 DooPush 设备
last_verified: v1.9.66
---
# 友盟 Alias 用户绑定
# APP 推送设备绑定
## 定义
`umeng_alias` 是 DooTask 用来记录「某用户的某台移动设备应该接收哪些推送」的绑定表。每次移动 APP 启动登录后会调用 `api/users/umeng/alias` 注册或更新这条记录,后端按 `userid → alias` 反向查到所有活跃设备来发推送。
DooTask 按用户绑定移动设备:新版 App 上报到 `app_push_devices` 的 DooPush 通道;旧版 App 继续使用 `umeng_alias` 的友盟别名。Web 端和桌面端不注册这两类设备。
## 关键字段
| 字段 | 说明 |
|---|---|
| userid | DooTask 用户 ID |
| alias | 友盟 alias,用户在友盟侧的唯一标识 |
| platform | ios / android(其他平台调用会被拒) |
| device | 设备型号 |
| device_hash | 设备指纹,关联 UserDevice 表 |
| version | APP 版本号(含 versionName) |
| ua | userAgent |
| is_notified | 系统通知权限(0=未授予 / 1=已授予) |
| updated_at | 最后活跃时间;超过 30 天的别名不再被推送选中 |
## 新版 App 上报
`api/push/device` 同时支持 GET 和 POST,返回标准的 ret、msg、data。
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?action=update`:
1. 校验 platform 必须是 ios / android
2. 校验 alias 长度 2-20 字符
3. 校验 isDebug=0(调试模式拒绝)
4. 表里同 `userid + alias + platform` 存在则更新;不存在则插入
## 旧版 App 上报
调用 `api/users/umeng/alias` 更新 alias,格式为 platform-userid-uniqueId;调试模式不注册友盟别名。
退出登录时可调 `action=remove` 删别名。
## 活跃设备与解绑
- 推送只选择一个月内更新的记录;DooPush 按用户取最新 5 台设备,每个用户一次请求。
- 角标取该用户未读且非静默的会话消息数。
- 新版退出登录调用 action=remove,传 device_id 和原请求头 token;token 可以已失效,但只能移除与其 MD5 匹配的 DooPush 记录。
- 服务端忘记登录设备时,也同步清理相同 token 哈希的两类推送绑定。
## 多设备规则
- 同一 userid 可绑定多台设备,每个设备一条记录
- 推送时按 platform 分组,每个用户每平台最多取最近 5 个 alias 一起发(按 updated_at 倒序)
- 角标数取自该用户未读且非静默消息总数(WebSocketDialogMsgRead)
## 解绑
- 用户卸载 APP 后系统不会主动删除别名记录;该别名后续推送会在友盟侧失败,不影响其他设备
- 30 天未活跃自动从推送目标中剔除(但记录仍在表里)
- 管理员可在数据库直接清理 `umeng_alias`
## 不支持
超过一个月未更新的记录不会自动物理删除;remove 不能删除其他登录 token 绑定的 DooPush 设备。

View File

@ -10,39 +10,36 @@ aliases:
- 手机推送
- 友盟推送
- UMENG
- 移动通知
- 后台推送
- 推送消息
- DooPush 推送
- 后台推送消息
related_tools: []
related_pages: []
prerequisites:
- 管理员已在系统设置「APP 推送」配置友盟 appkey
- 管理员已开启并配置与 App 版本匹配的推送通道
- 用户在 iOS / Android 客户端登录并允许通知
negative:
- Web 端、桌面端(Electron)不走友盟推送,靠 WebSocket + 本地通知
- 未配置友盟 appkey 时所有 APP 推送链路不工作,但站内消息正常
- 不支持替换为其他推送服务(如 FCM、个推、极光)
last_verified: v1.7.90
- Web 端、桌面端不走 APP 推送,使用 WebSocket 和本地通知
- 两个通道都关闭或对应凭证缺失时无法发送离线推送
- 没有按消息类型订阅推送的开关
last_verified: v1.9.66
---
# APP 推送是什么
## 定义
APP 推送是 DooTask 通过友盟(UMENG)通道向移动端 APP(iOS / Android)发送的离线消息通知。即便 APP 被后台杀掉或网络断开后再回来,推送也能触达手机系统通知栏。
APP 推送是 DooTask 向 iOS / Android 移动端发送的系统通知。新版 App 使用 DooPush,旧版 App 使用友盟 UMENG;管理员可以同时开启两个通道,让不同版本的设备继续接收消息。
## 关键属性
- **通道**:友盟 UMENG,分 iOS / Android 两套独立配置(`ios_key`、`ios_secret`、`android_key`、`android_secret`)
- **别名机制**:用户每次登录移动端会注册一个 alias 到 `umeng_alias` 表,详见 [[push-notice.alias.concept]]
- **触发**:默认配置下,对方收到新会话消息(且非自己发的、非静默)就会触发
- **PC 在线优化**:若用户 PC 端 60 秒内活跃过,APP 推送会延迟 10 秒;这期间消息被读则跳过推送
- **角标**:iOS 直接传 `badge`;Android 传 `set_badge`(最大 99)
- **通道配置**:友盟使用 iOS / Android 两组 Appkey、App Master Secret;DooPush 使用 App ID、App Secret 和服务地址,详见 [[push-notice.config.howto]]。
- **设备绑定**:新版 App 登录后上报 DooPush 设备 ID,旧版上报友盟 alias;升级上报会清理同设备的旧别名,详见 [[push-notice.alias.concept]]。
- **触发条件**:收到他人发送的非静默会话消息;任务提醒通过会话消息进入相同链路。
- **PC 在线优化**:PC 端 60 秒内活跃时,APP 推送延迟 10 秒,延迟期间已读则跳过。
- **角标**:取该用户未读且非静默的会话消息数。
## 与其他通知的关系
- 跟[[email-notice.concept]]:邮件是 SMTP,APP 推送是友盟,两条独立链路
- 跟[[desktop-notify.concept]]:桌面通知靠 Electron 本地 `Notification`,与友盟无关
- 跟[[mobile-notify.concept]]:APP 内的浮层通知,前台收到推送时显示
邮件通知 [[email-notice.concept]]、桌面通知 [[desktop-notify.concept]] 和 APP 前台浮层 [[mobile-notify.concept]] 各自有独立的提醒链路。
## 不支持
- 没有「按消息类型订阅」(无法只推@提及不推普通消息)
- 不能自定义推送音效(用系统默认)
- AI 助手消息也不例外:同样走推送,标题取自定义昵称或「AI 助手」
- Web 端和桌面端不注册 APP 推送设备。
- 没有按消息类型订阅推送的开关。
- 两个通道都关闭或对应凭证缺失时无法发送离线推送,站内消息仍正常。

View File

@ -8,50 +8,40 @@ locale: zh
aliases:
- 什么时候推送
- APP 推送场景
- 哪些情况推
- 推送规则
- 不推送的情况
related_tools: []
related_pages: []
prerequisites:
- 管理员已开启 APP 推送,详见 [[push-notice.config.howto]]
- 用户在移动端登录并注册了 UmengAlias
- 管理员已配置与 App 版本匹配的推送通道
- 用户已在移动端登录并注册设备
negative:
- 任务创建 / 分配 / 截止提醒不直接发友盟推送,通过聊天消息携带
- 审批通过 / 驳回不直接发友盟推送,通过聊天消息携带
- 自己发的消息不会推给自己
last_verified: v1.7.90
- 任务和审批事件通过会话消息进入 APP 推送链路
- 自己发的消息和静默消息不推给自己
last_verified: v1.9.66
---
# APP 推送触发场景
## 定义
DooTask 仅在用户**收到新会话消息**且满足若干前置条件时触发友盟推送。推送内容为「发件人 + 消息预览」,点击进入对应会话。
DooTask 收到新会话消息时,会通过 DooPush 或友盟向相应版本的移动设备发送通知。内容为发送者或群名称及消息预览,点击通知进入对应会话。
## 触发条件(全部满足才推)
1. 管理员后台「APP 推送」总开关 = open
2. 友盟 iOS / Android 至少一端配了 key/secret
3. 收件人 `umeng_alias` 表 30 天内有效别名
4. 消息发送者 ≠ 收件人本人
5. 该会话对收件人的 `silence` 标记 = 0(非免打扰)
6. 消息本身没带 silence=true 静默标志
7. 收件人未禁用(`disable_at` 为 NULL)
## 触发条件
1. 对应通道已开启并填好凭证:新版 App 使用 DooPush,旧版使用友盟。
2. 用户存在一个月内更新的对应通道设备记录。
3. 消息发送者不是收件人本人,收件人未被禁用。
4. 会话对收件人非免打扰,消息也没有静默标记。
## PC 在线时的延迟逻辑
- 收件人 PC 端 60 秒内有心跳:先**不推**,把任务放进 10 秒延迟队列
- 10 秒后再检查该消息是否已读:已读则跳过,未读才推
- 设计目的:用户在电脑前已经看到消息时不再叨扰手机
- PC 端 60 秒内有活动时,手机通知延迟 10 秒。
- 延迟后重新检查消息已读状态,已读则跳过,未读才发。
- 两个通道共用这套延迟和已读判断。
## 推送内容
- **单聊**:标题 = 发件人昵称;正文 = 消息预览
- **群聊**:标题 = 群名;正文 = `昵称: 消息预览`
- **AI 助手**:标题取 `msg.nickname` 自定义昵称或默认「AI 助手」
- 附加数据:`dialog_id`、`msg_id`、`badge`(未读总数)
- 单聊标题为发送者昵称,正文为消息预览。
- 群聊标题为群名,正文为昵称和消息预览;AI 助手标题取自定义昵称或默认名称。
- DooPush 载荷的 data 字段为 JSON 字符串,包含 dialog_id、msg_id,点击通知可直接打开对应会话;角标为未读且非静默的消息数。
- 任务创建、分配、即将超时和已超时提醒先生成会话消息,再进入相同推送链路。
## 不触发推送的情形
- 任务更新、审批结果、@提及通过同一条聊天消息送达,那条消息会推;但脱离聊天消息的「纯系统事件」(如签到提醒 TodoRemindTask)按内部参数决定(默认不推送本人)
- 标记为已读的旧消息
- 静默发送的系统消息
## 调试推送
若想确认是否推送,看 `umeng_logs` 表 request/response 字段。
## 不支持
脱离会话消息的纯系统事件不会自动发送 APP 推送;静默消息、自己发给自己的消息,以及延迟期间已读的消息不推送。

View File

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

View File

@ -18,7 +18,7 @@ negative:
- DooTask 当前版本不支持 iCal / webcal / Google Calendar 同步
- 不能把 DooTask 日历嵌入 Outlook / Apple 日历
- 没有 .ics 文件导出按钮
last_verified: v1.7.90
last_verified: v1.9.66
---
# 不支持 iCal 导出 / 外部日历订阅
@ -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 推送**:UMENG 推送同样替代日历提醒
4. **移动端 APP 推送**:DooPush / UMENG 推送同样可用于任务提醒
## 未来支持?
这块在 P1 / P2 路线图(issues 可能跟踪),但 v1.7.90 主线版本无计划。

View File

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

View File

@ -19,7 +19,7 @@ negative:
- DooTask 不会主动短信通知,不必去等短信
- 系统不会自动告知用户「你被免打扰了」;得自己检查设置
- 「已读」消息不会再次推送,被另一端读完不算故障
last_verified: v1.7.90
last_verified: v1.9.66
---
# 收不到任何通知
@ -32,7 +32,7 @@ DooTask 有四条独立的通知通道,依次自检:
1. **应用内红点**:左侧聊天列表、任务列表是否有红点。没有 → 后端根本没产生通知,从源头排查(消息真的发给你了?被设了可见用户排除?)
2. **桌面 / Web 通知**:桌面端 / Web 是否拿到系统通知权限,详见 [[desktop-notify.permission.faq]]
3. **移动端 APP 推送**:手机是否给 DooTask 通知权限 + 后台运行;管理员是否配了友盟 [[mobile-notify.permission.faq]] / [[push-notice.troubleshoot.faq]]
3. **移动端 APP 推送**:手机是否给 DooTask 通知权限 + 后台运行;管理员是否配了对应的 DooPush / 友盟通道 [[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、友盟配置
5. 全公司都没通知 → 让管理员检查 swoole 进程、Redis、SMTP、DooPush / 友盟配置
## 不支持
- 没有「通知历史」可回放所有过去通知的全局界面(仅站内红点 + 邮件)

View File

@ -19,7 +19,7 @@ negative:
- DooTask 不是离线优先架构,不支持本地写入 → 上线后合并
- 离线期间不能发消息、不能改任务,所有操作必须联网完成
- WebSocket 断线期间的实时推送不会被服务器缓存补发
last_verified: v1.7.90
last_verified: v1.9.66
---
# 离线后再上线,期间的数据怎么办
@ -49,7 +49,7 @@ DooTask 是「在线优先」架构:
| 任务变更 | 是,进入任务 / 项目即拉最新 |
| 实时通知(弹窗) | 否,已错过不会重发 |
| 未读数 | 是,重新计算 |
| 推送通知(手机) | 是,由 UMENG/APNs 离线推送 |
| 推送通知(手机) | 是,由 DooPush / UMENG 经 APNs 或厂商通道离线推送 |
## 解决
1. 重新上线后**手动进各页面**走一遍 → 自动拉新

View File

@ -6,73 +6,36 @@ feature: mobile-client
scope: end-user
locale: zh
aliases:
- 收不到推送
- 收不到消息
- 收不到手机消息
- 手机端没声音
- 锁屏没通知
- 后台收不到消息
- 推送失败
- 通知不响
- 收不到通知
related_tools: []
related_pages: []
prerequisites: []
negative:
- 自部署的 DooTask 默认未必配置 UMENG,这种情况下移动端只能靠 WebSocket 在线时收消息
- 厂商通道(华为 / 小米 / OPPO)的到达率受系统电池策略影响很大,无法 100% 保证
- 推送收不到不影响数据,所有消息打开 App 后都会同步出现
last_verified: v1.7.90
- 自部署服务需要管理员配置与 App 版本匹配的推送通道
- 推送送达率受手机系统和网络影响,无法完全保证
- 离线推送失败不影响登录后同步站内消息
last_verified: v1.9.66
---
# 移动端推送收不到怎么办
## 问题
DooTask 移动端 App 关闭 / 锁屏 / 后台时,新消息没有弹通知或没声音。
DooTask App 在后台或锁屏时收不到新消息。新版 App 使用 DooPush,旧版使用友盟,需检查对应链路:[[mobile-client.notify.concept]]。
## 原因
移动端推送依赖一条链路:**DooTask 服务端 → UMENG(友盟)→ APNs(iOS)或厂商通道(Android)→ 你的设备**。任一环节失败都收不到。详细架构见 [[mobile-client.notify.concept]]。
## 排查步骤
1. 联系管理员检查「系统设置 → APP 推送」。新版需开启 DooPush 并填好 App ID、App Secret、服务地址;旧版需开启友盟并填好 iOS / Android 凭证。
2. 手机系统设置 → DooTask → 通知,打开允许通知、锁屏和横幅等选项。
3. 检查聊天是否免打扰;PC 最近活跃时手机会延迟 10 秒通知,期间已读则跳过。
4. Android 检查允许自启动、后台运行和电池不受限,网络需能访问对应推送服务。
5. 重新登录 App 刷新设备绑定。新版上报到 `api/push/device`,旧版上报到 `api/users/umeng/alias`。退出登录后设备解绑,不再接收该账号推送。
6. 让其他人发送一条新消息验证锁屏通知,不要用自己发送的消息测试。
## 排查清单(从易到难)
## 仍未解决
管理员检查 DooPush 的 `[DooPush]` 应用日志及 DooPush 控制台投递结果,或友盟 `umeng_logs` 返回信息,详见 [[push-notice.troubleshoot.faq]]。
### 1. 服务端是否配置了 UMENG
最常见原因。自部署版本默认未填 UMENG 的 AppKey / Master Secret,导致**整个推送通道不可用**。表现:任何用户在后台都收不到推送。
- 联系部署管理员,确认服务端是否配置 UMENG 推送
- 未配置则推送整体不可用,只有 App 前台在线时通过 WebSocket 收消息
### 2. 系统通知权限
- iOS:设置 → 「通知」→「DooTask」→ 打开「允许通知」+「锁定屏幕」+「通知中心」+「横幅」
- Android:设置 → 「应用」→「DooTask」→「通知」→ 打开
### 3. App 内免打扰
- DooTask 「我的」→「设置」→「通知」→ 检查总开关是否关
- 单个会话的免打扰开关也要检查(头像右上角铃铛图标)
### 4. Android 后台被系统杀
Android 厂商对后台 App 限制非常严格:
- **华为**:「电池」→ DooTask 设为「不限制」+「应用启动管理」全开
- **小米**:「省电与电池」→ DooTask 设「无限制」+「自启动管理」开
- **OPPO / vivo / Realme**:类似设置,核心是「省电策略不限制」+「允许后台」+「自启动」
- 所有 Android:把 DooTask 锁到「最近任务」防止滑掉(各品牌实现不同)
### 5. iOS 后台问题
- iOS 设置 → 「通用」→「后台 App 刷新」→「DooTask」打开
- 「设置 → 通知 → DooTask → 即时推送」打开(部分版本叫法)
### 6. 网络问题
- 锁屏时手机省电关闭数据连接,推送通道不可达
- 设置 → WLAN → 高级 → 「在休眠状态下保持 WLAN 连接」
### 7. 设备别名注册失败
登录时 App 调 `/api/users/umeng/alias` 注册设备到友盟,若网络异常此次注册可能失败。解决:
- 重新登录 App(退出再登)
- 重启 App
## 检验是否解决
- 让同事发条消息,观察是否在锁屏弹出
- 看应用内通知历史是否到达
## 仍收不到
联系运维 / 部署管理员:
1. 服务端 UMENG 配置是否生效
2. 服务端日志中是否有该用户的推送投递记录
3. 设备友盟 token 是否正确绑定
## 不支持
推送送达率受手机系统和网络影响,无法完全保证。离线推送失败不影响登录后同步站内消息。

View File

@ -20,7 +20,7 @@ negative:
- 通知权限拒绝过一次后 DooTask 不会主动再次弹窗请求;只能去系统设置改
- 即便授予通知权限,国产 ROM 的电池优化 / 自启动限制仍可能屏蔽后台通知
- 通知权限和「APP 后台运行」是两件事,二者都允许才稳定收推送
last_verified: v1.7.90
last_verified: v1.9.66
---
# 移动端没有通知权限
@ -32,7 +32,7 @@ DooTask 移动端 APP 收不到通知 / 没振动 / 锁屏后没推送。
- 系统通知权限未授予
- 国产 ROM 后台杀进程严格,APP 被关闭
- DooTask 时段免打扰打开
- 网络异常导致推送未达(友盟服务器无法触达设备)
- 网络异常导致推送未达(DooPush / 友盟通道无法触达设备)
## 解决(iOS)
1. 「设置 → DooTask」
@ -68,6 +68,6 @@ DooTask 移动端 APP 收不到通知 / 没振动 / 锁屏后没推送。
## 仍然不收?
1. 检查 DooTask 内时段免打扰是否打开([[mobile-notify.silent.howto]])
2. 检查会话是否被免打扰([[push-notice.silent.howto]])
3. 检查 APP 内是否已登录(退出登录后 UmengAlias 失效)
4. 联系管理员查 `umeng_logs` 看友盟侧是否收到了推送请求
3. 检查 APP 内是否已登录(退出登录后推送设备解绑)
4. 联系管理员查 `[DooPush]` 应用日志及 DooPush 投递结果,或 `umeng_logs` 的友盟返回信息
5. 详见 [[push-notice.troubleshoot.faq]]

View File

@ -7,56 +7,36 @@ scope: end-user
locale: zh
aliases:
- 收不到推送
- APP 没声音
- 通知不响
- 手机没消息
- 推送失败
- DooPush 推送失败
- 友盟没声音
- 后台推送不到
related_tools: []
related_pages: []
prerequisites: []
negative:
- DooTask 内部没有「重发推送」按钮
- 推送日志只有管理员通过查库 umeng_logs 才能看
- 静默消息和自己发的消息本来就不推,不算故障
last_verified: v1.7.90
- DooTask 没有重发推送按钮
- 退出登录后设备解绑,不再接收该账号推送
- 已读、静默和自己发给自己的消息不推送
last_verified: v1.9.66
---
# APP 收不到推送怎么办
## 问题
DooTask 移动端 APP 后台 / 退出登录状态下没收到新消息通知。
DooTask 移动 App 在后台或锁屏时没有收到新消息通知。新版使用 DooPush,旧版使用友盟,需要先确认当前 App 对应的通道。
## 排查顺序
1. **管理员有没有配友盟?**
- 进「管理后台 → 系统设置 → APP 推送」检查「开启推送」= 开启
- 检查 iOS / Android 的 Appkey 和 Master Secret 非空
2. **当前会话是不是免打扰?**
- 打开对应聊天 → 右上角设置 → 检查「免打扰」是否打开
- 免打扰时该会话不推
3. **APP 是不是开了通知权限?**
- iOS:「设置 → DooTask → 通知 → 允许通知」
- Android:「设置 → 应用 → DooTask → 通知 → 允许全部」
- 详见 [[mobile-notify.permission.faq]]
4. **是不是 PC 在线被延迟了?**
- PC 端 60 秒内活跃过,APP 推送会被延迟 10 秒
- 这期间消息被读则**跳过**推送;这是设计行为
5. **是不是别名失效了?**
- 卸载 APP / 30 天没启动会让 UmengAlias 过期
- 重新打开 APP 登录会触发别名注册(自动刷新 updated_at)
6. **是不是国产 ROM 的厂商通道问题?**
- 华为/小米/OPPO/VIVO 后台杀进程严格,需在系统中允许「自启动 + 后台运行」
- 友盟厂商通道(mipush 等)需在友盟控制台配证书,并使用正式签名的 APP
1. 管理员进入「系统设置 → APP 推送」:新版检查「DooPush 推送」已开启,App ID、App Secret 和服务地址正确;旧版检查友盟开关及 iOS / Android 凭证。两通道可同时开启。
2. 在手机系统设置中允许 DooTask 通知,检查会话是否免打扰。Android 可检查自启动、后台运行和电池限制。
3. PC 端 60 秒内有活动会延迟手机推送 10 秒;期间读过消息则跳过,这是正常行为。
4. 设备超过一个月未更新则不参与推送,重新登录 App 刷新绑定。退出登录会解绑设备,需要先登录才能接收账号消息。
5. 新版升级上报会清理同设备旧友盟别名;新旧账号切换后,设备归属应为当前账号。
## 系统级排查(管理员)
- 用「邮件发送测试」类比的思路验证一条消息能不能推(无界面,需查 `umeng_logs` 表)
- `umeng_logs.response` 字段记录友盟接口返回,按错误码定位
- 临时关闭 PC 端登录测试 APP 端是否能直接收到(排除延迟优化)
## 管理员排查
- DooPush:检查服务器应用日志的 `[DooPush]` 记录,核对 App Secret 是否属于当前 App ID 且有 push:send 权限;每次发送失败最多重试 2 次。
- DooPush 接口受理成功后,实际投递在 DooPush 后台异步执行,应在 DooPush 控制台继续检查厂商投递结果。
- 友盟:检查 `umeng_logs` 的 request / response 及厂商凭证。
## 不算故障的情形
- 静默消息(silence=1):刻意不推
- 自己发的消息:不推自己
- 已读消息:延迟推送时再次检查已读则跳过
## 仍然不行
联系系统管理员把对应时段的 `umeng_logs` 反给运维分析。
## 不支持
DooTask 没有重发推送按钮;已读、静默和自己发给自己的消息不推送。退出登录后设备解绑,不再接收该账号推送。

View File

@ -9,39 +9,35 @@ aliases:
- APP 推送怎么打开
- 手机推送配置
- UMENG 设置
- 移动端推送
- DooPush 设置
- 友盟推送
related_tools: []
related_pages: [application]
prerequisites:
- 需要系统管理员权限
- 需要已申请的友盟(UMENG)账号与 App Key/Secret
- 已取得对应推送服务的应用凭证
negative:
- 推送仅作用于 DooTask 官方移动 App(iOS / Android),不支持 Web 端
- 推送依赖友盟服务可达,私有化网络隔离环境下需自行评估可用性
- 不支持改用其他推送服务商(如极光 / 个推)
last_verified: v1.7.90
- APP 推送不作用于 Web 端和桌面端
- 配置页没有测试推送和事件订阅按钮
last_verified: v1.9.66
---
# APP 推送入口
## 是什么
APP 推送应用让管理员配置移动端的推送服务(基于友盟 UMENG),用于消息、任务、审批等场景在 App 关闭时弹通知。属于管理员应用。
APP 推送应用让管理员配置 DooTask 移动端通知:新版 App 使用 DooPush,旧版 App 使用友盟 UMENG,两者可同时开启。任务和审批等提醒通过会话消息进入推送链路。
## 入口
- 桌面端:左侧栏「应用」→ 下方「管理员」分区 →「APP 推送」卡片
- 移动端竖屏:底部 Tabbar「应用」→「管理员」分区 →「APP 推送」
- 点击后右侧弹出 700 宽的「APP 推送」抽屉
- 桌面端:左侧「应用」→「管理员」分区 →「APP 推送」卡片,打开配置抽屉。
- 也可从左上角头像 →「系统设置」→ 左侧「APP 推送」进入相同表单。
## 抽屉内能做什么
- 填入 iOS 与 Android 各自的 UMENG App Key / App Master Secret
- 启用 / 关闭整体推送开关
- 选择哪些事件触发推送(新消息、任务分配、审批待办等)
- 测试推送(向当前管理员的 App 发送一条)
## 详细配置
UMENG 字段含义、各事件推送格式由 push-notice feature 的 chunk 覆盖(后续起草)。
## 配置项
- 「友盟推送」区:独立开关、iOS / Android 各自的 Appkey 和 App Master Secret。
- 「DooPush 推送」区:独立开关、App ID、密码形式输入的 App Secret、服务地址。
- 服务地址留空使用 https://doopush.com/api/v1。
- 点击底部「提交」保存;详细字段及设备绑定见 [[push-notice.config.howto]]。
## 不支持
- 关闭推送后用户端会立即停止收到通知,无延迟回调
- 不支持自定义推送声音 / 图标,全部走 App 默认
- APP 推送不作用于 Web 端和桌面端。
- 配置页没有测试推送和事件订阅按钮。
- 推送送达依赖推送服务可达和手机通知权限,不能保证所有设备即时送达。

View File

@ -1,6 +1,6 @@
---
id: push-notice.config.howto
title: 配置友盟 APP 推送
title: 配置 APP 推送(DooPush / 友盟)
type: howto
feature: push-notice
scope: admin
@ -9,52 +9,44 @@ aliases:
- 配置友盟
- UMENG 配置
- APP 推送配置
- 配置 appkey
- 怎么开推送
- 友盟密钥
- DooPush 怎么配置
- DooPush 服务地址怎么填
related_tools: []
related_pages: []
prerequisites:
- 需要系统管理员权限
- 已在 https://www.umeng.com 注册友盟账号并创建对应的 iOS / Android 应用
- 已拿到 App Key 和 App Master Secret
- 已获得对应推送服务的应用凭证
negative:
- 当环境变量 SYSTEM_SETTING=disabled 时禁止从界面修改推送设置(密钥会脱敏显示)
- 必须同时填 iOS 和 Android 的 key/secret 才能两端都收到;只填一端则只推一端
- 默认 production_mode=true,意味必须用正式签名的 APP 包,开发版 APP 收不到
last_verified: v1.7.90
- 当前环境禁止修改系统设置时无法保存推送配置
- App Secret 是服务端发送凭证,不是客户端 App Key
- 页面没有测试推送或事件订阅按钮
last_verified: v1.9.66
---
# 配置友盟 APP 推送
# 配置 APP 推送(DooPush / 友盟)
## 入口
桌面端:左侧栏「管理后台」→「系统设置」→「APP 推送」选项卡
桌面端:左上角头像 →「系统设置」→ 左侧「APP 推送」。也可通过管理员应用「APP 推送」打开配置。
## 操作步骤
1. 在「开启推送」选择「开启」
2. 「iOS 参数配置」区填写:
- Appkey
- App Master Secret
3. 「Android 参数配置」区填写:
- Appkey
- App Master Secret
4. 底部「提交」保存
5. 保存后即时生效,无需重启服务
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 登录后自动注册对应通道的设备,无需管理员手工绑定用户。旧版升级到新版上报时会清理同设备的旧友盟别名。
## 字段说明
| 字段 | 默认 | 说明 |
|---|---|---|
| push | close | 总开关;close 时所有 APP 推送任务不入队 |
| ios_key | 空 | 友盟 iOS App Key |
| ios_secret | 空 | 友盟 iOS App Master Secret,前端 password 显示 |
| android_key | 空 | 友盟 Android App Key |
| android_secret | 空 | 友盟 Android App Master Secret |
## 推送配置生效后做什么
- 接 alias 注册:APP 启动登录后自动调 `api/users/umeng/alias` 注册别名(详见 [[push-notice.alias.concept]])
- 推送触发:新消息到达 `WebSocketDialogMsgTask` 会调用 `PushUmengMsg` 入队推送,参考 [[push-notice.scenarios.concept]]
- 推送结果落 `umeng_logs` 表:可用于排查(详见 [[push-notice.troubleshoot.faq]])
| 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 |
## 不支持
- 国内厂商通道(OPPO/华为/小米/VIVO)不开箱即用:需走友盟厂商通道下发,并在友盟控制台单独配置厂商证书
- 不能用替代推送服务,DooTask 推送层与友盟 SDK 强绑定
- 关闭某个通道只停止该通道发送,另一个已开启通道仍可工作。
- App Secret 是服务端凭证,不应填成客户端 App Key,也不要放进客户端或版本库。
- 页面没有测试推送或事件订阅按钮;排查见 [[push-notice.troubleshoot.faq]]。

View File

@ -21,7 +21,7 @@ negative:
- 免打扰只对该单个会话生效,不影响其他会话
- 免打扰只屏蔽 APP 推送和未读邮件,不屏蔽 WebSocket 站内消息(消息列表仍能看到)
- 没有「按时段免打扰单个会话」,只有总开关
last_verified: v1.7.90
last_verified: v1.9.66
---
# 单个会话免打扰
@ -36,7 +36,7 @@ last_verified: v1.7.90
## 工作原理
- 后端记录 `web_socket_dialog_users.silence` = 1
- 该用户对该会话的所有新消息:
- 不触发友盟 APP 推送(`silence=1` 会被 [[push-notice.scenarios.concept]] 过滤)
- 不触发 DooPush / 友盟 APP 推送(`silence=1` 会被 [[push-notice.scenarios.concept]] 过滤)
- 不汇总进未读邮件(EmailNoticeTask 跳过 silence=1 的记录)
- 角标计数也不算它(badge 未读统计排除 silence)
- 但消息**仍会**到达 WebSocket,仍出现在消息列表和会话内

View File

@ -20,7 +20,7 @@ negative:
- DooTask 没有单一「通知偏好」设置页;各通道的开关分散在不同位置
- 邮件通知的全局开关由系统管理员控制,普通用户只能关单条
- 不支持「按时间段免打扰」全局规则(仅会话级置顶/免打扰)
last_verified: v1.7.90
last_verified: v1.9.66
---
# 个人通知开关
@ -36,7 +36,7 @@ DooTask 的通知分多个通道,没有集中的「通知偏好」设置页,
## 移动端推送(iOS / Android)
- 入口:手机系统设置 → DooTask → 通知;APP 内只跟随系统权限
- 注册推送通过 UMENG 渠道,APP 启动时自动注册 device-token
- APP 登录后自动绑定推送设备,新版使用 DooPush,旧版使用 UMENG
- 关掉手机系统通知后,APP 内只剩前台徽标,没有推送
## 会话级免打扰 / 静音

View File

@ -19,7 +19,7 @@ prerequisites:
negative:
- 不在「系统设置」页面里,而是在「应用」中心
- 普通成员路径不存在该入口
last_verified: v1.7.90
last_verified: v1.9.66
---
# 管理员应用入口在哪
@ -38,7 +38,7 @@ last_verified: v1.7.90
点击卡片直接弹出右侧抽屉,不跳页面:
- 「LDAP」→ LDAP 设置抽屉(700 宽)
- 「邮件通知」→ 邮件设置抽屉
- 「APP 推送」→ UMENG 推送抽屉
- 「APP 推送」→ DooPush / 友盟推送配置抽屉
- 「举报管理」→ 举报记录抽屉
- 「数据导出」→ 下拉菜单选导出类型(任务/超期/审批/签到)
- 「团队管理」→ 跳转到团队管理页

View File

@ -19,7 +19,7 @@ prerequisites:
negative:
- 普通用户没有「系统设置」入口,左侧栏看不到
- 部分子项(如 LDAP / License)需要进一步的权限或环境支持
last_verified: v1.7.90
last_verified: v1.9.66
---
# 系统设置入口
@ -39,7 +39,7 @@ last_verified: v1.7.90
- **AI 设置** — 配置 AI 模型供应商:[[system-setting.ai-model.howto]]
- **AI 机器人** — 关联机器人与默认模型:[[system-setting.ai-bot.howto]]
- **签到** — 打卡规则、地理围栏、人脸:[[system-setting.checkin.howto]]
- **APP 推送** — UMENG iOS/Android 推送配置
- **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 6328b8e934f3566a4195ca0452f257dce2f54a4f
Subproject commit f7ad864759a55abbc80a5cdf3d6fd5b9c684a362

View File

@ -802,6 +802,13 @@ export default {
window.__handleLink = (path) => {
this.goForward({ path: (path || '').indexOf('/') !==0 ? "/" + path : path });
}
// 点击推送通知,打开对应会话
window.__onNotificationClick = (data) => {
const dialogId = $A.runNum($A.isJson(data) ? data.dialog_id : 0);
if (dialogId > 0 && this.userId > 0) {
this.$store.dispatch('openDialog', dialogId).catch(() => {});
}
}
// 发送网页尺寸
$A.eeuiAppSendMessage({
action: 'windowSize',

View File

@ -33,6 +33,29 @@
</template>
</div>
</div>
<div class="block-setting-box">
<h3>{{ $L('DooPush 推送') }}</h3>
<div class="form-box">
<FormItem :label="$L('开启推送')" prop="doopush">
<RadioGroup v-model="formData.doopush">
<Radio label="open">{{ $L('开启') }}</Radio>
<Radio label="close">{{ $L('关闭') }}</Radio>
</RadioGroup>
<div class="form-tip">{{ $L('新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。') }}</div>
</FormItem>
<template v-if="formData.doopush === 'open'">
<FormItem :label="$L('App ID')" prop="doopush_app_id">
<Input :maxlength="255" v-model="formData.doopush_app_id"/>
</FormItem>
<FormItem :label="$L('App Secret')" prop="doopush_app_secret">
<Input :maxlength="255" v-model="formData.doopush_app_secret" type="password"/>
</FormItem>
<FormItem :label="$L('服务地址')" prop="doopush_base_url">
<Input :maxlength="255" v-model="formData.doopush_base_url" placeholder="https://doopush.com/api/v1"/>
</FormItem>
</template>
</div>
</div>
</Form>
<div class="setting-footer">
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{ $L('提交') }}</Button>
@ -55,6 +78,10 @@ export default {
ios_secret: '',
android_key: '',
android_secret: '',
doopush: 'close',
doopush_app_id: '',
doopush_app_secret: '',
doopush_base_url: '',
},
ruleData: {},
}

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:334
接口总数:335
## 路由规则
@ -86,6 +86,12 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
| api/users/favorites/clean | favorites__clean() | post | 清理用户收藏 |
| api/users/favorite/check | favorite__check() | get | 检查收藏状态 |
## push(PushController)
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/push/device | device() | post | 更新或移除 DooPush 设备 |
## contacts(ContactsController)
| URL | 方法名 | HTTP | 说明 |

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Api\TestController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\FileDavController;
use App\Http\Controllers\Api\UsersController;
use App\Http\Controllers\Api\PushController;
use App\Http\Controllers\Api\ContactsController;
use App\Http\Controllers\Api\DialogController;
use App\Http\Controllers\Api\PublicController;
@ -41,6 +42,8 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
// 会员
Route::any('users/{method}', UsersController::class);
Route::any('users/{method}/{action}', UsersController::class);
// APP 推送设备
Route::any('push/{method}', PushController::class);
// 通讯录
Route::any('contacts/{method}', ContactsController::class);
Route::any('contacts/{method}/{action}', ContactsController::class);

View File

@ -0,0 +1,328 @@
<?php
namespace Tests\Feature;
use App\Models\AppPushDevice;
use App\Models\UmengAlias;
use App\Models\User;
use App\Models\UserDevice;
use App\Models\WebSocketDialogMsgRead;
use App\Module\AppPush\AppPush;
use App\Module\AppPush\DooPushClient;
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
use App\Tasks\PushUmengMsg;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AppPushTest extends TestCase
{
use DatabaseTransactions;
private $originalDispatcher;
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
$this->originalDispatcher = Model::getEventDispatcher();
// 测试数据不触发无关的搜索同步和 Swoole 任务。
Model::setEventDispatcher(new Dispatcher(app()));
RequestContext::clean();
$this->settings();
}
protected function tearDown(): void
{
RequestContext::clean();
Model::setEventDispatcher($this->originalDispatcher);
parent::tearDown();
}
private function settings(array $overrides = []): void
{
Base::setting('appPushSetting', array_merge([
'push' => 'close',
'doopush' => 'open',
'doopush_app_id' => '123',
'doopush_app_secret' => 'test-secret',
'doopush_base_url' => '',
], $overrides));
}
private function userToken(bool $admin = false): array
{
$user = User::createInstance([
'email' => uniqid('app_push_', true) . '@test.local',
'nickname' => 'Push test',
'encrypt' => bin2hex(random_bytes(8)),
'identity' => $admin ? 'admin' : 'normal',
]);
$user->save();
$token = Doo::tokenEncode($user->userid, $user->email, $user->encrypt, 1);
UserDevice::record($token);
return [$user, $token];
}
private function device(int $userid, string $id, array $overrides = []): AppPushDevice
{
$device = AppPushDevice::createInstance(array_merge([
'userid' => $userid, 'channel' => 'doopush', 'device_id' => $id,
'vendor' => 'apns', 'platform' => 'ios', 'device_hash' => md5('test-token'),
], $overrides));
$device->save();
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
{
[$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_model' => 'iPhone', 'app_version' => '245', 'app_version_name' => '1.9.66',
'user_agent' => 'Push test UA', 'is_notified' => 'true',
];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'iOS'])
->assertOk()->assertJsonPath('ret', 1);
$row = AppPushDevice::whereDeviceId('94001')->sole();
$this->assertSame($first->userid, $row->userid);
$this->assertSame(md5($token), $row->device_hash);
$this->assertSame('1.9.66 (245)', $row->version);
$this->assertSame('ios', $row->platform);
$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([
'device_id' => '94001', 'vendor' => 'fcm', 'is_notified' => 1,
]), ['token' => $secondToken, 'platform' => 'ANDROID'])
->assertOk()->assertJsonPath('ret', 1);
$updated = AppPushDevice::whereDeviceId('94001')->sole();
$this->assertSame($row->id, $updated->id);
$this->assertSame($second->userid, $updated->userid);
$this->assertSame(md5($secondToken), $updated->device_hash);
$this->assertSame($row->created_at->toDateTimeString(), $updated->created_at->toDateTimeString());
$this->assertSame('android', $updated->platform);
$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();
[, $wrongToken] = $this->userToken();
// 使用真实格式但已撤销的 token,避免格式错误字符串触发原生解码器异常。
UserDevice::whereHash(md5($token))->delete();
Cache::forget(UserDevice::ck(md5($token)));
$row = $this->device(1, '94003', ['device_hash' => md5($token)]);
$otherChannel = $this->device(1, '94003', ['channel' => 'other', 'device_hash' => md5($token)]);
$otherId = $this->device(1, '94004', ['device_hash' => md5($token)]);
$data = ['action' => 'remove', 'device_id' => '94003'];
$this->postJson('/api/push/device', $data)->assertJsonPath('ret', 1);
$this->postJson('/api/push/device', $data, ['token' => $wrongToken])->assertJsonPath('ret', 1);
$this->assertNotNull($row->fresh());
$this->getJson('/api/push/device?' . http_build_query($data), ['token' => $token])->assertJsonPath('ret', 1);
$this->assertNull($row->fresh());
$this->assertNotNull($otherChannel->fresh());
$this->assertNotNull($otherId->fresh());
}
public function test_update_validates_token_platform_vendor_and_digit_string(): void
{
foreach (['', '-1', '12a', str_repeat('1', 65), ['12'], 123] as $id) {
$this->postJson('/api/push/device', ['device_id' => $id])->assertJsonPath('ret', 0);
}
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'apns'])->assertJsonPath('ret', -1);
[, $token] = $this->userToken();
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'apns'], [
'token' => $token, 'platform' => 'web',
])->assertJsonPath('ret', 0);
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'unknown'], [
'token' => $token, 'platform' => 'ios',
])->assertJsonPath('ret', 0);
$this->assertFalse(AppPushDevice::whereDeviceId('94005')->exists());
}
public function test_logout_cleans_both_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
{
Base::setting('appPushSetting', ['push' => 'close']);
[, $token] = $this->userToken(true);
$this->getJson('/api/system/setting/apppush', ['token' => $token])
->assertJsonPath('ret', 1)->assertJsonPath('data.doopush', 'close');
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',
'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')
->assertJsonPath('data.doopush_app_secret', 'new-secret')
->assertJsonPath('data.doopush_base_url', 'https://push.test/api/v1/')->assertJsonMissingPath('data.unexpected');
}
public function test_send_groups_latest_five_active_devices_per_user_with_unread_badge_and_data_payload(): void
{
$uid = 910001;
$otherUid = 910002;
for ($i = 1; $i <= 7; $i++) {
$this->device($uid, (string)(95000 + $i), ['updated_at' => now()->subMinutes(8 - $i)]);
}
$this->device($uid, '95999', ['updated_at' => now()->subMonths(2)]);
$this->device($uid, '95998', ['channel' => 'other']);
$this->device($otherUid, '96001');
foreach ([['silence' => 0], ['silence' => 0], ['silence' => 1], ['silence' => 0, 'read_at' => now()]] as $i => $fields) {
WebSocketDialogMsgRead::createInstance(array_merge(['userid' => $uid, 'msg_id' => 97000 + $i], $fields))->save();
}
$client = new RecordingDooPushClient();
$client->pushToUsers([$uid, $uid, $otherUid, -1], [
'title' => "标题\n", 'body' => "正文\r\n第二行", 'extra' => ['dialog_id' => 41, 'msg_id' => 42],
]);
$this->assertCount(2, $client->requests);
$request = $client->requests[0];
$this->assertSame('https://doopush.com/api/v1/apps/123/push', $request['url']);
$this->assertSame('Bearer test-secret', $request['headers']['Authorization']);
$this->assertSame('application/json', $request['headers']['Content-Type']);
$body = $request['body'];
$this->assertSame(['type' => 'devices', 'device_ids' => [95007, 95006, 95005, 95004, 95003]], $body['target']);
$this->assertSame('标题', $body['title']);
$this->assertSame('正文第二行', $body['content']);
$this->assertSame(2, $body['badge']);
$this->assertSame(['dialog_id' => '41', 'msg_id' => '42'], json_decode($body['payload']['data'], true));
$this->assertArrayNotHasKey('dialog_id', $body['payload']);
$this->assertSame(['importance' => 'NORMAL', 'category' => 'IM'], $body['payload']['huawei']);
$this->assertSame(['classification' => 1], $body['payload']['vivo']);
$this->assertSame([96001], $client->requests[1]['body']['target']['device_ids']);
$this->assertSame(0, $client->requests[1]['body']['badge']);
}
public function test_title_fallback_empty_targets_and_configuration_guards(): void
{
$this->device(910003, '96002');
$client = new RecordingDooPushClient();
foreach ([['doopush' => 'close'], ['doopush_app_id' => ''], ['doopush_app_secret' => '']] as $settings) {
$this->settings($settings);
$client->pushToUsers(910003, ['body' => '内容']);
}
$this->settings(['doopush_base_url' => 'https://push.test/api/v1///']);
$client->pushToUsers([], ['body' => '内容']);
$client->pushToUsers(910003, ['title' => "\n", 'body' => "\r\n"]);
$this->assertCount(0, $client->requests);
$client->pushToUsers(910003, ['body' => "内容\n"]);
$this->assertCount(1, $client->requests);
$this->assertSame('内容', $client->requests[0]['body']['title']);
$this->assertSame('https://push.test/api/v1/apps/123/push', $client->requests[0]['url']);
}
public function test_device_ids_cannot_overflow_into_a_different_target(): void
{
$this->device(910007, str_repeat('9', 64));
$this->device(910007, '00096006');
$client = new RecordingDooPushClient();
$client->pushToUsers(910007, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame([96006], $client->requests[0]['body']['target']['device_ids']);
}
public function test_failures_retry_at_most_twice_and_do_not_block_the_next_user(): void
{
$this->device(910004, '96003');
$this->device(910005, '96004');
$client = new RecordingDooPushClient([
new \RuntimeException('Connection timeout'),
['ret' => 1, 'msg' => '200', 'data' => 'not-json'],
['ret' => 1, 'msg' => '400', 'data' => '{"code":400,"message":"Invalid device"}'],
]);
$client->pushToUsers([910004, 910005], ['body' => '消息']);
$this->assertCount(4, $client->requests);
$this->assertSame([96003], $client->requests[2]['body']['target']['device_ids']);
$this->assertSame([96004], $client->requests[3]['body']['target']['device_ids']);
$client = new RecordingDooPushClient([['ret' => 0, 'msg' => 'Timeout', 'data' => []]]);
$client->pushToUsers(910004, ['body' => '消息']);
$this->assertCount(2, $client->requests);
}
public function test_doopush_only_task_keeps_pc_delay_and_read_skip_behavior(): void
{
$userid = 910006;
$this->device($userid, '96005');
$client = new RecordingDooPushClient();
$this->app->instance(DooPushClient::class, $client);
$this->assertTrue(AppPush::enabled());
$msg = ['body' => '消息', 'extra' => ['msg_id' => 98001]];
Cache::put("user_pc_active:{$userid}", time(), 60);
(new PushUmengMsg($userid, $msg))->start();
$this->assertCount(0, $client->requests);
Cache::forget("user_pc_active:{$userid}");
(new PushUmengMsg($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->delay(10);
$delayed->start();
$this->assertCount(1, $client->requests);
$this->settings(['doopush' => 'close']);
$this->assertFalse(AppPush::enabled());
}
}
/** 用可控响应替换外部网络,验证实际数据库选出的发送目标与载荷。 */
class RecordingDooPushClient extends DooPushClient
{
public array $requests = [];
public function __construct(private array $responses = []) {}
protected function request(string $url, array $headers, array $body): array
{
$this->requests[] = compact('url', 'headers', 'body');
$response = array_shift($this->responses);
if ($response instanceof \Throwable) {
throw $response;
}
return $response ?? ['ret' => 1, 'msg' => '200', 'data' => '{"code":200,"data":[]}'];
}
}