Initial commit: X-Server Mobile enterprise tool system

- Frontend: React 18 + TypeScript + Vite + Ant Design Mobile
- Backend: PHP 8.5 + Webman 2.x + MySQL + Redis
- i18n support: Chinese, English, Russian, Spanish, French
- Approval workflow engine
- Docker development environment
- Feishu integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 17:57:25 +08:00
commit 8d78e18267
121 changed files with 18563 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# 应用配置
APP_DEBUG=true
APP_ENV=local
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=x_server_mobile
DB_USERNAME=root
DB_PASSWORD=
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0
# JWT 配置
JWT_SECRET=your-secret-key-change-in-production
# 飞书配置
FEISHU_APP_ID=
FEISHU_APP_SECRET=
+34
View File
@@ -0,0 +1,34 @@
FROM php:8.5-cli
# Install system dependencies
RUN apt-get update && apt-get install -y \
unzip \
git \
libzip-dev \
libpng-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install zip pdo pdo_mysql bcmath pcntl
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Set working directory
WORKDIR /app
# Copy composer files
COPY composer.json composer.lock* ./
# Install dependencies
RUN composer install --no-interaction --optimize-autoloader
# Copy application files
COPY . .
# Expose port
EXPOSE 8787
# Start webman in foreground mode
CMD ["php", "start.php", "start"]
@@ -0,0 +1,134 @@
<?php
namespace App\Controller;
use App\Service\ApprovalService;
use Webman\Http\Request;
use Webman\Http\Response;
class ApprovalController extends BaseController
{
private ApprovalService $approvalService;
public function __construct()
{
$this->approvalService = new ApprovalService();
}
/**
* 获取审批列表
*/
public function list(Request $request): Response
{
$type = $request->input('type', 'my');
$page = (int) $request->input('page', 1);
$pageSize = (int) $request->input('page_size', 20);
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
$result = $this->approvalService->getList($userId, $type, $page, $pageSize);
return $this->success($result);
}
/**
* 获取审批详情
*/
public function detail(Request $request): Response
{
$id = $request->route('id');
$order = \App\Model\ApprovalOrder::with(['flow', 'applicant', 'records.approver'])
->findOrFail($id);
return $this->success($order);
}
/**
* 提交审批
*/
public function submit(Request $request): Response
{
$data = $request->all();
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
$data['applicant_id'] = $userId;
try {
$order = $this->approvalService->submit($data);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批通过
*/
public function approve(Request $request): Response
{
$id = $request->route('id');
$comment = $request->post('comment');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->approve((int) $id, $userId, $comment);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批拒绝
*/
public function reject(Request $request): Response
{
$id = $request->route('id');
$comment = $request->post('comment');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->reject((int) $id, $userId, $comment);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 撤销申请
*/
public function cancel(Request $request): Response
{
$id = $request->route('id');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->cancel((int) $id, $userId);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Controller;
use App\Util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 用户登录
*/
public function login(Request $request): Response
{
$username = $request->post('username');
$password = $request->post('password');
// TODO: 实现真实的登录逻辑
if (!$username || !$password) {
return $this->error('用户名或密码不能空');
}
// 模拟用户数据
$user = [
'user_id' => 1,
'username' => $username,
'name' => '测试用户',
'company_id' => 1,
];
$token = JwtUtil::encode($user);
return $this->success([
'token' => $token,
'user' => $user,
]);
}
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$tenantKey = $request->post('tenant_key');
// TODO: 实现飞书免登逻辑
// 1. 根据 tenant_key 获取公司配置
// 2. 使用 auth_code 换取用户信息
// 3. 创建或更新用户
// 4. 生成 JWT Token
return $this->success([
'token' => JwtUtil::encode(['user_id' => 1, 'company_id' => 1]),
'user' => ['id' => 1, 'name' => '飞书用户', 'company_id' => 1],
]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
abstract class BaseController
{
/**
* 成功响应
*/
protected function success($data = null, string $message = 'success'): Response
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
]);
}
/**
* 错误响应
*/
protected function error(string $message = 'error', int $code = 1, $data = null): Response
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
]);
}
/**
* 获取当前用户ID
*/
protected function getUserId(Request $request): ?int
{
return $request->userId ?? null;
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
class BusinessTripController extends BaseController
{
/**
* 获取出差列表
*/
public function list(Request $request): Response
{
$type = $request->input('type', 'my');
// TODO: 从数据库获取出差列表
return $this->success([
'list' => [],
'total' => 0,
]);
}
/**
* 获取出差详情
*/
public function detail(Request $request): Response
{
$id = $request->route('id');
// TODO: 从数据库获取出差详情
return $this->success([
'id' => $id,
'title' => '测试出差',
]);
}
/**
* 提交出差申请
*/
public function apply(Request $request): Response
{
$data = $request->all();
// TODO: 保存出差数据并创建审批流程
return $this->success();
}
/**
* 更新出差申请
*/
public function update(Request $request): Response
{
$id = $request->route('id');
$data = $request->all();
// TODO: 更新出差数据
return $this->success();
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
class UserController extends BaseController
{
/**
* 获取用户信息
*/
public function info(Request $request): Response
{
$userId = $this->getUserId($request);
// TODO: 从数据库获取用户信息
return $this->success([
'id' => $userId,
'name' => '测试用户',
'email' => 'test@example.com',
'company_id' => 1,
]);
}
/**
* 获取用户菜单权限
*/
public function menu(Request $request): Response
{
// TODO: 根据用户权限返回菜单
return $this->success([
['id' => 1, 'name' => '首页', 'path' => '/dashboard'],
['id' => 2, 'name' => '出差审批', 'path' => '/business-trip'],
]);
}
/**
* 获取用户可切换的公司列表
*/
public function companies(Request $request): Response
{
$userId = $this->getUserId($request);
// TODO: 从数据库获取用户关联的公司列表
return $this->success([
['id' => 1, 'name' => '公司A'],
]);
}
/**
* 切换当前公司
*/
public function switchCompany(Request $request): Response
{
$companyId = $request->post('company_id');
// TODO: 更新用户当前公司
return $this->success();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalFlow extends Model
{
protected $table = 'approval_flow';
protected $fillable = [
'business_type',
'name',
'description',
'status',
];
protected $casts = [
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联审批节点
*/
public function nodes()
{
return $this->hasMany(ApprovalNode::class, 'flow_id')->orderBy('order');
}
/**
* 获取启用的流程
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据业务类型获取流程
*/
public function scopeByBusinessType($query, string $businessType)
{
return $query->where('business_type', $businessType);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalNode extends Model
{
protected $table = 'approval_node';
protected $fillable = [
'flow_id',
'type',
'name',
'order',
'approvers',
'conditions',
];
protected $casts = [
'approvers' => 'array',
'conditions' => 'array',
'order' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 节点类型常量
*/
const TYPE_APPROVER = 'approver'; // 审批人(单人)
const TYPE_AND_APPROVE = 'and_approve'; // 会签(需所有人通过)
const TYPE_OR_APPROVE = 'or_approve'; // 或签(任一人通过)
const TYPE_NOTIFY = 'notify'; // 抄送(通知用)
/**
* 关联流程
*/
public function flow()
{
return $this->belongsTo(ApprovalFlow::class, 'flow_id');
}
/**
* 获取指定顺序的节点
*/
public function scopeByOrder($query, int $order)
{
return $query->where('order', $order);
}
/**
* 判断是否是审批节点
*/
public function isApprovalNode(): bool
{
return in_array($this->type, [
self::TYPE_APPROVER,
self::TYPE_AND_APPROVE,
self::TYPE_OR_APPROVE,
]);
}
/**
* 判断是否是会签
*/
public function isAndApprove(): bool
{
return $this->type === self::TYPE_AND_APPROVE;
}
/**
* 判断是否是或签
*/
public function isOrApprove(): bool
{
return $this->type === self::TYPE_OR_APPROVE;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalOrder extends Model
{
protected $table = 'approval_order';
protected $fillable = [
'business_type',
'business_id',
'title',
'applicant_id',
'status',
'current_nodes',
'flow_id',
'form_data',
'attachments',
];
protected $casts = [
'current_nodes' => 'array',
'form_data' => 'array',
'attachments' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 审批状态常量
*/
const STATUS_DRAFT = 'draft'; // 草稿
const STATUS_PENDING = 'pending'; // 审批中
const STATUS_APPROVED = 'approved'; // 已通过
const STATUS_REJECTED = 'rejected'; // 已拒绝
const STATUS_CANCELLED = 'cancelled'; // 已撤销
/**
* 关联流程
*/
public function flow()
{
return $this->belongsTo(ApprovalFlow::class, 'flow_id');
}
/**
* 关联申请人
*/
public function applicant()
{
return $this->belongsTo(User::class, 'applicant_id');
}
/**
* 关联审批记录
*/
public function records()
{
return $this->hasMany(ApprovalRecord::class, 'order_id')->orderBy('created_at');
}
/**
* 判断是否可以编辑
*/
public function canEdit(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 判断是否可以审批
*/
public function canApprove(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 判断是否已结束
*/
public function isEnded(): bool
{
return in_array($this->status, [
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalRecord extends Model
{
protected $table = 'approval_record';
protected $fillable = [
'order_id',
'node_id',
'node_type',
'approver_id',
'action',
'comment',
];
protected $casts = [
'created_at' => 'datetime',
];
/**
* 操作类型常量
*/
const ACTION_SUBMIT = 'submit'; // 提交
const ACTION_APPROVE = 'approve'; // 通过
const ACTION_REJECT = 'reject'; // 拒绝
const ACTION_CANCEL = 'cancel'; // 撤销
/**
* 关联审批单
*/
public function order()
{
return $this->belongsTo(ApprovalOrder::class, 'order_id');
}
/**
* 关联审批人
*/
public function approver()
{
return $this->belongsTo(User::class, 'approver_id');
}
/**
* 判断是否是批准操作
*/
public function isApproved(): bool
{
return $this->action === self::ACTION_APPROVE;
}
/**
* 判断是否是拒绝操作
*/
public function isRejected(): bool
{
return $this->action === self::ACTION_REJECT;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Model;
use support\Model;
class Company extends Model
{
protected $table = 'company';
protected $fillable = [
'name',
'feishu_app_id',
'feishu_app_secret',
'feishu_tenant_key',
'status',
];
protected $casts = [
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
protected $hidden = [
'feishu_app_secret',
];
/**
* 获取启用的公司
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据飞书租户Key查找
*/
public function scopeByFeishuTenantKey($query, string $tenantKey)
{
return $query->where('feishu_tenant_key', $tenantKey);
}
/**
* 根据飞书AppID查找
*/
public function scopeByFeishuAppId($query, string $appId)
{
return $query->where('feishu_app_id', $appId);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Model;
use support\Model;
class User extends Model
{
protected $table = 'user';
protected $fillable = [
'company_id',
'feishu_user_id',
'feishu_union_id',
'name',
'email',
'phone',
'avatar',
'status',
];
protected $casts = [
'company_id' => 'integer',
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联公司
*/
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
}
/**
* 获取启用的用户
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据飞书用户ID查找
*/
public function scopeByFeishuUserId($query, string $feishuUserId)
{
return $query->where('feishu_user_id', $feishuUserId);
}
/**
* 根据飞书 UnionID 查找
*/
public function scopeByFeishuUnionId($query, string $feishuUnionId)
{
return $query->where('feishu_union_id', $feishuUnionId);
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
namespace App\Service;
use App\Model\ApprovalFlow;
use App\Model\ApprovalNode;
use App\Model\ApprovalOrder;
use App\Model\ApprovalRecord;
use App\Model\User;
use Illuminate\Database\DatabaseManager;
use App\Util\Db;
use Illuminate\Database\Connection;
class ApprovalService
{
private Connection $db;
public function __construct()
{
// Initialize database if not already initialized
Db::init();
$this->db = Db::connection();
}
/**
* 提交审批
*/
public function submit(array $data): ApprovalOrder
{
return $this->db->transaction(function () use ($data) {
// 1. 获取审批流程
$flow = ApprovalFlow::active()
->byBusinessType($data['business_type'])
->with('nodes')
->findOrFail($data['flow_id']);
// 2. 创建审批单
$order = ApprovalOrder::create([
'business_type' => $data['business_type'],
'business_id' => $data['business_id'],
'title' => $data['title'],
'applicant_id' => $data['applicant_id'],
'status' => ApprovalOrder::STATUS_PENDING,
'current_nodes' => $this->getFirstNodeIds($flow),
'flow_id' => $flow->id,
'form_data' => $data['form_data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
// 3. 记录提交操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $flow->nodes->first()->id ?? 0,
'node_type' => 'submit',
'approver_id' => $data['applicant_id'],
'action' => ApprovalRecord::ACTION_SUBMIT,
'comment' => $data['comment'] ?? null,
]);
return $order->load(['flow', 'applicant']);
});
}
/**
* 审批通过
*/
public function approve(int $orderId, int $approverId, ?string $comment = null): ApprovalOrder
{
return $this->db->transaction(function () use ($orderId, $approverId, $comment) {
$order = ApprovalOrder::with(['flow.nodes'])->findOrFail($orderId);
if (!$order->canApprove()) {
throw new \Exception('当前状态无法审批');
}
$currentNodeIds = $order->current_nodes ?? [];
$currentNode = ApprovalNode::whereIn('id', $currentNodeIds)->first();
if (!$currentNode) {
throw new \Exception('当前节点不存在');
}
// 记录审批操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $currentNode->id,
'node_type' => $currentNode->type,
'approver_id' => $approverId,
'action' => ApprovalRecord::ACTION_APPROVE,
'comment' => $comment,
]);
// 判断节点是否完成
if ($this->isNodeCompleted($order->id, $currentNode->id)) {
// 移动到下一节点
$nextNodes = $this->getNextNodes($order->flow, $currentNode->order);
if (empty($nextNodes)) {
// 审批完成
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
$order->current_nodes = collect($nextNodes)->pluck('id')->toArray();
}
$order->save();
}
return $order->fresh(['flow', 'applicant', 'records']);
});
}
/**
* 审批拒绝
*/
public function reject(int $orderId, int $approverId, ?string $comment = null): ApprovalOrder
{
return $this->db->transaction(function () use ($orderId, $approverId, $comment) {
$order = ApprovalOrder::findOrFail($orderId);
if (!$order->canApprove()) {
throw new \Exception('当前状态无法审批');
}
$currentNodeIds = $order->current_nodes ?? [];
$currentNode = ApprovalNode::whereIn('id', $currentNodeIds)->first();
// 记录拒绝操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $currentNode->id ?? 0,
'node_type' => $currentNode->type ?? 'unknown',
'approver_id' => $approverId,
'action' => ApprovalRecord::ACTION_REJECT,
'comment' => $comment,
]);
// 更新状态为已拒绝
$order->status = ApprovalOrder::STATUS_REJECTED;
$order->current_nodes = [];
$order->save();
return $order->fresh(['flow', 'applicant', 'records']);
});
}
/**
* 撤销申请
*/
public function cancel(int $orderId, int $userId): ApprovalOrder
{
$order = ApprovalOrder::findOrFail($orderId);
if ($order->applicant_id !== $userId) {
throw new \Exception('只有申请人可以撤销');
}
if (!$order->canEdit() && !$order->canApprove()) {
throw new \Exception('当前状态无法撤销');
}
// 记录撤销操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => 0,
'node_type' => 'cancel',
'approver_id' => $userId,
'action' => ApprovalRecord::ACTION_CANCEL,
'comment' => '申请人撤销',
]);
$order->status = ApprovalOrder::STATUS_CANCELLED;
$order->current_nodes = [];
$order->save();
return $order->fresh(['flow', 'applicant', 'records']);
}
/**
* 获取审批列表
*/
public function getList(int $userId, string $type = 'my', int $page = 1, int $pageSize = 20): array
{
$query = ApprovalOrder::with(['flow', 'applicant']);
switch ($type) {
case 'my':
$query->where('applicant_id', $userId);
break;
case 'pending':
// 获取用户待审批的订单
$pendingOrderIds = $this->getPendingOrderIds($userId);
$query->whereIn('id', $pendingOrderIds);
break;
case 'approved':
// 获取用户已审批的订单
$approvedOrderIds = ApprovalRecord::where('approver_id', $userId)
->where('action', '!=', ApprovalRecord::ACTION_SUBMIT)
->pluck('order_id')
->unique()
->toArray();
$query->whereIn('id', $approvedOrderIds);
break;
}
$total = $query->count();
$list = $query->orderBy('created_at', 'desc')
->offset(($page - 1) * $pageSize)
->limit($pageSize)
->get();
return [
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
];
}
/**
* 获取第一个节点ID列表
*/
private function getFirstNodeIds(ApprovalFlow $flow): array
{
return $flow->nodes
->where('order', 0)
->pluck('id')
->toArray();
}
/**
* 判断节点是否完成
*/
private function isNodeCompleted(int $orderId, int $nodeId): bool
{
$node = ApprovalNode::findOrFail($nodeId);
$records = ApprovalRecord::where('order_id', $orderId)
->where('node_id', $nodeId)
->where('action', ApprovalRecord::ACTION_APPROVE)
->get();
$approvers = $node->approvers ?? [];
if ($node->type === ApprovalNode::TYPE_AND_APPROVE) {
// 会签:需要所有人通过
return count($records) >= count($approvers);
} elseif ($node->type === ApprovalNode::TYPE_OR_APPROVE) {
// 或签:任一人通过即可
return count($records) >= 1;
} else {
// 单人审批:需要一人通过
return count($records) >= 1;
}
}
/**
* 获取下一节点
*/
private function getNextNodes(ApprovalFlow $flow, int $currentOrder): array
{
return $flow->nodes
->where('order', '>', $currentOrder)
->sortBy('order')
->values()
->toArray();
}
/**
* 获取用户待审批的订单ID列表
*/
private function getPendingOrderIds(int $userId): array
{
// 获取用户参与的待审批订单
$pendingOrders = ApprovalOrder::where('status', ApprovalOrder::STATUS_PENDING)
->whereNotNull('current_nodes')
->get();
$result = [];
foreach ($pendingOrders as $order) {
$currentNodeIds = $order->current_nodes ?? [];
$nodes = ApprovalNode::whereIn('id', $currentNodeIds)->get();
foreach ($nodes as $node) {
$approvers = $node->approvers ?? [];
if (in_array($userId, $approvers)) {
// 检查是否已审批
$hasApproved = ApprovalRecord::where('order_id', $order->id)
->where('node_id', $node->id)
->where('approver_id', $userId)
->exists();
if (!$hasApproved) {
$result[] = $order->id;
break;
}
}
}
}
return $result;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace App\Service;
use App\Model\Company;
use App\Model\User;
use support\Cache;
class FeishuService
{
private ?string $accessToken = null;
private ?int $companyId = null;
/**
* 设置当前操作的公司
*/
public function setCompany(int $companyId): self
{
$this->companyId = $companyId;
$this->accessToken = null;
return $this;
}
/**
* 根据 tenant_key 获取公司配置
*/
public function getCompanyByTenantKey(string $tenantKey): ?Company
{
return Company::active()->byFeishuTenantKey($tenantKey)->first();
}
/**
* 获取飞书 access_token
*/
public function getAccessToken(): string
{
if ($this->accessToken) {
return $this->accessToken;
}
if (!$this->companyId) {
throw new \Exception('未设置公司ID');
}
$company = Company::findOrFail($this->companyId);
// 从缓存获取
$cacheKey = "feishu:access_token:{$company->id}";
$token = Cache::get($cacheKey);
if ($token) {
$this->accessToken = $token;
return $token;
}
// 请求新的 token
$response = $this->httpPost('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', [
'app_id' => $company->feishu_app_id,
'app_secret' => $company->feishu_app_secret,
]);
if ($response['code'] !== 0) {
throw new \Exception('获取飞书 token 失败: ' . ($response['msg'] ?? '未知错误'));
}
$token = $response['tenant_access_token'];
$expire = $response['expire'] ?? 7200;
// 缓存 token(提前5分钟过期)
Cache::set($cacheKey, $token, $expire - 300);
$this->accessToken = $token;
return $token;
}
/**
* 根据 auth_code 获取用户信息
*/
public function getUserInfo(string $authCode): array
{
if (!$this->companyId) {
throw new \Exception('未设置公司ID');
}
$token = $this->getAccessToken();
// 获取用户 access_token
$response = $this->httpPost('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', [
'grant_type' => 'authorization_code',
'client_id' => Company::findOrFail($this->companyId)->feishu_app_id,
'client_secret' => Company::findOrFail($this->companyId)->feishu_app_secret,
'code' => $authCode,
], [
'Authorization: Bearer ' . $token,
]);
if ($response['code'] !== 0) {
throw new \Exception('获取用户信息失败: ' . ($response['msg'] ?? '未知错误'));
}
return $response['data'] ?? [];
}
/**
* 创建或更新用户
*/
public function createOrUpdateUser(array $feishuData, int $companyId): User
{
$unionId = $feishuData['union_id'] ?? null;
$userId = $feishuData['user_id'] ?? null;
$name = $feishuData['name'] ?? '未知';
$avatar = $feishuData['avatar_url'] ?? null;
$email = $feishuData['email'] ?? null;
if (!$unionId) {
throw new \Exception('缺少 union_id');
}
// 查找或创建用户
$user = User::byFeishuUnionId($unionId)->first();
if (!$user) {
$user = User::create([
'company_id' => $companyId,
'feishu_user_id' => $userId,
'feishu_union_id' => $unionId,
'name' => $name,
'email' => $email,
'avatar' => $avatar,
'status' => 1,
]);
} else {
// 更新用户信息
$user->update([
'feishu_user_id' => $userId,
'name' => $name,
'avatar' => $avatar,
'email' => $email,
]);
}
return $user;
}
/**
* 发送审批消息通知
*/
public function sendApprovalMessage(int $userId, string $title, string $url): bool
{
try {
$user = User::findOrFail($userId);
$company = Company::findOrFail($user->company_id);
$this->setCompany($company->id);
$token = $this->getAccessToken();
// 构建消息内容
$message = [
'msg_type' => 'interactive',
'card' => [
'header' => [
'title' => [
'content' => '审批通知',
'tag' => 'plain_text',
],
'template' => 'blue',
],
'elements' => [
[
'tag' => 'div',
'text' => [
'content' => $title,
'tag' => 'plain_text',
],
],
[
'tag' => 'action',
'actions' => [
[
'tag' => 'button',
'text' => [
'content' => '查看详情',
'tag' => 'plain_text',
],
'type' => 'default',
'url' => $url,
],
],
],
],
],
];
// 发送消息
$response = $this->httpPost(
"https://open.feishu.cn/open-apis/message/v4/send_messages",
[
'receive_id_type' => 'user_id',
'receive_id' => $user->feishu_user_id,
'msg_type' => 'interactive',
'content' => json_encode(['card' => $message['card']]),
],
[
'Authorization: Bearer ' . $token,
]
);
return ($response['code'] ?? -1) === 0;
} catch (\Exception $e) {
// 记录错误但不中断流程
error_log('发送飞书消息失败: ' . $e->getMessage());
return false;
}
}
/**
* HTTP POST 请求
*/
private function httpPost(string $url, array $data, array $headers = []): array
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$defaultHeaders = [
'Content-Type: application/json',
];
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($defaultHeaders, $headers));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new \Exception("HTTP 请求失败: {$httpCode}");
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('JSON 解析失败');
}
return $result;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Util;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model as EloquentModel;
class Db
{
private static ?Capsule $capsule = null;
public static function init(): void
{
if (self::$capsule !== null) {
return;
}
$config = config('database');
self::$capsule = new Capsule();
// Add connection
self::$capsule->addConnection([
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => getenv('DB_PORT') ?: '3306',
'database' => getenv('DB_DATABASE') ?: 'x_server_mobile',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]);
// Set event dispatcher
self::$capsule->setEventDispatcher(new Dispatcher(new Container()));
// Make this Capsule instance available globally via static methods
self::$capsule->setAsGlobal();
// Setup the Eloquent ORM
self::$capsule->bootEloquent();
}
public static function connection(?string $name = null): \Illuminate\Database\Connection
{
if (self::$capsule === null) {
self::init();
}
return self::$capsule->connection($name);
}
public static function table(string $table): \Illuminate\Database\Query\Builder
{
return self::connection()->table($table);
}
public static function raw(string $value): \Illuminate\Database\Query\Expression
{
return self::connection()->raw($value);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Util;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
class JwtUtil
{
private static string $secret = 'your-secret-key-change-in-production';
private static int $expire = 86400 * 7; // 7 days
/**
* 生成 Token
*/
public static function encode(array $data): string
{
$payload = array_merge($data, [
'iat' => time(),
'exp' => time() + self::$expire,
]);
return JWT::encode($payload, self::$secret, 'HS256');
}
/**
* 解析 Token
*/
public static function decode(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key(self::$secret, 'HS256'));
return (array) $decoded;
} catch (Exception $e) {
return null;
}
}
/**
* 验证 Token
*/
public static function verify(string $token): bool
{
return self::decode($token) !== null;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Util;
/**
* 多语言翻译类
* 支持:中文(zh)、英文(en)、俄语(ru)、西语(es)、法语(fr)
*/
class Lang
{
private static ?array $translations = null;
private static string $locale = 'zh';
private static string $fallbackLocale = 'zh';
/**
* 支持的语言列表
*/
public const SUPPORTED_LOCALES = ['zh', 'en', 'ru', 'es', 'fr'];
/**
* 语言名称映射
*/
public const LOCALE_NAMES = [
'zh' => '简体中文',
'en' => 'English',
'ru' => 'Русский',
'es' => 'Español',
'fr' => 'Français',
];
/**
* 初始化翻译
*/
private static function loadTranslations(): void
{
if (self::$translations !== null) {
return;
}
self::$translations = [];
foreach (self::SUPPORTED_LOCALES as $locale) {
$file = base_path() . "/lang/{$locale}.php";
if (file_exists($file)) {
self::$translations[$locale] = require $file;
}
}
}
/**
* 设置当前语言
*/
public static function setLocale(string $locale): void
{
if (in_array($locale, self::SUPPORTED_LOCALES)) {
self::$locale = $locale;
}
}
/**
* 获取当前语言
*/
public static function getLocale(): string
{
return self::$locale;
}
/**
* 从请求中获取语言
*/
public static function detectFromRequest(): string
{
// 从请求头获取语言
$request = \request();
$acceptLanguage = $request->header('Accept-Language');
if ($acceptLanguage) {
// 解析 Accept-Language 头
$locales = explode(',', $acceptLanguage);
foreach ($locales as $locale) {
$locale = trim(strstr($locale, ';', true));
$locale = substr($locale, 0, 2);
if (in_array($locale, self::SUPPORTED_LOCALES)) {
return $locale;
}
}
}
// 从查询参数获取
$lang = $request->input('lang');
if ($lang && in_array($lang, self::SUPPORTED_LOCALES)) {
return $lang;
}
return self::$fallbackLocale;
}
/**
* 翻译文本
*
* @param string $key 翻译键,使用点号分隔,如 'common.confirm'
* @param array $params 替换参数
* @param string|null $locale 指定语言,null 则使用当前语言
* @return string 翻译后的文本
*/
public static function get(string $key, array $params = [], ?string $locale = null): string
{
self::loadTranslations();
$locale = $locale ?? self::$locale;
// 获取翻译值
$value = self::getTranslationValue($key, $locale);
// 替换参数
if (!empty($params)) {
foreach ($params as $search => $replace) {
$value = str_replace(':' . $search, $replace, $value);
$value = str_replace('{' . $search . '}', $replace, $value);
}
}
return $value;
}
/**
* 获取翻译值
*/
private static function getTranslationValue(string $key, string $locale): string
{
$keys = explode('.', $key);
$value = self::$translations[$locale] ?? [];
foreach ($keys as $k) {
if (isset($value[$k])) {
$value = $value[$k];
} else {
// 尝试使用回退语言
if ($locale !== self::$fallbackLocale) {
return self::getTranslationValue($key, self::$fallbackLocale);
}
return $key;
}
}
return is_string($value) ? $value : $key;
}
/**
* 判断翻译是否存在
*/
public static function has(string $key, ?string $locale = null): bool
{
self::loadTranslations();
$locale = $locale ?? self::$locale;
$keys = explode('.', $key);
$value = self::$translations[$locale] ?? [];
foreach ($keys as $k) {
if (isset($value[$k])) {
$value = $value[$k];
} else {
return false;
}
}
return true;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Middleware;
use App\Util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
use support\Log;
class AuthMiddleware
{
public function process(Request $request, callable $handler): Response
{
// 获取 Token
$token = $request->header('Authorization');
$token = str_replace('Bearer ', '', $token);
if (!$token || !JwtUtil::verify($token)) {
return json([
'code' => 401,
'message' => 'Unauthorized',
'data' => null,
], 401);
}
// 解析 Token 并设置用户信息到请求
$payload = JwtUtil::decode($token);
if ($payload) {
$request->userId = $payload['user_id'] ?? null;
$request->companyId = $payload['company_id'] ?? null;
}
return $handler($request);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Middleware;
use Webman\Http\Request;
use Webman\Http\Response;
class CorsMiddleware
{
public function process(Request $request, callable $handler): Response
{
// 处理 OPTIONS 预检请求
if ($request->method() === 'OPTIONS') {
$response = response('');
} else {
$response = $handler($request);
}
// 添加 CORS 头
$response->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
->withHeader('Access-Control-Max-Age', '86400');
return $response;
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "x-server/mobile-backend",
"description": "Enterprise Tool System Backend",
"type": "project",
"require": {
"php": ">=8.1",
"workerman/webman": "^2.0",
"workerman/webman-framework": "^2.0",
"illuminate/database": "^10.0",
"illuminate/pagination": "^10.0",
"illuminate/redis": "^10.0",
"illuminate/events": "^10.0",
"firebase/php-jwt": "^6.10",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"mockery/mockery": "^1.5",
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-package-install": [
"support\\Plugin::install"
],
"post-package-update": [
"support\\Plugin::install"
]
},
"config": {
"audit": {
"ignore": {
"firebase/php-jwt": "PKSA-y2cr-5h3j-g3ys"
}
},
"allow-plugins": {
"*": true
}
}
}
+4750
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'debug' => getenv('APP_DEBUG') ?: true,
'default_timezone' => 'Asia/Shanghai',
'request_class' => support\Request::class,
'public_path' => base_path() . '/public',
'runtime_path' => base_path() . '/runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => true,
];
+3
View File
@@ -0,0 +1,3 @@
<?php
return new \Illuminate\Container\Container();
+24
View File
@@ -0,0 +1,24 @@
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: 'mysql',
'port' => getenv('DB_PORT') ?: 3306,
'database' => getenv('DB_DATABASE') ?: 'x_server_mobile',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '123456',
'unix_socket' => getenv('DB_SOCKET') ?: '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'options' => [
\PDO::ATTR_TIMEOUT => 5,
],
],
],
];
+17
View File
@@ -0,0 +1,17 @@
<?php
use support\Log;
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\StreamHandler::class,
'constructor' => [
runtime_path() . '/logs/workerman.log',
Monolog\Logger::DEBUG,
],
],
],
],
];
@@ -0,0 +1,28 @@
<?php
return [
'enable' => true,
'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build',
'phar_filename' => 'webman.phar',
'phar_format' => Phar::PHAR, // Phar archive format: Phar::PHAR, Phar::TAR, Phar::ZIP
'phar_compression' => Phar::NONE, // Compression method for Phar archive: Phar::NONE, Phar::GZ, Phar::BZ2
'bin_filename' => 'webman.bin',
'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL.
'private_key_file' => '', // The file path for certificate or OpenSSL private key file.
'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#',
'exclude_files' => [
'.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin'
],
'custom_ini' => '
memory_limit = 256M
',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'default' => [
'host' => getenv('REDIS_HOST') ?: 'redis',
'port' => getenv('REDIS_PORT') ?: 6379,
'password' => getenv('REDIS_PASSWORD') ?: null,
'database' => getenv('REDIS_DATABASE') ?: 0,
],
];
+41
View File
@@ -0,0 +1,41 @@
<?php
use Webman\Route;
// API 路由
Route::group('/api/v1', function () {
// 认证路由
Route::post('/auth/login', [App\Controller\AuthController::class, 'login']);
Route::post('/auth/feishu-login', [App\Controller\AuthController::class, 'feishuLogin']);
// 用户路由(需要认证)
Route::group(function () {
Route::get('/user/info', [App\Controller\UserController::class, 'info']);
Route::get('/user/menu', [App\Controller\UserController::class, 'menu']);
Route::get('/user/companies', [App\Controller\UserController::class, 'companies']);
Route::post('/user/switch-company', [App\Controller\UserController::class, 'switchCompany']);
});
// 审批路由
Route::group(function () {
Route::get('/approval/list', [App\Controller\ApprovalController::class, 'list']);
Route::get('/approval/detail/{id}', [App\Controller\ApprovalController::class, 'detail']);
Route::post('/approval/submit', [App\Controller\ApprovalController::class, 'submit']);
Route::post('/approval/approve/{id}', [App\Controller\ApprovalController::class, 'approve']);
Route::post('/approval/reject/{id}', [App\Controller\ApprovalController::class, 'reject']);
Route::post('/approval/cancel/{id}', [App\Controller\ApprovalController::class, 'cancel']);
});
// 出差审批路由
Route::group(function () {
Route::get('/business-trip/list', [App\Controller\BusinessTripController::class, 'list']);
Route::get('/business-trip/detail/{id}', [App\Controller\BusinessTripController::class, 'detail']);
Route::post('/business-trip/apply', [App\Controller\BusinessTripController::class, 'apply']);
Route::put('/business-trip/{id}', [App\Controller\BusinessTripController::class, 'update']);
});
});
// 默认路由
Route::fallback(function () {
return json(['code' => 404, 'message' => 'Not Found']);
});
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* Webman server configuration
*/
use support\Log;
return [
'listen' => 'http://0.0.0.0:8787',
'transport' => 'tcp',
'context' => [],
'name' => 'webman',
'count' => (int)shell_exec('nproc') ?: 4,
'user' => '',
'group' => '',
'reusePort' => false,
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'max_request' => 1000000,
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024,
];
+196
View File
@@ -0,0 +1,196 @@
-- 企业内部工具系统 - 数据库初始化脚本
-- 数据库: x_server_mobile
-- =====================================
-- 1. 公司表(飞书应用配置)
-- =====================================
CREATE TABLE IF NOT EXISTS `company` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '公司ID',
`name` VARCHAR(100) NOT NULL COMMENT '公司名称',
`feishu_app_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书应用ID',
`feishu_app_secret` VARCHAR(255) DEFAULT NULL COMMENT '飞书应用密钥',
`feishu_tenant_key` VARCHAR(100) DEFAULT NULL COMMENT '飞书租户Key',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_feishu_app_id` (`feishu_app_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司表';
-- =====================================
-- 2. 用户表(全局用户)
-- =====================================
CREATE TABLE IF NOT EXISTS `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`company_id` BIGINT UNSIGNED NOT NULL COMMENT '所属公司ID',
`feishu_user_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书用户ID',
`feishu_union_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书UnionID(跨应用唯一)',
`name` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=正常,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_company_id` (`company_id`),
KEY `idx_feishu_user_id` (`feishu_user_id`),
KEY `idx_feishu_union_id` (`feishu_union_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- =====================================
-- 3. 用户与飞书应用关联表
-- =====================================
CREATE TABLE IF NOT EXISTS `user_feishu_binding` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`company_id` BIGINT UNSIGNED NOT NULL COMMENT '公司ID',
`feishu_open_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书OpenID',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_company` (`user_id`, `company_id`),
KEY `idx_feishu_open_id` (`feishu_open_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户飞书应用绑定表';
-- =====================================
-- 4. 审批流程配置表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_flow` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '流程ID',
`business_type` VARCHAR(50) NOT NULL COMMENT '业务类型标识(如:business-trip, purchase)',
`name` VARCHAR(100) NOT NULL COMMENT '流程名称',
`description` VARCHAR(255) DEFAULT NULL COMMENT '流程描述',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business_type` (`business_type`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程配置表';
-- =====================================
-- 5. 审批流程节点表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_node` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '节点ID',
`flow_id` BIGINT UNSIGNED NOT NULL COMMENT '流程ID',
`type` ENUM('approver', 'and_approve', 'or_approve', 'notify') NOT NULL COMMENT '节点类型',
`name` VARCHAR(100) NOT NULL COMMENT '节点名称',
`order` INT NOT NULL DEFAULT 0 COMMENT '节点顺序',
`approvers` JSON DEFAULT NULL COMMENT '审批人列表(JSON数组)',
`conditions` JSON DEFAULT NULL COMMENT '条件判断(JSON)',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_flow_id` (`flow_id`),
KEY `idx_order` (`order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程节点表';
-- =====================================
-- 6. 审批单表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_order` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审批单ID',
`business_type` VARCHAR(50) NOT NULL COMMENT '业务类型',
`business_id` BIGINT UNSIGNED NOT NULL COMMENT '业务单据ID',
`title` VARCHAR(200) NOT NULL COMMENT '审批标题',
`applicant_id` BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
`status` ENUM('draft', 'pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'draft' COMMENT '审批状态',
`current_nodes` JSON DEFAULT NULL COMMENT '当前待审批节点ID列表',
`flow_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的流程ID',
`form_data` JSON DEFAULT NULL COMMENT '业务表单数据(JSON)',
`attachments` JSON DEFAULT NULL COMMENT '附件列表(JSON)',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business` (`business_type`, `business_id`),
KEY `idx_applicant` (`applicant_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批单表';
-- =====================================
-- 7. 审批记录表(审批轨迹)
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_record` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`order_id` BIGINT UNSIGNED NOT NULL COMMENT '审批单ID',
`node_id` BIGINT UNSIGNED NOT NULL COMMENT '节点ID',
`node_type` VARCHAR(20) NOT NULL COMMENT '节点类型',
`approver_id` BIGINT UNSIGNED NOT NULL COMMENT '审批人ID',
`action` ENUM('submit', 'approve', 'reject', 'cancel') NOT NULL COMMENT '操作类型',
`comment` VARCHAR(500) DEFAULT NULL COMMENT '审批意见',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_approver_id` (`approver_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批记录表';
-- =====================================
-- 8. 出差申请表
-- =====================================
CREATE TABLE IF NOT EXISTS `business_trip` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '出差ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
`reason` VARCHAR(500) NOT NULL COMMENT '出差事由',
`start_date` DATE NOT NULL COMMENT '开始日期',
`end_date` DATE NOT NULL COMMENT '结束日期',
`destination` VARCHAR(200) NOT NULL COMMENT '目的地',
`estimated_cost` DECIMAL(10,2) DEFAULT 0.00 COMMENT '预计费用',
`status` ENUM('draft', 'pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'draft' COMMENT '状态',
`approval_order_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联审批单ID',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_dates` (`start_date`, `end_date`),
KEY `idx_approval_order_id` (`approval_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出差申请表';
-- =====================================
-- 9. 出差行程明细表
-- =====================================
CREATE TABLE IF NOT EXISTS `business_trip_itinerary` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '行程ID',
`business_trip_id` BIGINT UNSIGNED NOT NULL COMMENT '出差申请ID',
`date` DATE NOT NULL COMMENT '行程日期',
`departure` VARCHAR(100) DEFAULT NULL COMMENT '出发地',
`destination` VARCHAR(100) NOT NULL COMMENT '目的地',
`transport` VARCHAR(50) DEFAULT NULL COMMENT '交通方式',
`accommodation` VARCHAR(100) DEFAULT NULL COMMENT '住宿安排',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business_trip_id` (`business_trip_id`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出差行程明细表';
-- =====================================
-- 初始化数据
-- =====================================
-- 插入默认公司
INSERT INTO `company` (`id`, `name`, `status`) VALUES
(1, '示例公司', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认用户
INSERT INTO `user` (`id`, `company_id`, `name`, `email`, `status`) VALUES
(1, 1, '管理员', 'admin@example.com', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认出差审批流程
INSERT INTO `approval_flow` (`id`, `business_type`, `name`, `status`) VALUES
(1, 'business-trip', '默认出差审批流程', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认审批节点
INSERT INTO `approval_node` (`flow_id`, `type`, `name`, `order`, `approvers`) VALUES
(1, 'approver', '部门主管审批', 1, '[]'),
(1, 'approver', '人事审批', 2, '[]')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* English translation file
*/
return [
'app' => [
'name' => 'Enterprise Tool System',
'title' => 'Enterprise Internal Tool Platform',
],
'common' => [
'success' => 'Operation successful',
'error' => 'Operation failed',
'confirm' => 'Confirm',
'cancel' => 'Cancel',
'submit' => 'Submit',
'save' => 'Save',
'delete' => 'Delete',
'edit' => 'Edit',
'search' => 'Search',
'refresh' => 'Refresh',
'back' => 'Back',
'close' => 'Close',
'loading' => 'Loading...',
'no_data' => 'No data',
'operation' => 'Operation',
'status' => 'Status',
'create_time' => 'Create Time',
'update_time' => 'Update Time',
'remark' => 'Remark',
],
'auth' => [
'login_success' => 'Login successful',
'login_failed' => 'Login failed',
'username_required' => 'Username is required',
'password_required' => 'Password is required',
'invalid_credentials' => 'Invalid username or password',
'token_expired' => 'Session expired, please login again',
'unauthorized' => 'Unauthorized, please login first',
],
'user' => [
'not_found' => 'User not found',
'info_updated' => 'Profile updated successfully',
'company_switched' => 'Company switched successfully',
],
'approval' => [
'submit_success' => 'Approval submitted successfully',
'approve_success' => 'Approved successfully',
'reject_success' => 'Rejected successfully',
'cancel_success' => 'Cancelled successfully',
'not_found' => 'Approval not found',
'cannot_approve' => 'Cannot approve in current status',
'cannot_cancel' => 'Cannot cancel in current status',
'not_owner' => 'Only the applicant can cancel',
'status' => [
'pending' => 'Pending',
'approved' => 'Approved',
'rejected' => 'Rejected',
'cancelled' => 'Cancelled',
],
],
'business_trip' => [
'submit_success' => 'Business trip submitted successfully',
'update_success' => 'Business trip updated successfully',
'not_found' => 'Business trip not found',
],
'error' => [
'network_error' => 'Network error, please try again later',
'server_error' => 'Server error',
'not_found' => 'Page not found',
'unknown_error' => 'Unknown error',
'params_error' => 'Parameter error',
'validation_error' => 'Validation failed',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Traducción al español (Spanish translation)
*/
return [
'app' => [
'name' => 'Sistema Empresarial',
'title' => 'Plataforma de Herramientas Empresariales',
],
'common' => [
'success' => 'Operación exitosa',
'error' => 'Operación fallida',
'confirm' => 'Confirmar',
'cancel' => 'Cancelar',
'submit' => 'Enviar',
'save' => 'Guardar',
'delete' => 'Eliminar',
'edit' => 'Editar',
'search' => 'Buscar',
'refresh' => 'Actualizar',
'back' => 'Atrás',
'close' => 'Cerrar',
'loading' => 'Cargando...',
'no_data' => 'Sin datos',
'operation' => 'Operación',
'status' => 'Estado',
'create_time' => 'Fecha de creación',
'update_time' => 'Fecha de actualización',
'remark' => 'Observación',
],
'auth' => [
'login_success' => 'Inicio de sesión exitoso',
'login_failed' => 'Error de inicio de sesión',
'username_required' => 'Se requiere usuario',
'password_required' => 'Se requiere contraseña',
'invalid_credentials' => 'Usuario o contraseña inválidos',
'token_expired' => 'Sesión expirada, inicie sesión nuevamente',
'unauthorized' => 'No autorizado, inicie sesión primero',
],
'user' => [
'not_found' => 'Usuario no encontrado',
'info_updated' => 'Perfil actualizado exitosamente',
'company_switched' => 'Empresa cambiada exitosamente',
],
'approval' => [
'submit_success' => 'Aprobación enviada exitosamente',
'approve_success' => 'Aprobado exitosamente',
'reject_success' => 'Rechazado exitosamente',
'cancel_success' => 'Cancelado exitosamente',
'not_found' => 'Aprobación no encontrada',
'cannot_approve' => 'No se puede aprobar en estado actual',
'cannot_cancel' => 'No se puede cancelar en estado actual',
'not_owner' => 'Solo el solicitante puede cancelar',
'status' => [
'pending' => 'Pendiente',
'approved' => 'Aprobado',
'rejected' => 'Rechazado',
'cancelled' => 'Cancelado',
],
],
'business_trip' => [
'submit_success' => 'Viaje enviado exitosamente',
'update_success' => 'Viaje actualizado exitosamente',
'not_found' => 'Viaje no encontrado',
],
'error' => [
'network_error' => 'Error de red, inténtelo más tarde',
'server_error' => 'Error del servidor',
'not_found' => 'Página no encontrada',
'unknown_error' => 'Error desconocido',
'params_error' => 'Error de parámetros',
'validation_error' => 'Validación fallida',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Traduction française (French translation)
*/
return [
'app' => [
'name' => 'Système d\'Entreprise',
'title' => 'Plateforme d\'Outils Entreprise',
],
'common' => [
'success' => 'Opération réussie',
'error' => 'Opération échouée',
'confirm' => 'Confirmer',
'cancel' => 'Annuler',
'submit' => 'Soumettre',
'save' => 'Enregistrer',
'delete' => 'Supprimer',
'edit' => 'Modifier',
'search' => 'Rechercher',
'refresh' => 'Actualiser',
'back' => 'Retour',
'close' => 'Fermer',
'loading' => 'Chargement...',
'no_data' => 'Aucune donnée',
'operation' => 'Opération',
'status' => 'Statut',
'create_time' => 'Date de création',
'update_time' => 'Date de mise à jour',
'remark' => 'Remarque',
],
'auth' => [
'login_success' => 'Connexion réussie',
'login_failed' => 'Échec de la connexion',
'username_required' => 'Nom d\'utilisateur requis',
'password_required' => 'Mot de passe requis',
'invalid_credentials' => 'Nom d\'utilisateur ou mot de passe invalide',
'token_expired' => 'Session expirée, veuillez vous reconnecter',
'unauthorized' => 'Non autorisé, veuillez vous connecter d\'abord',
],
'user' => [
'not_found' => 'Utilisateur introuvable',
'info_updated' => 'Profil mis à jour avec succès',
'company_switched' => 'Entreprise changée avec succès',
],
'approval' => [
'submit_success' => 'Approbation soumise avec succès',
'approve_success' => 'Approuvé avec succès',
'reject_success' => 'Rejeté avec succès',
'cancel_success' => 'Annulé avec succès',
'not_found' => 'Approbation introuvable',
'cannot_approve' => 'Impossible d\'approuver dans cet état',
'cannot_cancel' => 'Impossible d\'annuler dans cet état',
'not_owner' => 'Seul le demandeur peut annuler',
'status' => [
'pending' => 'En attente',
'approved' => 'Approuvé',
'rejected' => 'Rejeté',
'cancelled' => 'Annulé',
],
],
'business_trip' => [
'submit_success' => 'Déplacement soumis avec succès',
'update_success' => 'Déplacement mis à jour avec succès',
'not_found' => 'Déplacement introuvable',
],
'error' => [
'network_error' => 'Erreur réseau, veuillez réessayer plus tard',
'server_error' => 'Erreur du serveur',
'not_found' => 'Page introuvable',
'unknown_error' => 'Erreur inconnue',
'params_error' => 'Erreur de paramètres',
'validation_error' => 'Validation échouée',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Русский перевод (Russian translation)
*/
return [
'app' => [
'name' => 'Корпоративная система',
'title' => 'Корпоративная платформа инструментов',
],
'common' => [
'success' => 'Операция выполнена успешно',
'error' => 'Операция не удалась',
'confirm' => 'Подтвердить',
'cancel' => 'Отмена',
'submit' => 'Отправить',
'save' => 'Сохранить',
'delete' => 'Удалить',
'edit' => 'Редактировать',
'search' => 'Поиск',
'refresh' => 'Обновить',
'back' => 'Назад',
'close' => 'Закрыть',
'loading' => 'Загрузка...',
'no_data' => 'Нет данных',
'operation' => 'Операция',
'status' => 'Статус',
'create_time' => 'Время создания',
'update_time' => 'Время обновления',
'remark' => 'Примечание',
],
'auth' => [
'login_success' => 'Вход выполнен успешно',
'login_failed' => 'Ошибка входа',
'username_required' => 'Имя пользователя обязательно',
'password_required' => 'Пароль обязателен',
'invalid_credentials' => 'Неверное имя пользователя или пароль',
'token_expired' => 'Сессия истекла, войдите снова',
'unauthorized' => 'Не авторизован, войдите сначала',
],
'user' => [
'not_found' => 'Пользователь не найден',
'info_updated' => 'Профиль обновлен успешно',
'company_switched' => 'Компания изменена успешно',
],
'approval' => [
'submit_success' => 'Согласование отправлено успешно',
'approve_success' => 'Согласовано успешно',
'reject_success' => 'Отклонено успешно',
'cancel_success' => 'Отменено успешно',
'not_found' => 'Согласование не найдено',
'cannot_approve' => 'Невозможно согласовать в текущем статусе',
'cannot_cancel' => 'Невозможно отменить в текущем статусе',
'not_owner' => 'Только заявитель может отменить',
'status' => [
'pending' => 'Ожидает',
'approved' => 'Согласовано',
'rejected' => 'Отклонено',
'cancelled' => 'Отменено',
],
],
'business_trip' => [
'submit_success' => 'Командировка отправлена успешно',
'update_success' => 'Командировка обновлена успешно',
'not_found' => 'Командировка не найдена',
],
'error' => [
'network_error' => 'Ошибка сети, попробуйте позже',
'server_error' => 'Ошибка сервера',
'not_found' => 'Страница не найдена',
'unknown_error' => 'Неизвестная ошибка',
'params_error' => 'Ошибка параметров',
'validation_error' => 'Ошибка проверки данных',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* 中文翻译文件
*/
return [
'app' => [
'name' => '企业工具系统',
'title' => '企业内部工具平台',
],
'common' => [
'success' => '操作成功',
'error' => '操作失败',
'confirm' => '确认',
'cancel' => '取消',
'submit' => '提交',
'save' => '保存',
'delete' => '删除',
'edit' => '编辑',
'search' => '搜索',
'refresh' => '刷新',
'back' => '返回',
'close' => '关闭',
'loading' => '加载中...',
'no_data' => '暂无数据',
'operation' => '操作',
'status' => '状态',
'create_time' => '创建时间',
'update_time' => '更新时间',
'remark' => '备注',
],
'auth' => [
'login_success' => '登录成功',
'login_failed' => '登录失败',
'username_required' => '用户名不能为空',
'password_required' => '密码不能为空',
'invalid_credentials' => '用户名或密码错误',
'token_expired' => '登录已过期,请重新登录',
'unauthorized' => '未授权,请先登录',
],
'user' => [
'not_found' => '用户不存在',
'info_updated' => '个人信息更新成功',
'company_switched' => '公司切换成功',
],
'approval' => [
'submit_success' => '审批提交成功',
'approve_success' => '审批通过',
'reject_success' => '已拒绝',
'cancel_success' => '已撤销',
'not_found' => '审批单不存在',
'cannot_approve' => '当前状态无法审批',
'cannot_cancel' => '当前状态无法撤销',
'not_owner' => '只有申请人可以撤销',
'status' => [
'pending' => '待审批',
'approved' => '已通过',
'rejected' => '已拒绝',
'cancelled' => '已撤销',
],
],
'business_trip' => [
'submit_success' => '出差申请提交成功',
'update_success' => '出差申请更新成功',
'not_found' => '出差申请不存在',
],
'error' => [
'network_error' => '网络错误,请稍后重试',
'server_error' => '服务器错误',
'not_found' => '页面不存在',
'unknown_error' => '未知错误',
'params_error' => '参数错误',
'validation_error' => '数据验证失败',
],
];
+1
View File
@@ -0,0 +1 @@
1
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace support;
use Webman\App;
use Workerman\Worker;
class Bootstrap
{
public static function start(?Worker $worker): void
{
// 应用启动时的初始化
error_reporting(E_ALL);
ini_set('display_errors', 'on');
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Dotenv\Dotenv;
use support\Log;
use Webman\Bootstrap;
use Webman\Config;
use Webman\Middleware;
use Webman\Route;
use Webman\Util;
use Workerman\Events\Select;
use Workerman\Worker;
$worker = $worker ?? null;
if (empty(Worker::$eventLoopClass)) {
Worker::$eventLoopClass = Select::class;
}
set_error_handler(function ($level, $message, $file = '', $line = 0) {
if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
}
});
if ($worker) {
register_shutdown_function(function ($startTime) {
if (time() - $startTime <= 0.1) {
sleep(1);
}
}, time());
}
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
Dotenv::createUnsafeMutable(base_path(false))->load();
} else {
Dotenv::createMutable(base_path(false))->load();
}
}
Config::clear();
support\App::loadAllConfig(['route']);
if ($timezone = config('app.default_timezone')) {
date_default_timezone_set($timezone);
}
foreach (config('autoload.files', []) as $file) {
include_once $file;
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
foreach ($projects['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
Middleware::load(config('middleware', []));
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project) || $name === 'static') {
continue;
}
Middleware::load($project['middleware'] ?? []);
}
Middleware::load($projects['middleware'] ?? [], $firm);
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
Middleware::load(['__static__' => $staticMiddlewares], $firm);
}
}
Middleware::load(['__static__' => config('static.middleware', [])]);
foreach (config('bootstrap', []) as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['bootstrap'] ?? [] as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
foreach ($projects['bootstrap'] ?? [] as $className) {
/** @var string $className */
if (!class_exists($className)) {
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
$directory = base_path() . '/plugin';
$paths = [config_path()];
foreach (Util::scanDir($directory) as $path) {
if (is_dir($path = "$path/config")) {
$paths[] = $path;
}
}
Route::load($paths);
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env php
<?php
use Webman\Config;
use Webman\Console\Command;
use Webman\Console\Util;
use support\Container;
use Dotenv\Dotenv;
if (!Phar::running()) {
chdir(__DIR__);
}
require_once __DIR__ . '/vendor/autoload.php';
if (!$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
if (class_exists(Dotenv::class) && file_exists(run_path('.env'))) {
if (method_exists(Dotenv::class, 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(run_path())->load();
} else {
Dotenv::createMutable(run_path())->load();
}
}
$appConfig = require $appConfigFile;
if ($timezone = $appConfig['default_timezone'] ?? '') {
date_default_timezone_set($timezone);
}
if ($errorReporting = $appConfig['error_reporting'] ?? '') {
error_reporting($errorReporting);
}
if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) {
require_once __DIR__ . '/support/bootstrap.php';
} else {
if (class_exists('Support\App')) {
Support\App::loadAllConfig(['route']);
} else {
Config::reload(config_path(), ['route', 'container']);
}
}
$cli = new Command();
$cli->setName('webman cli');
$cli->installInternalCommands();
if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) {
$cli->installCommands($command_path);
}
foreach (config('plugin', []) as $firm => $projects) {
if (isset($projects['app'])) {
foreach (['', '/app'] as $app) {
if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) {
$command_path = base_path() . "/plugin/$firm{$app}/$command_str";
$cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str");
}
}
}
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
$project['command'] ??= [];
array_walk($project['command'], [$cli, 'createCommandInstance']);
}
}
$cli->run();
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Start file for windows
*/
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
use support\App;
use Workerman\Worker;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(base_path())->load();
} else {
Dotenv::createMutable(base_path())->load();
}
}
App::loadAllConfig(['route']);
$errorReporting = config('app.error_reporting');
if (isset($errorReporting)) {
error_reporting($errorReporting);
}
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
$paths = [
$runtimeProcessPath,
runtime_path('logs'),
runtime_path('views')
];
foreach ($paths as $path) {
if (!is_dir($path)) {
mkdir($path, 0777, true);
}
}
$processFiles = [];
if (config('server.listen')) {
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
}
foreach (config('process', []) as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
}
}
foreach ($projects['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
}
}
function write_process_file($runtimeProcessPath, $processName, $firm): string
{
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
$fileContent = <<<EOF
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Webman\Config;
use support\App;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (is_callable('opcache_reset')) {
opcache_reset();
}
if (!\$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
\$appConfig = require \$appConfigFile;
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
date_default_timezone_set(\$timezone);
}
App::loadAllConfig(['route']);
worker_start('$processParam', $configParam);
if (DIRECTORY_SEPARATOR != "/") {
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
}
Worker::runAll();
EOF;
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
file_put_contents($processFile, $fileContent);
return $processFile;
}
if ($monitorConfig = config('process.monitor.constructor')) {
$monitorHandler = config('process.monitor.handler');
$monitor = new $monitorHandler(...array_values($monitorConfig));
}
function popen_processes($processFiles)
{
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
$descriptorspec = [STDIN, STDOUT, STDOUT];
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
if (!$resource) {
exit("Can not execute $cmd\r\n");
}
return $resource;
}
$resource = popen_processes($processFiles);
echo "\r\n";
while (1) {
sleep(1);
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
$status = proc_get_status($resource);
$pid = $status['pid'];
shell_exec("taskkill /F /T /PID $pid");
proc_close($resource);
$resource = popen_processes($processFiles);
}
}