diff --git a/app/Controller/Admin/Login.php b/app/Controller/Admin/Login.php index fe112c3..a6ccba7 100644 --- a/app/Controller/Admin/Login.php +++ b/app/Controller/Admin/Login.php @@ -156,7 +156,7 @@ class Login extends Base #[Auth(needAuth: false)] public function saveInfo() { - $param = Param::only(['nickname', 'birthday', 'sex', 'bio', 'avatar']); + $param = Param::only(['nickname', 'birthday', 'sex', 'bio', 'avatar', 'phone', 'email']); $res = Account::where("account_id", $this->accountId())->update($param); return $res ? $this->success("保存成功") : $this->error("保存失败"); } diff --git a/app/Controller/Admin/System.php b/app/Controller/Admin/System.php index 34d3ded..1417981 100644 --- a/app/Controller/Admin/System.php +++ b/app/Controller/Admin/System.php @@ -11,6 +11,7 @@ use App\Model\Crontab as cModel; use App\Model\CrontabLog as clModel; use App\Model\Dept as dModel; use App\Model\Menu as mModel; +use App\Model\AccountMessage as amModel; use App\Model\Online as oModel; use App\Model\Post as pModel; use App\Model\Role as rModel; @@ -275,7 +276,7 @@ class System extends Base $request = $this->container->get(aRequest::class); $request->scene('add')->validateResolved(); $account = $this->request->getAttribute("account"); - $data = Param::only(['roles' => [], 'username', 'status' => 1, 'dept_id' => 0, 'posts' => [], 'password' => '123456']); + $data = Param::only(['roles' => [], 'username', 'email', 'phone', 'status' => 1, 'dept_id' => 0, 'posts' => [], 'password' => '123456']); // 判断数据库是否存在相同的用户名 $admin = aModel::getByUsername($data['username'], ['account_id']); if (!empty($admin)) { @@ -294,7 +295,7 @@ class System extends Base { $request = $this->container->get(aRequest::class); $request->scene('edit')->validateResolved(); - $data = Param::only(['roles' => [], 'username', 'status' => 1, 'dept_id' => 0, 'posts' => [], 'password' => '', 'account_id']); + $data = Param::only(['roles' => [], 'username', 'email', 'phone', 'status' => 1, 'dept_id' => 0, 'posts' => [], 'password' => '', 'account_id']); $res = aModel::edit($data); return $res ? $this->success("操作成功") : $this->error("操作失败"); } @@ -630,7 +631,7 @@ class System extends Base $request->scene('edit')->validateResolved(); $data = Param::only(["config_id", "config_name", "config_key", "config_value", "remark"]); // 删除原来的缓存 - $info = scModel::getById($data['config_id'],['config_key']); + $info = scModel::getById($data['config_id'], ['config_key']); (new ConfigCacheService())->removeItem($info['config_key']); // 变更数据 $res = scModel::edit($data); @@ -655,7 +656,7 @@ class System extends Base #[Auth(auth: "dict:list")] public function dictList() { - $param = Param::only(["dict_name", "dict_type", "limit"=>10]); + $param = Param::only(["dict_name", "dict_type", "limit" => 10]); return $this->success("列表接口", dtModel::list($param)); } @@ -706,7 +707,7 @@ class System extends Base #[Auth(auth: "dict_data:list")] public function dictDataList() { - $param = Param::only(["dict_id", "dict_label", "status", "limit"=>10]); + $param = Param::only(["dict_id", "dict_label", "status", "limit" => 10]); return $this->success("列表接口", ddModel::list($param)); } @@ -714,7 +715,7 @@ class System extends Base #[Auth(needAuth: false)] public function dictDataOption() { - $param = Param::only(["key"=>""]); + $param = Param::only(["key" => ""]); return $this->success(ddModel::options($param)); } @@ -758,7 +759,7 @@ class System extends Base #[Auth(auth: "translation:list")] public function translationList() { - $param = Param::only(["group", "translation_key", "limit"=>10]); + $param = Param::only(["group", "translation_key", "limit" => 10]); return $this->success("列表接口", tsModel::list($param)); } @@ -789,4 +790,50 @@ class System extends Base $ids = $this->request->input("ids"); return $this->toAjax(tsModel::del($ids)); } + + #[GetMapping(path: "message/mine")] + #[Auth(needAuth: false)] + public function messageList() + { + $param = Param::only(["limit" => 10, 'type' => 'system']); + $param['account_id'] = $this->accountId(); + return $this->success("列表接口", amModel::list($param)); + } + + #[PostMapping(path: "message/read")] + #[Auth(needAuth: false)] + public function messageRead() + { + $param = Param::only(["ids" => [], 'type' => '']); + $param['account_id'] = $this->accountId(); + amModel::read($param); + // 全部已读 + return $this->success(); + } + + #[GetMapping(path: "message/detail")] + #[Auth(needAuth: false)] + public function messageDetail() + { + $param = Param::only(["id"]); + if (!$param['id']) return $this->error(); + $param['account_id'] = $this->accountId(); + $info = amModel::detail($param); + if (!empty($info)) { + $info = $info->toArray(); + // 设为已读 + amModel::read(['ids' => [$param['id']], 'type' => $info['type'], 'account_id' => $this->accountId()]); + } + return $this->success("详情", $info); + } + + #[DeleteMapping(path: "message/remove")] + #[Auth(needAuth: false)] + public function messageRemove() + { + $param = Param::only(["id"]); + if (!$param['id']) return $this->error(); + $res = amModel::where(['mess_id' => $param['id'], 'account_id' => $this->accountId()])->delete(); + return $this->toAjax($res); + } } \ No newline at end of file diff --git a/app/Controller/Admin/Tools.php b/app/Controller/Admin/Tools.php index 8b793b5..feb2644 100644 --- a/app/Controller/Admin/Tools.php +++ b/app/Controller/Admin/Tools.php @@ -5,7 +5,10 @@ namespace App\Controller\Admin; use App\Annotation\Auth; use App\Model\GenTable as gtModel; use App\Request\GenTable as gtRequest; +use App\Model\Message as mModel; +use App\Request\Message as mRequest; use App\Utils\Param; +use App\Utils\QueueClient; use Hyperf\DbConnection\Db; use Hyperf\HttpServer\Annotation\Controller; use Hyperf\HttpServer\Annotation\DeleteMapping; @@ -216,4 +219,54 @@ class Tools extends Base } return $this->success("表单信息", $data); } + + #[GetMapping(path: "message/list")] + #[Auth(auth: "message:list")] + public function messageList() + { + $param = Param::only(["title", "limit" => 10]); + return $this->success("列表接口", mModel::list($param)); + } + + #[PostMapping(path: "message/add")] + #[Auth(auth: "message:add")] + public function messageAdd() + { + $request = $this->container->get(mRequest::class); + $request->scene('add')->validateResolved(); + $data = Param::only(["title", "content"]); + $data['create_id'] = $this->accountId(); + $data['type'] = 'system'; + $res = mModel::add($data); + if (!$res) return $this->error(); + QueueClient::push("App\Job\NotifyJob", ['messageId' => $res, 'channels' => ['websocket', 'email']]); + return $this->success(); + } + + #[PutMapping(path: "message/edit")] + #[Auth(auth: "message:edit")] + public function messageEdit() + { + $request = $this->container->get(mRequest::class); + $request->scene('edit')->validateResolved(); + $data = Param::only(["message_id", "title", "content"]); + return $this->toAjax(mModel::edit($data)); + } + + #[DeleteMapping(path: "message/del")] + #[Auth(auth: "message:del")] + public function messageDel() + { + $ids = $this->request->input("ids"); + return $this->toAjax(mModel::del($ids)); + } + + #[PutMapping(path: "message/recalled")] + #[Auth(auth: "message:recalled")] + public function messageRecalled() + { + $data = Param::only(["id"]); + if (!$data['id']) return $this->error(); + return $this->toAjax(mModel::edit(['message_id' => $data['id'], 'recalled_flag' => 1])); + } } \ No newline at end of file diff --git a/app/Job/NotifyJob.php b/app/Job/NotifyJob.php new file mode 100644 index 0000000..0b04094 --- /dev/null +++ b/app/Job/NotifyJob.php @@ -0,0 +1,64 @@ +messageId = $params["messageId"] ?? null; + $this->userIds = $params["userIds"] ?? null; + $this->channels = $params["channels"] ?? null; + } + + public function handle() + { + // 查询消息信息 + $message = mModel::getById($this->messageId); + if (empty($message)) return true; + if (empty($this->userIds) && $message['type'] == "system") { + // 系统消息推送给所有用户 + $this->userIds = aModel::pluck("account_id")->toArray(); + } + if (empty($this->userIds)) return true; + // 批量插入数据 + $mess = []; + foreach ($this->userIds as $id) { + $mess[] = [ + 'account_id' => $id, + 'message_id' => $this->messageId, + 'create_time' => date("Y-m-d H:i:s") + ]; + } + amModel::insert($mess); + // 多渠道推送 + if (empty($this->channels)) return true; + foreach ($this->userIds as $id) { + foreach ($this->channels as $channel) { + mcModel::add([ + 'account_id' => $id, + 'message_id' => $this->messageId, + 'channel' => $channel + ]); + // 队列多渠道推送 + QueueClient::push("App\Job\SendChannelJob", ["userId" => $id, "messageId" => $this->messageId, "channel" => $channel]); + } + } + return true; + } +} \ No newline at end of file diff --git a/app/Job/SendChannelJob.php b/app/Job/SendChannelJob.php new file mode 100644 index 0000000..170d52a --- /dev/null +++ b/app/Job/SendChannelJob.php @@ -0,0 +1,51 @@ +userId = $params['userId']; + $this->messageId = $params['messageId']; + $this->channel = $params['channel']; + } + + public function handle() + { + try { + $channel = make(ChannelFactory::class)->get($this->channel); + $account = aModel::find($this->userId); + $message = mModel::find($this->messageId); + $channel->send($account, $message); + mchModel::query() + ->where('message_id', $this->messageId) + ->where('account_id', $this->userId) + ->where('channel', $this->channel) + ->update(['status' => 1]); + } catch (\Exception $e) { + Log::record("消息投递失败:" . $e->getMessage(), "SendChannelJob"); + mchModel::query() + ->where('message_id', $this->messageId) + ->where('account_id', $this->userId) + ->where('channel', $this->channel) + ->update(['status' => 2, 'fail_reason' => $e->getMessage()]); + } + } +} \ No newline at end of file diff --git a/app/Model/Account.php b/app/Model/Account.php index fa11872..85a0f47 100644 --- a/app/Model/Account.php +++ b/app/Model/Account.php @@ -22,6 +22,8 @@ use Hyperf\DbConnection\Db; * @property string $bio * @property string $tags * @property int $sex + * @property string $email + * @property string $phone * @property string $birthday * @property string $deleted_at * @property string $create_time @@ -60,14 +62,14 @@ class Account extends Model { $info = self::with('roles') ->with('posts') - ->select(['account_id', 'username', 'nickname', 'avatar', 'bio', 'tags', 'sex', 'birthday', 'create_time', 'dept_id']) + ->select(['account_id', 'username', 'nickname', 'avatar', 'bio', 'tags', 'sex', 'birthday', 'create_time', 'dept_id', 'email', 'phone']) ->find($account_id); if ($info) { $info = $info->toArray(); // 获取所有上级 $dept = []; Dept::getTop($info['dept_id'], $dept); - $info['dept'] = array_reverse(array_column($dept,"dept_name")); + $info['dept'] = array_reverse(array_column($dept, "dept_name")); } return $info; } @@ -108,7 +110,7 @@ class Account extends Model $model = $model->where('dept_id', $where['dept_id']); } $paginate = $model->orderByDesc("account_id") - ->paginate((int)$where['limit'], ['account_id', 'username', 'avatar', 'create_time', 'dept_id']); + ->paginate((int)$where['limit'], ['account_id', 'username', 'avatar', 'create_time', 'dept_id', 'email', 'phone', 'nickname']); $count = $paginate->total(); $data = $paginate->items(); foreach ($data as &$item) { diff --git a/app/Model/AccountMessage.php b/app/Model/AccountMessage.php new file mode 100644 index 0000000..cc66e53 --- /dev/null +++ b/app/Model/AccountMessage.php @@ -0,0 +1,82 @@ + 'integer', 'account_id' => 'integer', 'message_id' => 'integer', 'read_flag' => 'integer']; + + public static function list(array $param) + { + return (new self())->setTable("am") + ->from("account_message as am") + ->leftJoin("message as m", "m.message_id", "=", "am.message_id") + ->where("m.recalled_flag", 0) + ->where("m.type", $param['type']) + ->where("am.account_id", $param['account_id']) + ->whereNull("m.deleted_at") + ->orderBy("am.read_flag") + ->orderByDesc("am.mess_id") + ->select(['am.mess_id', 'am.read_flag', 'am.create_time', 'm.title', 'm.extra']) + ->paginate($param['limit']); + } + + public static function read(array $param) + { + return (new self())->setTable("am") + ->from("account_message as am") + ->leftJoin("message as m", "m.message_id", "=", "am.message_id") + ->where("m.recalled_flag", 0) + ->whereNull("m.deleted_at") + ->where("am.account_id", $param['account_id']) + ->where("m.type", $param['type']) + ->where("am.read_flag", 0) + ->when(!empty($param['ids']), function ($query) use ($param) { + $query->whereIn("am.mess_id", $param['ids']); + }) + ->update([ + 'read_flag' => 1 + ]); + } + + public static function detail(array $param) + { + return (new self())->setTable("am") + ->from("account_message as am") + ->leftJoin("message as m", "m.message_id", "=", "am.message_id") + ->leftJoin("account as a", "a.account_id", "=", "m.create_id") + ->where("m.recalled_flag", 0) + ->whereNull("m.deleted_at") + ->where("am.account_id", $param['account_id']) + ->where("am.mess_id", $param['id']) + ->select(['am.mess_id', 'am.create_time', 'm.title', 'm.content', 'm.extra', 'm.type', 'a.nickname']) + ->first(); + } +} diff --git a/app/Model/Message.php b/app/Model/Message.php new file mode 100644 index 0000000..ce88df0 --- /dev/null +++ b/app/Model/Message.php @@ -0,0 +1,61 @@ + 'integer', 'recalled_flag' => 'integer', 'create_id' => 'integer']; + + /** + * 仅管理系统消息 + * @param array $param + * @return \Hyperf\Contract\LengthAwarePaginatorInterface + */ + public static function list(array $param) + { + $model = self::query()->with(["account" => function ($query) { + $query->select(['account_id', 'username']); + }]); + if (isset($param['title']) && $param['title'] != '') { + $model = $model->where('title', "like", "%{$param['title']}%"); + } + return $model->where("type", "system") + ->orderByDesc("message_id") + ->select(["message_id", "title", "content", "recalled_flag", "create_id", "create_time", "update_time"]) + ->paginate((int)$param['limit']); + } + + public function account() + { + return $this->hasOne(Account::class, 'account_id', 'create_id'); + } +} diff --git a/app/Model/MessageChannel.php b/app/Model/MessageChannel.php new file mode 100644 index 0000000..ee27010 --- /dev/null +++ b/app/Model/MessageChannel.php @@ -0,0 +1,36 @@ + 'integer', 'account_id' => 'integer', 'message_id' => 'integer', 'status' => 'integer']; +} diff --git a/app/Model/Online.php b/app/Model/Online.php index b99abca..224cc57 100644 --- a/app/Model/Online.php +++ b/app/Model/Online.php @@ -4,9 +4,6 @@ declare(strict_types=1); namespace App\Model; -use App\Utils\Ip; -use Swoole\Http\Request; - /** * @property int $online_id * @property string $session_id @@ -92,4 +89,12 @@ class Online extends Model { return $this->hasOne(Account::class, 'account_id', 'account_id'); } + + public static function getFds($accountId): array + { + return self::where("status", 1) + ->where("account_id", $accountId) + ->pluck("fd") + ->toArray(); + } } diff --git a/app/Request/Message.php b/app/Request/Message.php new file mode 100644 index 0000000..e892931 --- /dev/null +++ b/app/Request/Message.php @@ -0,0 +1,50 @@ + ["title", "content"], + 'edit' => ["message_id", "title", "content"], + ]; + + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'message_id' => 'required', + 'title' => 'required', + 'content' => 'required', + 'type' => 'required', + 'extra' => 'required', + 'recalled_flag' => 'required', + 'create_id' => 'required', + 'deleted_at' => 'required', + 'create_time' => 'required', + 'update_time' => 'required' + ]; + } + + public function messages(): array + { + return [ + 'message_id.required' => 'ID必传!', + 'title.required' => '标题必传!', + 'content.required' => '内容必传!', + 'recalled_flag.required' => '是否撤回必传!' + ]; + } +} diff --git a/app/Service/Message/Channel/EmailChannel.php b/app/Service/Message/Channel/EmailChannel.php new file mode 100644 index 0000000..fc48595 --- /dev/null +++ b/app/Service/Message/Channel/EmailChannel.php @@ -0,0 +1,56 @@ +mailer = new Mailer($transport); + } + + public function send(Account $account, Message $message): bool + { + try { + if (!$account->email) { + throw new ChannelFailException("用户邮箱地址不存在"); + } + $email = (new Email()) + ->from(new Address(config("mailer.default.username"), config("mailer.default.name"))) + ->to($account->email) + ->subject($message->title) + ->html($message->content); + $this->mailer->send($email); + return true; + } catch (\Exception $exception) { + throw new ChannelFailException($exception->getMessage()); + } + } + + public function getName(): string + { + return "email"; + } +} \ No newline at end of file diff --git a/app/Service/Message/Channel/WebsocketChannel.php b/app/Service/Message/Channel/WebsocketChannel.php new file mode 100644 index 0000000..17a0abe --- /dev/null +++ b/app/Service/Message/Channel/WebsocketChannel.php @@ -0,0 +1,90 @@ +account_id); + foreach ($lines as $fd) { + $this->sendOk($fd, $message->title, $message->type, $message->extra); + } + return true; + } catch (\Exception $exception) { + throw new ChannelFailException($exception->getMessage()); + } + } + + public function getName(): string + { + return "websocket"; + } + + /** + * 推送信息 + * Author: cfn + * @param int $fd + * @param string $msg + * @param string $type + * @param string|null $extra + * @return void + */ + public function sendOk(int $fd, string $msg, string $type = "msg", string $extra = null): void + { + $resp = [ + 'code' => 200, + 'msg' => $msg, + 'type' => $type, + 'extra' => $extra + ]; + $this->sender->push($fd, json_encode($resp, true)); + } + + /** + * 推送信息 + * Author: cfn + * @param int $fd + * @param string $msg + * @param string $type + * @param string|null $extra + * @return void + */ + public function sendErr(int $fd, string $msg, string $type = "msg", string $extra = null): void + { + $resp = [ + 'code' => 400, + 'msg' => $msg, + 'type' => $type, + 'extra' => $extra + ]; + $this->sender->push($fd, json_encode($resp, true)); + } + + /** + * 关闭连接 + * Author: cfn + * @param int $fd + * @return void + */ + public function close(int $fd): void + { + go(function () use ($fd) { + sleep(1); + $this->sender->disconnect($fd); + }); + } +} \ No newline at end of file diff --git a/app/Service/Message/ChannelFactory.php b/app/Service/Message/ChannelFactory.php new file mode 100644 index 0000000..f873206 --- /dev/null +++ b/app/Service/Message/ChannelFactory.php @@ -0,0 +1,50 @@ + EmailChannel::class, + 'websocket' => WebsocketChannel::class, + ]; + + public function __construct(Container $container) + { + $this->container = $container; + } + + /** + * 获取渠道实例 + */ + public function get(string $name): ChannelInterface + { + if (!isset($this->channels[$name])) { + throw new ChannelNotFoundException("Channel [$name] not found"); + } + return $this->container->get($this->channels[$name]); + } + + /** + * 获取所有可用渠道 + */ + public function all(): array + { + $list = []; + foreach ($this->channels as $class) { + $list[] = $this->container->get($class); + } + return $list; + } +} \ No newline at end of file diff --git a/app/Service/Message/Exception/ChannelFailException.php b/app/Service/Message/Exception/ChannelFailException.php new file mode 100644 index 0000000..24831c1 --- /dev/null +++ b/app/Service/Message/Exception/ChannelFailException.php @@ -0,0 +1,8 @@ +driver = $driverFactory->get('default'); + } + + /** + * 生产消息. + * @param array $params 数据 + * @param int $delay 延时时间 单位秒 + * @param string $queue_name + * @return bool + */ + public function push(string $queue_name, array $params, int $delay = 0): bool + { + return $this->driver->push(new $queue_name($params), $delay); + } +} diff --git a/app/Utils/QueueClient.php b/app/Utils/QueueClient.php new file mode 100644 index 0000000..4957188 --- /dev/null +++ b/app/Utils/QueueClient.php @@ -0,0 +1,43 @@ + + */ + +namespace App\Utils; + +use App\Service\QueueService; +use Hyperf\Di\Annotation\Inject; + +/** + * Author: cfn + */ +class QueueClient +{ + #[Inject] + protected QueueService $queueService; + + /** + * 静态调用 + * Author: cfn + * @param $name + * @param $arguments + * @return void + */ + public static function __callStatic($name, $arguments) + { + (new self())->$name(...$arguments); + } + + /** + * 异步队列推送 + * Author: cfn + * @param string $queue_name + * @param array $params + * @param int $delay + * @return void + */ + protected function push(string $queue_name, array $params, int $delay = 0): void + { + $this->queueService->push($queue_name, $params, $delay); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 2f7ddde..0247d62 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "hyperf/view": "^3.1", "hyperf/websocket-server": "^3.1", "phpoffice/phpspreadsheet": "^4.5", + "symfony/mailer": "^7.4", "twig/twig": "^3.22", "zoujingli/ip2region": "^3.0" }, diff --git a/config/autoload/mailer.php b/config/autoload/mailer.php new file mode 100644 index 0000000..3164a62 --- /dev/null +++ b/config/autoload/mailer.php @@ -0,0 +1,13 @@ + [ + 'transport' => 'smtp', + 'host' => 'smtp.ym.163.com', // SMTP 服务器地址 + 'port' => 465, // SMTP 端口 + 'encryption' => 'ssl', // 加密方式,支持 ssl 或 tls + 'username' => 'mail@leapy.cn', // 邮箱地址 + 'password' => 'e!@3j9wjri2', // SMTP 授权码 + 'name' => '里派科技', // 发件人名称 + ], +]; \ No newline at end of file diff --git a/config/autoload/processes.php b/config/autoload/processes.php index 87e3b4c..cd4e9e2 100644 --- a/config/autoload/processes.php +++ b/config/autoload/processes.php @@ -11,4 +11,5 @@ declare(strict_types=1); */ return [ App\Process\CrontabDispatcherProcess::class, + Hyperf\AsyncQueue\Process\ConsumerProcess::class, ]; diff --git a/config/routes.php b/config/routes.php index d7104f1..9b47b1e 100644 --- a/config/routes.php +++ b/config/routes.php @@ -1,17 +1,17 @@ withStatus(302) + ->withHeader('Location', '/super/'); +}); Router::get('/favicon.ico', function () { return ''; diff --git a/static/view/templates/save.vue.twig b/static/view/templates/save.vue.twig index 5fc03f6..e4d7777 100644 --- a/static/view/templates/save.vue.twig +++ b/static/view/templates/save.vue.twig @@ -1,5 +1,5 @@ - +