Files
kuaifanandClaude Opus 4.8 6b54b7b1c5 feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)
给消息待办增加可选「提醒时间」,到点由 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>
2026-06-01 12:08:34 +00:00

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