Merge branch 'develop'

This commit is contained in:
mzl
2025-07-29 09:00:04 +08:00
parent a551507e4d
commit d74b374198
106 changed files with 6754 additions and 1346 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ class AuthController extends BaseController
/**
* 不需要头信息的方法
*/
protected $noNeedHeader = ['feishuAuth'];
protected $noNeedHeaderData = ['feishuAuth'];
protected $noNeedLogin = ['feishuScope', 'login', 'register', 'feishuAuth'];
+44
View File
@@ -10,10 +10,54 @@ use support\Request;
class BaseController
{
/**
* 响应处理、数据格式化处理
*/
use ResponseTrait, DataTransformer;
/**
* 验证器
* @var Validate
*/
protected mixed $validator = null;
/**
* @var Model
*/
protected $model = null;
/**
* 不登录不鉴权
* @var array
*/
protected $noNeedLogin = [];
/**
* 登录不鉴权
* @var array
*/
protected $noNeedAuth = [];
/**
* 不用头部数据
* @var array
*/
protected $noNeedHeaderData = [];
/**
* 数据限制
* null 不做限制,任何管理员都可以查看该表的所有数据
* auth 管理员能看到自己以及自己的子管理员插入的数据
* personal 管理员只能看到自己插入的数据
* @var string
*/
protected $dataLimit = null;
/**
* 数据限制字段
*/
protected $dataLimitField = 'admin_id';
/**
* 验证数据
*
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace app\common\controller;
use support\Request;
use support\Response;
use app\common\utils\FeishuMessage;
use app\inquiry\service\InquiryLogService;
use app\common\model\CrmCustomer;
use app\common\model\InquiryLog;
use app\common\model\User;
use support\exception\BusinessException;
class NotifyController
{
/**
* 不用头部数据
* @var array
*/
protected $noNeedHeaderData = ['feishuCallback', 'feishuEvent'];
/**
* 不登录不鉴权
* @var array
*/
protected $noNeedLogin = ['feishuCallback', 'feishuEvent'];
/**
* 飞书回调
* @param Request $request
* @return Response
*/
public function feishuCallback(Request $request)
{
try {
$param = $request->post();
// 解析数据
// $param = json_decode($param['event'], true);
$orderSn = $param['event']['action']['value'];
if (strpos($orderSn, '"') !== false) {
$orderSn = str_replace('"', '', $orderSn);
}
// 处理询单&客户线索信息
$openId = $param['event']['operator']['open_id'];
// 获取询单
$order = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('order_no', $orderSn)->first();
if (!$order) {
return $this->error('未找到询单记录');
}
// 获取客户xians
$customer = CrmCustomer::where('sn', $orderSn)->first();
if (!$customer) {
return $this->error('未找到客户线索');
}
// 获取用户
$uinfo = User::where('open_id', $openId)->first();
// 更新客户线索
$customer->user_id = $uinfo->id;
// 更新询单数据
$order->recipient = $uinfo->id;
$order->status = 2;
$order->t_time = time();
// 保存
if (!$order->save() || !$customer->save()) {
return $this->error('跟踪失败');
}
// 获取飞书消息模板
$feishuMessage = new FeishuMessage();
$content = $feishuMessage->groupUpdateTemplates(
(new InquiryLogService())->qualityName($order->quality),
$order->order_no,
$order->channelInfo ? $order->channelInfo->name : '',
$order->countryInfo ? $order->countryInfo->cn_addr : '',
$order->enter ? $order->enter->name : '',
$order->r_time ? date('Y-m-d H:i:s', $order->r_time) : '',
$order->t_time ? date('Y-m-d H:i:s', $order->t_time) : ''
);
// 组装飞书消息模板
$data = '{
"toast": {
"type": "success",
"content": "操作成功"
},
"card": {
"type": "raw",
"data": '.$content.'
}
}';
// 响应消息
return new Response(200, ['Content-Type' => 'application/json'], $data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function error($msg = '')
{
$data = '{
"toast": {
"type": "error",
"content": "'.$msg.'"
}
}';
return new Response(200, ['Content-Type' => 'application/json'], $data);
}
/**
* 飞书事件
* @param Request $request
* @return Response
*/
public function feishuEvent(Request $request)
{
try {
$data = $request->post();
var_dump('飞书事件回调:'.$data);
return json($data);
} catch (\Throwable $e) {
return json($e->getMessage());
}
}
}
+30 -2
View File
@@ -5,11 +5,15 @@ namespace app\common\controller;
use support\Request;
use app\common\service\CorpService;
use app\common\service\CorpFeishuAppService;
use app\common\service\SystemsService;
use app\common\service\UserService;
class PresetController extends BaseController
{
protected $noNeedLogin = ['corps', 'feishuApp'];
protected $noNeedLogin = ['corps', 'feishuApp', 'systems'];
protected $noNeedHeaderData = ['corps', 'feishuApp', 'systems'];
public function corps()
{
@@ -27,10 +31,34 @@ class PresetController extends BaseController
{
try {
$corpId = $request->get('corp_id');
$result = (new CorpFeishuAppService())->getFeishuApp($corpId);
$systemId = $request->get('system_id');
$result = (new CorpFeishuAppService())->getFeishuApp($corpId, $systemId);
return $this->success($result, '获取飞书应用信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function systems()
{
try {
$result = (new SystemsService())->getList();
return $this->success($result, '获取系统列表');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function users()
{
try {
$result = (new UserService())->getList();
return $this->success($result, '获取用户列表');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
@@ -12,6 +12,10 @@ class UploadController
use ResponseTrait;
protected $noNeedLogin = ['proxy'];
protected $noNeedHeaderData = ['proxy'];
public function upload(Request $request)
{
try {
+55 -1
View File
@@ -15,11 +15,65 @@ class UserController extends BaseController
{
try {
$uid = $request->user->uid;
$result = (new UserService())->getUserInfo($uid);
$systemCode = $request->system_code;
$result = (new UserService())->getUserInfo($uid, $systemCode);
return $this->success($result, '获取用户信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 用户信息
*/
public function getUser(Request $request)
{
try {
$uid = $request->user->uid;
$corp_abbr = $request->basic_data['corp_abbr'];
$result = (new UserService())->getUser($uid,$corp_abbr);
return $this->success($result, '获取用户信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 编辑用户
*/
public function edit(Request $request)
{
try {
$uid = $request->user->uid;
$data = $request->all();
$result = (new UserService())->edit($uid,$data);
unset($result['password']);
unset($result['union_id']);
unset($result['open_id']);
unset($result['tenant_key']);
return $this->success($result, '编辑成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 修改密码
* @param Request $request
* @return \support\Response
* @throws \Random\RandomException
*/
public function savePassword(Request $request)
{
try {
$uid = $request->user->uid;
$data = $request->all();
$result = (new UserService())->savePassword($uid,$data);;
return $this->success($result, '编辑成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
+61 -5
View File
@@ -4,6 +4,7 @@ namespace app\common\logic;
use app\common\model\Corp;
use app\common\model\CorpFeishuApp;
use app\common\model\Resource;
use app\common\model\UserTokenLog;
use app\common\model\Systems;
use app\common\model\User;
@@ -85,16 +86,23 @@ class AuthLogic
{
try {
$phone = $this->sanitizePhone($feishuUserInfo['mobile'] ?? '');
$user = User::where('phone', $phone)->where('corp_id', $feishuUserInfo['corp_id'])->first();
$email = $feishuUserInfo['enterprise_email'];
$user = User::where('corp_id', $feishuUserInfo['corp_id'])
->where(function($query) use ($phone, $email) {
$query->where('phone', $phone)
->orWhere('email', $email);
})
->first();
if (!$user) {
// 获取编号
$userSn = $this->getAvailableUserSn();
// 创建用户
$user = User::create([
'name' => $feishuUserInfo['en_name'],
'name' => $feishuUserInfo['name'],
'avatar' => $feishuUserInfo['avatar_middle'],
'phone' => $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''),
'email' => $feishuUserInfo['enterprise_email'],
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
@@ -111,12 +119,20 @@ class AuthLogic
// 是否登陆
$user->isLogin = false;
} else {
$user->update([
$updated = [
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
'tenant_key' => $feishuUserInfo['tenant_key']
]);
];
if ($phone && $phone == $user->phone) {
$updated['phone'] = $phone;
}
if ($phone && $phone == $user->phone) {
$updated['email'] = $email;
}
// 更新飞书信息
$user->update($updated);
}
return $user;
} catch (\Throwable $e) {
@@ -140,6 +156,7 @@ class AuthLogic
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
$homeUrl = $this->getHome($system->id);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
@@ -150,7 +167,8 @@ class AuthLogic
'expires_in' => config('jwt.jwt_expiration'),
'system_code' => $data['system_code'],
'corp_abbr' => $data['corp_abbr'],
'login_type' => $data['login_type']
'login_type' => $data['login_type'],
'home_url' => $homeUrl,
// 'payload' => $payload,
];
}
@@ -500,4 +518,42 @@ class AuthLogic
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
public function getHome($systemId)
{
$info = Resource::where('system_id', $systemId)
->where('is_home', 1)
->first();
if (!$info) {
return '/dashboard/console';
}
// 递归拼接 URL
$url = $this->buildParentUrl($info);
return $url ?: '/dashboard/console';
}
/**
* 递归构建父级 URL
* @param Resource $resource
* @return string
*/
protected function buildParentUrl(Resource $resource)
{
$url = $resource->url ?? '';
// 如果存在父级,则递归拼接
if ($resource->parent_id != 0) {
$parent = Resource::find($resource->parent_id);
if ($parent) {
$parentUrl = $this->buildParentUrl($parent);
$url = $parentUrl . ($url ? '/' . ltrim($url, '/') : '');
}
}
return $url;
}
}
-593
View File
@@ -1,593 +0,0 @@
<?php
namespace app\common\logic;
use app\common\model\Corp;
use app\common\model\CorpFeishuApp;
use app\common\model\UserTokenLog;
use app\common\model\Systems;
use app\common\model\User;
use app\common\model\UserFeishuApp;
use app\common\model\UserSn;
use app\common\utils\Feishu;
use app\common\utils\Jwt;
use support\Redis;
use support\exception\BusinessException;
use support\Log;
class AuthLogic_bak
{
/**
* 账号密码登录
*/
public function uPwdLogin(array $data): array
{
$user = $this->validateUser($data['account'], $data['password']);
$system = $this->validateSystem($data['system_code']);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 飞书登录
* 如果用户不存在则新增用户信息
*/
public function feishuLogin(array $data, bool $isLogin = true): array
{
$feishuUserInfo = $this->getFeishuUserInfo($data);
$user = $this->getOrCreateUser($feishuUserInfo);
$system = $this->validateSystem($data['system_code']);
if ($user->isLogin === true) {
$this->validateSystemPermission($user, $system);
}
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 扫码登录
*/
public function qrCodeLogin(array $data): array
{
$userId = $this->validateQrCodeToken($data['qr_code_token']);
$user = User::find($userId);
if (!$user || $user->status != 1) {
throw new BusinessException('用户无效', 403);
}
$system = $this->validateSystem($data['system_code']);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 注册
*/
public function register(array $data)
{
// 获取可用用户编号
$sn = $this->getAvailableUserSn();
$data['sn'] = $sn->user_sn;
// 对密码进行哈希处理
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
// 获取企业ID
$corpInfo = $this->validateCorp($data['corp_abbr']);
$data['corp_id'] = $corpInfo->id;
// 创建用户记录
$user = $this->createUser($data);
// 更新用户编号的用户 ID
$this->updateUserId($user->id, $sn->user_sn);
// 注册后如果直接登录(返回token)
$system = $this->validateSystem($data['system_code']);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'user' => $user,
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
public function updateUserId($userId, $userSn)
{
$updated = UserSn::where('user_sn', $userSn)
->update(['user_id' => $userId]);
if ($updated === 0) {
throw new BusinessException('用户编号不存在或更新失败');
}
return $updated;
}
/**
* 获取可用的用户编号
*
* @return \app\common\model\UserSn 用户编号模型实例
* @throws BusinessException 当未找到适用编号时抛出
*/
private function getAvailableUserSn(): \app\common\model\UserSn
{
$userSnModel = new UserSn();
$sn = $userSnModel->where('user_id', '=', 0)->inRandomOrder()->first();
if (!$sn) {
Log::error('用户注册时未找到适用编号');
throw new BusinessException('未找到适用编号');
}
return $sn;
}
/**
* 创建用户记录
*
* @param array $data 包含用户信息的数组
* @return \app\common\model\User 用户模型实例
*/
private function createUser(array $data): \app\common\model\User
{
try {
return User::create($data);
} catch (\Throwable $e) {
Log::error('用户注册时创建用户记录失败', [
'data' => $data,
'error' => $e->getMessage()
]);
throw new BusinessException('用户注册失败,请稍后重试', 500);
}
}
/**
* 刷新token
* @param string $refreshToken
* @param string|null $systemCode 可选,指定系统
* @return array
*/
public function refreshToken(string $refreshToken, ?string $systemCode = null): array
{
if (!$systemCode) throw new BusinessException('缺少system_code', 422);
// 校验refresh_token
$userId = Redis::get("refresh_token_map:{$refreshToken}:{$systemCode}");
if (!$userId) {
throw new BusinessException('refresh_token无效或已过期', 401);
}
$validToken = Redis::get("refresh_token:{$userId}:{$systemCode}");
if ($validToken !== $refreshToken) {
throw new BusinessException('refresh_token无效或已过期', 401);
}
$user = User::find($userId);
if (!$user || $user->status != 1) {
throw new BusinessException('用户无效', 403);
}
$system = $this->validateSystem($systemCode);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$this->recordUserToken($user->id, $token, $systemCode);
return [
'access_token' => $token,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 记录用户token到 Redis 和 MySQL
*/
protected function recordUserToken(int $userId, string $token, string $systemCode)
{
$request = request();
$ip = $request ? $request->getRealIp() : '';
$ua = $request ? $request->header('user-agent', '') : '';
$now = time();
$expireAt = $now + config('jwt.jwt_expiration');
// Redis Set 用户token集合
Redis::sAdd("user_tokens:{$userId}:{$systemCode}", $token);
Redis::expire("user_tokens:{$userId}:{$systemCode}", config('jwt.jwt_expiration'));
// Redis Hash token元信息
$meta = [
'system_code' => $systemCode,
'login_ip' => $ip,
'user_agent' => $ua,
'login_time' => $now,
'expired_at' => $expireAt,
];
Redis::hMSet("user_token_meta:{$token}", $meta);
Redis::expire("user_token_meta:{$token}", config('jwt.jwt_expiration'));
// 可选:MySQL 持久化
try {
UserTokenLog::create([
'user_id' => $userId,
'token' => $token,
'system_code'=> $systemCode,
'login_ip' => $ip,
'user_agent' => $ua,
'login_time' => $now,
'expired_at' => $expireAt,
'is_valid' => 1,
]);
} catch (\Throwable $e) {
Log::error('Token log write failed: '.$e->getMessage());
}
}
/**
* 校验token有效性
*/
public function verifyToken(string $token, string $systemCode): array
{
if (!$systemCode) throw new BusinessException('缺少system_code', 422);
$payload = (new Jwt())->verifyToken($token);
// 校验黑名单
if (Redis::has("token_blacklist:{$token}")) {
throw new BusinessException('token已失效', 403);
}
// 强制校验aud(系统隔离)
if ($systemCode && $payload['aud'] != $systemCode && $payload['aud'] != ($payload['system_code'] ?? '')) {
throw new BusinessException('token不属于当前系统', 403);
}
return (array)$payload;
}
/**
* 登出/失效token
*/
public function logout(?string $token, ?string $systemCode = null)
{
if ($token) {
Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration'));
$payload = null;
try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {}
$userId = $payload['uid'] ?? null;
$aud = $payload['aud'] ?? null;
$sc = $systemCode ?? $aud;
if ($userId && $sc) {
Redis::sRem("user_tokens:{$userId}:{$sc}", $token);
}
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
}
/**
* 强制拉黑token
*/
public function blockToken(string $token, ?string $systemCode = null)
{
if (!$token) {
throw new BusinessException('token不能为空', 422);
}
Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration'));
$payload = null;
try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {}
$userId = $payload['uid'] ?? null;
$aud = $payload['aud'] ?? null;
$sc = $systemCode ?? $aud;
if ($userId && $sc) {
Redis::sRem("user_tokens:{$userId}:{$sc}", $token);
}
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
/**
* 获取用户所有活跃token及元信息(按system_code)
*/
public function getUserTokens(int $userId, string $systemCode): array
{
$tokens = Redis::sMembers("user_tokens:{$userId}:{$systemCode}") ?: [];
$result = [];
foreach ($tokens as $token) {
$meta = Redis::hGetAll("user_token_meta:{$token}");
$result[] = [
'token' => $token,
'meta' => $meta,
];
}
return $result;
}
/**
* 强制某用户所有token全部下线
*/
public function blockAllTokensOfUser(int $userId, string $systemCode)
{
$tokens = Redis::sMembers("user_tokens:{$userId}:{$systemCode}") ?: [];
foreach ($tokens as $token) {
$this->blockToken($token, $systemCode);
}
}
/** ----------- 内部方法 ------------- **/
/**
* 用户验证
*/
private function validateUser(string $account, string $password)
{
$user = User::byAccount($account)->first();
if (!$user || !password_verify($password, $user->password)) {
throw new BusinessException('账号或密码错误', 401);
}
if ($user->status != 1) {
throw new BusinessException('账号已禁用', 403);
}
return $user;
}
/**
* 系统验证
*/
private function validateSystem(string $systemCode)
{
$system = Systems::where('code', $systemCode)->first();
if (!$system) {
throw new BusinessException('系统不存在', 403);
}
return $system;
}
/**
* 公司验证
*/
private function validateCorp(string $corpAbbr)
{
$corp = Corp::where('abbr', $corpAbbr)->first();
if (!$corp) {
throw new BusinessException('公司不存在', 403);
}
return $corp;
}
/**
* 飞书应用信息
*/
private function validateFeishuApp(int $corpId, string $systemCode)
{
$feishuAppInfo = CorpFeishuApp::where('corp_id', $corpId)->where('system_code', $systemCode)->first();
if (!$feishuAppInfo) {
throw new BusinessException('飞书应用不存在', 403);
}
return $feishuAppInfo;
}
/**
* 用户系统权限校验
*/
private function validateSystemPermission(User $user, Systems $system)
{
if ($system->is_role) {
$systemIds = $user->systems->pluck('id')->toArray();
if (!in_array($system->id, $systemIds)) {
throw new BusinessException('无该系统权限', 403);
}
}
}
/**
* JWT payload 构建
* 可扩展 payload 字段
*/
private function buildJwtPayload(User $user, ?Systems $system = null): array
{
$now = time();
$exp = $now + config('jwt.jwt_expiration');
$system_ids = $user->systems ? $user->systems->pluck('id')->toArray() : [];
$role_ids = $user->roles ? $user->roles->pluck('id')->toArray() : [];
return [
'sub' => 'user_' . $user->id,
'aud' => $system->code,
'iat' => $now,
'nbf' => $now,
'exp' => $exp,
'typ' => 'access',
'uid' => $user->id,
'role_ids' => $role_ids,
'system_ids' => $system_ids
];
}
/**
* JWT签发
*/
private function generateAccessToken(array $payload)
{
return (new Jwt())->generateToken($payload);
}
/**
* 刷新令牌生成
*/
private function generateRefreshToken(int $userId, string $systemCode)
{
$refreshToken = bin2hex(random_bytes(32));
// user_id+system_code只允许一个refresh_token生效
Redis::set("refresh_token:{$userId}:{$systemCode}", $refreshToken, config('jwt.jwt_refresh_token_expiration'));
// 建立refresh_token到user_id+system_code映射,便于校验
Redis::set("refresh_token_map:{$refreshToken}:{$systemCode}", $userId, config('jwt.jwt_refresh_token_expiration'));
return $refreshToken;
}
/**
* 验证扫码令牌
*/
private function validateQrCodeToken(string $qrCodeToken)
{
$userId = Redis::get("qr_code_token:{$qrCodeToken}");
if (!$userId) {
throw new BusinessException('扫码令牌无效或已过期', 401);
}
return $userId;
}
/**
* 获取飞书用户信息
*/
private function getFeishuUserInfo(array $data)
{
// 获取凭证
$certificate = $this->feishuAppInfo($data['corp_abbr'], $data['system_code']);
// 获取token
$feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], $certificate['abbr']);
$userAccessToken = $feishu->userAccessToken($data['feishu_code'], $certificate['r_url']);
// 获取用户信息
$userInfo = $feishu->userInfo($userAccessToken['access_token']);
if ($userInfo['code'] != 0) {
throw new BusinessException('授权失败,请重试');
}
return $userInfo['data'] + $data + $certificate;
}
/**
* 查找或创建飞书用户
*/
private function getOrCreateUser(array $feishuUserInfo)
{
$userFeishuApp = UserFeishuApp::firstOrCreate([
'name' => $feishuUserInfo['en_name'],
'mobile' => $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''),
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
'tenant_key' => $feishuUserInfo['tenant_key']
]);
if ($userFeishuApp->uid) {
$user = User::find($userFeishuApp->uid);
$user->isLogin = true;
return $user;
} else {
$user = User::where('phone', $userFeishuApp->mobile)->first();
$user->isLogin = true;
if (!$user) {
$user = $this->createUserByFeishu($feishuUserInfo, $userFeishuApp);
$user->isLogin = false;
}
$userFeishuApp->uid = $user->id;
$userFeishuApp->corp_id = $feishuUserInfo['corp_id'];
if (!$userFeishuApp->save()) {
throw new BusinessException('绑定用户飞书记录失败');
}
return $user;
}
}
/**
* 过滤和格式化手机号
*/
private function sanitizePhone($mobile)
{
return isset($mobile) ? str_replace('+86', '', $mobile) : '';
}
/**
* 创建飞书用户
*/
private function createUserByFeishu(array $feishuUserInfo, UserFeishuApp $userFeishuApp)
{
// 1. 获取适用的用户编号
$userSn = UserSn::where('user_id', '=', 0)->inRandomOrder()->first();
if (!$userSn) {
throw new BusinessException('未找到适用编号');
}
// 2. 创建用户
$user = new User();
$user->sn = $userSn->user_sn;
$user->name = $feishuUserInfo['en_name'];
$user->avatar = $feishuUserInfo['avatar_middle'] ?? '';
$user->password = password_hash($userSn->user_sn, PASSWORD_DEFAULT);
$user->phone = $userFeishuApp->mobile;
$user->email = '';
$user->system_code = $feishuUserInfo['system_code'];
$user->corp_id = $feishuUserInfo['corp_id'];
if (!$user->save()) {
throw new BusinessException('用户创建失败');
}
// 更新使用状态
$this->updateUserId($user->id, $userSn->user_sn);
return $user;
}
/**
* 飞书应用信息
*/
public function feishuAppInfo(string $corpAbbr, string $systemCode)
{
$corpInfo = $this->validateCorp($corpAbbr);
$fsAppInfo = $this->validateFeishuApp($corpInfo->id, $systemCode);
return [
'app_id' => $fsAppInfo->app_id,
'app_secret' => $fsAppInfo->app_secret,
'r_url' => $fsAppInfo->r_url,
'scope' => $fsAppInfo->scopes,
'state' => $fsAppInfo->state,
'abbr' => $corpAbbr,
'corp_id' => $corpInfo->id
];
}
}
-86
View File
@@ -1,86 +0,0 @@
<?php
namespace app\common\logic;
use Illuminate\Database\Eloquent\Model;
use support\exception\BusinessException;
class BaseLogic
{
/**
* 获取列表
*/
public function getList(array $params, string $modelClass)
{
$query = $modelClass::query();
// 你可以在此处添加分页、排序等功能
if (isset($params['page']) && isset($params['per_page'])) {
$query->paginate($params['per_page'], ['*'], 'page', $params['page']);
}
return $query->get();
}
/**
* 获取单个记录
*/
public function getDetail(int $id, string $modelClass)
{
$record = $modelClass::find($id);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
return $record;
}
/**
* 创建记录
*/
public function create(array $data, string $modelClass)
{
$model = new $modelClass;
$model->fill($data); // 根据字段填充数据
try {
$model->save();
} catch (\Throwable $e) {
throw new BusinessException("创建记录失败: " . $e->getMessage(), 500);
}
return $model;
}
/**
* 更新记录
*/
public function update(array $data, string $modelClass)
{
$record = $modelClass::find($data['id']);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
$record->update($data);
return $record;
}
/**
* 删除记录
*/
public function delete(int $id, string $modelClass)
{
$record = $modelClass::find($id);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
try {
$record->delete();
} catch (\Throwable $e) {
throw new BusinessException("删除记录失败: " . $e->getMessage(), 500);
}
return true;
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ class CorpFeishuAppLogic
{
public function getFeishuApp($corpId)
public function getFeishuApp($corpId, $systemId)
{
return CorpFeishuApp::where('corp_id', $corpId)->first(['id', 'corp_id', 'name', 'app_id', 'r_url', 'state']);
return CorpFeishuApp::where('corp_id', $corpId)->where('system_id', $systemId)->first(['id', 'corp_id', 'name', 'app_id', 'r_url', 'state']);
}
}
+7
View File
@@ -2,10 +2,17 @@
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\Corp;
class CorpLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Corp::class;
public function getList()
{
+162 -14
View File
@@ -4,14 +4,17 @@ namespace app\common\logic;
use app\common\model\User;
use app\common\model\Resource;
use app\common\model\Systems;
class PermLogic
{
/**
* 获取用户所有资源(合并角色+直接分配),并加入公共菜单,排序按sort字段
*/
public function getUserResources(int $userId)
public function getUserResources(int $userId, string $systemCode)
{
$system = Systems::where('code', $systemCode)->first();
// 获取用户及其关联角色资源和直接分配的资源
$user = User::with(['roles.resources', 'resources'])->find($userId);
@@ -24,9 +27,10 @@ class PermLogic
// 合并角色资源,排除不需要权限认证的资源
foreach ($user->roles as $role) {
$all = $all->merge(
$role->resources->filter(function ($resource) {
$role->resources->filter(function ($resource) use($system) {
// var_dump($resource->toArray());
// 只保留需要权限认证的资源(或公共资源)
return !$resource->no_auth;
return !$resource->no_auth && $system->id == $resource->system_id;
})
);
}
@@ -52,14 +56,159 @@ class PermLogic
return $all->unique('id')->values()->sortBy('sort');
}
/**
* 构建目录/菜单树,节点包含权限与打开方式,拼接完整路径,并对子级菜单component处理
* @param \Illuminate\Support\Collection $resources
* @param int $parentId
* @param string $parentPath
* 构建目录/菜单树
*
* @param \Illuminate\Support\Collection $resources 全部资源集合
* @param int $parentId 当前父级资源 ID
* @param string $parentPath 父级完整路径(用于递归)
* @return array
*/
public function buildTree_bak($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
foreach ($resources as $node) {
// 只处理目录和菜单,不把 permission 当作菜单节点
if ($node->parent_id !== $parentId || $node->type === 'permission') {
continue;
}
// —— 拼接当前节点的完整路径 ——
$currentPath = rtrim($parentPath, '/');
if (!empty($node->url)) {
$urlSegment = ltrim($node->url, '/');
$currentPath = $currentPath === ''
? $urlSegment
: "{$currentPath}/{$urlSegment}";
}
// —— 递归构建子节点 ——
// 这里只会继续收目录和菜单,permission 永远不会进入此方法
$children = $this->buildTree($resources, $node->id, $currentPath);
// —— 本级权限列表 ——
$authList = $resources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])
->values()
->all();
// —— 判断是否有子目录或子菜单 ——
$hasChildrenMenu = ! empty($children);
// —— 计算 component 字段 ——
if ($hasChildrenMenu) {
if ($node->type === 'menu' && !empty($node->url)) {
// menu 类型且自身有 url,用自身 url(加前导 slash)
$componentValue = '/' . ltrim($node->url, '/');
} else {
// 其它含子项的节点,固定 Layout 组件
$componentValue = '/index/index';
}
} else {
// 叶子节点:拼接路径,确保以 slash 开头
$componentValue = $currentPath !== ''
? '/' . ltrim($currentPath, '/')
: '';
}
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'component' => $componentValue,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
'isHide' => $node->is_hide == 0 ? false : true,
'isHideTab' => $node->is_hide_tab == 0 ? false : true
],
'children' => $children
];
}
return $tree;
}
public function buildTree_bak2($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
foreach ($resources as $node) {
if ($node->parent_id !== $parentId) {
continue;
}
// —— 拼接当前节点的完整路径 ——
$currentPath = $parentPath;
// 去掉末尾多余 slash
$currentPath = rtrim($currentPath, '/');
if (!empty($node->url)) {
// 如果当前节点有 url,则拼接
$urlSegment = ltrim($node->url, '/');
$currentPath = $currentPath === ''
? $urlSegment
: "{$currentPath}/{$urlSegment}";
}
// —— 递归构建子节点 ——
$children = [];
if ($node->type === 'directory') {
$children = $this->buildTree($resources, $node->id, $currentPath);
}
// —— 收集本级的 permission 权限列表 ——
$authList = $resources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])
->values()
->all();
// —— 判断是否有子菜单/目录 ——
$hasChildrenMenu = $resources
->where('parent_id', $node->id)
->whereIn('type', ['directory', 'menu'])
->isNotEmpty();
// —— 计算 component 字段 ——
if ($hasChildrenMenu) {
// 只要有子项,就一律显示顶层 Layout 组件
$componentValue = '/index/index';
} else {
// 叶子节点:如果定义了 url,就使用拼接后的路径;否则用占位符(这里用空串,你可替换)
$componentValue = $currentPath !== '' ? $currentPath : '';
}
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'component' => $componentValue,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
],
'children' => $children,
];
}
return $tree;
}
public function buildTree($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
@@ -99,10 +248,7 @@ class PermLogic
$hasChildrenMenu = collect($resources)->where('parent_id', $node->id)
->whereIn('type', ['directory', 'menu'])->isNotEmpty();
// component 字段规则:
// 1. 存在子菜单,固定 '/index/index'
// 2. 否则为当前完整路径(或其它你想要的)
$componentValue = ($hasChildrenMenu && !$node->url && $node->type !== 'directory') ? '/index/index' : $currentPath;
$componentValue = $hasChildrenMenu ? '/index/index' : $currentPath;
$tree[] = [
'id' => $node->id,
@@ -115,6 +261,8 @@ class PermLogic
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
'isHide' => $node->is_hide == 0 ? false : true,
'isHideTab' => $node->is_hide_tab == 0 ? false : true
],
'children' => $children,
];
@@ -125,9 +273,9 @@ class PermLogic
/**
* 获取用户所有权限code
*/
public function getPermissionCodes(int $userId): array
public function getPermissionCodes(int $userId, string $systemCode): array
{
$resources = $this->getUserResources($userId);
$resources = $this->getUserResources($userId, $systemCode);
return $resources
->filter(fn($res) => $res->type === 'permission')
->pluck('code')
+2 -81
View File
@@ -8,86 +8,7 @@ use support\Log;
class RoleLogic
{
/**
* 获取角色列表
*/
public function getRoleList(array $params)
{
// 假设我们需要根据system_id进行过滤,其他过滤条件也可以添加
$query = Role::query();
if (isset($params['system_id'])) {
$query->where('system_id', $params['system_id']);
}
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
// 添加分页
$roles = $query->paginate($params['per_page'] ?? 15);
return $roles;
}
/**
* 获取角色详情
*/
public function getRoleDetail(int $roleId)
{
$role = Role::find($roleId);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
return $role;
}
/**
* 创建角色
*/
public function createRole(array $data)
{
$role = new Role();
$role->fill($data); // 根据字段填充数据
try {
$role->save();
} catch (\Throwable $e) {
Log::error('创建角色失败:'.$e->getMessage());
throw new BusinessException('创建角色失败', 500);
}
return $role;
}
/**
* 更新角色
*/
public function updateRole(array $data)
{
$role = Role::find($data['id']);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
$role->update($data); // 更新字段
return $role;
}
/**
* 删除角色
*/
public function deleteRole(int $roleId)
{
$role = Role::find($roleId);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
try {
$role->delete();
} catch (\Throwable $e) {
Log::error('删除角色失败:'.$e->getMessage());
throw new BusinessException('删除角色失败', 500);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\Systems;
class SystemsLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Systems::class;
public function getList()
{
return Systems::where('status', 1)->orderBy('sort', 'asc')->get(['id', 'name', 'code', 'type', 'type']);
}
}
+44
View File
@@ -24,4 +24,48 @@ class UserLogic
return $user;
}
/**
* 用户信息
*/
public function edit(int $uid, array $data)
{
$user = User::find($uid);
if (!$user) {
throw new BusinessException('账户不存在', 404);
}
$user->fill($data);
$user->save();
return $user;
}
/**
* 修改密码
*/
public function savePassword(int $uid, array $data)
{
$password = password_hash($data['password_new'], PASSWORD_DEFAULT);
$res = User::where('id',$uid)->update(['password' => $password]);
return $res;
}
/**
* 获取用户列表
*/
public function getList()
{
$list = User::where('status', 1)->orderBy('id', 'desc')->get();
$arr = [];
foreach ($list->toArray() as $item) {
$arr[] = [
'name' => $item['name'],
'id' => $item['id'],
];
}
return $arr;
}
}
+11 -1
View File
@@ -46,4 +46,14 @@ class Corp extends Model
public $timestamps = true;
}
protected $dateFormat = 'U';
protected $fillable = [
'name',
'abbr',
'tenant_key',
'sort',
'status'
];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class Country extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'country';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'cn_addr', 'en_addr', 'en_full_name', 'two_letter', 'three_letter', 'num_code', 'steering', 'c_time', 'u_time'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class CrmCustomer extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_customer';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'sn', 'corp_abbr', 'user_id', 'user_sn', 'type', 'grade', 'sex', 'channel_id', 'country_id', 'car_type_id', 'magnitude', 'email', 'phone', 'WhatsApp', 'contacts', 'describe', 'desc_collect', 'address', 'lat', 'lng', 'follow_last', 'follow_next', 'warn', 'follow_remark', 'enter_user_id', 'created_at', 'updated_at', 'deleted_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class InquiryChannel extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_channel';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'name', 'sort', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace app\common\model;
use support\Model;
use app\common\model\User;
use app\common\model\Country;
class InquiryLog extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_log';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = [
'id', 'order_no', 'enter_user_id', 'recipient',
'quality', 'channel', 'email', 'tel', 'whatsapp',
'country', 'contacts', 'content', 'status',
'r_time', 't_time', 'created_at', 'updated_at'
];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
public function enter()
{
return $this->hasOne(User::class, 'id', 'enter_user_id');
}
public function recipient()
{
return $this->hasOne(User::class, 'id', 'recipient');
}
public function channelInfo()
{
return $this->hasOne(InquiryChannel::class, 'id', 'channel');
}
public function countryInfo()
{
return $this->hasOne(Country::class, 'id', 'country');
}
}
+9 -1
View File
@@ -43,6 +43,14 @@ class Role extends Model
public $timestamps = true;
protected $dateFormat = 'U';
protected $fillable = [
'system_id',
'name',
'desc',
'sort',
'status'
];
// 角色下所有用户
public function users()
@@ -53,6 +61,6 @@ class Role extends Model
// 角色拥有的资源(权限点、菜单)
public function resources()
{
return $this->belongsToMany(Resource::class, 'role_resources', 'role_id', 'resource_id')->orderBy('sort', 'asc');
return $this->belongsToMany(Resource::class, 'role_resources', 'role_id', 'resource_id');
}
}
+11 -26
View File
@@ -4,21 +4,6 @@ namespace app\common\model;
use support\Model;
/**
* systems 系统/应用表
* @property integer $id 系统ID(主键)
* @property string $code 系统编码(唯一英文标识)
* @property string $name 系统名称
* @property string $type 系统类型
* @property string $desc 系统描述
* @property string $icon 系统图标
* @property string $domain_url 系统URL
* @property string $is_role 是否区分角色权限
* @property integer $sort 排序
* @property integer $status 状态
* @property integer $created_at 创建时间
* @property integer $updated_at 更新时间
*/
class Systems extends Model
{
/**
@@ -50,15 +35,15 @@ class Systems extends Model
public $timestamps = true;
// 系统下的资源
public function resources()
{
return $this->hasMany(Resource::class, 'system_id', 'id');
}
protected $dateFormat = 'U';
// 拥有该系统访问权限的用户
public function users()
{
return $this->belongsToMany(User::class, 'user_systems', 'system_id', 'user_id');
}
}
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'code', 'name', 'type', 'desc', 'icon', 'domain_url', 'is_role', 'sort', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+7 -2
View File
@@ -54,6 +54,7 @@ class User extends Model
'sn',
'name',
'avatar',
'sex',
'password',
'phone',
'email',
@@ -63,7 +64,11 @@ class User extends Model
'union_id',
'user_id',
'open_id',
'tenant_key'
'tenant_key',
'country_id',
'position',
'socialize',
'socialize_pic',
];
// 用户所有角色
@@ -91,4 +96,4 @@ class User extends Model
->orWhere('phone', $account)
->orWhere('email', $account);
}
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
namespace app\common\model;
use support\Model;
/**
* user_feishu_app 用户-飞书应用
* @property integer $id ID(主键)
* @property integer $uid 用户ID
* @property integer $corp_id 公司ID
* @property string $name 名称
* @property string $mobile 手机
* @property string $union_id ISV的唯一标识
* @property string $user_id 用户 user_id
* @property string $open_id 应用内的唯一标识
* @property string $tenant_key 当前企业标识
* @property integer $sort 排序
* @property integer $status 状态
* @property integer $created_at 创建时间
* @property integer $updated_at 更新时间
*/
class UserFeishuApp_bak extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'user_feishu_app';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
protected $fillable = [
'uid',
'corp_id',
'name',
'mobile',
'union_id',
'user_id',
'open_id',
'tenant_key',
'sort',
'status'
];
}
-9
View File
@@ -86,13 +86,4 @@ class AuthService
{
(new AuthLogic())->blockToken($token, $systemCode);
}
// public function perms()
// {
// $permService = new PermService();
// $menuTree = $permService->getMenuTree($uid);
// $permissionCodes = $permService->getPermissionCodes($uid);
// return (new PermService())->getMenuTree($this->userId)
// }
}
-155
View File
@@ -1,155 +0,0 @@
<?php
namespace app\common\service;
use app\common\logic\AuthLogic;
use app\common\model\User;
use support\Cache;
class AuthService_bak
{
// 缓存 Key
protected static function cacheKey(int $userId): string
{
return "user_resources_{$userId}";
}
/**
* 获取用户所有资源(目录/菜单/权限),合并角色+直接分配,并缓存
*/
public static function getUserResources(int $userId)
{
$cacheKey = self::cacheKey($userId);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$user = User::with(['roles.resources', 'resources'])->find($userId);
if (!$user) {
return collect();
}
// 合并:角色资源 + 用户直接资源
$all = collect();
foreach ($user->roles as $role) {
$all = $all->merge($role->resources);
}
$all = $all->merge($user->resources);
$unique = $all->unique('id')->values();
Cache::set($cacheKey, $unique, 3600);
return $unique;
}
/**
* 构建目录/菜单树,并给每个菜单节点挂载权限列表和打开方式
*/
public static function getMenuTree(int $userId): array
{
$resources = self::getUserResources($userId);
$menus = $resources
->filter(fn($r) => in_array($r->type, ['directory','menu']))
->values();
return self::buildTree($menus, $resources);
}
protected static function buildTree($nodes, $allResources, int $parentId = 0): array
{
$tree = [];
foreach ($nodes as $node) {
if ($node->parent_id !== $parentId) continue;
// 子节点
$children = self::buildTree($nodes, $allResources, $node->id);
// 本级权限列表
$authList = $allResources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])->values()->all();
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
// 新增打开方式字段
'openType' => $node->open_type, // 'internal' | 'external' | 'iframe'
],
'children' => $children,
];
}
return $tree;
}
/**
* 获取用户所有权限 code(仅 type=permission)
*/
public static function getPermissionCodes(int $userId): array
{
return self::getUserResources($userId)
->filter(fn($res) => $res->type === 'permission')
->pluck('code')
->unique()
->values()
->all();
}
/** 清除缓存(角色/资源变更时调用) */
public static function clearCache(int $userId): void
{
Cache::delete(self::cacheKey($userId));
}
public function login(array $data): array
{
$loginType = $data['login_type'] ?? 'upwd';
$logic = new AuthLogic();
return match ($loginType) {
'feishu' => $logic->feishuLogin($data),
'qrcode' => $logic->qrCodeLogin($data),
default => $logic->uPwdLogin($data),
};
}
/**
* 用户注册
*
* @param array $data
* @return User
*/
public function register(array $data): User
{
return (new AuthLogic())->register($data);
}
public function verifyToken(string $token): array
{
return (new AuthLogic())->verifyToken($token);
}
public function refreshToken(string $refreshToken): array
{
return (new AuthLogic())->refreshToken($refreshToken);
}
public function logout(?string $token)
{
(new AuthLogic())->logout($token);
}
public function blockToken(string $token)
{
(new AuthLogic())->blockToken($token);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class BasicDataService
public function checkAndCacheBaseParams($loginType, $systemCode, $corpAbbr)
{
if (!$loginType && !$systemCode && !$corpAbbr) {
throw new BusinessException(htmlpage());
throw new BusinessException('header缺少必要参数');
}
// 检查并缓存登录类型
+2 -2
View File
@@ -14,8 +14,8 @@ class CorpFeishuAppService
$this->logic = (new CorpFeishuAppLogic());
}
public function getFeishuApp(int $corpId)
public function getFeishuApp(int $corpId, int $systemId)
{
return $this->logic->getFeishuApp($corpId);
return $this->logic->getFeishuApp($corpId, $systemId);
}
}
+11 -11
View File
@@ -15,23 +15,23 @@ class PermService
}
// 用户资源缓存key
protected static function cacheKey(int $userId): string
protected static function cacheKey(int $userId, string $systemCode): string
{
return "user_resources_{$userId}";
return "user_resources_{$userId}_{$systemCode}";
}
/**
* 获取用户所有资源(合并角色+直接分配),结果缓存
*/
public function getUserResources(int $userId)
public function getUserResources(int $userId, string $systemCode)
{
$cacheKey = self::cacheKey($userId);
$cacheKey = self::cacheKey($userId, $systemCode);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
// 调用 Logic 层获取资源
$resources = $this->logic->getUserResources($userId);
$resources = $this->logic->getUserResources($userId, $systemCode);
// 缓存结果
Cache::set($cacheKey, $resources, 3600);
return $resources;
@@ -40,26 +40,26 @@ class PermService
/**
* 构建目录/菜单树,节点包含权限与打开方式
*/
public function getMenuTree(int $userId): array
public function getMenuTree(int $userId, string $systemCode): array
{
// 调用 Logic 层处理菜单构建
$resources = $this->getUserResources($userId);
$resources = $this->getUserResources($userId, $systemCode);
return $this->logic->buildTree($resources);
}
/**
* 获取用户所有权限code
*/
public function getPermissionCodes(int $userId): array
public function getPermissionCodes(int $userId, string $systemCode): array
{
return $this->logic->getPermissionCodes($userId);
return $this->logic->getPermissionCodes($userId, $systemCode);
}
/**
* 清除资源相关缓存
*/
public function clearCache(int $userId): void
public function clearCache(int $userId, string $systemCode): void
{
Cache::delete(self::cacheKey($userId));
Cache::delete(self::cacheKey($userId, $systemCode));
}
}
-55
View File
@@ -1,55 +0,0 @@
<?php
namespace app\common\service;
use app\common\logic\RoleLogic;
class RoleService
{
protected $roleLogic;
public function __construct()
{
$this->roleLogic = (new RoleLogic());
}
/**
* 获取角色列表
*/
public function getRoleList(array $params)
{
return $this->roleLogic->getRoleList($params);
}
/**
* 获取角色详情
*/
public function getRoleDetail(int $roleId)
{
return $this->roleLogic->getRoleDetail($roleId);
}
/**
* 创建角色
*/
public function createRole(array $data)
{
return $this->roleLogic->createRole($data);
}
/**
* 更新角色
*/
public function updateRole(array $data)
{
return $this->roleLogic->updateRole($data);
}
/**
* 删除角色
*/
public function deleteRole(int $roleId)
{
return $this->roleLogic->deleteRole($roleId);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace app\common\service;
use app\common\logic\SystemsLogic;
class SystemsService
{
protected $logic;
public function __construct()
{
$this->logic = (new SystemsLogic());
}
/**
* 获取系统list
*/
public function getList()
{
return $this->logic->getList();
}
}
+74 -3
View File
@@ -3,6 +3,11 @@
namespace app\common\service;
use app\common\logic\UserLogic;
use app\common\model\Corp;
use app\common\model\User;
use app\common\utils\CrudUtil;
use app\crm\model\Country;
use support\exception\BusinessException;
class UserService
{
@@ -17,12 +22,12 @@ class UserService
/**
* 获取用户信息(包括菜单和权限)
*/
public function getUserInfo(int $uid)
public function getUserInfo(int $uid, string $systemCode)
{
$user = $this->logic->userInfo($uid);
$permService = new PermService();
$menuTree = $permService->getMenuTree($uid);
$permissionCodes = $permService->getPermissionCodes($uid);
$menuTree = $permService->getMenuTree($uid, $systemCode);
$permissionCodes = $permService->getPermissionCodes($uid, $systemCode);
return [
'user' => [
'id' => $user->id,
@@ -37,4 +42,70 @@ class UserService
'permissions' => $permissionCodes
];
}
/**
* 获取用户信息(包括菜单和权限)
*/
public function getUser(int $uid, $corp_abbr)
{
$user = $this->logic->userInfo($uid);
$corp_name = Corp::where('abbr', $corp_abbr)->value('name');
$ossservice = new OssService();
return [
'id' => $user->id,
'name' => $user->name,
'position' => $user->position,
'socialize' => $user->socialize,
'socialize_pic' => $user->socialize_pic,
'socialize_pic_full' => (!empty($user->socialize_pic))?$ossservice->generatePrivateUrl($user->socialize_pic):'',
'country_id' => $user->country_id,
'country_txt' => Country::where('id',$user->country_id)->value('cn_addr'),
'avatar' => $user->avatar,
'avatar_full' => (!empty($user->avatar))?$ossservice->generatePrivateUrl($user->avatar):'',
'phone' => $user->phone,
'corp_name' => $corp_name,
'email' => $user->email,
'status' => $user->status,
'roles' => $user->roles->pluck('name')
];
}
/**
* 编辑用户信息
*/
public function edit(int $uid, array $data)
{
unset($data['password']);
unset($data['phone']);
unset($data['email']);
unset($data['sn']);
$user = $this->logic->edit($uid,$data);
return $user;
}
/**
* 修改用户密码
*/
public function savePassword(int $uid, array $data)
{
// 对密码进行哈希处理
$password = $data['password'];
$user = User::find($uid);
if (!password_verify($password, $user->password)) {
throw new BusinessException('密码错误');
}
$user = $this->logic->savePassword($uid,$data);
return $user;
}
/**
* 获取用户列表
*/
public function getList()
{
return $this->logic->getList();
}
}
+80 -64
View File
@@ -1,47 +1,51 @@
<?php
namespace app\common\traits;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* 数据转换器 Trait
* 用于将原始数据(如模型、集合、分页等)统一转换为数组格式,
* 并支持字段级与结果级的灵活后置加工。
*/
trait DataTransformer
{
/**
* 核心方法:对任意数据进行转换并返回数组
* 核心转换方法
*
* @param mixed $rawData 原始数据,可为 Paginator / Model / Collection / array 等
* @param callable|null $postProcessor 可选的后置处理回调,接受处理完的数组再返回新的数组
* @param callable|null $fieldFormatter 可选的字段命名转换回调,如 snake_case
* @param mixed $rawData 原始数据:Model、Collection、Paginator、数组等
* @param array $opts 可选配置:
* - 'fieldFormatter' => callable 字段名格式化回调,如 fn($f)=>snake_case($f)
* - 'postFormatter' => callable 结果级加工回调,接收整个数组并返回
*
* @return array
* @return array 转换并加工后的纯 PHP 数组
*/
protected function transformData(
mixed $rawData,
?callable $postProcessor = null,
?callable $postFormatter = null,
?callable $fieldFormatter = null
): array {
try {
$fieldFormatter = $fieldFormatter ?: fn($field) => $field;
if ($rawData instanceof LengthAwarePaginator) {
$formatted = $this->formatPaginated($rawData, $postProcessor, $fieldFormatter);
$arr = $this->formatPaginated($rawData, $postFormatter, $fieldFormatter);
} elseif ($rawData instanceof Model) {
$formatted = $this->formatModel($rawData, $fieldFormatter);
$arr = $this->formatModel($rawData, $fieldFormatter);
} elseif ($rawData instanceof Collection) {
$formatted = $this->formatCollection($rawData, $fieldFormatter);
$arr = $this->formatCollection($rawData, $fieldFormatter);
} elseif (is_array($rawData)) {
$formatted = $this->formatArray($rawData, $fieldFormatter);
$arr = $this->formatArray($rawData, $fieldFormatter);
} else {
$formatted = (array) $rawData;
$arr = (array)$rawData;
}
if (is_callable($postProcessor)) {
$formatted = $postProcessor($formatted);
if (is_callable($postFormatter)) {
$arr = $postFormatter($arr);
}
return $formatted;
return $arr;
} catch (\Throwable $e) {
return [
'error' => true,
@@ -52,84 +56,96 @@ trait DataTransformer
}
/**
* 格式化分页数据:将每条记录也进行 transformData 递归处理
* 格式化分页数据:转换每条记录
*
* @param LengthAwarePaginator $paginator
* @param callable|null $postProcessor
* @param callable $fieldFormatter
* @return array
* @param LengthAwarePaginator $p
* @param callable $fmt
* @return array{records: array, total: int, current: int, size: int}
*/
protected function formatPaginated(
LengthAwarePaginator $paginator,
?callable $postProcessor,
callable $fieldFormatter
LengthAwarePaginator $p,
?callable $postFormatter,
callable $fmt
): array {
$list = $paginator->getCollection()->map(function ($item) use ($postProcessor, $fieldFormatter) {
return $this->transformData($item, $postProcessor, $fieldFormatter);
})->toArray();
$records = $p->getCollection()
->map(fn($item) => $this->transformData($item, $postFormatter, $fmt))
->toArray();
return [
'records' => $list,
'total' => $paginator->total(),
'current' => $paginator->currentPage(),
'size' => $paginator->perPage()
'records' => $records,
'total' => $p->total(),
'current' => $p->currentPage(),
'size' => $p->perPage(),
];
}
/**
* 格式化 Eloquent 模型:递归嵌套关联
* 格式化 Eloquent 模型:主表属性 + 关联递归
*
* @param Model $model
* @param callable $fieldFormatter
* @param callable $fmt
* @return array
*/
protected function formatModel(Model $model, callable $fieldFormatter): array
{
$formatted = [];
// 主表字段
protected function formatModel(
Model $model,
callable $fmt
): array {
$out = [];
// 1) 主表字段
foreach ($model->getAttributes() as $key => $value) {
$formatted[$fieldFormatter($key)] = $value;
$out[$fmt($key)] = $value;
}
// 关联数据,递归嵌套
foreach ($model->getRelations() as $relation => $relationData) {
if ($relationData instanceof Model) {
// 单个关联模型
$formatted[$relation] = $this->formatModel($relationData, $fieldFormatter);
} elseif ($relationData instanceof Collection) {
// 关联集合
$formatted[$relation] = $relationData
->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
// 2) 关联递归
foreach ($model->getRelations() as $rel => $relData) {
if ($relData instanceof Model) {
$out[$rel] = $this->formatModel($relData, $fmt);
} elseif ($relData instanceof Collection) {
$out[$rel] = $relData
->map(fn($item) => $this->transformData($item, null, $fmt))
->toArray();
} else {
$out[$rel] = $relData;
}
}
return $formatted;
return $out;
}
/**
* 格式化集合
* 格式化集合数据:对每个元素递归调用 transformData
*
* @param Collection $collection
* @param callable $fieldFormatter
* @param Collection $c
* @param callable $fmt
* @return array
*/
protected function formatCollection(Collection $collection, callable $fieldFormatter): array
{
return $collection->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
->toArray();
protected function formatCollection(
Collection $c,
callable $fmt
): array {
return $c
->map(fn($item) => $this->transformData($item, null, $fmt))
->toArray();
}
/**
* 格式化普通数组
* 格式化普通数组:递归处理可能出现的模型或集合
*
* @param array $array
* @param callable $fieldFormatter
* @param array $arr
* @param callable $fmt
* @return array
*/
protected function formatArray(array $array, callable $fieldFormatter): array
{
return array_map(fn($item) => $this->transformData($item, null, $fieldFormatter), $array);
protected function formatArray(
array $arr,
callable $fmt
): array {
return array_map(function($item) use ($fmt) {
if (is_array($item)
|| $item instanceof Model
|| $item instanceof Collection
|| $item instanceof LengthAwarePaginator
) {
return $this->transformData($item, null, $fmt);
}
return $item;
}, $arr);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace app\common\traits;
use support\Request;
trait FormatKvTrait
{
/**
* 主入口:一行把前端扁平化参数变成完整的查询配置
*
* @param Request $request
* @param string $modelClass Eloquent 模型类名
* @return array{
* model: string,
* where: array,
* order: array<string,string>,
* fields: string[],
* include: string[]
* }
*/
protected function buildQueryConfig(Request $request, string $modelClass): array
{
$all = $request->all();
return [
'model' => $modelClass,
'where' => $this->parseFilters($all),
'order' => $this->parseSort($all['sort'] ?? ''),
'fields' => $this->parseCsv($all['fields'] ?? ''),
'include' => $this->parseCsv($all['include'] ?? ''),
];
}
/**
* 把除 sort/fields/include/page/limit 以外的所有参数
* 智能转换为 where 条件
*
* 支持:
* - 等值:foo=bar
* - NOT:foo=!bar 或 foo=!bar1,bar2
* - 范围:foo=2025-07-01,2025-07-15 → between
* - 列表:foo=a,b,c → in
* - NULL:foo=null
* - NOT NULL:foo=!null
* - 通配符:foo=xia* → like 'xia%'
*
* 关联字段直接写 relation.field=xxx
*/
protected function parseFilters(array $params): array
{
$where = [];
$reserved = ['sort','fields','include','page','limit'];
foreach ($params as $key => $val) {
if (in_array($key, $reserved, true)) {
continue;
}
// NULL / NOT NULL
if ($val === 'null') {
$where[$key] = ['is null'];
continue;
}
if ($val === '!null') {
$where[$key] = ['is not null'];
continue;
}
// NOT equal or NOT IN
if (str_starts_with($val, '!')) {
$v = substr($val, 1);
if (str_contains($v, ',')) {
$list = array_map('trim', explode(',', $v));
$where[$key] = ['not in', $list];
} else {
$where[$key] = ['<>', $this->cast($v)];
}
continue;
}
// BETWEEN or IN
if (str_contains($val, ',')) {
$parts = array_map('trim', explode(',', $val));
if (count($parts) === 2 && $this->isRangeValue($parts[0], $parts[1])) {
$where[$key] = ['between', $parts];
} else {
$where[$key] = ['in', $this->castArray($parts)];
}
continue;
}
// LIKE
if (str_contains($val, '*') || str_contains($val, '%')) {
$pattern = str_replace('*', '%', $val);
$where[$key] = ['like', $pattern];
continue;
}
// 默认 EQ
$where[$key] = $this->cast($val);
}
return $where;
}
/**
* 解析 sort=id,-created_at → ['id'=>'asc','created_at'=>'desc']
*/
protected function parseSort(string $sort): array
{
$order = [];
foreach (explode(',', $sort) as $piece) {
$f = trim($piece);
if ($f === '') {
continue;
}
if ($f[0] === '-') {
$order[substr($f, 1)] = 'desc';
} else {
$order[$f] = 'asc';
}
}
return $order;
}
/**
* 逗号分割成数组
*/
protected function parseCsv(string $csv): array
{
if ($csv === '') {
return [];
}
return array_filter(array_map('trim', explode(',', $csv)));
}
/** 简单类型转换:全数字→int/float,其余保留原字符串 */
protected function cast(string $v): mixed
{
if (is_numeric($v)) {
return strpos($v, '.') !== false ? (float)$v : (int)$v;
}
return $v;
}
/** 批量类型转换 */
protected function castArray(array $arr): array
{
return array_map(fn($v) => $this->cast($v), $arr);
}
/** 判断两端是否可做 BETWEEN(数字或 YYYY-MM-DD 日期) */
protected function isRangeValue(string $a, string $b): bool
{
return (is_numeric($a) && is_numeric($b))
|| (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $b));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace app\common\traits;
use think\Validate;
use support\Request;
use support\exception\BusinessException;
trait RequestTrait
{
/**
* 验证数据
*
* @param Validate $validator 验证器实例
* @param array $data 待验证的数据
* @param string $scene 验证场景
* @return array 验证后的数据
* @throws BusinessException 验证失败时抛出业务异常
*/
public function validate(Validate $validator, array $data, string $scene = ''): array
{
// 如果指定了验证场景,并且验证器支持场景方法,则设置验证场景
if ($scene && method_exists($validator, 'scene')) {
$validator->scene($scene);
}
// 执行数据验证
if (!$validator->check($data)) {
// 验证失败,抛出业务异常,异常信息为验证器返回的错误信息
throw new BusinessException($validator->getError());
}
// 验证成功,返回原始数据
return $data;
}
}
@@ -0,0 +1,148 @@
<?php
namespace app\common\traits\crud;
use support\Request;
/**
* CRUD 操作控制器 Trait
* 提供通用的增删改查控制器方法,通过调用 service 层实现业务逻辑解耦。
*/
trait CrudControllerTrait
{
/**
* 获取列表
*
* @param Request $request 请求对象
* @return mixed 返回分页数据及总数
*/
public function index(Request $request)
{
try {
$raw = $this->service->serviceList($request);
$data = $this->transformData($raw, method_exists($this, 'indexFormat')
? [$this, 'indexFormat']
: null);
return $this->success($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 获取详情
*
* @param Request $request 请求对象
* @param int|string $id 资源 ID
* @return mixed 返回资源详情数据
*/
public function show(Request $request)
{
try {
$raw = $this->service->serviceShow($request);
$data = $this->transformData($raw, method_exists($this, 'showFormat')
? [$this, 'showFormat']
: null);
return $this->success($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 创建
*
* @param Request $request 请求对象
* @return mixed 返回创建成功的数据或信息
*/
public function store(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'store');
$res = $this->service->serviceStore($request, $data);
return $this->success($res, '创建成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 更新
*
* @param Request $request 请求对象
* @return mixed 返回更新成功的数据或信息
*/
public function update(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
$res = $this->service->serviceUpdate($request, $data);
return $this->success([], '更新成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 删除
*
* @param Request $request 请求对象
* @return mixed 返回删除操作结果
*/
public function destroy(Request $request)
{
try {
$count = $this->service->serviceDestroy($request);
return $this->success([], "成功删除了 {$count} 条");
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// ----------------------------------------------------------------
// 以下三个钩子方法,**在使用此 Trait 的 Controller** 中实现
// ----------------------------------------------------------------
/**
* 字段名格式化:例如全部下划线
*
* @param string $field
* @return string
*/
// protected function fieldFormatter(string $field): string
// {
// return snake_case($field);
// }
/**
* 字段级后置加工规则
*
* @return array<string,callable>
* 键为 “点号嵌套”或 “通配符” 字段路径,
* 值为 fn($value, $row)=>mixed
*/
// protected function getFieldTransforms(): array
// {
// return [
// 'name' => fn($v,$row)=>strtoupper($v),
// 'orders.*.total' => fn($v,$row)=>number_format($v,2),
// ];
// }
/**
* 结果级后置加工:接收整个数组,可任意增删改
*
* @param array $data
* @return array
*/
// protected function postFormat(array $data): array
// {
// // 例如重命名 records 为 items
// if (isset($data['records'])) {
// $data['items'] = $data['records'];
// unset($data['records']);
// }
// return $data;
// }
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace app\common\traits\crud;
use app\common\utils\CrudUtil;
/**
* Trait CrudLogicTrait
*
* 提供最基础的 CRUD 和复杂查询方法,基于 CrudUtil。
* 使用此 Trait 的类,须定义:
* protected string $modelClass = \App\common\model\YourModel::class;
*/
trait CrudLogicTrait
{
/**
* 分页列表
*/
public function logicList(array $config, int $limit, int $page)
{
$config['model'] = $this->modelClass;
return CrudUtil::paginate(CrudUtil::buildQuery($config), $limit, $page);
}
/**
* 全量列表
*/
public function logicListAll(array $config)
{
$config['model'] = $this->modelClass;
return CrudUtil::getAll(CrudUtil::buildQuery($config));
}
/**
* 查询单条
*/
public function logicShow(array $config)
{
return CrudUtil::findOneBy(CrudUtil::buildQuery($config));
}
/**
* 创建(可带 dataLimit)
*/
public function logicCreate(array $data, array $dataLimit = [])
{
return CrudUtil::create($this->modelClass, $data, $dataLimit);
}
/**
* 任意条件更新(可带 dataLimit)
*/
public function logicUpdateBy(array $config, array $data)
{
return CrudUtil::updateBy(CrudUtil::buildQuery($config), $data);
}
/**
* 任意条件删除(可带 dataLimit)
*/
public function logicDeleteBy(array $config)
{
return CrudUtil::deleteBy(CrudUtil::buildQuery($config));
}
/**
* 复杂查询回调
*/
public function logicComplex(callable $callback)
{
return CrudUtil::executeComplex($this->modelClass, $callback);
}
/**
* 根据主键查询
*/
public function findByPk(int $id, array $with = [])
{
return CrudUtil::findByPk($this->modelClass, $id, $with);
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace app\common\traits\crud;
use support\Request;
/**
* Trait CrudServiceTrait
*
* 提供标准的增删改查 Service 接口,依赖 CrudLogicTrait。
* 使用此 Trait 的 Service 类,须:
* use CrudLogicTrait, CrudServiceTrait;
* protected string $modelClass = \App\common\model\YourModel::class;
*/
trait CrudServiceTrait
{
use CrudLogicTrait;
/** 具体 Service 里定义:protected string $modelClass = YourModel::class; */
/**
* 获取验证器
*/
public function getValidator()
{
return $this->validator;
}
/** 分页列表 */
public function serviceList(Request $request)
{
if (method_exists($this, 'complexQuery')) {
$result = $this->complexQuery($request->get());
} else {
$config = $this->buildQueryConfig($request, 'index');
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$result = $this->logicList($config, $limit, $page);
}
return $this->postProcess('List', $result);
}
/** 全量列表 */
public function serviceListAll(Request $request)
{
if (method_exists($this, 'complexQuery')) {
$result = $this->complexQuery($request->get());
} else {
$config = $this->buildQueryConfig($request, 'default');
$result = $this->logicListAll($config);
}
return $this->postProcess('List', $result);
}
/** 查看单条 */
public function serviceShow(Request $request)
{
$config = $this->buildQueryConfig($request, 'show');
$item = $this->logicShow($config);
return $this->postProcess('Show', $item);
}
/** 创建 */
public function serviceStore(Request $request, array $data = [])
{
$data = $this->preProcess('Store', $data, $request);
$dl = $this->getDataLimitFromRequest($request);
$created = $this->logicCreate($data, $dl);
return $this->postProcess('Store', $created);
}
/** 更新 */
public function serviceUpdate(Request $request, array $data = [])
{
$data = $this->preProcess('Update', $data, $request);
$config = $this->buildQueryConfig($request, 'update');
$updated = $this->logicUpdateBy($config, $data);
return $this->postProcess('Update', $updated);
}
/** 删除 */
public function serviceDestroy(Request $request)
{
$config = $this->buildQueryConfig($request, 'destroy');
$deleted = $this->logicDeleteBy($config);
return $this->postProcess('Destroy', $deleted);
}
/** ----------------- 公共辅助方法 ----------------- */
/**
* 构造 CrudUtil 查询配置
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['where']['status'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'index':
$config['where']['status'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
$config['where']['status'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
/** 解析 withFields 字符串 "rel:field,field;..." */
protected function parseWithFields(string $str): array
{
if (!$str) {
return [];
}
$out = [];
foreach (explode(';', $str) as $pair) {
[$rel, $flds] = explode(':', $pair);
$out[$rel] = explode(',', $flds);
}
return $out;
}
/** 从请求读取 dataLimitType, dataLimitField */
protected function getDataLimitFromRequest(Request $req): array
{
$type = $req->get('dataLimitType');
if (!$type) {
return [];
}
return [
'type' => $type,
'field' => $req->get('dataLimitField', 'admin_id'),
];
}
/**
* $data 前置处理
*
* @param array $data
* @param Request $request
* @return array
*/
protected function preProcess(string $op, mixed $data, Request $request): mixed
{
$hook = "beforeService{$op}";
return method_exists($this, $hook)
? $this->{$hook}($data, $request)
: $data;
}
/**
* 根据操作类型,调用对应的后置钩子
*
* @param string $op 操作:List, Show, Store, Update, Destroy
* @param mixed $data 原始返回值
* @return mixed 处理后的返回值
*/
protected function postProcess(string $op, mixed $data): mixed
{
$hook = "afterService{$op}";
return method_exists($this, $hook)
? $this->{$hook}($data)
: $data;
}
/**
* 处理复杂查询
* 覆盖此方法,实现复杂查询逻辑
*/
// protected function complexQuery(array $params): array
// {
// throw new \LogicException('请在子类实现 complexQuery');
// }
}
+431
View File
@@ -0,0 +1,431 @@
<?php
namespace app\common\utils;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use plugin\admin\app\common\Auth;
use support\exception\BusinessException;
/**
* 构建查询工具类
* | 方法 | 说明 |
* | ------------------------------- | --------------------- |
* | `buildQuery($config)` | 构建动态查询(关联加载/字段/过滤/排序) |
* | `paginate($q,$per,$page)` | 分页返回 Paginator |
* | `getAll($q)` | 不分页直接 `get()` |
* | `findByPk($model,$id)` | 根据主键查询单条,找不到抛 404 |
* | `findOneBy($model,$where)` | 根据任意条件查询单条,找不到抛 404 |
* | `deleteByPk($model,$ids)` | 根据主键删除(可批量),返回删除条数 |
* | `deleteBy($model,$where)` | 根据任意条件删除,返回删除条数 |
* | `updateByPk($model,$id,$data)` | 根据主键更新,找不到抛 404 |
* | `updateBy($model,$where,$data)` | 根据任意条件更新,返回影响行数 |
*/
class CrudUtil
{
/**
* 构建查询,支持:
* - 本表字段过滤 where
* - 关联加载 with
* - 关联表字段选择 withFields
* - 关联表字段过滤 where with dot-notation
* - 排序 order
* - 本表字段选择 fields
*
* $config = [
* 'model' => User::class,
* 'where' => [
* 'status' => 1,
* 'name' => ['like','%张%'],
* 'roles.name' => '管理员', // 关联表字段过滤
* 'posts.created_at'=> ['>','2025-01-01'],
* ],
* 'order' => ['id'=>'desc'],
* 'fields' => ['id','name','email'],
* 'with' => ['roles','posts'],
* 'withFields' => [
* 'roles' => ['id','name'], // 关联表字段选择
* 'posts' => ['id','title','user_id'],
* ],
* ];
*/
public static function buildQuery(array $config): Builder
{
/** @var Model $model */
$model = new $config['model'];
$q = $model->newQuery();
// eager-load:支持 ['rel','rel2'] 和 ['rel'=>fields[], ...]
if (! empty($config['with'])) {
foreach ($config['with'] as $rel => $fields) {
if (is_int($rel)) {
// 普通关联
$q->with($fields);
} else {
// 带字段筛选的关联
$q->with([$rel => function ($qr) use ($fields) {
$key = $qr->getModel()->getKeyName();
if (! in_array($key, $fields, true)) {
$fields[] = $key;
}
$qr->select($fields);
}]);
}
}
}
// 表字段
if (!empty($config['fields'])) {
$q->select($config['fields']);
}
// where
if (!empty($config['where'])) {
foreach ($config['where'] as $col => $val) {
if (strpos($col, '.') !== false) {
// 多层支持
$segments = explode('.', $col);
$leaf = array_pop($segments);
self::applyNestedWhere($q, $segments, $leaf, $val);
} else {
self::applyWhere($q, $col, $val);
}
}
}
// or where
if (!empty($config['orWhere'])) {
$q->where(function($q) use($config) {
foreach ($config['orWhere'] as $cond) {
[$col,$op,$val] = $cond;
$q->orWhere($col,$op,$val);
}
});
}
// 排序
if (!empty($config['order'])) {
foreach ($config['order'] as $col => $dir) {
$q->orderBy($col, strtolower($dir) === 'asc' ? 'asc' : 'desc');
}
}
// 数据权限过滤
if (!empty($config['dataLimit'])) {
self::applyDataLimit($q, $config['dataLimit']);
}
return $q;
}
/**
* 分页查询
*
* @param Builder $query
* @param int $perPage
* @param int $page
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public static function paginate(Builder $q, int $perPage, int $page)
{
return $q->paginate($perPage, ['*'], 'page', $page);
}
/**
* 非分页查询
*
* @param Builder $query
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getAll(Builder $q)
{
return $q->get();
}
/**
* 执行复杂查询——仅在显式调用时生效
*
* @param string $modelClass 模型类
* @param callable $callback function(Builder $q) { ... }
* @return mixed 由 callback 返回的结果
*/
public static function executeComplex(string $modelClass, callable $cb)
{
/** @var Model $model */
$model = new $modelClass;
return $cb($model->newQuery());
}
/**
* 主键单条查询
*
* @param string $modelClass
* @param mixed $id
* @param array $with
* @return Model
* @throws BusinessException
*/
public static function findByPk(string $modelClass, $id, array $with = []): Model
{
$m = $modelClass::with($with)->find($id);
if (!$m) {
throw new BusinessException("记录 ID={$id} 不存在", 404);
}
return $m;
}
/**
* 任意条件单条查询,支持 dataLimit
*
* @param Builder $query
* @return Model
* @throws BusinessException
*/
public static function findOneBy(Builder $q): Model
{
return $q->first();
}
/**
* 创建,支持 dataLimit
*
* @param string $modelClass
* @param array $data
* @param array $dataLimit
* @return Model
* @throws BusinessException
*/
public static function create(
string $modelClass,
array $data,
array $dataLimit = []
): Model {
// dataLimit 检查/注入
if (!empty($dataLimit)) {
self::applyDataLimitOnMutate($data, $dataLimit);
}
return $modelClass::create($data);
}
/**
* 主键更新,支持 dataLimit
*
* @param string $modelClass
* @param mixed $id
* @param array $data
* @param array $dataLimit
* @return Model
* @throws BusinessException
*/
public static function updateByPk(
string $modelClass,
$id,
array $data,
array $dataLimit = []
): Model {
/** @var Model $m */
$m = $modelClass::find($id);
if (!$m) {
throw new BusinessException("记录 ID={$id} 不存在", 404);
}
// dataLimit 校验
if (!empty($dataLimit)) {
self::applyDataLimitOnMutate($m, $dataLimit, $data);
}
$m->fill($data)->save();
return $m;
}
/**
* 非主键条件更新(批量),支持 dataLimit
*
* @param Builder $query
* @param array $data
* @return int 受影响行数
*/
public static function updateBy(Builder $q, array $data): int
{
return $q->update($data);
}
/**
* 主键删除(支持批量)
*
* @param string $modelClass
* @param array $ids
* @return int 删除条数
*/
public static function deleteByPk(string $modelClass, array $ids): int
{
return $modelClass::destroy($ids);
}
/**
* 非主键条件删除,支持 dataLimit
*
* @param Builder $query
* @return int 删除条数
*/
public static function deleteBy(Builder $q): int
{
return $q->delete();
}
//=================== Internal Methods ===================//
/**
* 支持多层的 whereHas:$relations=['roles','permissions'],$leaf='name'
*/
protected static function applyNestedWhere(Builder $q, array $relations, string $leaf, $val): void
{
$rel = array_shift($relations);
$q->whereHas($rel, function (Builder $qr) use ($relations, $leaf, $val) {
if ($relations) {
// 还有更深层,继续递归
self::applyNestedWhere($qr, $relations, $leaf, $val);
} else {
// 最后一层,使用普通 applyWhere
self::applyWhere($qr, $leaf, $val);
}
});
}
/**
* 单字段 where 支持:
* - 多条件数组 [ [op1,val1], [op2,val2], … ]
* - 单条件 ['op', val]
* - 直接值
*
* @param Builder $q
* @param string $col
* @param mixed $val
* @return void
*/
protected static function applyWhere(Builder $q, string $col, $val): void
{
// 多条件数组
if (is_array($val) && isset($val[0]) && is_array($val[0])) {
foreach ($val as $pair) {
if (is_array($pair) && count($pair) === 2) {
self::applySingleWhere($q, $col, $pair[0], $pair[1]);
}
}
}
// 单条件
elseif (is_array($val) && count($val) === 2) {
self::applySingleWhere($q, $col, $val[0], $val[1]);
}
// 直接等值
else {
$q->where($col, $val);
}
}
/**
* 执行条件 where
*/
protected static function applySingleWhere(Builder $q, string $col, string $op, $v): void
{
switch (strtolower($op)) {
case 'like':
$q->where($col, 'like', $v);
break;
case 'in':
$q->whereIn($col, (array)$v);
break;
case 'not in':
$q->whereNotIn($col, (array)$v);
break;
case 'null':
$q->whereNull($col);
break;
case 'not null':
$q->whereNotNull($col);
break;
case 'between':
$q->whereBetween($col, (array)$v);
break;
default:
$q->where($col, $op, $v);
}
}
/**
* 应用数据权限过滤到查询(buildQuery 场景)
*/
protected static function applyDataLimit(Builder $q, array $dataLimit): void
{
if (Auth::isSuperAdmin()) {
return;
}
$type = $dataLimit['type'] ?? null;
$field = $dataLimit['field'] ?? 'admin_id';
if ($type === 'personal') {
$q->where($field, admin_id());
} elseif ($type === 'auth') {
$q->whereIn($field, Auth::getScopeAdminIds(true));
}
}
/**
* 应用数据权限到“创建/更新”操作
*
* - $target 可能是 array|Model 对象
* - $newData 仅在 updateByPk 场景时传入,用以校验变更归属
*/
protected static function applyDataLimitOnMutate(&$target, array $dataLimit, array $newData = []): void
{
if (Auth::isSuperAdmin()) {
return;
}
$type = $dataLimit['type'] ?? null;
$field = $dataLimit['field'] ?? 'admin_id';
// 读取当前归属:create 时 $target 是 array,update 时是 Model
$current = is_array($target) ? null : $target->{$field};
// 1. personal → 只能自己
if ($type === 'personal') {
// create: 强制设置
if (is_array($target)) {
$target[$field] = admin_id();
}
// update: 校验当前归属
elseif ($current !== admin_id()) {
throw new BusinessException("无权操作该记录", 403);
}
// 若要修改归属,也必须和自己一致
if (isset($newData[$field]) && $newData[$field] !== admin_id()) {
throw new BusinessException("无权变更归属字段", 403);
}
}
// 2. auth → 自己及下属
if ($type === 'auth') {
$scope = Auth::getScopeAdminIds(true);
// create: 若前端指定归属,则校验范围;否则默认自己
if (is_array($target)) {
if (isset($target[$field])) {
if (!in_array($target[$field], $scope)) {
throw new BusinessException("无权为指定用户操作", 403);
}
} else {
$target[$field] = admin_id();
}
}
// update: 校验当前归属
elseif (!in_array($current, $scope)) {
throw new BusinessException("无权操作该记录", 403);
}
// 校验更新中指定的新归属
if (isset($newData[$field]) && !in_array($newData[$field], $scope)) {
throw new BusinessException("无权变更归属字段", 403);
}
}
}
}
+555
View File
@@ -0,0 +1,555 @@
<?php
namespace app\common\utils;
use Webman\Exception\BusinessException;
class FeishuMessage
{
protected $app_id = 'cli_a72f13d6c022500d';
protected $app_secret = 'Kxtbq70OQWrEhpltrufRZc76z7kjDqPT';
protected $class = null;
public function __construct()
{
$this->class = new Feishu($this->app_id, $this->app_secret);
}
/**
* 飞书消息模板
* @param string $scene
* @param array $options
*/
public function messageTemplates(string $scene = 'group-send', array $options = [])
{
try {
$template = match ($scene) {
'inquiry-group-send' => $this->groupNewTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time']
),
'inquiry-group-update' => $this->groupUpdateTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time'],
$options['t_time']
),
'inquiry-person-allocation' => $this->personAllocationTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time'],
$options['t_time']
),
'crm-person-follow-up' => $this->personFollowUpTemplates(
$options['contacts'],
$options['follow_next'],
$options['follow_last']
)
};
} catch (\UnhandledMatchError $e) {
throw new BusinessException($e->getMessage());
}
return $template;
}
/**
* 发送飞书消息
* @param array $options
*/
public function messageSend(string $scene = 'group-send', array $options = [])
{
$template = $this->messageTemplates($scene, $options);
$result = $this->class->sendMessages($options['receive_id'], $template, $options['msg_type'], $options['receive_id_type']);
if (!isset($result['code']) || $result['code'] !== 0) {
throw new BusinessException('发送飞书消息失败');
}
return $result;
}
/**
* 线索消息发送
*/
public function groupNewTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "form",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"flex_mode": "stretch",
"horizontal_spacing": "8px",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "auto",
"elements": [
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "立即跟踪"
},
"type": "primary_filled",
"width": "fill",
"size": "medium",
"icon": {
"tag": "standard_icon",
"token": "list-check-bold_outlined"
},
"confirm": {
"title": {
"tag": "plain_text",
"content": "确认跟踪?"
},
"text": {
"tag": "plain_text",
"content": "客户线索:'.$order_no.'"
}
},
"behaviors": [
{
"type": "callback",
"value": "'.$order_no.'"
}
],
"form_action_type": "submit",
"name": "Button_m7d0zqa2",
"element_id": "track"
}
],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top"
},
{
"tag": "column",
"width": "auto",
"elements": [],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top"
},
{
"tag": "column",
"width": "auto",
"elements": [],
"padding": "0px 0px 0px 0px",
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"margin": "0px 0px 0px 0px"
}
],
"margin": "4px 0px 0px 0px"
}
],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "12px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 0px 0px 0px",
"margin": "0px 0px 0px 0px",
"name": "Form_m7d0zqa1"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "新的【公海】客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"template": "default",
"icon": {
"tag": "standard_icon",
"token": "ai-common_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 线索消息更新
*/
public function groupUpdateTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "跟踪时间:'.$t_time.'",
"text_size": "notation",
"text_align": "left",
"text_color": "green"
},
"icon": {
"tag": "standard_icon",
"token": "chat-done_outlined",
"color": "green"
},
"element_id": "track_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "【公海】客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"text_tag_list": [
{
"tag": "text_tag",
"text": {
"tag": "plain_text",
"content": "已跟踪"
},
"color": "orange"
}
],
"template": "green",
"icon": {
"tag": "standard_icon",
"token": "ai-common_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 线索分配消息
*/
public function personAllocationTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **录单人:** '.$enter_user.'\n- **录入时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "分配时间:'.$t_time.'",
"text_size": "notation",
"text_align": "left",
"text_color": "green"
},
"icon": {
"tag": "standard_icon",
"token": "chat-done_outlined",
"color": "green"
},
"element_id": "allocation_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "新的客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"text_tag_list": [
{
"tag": "text_tag",
"text": {
"tag": "plain_text",
"content": "指定"
},
"color": "yellow"
}
],
"template": "blue",
"icon": {
"tag": "standard_icon",
"token": "todo_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 跟进提醒消息
*/
public function personFollowUpTemplates($contacts = '木子李', $follow_next = '2025-07-24 14:41:23', $follow_last = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "- **客户名称:** '.$contacts.'\n- **跟进时间:** '.$follow_next.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "最近跟进:'.$follow_last.'",
"text_size": "notation",
"text_align": "left",
"text_color": "grey"
},
"icon": {
"tag": "standard_icon",
"token": "chat_outlined",
"color": "grey"
},
"element_id": "track_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "跟进提醒"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"template": "green",
"icon": {
"tag": "standard_icon",
"token": "calendar_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace app\common\utils;
use support\Db;
class TableInspector
{
/**
* 获取某个表的所有字段信息
*/
public static function getColumns(string $table, ?string $database = null): array
{
$database = $database ?: Db::connection()->getDatabaseName();
$columns = Db::select("
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE,
COLUMN_DEFAULT, COLUMN_COMMENT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
", [$database, $table]);
return array_map(function ($col) {
return [
'name' => $col->COLUMN_NAME,
'type' => $col->DATA_TYPE,
'column_type' => $col->COLUMN_TYPE,
'nullable' => $col->IS_NULLABLE === 'YES',
'default' => $col->COLUMN_DEFAULT,
'comment' => $col->COLUMN_COMMENT,
'extra' => $col->EXTRA,
];
}, $columns);
}
/**
* 获取字段名列表
*/
public static function getColumnNames(string $table, ?string $database = null): array
{
return array_column(self::getColumns($table, $database), 'name');
}
/**
* 获取字段注释 map:字段名 => 注释
*/
public static function getColumnComments(string $table, ?string $database = null): array
{
$columns = self::getColumns($table, $database);
$map = [];
foreach ($columns as $col) {
$map[$col['name']] = $col['comment'];
}
return $map;
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace app\common\utils\scaffold;
use Symfony\Component\Filesystem\Filesystem;
use app\common\utils\TableInspector;
class Generate
{
// 配置项
protected $options = [];
public function __construct(array $options = [])
{
if (empty($options)) {
return;
}
$this->options = $options;
}
public function doGenerate()
{
$fs = new Filesystem();
$templateDir = __DIR__ . '/templates/';
$resultStr = '';
if (!empty($this->options['controller'])) {
$controllerPath = __DIR__ . "/../../../{$this->options['controller']['module']}/controller/".$this->rName($this->options['controller']['name'])."Controller.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['controller']['module']}/controller");
$this->deleteFileIfExists($controllerPath);
$ControllerStub = $this->generateFile($templateDir, 'ControllerStub.stub', $controllerPath, 'controller');
$resultStr .= $ControllerStub;
}
if (!empty($this->options['service'])) {
$servicePath = __DIR__ . "/../../../{$this->options['service']['module']}/service/".$this->rName($this->options['service']['name'])."Service.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['service']['module']}/service");
$this->deleteFileIfExists($servicePath);
$ServiceStub = $this->generateFile($templateDir, 'ServiceStub.stub', $servicePath, 'service');
$resultStr .= $ServiceStub;
}
if (!empty($this->options['logic'])) {
$logicPath = __DIR__ . "/../../../{$this->options['logic']['module']}/logic/".$this->rName($this->options['logic']['name'])."Logic.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['logic']['module']}/logic");
$this->deleteFileIfExists($logicPath);
$LogicStub = $this->generateFile($templateDir, 'LogicStub.stub', $logicPath, 'logic');
$resultStr .= $LogicStub;
}
if (!empty($this->options['model'])) {
$modelPath = __DIR__ . "/../../../{$this->options['model']['module']}/model/".$this->rName($this->options['model']['name']).".php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['model']['module']}/model");
$this->deleteFileIfExists($modelPath);
$ModelStub = $this->generateFile($templateDir, 'ModelStub.stub', $modelPath, 'model');
$resultStr .= $ModelStub;
}
if (!empty($this->options['validate'])) {
$validatePath = __DIR__ . "/../../../{$this->options['validate']['module']}/validate/".$this->rName($this->options['validate']['name'])."Validate.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['validate']['module']}/validate");
$this->deleteFileIfExists($validatePath);
$ValidatorStub = $this->generateFile($templateDir, 'ValidateStub.stub', $validatePath, 'validate');
$resultStr .= $ValidatorStub;
}
return $resultStr ? $resultStr . "<info>文件生成成功!</info> <br/>" : '未生成任何文件';
}
/**
* 根据模板生成文件
*
* @param string $templateDir 模板目录
* @param string $templateFile 模板文件名
* @param string $outputPath 输出文件路径
*/
protected function generateFile($templateDir, $templateFile, $outputPath, $fileType)
{
$templatePath = $templateDir . $templateFile;
if (!file_exists($templatePath)) {
echo "<error>模板文件 {$templateFile} 不存在!</error> \n";
return;
}
$content = file_get_contents($templatePath);
switch ($fileType) {
case 'controller':
$content = str_replace('{{controllerModule}}', $this->options['controller']['module'], $content);
$content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content);
$content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content);
$content = str_replace('{{controllerName}}', $this->rName($this->options['controller']['name']), $content);
break;
case 'service':
$content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content);
$content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content);
$content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content);
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content);
break;
case 'logic':
$content = str_replace('{{logicModule}}', $this->options['logic']['module'], $content);
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{logicName}}', $this->rName($this->options['logic']['name']), $content);
break;
case 'model':
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{table}}', $this->options['table'], $content);
$content = str_replace('{{fillable}}', $this->fillableFields(), $content);
break;
case 'validate':
$content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content);
$content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content);
break;
}
file_put_contents($outputPath, $content);
return "生成文件: <info>{$outputPath}</info> <br/>";
}
/**
* 设置字段
*
* @param array $fields 字段数组
*/
public function fillableFields()
{
$tableName = $this->options['table'];
$fields = TableInspector::getColumnNames($tableName);
$str = '';
foreach ($fields as $key => $val) {
if ($key < (count($fields)-1)) {
$str .= "'{$val}', ";
} else {
$str .= "'{$val}'";
}
}
$fields = trim($str, '"');
return $fields;
}
/**
* 如果文件存在,删除它
*
* @param string $path 文件路径
*/
protected function deleteFileIfExists($path)
{
if (file_exists($path)) {
unlink($path);
echo "删除已存在文件: <comment>{$path}</comment> \n";
}
}
/**
* 设置名称
*/
public function rName($name = '')
{
return ucfirst(camelize($name));
}
}
/**
* 下划线转驼峰
* 思路:
* step1.原字符串转小写,原字符串中的分隔符用空格替换,在字符串开头加上分隔符
* step2.将字符串中每个单词的首字母转换为大写,再去空格,去字符串首部附加的分隔符.
*/
function camelize($uncamelized_words, $separator='_') {
$uncamelized_words = $separator . str_replace($separator, " ", strtolower($uncamelized_words));
return ltrim(str_replace(" ", "", ucwords($uncamelized_words)), $separator );
}
/**
* 驼峰命名转下划线命名
* 思路:
* 小写和大写紧挨一起的地方,加上分隔符,然后全部转小写
*/
function uncamelize($camelCaps, $separator='_') {
return strtolower(preg_replace('/([a-z])([A-Z])/', "$1" . $separator . "$2", $camelCaps));
}
@@ -0,0 +1,33 @@
<?php
namespace app\{{controllerModule}}\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\{{serviceModule}}\service\{{serviceName}}Service;
class {{controllerName}}Controller extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new {{serviceName}}Service();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,17 @@
<?php
namespace app\{{logicModule}}\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\{{modelModule}}\model\{{modelName}};
class {{logicName}}Logic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = {{modelName}}::class;
}
@@ -0,0 +1,49 @@
<?php
namespace app\{{modelModule}}\model;
use support\Model;
class {{modelName}} extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = '{{table}}';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = [{{fillable}}];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,31 @@
<?php
namespace app\{{serviceModule}}\service;
use app\{{validateModule}}\validate\{{validateName}}Validate;
use app\common\traits\crud\CrudServiceTrait;
use app\{{modelModule}}\model\{{modelName}};
class {{serviceName}}Service
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = {{modelName}}::class;
/**
* 验证器
*/
protected $validator = {{validateName}}Validate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,29 @@
<?php
namespace app\{{validateModule}}\validate;
use think\Validate;
class {{validateName}}Validate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace app\common\validate;
use think\Validate;
class CorpValidate extends Validate
{
protected $rule = [
'id' => 'require|number',
'name' => 'require|max:255',
'abbr' => 'require|in:hesu,xinyouhe,paika,simate,mbaf',
'tenant_key' => 'string',
'sort' => 'number',
'status' => 'require|in:0,1'
];
protected $message = [
'id.require' => 'ID不能为空',
'id.number' => 'ID必须是数字',
'name.require' => '企业名称不能为空',
'name.max' => '企业名称长度不能超过255个字符',
'abbr.require' => '企业缩写不能为空',
'abbr.in' => '企业缩写必须是 hesu,xinyouhe,paika,simate,mbaf 中的一个',
'tenant_key.string' => '飞书企业标识必须是字符串',
'sort.number' => '排序值必须是数字',
'status.require' => '状态不能为空',
'status.in' => '状态必须是0或1'
];
protected $scene = [
'store' => ['name', 'abbr', 'tenant_key', 'sort', 'status'],
'update' => ['id', 'name', 'abbr', 'tenant_key', 'sort', 'status']
];
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace app\common\validate;
use think\Validate;
use app\common\model\InquiryLog;
class InquiryLogValidate extends Validate
{
// 规则
protected $rule = [
'id' => 'require|integer|gt:0',
'order_no' => 'require|alphaDash|length:36',
'enter_user_id'=> 'integer|egt:0',
'recipient' => 'integer|egt:0',
'quality' => 'require|in:1,2,3',
'channel' => 'integer|between:0,99',
'email' => 'email|max:255|checkEmailContactUnique',
'tel' => 'max:255|checkTelContactUnique',
'whatsapp' => 'max:255|checkWhatsappContactUnique',
'country' => 'integer|egt:0|requireWith:contacts',
'contacts' => 'require|max:255|requireWithout:email,tel,whatsapp|checkContactUnique',
'content' => 'string',
'status' => 'in:0,1,2',
'r_time' => 'integer|egt:0',
't_time' => 'integer|egt:0'
];
// 提示
protected $message = [
'id.require' => 'ID不能为空',
'id.integer' => 'ID必须是整数',
'id.gt' => 'ID必须大于0',
'order_no.require' => '订单号不能为空',
'order_no.alphaDash'=> '订单号只能包含字母、数字和下划线',
'order_no.length' => '订单号长度必须为36位',
'enter_user_id.integer' => '录入人ID必须是整数',
'enter_user_id.egt' => '录入人ID不能为负数',
'recipient.integer' => '接单人ID必须是整数',
'recipient.egt' => '接单人ID不能为负数',
'quality.require' => '询单等级不能为空',
'quality.in' => '询单等级只能是1、2或3',
'channel.integer' => '渠道编号必须是整数',
'channel.between' => '渠道编号范围0-99',
'email.email' => '邮箱格式不正确',
'email.max' => '邮箱长度不能超过255个字符',
'tel.max' => '电话长度不能超过255个字符',
'whatsapp.max' => 'WhatsApp长度不能超过255个字符',
'country.integer' => '国家代码必须是整数',
'country.egt' => '国家代码不能为负数',
'contacts.require' => '联系人不能为空',
'contacts.max' => '联系人长度不能超过255个字符',
'content.string' => '询单内容必须是字符串',
'status.in' => '状态值只能是0、1或2',
'r_time.integer' => '发布时间必须是整数',
'r_time.egt' => '发布时间不能为负数',
't_time.integer' => '跟踪时间必须是整数',
't_time.egt' => '跟踪时间不能为负数',
'email.checkEmailContactUnique' => '邮箱联系方式已存在',
'tel.checkTelContactUnique' => '电话联系方式已存在',
'whatsapp.checkWhatsappContactUnique' => 'WhatsApp联系方式已存在',
'contacts.requireWithout' => '至少需要提供一种联系方式',
'contacts.checkContactUnique' => '联系人已存在',
'country.requireWith' => '国家必须与联系人一起提供'
];
// 场景
protected $scene = [
'store' => [
'enter_user_id', 'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content'
],
'update' => [
'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content'
],
'release' => [
'status', 'r_time'
],
'allocation' => [
'status', 't_time'
]
];
/**
* 验证联系方式唯一性
*/
protected function checkContactUnique($value, $rule, $data, $field, $title)
{
// 如果是更新操作且联系人未修改,跳过检查
if ($this->currentScene == 'update') {
$original = InquiryLog::find($data['id']);
if ($original && $original->contacts == $value && $original->country == ($data['country'] ?? 0)) {
return true;
}
}
if (empty($data['email']) && empty($data['tel']) && empty($data['whatsapp'])) {
$query = InquiryLog::where('contacts', trim($value))
->where('country', $data['country']);
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
return true;
}
/**
* 验证邮箱联系方式唯一性
*/
protected function checkEmailContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且邮箱未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->email == $value) {
return true;
}
}
$query = InquiryLog::where('email', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
/**
* 验证电话联系方式唯一性
*/
protected function checkTelContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且电话未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->tel == $value) {
return true;
}
}
$query = InquiryLog::where('tel', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
/**
* 验证WhatsApp联系方式唯一性
*/
protected function checkWhatsappContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且WhatsApp未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->whatsapp == $value) {
return true;
}
}
$query = InquiryLog::where('whatsapp', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ class RoleValidate extends Validate
];
protected $scene = [
'role_create' => ['system_id', 'name', 'desc', 'sort'],
'role_update' => ['id', 'system_id', 'name', 'desc', 'sort', 'status'],
'store' => ['system_id', 'name', 'desc', 'sort'],
'update' => ['id', 'system_id', 'name', 'desc', 'sort', 'status'],
];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class SystemsValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}