mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 09:52:26 +00:00
- 成员行操作菜单新增「标记邮箱为已认证/未认证」,复用 users.email_verity 字段与 api/users/operation 接口,新增 setverity/clearverity 操作类型 - 创建用户:邮箱下方新增「标记邮箱为已认证」复选框(默认勾选), 「首次登录需改密」复选框移到初始密码下方 - 批量导入:预览列表邮箱右侧显示主题色已认证图标(错误行不显示), 支持勾选行后批量标记已/未认证;部门与认证批量行加标签对齐、 三个批量按钮样式随选中状态统一 - createByAdmin 新增 emailVerity 选项,createuser/import 透传逐行认证状态 - 新增导入预览默认认证状态单测 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1100 lines
38 KiB
PHP
1100 lines
38 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Exceptions\ApiException;
|
||
use App\Module\Base;
|
||
use App\Module\Doo;
|
||
use App\Module\Apps;
|
||
use App\Module\Table\OnlineData;
|
||
use App\Observers\AbstractObserver;
|
||
use App\Services\RequestContext;
|
||
use App\Tasks\ManticoreSyncTask;
|
||
use Cache;
|
||
use Carbon\Carbon;
|
||
|
||
/**
|
||
* App\Models\User
|
||
*
|
||
* @property int $userid
|
||
* @property array $identity 身份
|
||
* @property array $department 所属部门
|
||
* @property string|null $az A-Z
|
||
* @property string|null $pinyin 拼音(主要用于搜索)
|
||
* @property string|null $email 邮箱
|
||
* @property string|null $tel 联系电话
|
||
* @property string $nickname 昵称
|
||
* @property string|null $profession 职位/职称
|
||
* @property string|null $birthday 生日
|
||
* @property string|null $address 地址
|
||
* @property string|null $introduction 个人简介
|
||
* @property string $userimg 头像
|
||
* @property string|null $encrypt
|
||
* @property string|null $password 登录密码
|
||
* @property int|null $changepass 登录需要修改密码
|
||
* @property int|null $login_num 累计登录次数
|
||
* @property string|null $last_ip 最后登录IP
|
||
* @property \Illuminate\Support\Carbon|null $last_at 最后登录时间
|
||
* @property string|null $line_ip 最后在线IP(接口)
|
||
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口)
|
||
* @property int|null $task_dialog_id 最后打开的任务会话ID
|
||
* @property string|null $created_ip 注册IP
|
||
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间)
|
||
* @property int|null $email_verity 邮箱是否已验证
|
||
* @property int|null $bot 是否机器人
|
||
* @property string|null $lang 语言首选项
|
||
* @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 \Database\Factories\UserFactory factory(...$parameters)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User query()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User searchByKeyword(string $keyword)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAddress($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBirthday($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedIp($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereDepartment($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereDisableAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIntroduction($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLang($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLineAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLineIp($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLoginNum($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereNickname($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User wherePinyin($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereProfession($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereTaskDialogId($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereTel($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereUserid($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|User whereUserimg($value)
|
||
* @mixin \Eloquent
|
||
*/
|
||
class User extends AbstractModel
|
||
{
|
||
const IMPORT_MAX = 500;
|
||
|
||
protected $primaryKey = 'userid';
|
||
|
||
protected $hidden = [
|
||
'updated_at',
|
||
];
|
||
|
||
// 默认头像类型:auto自动生成,system系统默认
|
||
public static $defaultAvatarMode = 'auto';
|
||
|
||
// 基本信息的字段
|
||
public static $basicField = ['userid', 'email', 'nickname', 'profession', 'department', 'userimg', 'bot', 'az', 'pinyin', 'line_at', 'disable_at'];
|
||
|
||
/**
|
||
* 昵称
|
||
* @param $value
|
||
* @return string
|
||
*/
|
||
public function getNicknameAttribute($value)
|
||
{
|
||
if ($value) {
|
||
if (UserBot::isSystemBot($this->email)) {
|
||
return Doo::translate($value);
|
||
}
|
||
return $value;
|
||
}
|
||
return Base::formatName($this->email);
|
||
}
|
||
|
||
/**
|
||
* 头像地址
|
||
* @param $value
|
||
* @return string
|
||
*/
|
||
public function getUserimgAttribute($value)
|
||
{
|
||
return self::getAvatar($this->userid, $value, $this->email, $this->nickname);
|
||
}
|
||
|
||
/**
|
||
* 身份权限
|
||
* @param $value
|
||
* @return array
|
||
*/
|
||
public function getIdentityAttribute($value)
|
||
{
|
||
if (empty($value)) {
|
||
return [];
|
||
}
|
||
return array_filter(is_array($value) ? $value : explode(",", trim($value, ",")));
|
||
}
|
||
|
||
/**
|
||
* 部门
|
||
* @param $value
|
||
* @return array
|
||
*/
|
||
public function getDepartmentAttribute($value)
|
||
{
|
||
if (empty($value)) {
|
||
return [];
|
||
}
|
||
return array_filter(is_array($value) ? $value : Base::explodeInt($value));
|
||
}
|
||
|
||
/**
|
||
* 获取所属部门名称
|
||
* @return string
|
||
*/
|
||
public function getDepartmentName()
|
||
{
|
||
if (empty($this->department)) {
|
||
return "";
|
||
}
|
||
$key = "UserDepartment::" . md5(Cache::get("UserDepartment::rand") . '-' . implode(',' , $this->department));
|
||
$list = Cache::remember($key, now()->addMonth(), function() {
|
||
$list = UserDepartment::select(['id', 'owner_userid', 'name'])->whereIn('id', $this->department)->take(10)->get();
|
||
return $list->toArray();
|
||
});
|
||
$array = [];
|
||
foreach ($list as $item) {
|
||
$array[] = $item['name'] . ($item['owner_userid'] === $this->userid ? ' (M)' : '');
|
||
}
|
||
return implode(', ', $array);
|
||
}
|
||
|
||
/**
|
||
* 判断是否为部门负责人
|
||
*/
|
||
public function isDepartmentOwner()
|
||
{
|
||
return UserDepartment::where('owner_userid', $this->userid)->exists();
|
||
}
|
||
|
||
/**
|
||
* 获取机器人所有者
|
||
* @return int
|
||
*/
|
||
public function getBotOwner()
|
||
{
|
||
if (!$this->bot) {
|
||
return 0;
|
||
}
|
||
$key = "userBotOwner::" . $this->userid;
|
||
return intval(Cache::remember($key, now()->addMonth(), function() {
|
||
return intval(UserBot::whereBotId($this->userid)->value('userid')) ?: $this->userid;
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 是否在线
|
||
* @return bool
|
||
*/
|
||
public function getOnlineStatus()
|
||
{
|
||
$online = $this->bot || OnlineData::live($this->userid) > 0;
|
||
if ($online) {
|
||
return true;
|
||
}
|
||
return WebSocket::whereUserid($this->userid)->exists();
|
||
}
|
||
|
||
/**
|
||
* 返回是否LDAP用户
|
||
* @return bool
|
||
*/
|
||
public function isLdap()
|
||
{
|
||
return in_array('ldap', $this->identity);
|
||
}
|
||
|
||
/**
|
||
* 返回是否临时帐号
|
||
* @return bool
|
||
*/
|
||
public function isTemp()
|
||
{
|
||
return in_array('temp', $this->identity);
|
||
}
|
||
|
||
/**
|
||
* 返回是否禁用帐号(离职)
|
||
* @param bool $incAt 是否包含禁用时间
|
||
* @return bool
|
||
*/
|
||
public function isDisable($incAt = false)
|
||
{
|
||
if ($incAt) {
|
||
return in_array('disable', $this->identity) || $this->disable_at;
|
||
}
|
||
return in_array('disable', $this->identity);
|
||
}
|
||
|
||
/**
|
||
* 返回是否管理员
|
||
* @return bool
|
||
*/
|
||
public function isAdmin()
|
||
{
|
||
return in_array('admin', $this->identity);
|
||
}
|
||
|
||
/**
|
||
* 返回是否AI机器人
|
||
* @return bool
|
||
*/
|
||
public function isAiBot(&$aiName = '')
|
||
{
|
||
if (preg_match('/^ai-(.*?)@bot\.system$/', $this->email, $matches)) {
|
||
$aiName = $matches[1];
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 返回是否用户机器人
|
||
* @return bool
|
||
*/
|
||
public function isUserBot()
|
||
{
|
||
if (preg_match('/^user-(.*?)@bot\.system$/', $this->email)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 判断是否管理员
|
||
*/
|
||
public function checkAdmin()
|
||
{
|
||
$this->identity('admin');
|
||
}
|
||
|
||
/**
|
||
* 判断用户权限(身份)
|
||
* @param $identity
|
||
*/
|
||
public function identity($identity)
|
||
{
|
||
if (!in_array($identity, $this->identity)) {
|
||
throw new ApiException('权限不足');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查环境是否允许
|
||
* @param null $onlyUserid 仅指定会员
|
||
*/
|
||
public function checkSystem($onlyUserid = null)
|
||
{
|
||
if ($onlyUserid && $onlyUserid != $this->userid) {
|
||
return;
|
||
}
|
||
if (env("PASSWORD_ADMIN") == 'disabled') {
|
||
if ($this->userid == 1) {
|
||
throw new ApiException('当前环境禁止此操作');
|
||
}
|
||
}
|
||
if (env("PASSWORD_OWNER") == 'disabled') {
|
||
throw new ApiException('当前环境禁止此操作');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除会员
|
||
* @param $reason
|
||
* @return bool|null
|
||
*/
|
||
public function deleteUser($reason)
|
||
{
|
||
$ret = AbstractModel::transaction(function () use ($reason) {
|
||
// 删除原因
|
||
$userDelete = UserDelete::createInstance([
|
||
'operator' => User::userid(),
|
||
'userid' => $this->userid,
|
||
'email' => $this->email,
|
||
'reason' => $reason,
|
||
'cache' => array_merge($this->getRawOriginal(), [
|
||
'department_name' => $this->getDepartmentName()
|
||
])
|
||
]);
|
||
$userDelete->save();
|
||
// 删除未读
|
||
WebSocketDialogMsgRead::whereUserid($this->userid)->delete();
|
||
// 删除待办
|
||
WebSocketDialogMsgTodo::whereUserid($this->userid)->delete();
|
||
// 删除邮箱验证记录
|
||
UserEmailVerification::whereEmail($this->email)->delete();
|
||
//
|
||
return $this->delete();
|
||
});
|
||
return $ret;
|
||
}
|
||
|
||
/**
|
||
* 检查发送聊天内容前必须设置昵称、电话
|
||
* @return void
|
||
*/
|
||
public function checkChatInformation()
|
||
{
|
||
if ($this->bot) {
|
||
return;
|
||
}
|
||
$chatInformation = Base::settingFind('system', 'chat_information');
|
||
if ($chatInformation == 'required') {
|
||
if (empty($this->getRawOriginal('nickname'))) {
|
||
throw new ApiException('请设置昵称', [], -2);
|
||
}
|
||
if (empty($this->getRawOriginal('tel'))) {
|
||
throw new ApiException('请设置联系电话', [], -3);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** ***************************************************************************************** */
|
||
/** ***************************************************************************************** */
|
||
/** ***************************************************************************************** */
|
||
|
||
/**
|
||
* 注册会员
|
||
* @param $email
|
||
* @param $password
|
||
* @param array $other
|
||
* @return self
|
||
*/
|
||
public static function reg($email, $password, $other = [])
|
||
{
|
||
// 邮箱
|
||
if (!Base::isEmail($email)) {
|
||
throw new ApiException('请输入正确的邮箱地址');
|
||
}
|
||
$user = self::whereEmail($email)->first();
|
||
if ($user) {
|
||
$isRegVerify = Base::settingFind('emailSetting', 'reg_verify') === 'open';
|
||
if ($isRegVerify && $user->email_verity === 0) {
|
||
UserEmailVerification::userEmailSend($user);
|
||
throw new ApiException('您的帐号已注册过,请验证邮箱', ['code' => 'email']);
|
||
}
|
||
throw new ApiException('邮箱地址已存在');
|
||
}
|
||
// 密码
|
||
self::passwordPolicy($password);
|
||
// 开始注册
|
||
$user = Doo::userCreate($email, $password);
|
||
if ($other) {
|
||
$user->updateInstance($other);
|
||
}
|
||
$user->az = Base::getFirstCharter($user->nickname);
|
||
$user->pinyin = Base::cn2pinyin($user->nickname);
|
||
$user->created_ip = Base::getIp();
|
||
if ($user->save()) {
|
||
$setting = Base::setting('system');
|
||
$reg_identity = $setting['reg_identity'] ?: 'normal';
|
||
$all_group_autoin = $setting['all_group_autoin'] ?: 'yes';
|
||
// 注册临时身份
|
||
if ($reg_identity === 'temp') {
|
||
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['temp']), ['temp']));
|
||
$user->save();
|
||
}
|
||
// 加入全员群组
|
||
if ($all_group_autoin === 'yes') {
|
||
$dialog = WebSocketDialog::whereGroupType('all')->orderByDesc('id')->first();
|
||
$dialog?->joinGroup($user->userid, 0);
|
||
}
|
||
}
|
||
$createdUser = $user->find($user->userid);
|
||
if (!$createdUser->bot) {
|
||
// Manticore 索引同步
|
||
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
|
||
// 触发 user_onboard hook
|
||
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
|
||
}
|
||
return $createdUser;
|
||
}
|
||
|
||
/**
|
||
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||
* @param string $email
|
||
* @param string $password
|
||
* @param string $nickname
|
||
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
|
||
* @return self
|
||
* @throws ApiException
|
||
*/
|
||
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
|
||
{
|
||
$nickname = trim($nickname);
|
||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||
throw new ApiException('昵称需为2-20个字');
|
||
}
|
||
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
|
||
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
|
||
$profession = trim((string)($options['profession'] ?? ''));
|
||
// 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测)
|
||
self::assertValidProfession($profession);
|
||
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
|
||
// 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
|
||
$user = self::reg($email, $password, ['nickname' => $nickname]);
|
||
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
|
||
if (in_array('temp', $user->identity)) {
|
||
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
|
||
}
|
||
$user->changepass = $changePass; // 复用现有首登强制改密机制
|
||
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
|
||
if ($profession !== '') {
|
||
$user->profession = $profession;
|
||
}
|
||
if ($departmentIds) {
|
||
$user->department = Base::arrayImplode($departmentIds);
|
||
}
|
||
$user->save();
|
||
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
|
||
if ($departmentIds) {
|
||
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
|
||
foreach ($departments as $department) {
|
||
try {
|
||
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
|
||
$dialog->joinGroup([$user->userid], 0, true);
|
||
$dialog->pushMsg("groupJoin", null, [$user->userid]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
|
||
\Log::warning('createByAdmin: 部门入群失败', [
|
||
'userid' => $user->userid,
|
||
'department_id' => $department->id,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 将上传表格(Excel::toArray 的二维数组)归一化为导入行
|
||
* @param array $sheet
|
||
* @return array [{line, email, nickname, password}]
|
||
*/
|
||
public static function parseImportRows(array $sheet): array
|
||
{
|
||
$rows = [];
|
||
foreach ($sheet as $index => $cells) {
|
||
if ($index === 0) {
|
||
continue; // 表头
|
||
}
|
||
$email = trim((string)($cells[0] ?? ''));
|
||
$nickname = trim((string)($cells[1] ?? ''));
|
||
$password = trim((string)($cells[2] ?? ''));
|
||
$profession = trim((string)($cells[3] ?? ''));
|
||
if ($email === '' && $nickname === '' && $password === '') {
|
||
continue; // 空行(仅职位有值也视为空行跳过)
|
||
}
|
||
$rows[] = [
|
||
'line' => $index + 1, // 电子表格行号(从 1 开始)
|
||
'email' => $email,
|
||
'nickname' => $nickname,
|
||
'password' => $password,
|
||
'profession' => $profession,
|
||
];
|
||
}
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 校验单条导入行
|
||
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
|
||
* @return string|null 错误文案;null 表示通过
|
||
*/
|
||
public static function validateImportRow(array $row): ?string
|
||
{
|
||
$email = trim((string)($row['email'] ?? ''));
|
||
$nickname = trim((string)($row['nickname'] ?? ''));
|
||
$password = trim((string)($row['password'] ?? ''));
|
||
if ($email === '' || $nickname === '' || $password === '') {
|
||
return '邮箱、昵称、初始密码均为必填';
|
||
}
|
||
if (!Base::isEmail($email)) {
|
||
return '邮箱格式不正确';
|
||
}
|
||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||
return '昵称需为2-20个字';
|
||
}
|
||
try {
|
||
self::passwordPolicy($password);
|
||
} catch (ApiException $e) {
|
||
return $e->getMessage();
|
||
}
|
||
// 职位/职称选填,填写则校验 2-20 字
|
||
try {
|
||
self::assertValidProfession((string)($row['profession'] ?? ''));
|
||
} catch (ApiException $e) {
|
||
return $e->getMessage();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
|
||
* @param string $profession
|
||
* @return void
|
||
* @throws ApiException
|
||
*/
|
||
public static function assertValidProfession(string $profession): void
|
||
{
|
||
$profession = trim($profession);
|
||
if ($profession === '') {
|
||
return;
|
||
}
|
||
if (mb_strlen($profession) < 2) {
|
||
throw new ApiException('职位/职称不可以少于2个字');
|
||
}
|
||
if (mb_strlen($profession) > 20) {
|
||
throw new ApiException('职位/职称最多只能设置20个字');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
|
||
* @param mixed $ids
|
||
* @return int[]
|
||
* @throws ApiException
|
||
*/
|
||
public static function assertValidDepartments($ids): array
|
||
{
|
||
if (!is_array($ids)) {
|
||
$ids = [];
|
||
}
|
||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||
if (count($ids) > 10) {
|
||
throw new ApiException('最多只可加入10个部门');
|
||
}
|
||
if ($ids) {
|
||
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
|
||
if (count($existing) < count($ids)) {
|
||
throw new ApiException('修改部门不存在');
|
||
}
|
||
}
|
||
return $ids;
|
||
}
|
||
|
||
/**
|
||
* 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行)
|
||
* @param array $rows 每行含 email/nickname/password/profession,可选 department(int[])
|
||
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
|
||
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
|
||
* @throws ApiException 行数超限
|
||
*/
|
||
public static function importUsers(array $rows, bool $changePass = true): array
|
||
{
|
||
if (count($rows) > self::IMPORT_MAX) {
|
||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||
}
|
||
$success = 0;
|
||
$failed = [];
|
||
$seen = [];
|
||
foreach ($rows as $row) {
|
||
$error = self::validateImportRow($row);
|
||
if ($error === null) {
|
||
$emailLower = strtolower(trim((string)$row['email']));
|
||
if (isset($seen[$emailLower])) {
|
||
$error = '文件内邮箱重复';
|
||
} else {
|
||
$seen[$emailLower] = true;
|
||
}
|
||
}
|
||
if ($error === null) {
|
||
try {
|
||
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
|
||
'changePass' => $changePass,
|
||
'emailVerity' => !empty($row['email_verity']),
|
||
'department' => $row['department'] ?? [],
|
||
'profession' => $row['profession'] ?? '',
|
||
]);
|
||
$success++;
|
||
continue;
|
||
} catch (ApiException $e) {
|
||
$error = $e->getMessage();
|
||
}
|
||
}
|
||
$failed[] = [
|
||
'line' => $row['line'] ?? 0,
|
||
'email' => $row['email'] ?? '',
|
||
'reason' => $error,
|
||
];
|
||
}
|
||
return [
|
||
'total' => count($rows),
|
||
'success' => $success,
|
||
'failed' => $failed,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 批量导入预览(只解析+校验,不创建任何账号)
|
||
* 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
|
||
* @param array $rows parseImportRows 的输出
|
||
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
|
||
*/
|
||
public static function importPreview(array $rows): array
|
||
{
|
||
if (count($rows) > self::IMPORT_MAX) {
|
||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||
}
|
||
// 预查系统中已存在的邮箱(小写比较)
|
||
$emails = [];
|
||
foreach ($rows as $row) {
|
||
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||
if ($e !== '') {
|
||
$emails[$e] = true;
|
||
}
|
||
}
|
||
$existing = [];
|
||
if ($emails) {
|
||
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
|
||
$existing[strtolower($em)] = true;
|
||
}
|
||
}
|
||
$seen = [];
|
||
$valid = 0;
|
||
$list = [];
|
||
foreach ($rows as $row) {
|
||
$reason = self::validateImportRow($row);
|
||
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
|
||
if ($reason === null) {
|
||
if (isset($seen[$emailLower])) {
|
||
$reason = '文件内邮箱重复';
|
||
} else {
|
||
$seen[$emailLower] = true;
|
||
if (isset($existing[$emailLower])) {
|
||
$reason = '邮箱地址已存在';
|
||
}
|
||
}
|
||
}
|
||
$ok = $reason === null;
|
||
if ($ok) {
|
||
$valid++;
|
||
}
|
||
$list[] = [
|
||
'line' => $row['line'] ?? 0,
|
||
'email' => $row['email'] ?? '',
|
||
'nickname' => $row['nickname'] ?? '',
|
||
'password' => $row['password'] ?? '',
|
||
'profession' => $row['profession'] ?? '',
|
||
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
|
||
'status' => $ok ? 'ok' : 'error',
|
||
'reason' => $reason ?? '',
|
||
];
|
||
}
|
||
return [
|
||
'total' => count($rows),
|
||
'valid' => $valid,
|
||
'invalid' => count($rows) - $valid,
|
||
'rows' => $list,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取我的ID
|
||
* @return int
|
||
*/
|
||
public static function userid()
|
||
{
|
||
$user = self::authInfo();
|
||
if (!$user) {
|
||
return 0;
|
||
}
|
||
return $user->userid;
|
||
}
|
||
|
||
/**
|
||
* 获取我的昵称
|
||
* @return string
|
||
*/
|
||
public static function nickname()
|
||
{
|
||
$user = self::authInfo();
|
||
if (!$user) {
|
||
return '';
|
||
}
|
||
return $user->nickname;
|
||
}
|
||
|
||
/**
|
||
* 用户身份认证(获取用户信息)
|
||
* @param null $identity 判断身份
|
||
* @return self
|
||
*/
|
||
public static function auth($identity = null)
|
||
{
|
||
$user = self::authInfo();
|
||
if (!$user) {
|
||
$token = Base::token();
|
||
if ($token) {
|
||
UserDevice::forget($token);
|
||
throw new ApiException('身份已失效,请重新登录', [], -1);
|
||
} else {
|
||
throw new ApiException('请登录后继续...', [], -1);
|
||
}
|
||
}
|
||
if ($user->isDisable()) {
|
||
throw new ApiException('帐号已停用...', [], -1);
|
||
}
|
||
if ($identity) {
|
||
$user->identity($identity);
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 用户身份认证(获取用户信息)
|
||
* @return self|false
|
||
*/
|
||
private static function authInfo()
|
||
{
|
||
if (RequestContext::has('auth')) {
|
||
// 缓存
|
||
return RequestContext::get('auth');
|
||
}
|
||
if (Doo::userId() <= 0) {
|
||
// 没有登录
|
||
return RequestContext::save('auth', false);
|
||
}
|
||
if (Doo::userExpired()) {
|
||
// 登录过期
|
||
return RequestContext::save('auth', false);
|
||
}
|
||
if (!UserDevice::check()) {
|
||
// token 不存在
|
||
return RequestContext::save('auth', false);
|
||
}
|
||
$user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first();
|
||
if (!$user) {
|
||
// 登录信息不匹配
|
||
return RequestContext::save('auth', false);
|
||
}
|
||
|
||
// 更新登录信息
|
||
$upArray = [];
|
||
if (Base::getIp() && $user->line_ip != Base::getIp()) {
|
||
$upArray['line_ip'] = Base::getIp();
|
||
}
|
||
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
|
||
$upArray['line_at'] = Carbon::now();
|
||
}
|
||
$headerLanguage = RequestContext::get('header_language');
|
||
if (empty($user->lang) || $headerLanguage) {
|
||
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
|
||
$upArray['lang'] = $headerLanguage;
|
||
}
|
||
}
|
||
if ($upArray) {
|
||
$user->updateInstance($upArray);
|
||
$user->save();
|
||
}
|
||
return RequestContext::save('auth', $user);
|
||
}
|
||
|
||
/**
|
||
* 生成 token
|
||
* @param self $userinfo
|
||
* @param bool $refresh 获取新的token
|
||
* @return string
|
||
*/
|
||
public static function generateToken($userinfo, $refresh = false)
|
||
{
|
||
if (!$refresh) {
|
||
if (Doo::userId() != $userinfo->userid
|
||
|| Doo::userEmail() != $userinfo->email
|
||
|| Doo::userEncrypt() != $userinfo->encrypt) {
|
||
$refresh = true;
|
||
}
|
||
}
|
||
if ($refresh) {
|
||
$days = $userinfo->bot ? 0 : max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
|
||
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt, $days);
|
||
} else {
|
||
$token = Doo::userToken();
|
||
}
|
||
UserDevice::record($token);
|
||
unset($userinfo->encrypt);
|
||
unset($userinfo->password);
|
||
return $userinfo->token = $token;
|
||
}
|
||
|
||
/**
|
||
* 生成无设备的 token(主要用于接口调用,此 token 不检查设备是否存在)
|
||
* @param self $userinfo
|
||
* @param $ttl
|
||
* @return mixed
|
||
*/
|
||
public static function generateTokenNoDevice($userinfo, $ttl)
|
||
{
|
||
$key = 'user_token_no_device_' . $userinfo->userid;
|
||
return Cache::remember($key, $ttl, function () use ($userinfo, $ttl) {
|
||
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt);
|
||
Cache::put(UserDevice::ck(md5($token)), $userinfo->userid, $ttl);
|
||
return $token;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* userid 获取 基础信息
|
||
* @param int $userid 会员ID
|
||
* @return self
|
||
*/
|
||
public static function userid2basic($userid, $addField = [])
|
||
{
|
||
if (empty($userid)) {
|
||
return null;
|
||
}
|
||
$userid = intval($userid);
|
||
if (RequestContext::has("userid2basic_" . $userid)) {
|
||
return RequestContext::get("userid2basic_" . $userid);
|
||
}
|
||
$userInfo = self::whereUserid($userid)->select(array_merge(User::$basicField, $addField))->first();
|
||
if ($userInfo) {
|
||
$userInfo->online = $userInfo->getOnlineStatus();
|
||
$userInfo->department_name = $userInfo->getDepartmentName();
|
||
}
|
||
return RequestContext::save("userid2basic_" . $userid, $userInfo ?: []);
|
||
}
|
||
|
||
|
||
/**
|
||
* userid 获取 昵称
|
||
* @param $userid
|
||
* @return string
|
||
*/
|
||
public static function userid2nickname($userid)
|
||
{
|
||
return self::userid2basic($userid)?->nickname ?: '';
|
||
}
|
||
|
||
/**
|
||
* 是否需要验证码
|
||
* @param $email
|
||
* @return array
|
||
*/
|
||
public static function needCode($email)
|
||
{
|
||
$login_code = Base::settingFind('system', 'login_code');
|
||
switch ($login_code) {
|
||
case 'open':
|
||
return Base::retSuccess('need');
|
||
|
||
case 'close':
|
||
return Base::retError('no');
|
||
|
||
default:
|
||
if (Cache::get("code::" . $email) == 'need') {
|
||
return Base::retSuccess('need');
|
||
} else {
|
||
return Base::retError('no');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 临时帐号别名
|
||
* @return mixed|string
|
||
*/
|
||
public static function tempAccountAlias()
|
||
{
|
||
$alias = Base::settingFind('system', 'temp_account_alias');
|
||
return $alias ?: Doo::translate("临时帐号");
|
||
}
|
||
|
||
/**
|
||
* 获取头像
|
||
* @param $userid
|
||
* @param $userimg
|
||
* @param $email
|
||
* @param $nickname
|
||
* @return string
|
||
*/
|
||
public static function getAvatar($userid, $userimg, $email, $nickname)
|
||
{
|
||
// 自定义头像
|
||
if ($userimg && !str_contains($userimg, 'avatar/')) {
|
||
return Base::fillUrl($userimg);
|
||
}
|
||
// 机器人头像
|
||
switch ($email) {
|
||
case 'system-msg@bot.system':
|
||
return url("images/avatar/default_system.png");
|
||
case 'task-alert@bot.system':
|
||
return url("images/avatar/default_task.png");
|
||
case 'check-in@bot.system':
|
||
return url("images/avatar/default_checkin.png");
|
||
case 'anon-msg@bot.system':
|
||
return url("images/avatar/default_anon.png");
|
||
case 'approval-alert@bot.system':
|
||
return url("images/avatar/default_approval.png");
|
||
case 'okr-alert@bot.system':
|
||
return url("images/avatar/default_okr.png");
|
||
case 'ai-openai@bot.system':
|
||
return url("images/avatar/default_openai.png");
|
||
case 'ai-claude@bot.system':
|
||
return url("images/avatar/default_claude.png");
|
||
case 'ai-deepseek@bot.system':
|
||
return url("images/avatar/default_deepseek.png");
|
||
case 'ai-gemini@bot.system':
|
||
return url("images/avatar/default_gemini.png");
|
||
case 'ai-grok@bot.system':
|
||
return url("images/avatar/default_grok.png");
|
||
case 'ai-ollama@bot.system':
|
||
return url("images/avatar/default_ollama.png");
|
||
case 'ai-zhipu@bot.system':
|
||
return url("images/avatar/default_zhipu.png");
|
||
case 'bot-manager@bot.system':
|
||
return url("images/avatar/default_bot.png");
|
||
case 'meeting-alert@bot.system':
|
||
return url("images/avatar/default_meeting.png");
|
||
}
|
||
// 生成文字头像
|
||
if (self::$defaultAvatarMode === 'auto') {
|
||
return url("avatar/" . urlencode($nickname) . ".png");
|
||
}
|
||
// 系统默认头像
|
||
$name = ($userid - 1) % 21 + 1;
|
||
return url("images/avatar/default_{$name}.png");
|
||
}
|
||
|
||
/**
|
||
* 检测密码策略是否符合
|
||
* @param $password
|
||
* @return void
|
||
*/
|
||
public static function passwordPolicy($password)
|
||
{
|
||
if (strlen($password) < 6) {
|
||
throw new ApiException('密码设置不能小于6位数');
|
||
}
|
||
if (strlen($password) > 32) {
|
||
throw new ApiException('密码最多只能设置32位数');
|
||
}
|
||
// 复杂密码
|
||
$password_policy = Base::settingFind('system', 'password_policy');
|
||
if ($password_policy == 'complex') {
|
||
if (preg_match("/^[0-9]+$/", $password)) {
|
||
throw new ApiException('密码不能全是数字,请包含数字,字母大小写或者特殊字符');
|
||
}
|
||
if (preg_match("/^[a-zA-Z]+$/", $password)) {
|
||
throw new ApiException('密码不能全是字母,请包含数字,字母大小写或者特殊字符');
|
||
}
|
||
if (preg_match("/^[0-9A-Z]+$/", $password)) {
|
||
throw new ApiException('密码不能全是数字+大写字母,密码包含数字,字母大小写或者特殊字符');
|
||
}
|
||
if (preg_match("/^[0-9a-z]+$/", $password)) {
|
||
throw new ApiException('密码不能全是数字+小写字母,密码包含数字,字母大小写或者特殊字符');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取机器人或创建
|
||
* @param $key
|
||
* @param $update
|
||
* @param $userid
|
||
* @return self|null
|
||
*/
|
||
public static function botGetOrCreate($key, $update = [], $userid = 0)
|
||
{
|
||
$email = "{$key}@bot.system";
|
||
$botUser = self::whereEmail($email)->first();
|
||
if (empty($botUser)) {
|
||
$botUser = Doo::userCreate($email, Base::generatePassword(32));
|
||
if (empty($botUser)) {
|
||
return null;
|
||
}
|
||
$botUser->updateInstance([
|
||
'created_ip' => Base::getIp(),
|
||
]);
|
||
$botUser->save();
|
||
if ($userid > 0) {
|
||
UserBot::createInstance([
|
||
'userid' => $userid,
|
||
'bot_id' => $botUser->userid,
|
||
])->save();
|
||
}
|
||
//
|
||
if (empty($update['nickname'])) {
|
||
$update['nickname'] = UserBot::systemBotName($email);
|
||
}
|
||
}
|
||
if ($update) {
|
||
if (isset($update['nickname']) && $botUser->nickname != $update['nickname']) {
|
||
$botUser->az = Base::getFirstCharter($botUser->nickname);
|
||
$botUser->pinyin = Base::cn2pinyin($botUser->nickname);
|
||
}
|
||
$botUser->updateInstance($update);
|
||
$botUser->save();
|
||
}
|
||
return $botUser;
|
||
}
|
||
|
||
/**
|
||
* 是否机器人
|
||
* @param $userid
|
||
* @return bool
|
||
*/
|
||
public static function isBot($userid)
|
||
{
|
||
if (empty($userid)) {
|
||
return false;
|
||
}
|
||
// 这个不会有变化,所以可以使用永久缓存
|
||
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
|
||
return (bool)User::find($userid)?->bot;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 按关键词搜索用户(Scope)
|
||
* 支持:邮箱(含@)、用户ID(纯数字)、昵称/拼音/职业
|
||
*
|
||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||
* @param string $keyword 搜索关键词
|
||
* @return \Illuminate\Database\Eloquent\Builder
|
||
*/
|
||
public function scopeSearchByKeyword($query, string $keyword)
|
||
{
|
||
if (str_contains($keyword, "@")) {
|
||
// 包含 @ 按邮箱搜索
|
||
return $query->where("email", "like", "%{$keyword}%");
|
||
}
|
||
|
||
if (is_numeric($keyword)) {
|
||
// 纯数字:匹配用户ID 或 昵称/拼音/职业
|
||
return $query->where(function ($q) use ($keyword) {
|
||
$q->where("userid", intval($keyword))
|
||
->orWhere("nickname", "like", "%{$keyword}%")
|
||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||
->orWhere("profession", "like", "%{$keyword}%");
|
||
});
|
||
}
|
||
|
||
// 普通文本:搜索昵称/拼音/职业
|
||
return $query->where(function ($q) use ($keyword) {
|
||
$q->where("nickname", "like", "%{$keyword}%")
|
||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||
->orWhere("profession", "like", "%{$keyword}%");
|
||
});
|
||
}
|
||
}
|