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/.example.env Normal file
View File

@@ -0,0 +1 @@
APP_DEBUG = true

5
thinkphp/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.idea
/.vscode
/vendor
*.log
.env

42
thinkphp/.travis.yml Normal file
View File

@@ -0,0 +1,42 @@
sudo: false
language: php
branches:
only:
- stable
cache:
directories:
- $HOME/.composer/cache
before_install:
- composer self-update
install:
- composer install --no-dev --no-interaction --ignore-platform-reqs
- zip -r --exclude='*.git*' --exclude='*.zip' --exclude='*.travis.yml' ThinkPHP_Core.zip .
- composer require --update-no-dev --no-interaction "topthink/think-image:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-migration:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-captcha:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-mongo:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-worker:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-helper:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-queue:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-angular:^1.0"
- composer require --dev --update-no-dev --no-interaction "topthink/think-testing:^1.0"
- zip -r --exclude='*.git*' --exclude='*.zip' --exclude='*.travis.yml' ThinkPHP_Full.zip .
script:
- php think unit
deploy:
provider: releases
api_key:
secure: TSF6bnl2JYN72UQOORAJYL+CqIryP2gHVKt6grfveQ7d9rleAEoxlq6PWxbvTI4jZ5nrPpUcBUpWIJHNgVcs+bzLFtyh5THaLqm39uCgBbrW7M8rI26L8sBh/6nsdtGgdeQrO/cLu31QoTzbwuz1WfAVoCdCkOSZeXyT/CclH99qV6RYyQYqaD2wpRjrhA5O4fSsEkiPVuk0GaOogFlrQHx+C+lHnf6pa1KxEoN1A0UxxVfGX6K4y5g4WQDO5zT4bLeubkWOXK0G51XSvACDOZVIyLdjApaOFTwamPcD3S1tfvuxRWWvsCD5ljFvb2kSmx5BIBNwN80MzuBmrGIC27XLGOxyMerwKxB6DskNUO9PflKHDPI61DRq0FTy1fv70SFMSiAtUv9aJRT41NQh9iJJ0vC8dl+xcxrWIjU1GG6+l/ZcRqVx9V1VuGQsLKndGhja7SQ+X1slHl76fRq223sMOql7MFCd0vvvxVQ2V39CcFKao/LB1aPH3VhODDEyxwx6aXoTznvC/QPepgWsHOWQzKj9ftsgDbsNiyFlXL4cu8DWUty6rQy8zT2b4O8b1xjcwSUCsy+auEjBamzQkMJFNlZAIUrukL/NbUhQU37TAbwsFyz7X0E/u/VMle/nBCNAzgkMwAUjiHM6FqrKKBRWFbPrSIixjfjkCnrMEPw=
file:
- ThinkPHP_Core.zip
- ThinkPHP_Full.zip
skip_cleanup: true
on:
tags: true

32
thinkphp/LICENSE.txt Normal file
View File

@@ -0,0 +1,32 @@
ThinkPHP遵循Apache2开源协议发布并提供免费使用。
版权所有Copyright © 2006-2016 by ThinkPHP (http://thinkphp.cn)
All rights reserved。
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
Apache Licence是著名的非盈利开源组织Apache采用的协议。
该协议和BSD类似鼓励代码共享和尊重原作者的著作权
允许代码修改,再作为开源或商业软件发布。需要满足
的条件:
1 需要给代码的用户一份Apache Licence
2 如果你修改了代码,需要在被修改的文件中说明;
3 在延伸的代码中(修改和有源代码衍生的代码中)需要
带有原来代码中的协议,商标,专利声明和其他原来作者规
定需要包含的说明;
4 如果再发布的产品中包含一个Notice文件则在Notice文
件中需要带有本协议内容。你可以在Notice中增加自己的
许可但不可以表现为对Apache Licence构成更改。
具体的协议参考http://www.apache.org/licenses/LICENSE-2.0
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

266
thinkphp/README.md Normal file
View File

@@ -0,0 +1,266 @@
# 进销存数据开放平台 - ThinkPHP 6.1.4
为上下游合作伙伴提供安全的出入库数据查询API服务。
## 功能特性
### 对外API (`/api/v1/*`)
- 销售数据接口:`GET /api/v1/chuku`
- 入库数据接口:`GET /api/v1/ruku`
- 支持分页、日期范围查询
- 需要签名验证:`X-API-Key``X-Timestamp``X-Sign`
### 后台管理 (`/api/admin/*`)
- 认证管理管理员登录、JWT验证
- 客户管理:创建/更新/启用/禁用客户
- API密钥管理生成API Key和Secret
- 权限管理:配置接口访问权限、数据隔离规则
- 日志管理查看API调用记录
- 仪表盘:统计数据、调用趋势
### 安全机制
- 签名验证HMAC-SHA256
- 时间戳防重放攻击
- 限流保护(按分钟/按天)
- 数据隔离(根据客户类型自动过滤)
- 账号状态管理
## 安装步骤
### 1. 环境要求
- PHP >= 7.2.5
- MySQL >= 5.7
- SQL Server 2008 R2+ (业务数据库)
- Redis (用于限流)
- Composer
### 2. 安装依赖
```bash
composer install
```
### 3. 配置环境变量
编辑 `.env` 文件,配置数据库连接等信息:
```ini
[DATABASE]
TYPE = mysql
HOSTNAME = 127.0.0.1
DATABASE = jc_open_platform
USERNAME = root
PASSWORD = root
HOSTPORT = 3306
CHARSET = utf8
[MSSQL]
HOSTNAME = 192.168.1.100
DATABASE = 进销存数据库
USERNAME = readonly_user
PASSWORD = your_password_here
HOSTPORT = 1433
[JWT]
SECRET_KEY = change-this-to-a-random-32-char-string!
ALGORITHM = HS256
EXPIRE_HOURS = 24
[RATE_LIMIT]
DEFAULT_PER_MINUTE = 60
DEFAULT_PER_DAY = 5000
MAX_PAGE_SIZE = 500
[SIGNATURE]
EXPIRE_SECONDS = 604800
```
### 4. 创建数据库表
```bash
mysql -u root -p jc_open_platform < database.sql
```
### 5. 启动服务
```bash
php think run
```
服务将在 `http://localhost:8000` 启动。
## API文档
### 系统接口
#### 健康检查
```
GET /health
```
响应:
```json
{
"status": "ok",
"sql_server": "connected"
}
```
### 对外API接口
#### 销售数据查询
```
GET /api/v1/chuku?start_date=2026-01-01&end_date=2026-12-31&page=1&limit=50
```
请求头:
```
X-API-Key: ak_xxxxxxxxxxxxxxxxxxxx
X-Timestamp: 1234567890
X-Sign: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
响应:
```json
{
"code": 200,
"msg": "success",
"total": 100,
"data": [...]
}
```
#### 入库数据查询
```
GET /api/v1/ruku?start_date=2026-01-01&end_date=2026-12-31&page=1&limit=50
```
请求头同上。
### 后台管理接口
#### 管理员登录
```
POST /api/admin/auth/login
```
请求体:
```json
{
"username": "admin",
"password": "admin123"
}
```
响应:
```json
{
"code": 200,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "admin"
}
}
```
#### 客户管理
```
GET /api/admin/clients
POST /api/admin/clients
PUT /api/admin/clients/{id}
PATCH /api/admin/clients/{id}/toggle
```
#### API密钥管理
```
POST /api/admin/clients/{id}/api-keys
GET /api/admin/clients/{id}/api-keys
DELETE /api/admin/api-keys/{keyId}
```
#### 权限管理
```
GET /api/admin/permissions/{id}
PUT /api/admin/permissions/{id}
```
#### 日志管理
```
GET /api/admin/logs
GET /api/admin/logs/{id}
GET /api/admin/logs/stats
```
#### 仪表盘
```
GET /api/admin/dashboard/stats
GET /api/admin/dashboard/trend
```
## 签名算法
### 1. 构建待签名字符串
```
queryString = 按键名排序后的查询字符串
stringToSign = queryString + '&timestamp=' + timestamp
```
### 2. 计算签名
```
signature = HMAC-SHA256(stringToSign, apiSecret)
```
### PHP示例
```php
$params = ['start_date' => '2026-01-01', 'page' => '1'];
ksort($params);
$queryString = http_build_query($params);
$stringToSign = $queryString . '&timestamp=' . time();
$signature = hash_hmac('sha256', $stringToSign, $apiSecret);
```
## 数据隔离策略
### 1. 全量数据权限
- `allow_full_data = 1`
- 不加任何过滤条件
### 2. 按客户名称自动过滤
- `data_mode = 'filter'`
- 销售接口:过滤 `销方全称 = 客户名称`
- 入库接口:过滤 `供方全称 = 客户名称`
### 3. 自定义过滤规则
- `data_mode = 'custom'`
- 手动配置 `filter_field``filter_values`
- 支持多值 IN 查询
## 默认账户
- 管理员:`admin` / `admin123`
## 目录结构
```
thinkphp/
├── app/
│ ├── controller/
│ │ ├── api/
│ │ │ ├── v1/ # 对外API控制器
│ │ │ └── admin/ # 后台管理控制器
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型
│ └── service/ # 服务层
├── config/ # 配置文件
├── route/ # 路由配置
├── database.sql # 数据库表结构
└── .env # 环境变量配置
```
## 注意事项
1. 生产环境请修改JWT密钥和数据库密码
2. SQL Server连接失败时会自动返回模拟数据
3. 限流功能需要Redis支持
4. API Secret仅在生成时可见一次请妥善保存
## 许可证
Apache-2.0

View File

@@ -0,0 +1,35 @@
<?php
$API_KEY = "4e2bf206a82f846c699e978cea8c2f87";
$API_SECRET = "e763f998cbc9ceb3ff41356638d18e7725cb6b6d2ae02bdc4bdbba55a0978a4f";
$params = [
"start_date" => "2024-02-02",
"end_date" => "2026-06-22",
"page" => "1",
"limit" => "100",
];
$timestamp = strval(time());
ksort($params);
$paramStr = "";
foreach ($params as $k => $v) {
if ($v !== null && $v !== "") {
if ($paramStr) $paramStr .= "&";
$paramStr .= "{$k}={$v}";
}
}
$fullStr = "{$paramStr}&timestamp={$timestamp}";
$sign = hash_hmac("sha256", $fullStr, $API_SECRET);
echo "=" . str_repeat("=", 59) . "\n";
echo "Postman 请求配置\n";
echo "=" . str_repeat("=", 59) . "\n";
echo "GET http://localhost:8000/api/v1/chuku?{$paramStr}\n\n";
echo "Headers:\n";
echo " X-API-Key: {$API_KEY}\n";
echo " X-Timestamp: {$timestamp}\n";
echo " X-Sign: {$sign}\n\n";
echo "注意: 签名有效期 7 天,过期请重新运行此脚本生成\n";

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);
}
}

51
thinkphp/composer.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "topthink/think",
"description": "the new thinkphp framework",
"type": "project",
"keywords": [
"framework",
"thinkphp",
"ORM"
],
"homepage": "https://www.thinkphp.cn/",
"license": "Apache-2.0",
"authors": [
{
"name": "liu21st",
"email": "liu21st@gmail.com"
},
{
"name": "yunwuxin",
"email": "448901948@qq.com"
}
],
"require": {
"php": ">=7.2.5",
"topthink/framework": "^6.1.0",
"topthink/think-orm": "^2.0",
"topthink/think-filesystem": "^1.0",
"firebase/php-jwt": "^6.4",
"predis/predis": "^2.0"
},
"require-dev": {
"symfony/var-dumper": "^4.2",
"topthink/think-trace": "^1.0"
},
"autoload": {
"psr-4": {
"app\\": "app"
},
"psr-0": {
"": "extend/"
}
},
"config": {
"preferred-install": "dist"
},
"scripts": {
"post-autoload-dump": [
"@php think service:discover",
"@php think vendor:publish"
]
}
}

1297
thinkphp/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
thinkphp/config/app.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
// +----------------------------------------------------------------------
// | 应用设置
// +----------------------------------------------------------------------
return [
// 应用地址
'app_host' => env('app.host', ''),
// 应用的命名空间
'app_namespace' => '',
// 是否启用路由
'with_route' => true,
// 默认应用
'default_app' => 'index',
// 默认时区
'default_timezone' => 'Asia/Shanghai',
// 应用映射(自动多应用模式有效)
'app_map' => [],
// 域名绑定(自动多应用模式有效)
'domain_bind' => [],
// 禁止URL访问的应用列表自动多应用模式有效
'deny_app_list' => [],
// 异常页面的模板文件
'exception_tmpl' => app()->getThinkPath() . 'tpl/think_exception.tpl',
// 错误显示信息,非调试模式有效
'error_message' => '页面错误!请稍后再试~',
// 显示错误信息
'show_error_msg' => false,
];

29
thinkphp/config/cache.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
return [
// 默认缓存驱动
'default' => env('cache.driver', 'file'),
// 缓存连接方式配置
'stores' => [
'file' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => '',
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 序列化机制 例如 ['serialize', 'unserialize']
'serialize' => [],
],
// 更多的缓存连接
],
];

View File

@@ -0,0 +1,9 @@
<?php
// +----------------------------------------------------------------------
// | 控制台配置
// +----------------------------------------------------------------------
return [
// 指令定义
'commands' => [
],
];

View File

@@ -0,0 +1,20 @@
<?php
// +----------------------------------------------------------------------
// | Cookie设置
// +----------------------------------------------------------------------
return [
// cookie 保存时间
'expire' => 0,
// cookie 保存路径
'path' => '/',
// cookie 有效域名
'domain' => '',
// cookie 启用安全传输
'secure' => false,
// httponly设置
'httponly' => false,
// 是否使用 setcookie
'setcookie' => true,
// samesite 设置,支持 'strict' 'lax'
'samesite' => '',
];

View File

@@ -0,0 +1,80 @@
<?php
return [
// 默认使用的数据库连接配置
'default' => env('database.driver', 'mysql'),
// 自定义时间查询规则
'time_query_rule' => [],
// 自动写入时间戳字段
// true为自动识别类型 false关闭
// 字符串则明确指定时间字段类型 支持 int timestamp datetime date
'auto_timestamp' => true,
// 时间字段取出后的默认时间格式
'datetime_format' => 'Y-m-d H:i:s',
// 时间字段配置 配置格式create_time,update_time
'datetime_field' => '',
// 数据库连接配置信息
'connections' => [
'mysql' => [
// 数据库类型
'type' => env('database.type', 'mysql'),
// 服务器地址
'hostname' => env('database.hostname', '127.0.0.1'),
// 数据库名
'database' => env('database.database', ''),
// 用户名
'username' => env('database.username', 'root'),
// 密码
'password' => env('database.password', ''),
// 端口
'hostport' => env('database.hostport', '3306'),
// 数据库连接参数
'params' => [],
// 数据库编码默认采用utf8
'charset' => env('database.charset', 'utf8'),
// 数据库表前缀
'prefix' => env('database.prefix', ''),
// 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'deploy' => 0,
// 数据库读写是否分离 主从式有效
'rw_separate' => false,
// 读写分离后 主服务器数量
'master_num' => 1,
// 指定从服务器序号
'slave_no' => '',
// 是否严格检查字段是否存在
'fields_strict' => true,
// 是否需要断线重连
'break_reconnect' => false,
// 监听SQL
'trigger_sql' => env('app_debug', true),
// 开启字段缓存
'fields_cache' => false,
],
// SQL Server 业务数据库配置
'mssql' => [
'type' => 'sqlsrv',
'hostname' => env('mssql.hostname', '127.0.0.1'),
'database' => env('mssql.database', ''),
'username' => env('mssql.username', 'sa'),
'password' => env('mssql.password', ''),
'hostport' => env('mssql.hostport', '1433'),
'params' => [],
'charset' => 'utf8',
'prefix' => '',
'deploy' => 0,
'rw_separate' => false,
'fields_strict' => true,
'break_reconnect' => false,
'trigger_sql' => env('app_debug', true),
'fields_cache' => false,
],
],
];

View File

@@ -0,0 +1,24 @@
<?php
return [
// 默认磁盘
'default' => env('filesystem.driver', 'local'),
// 磁盘列表
'disks' => [
'local' => [
'type' => 'local',
'root' => app()->getRuntimePath() . 'storage',
],
'public' => [
// 磁盘类型
'type' => 'local',
// 磁盘路径
'root' => app()->getRootPath() . 'public/storage',
// 磁盘路径对应的外部URL路径
'url' => '/storage',
// 可见性
'visibility' => 'public',
],
// 更多的磁盘配置信息
],
];

27
thinkphp/config/lang.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
// +----------------------------------------------------------------------
// | 多语言设置
// +----------------------------------------------------------------------
return [
// 默认语言
'default_lang' => env('lang.default_lang', 'zh-cn'),
// 允许的语言列表
'allow_lang_list' => [],
// 多语言自动侦测变量名
'detect_var' => 'lang',
// 是否使用Cookie记录
'use_cookie' => true,
// 多语言cookie变量
'cookie_var' => 'think_lang',
// 多语言header变量
'header_var' => 'think-lang',
// 扩展语言包
'extend_list' => [],
// Accept-Language转义为对应语言包名称
'accept_language' => [
'zh-hans-cn' => 'zh-cn',
],
// 是否支持语言分组
'allow_group' => false,
];

45
thinkphp/config/log.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | 日志设置
// +----------------------------------------------------------------------
return [
// 默认日志记录通道
'default' => env('log.channel', 'file'),
// 日志记录级别
'level' => ['debug'],
// 日志类型记录的通道 ['error'=>'email',...]
'type_channel' => [],
// 关闭全局日志写入
'close' => false,
// 全局日志处理 支持闭包
'processor' => null,
// 日志通道列表
'channels' => [
'file' => [
// 日志记录方式
'type' => 'File',
// 日志保存目录
'path' => '',
// 单文件日志写入
'single' => false,
// 独立日志级别
'apart_level' => [],
// 最大日志文件数量
'max_files' => 0,
// 使用JSON格式记录
'json' => false,
// 日志处理
'processor' => null,
// 关闭通道日志写入
'close' => false,
// 日志输出格式化
'format' => '[%s][%s] %s',
// 是否实时写入
'realtime_write' => false,
],
// 其它日志通道配置
],
];

View File

@@ -0,0 +1,12 @@
<?php
// 中间件配置
return [
// 别名或分组
'alias' => [
'auth' => \app\middleware\Auth::class,
'ratelimit' => \app\middleware\RateLimit::class,
'adminauth' => \app\middleware\AdminAuth::class,
],
// 优先级设置,此数组中的中间件会按照数组中的顺序优先执行
'priority' => [],
];

45
thinkphp/config/route.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | 路由设置
// +----------------------------------------------------------------------
return [
// pathinfo分隔符
'pathinfo_depr' => '/',
// URL伪静态后缀
'url_html_suffix' => 'html',
// URL普通方式参数 用于自动生成
'url_common_param' => true,
// 是否开启路由延迟解析
'url_lazy_route' => false,
// 是否强制使用路由
'url_route_must' => false,
// 合并路由规则
'route_rule_merge' => false,
// 路由是否完全匹配
'route_complete_match' => false,
// 访问控制器层名称
'controller_layer' => 'controller',
// 空控制器名
'empty_controller' => 'Error',
// 是否使用控制器后缀
'controller_suffix' => false,
// 默认的路由变量规则
'default_route_pattern' => '[\w\.]+',
// 是否开启请求缓存 true自动缓存 支持设置请求缓存规则
'request_cache_key' => false,
// 请求缓存有效期
'request_cache_expire' => null,
// 全局请求缓存排除规则
'request_cache_except' => [],
// 默认控制器名
'default_controller' => 'Index',
// 默认操作名
'default_action' => 'index',
// 操作方法后缀
'action_suffix' => '',
// 默认JSONP格式返回的处理方法
'default_jsonp_handler' => 'jsonpReturn',
// 默认JSONP处理方法
'var_jsonp_handler' => 'callback',
];

View File

@@ -0,0 +1,19 @@
<?php
// +----------------------------------------------------------------------
// | 会话设置
// +----------------------------------------------------------------------
return [
// session name
'name' => 'PHPSESSID',
// SESSION_ID的提交变量,解决flash上传跨域
'var_session_id' => '',
// 驱动方式 支持file cache
'type' => 'file',
// 存储连接标识 当type使用cache的时候有效
'store' => null,
// 过期时间
'expire' => 1440,
// 前缀
'prefix' => '',
];

10
thinkphp/config/trace.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
// +----------------------------------------------------------------------
// | Trace设置 开启调试模式后有效
// +----------------------------------------------------------------------
return [
// 内置Html和Console两种方式 支持扩展
'type' => 'Html',
// 读取的日志通道名
'channel' => '',
];

25
thinkphp/config/view.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
// +----------------------------------------------------------------------
// | 模板设置
// +----------------------------------------------------------------------
return [
// 模板引擎类型使用Think
'type' => 'Think',
// 默认模板渲染规则 1 解析为小写+下划线 2 全部转换小写 3 保持操作方法
'auto_rule' => 1,
// 模板目录名
'view_dir_name' => 'view',
// 模板后缀
'view_suffix' => 'html',
// 模板文件名分隔符
'view_depr' => DIRECTORY_SEPARATOR,
// 模板引擎普通标签开始标记
'tpl_begin' => '{',
// 模板引擎普通标签结束标记
'tpl_end' => '}',
// 标签库标签开始标记
'taglib_begin' => '{',
// 标签库标签结束标记
'taglib_end' => '}',
];

104
thinkphp/database.sql Normal file
View File

@@ -0,0 +1,104 @@
-- 进销存数据开放平台 - 数据库表结构
-- 数据库: jc_open_platform
-- ========== 客户表 ==========
CREATE TABLE IF NOT EXISTS `clients` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`client_no` varchar(50) DEFAULT NULL COMMENT '客户序号',
`name` varchar(200) NOT NULL COMMENT '客户名称',
`client_type` varchar(20) NOT NULL COMMENT '客户类型: supplier/distributor/hospital/other',
`contact_person` varchar(100) DEFAULT NULL COMMENT '联系人',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`status` tinyint(1) DEFAULT '1' COMMENT '1=启用 0=禁用',
`remark` text COMMENT '备注',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_client_no` (`client_no`),
KEY `idx_client_type` (`client_type`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上下游客户表';
-- ========== API密钥表 ==========
CREATE TABLE IF NOT EXISTS `api_keys` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`client_id` bigint(20) unsigned NOT NULL COMMENT '客户ID',
`api_key` varchar(64) NOT NULL COMMENT 'API Key 公开标识',
`api_secret_hash` varchar(256) NOT NULL COMMENT 'API Secret 原始值用于HMAC验证',
`status` tinyint(1) DEFAULT '1' COMMENT '1=启用 0=禁用',
`rate_limit_per_minute` int(11) DEFAULT '60' COMMENT '每分钟限流次数',
`rate_limit_per_day` int(11) DEFAULT '5000' COMMENT '每天限流次数',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_api_key` (`api_key`),
KEY `idx_client_id` (`client_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API密钥表';
-- ========== 权限配置表 ==========
CREATE TABLE IF NOT EXISTS `permissions` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`api_key_id` bigint(20) unsigned NOT NULL COMMENT 'API Key ID',
`allow_chuku` tinyint(1) DEFAULT '0' COMMENT '是否允许销售接口 1=允许 0=禁止',
`allow_ruku` tinyint(1) DEFAULT '0' COMMENT '是否允许入库接口 1=允许 0=禁止',
`allow_full_data` tinyint(1) DEFAULT '0' COMMENT '是否允许全量数据 1=允许 0=过滤',
`data_mode` varchar(20) DEFAULT 'filter' COMMENT '数据过滤模式: filter=按接口权限过滤, custom=自定义过滤规则, full=全量数据',
`filter_field` varchar(50) DEFAULT NULL COMMENT '过滤字段: 销方全称/供方全称/销方序号/供方序号',
`filter_values` json DEFAULT NULL COMMENT '过滤值 JSON 数组',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_api_key_id` (`api_key_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限配置表';
-- ========== 管理员表 ==========
CREATE TABLE IF NOT EXISTS `admin_users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(255) NOT NULL COMMENT '密码bcrypt加密',
`status` tinyint(1) DEFAULT '1' COMMENT '1=启用 0=禁用',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
-- ========== API调用日志表 ==========
CREATE TABLE IF NOT EXISTS `api_logs` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`api_key_id` bigint(20) unsigned NOT NULL COMMENT 'API Key ID',
`client_id` bigint(20) unsigned NOT NULL COMMENT '客户ID',
`endpoint` varchar(100) NOT NULL COMMENT '接口路径',
`request_params` json DEFAULT NULL COMMENT '请求参数',
`response_code` int(11) DEFAULT NULL COMMENT '响应状态码',
`response_body` json DEFAULT NULL COMMENT '响应体摘要',
`record_count` int(11) DEFAULT '0' COMMENT '返回记录数',
`sql_query` text COMMENT '执行的SQL语句',
`duration_ms` int(11) DEFAULT NULL COMMENT '执行耗时(毫秒)',
`ip_address` varchar(50) DEFAULT NULL COMMENT '客户端IP',
`error_message` varchar(500) DEFAULT NULL COMMENT '错误信息',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_api_key_id` (`api_key_id`),
KEY `idx_client_id` (`client_id`),
KEY `idx_endpoint` (`endpoint`),
KEY `idx_created_at` (`created_at`),
KEY `idx_response_code` (`response_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API调用日志表';
-- ========== 初始化数据 ==========
-- 插入默认管理员(用户名: admin密码: admin123
INSERT INTO `admin_users` (`username`, `password`, `status`) VALUES
('admin', '$2y$10$Kds/RYu23SNPlroW3NnyveSP5Yd2JiYWklgx5zLE5Y/ORYlzi8F52', 1)
ON DUPLICATE KEY UPDATE `id` = `id`;
-- ========== 外键约束 ==========
ALTER TABLE `api_keys`
ADD CONSTRAINT `fk_api_keys_client` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE;
ALTER TABLE `permissions`
ADD CONSTRAINT `fk_permissions_api_key` FOREIGN KEY (`api_key_id`) REFERENCES `api_keys` (`id`) ON DELETE CASCADE;
ALTER TABLE `api_logs`
ADD CONSTRAINT `fk_api_logs_api_key` FOREIGN KEY (`api_key_id`) REFERENCES `api_keys` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `fk_api_logs_client` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE;

2
thinkphp/extend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

File diff suppressed because one or more lines are too long

8
thinkphp/nginx.htaccess Normal file
View File

@@ -0,0 +1,8 @@
location ~* (runtime|application)/{
return 403;
}
location / {
if (!-e $request_filename){
rewrite ^(.*)$ /index.php?s=$1 last; break;
}
}

View File

@@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
</IfModule>

View File

@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API测试工具</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; padding: 20px; }
.container { max-width: 1000px; margin: 0 auto; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.header h1 { font-size: 24px; font-weight: 600; }
.header p { font-size: 14px; opacity: 0.9; margin-top: 5px; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.card-title { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; font-size: 14px; color: #555; margin-bottom: 6px; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; transition: border-color 0.2s; }
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #667eea; }
.form-group textarea { resize: vertical; min-height: 80px; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); }
.btn-success { background: #28a745; color: white; }
.btn-success:hover { background: #218838; }
.btn-group { display: flex; gap: 10px; }
.result-area { margin-top: 15px; }
.result-area pre { background: #f8f9fa; border-radius: 4px; padding: 15px; font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; max-height: 500px; overflow-y: auto; color: #333; }
.signature-box { background: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 4px; padding: 15px; margin-top: 10px; }
.signature-box .label { font-size: 12px; color: #2e7d32; margin-bottom: 8px; }
.signature-box .value { font-family: 'Consolas', monospace; font-size: 13px; color: #1b5e20; word-break: break-all; }
.endpoint-tabs { display: flex; gap: 8px; margin-bottom: 15px; }
.endpoint-tabs button { padding: 8px 16px; border: 1px solid #ddd; background: white; border-radius: 4px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
.endpoint-tabs button.active { background: #667eea; color: white; border-color: #667eea; }
.loading { display: inline-block; width: 20px; height: 20px; border: 2px solid #f3f3f3; border-top: 2px solid #667eea; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>API测试工具</h1>
<p>签名算法: HMAC-SHA256 | 支持出库(chuku)和入库(ruku)接口</p>
</div>
<div class="card">
<div class="card-title">1. 配置API密钥</div>
<div class="grid">
<div class="form-group">
<label>API Key</label>
<input type="text" id="apiKey" value="" placeholder="输入API Key">
</div>
<div class="form-group">
<label>API Secret</label>
<input type="text" id="apiSecret" value="" placeholder="输入API Secret">
</div>
</div>
</div>
<div class="card">
<div class="card-title">2. 请求参数</div>
<div class="endpoint-tabs">
<button class="active" onclick="switchEndpoint('chuku')">出库接口 /api/v1/chuku</button>
<button onclick="switchEndpoint('ruku')">入库接口 /api/v1/ruku</button>
</div>
<div class="grid">
<div class="form-group">
<label>开始日期 (start_date)</label>
<input type="date" id="startDate" value="2024-02-02">
</div>
<div class="form-group">
<label>结束日期 (end_date)</label>
<input type="date" id="endDate" value="2026-06-22">
</div>
<div class="form-group">
<label>页码 (page)</label>
<input type="number" id="page" value="1" min="1">
</div>
<div class="form-group">
<label>每页数量 (limit)</label>
<input type="number" id="limit" value="100" min="1" max="500">
</div>
</div>
</div>
<div class="card">
<div class="card-title">3. 签名生成</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="generateSignature()">生成签名</button>
<button class="btn btn-success" onclick="executeQuery()">执行查询</button>
</div>
<div class="signature-box" id="signatureBox" style="display: none;">
<div class="label">生成的请求信息:</div>
<div class="value" id="signatureInfo"></div>
</div>
</div>
<div class="card">
<div class="card-title">4. 查询结果</div>
<div id="resultArea"></div>
</div>
</div>
<script>
let currentEndpoint = 'chuku';
function switchEndpoint(endpoint) {
currentEndpoint = endpoint;
document.querySelectorAll('.endpoint-tabs button').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
}
async function generateSignature() {
const apiKey = document.getElementById('apiKey').value;
const apiSecret = document.getElementById('apiSecret').value;
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const page = document.getElementById('page').value;
const limit = document.getElementById('limit').value;
const params = {
start_date: startDate,
end_date: endDate,
page: page,
limit: limit
};
const timestamp = Math.floor(Date.now() / 1000).toString();
const sortedKeys = Object.keys(params).sort();
let paramStr = '';
for (let i = 0; i < sortedKeys.length; i++) {
const k = sortedKeys[i];
const v = params[k];
if (v !== null && v !== '') {
if (paramStr) paramStr += '&';
paramStr += k + '=' + v;
}
}
const fullStr = paramStr + '&timestamp=' + timestamp;
const sign = await hmacSha256(fullStr, apiSecret);
const url = `http://localhost/api/v1/${currentEndpoint}?${paramStr}`;
const info = `
请求URL: ${url}
Headers:
X-API-Key: ${apiKey}
X-Timestamp: ${timestamp}
X-Sign: ${sign}
签名字符串:
${fullStr}
签名有效期: 7天 (当前时间戳: ${new Date(parseInt(timestamp) * 1000).toLocaleString()})
`.trim();
document.getElementById('signatureInfo').textContent = info;
document.getElementById('signatureBox').style.display = 'block';
window.signatureCache = {
url: url,
headers: {
'X-API-Key': apiKey,
'X-Timestamp': timestamp,
'X-Sign': sign
}
};
}
async function executeQuery() {
if (!window.signatureCache) {
await generateSignature();
}
const resultArea = document.getElementById('resultArea');
resultArea.innerHTML = '<div style="text-align:center; padding:20px;"><div class="loading"></div> 查询中...</div>';
try {
const response = await fetch(window.signatureCache.url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-API-Key': window.signatureCache.headers['X-API-Key'],
'X-Timestamp': window.signatureCache.headers['X-Timestamp'],
'X-Sign': window.signatureCache.headers['X-Sign']
}
});
const result = await response.json();
let html = '<pre>' + JSON.stringify(result, null, 2) + '</pre>';
if (result.code === 200) {
html = '<div style="color:#28a745; margin-bottom:10px;">✅ 查询成功</div>' + html;
} else {
html = '<div style="color:#dc3545; margin-bottom:10px;">❌ 查询失败: ' + (result.msg || '未知错误') + '</div>' + html;
}
resultArea.innerHTML = html;
} catch (error) {
resultArea.innerHTML = '<div style="color:#dc3545; margin-bottom:10px;">❌ 请求失败: ' + error.message + '</div><pre>' + error.stack + '</pre>';
}
}
function hmacSha256(message, secret) {
const encoder = new TextEncoder();
const messageData = encoder.encode(message);
const secretData = encoder.encode(secret);
return crypto.subtle.importKey(
'raw',
secretData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
).then(key => {
return crypto.subtle.sign('HMAC', key, messageData);
}).then(signature => {
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
});
}
</script>
</body>
</html>

BIN
thinkphp/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

24
thinkphp/public/index.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2019 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// [ 应用入口文件 ]
namespace think;
require __DIR__ . '/../vendor/autoload.php';
// 执行HTTP应用并响应
$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -0,0 +1,19 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2019 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// $Id$
if (is_file($_SERVER["DOCUMENT_ROOT"] . $_SERVER["SCRIPT_NAME"])) {
return false;
} else {
$_SERVER["SCRIPT_FILENAME"] = __DIR__ . '/index.php';
require __DIR__ . "/index.php";
}

2
thinkphp/public/static/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

73
thinkphp/route/app.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
use think\facade\Route;
// ========== 系统路由 ==========
/* Route::get('/', function () {
return json([
'app' => '进销存数据开放平台',
'version' => '1.0.0',
'docs' => '/docs',
]);
}); */
Route::get('/health', function () {
try {
\think\facade\Db::connect('mssql')->query('SELECT 1');
$mssqlStatus = 'connected';
} catch (\Exception $e) {
$mssqlStatus = 'disconnected';
}
return json([
'status' => $mssqlStatus === 'connected' ? 'ok' : 'degraded',
'sql_server' => $mssqlStatus,
]);
});
// ========== 对外API路由需要签名验证和限流==========
Route::group('api/v1', function () {
Route::get('chuku', 'api.v1.Chuku/index');
Route::get('ruku', 'api.v1.Ruku/index');
})->middleware([\app\middleware\Auth::class, \app\middleware\RateLimit::class]);
// ========== 后台管理路由(需要管理员认证)==========
// 登录接口不需要认证
Route::post('api/admin/auth/login', 'api.admin.Auth/login');
// 修改密码接口需要认证
Route::put('api/admin/auth/password', 'api.admin.Auth/changePassword')->middleware([\app\middleware\AdminAuth::class]);
// 其他管理接口需要认证
Route::group('api/admin', function () {
// 仪表盘
Route::get('dashboard/stats', 'api.admin.Dashboard/stats');
Route::get('dashboard/trend', 'api.admin.Dashboard/trend');
// 客户管理 - 按从具体到通用的顺序排列
Route::post('clients/api-keys/create/:id', 'api.admin.ClientController/generateApiKey');
Route::get('clients/api-keys/:id', 'api.admin.ClientController/apiKeys');
Route::put('clients/update/:id', 'api.admin.ClientController/update');
Route::patch('clients/toggle/:id', 'api.admin.ClientController/toggle');
Route::delete('api-keys/delete/:keyId', 'api.admin.ClientController/deleteApiKey');
Route::get('clients', 'api.admin.ClientController/index');
Route::post('clients', 'api.admin.ClientController/create');
// 权限管理
Route::get('permissions/:id', 'api.admin.PermissionController/read');
Route::put('permissions/:id', 'api.admin.PermissionController/update');
// 日志管理
Route::get('logs', 'api.admin.Log/index');
Route::get('logs/:id', 'api.admin.Log/read');
Route::get('logs/stats', 'api.admin.Log/stats');
Route::get('logs/export', 'api.admin.Log/export');
})->middleware([\app\middleware\AdminAuth::class]);

64
thinkphp/route/route.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
use think\facade\Route;
defined('DS') || define('DS', DIRECTORY_SEPARATOR);
Route::miss(function () {
$appRequest = request()->pathinfo();
if ($appRequest === null) {
$appName = '';
} else {
$appRequest = str_replace('//', '/', $appRequest);
$appName = explode('/', $appRequest)[0] ?? '';
}
$publicPath = app()->getRootPath() . 'public' . DS;
$staticExtensions = ['js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot'];
$pathParts = explode('.', $appRequest);
$extension = strtolower(end($pathParts));
if (in_array($extension, $staticExtensions)) {
$filePath = $publicPath . $appRequest;
if (file_exists($filePath)) {
$mimeTypes = [
'js' => 'application/javascript',
'css' => 'text/css',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
];
header('Content-Type: ' . ($mimeTypes[$extension] ?? 'application/octet-stream'));
return file_get_contents($filePath);
}
}
switch (strtolower($appName)) {
case 'admin':
$filePath = $publicPath . 'admin' . DS . 'index.html';
break;
case 'home':
case 'pages':
default:
$filePath = $publicPath . 'admin' . DS . 'client.html';
break;
}
if (file_exists($filePath)) {
header('Content-Type: text/html; charset=UTF-8');
return file_get_contents($filePath);
}
return json([
'code' => 404,
'msg' => '页面不存在'
], 404);
});

2
thinkphp/runtime/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

81
thinkphp/test_mssql.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
echo "=== SQL Server 连接测试 ===\n\n";
// 测试1直接使用 sqlsrv_connect
echo "测试1: 使用 sqlsrv_connect\n";
$serverName = "192.168.0.156\dlsql";
$connectionInfo = array(
"Database" => "ywsj",
"UID" => "yisheng",
"PWD" => "yisheng"
);
$conn = sqlsrv_connect($serverName, $connectionInfo);
if ($conn) {
echo "✅ 连接成功!\n";
// 测试查询
$sql = "SELECT 1 AS test";
$stmt = sqlsrv_query($conn, $sql);
if ($stmt) {
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
echo "✅ 查询成功: " . $row['test'] . "\n";
} else {
echo "❌ 查询失败: " . print_r(sqlsrv_errors(), true) . "\n";
}
sqlsrv_close($conn);
} else {
echo "❌ 连接失败\n";
$errors = sqlsrv_errors();
if ($errors) {
foreach ($errors as $error) {
echo "SQLSTATE: " . $error['SQLSTATE'] . "\n";
echo "代码: " . $error['code'] . "\n";
echo "消息: " . $error['message'] . "\n";
}
}
}
echo "\n";
// 测试2使用不同的连接格式
echo "测试2: 使用 IP,端口 格式\n";
$serverName2 = "192.168.0.156,1433";
$conn2 = sqlsrv_connect($serverName2, $connectionInfo);
if ($conn2) {
echo "✅ 连接成功!\n";
sqlsrv_close($conn2);
} else {
echo "❌ 连接失败\n";
$errors = sqlsrv_errors();
if ($errors) {
foreach ($errors as $error) {
echo "消息: " . $error['message'] . "\n";
}
}
}
echo "\n";
// 测试3检查扩展是否加载
echo "测试3: 检查扩展加载情况\n";
if (extension_loaded('sqlsrv')) {
echo "✅ sqlsrv 扩展已加载\n";
} else {
echo "❌ sqlsrv 扩展未加载\n";
}
if (extension_loaded('pdo_sqlsrv')) {
echo "✅ pdo_sqlsrv 扩展已加载\n";
} else {
echo "❌ pdo_sqlsrv 扩展未加载\n";
}
echo "\n=== 测试结束 ===";
?>

10
thinkphp/think Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env php
<?php
namespace think;
// 命令行入口文件
// 加载基础文件
require __DIR__ . '/vendor/autoload.php';
// 应用初始化
(new App())->console->run();

1
thinkphp/view/README.md Normal file
View File

@@ -0,0 +1 @@
如果不使用模板,可以删除该目录

399
thinkphp/采坑文档.md Normal file
View File

@@ -0,0 +1,399 @@
# SQL Server 连接与查询采坑文档
## 问题概述
本项目在 PHP + ThinkPHP 框架下连接 SQL Server 数据库时,遇到了一系列连接和查询问题,现整理如下。
---
## 问题一PHP 缺少 SQL Server 驱动
### 现象
```
SQLSTATE[IMSSP]: This extension requires the Microsoft ODBC Driver for SQL Server to communicate with SQL Server
```
### 原因
PHP 未安装 `sqlsrv``pdo_sqlsrv` 扩展。
### 解决方案
1. 下载对应 PHP 版本的驱动:
- 下载地址:[Microsoft Drivers for PHP for SQL Server](https://learn.microsoft.com/zh-cn/sql/connect/php/download-drivers-php-sql-server?view=sql-server-ver16)
- 版本选择:需匹配 PHP 版本、NTS/TS、x86/x64
2. 将驱动文件复制到 PHP 扩展目录:
- `php_sqlsrv.dll`
- `php_pdo_sqlsrv.dll`
3.`php.ini` 中启用扩展:
```ini
extension=php_sqlsrv.dll
extension=php_pdo_sqlsrv.dll
```
4. 重启 PHP 服务。
### 注意事项
- **Windows 7 兼容性**SQLSRV 5.13.1 支持 Windows 7但需要安装 Visual C++ Redistributable 2017。
- **避免重复加载**:检查 `php.ini` 中是否有重复的扩展配置。
---
## 问题二:缺少 ODBC Driver 依赖
### 现象
```
This extension requires the Microsoft ODBC Driver for SQL Server
```
### 原因
SQLSRV 扩展依赖 Microsoft ODBC Driver for SQL Server。
### 解决方案
1. 安装 ODBC Driver 17 for SQL Server
- 下载地址:[ODBC Driver for SQL Server](https://learn.microsoft.com/zh-cn/sql/connect/odbc/download-odbc-driver-for-sql-server)
2. 安装 Visual C++ Redistributable 2017
- 下载地址:[Visual Studio 2017 Redistributable](https://learn.microsoft.com/zh-cn/cpp/windows/latest-supported-vc-redist?view=msvc-170)
---
## 问题三SQL Server 端口配置错误
### 现象
```
用户 'xxx' 登录失败
```
### 原因
SQL Server 未使用默认端口 1433而是使用了动态端口。
### 解决方案
1. 在 SQL Server 上查询实际端口:
```sql
SELECT local_tcp_port FROM sys.dm_exec_connections WHERE session_id = @@SPID;
```
2. 修改 `.env` 配置文件:
```ini
[MSSQL]
HOSTNAME = 192.168.0.156
HOSTPORT = 50109 ; 实际端口
```
3. 使用 IP+端口方式连接,避免使用实例名。
---
## 问题四参数绑定错误HY093
### 现象
```
SQLSTATE[HY093]: Invalid parameter number: parameter was not defined
```
### 原因分析
#### 子问题 4.1SQL Server PDO 不支持表达式中的参数
**错误代码**
```php
// SQL 模板
WHERE RowID BETWEEN (:offset + 1) AND (:offset + :limit)
// 参数绑定
$params = [':offset' => 0, ':limit' => 20];
```
**问题**SQL Server 的 PDO 驱动不支持在算术表达式中使用命名参数。
**解决方案**:预先计算分页范围,传递单个参数:
```php
$startRow = ($page - 1) * $limit + 1;
$endRow = $page * $limit;
$params = [':start_row' => $startRow, ':end_row' => $endRow];
```
SQL 模板改为:
```sql
WHERE RowID BETWEEN :start_row AND :end_row
```
#### 子问题 4.2:统计查询与数据查询参数不匹配
**错误代码**
```php
$allParams = array_merge($dateParams, $isolationParams, [':start_row', ':end_row']);
Db::query($countSql, $allParams); // countSql 中没有 :start_row 和 :end_row
```
**问题**countSql统计查询和 dataSql数据查询使用了相同的参数数组但 countSql 不需要分页参数。
**解决方案**:将参数数组分开:
```php
$countParams = array_merge($dateParams, $isolationParams);
$dataParams = array_merge($countParams, [':start_row', ':end_row']);
Db::query($countSql, $countParams);
Db::query($dataSql, $dataParams);
```
#### 子问题 4.3:日志 SQL 与执行 SQL 混淆
**错误代码**
```php
$logSql = self::buildLogSql($sqlTemplate, $dateCondition, $isolationSql, $allParams);
$dataSql = $logSql; // $logSql 已替换参数值
Db::query($dataSql, $allParams); // 参数绑定失败
```
**问题**`buildLogSql` 函数返回的 SQL 已经把参数值替换进去(用于日志),但代码用这个 SQL 去做参数绑定,导致 PDO 找不到占位符。
**解决方案**:让 `buildLogSql` 返回两个版本的 SQL
```php
return ['dataSql' => $dataSql, 'logSql' => $logSql];
```
---
## 问题五参数前缀错误IMSSP
### 现象
```
SQLSTATE[IMSSP]: Tried to bind parameter number 65536. SQL Server supports a maximum of 2100 parameters.
```
### 原因分析
#### 子问题 5.1:手动使用 `@` 前缀导致参数编号溢出
**错误代码**
```php
// SQL 模板
WHERE [登记时间] >= @start_date
// 参数绑定
$params = ['@start_date' => $startDate];
```
**问题**SQL Server 的 PDO 驱动在手动使用 `bindValue` 时,会把 `@start_date` 解析为参数编号 655360xFFFF导致参数编号溢出。
**尝试方案 1失败**:改用 `:` 前缀
```php
// SQL 模板
WHERE [登记时间] >= :start_date
// 参数绑定
$params = [':start_date' => $startDate];
```
**结果**:仍然报错 `HY093: Invalid parameter number`,因为 SQL Server 的 PDO 驱动在 CTEWITH 子句)中对 `:` 前缀的参数支持不佳。
#### 子问题 5.2ThinkPHP 自动添加 `:` 前缀
**根本原因**ThinkPHP 的 `PDOConnection` 类在 `bindValue` 方法中会自动添加 `:` 前缀:
```php
// vendor/topthink/think-orm/src/db/PDOConnection.php:1358
protected function bindValue(array $bind = []): void
{
foreach ($bind as $key => $val) {
// 占位符
$param = is_numeric($key) ? $key + 1 : ':' . $key; // 自动添加 : 前缀
// ...
}
}
```
这意味着:
- 参数 `@start_date` → 变成 `:@start_date` ❌
- 参数 `:start_date` → 变成 `::start_date` ❌
- 参数 `start_date` → 变成 `:start_date` ✅
### 解决方案
**正确的参数绑定方式**
```php
// 1. SQL 模板中使用 : 前缀
$dateCondition = " AND [登记时间] >= :start_date";
// 2. 参数数组中使用无前缀键名
$dateParams['start_date'] = $startDate;
// 3. ThinkPHP 自动处理为 :start_date → 绑定成功!
```
**完整示例**
```php
// SQL 模板
WHERE [登记时间] >= :start_date AND [登记时间] <= :end_date
AND [销方全称] = :iso_0
// 参数数组
$params = [
'start_date' => '2024-02-02',
'end_date' => '2026-06-22',
'iso_0' => '喀喇沁旗中医蒙医医院'
];
// 执行查询
Db::connect('mssql')->query($sql, $params);
```
**buildLogSql 函数修改**
```php
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];
}
```
### 修改的文件
1. **MssqlQuery.php**
- `queryChuku` 方法:参数键名从 `:start_date` 改为 `start_date`
- `queryChukuReal` 方法:参数键名从 `@start_row` 改为 `start_row`
- `queryRuku` 方法:参数键名从 `:start_date` 改为 `start_date`
- `queryRukuReal` 方法:参数键名从 `@start_row` 改为 `start_row`
- SQL 模板:保持 `:` 前缀占位符
- `buildLogSql` 方法:替换时自动添加 `:` 前缀
2. **DataIsolation.php**
- `buildCustomFilter` 方法:参数键名从 `@iso_0` 改为 `iso_0`
- `buildAutoFilter` 方法:参数键名从 `@iso_0` 改为 `iso_0`
### 总结
| 方案 | SQL 占位符 | 参数键名 | ThinkPHP 处理后 | 结果 |
|------|-----------|---------|-----------------|------|
| 错误 1 | `@start_date` | `@start_date` | `:@start_date` | ❌ IMSSP 错误 |
| 错误 2 | `:start_date` | `:start_date` | `::start_date` | ❌ HY093 错误 |
| **正确** | `:start_date` | `start_date` | `:start_date` | ✅ 成功 |
---
## 问题六:日志记录缺失
### 现象
无法看到生成的 SQL 语句,难以排查问题。
### 解决方案
在查询执行前后添加详细日志:
```php
Log::debug("出库查询 - 统计SQL: " . $countSql);
Log::debug("出库查询 - 统计参数: " . json_encode($countParams));
Log::debug("出库查询 - 数据SQL: " . $dataSql);
Log::debug("出库查询 - 数据参数: " . json_encode($dataParams));
```
---
## 排查流程总结
```
连接失败
检查 PHP 扩展是否安装
检查 ODBC Driver 是否安装
检查 SQL Server 端口配置
连接成功但查询失败
检查 SQL 语法是否正确
检查参数绑定是否匹配
添加日志记录调试
定位具体问题并修复
```
---
## 关键代码修改
### 1. SQL 模板修改
**文件**`app/service/MssqlQuery.php`
```php
// 修改前
WHERE RowID BETWEEN (:offset + 1) AND (:offset + :limit)
// 修改后
WHERE RowID BETWEEN :start_row AND :end_row
```
### 2. 参数绑定修改
```php
// 修改前
$offset = ($page - 1) * $limit;
$allParams = array_merge($dateParams, $isolationParams, [
':offset' => $offset,
':limit' => $limit
]);
// 修改后
$startRow = ($page - 1) * $limit + 1;
$endRow = $page * $limit;
$countParams = array_merge($dateParams, $isolationParams);
$dataParams = array_merge($countParams, [
':start_row' => $startRow,
':end_row' => $endRow
]);
```
### 3. buildLogSql 函数修改
```php
// 修改前
private static function buildLogSql(...)
{
// ...
return $logSql;
}
// 修改后
private static function buildLogSql(...)
{
// ...
return ['dataSql' => $dataSql, 'logSql' => $logSql];
}
```
---
## 总结
| 问题 | 根本原因 | 解决方案 |
|------|----------|----------|
| 驱动缺失 | PHP 未安装 sqlsrv 扩展 | 安装对应版本驱动 |
| ODBC 依赖 | 缺少 ODBC Driver | 安装 ODBC Driver 17 |
| 端口错误 | SQL Server 使用动态端口 | 查询实际端口并配置 |
| 参数表达式 | SQL Server PDO 不支持 | 预先计算参数值 |
| 参数不匹配 | 统计/数据查询共用参数 | 分开参数数组 |
| 日志混淆 | 执行 SQL 使用了日志版本 | 返回分离的 SQL |
| 参数前缀错误 | ThinkPHP 自动添加 `:` 前缀 | 参数键名用无前缀SQL 用 `:` 前缀 |
---
## 参考文献
- [Microsoft Drivers for PHP for SQL Server](https://learn.microsoft.com/zh-cn/sql/connect/php/download-drivers-php-sql-server)
- [ODBC Driver for SQL Server](https://learn.microsoft.com/zh-cn/sql/connect/odbc/download-odbc-driver-for-sql-server)
- [ThinkPHP 数据库操作](https://www.kancloud.cn/manual/thinkphp6_0/1037537)