mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-29 22:10:42 +00:00
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:
parent
0f2f165940
commit
a49ee608a5
82
app/Http/Controllers/Api/PushController.php
Normal file
82
app/Http/Controllers/Api/PushController.php
Normal 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('保存成功');
|
||||
}
|
||||
}
|
||||
@ -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('{}'));
|
||||
}
|
||||
|
||||
48
app/Models/AppPushDevice.php
Normal file
48
app/Models/AppPushDevice.php
Normal 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';
|
||||
}
|
||||
@ -307,6 +307,7 @@ class UserDevice extends AbstractModel
|
||||
if (isset($hash)) {
|
||||
Cache::forget(self::ck($hash));
|
||||
UmengAlias::whereDeviceHash($hash)->delete();
|
||||
AppPushDevice::whereDeviceHash($hash)->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Module/AppPush/AppPush.php
Normal file
32
app/Module/AppPush/AppPush.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/Module/AppPush/DeviceRegistry.php
Normal file
51
app/Module/AppPush/DeviceRegistry.php
Normal 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();
|
||||
}
|
||||
}
|
||||
107
app/Module/AppPush/DooPushClient.php
Normal file
107
app/Module/AppPush/DooPushClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// 创建延迟推送任务
|
||||
|
||||
@ -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 助手');
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -1092,3 +1092,5 @@ WebDAV 未启用或你没有使用权限
|
||||
已开始更新系统
|
||||
关联任务
|
||||
解除关联任务
|
||||
推送设备ID必须为1至64位数字
|
||||
推送厂商错误
|
||||
|
||||
@ -2844,3 +2844,8 @@ APP 新版本
|
||||
更新内容
|
||||
按字母显示
|
||||
按部门显示
|
||||
DooPush 推送
|
||||
App ID
|
||||
App Secret
|
||||
服务地址
|
||||
新版 App 使用 DooPush,旧版 App 使用友盟,两者可同时开启。
|
||||
|
||||
@ -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. Оба сервиса можно включить одновременно."
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
||||
@ -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 种数据导出
|
||||
- **团队管理**:成员列表 / 部门管理 / 邀请加入
|
||||
|
||||
@ -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 版桌面通知由浏览器实现,关闭浏览器即失效
|
||||
|
||||
|
||||
@ -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
|
||||
---
|
||||
|
||||
# 桌面端原生通知
|
||||
|
||||
@ -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 推送,不走邮件
|
||||
|
||||
|
||||
@ -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 是发送还是换行)
|
||||
- 二维码扫码 / 调起相机拍摄上传
|
||||
|
||||
@ -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]]
|
||||
不支持脱离推送服务的点对点离线通知;到达率受厂商通道、网络和手机电池策略影响,不能保证所有设备即时送达。
|
||||
|
||||
@ -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 会自动重试关键请求(消息发送)
|
||||
|
||||
@ -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` 上报友盟别名
|
||||
|
||||
## 何时选移动端
|
||||
- 出差 / 通勤时收消息和处理任务
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -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 设备。
|
||||
|
||||
@ -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 推送设备。
|
||||
- 没有按消息类型订阅推送的开关。
|
||||
- 两个通道都关闭或对应凭证缺失时无法发送离线推送,站内消息仍正常。
|
||||
|
||||
@ -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 推送;静默消息、自己发给自己的消息,以及延迟期间已读的消息不推送。
|
||||
|
||||
@ -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 小时各提醒一次
|
||||
|
||||
@ -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 主线版本无计划。
|
||||
|
||||
@ -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]]
|
||||
|
||||
## 不支持
|
||||
|
||||
@ -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 / 友盟配置
|
||||
|
||||
## 不支持
|
||||
- 没有「通知历史」可回放所有过去通知的全局界面(仅站内红点 + 邮件)
|
||||
|
||||
@ -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. 重新上线后**手动进各页面**走一遍 → 自动拉新
|
||||
|
||||
@ -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 是否正确绑定
|
||||
## 不支持
|
||||
推送送达率受手机系统和网络影响,无法完全保证。离线推送失败不影响登录后同步站内消息。
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -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 没有重发推送按钮;已读、静默和自己发给自己的消息不推送。退出登录后设备解绑,不再接收该账号推送。
|
||||
|
||||
@ -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 端和桌面端。
|
||||
- 配置页没有测试推送和事件订阅按钮。
|
||||
- 推送送达依赖推送服务可达和手机通知权限,不能保证所有设备即时送达。
|
||||
|
||||
@ -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]]。
|
||||
|
||||
@ -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,仍出现在消息列表和会话内
|
||||
|
||||
@ -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 内只剩前台徽标,没有推送
|
||||
|
||||
## 会话级免打扰 / 静音
|
||||
|
||||
@ -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 / 友盟推送配置抽屉
|
||||
- 「举报管理」→ 举报记录抽屉
|
||||
- 「数据导出」→ 下拉菜单选导出类型(任务/超期/审批/签到)
|
||||
- 「团队管理」→ 跳转到团队管理页
|
||||
|
||||
@ -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
|
||||
@ -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',
|
||||
|
||||
@ -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: {},
|
||||
}
|
||||
|
||||
@ -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 | 说明 |
|
||||
|
||||
@ -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);
|
||||
|
||||
328
tests/Feature/AppPushTest.php
Normal file
328
tests/Feature/AppPushTest.php
Normal 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":[]}'];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user