feat: 初始化进销存数据开放平台项目

初始化完整的前后端项目结构,包含ThinkPHP后端API服务和Next.js前端管理系统,添加基础配置、中间件、模型、路由等核心代码,以及项目文档和静态资源。
This commit is contained in:
cenzuhai
2026-07-08 17:48:00 +08:00
parent 6713e13ea6
commit 3a04eede84
99 changed files with 17002 additions and 0 deletions

1
thinkphp/app/.htaccess Normal file
View File

@@ -0,0 +1 @@
deny from all

View File

@@ -0,0 +1,22 @@
<?php
declare (strict_types = 1);
namespace app;
use think\Service;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
// 服务注册
}
public function boot()
{
// 服务启动
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare (strict_types = 1);
namespace app;
use think\App;
use think\exception\ValidateException;
use think\Validate;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
$this->request = $this->app->request;
// 控制器初始化
$this->initialize();
}
// 初始化
protected function initialize()
{}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace app;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Response;
use Throwable;
/**
* 应用异常处理类
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录)
*
* @access public
* @param Throwable $exception
* @return void
*/
public function report(Throwable $exception): void
{
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @access public
* @param \think\Request $request
* @param Throwable $e
* @return Response
*/
public function render($request, Throwable $e): Response
{
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
}

8
thinkphp/app/Request.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace app;
// 应用请求对象类
class Request extends \think\Request
{
}

2
thinkphp/app/common.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// 应用公共文件

View File

@@ -0,0 +1,17 @@
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V' . \think\facade\App::version() . '<br/><span style="font-size:30px;">16载初心不改 - 你值得信赖的PHP框架</span></p><span style="font-size:25px;">[ V6.0 版本由 <a href="https://www.yisu.com/" target="yisu">亿速云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ee9b1aa918103c4fc"></think>';
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\controller\api\v1;
use app\service\DataIsolation;
use app\service\MssqlQuery;
use app\model\ApiLog;
use think\facade\Log;
use think\Request;
class Chuku
{
/**
* 销售数据拉取接口
*/
public function index(Request $request)
{
$startTime = microtime(true);
$authContext = $request->authContext;
$permission = $authContext['permission'];
// 1. 数据隔离
try {
$supplierName = $request->param('supplier_name', '');
$isolation = DataIsolation::buildIsolationClause($permission, 'chuku', $supplierName);
} catch (\Exception $e) {
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/chuku';
$logEntry->request_params = $request->param();
$logEntry->response_code = 403;
$logEntry->response_body = ['code' => 403, 'msg' => $e->getMessage()];
$logEntry->record_count = 0;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = substr($e->getMessage(), 0, 500);
$logEntry->save();
return json([
'code' => 403,
'msg' => $e->getMessage()
], 403);
}
// 2. 获取请求参数
$startDate = $request->param('start_date');
$endDate = $request->param('end_date');
$page = (int)$request->param('page', 1);
$limit = min((int)$request->param('limit', 50), env('rate_limit.max_page_size', 500));
// 3. 查询数据
$errorMsg = null;
$sqlQuery = '';
try {
$result = MssqlQuery::queryChuku(
$startDate,
$endDate,
$page,
$limit,
$isolation['sql'],
$isolation['params']
);
$records = $result['records'];
$total = $result['total'];
$sqlQuery = $result['sql'];
} catch (\Exception $e) {
Log::error('销售查询失败: ' . $e->getMessage());
$errorMsg = $e->getMessage();
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/chuku';
$logEntry->request_params = [
'start_date' => $startDate,
'end_date' => $endDate,
'supplier_name' => $supplierName,
'page' => $page,
'limit' => $limit,
];
$logEntry->response_code = 500;
$logEntry->response_body = ['code' => 500, 'msg' => '数据查询失败: ' . $e->getMessage()];
$logEntry->record_count = 0;
$logEntry->sql_query = $sqlQuery;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = substr($e->getMessage(), 0, 500);
$logEntry->save();
return json([
'code' => 500,
'msg' => '数据查询失败: ' . $e->getMessage()
], 500);
}
// 4. 记录日志
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/chuku';
$logEntry->request_params = [
'start_date' => $startDate,
'end_date' => $endDate,
'supplier_name' => $supplierName,
'page' => $page,
'limit' => $limit,
];
$logEntry->response_code = 200;
$logEntry->response_body = [
'total' => $total,
'count' => count($records)
];
$logEntry->record_count = count($records);
$logEntry->sql_query = $sqlQuery;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = $errorMsg ? substr($errorMsg, 0, 500) : null;
$logEntry->save();
return json([
'code' => 200,
'msg' => 'success',
'total' => $total,
'data' => $records
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\controller\api\v1;
use app\service\DataIsolation;
use app\service\MssqlQuery;
use app\model\ApiLog;
use think\facade\Log;
use think\Request;
class Ruku
{
/**
* 入库数据拉取接口
*/
public function index(Request $request)
{
$startTime = microtime(true);
$authContext = $request->authContext;
$permission = $authContext['permission'];
// 1. 数据隔离
try {
$supplierName = $request->param('supplier_name', '');
$isolation = DataIsolation::buildIsolationClause($permission, 'ruku', $supplierName);
} catch (\Exception $e) {
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/ruku';
$logEntry->request_params = $request->param();
$logEntry->response_code = 403;
$logEntry->response_body = ['code' => 403, 'msg' => $e->getMessage()];
$logEntry->record_count = 0;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = substr($e->getMessage(), 0, 500);
$logEntry->save();
return json([
'code' => 403,
'msg' => $e->getMessage()
], 403);
}
// 2. 获取请求参数
$startDate = $request->param('start_date');
$endDate = $request->param('end_date');
$page = (int)$request->param('page', 1);
$limit = min((int)$request->param('limit', 50), env('rate_limit.max_page_size', 500));
// 3. 查询数据
$errorMsg = null;
$sqlQuery = '';
try {
$result = MssqlQuery::queryRuku(
$startDate,
$endDate,
$page,
$limit,
$isolation['sql'],
$isolation['params']
);
$records = $result['records'];
$total = $result['total'];
$sqlQuery = $result['sql'];
} catch (\Exception $e) {
Log::error('入库查询失败: ' . $e->getMessage());
$errorMsg = $e->getMessage();
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/ruku';
$logEntry->request_params = [
'start_date' => $startDate,
'end_date' => $endDate,
'supplier_name' => $supplierName,
'page' => $page,
'limit' => $limit,
];
$logEntry->response_code = 500;
$logEntry->response_body = ['code' => 500, 'msg' => '数据查询失败: ' . $e->getMessage()];
$logEntry->record_count = 0;
$logEntry->sql_query = $sqlQuery;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = substr($e->getMessage(), 0, 500);
$logEntry->save();
return json([
'code' => 500,
'msg' => '数据查询失败: ' . $e->getMessage()
], 500);
}
// 4. 记录日志
$durationMs = (int)((microtime(true) - $startTime) * 1000);
$logEntry = new ApiLog();
$logEntry->api_key_id = $authContext['api_key_id'];
$logEntry->client_id = $authContext['client_id'];
$logEntry->endpoint = '/api/v1/ruku';
$logEntry->request_params = [
'start_date' => $startDate,
'end_date' => $endDate,
'supplier_name' => $supplierName,
'page' => $page,
'limit' => $limit,
];
$logEntry->response_code = 200;
$logEntry->response_body = [
'total' => $total,
'count' => count($records)
];
$logEntry->record_count = count($records);
$logEntry->sql_query = $sqlQuery;
$logEntry->duration_ms = $durationMs;
$logEntry->ip_address = $request->ip();
$logEntry->error_message = $errorMsg ? substr($errorMsg, 0, 500) : null;
$logEntry->save();
return json([
'code' => 200,
'msg' => 'success',
'total' => $total,
'data' => $records
]);
}
}

17
thinkphp/app/event.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];

View File

@@ -0,0 +1,12 @@
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class,
// 跨域处理
\app\middleware\CrossDomain::class,
];

View File

@@ -0,0 +1,55 @@
<?php
namespace app\middleware;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use think\Request;
use think\Response;
/**
* 管理员认证中间件
*/
class AdminAuth
{
/**
* 处理请求
*/
public function handle(Request $request, \Closure $next)
{
$token = $request->header('Authorization');
if (empty($token)) {
return json([
'code' => 401,
'msg' => '未提供认证令牌'
], 401);
}
// 移除 "Bearer " 前缀
if (strpos($token, 'Bearer ') === 0) {
$token = substr($token, 7);
}
try {
$secretKey = env('jwt.secret_key', 'your-super-secret-key-change-in-production-32chars!');
$algorithm = env('jwt.algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secretKey, $algorithm));
$payload = (array)$decoded;
// 将管理员信息存入请求
$request->adminUser = [
'id' => $payload['sub'],
'username' => $payload['username'],
];
return $next($request);
} catch (\Exception $e) {
return json([
'code' => 401,
'msg' => '无效的认证令牌'
], 401);
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\middleware;
use app\model\ApiKey;
use app\model\Client;
use app\model\Permission;
use app\service\Signature;
use think\Request;
use think\Response;
/**
* API鉴权中间件
*/
class Auth
{
/**
* 处理请求
*/
public function handle(Request $request, \Closure $next)
{
// 1. 提取必要Headers
$apiKey = $request->header('X-API-Key');
$timestamp = $request->header('X-Timestamp');
$sign = $request->header('X-Sign');
if (empty($apiKey) || empty($timestamp) || empty($sign)) {
return json([
'code' => 401,
'msg' => '缺少鉴权头: 需要 X-API-Key, X-Timestamp, X-Sign'
], 401);
}
// 2. 查询API Key连带查询client和permission
$apiKeyObj = ApiKey::with(['client', 'permission'])
->where('api_key', $apiKey)
->find();
if (!$apiKeyObj) {
return json([
'code' => 401,
'msg' => '无效的 API Key'
], 401);
}
// 3. 检查状态
if ($apiKeyObj->status != ApiKey::STATUS_ENABLED) {
return json([
'code' => 403,
'msg' => 'API Key 已被禁用'
], 403);
}
$clientObj = $apiKeyObj->client;
if (!$clientObj || $clientObj->status != Client::STATUS_ENABLED) {
return json([
'code' => 403,
'msg' => '客户账号已被停用API 访问被拒绝'
], 403);
}
// 4. 验证签名
$params = $request->param();
if (!Signature::verify($params, $timestamp, $sign, $apiKeyObj->api_secret_hash)) {
return json([
'code' => 401,
'msg' => '签名验证失败'
], 401);
}
// 5. 检查权限配置
$permission = Permission::where('api_key_id', $apiKeyObj->id)->find();
if (!$permission) {
return json([
'code' => 403,
'msg' => '未配置 API 权限,请联系管理员'
], 403);
}
// 6. 将鉴权上下文存入请求
$request->authContext = [
'api_key_id' => $apiKeyObj->id,
'api_key' => $apiKeyObj->api_key,
'client_id' => $clientObj->id,
'client_name' => $clientObj->name,
'permission' => $permission,
'rate_limit_per_minute' => $apiKeyObj->rate_limit_per_minute,
'rate_limit_per_day' => $apiKeyObj->rate_limit_per_day,
];
return $next($request);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\middleware;
class CrossDomain
{
public function handle($request, \Closure $next)
{
$header = [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type,Authorization,Token,X-Requested-With,X-API-Key,X-Timestamp,X-Sign',
'Access-Control-Max-Age' => 1800,
];
if ($request->method() == 'OPTIONS') {
return response('', 204)->header($header);
}
$response = $next($request);
$response->header($header);
return $response;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace app\middleware;
use think\facade\Cache;
use think\Request;
class RateLimit
{
public function handle(Request $request, \Closure $next)
{
$authContext = $request->authContext ?? null;
if (!$authContext) {
return $next($request);
}
$apiKeyId = $authContext['api_key_id'];
$limitPerMinute = $authContext['rate_limit_per_minute'] ?? 60;
$limitPerDay = $authContext['rate_limit_per_day'] ?? 5000;
$minuteKey = 'rate_limit:minute:' . $apiKeyId;
$dayKey = 'rate_limit:day:' . $apiKeyId . ':' . date('Ymd');
$minuteCount = $this->getCount($minuteKey);
if ($minuteCount >= $limitPerMinute) {
return json([
'code' => 429,
'msg' => '请求过于频繁,请稍后再试'
], 429);
}
$dayCount = $this->getCount($dayKey);
if ($dayCount >= $limitPerDay) {
return json([
'code' => 429,
'msg' => '今日请求次数已达上限'
], 429);
}
$this->increment($minuteKey, 60);
$this->increment($dayKey, 86400);
return $next($request);
}
private function getCount($key)
{
try {
$value = Cache::get($key);
return $value !== false ? (int)$value : 0;
} catch (\Exception $e) {
return 0;
}
}
private function increment($key, $ttl)
{
try {
$current = $this->getCount($key);
$newValue = $current + 1;
Cache::set($key, $newValue, $ttl);
return $newValue;
} catch (\Exception $e) {
return 1;
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace app\model;
use think\Model;
class AdminUser extends Model
{
protected $name = 'admin_users';
protected $pk = 'id';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = false;
const STATUS_ENABLED = 1;
const STATUS_DISABLED = 0;
const ROLE_ADMIN = 'admin';
const ROLE_VIEWER = 'viewer';
public function checkPassword($password)
{
return password_verify($password, $this->getAttr('password_hash') ?? '');
}
public static function getRoles()
{
return [
self::ROLE_ADMIN => '管理员',
self::ROLE_VIEWER => '查看员',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\model;
use think\Model;
class ApiKey extends Model
{
protected $name = 'api_keys';
protected $pk = 'id';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = false;
// 状态枚举
const STATUS_ENABLED = 1;
const STATUS_DISABLED = 0;
/**
* 关联客户
*/
public function client()
{
return $this->belongsTo(Client::class, 'client_id');
}
/**
* 关联权限
*/
public function permission()
{
return $this->hasOne(Permission::class, 'api_key_id');
}
/**
* 关联日志
*/
public function logs()
{
return $this->hasMany(ApiLog::class, 'api_key_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace app\model;
use think\Model;
class ApiLog extends Model
{
protected $name = 'api_logs';
protected $pk = 'id';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = false;
protected $json = ['request_params', 'response_body'];
protected $jsonAssoc = true;
/**
* 关联API密钥
*/
public function apiKey()
{
return $this->belongsTo(ApiKey::class, 'api_key_id');
}
/**
* 关联客户
*/
public function client()
{
return $this->belongsTo(Client::class, 'client_id');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace app\model;
use think\Model;
class Client extends Model
{
protected $name = 'clients';
protected $pk = 'id';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $append = ['api_keys'];
// 客户类型枚举
const TYPE_SUPPLIER = 'supplier';
const TYPE_DISTRIBUTOR = 'distributor';
const TYPE_HOSPITAL = 'hospital';
const TYPE_OTHER = 'other';
// 状态枚举
const STATUS_ENABLED = 1;
const STATUS_DISABLED = 0;
/**
* 关联API密钥
*/
public function apiKeys()
{
return $this->hasMany(ApiKey::class, 'client_id');
}
/**
* 获取api_keys字段下划线命名
*/
public function getApiKeysAttr()
{
return $this->apiKeys()->select()->toArray();
}
/**
* 获取客户类型列表
*/
public static function getClientTypes()
{
return [
self::TYPE_SUPPLIER => '供应商',
self::TYPE_DISTRIBUTOR => '分销商',
self::TYPE_HOSPITAL => '医院',
self::TYPE_OTHER => '其他',
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace app\model;
use think\Model;
class Permission extends Model
{
protected $name = 'permissions';
protected $pk = 'id';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
// 权限枚举
const ALLOW_NO = 0;
const ALLOW_YES = 1;
// 数据模式枚举
const MODE_FILTER = 'filter';
const MODE_CUSTOM = 'custom';
const MODE_FULL = 'full';
/**
* 关联API密钥
*/
public function apiKey()
{
return $this->belongsTo(ApiKey::class, 'api_key_id');
}
/**
* 检查是否允许访问接口
*/
public function canAccess($endpoint)
{
if ($this->isFullData()) {
return true;
}
if ($endpoint === 'chuku') {
return $this->allow_chuku == self::ALLOW_YES;
} elseif ($endpoint === 'ruku') {
return $this->allow_ruku == self::ALLOW_YES;
}
return false;
}
/**
* 检查是否允许全量数据
*/
public function isFullData()
{
return $this->allow_full_data == self::ALLOW_YES;
}
}

View File

@@ -0,0 +1,9 @@
<?php
use app\ExceptionHandle;
use app\Request;
// 容器Provider定义文件
return [
'think\Request' => Request::class,
'think\exception\Handle' => ExceptionHandle::class,
];

9
thinkphp/app/service.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use app\AppService;
// 系统服务定义文件
// 服务在完成全局初始化之后执行
return [
AppService::class,
];

View File

@@ -0,0 +1,169 @@
<?php
namespace app\service;
use app\model\Permission;
use think\facade\Log;
/**
* 数据隔离服务
*/
class DataIsolation
{
/**
* 构建数据隔离WHERE子句
*
* @param Permission $permission 权限记录
* @param string $endpoint 接口标识 chuku/ruku
* @param string|null $customerName 客户名称(全量数据权限时可选)
* @return array ['sql' => string, 'params' => array]
* @throws \Exception
*/
public static function buildIsolationClause($permission, $endpoint, $customerName = null)
{
// 1. 检查接口权限
if ($endpoint === 'chuku' && !$permission->canAccess('chuku')) {
throw new \Exception('该 API Key 未被授权访问销售数据接口');
}
if ($endpoint === 'ruku' && !$permission->canAccess('ruku')) {
throw new \Exception('该 API Key 未被授权访问入库数据接口');
}
// 2. 全量数据权限:检查是否传入供方名称过滤
if ($permission->isFullData()) {
Log::debug("API Key {$permission->api_key_id} 拥有全量数据权限");
if (!empty($customerName)) {
$filterField = 'k.[供方全称]';
$sqlFragment = " AND {$filterField} LIKE :supplier_name";
$params = ['supplier_name' => '%' . $customerName . '%'];
Log::debug("全量数据模式下按供方名称过滤: {$filterField} LIKE %{$customerName}%");
return ['sql' => $sqlFragment, 'params' => $params];
}
return ['sql' => '', 'params' => []];
}
// 3. 根据数据模式确定过滤方式
$dataMode = $permission->data_mode;
if ($dataMode === Permission::MODE_FULL) {
return ['sql' => '', 'params' => []];
}
// 4. 使用数据库中已配置的过滤规则
if (!empty($permission->filter_field) && !empty($permission->filter_values)) {
return self::buildCustomFilter($permission, $endpoint);
}
// 5. 自动过滤:根据接口类型自动选择过滤字段和值
$apiKey = $permission->apiKey;
$client = $apiKey->client;
if (!$client) {
throw new \Exception('API Key 未关联客户');
}
return self::buildAutoFilter($permission, $endpoint, $client);
}
/**
* 构建自定义过滤规则
*/
private static function buildCustomFilter($permission, $endpoint)
{
$filterField = $permission->filter_field;
$filterValues = $permission->filter_values;
if (empty($filterField) || empty($filterValues)) {
Log::warning("API Key {$permission->api_key_id} 使用自定义模式,但未配置过滤规则,拒绝访问");
throw new \Exception('权限配置不完整:需要手动配置过滤字段和过滤值');
}
if (!is_array($filterValues)) {
$filterValues = json_decode($filterValues, true);
}
if ($filterValues === null || !is_array($filterValues) || empty($filterValues)) {
Log::warning("API Key {$permission->api_key_id} 过滤值为空或无效");
throw new \Exception('过滤值列表为空或无效,请联系管理员');
}
if (!self::validateFilterField($filterField, $endpoint)) {
throw new \Exception('非法的过滤字段');
}
$prefixedField = self::prefixFilterField($filterField, $endpoint);
$placeholders = [];
$params = [];
foreach ($filterValues as $index => $value) {
$placeholder = ':iso_' . $index;
$placeholders[] = $placeholder;
$params['iso_' . $index] = $value;
}
$sqlFragment = " AND {$prefixedField} IN (" . implode(',', $placeholders) . ')';
Log::debug("数据隔离: {$prefixedField} IN " . json_encode($filterValues));
return ['sql' => $sqlFragment, 'params' => $params];
}
/**
* 为过滤字段添加表别名前缀
*/
private static function prefixFilterField($field, $endpoint)
{
if (strpos($field, '[') === 0) {
$field = substr($field, 1, -1);
}
$prefixMap = [
'供方全称' => 'k.[供方全称]',
'供方序号' => 'k.[供方序号]',
'产品名称' => 'p.[产品名称]',
'产品序号' => 'p.[产品序号]',
];
if ($endpoint === 'chuku') {
$prefixMap['销方全称'] = 'f.[销方全称]';
$prefixMap['销方序号'] = 'f.[销方序号]';
}
return $prefixMap[$field] ?? "[{$field}]";
}
/**
* 构建自动过滤规则
*/
private static function buildAutoFilter($permission, $endpoint, $client)
{
$filterField = 'k.[供方全称]';
$filterValue = $client->name;
$sqlFragment = " AND {$filterField} = :iso_0";
$params = ['iso_0' => $filterValue];
Log::debug("数据隔离: {$filterField} = {$filterValue}");
return ['sql' => $sqlFragment, 'params' => $params];
}
/**
* 验证过滤字段是否在白名单内(防注入)
*/
private static function validateFilterField($field, $endpoint)
{
$allowedFields = [
'供方全称', '供方序号',
'产品名称', '产品序号',
'customer_name', 'supplier_name', 'customer_code', 'supplier_code',
];
if ($endpoint === 'chuku') {
$allowedFields = array_merge($allowedFields, ['销方全称', '销方序号']);
}
return in_array($field, $allowedFields);
}
}

View File

@@ -0,0 +1,428 @@
<?php
namespace app\service;
use think\facade\Db;
use think\facade\Log;
class MssqlQuery
{
private static $healthCache = [
'available' => null,
'checked_at' => 0
];
private static $healthCheckInterval = 60;
private static function isMssqlAvailable()
{
$now = time();
if ($now - self::$healthCache['checked_at'] < self::$healthCheckInterval) {
return self::$healthCache['available'];
}
try {
Db::connect('mssql')->query('SELECT 1');
self::$healthCache['available'] = true;
self::$healthCache['checked_at'] = $now;
Log::info('SQL Server 连接恢复正常');
} catch (\Exception $e) {
self::$healthCache['available'] = false;
self::$healthCache['checked_at'] = $now;
Log::warning("SQL Server 不可用(下次探测 " . self::$healthCheckInterval . "s 后): " . $e->getMessage());
}
return self::$healthCache['available'];
}
public static function queryChuku($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams)
{
if (!self::isMssqlAvailable()) {
throw new \Exception('SQL Server 连接不可用');
}
return self::queryChukuReal($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams);
}
private static function queryChukuReal($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams)
{
$dateCondition = '';
$dateParams = [];
if ($startDate) {
$dateCondition .= " AND x.[日期] >= :start_date";
$dateParams['start_date'] = $startDate;
}
if ($endDate) {
$dateCondition .= " AND x.[日期] <= :end_date";
$dateParams['end_date'] = $endDate;
}
$startRow = ($page - 1) * $limit + 1;
$endRow = $page * $limit;
$countParams = array_merge($dateParams, $isolationParams);
$dataParams = array_merge($countParams, [
'start_row' => $startRow,
'end_row' => $endRow
]);
$sqlResult = self::buildLogSql(self::getChukuBaseSql(), $dateCondition, $isolationSql, $dataParams);
$dataSql = $sqlResult['dataSql'];
$logSql = $sqlResult['logSql'];
$countSql = self::getChukuCountSql();
$countSql = str_replace(['{date_condition}', '{isolation_condition}'], [$dateCondition, $isolationSql], $countSql);
Log::debug("出库查询 - 统计SQL: " . $countSql);
Log::debug("出库查询 - 统计参数: " . json_encode($countParams));
Log::debug("出库查询 - 统计参数数量: " . count($countParams));
try {
$total = Db::connect('mssql')->query($countSql, $countParams);
$total = isset($total[0]['total']) ? (int)$total[0]['total'] : 0;
Log::debug("出库查询 - 统计成功,总数: " . $total);
} catch (\Exception $e) {
Log::error("出库查询 - 统计失败: " . $e->getMessage());
Log::error("出库查询 - 统计SQL(原始): " . $countSql);
Log::error("出库查询 - 统计参数(原始): " . json_encode($countParams));
throw $e;
}
Log::debug("出库查询 - 数据SQL: " . $dataSql);
Log::debug("出库查询 - 数据参数: " . json_encode($dataParams));
Log::debug("出库查询 - 数据参数数量: " . count($dataParams));
try {
$rows = Db::connect('mssql')->query($dataSql, $dataParams);
Log::debug("出库查询 - 数据查询成功,行数: " . count($rows));
} catch (\Exception $e) {
Log::error("出库查询 - 数据查询失败: " . $e->getMessage());
Log::error("出库查询 - 数据SQL(原始): " . $dataSql);
Log::error("出库查询 - 数据参数(原始): " . json_encode($dataParams));
throw $e;
}
$records = [];
foreach ($rows as $row) {
$records[] = self::convertChukuRow($row);
}
return [
'records' => $records,
'total' => $total,
'sql' => $logSql
];
}
public static function queryRuku($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams)
{
if (!self::isMssqlAvailable()) {
throw new \Exception('SQL Server 连接不可用');
}
return self::queryRukuReal($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams);
}
private static function queryRukuReal($startDate, $endDate, $page, $limit, $isolationSql, $isolationParams)
{
$dateCondition = '';
$dateParams = [];
if ($startDate) {
$dateCondition .= " AND x.[日期] >= :start_date";
$dateParams['start_date'] = $startDate;
}
if ($endDate) {
$dateCondition .= " AND x.[日期] <= :end_date";
$dateParams['end_date'] = $endDate;
}
$startRow = ($page - 1) * $limit + 1;
$endRow = $page * $limit;
$countParams = array_merge($dateParams, $isolationParams);
$dataParams = array_merge($countParams, [
'start_row' => $startRow,
'end_row' => $endRow
]);
$sqlResult = self::buildLogSql(self::getRukuBaseSql(), $dateCondition, $isolationSql, $dataParams);
$dataSql = $sqlResult['dataSql'];
$logSql = $sqlResult['logSql'];
$countSql = self::getRukuCountSql();
$countSql = str_replace(['{date_condition}', '{isolation_condition}'], [$dateCondition, $isolationSql], $countSql);
Log::debug("入库查询 - 统计SQL: " . $countSql);
Log::debug("入库查询 - 统计参数: " . json_encode($countParams));
$total = Db::connect('mssql')->query($countSql, $countParams);
$total = isset($total[0]['total']) ? (int)$total[0]['total'] : 0;
Log::debug("入库查询 - 数据SQL: " . $dataSql);
Log::debug("入库查询 - 数据参数: " . json_encode($dataParams));
$rows = Db::connect('mssql')->query($dataSql, $dataParams);
$records = [];
foreach ($rows as $row) {
$records[] = self::convertRukuRow($row);
}
return [
'records' => $records,
'total' => $total,
'sql' => $logSql
];
}
private static function buildLogSql($sqlTemplate, $dateCondition, $isolationSql, $allParams)
{
$dataSql = str_replace(['{date_condition}', '{isolation_condition}'], [$dateCondition, $isolationSql], $sqlTemplate);
$logSql = $dataSql;
foreach ($allParams as $key => $value) {
$placeholder = ':' . $key;
if (is_string($value)) {
$logSql = str_replace($placeholder, "'" . $value . "'", $logSql);
} else {
$logSql = str_replace($placeholder, (string)$value, $logSql);
}
}
return ['dataSql' => $dataSql, 'logSql' => $logSql];
}
private static function convertChukuRow($row)
{
return [
'chuku_id' => $row['chuku_id'] ?? '',
'ruku_id' => $row['ruku_id'] ?? '',
'ticket_no' => $row['ticket_no'] ?? '',
'date' => self::convertDate($row['date'] ?? null),
'warehouse' => $row['warehouse'] ?? '',
'batch_no' => $row['batch_no'] ?? '',
'expire_date' => self::convertDate($row['expire_date'] ?? null),
'quantity' => self::convertFloat($row['quantity'] ?? null),
'unit_price' => self::convertFloat($row['unit_price'] ?? null),
'amount' => self::convertFloat($row['amount'] ?? null),
'purchase_price' => self::convertFloat($row['purchase_price'] ?? null),
'profit' => self::convertFloat($row['profit'] ?? null),
'operator' => $row['operator'] ?? '',
'salesman' => $row['salesman'] ?? '',
'receiver' => $row['receiver'] ?? '',
'summary' => $row['summary'] ?? '',
'supplier_id' => $row['supplier_id'] ?? '',
'supplier_name' => $row['supplier_name'] ?? '',
'supplier_short_name' => $row['supplier_short_name'] ?? '',
'customer_id' => $row['customer_id'] ?? '',
'customer_name' => $row['customer_name'] ?? '',
'customer_short_name' => $row['customer_short_name'] ?? '',
'customer_region' => $row['customer_region'] ?? '',
'product_id' => $row['product_id'] ?? '',
'product_name' => $row['product_name'] ?? '',
'product_spec' => $row['product_spec'] ?? '',
'product_origin' => $row['product_origin'] ?? '',
'product_unit' => $row['product_unit'] ?? '',
'manufacturer' => $row['manufacturer'] ?? '',
];
}
private static function convertRukuRow($row)
{
return [
'ruku_id' => $row['ruku_id'] ?? '',
'ticket_no' => $row['ticket_no'] ?? '',
'date' => self::convertDate($row['date'] ?? null),
'warehouse' => $row['warehouse'] ?? '',
'batch_no' => $row['batch_no'] ?? '',
'expire_date' => self::convertDate($row['expire_date'] ?? null),
'quantity' => self::convertFloat($row['quantity'] ?? null),
'unit_price' => self::convertFloat($row['unit_price'] ?? null),
'amount' => self::convertFloat($row['amount'] ?? null),
'purchase_price' => self::convertFloat($row['purchase_price'] ?? null),
'warehouse_qty' => self::convertFloat($row['warehouse_qty'] ?? null),
'operator' => $row['operator'] ?? '',
'purchaser' => $row['purchaser'] ?? '',
'responsible_person' => $row['responsible_person'] ?? '',
'arrival_no' => $row['arrival_no'] ?? '',
'invoice_no' => $row['invoice_no'] ?? '',
'summary' => $row['summary'] ?? '',
'supplier_id' => $row['supplier_id'] ?? '',
'supplier_name' => $row['supplier_name'] ?? '',
'supplier_short_name' => $row['supplier_short_name'] ?? '',
'product_id' => $row['product_id'] ?? '',
'product_name' => $row['product_name'] ?? '',
'product_spec' => $row['product_spec'] ?? '',
'product_origin' => $row['product_origin'] ?? '',
'product_unit' => $row['product_unit'] ?? '',
'manufacturer' => $row['manufacturer'] ?? '',
];
}
private static function convertDate($value)
{
if ($value === null) {
return null;
}
if (is_numeric($value)) {
try {
$base = new \DateTime('1899-12-30');
$base->add(new \DateInterval('P' . (int)$value . 'D'));
return $base->format('Y-m-d');
} catch (\Exception $e) {
return (string)$value;
}
}
if ($value instanceof \DateTime) {
return $value->format('Y-m-d');
}
return (string)$value;
}
private static function convertFloat($value)
{
if ($value === null) {
return null;
}
return is_numeric($value) ? (float)$value : null;
}
private static function getChukuBaseSql()
{
return "
WITH PageData AS (
SELECT
ROW_NUMBER() OVER (ORDER BY x.[日期] DESC) AS RowID,
x.[出库序号] AS chuku_id,
x.[入库序号] AS ruku_id,
x.[票号] AS ticket_no,
x.[日期] AS date,
x.[库房] AS warehouse,
x.[批号] AS batch_no,
x.[效期] AS expire_date,
x.[数量] AS quantity,
x.[单价] AS unit_price,
x.[金额] AS amount,
x.[进价] AS purchase_price,
x.[毛利] AS profit,
x.[操作员] AS operator,
x.[业务员] AS salesman,
x.[提货人] AS receiver,
x.[摘要] AS summary,
x.[供方序号] AS supplier_id,
k.[供方全称] AS supplier_name,
k.[简称] AS supplier_short_name,
x.[销方序号] AS customer_id,
f.[销方全称] AS customer_name,
f.[简称] AS customer_short_name,
f.[地区] AS customer_region,
x.[产品序号] AS product_id,
p.[产品名称] AS product_name,
p.[规格] AS product_spec,
p.[产地] AS product_origin,
p.[单位] AS product_unit,
p.[生产厂家] AS manufacturer
FROM [ywsj].[dbo].[vckmx] x
JOIN [ywsj].[dbo].[bgfkh] k ON x.[供方序号] = k.[供方序号]
JOIN [ywsj].[dbo].[bcp] p ON x.[产品序号] = p.[产品序号]
JOIN [ywsj].[dbo].[bxfkh] f ON x.[销方序号] = f.[销方序号]
WHERE 1=1
{date_condition}
{isolation_condition}
)
SELECT
chuku_id, ruku_id, ticket_no, date, warehouse, batch_no, expire_date,
quantity, unit_price, amount, purchase_price, profit,
operator, salesman, receiver, summary,
supplier_id, supplier_name, supplier_short_name,
customer_id, customer_name, customer_short_name, customer_region,
product_id, product_name, product_spec, product_origin,
product_unit, manufacturer
FROM PageData
WHERE RowID BETWEEN :start_row AND :end_row
ORDER BY RowID
";
}
private static function getChukuCountSql()
{
return "
SELECT COUNT(*) AS total
FROM [ywsj].[dbo].[vckmx] x
JOIN [ywsj].[dbo].[bgfkh] k ON x.[供方序号] = k.[供方序号]
JOIN [ywsj].[dbo].[bcp] p ON x.[产品序号] = p.[产品序号]
JOIN [ywsj].[dbo].[bxfkh] f ON x.[销方序号] = f.[销方序号]
WHERE 1=1
{date_condition}
{isolation_condition}
";
}
private static function getRukuBaseSql()
{
return "
WITH PageData AS (
SELECT
ROW_NUMBER() OVER (ORDER BY x.[日期] DESC) AS RowID,
x.[入库序号] AS ruku_id,
x.[票号] AS ticket_no,
x.[日期] AS date,
x.[库房] AS warehouse,
x.[批号] AS batch_no,
x.[效期] AS expire_date,
x.[数量] AS quantity,
x.[单价] AS unit_price,
x.[金额] AS amount,
x.[进价] AS purchase_price,
x.[库房数量] AS warehouse_qty,
x.[操作员] AS operator,
x.[采购员] AS purchaser,
x.[负责人] AS responsible_person,
x.[来货单号] AS arrival_no,
x.[发票单号] AS invoice_no,
x.[摘要] AS summary,
x.[供方序号] AS supplier_id,
k.[供方全称] AS supplier_name,
k.[简称] AS supplier_short_name,
x.[产品序号] AS product_id,
p.[产品名称] AS product_name,
p.[规格] AS product_spec,
p.[产地] AS product_origin,
p.[单位] AS product_unit,
p.[生产厂家] AS manufacturer
FROM [ywsj].[dbo].[vrkmx] x
JOIN [ywsj].[dbo].[bgfkh] k ON x.[供方序号] = k.[供方序号]
JOIN [ywsj].[dbo].[bcp] p ON x.[产品序号] = p.[产品序号]
WHERE 1=1
{date_condition}
{isolation_condition}
)
SELECT
ruku_id, ticket_no, date, warehouse, batch_no, expire_date,
quantity, unit_price, amount, purchase_price, warehouse_qty,
operator, purchaser, responsible_person, arrival_no, invoice_no,
summary, supplier_id, supplier_name, supplier_short_name,
product_id, product_name, product_spec, product_origin,
product_unit, manufacturer
FROM PageData
WHERE RowID BETWEEN :start_row AND :end_row
ORDER BY RowID
";
}
private static function getRukuCountSql()
{
return "
SELECT COUNT(*) AS total
FROM [ywsj].[dbo].[vrkmx] x
JOIN [ywsj].[dbo].[bgfkh] k ON x.[供方序号] = k.[供方序号]
JOIN [ywsj].[dbo].[bcp] p ON x.[产品序号] = p.[产品序号]
WHERE 1=1
{date_condition}
{isolation_condition}
";
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace app\service;
/**
* 签名验证服务
* 完全按照 Python 版本实现
*/
class Signature
{
/**
* 生成API Key
*/
public static function generateApiKey()
{
return bin2hex(random_bytes(6));
}
/**
* 生成API Secret
*/
public static function generateApiSecret()
{
return bin2hex(random_bytes(10));
}
/**
* 对 API Secret 做 SHA256 哈希存储
*/
public static function hashSecret($secret)
{
return hash('sha256', $secret);
}
/**
* 验证签名(完全按照 Python 版本实现)
*
* @param array $params 请求查询参数
* @param string $timestamp X-Timestamp header 值
* @param string $signature X-Sign header 值
* @param string $apiSecretHash 数据库存储的 secret 哈希
* @return bool
*/
public static function verify($params, $timestamp, $signature, $apiSecretHash)
{
try {
$ts = (int)$timestamp;
if ($ts > 1e12) {
$ts = (int)($ts / 1000);
}
$now = time();
$expireSeconds = env('signature.expire_seconds', 300);
if (abs($now - $ts) > $expireSeconds) {
return false;
}
} catch (\Exception $e) {
return false;
}
$sortedParams = [];
foreach ($params as $k => $v) {
$sortedParams[$k] = $v;
}
ksort($sortedParams);
$paramParts = [];
foreach ($sortedParams as $k => $v) {
if ($v !== null && $v !== '') {
$paramParts[] = "{$k}={$v}";
}
}
$paramStr = implode('&', $paramParts);
$fullStr = $paramStr . '&timestamp=' . $timestamp;
$expectedSign = hash_hmac('sha256', $fullStr, $apiSecretHash);
return strcasecmp($expectedSign, $signature) === 0;
}
/**
* 生成签名(用于测试)
*
* @param array $params 请求参数
* @param string $timestamp 时间戳字符串
* @param string $apiSecret API Secret 明文
* @return string
*/
public static function sign($params, $timestamp, $apiSecret)
{
$sortedParams = [];
foreach ($params as $k => $v) {
$sortedParams[$k] = $v;
}
ksort($sortedParams);
$paramParts = [];
foreach ($sortedParams as $k => $v) {
if ($v !== null && $v !== '') {
$paramParts[] = "{$k}={$v}";
}
}
$paramStr = implode('&', $paramParts);
$fullStr = $paramStr . '&timestamp=' . $timestamp;
return hash_hmac('sha256', $fullStr, $apiSecret);
}
}