mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 18:02:22 +00:00
给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起 reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、 红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。 设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。 后端: - 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引, 注册为日期字段 - WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500) - WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at), 接入 toggleTodoMsg($remindAt) 与 msg__todo 透传 - 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、 pushMsg 同步 todo_done) - TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/ 会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本, 接入 crontab;登记 todo-alert 机器人 - msgJoinGroup 从提醒文本中提取被 @ 成员 前端: - 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker) - 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」 二次确认后取消,无时间时仅关闭面板不发请求 - 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空 展示空状态占位;提醒时间用 Icon 替换 emoji - 时间读写对齐项目任务时间的时区约定 测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/ text mention 提取),TodoSetPermissionTest 无回归。 任务 #124 后续增强。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.5 KiB
PHP
161 lines
6.5 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Models\User;
|
||
use App\Models\WebSocketDialog;
|
||
use App\Models\WebSocketDialogMsgTodo;
|
||
use App\Tasks\TodoRemindTask;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* 待办提醒时间测试
|
||
*/
|
||
class TodoRemindTest extends TestCase
|
||
{
|
||
use DatabaseTransactions;
|
||
|
||
private function makeTodo(array $attr = []): WebSocketDialogMsgTodo
|
||
{
|
||
$todo = WebSocketDialogMsgTodo::createInstance(array_merge([
|
||
'dialog_id' => 1001,
|
||
'msg_id' => 2001,
|
||
'userid' => 3001,
|
||
], $attr));
|
||
$todo->save();
|
||
return $todo;
|
||
}
|
||
|
||
public function test_remind_columns_persist()
|
||
{
|
||
$todo = $this->makeTodo(['remind_at' => '2026-06-02 09:00:00']);
|
||
$fresh = WebSocketDialogMsgTodo::whereId($todo->id)->first();
|
||
$this->assertEquals('2026-06-02 09:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
|
||
$this->assertNull($fresh->reminded_at);
|
||
}
|
||
|
||
public function test_due_reminders_selects_only_due_unreminded_undone()
|
||
{
|
||
$past = Carbon::now()->subMinutes(5)->format('Y-m-d H:i:s');
|
||
$future = Carbon::now()->addHours(2)->format('Y-m-d H:i:s');
|
||
|
||
$due = $this->makeTodo(['userid' => 1, 'remind_at' => $past]);
|
||
$future1 = $this->makeTodo(['userid' => 2, 'remind_at' => $future]);
|
||
$already = $this->makeTodo(['userid' => 3, 'remind_at' => $past, 'reminded_at' => Carbon::now()]);
|
||
$done = $this->makeTodo(['userid' => 4, 'remind_at' => $past, 'done_at' => Carbon::now()]);
|
||
$noRemind = $this->makeTodo(['userid' => 5]);
|
||
|
||
$ids = WebSocketDialogMsgTodo::dueReminders()->pluck('id')->toArray();
|
||
|
||
$this->assertContains($due->id, $ids);
|
||
$this->assertNotContains($future1->id, $ids);
|
||
$this->assertNotContains($already->id, $ids);
|
||
$this->assertNotContains($done->id, $ids);
|
||
$this->assertNotContains($noRemind->id, $ids);
|
||
}
|
||
|
||
public function test_set_todo_remind_sets_and_resets_and_clears()
|
||
{
|
||
// 同一消息两人,预置已提醒状态以验证会被重置
|
||
$a = $this->makeTodo(['msg_id' => 5001, 'userid' => 11, 'reminded_at' => Carbon::now()]);
|
||
$b = $this->makeTodo(['msg_id' => 5001, 'userid' => 12, 'reminded_at' => Carbon::now()]);
|
||
|
||
$msg = new \App\Models\WebSocketDialogMsg();
|
||
$msg->id = 5001;
|
||
|
||
// 设提醒:写入 remind_at,并把 reminded_at 重置为 null
|
||
$affected = $msg->setTodoRemind([11, 12], '2026-06-05 10:00:00');
|
||
$this->assertSame(2, $affected);
|
||
foreach ([$a, $b] as $row) {
|
||
$fresh = WebSocketDialogMsgTodo::whereId($row->id)->first();
|
||
$this->assertStringStartsWith('2026-06-05 10:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
|
||
$this->assertNull($fresh->reminded_at, '改时间后应允许再次提醒');
|
||
}
|
||
|
||
// 取消提醒:remind_at 置 null
|
||
$msg->setTodoRemind([11], null);
|
||
$this->assertNull(WebSocketDialogMsgTodo::whereId($a->id)->first()->remind_at);
|
||
// 未传 userid 时不动任何行
|
||
$this->assertSame(0, $msg->setTodoRemind([], '2026-06-05 10:00:00'));
|
||
}
|
||
|
||
private function makeUser(string $email): User
|
||
{
|
||
$user = User::createInstance([
|
||
'email' => $email,
|
||
'userimg' => '',
|
||
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
|
||
'profession' => '',
|
||
'password' => md5('123456'),
|
||
]);
|
||
$user->save();
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 镜像 msg__todoremind 的权限闸门:
|
||
* 开关 close 且改到「自己以外的人」时,需操作者命中 checkTodoOwnerPermission。
|
||
*
|
||
* 注意:此逻辑为 DialogController::msg__todoremind() 权限逻辑的镜像,
|
||
* 若 msg__todoremind 权限逻辑改动需同步更新此方法。
|
||
*/
|
||
private function remindGateAllow(WebSocketDialog $dialog, string $switch, int $sender, array $userids): bool
|
||
{
|
||
if ($switch !== 'close') {
|
||
return true;
|
||
}
|
||
$others = array_diff(array_map('intval', $userids), [$sender]);
|
||
if (!$others) {
|
||
return true; // 只改自己
|
||
}
|
||
return $dialog->checkTodoOwnerPermission($sender);
|
||
}
|
||
|
||
public function test_remind_edit_permission_follows_todo_gate()
|
||
{
|
||
$owner = $this->makeUser('r_o@test.local');
|
||
$member = $this->makeUser('r_m@test.local');
|
||
$dialog = WebSocketDialog::createGroup('Test_remind', [$owner->userid, $member->userid], 'user', $owner->userid)->fresh();
|
||
|
||
$this->assertTrue($this->remindGateAllow($dialog, 'close', $member->userid, [$member->userid])); // 改自己→放行
|
||
$this->assertFalse($this->remindGateAllow($dialog, 'close', $member->userid, [$owner->userid])); // 改他人→拒绝
|
||
$this->assertTrue($this->remindGateAllow($dialog, 'close', $owner->userid, [$member->userid])); // 群主改他人→放行
|
||
$this->assertTrue($this->remindGateAllow($dialog, 'open', $member->userid, [$owner->userid])); // open→放行
|
||
}
|
||
|
||
public function test_build_remind_text_produces_mention_spans()
|
||
{
|
||
$a = $this->makeUser('rt_a@test.local');
|
||
$b = $this->makeUser('rt_b@test.local');
|
||
|
||
$text = TodoRemindTask::buildRemindText([$a->userid, $b->userid]);
|
||
|
||
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$a->userid}\">@{$a->nickname}</span>", $text);
|
||
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$b->userid}\">@{$b->nickname}</span>", $text);
|
||
$this->assertStringContainsString('你有一条待办到提醒时间啦', $text);
|
||
}
|
||
|
||
public function test_msg_join_group_extracts_text_mention_from_spans()
|
||
{
|
||
$owner = $this->makeUser('tx_o@test.local');
|
||
$a = $this->makeUser('tx_a@test.local');
|
||
$b = $this->makeUser('tx_b@test.local');
|
||
$dialog = WebSocketDialog::createGroup('Test_text_mention', [$owner->userid, $a->userid, $b->userid], 'user', $owner->userid)->fresh();
|
||
|
||
$msg = new \App\Models\WebSocketDialogMsg();
|
||
$msg->dialog_id = $dialog->id;
|
||
$msg->userid = $owner->userid;
|
||
$msg->type = 'text';
|
||
$msg->msg = ['text' => TodoRemindTask::buildRemindText([$a->userid, $b->userid])];
|
||
|
||
$result = $msg->msgJoinGroup($dialog);
|
||
$mentions = array_map('intval', $result['mentions']);
|
||
sort($mentions);
|
||
$expected = [$a->userid, $b->userid];
|
||
sort($expected);
|
||
$this->assertEquals($expected, $mentions);
|
||
}
|
||
}
|