feat: 初始化进销存数据开放平台项目
初始化完整的前后端项目结构,包含ThinkPHP后端API服务和Next.js前端管理系统,添加基础配置、中间件、模型、路由等核心代码,以及项目文档和静态资源。
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
admin/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
|
||||||
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
5
frontend/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
1
frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
17
frontend/next.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const routePre = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
|
||||||
|
const basePath = routePre ? `/${routePre}` : '';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
distDir: routePre || "out",
|
||||||
|
trailingSlash: true,
|
||||||
|
basePath: basePath,
|
||||||
|
assetPrefix: basePath,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
8968
frontend/package-lock.json
generated
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/charts": "^2.6.7",
|
||||||
|
"@ant-design/icons": "^6.2.5",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
|
"antd": "^6.4.4",
|
||||||
|
"axios": "^1.18.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"next": "16.2.9",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.9",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
404
frontend/public/api_docs.html
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<!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; color: #333; line-height: 1.6; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
|
||||||
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; border-radius: 8px; margin-bottom: 30px; }
|
||||||
|
.header h1 { font-size: 32px; font-weight: 700; margin-bottom: 10px; }
|
||||||
|
.header p { font-size: 16px; opacity: 0.9; }
|
||||||
|
|
||||||
|
.section { background: white; border-radius: 8px; padding: 30px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
|
.section-title { font-size: 22px; font-weight: 600; color: #333; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #667eea; }
|
||||||
|
|
||||||
|
.endpoint { background: #f8f9fa; border-left: 4px solid #667eea; padding: 20px; margin: 20px 0; border-radius: 4px; }
|
||||||
|
.endpoint-header { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; }
|
||||||
|
.method { background: #28a745; color: white; padding: 4px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
|
||||||
|
.url { font-family: 'Consolas', monospace; font-size: 16px; color: #333; }
|
||||||
|
|
||||||
|
.param-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
||||||
|
.param-table th, .param-table td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
.param-table th { background: #f8f9fa; font-weight: 600; color: #555; }
|
||||||
|
.param-table tr:hover { background: #f8f9fa; }
|
||||||
|
.required { color: #dc3545; font-weight: 600; }
|
||||||
|
.optional { color: #28a745; }
|
||||||
|
|
||||||
|
.code-block { background: #282c34; color: #abb2bf; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'Consolas', monospace; font-size: 14px; margin: 15px 0; }
|
||||||
|
.code-block .keyword { color: #c678dd; }
|
||||||
|
.code-block .string { color: #98c379; }
|
||||||
|
.code-block .comment { color: #5c6370; font-style: italic; }
|
||||||
|
.code-block .function { color: #61afef; }
|
||||||
|
|
||||||
|
.response-example { background: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 4px; padding: 15px; margin: 15px 0; }
|
||||||
|
.response-title { font-weight: 600; color: #2e7d32; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.signature-steps { counter-reset: step; }
|
||||||
|
.signature-step { position: relative; padding-left: 40px; margin: 15px 0; }
|
||||||
|
.signature-step::before { counter-increment: step; content: counter(step); position: absolute; left: 0; top: 0; width: 28px; height: 28px; background: #667eea; color: white; border-radius: 50%; text-align: center; line-height: 28px; font-weight: 600; }
|
||||||
|
|
||||||
|
.alert { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 15px 0; }
|
||||||
|
.alert-title { font-weight: 600; color: #856404; margin-bottom: 5px; }
|
||||||
|
|
||||||
|
.nav { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.nav a { padding: 8px 16px; background: white; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #333; transition: all 0.2s; }
|
||||||
|
.nav a:hover { background: #667eea; color: white; border-color: #667eea; }
|
||||||
|
.test-btn { display: inline-block; background: #28a745; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; transition: all 0.2s; margin-top: 15px; }
|
||||||
|
.test-btn:hover { background: #218838; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>进销存开放平台 API 文档</h1>
|
||||||
|
<p>版本: v1.0 | 基础URL: https://yisheng.cpolar.cn</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="nav">
|
||||||
|
<a href="#auth">认证方式</a>
|
||||||
|
<a href="#chuku">出库接口</a>
|
||||||
|
<a href="#ruku">入库接口</a>
|
||||||
|
<a target="_blank" href="test_api.html">点击测试</a>
|
||||||
|
<a target="_blank" href="client.html">客户端查询</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="auth">
|
||||||
|
<div class="section-title">认证方式</div>
|
||||||
|
|
||||||
|
<p style="margin-bottom: 20px;">所有API请求都需要通过HMAC-SHA256签名验证,确保请求的合法性和完整性。</p>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 10px;">请求头</h3>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>参数名</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>必填</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>X-API-Key</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="required">是</span></td>
|
||||||
|
<td>API密钥,用于标识调用方身份</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>X-Timestamp</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="required">是</span></td>
|
||||||
|
<td>时间戳(秒级),用于防止重放攻击</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>X-Sign</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="required">是</span></td>
|
||||||
|
<td>请求签名,HMAC-SHA256算法生成</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 10px;">签名生成步骤</h3>
|
||||||
|
<div class="signature-steps">
|
||||||
|
<div class="signature-step">
|
||||||
|
<strong>1. 参数排序</strong><br>
|
||||||
|
将所有请求参数(不包括签名相关参数)按参数名ASCII码升序排列
|
||||||
|
</div>
|
||||||
|
<div class="signature-step">
|
||||||
|
<strong>2. 拼接参数字符串</strong><br>
|
||||||
|
将排序后的参数按 key=value 格式拼接,参数之间用 & 连接
|
||||||
|
</div>
|
||||||
|
<div class="signature-step">
|
||||||
|
<strong>3. 添加时间戳</strong><br>
|
||||||
|
在参数字符串末尾追加 ×tamp={时间戳}
|
||||||
|
</div>
|
||||||
|
<div class="signature-step">
|
||||||
|
<strong>4. 生成签名</strong><br>
|
||||||
|
使用 HMAC-SHA256 算法,以 API Secret 为密钥,对完整字符串进行签名
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
<div class="alert-title">⚠️ 注意事项</div>
|
||||||
|
<ul style="margin-left: 20px;">
|
||||||
|
<li>时间戳有效期:7天</li>
|
||||||
|
<li>签名大小写敏感</li>
|
||||||
|
<li>参数值不进行URL编码</li>
|
||||||
|
<li>空值参数不参与签名</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 10px;">签名示例(JavaScript)</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<span class="keyword">async function</span> <span class="function">generateSignature</span>(params, timestamp, apiSecret) {
|
||||||
|
<span class="comment">// 1. 参数排序</span>
|
||||||
|
<span class="keyword">const</span> sortedKeys = Object.keys(params).sort();
|
||||||
|
|
||||||
|
<span class="comment">// 2. 拼接参数字符串</span>
|
||||||
|
<span class="keyword">let</span> paramStr = <span class="string">""</span>;
|
||||||
|
<span class="keyword">for</span> (<span class="keyword">const</span> k <span class="keyword">of</span> sortedKeys) {
|
||||||
|
<span class="keyword">const</span> v = params[k];
|
||||||
|
<span class="keyword">if</span> (v !== null && v !== <span class="string">""</span>) {
|
||||||
|
<span class="keyword">if</span> (paramStr) paramStr += <span class="string">"&"</span>;
|
||||||
|
paramStr += k + <span class="string">"="</span> + v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="comment">// 3. 添加时间戳</span>
|
||||||
|
<span class="keyword">const</span> fullStr = paramStr + <span class="string">"×tamp="</span> + timestamp;
|
||||||
|
|
||||||
|
<span class="comment">// 4. 生成签名</span>
|
||||||
|
<span class="keyword">const</span> encoder = <span class="keyword">new</span> TextEncoder();
|
||||||
|
<span class="keyword">const</span> messageData = encoder.encode(fullStr);
|
||||||
|
<span class="keyword">const</span> secretData = encoder.encode(apiSecret);
|
||||||
|
|
||||||
|
<span class="keyword">const</span> key = <span class="keyword">await</span> crypto.subtle.importKey(
|
||||||
|
<span class="string">"raw"</span>,
|
||||||
|
secretData,
|
||||||
|
{ name: <span class="string">"HMAC"</span>, hash: <span class="string">"SHA-256"</span> },
|
||||||
|
<span class="keyword">false</span>,
|
||||||
|
[<span class="string">"sign"</span>]
|
||||||
|
);
|
||||||
|
|
||||||
|
<span class="keyword">const</span> signature = <span class="keyword">await</span> crypto.subtle.sign(<span class="string">"HMAC"</span>, key, messageData);
|
||||||
|
|
||||||
|
<span class="keyword">return</span> Array.from(<span class="keyword">new</span> Uint8Array(signature))
|
||||||
|
.map(b => b.toString(16).padStart(2, <span class="string">"0"</span>))
|
||||||
|
.join(<span class="string">""</span>);
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="chuku">
|
||||||
|
<div class="section-title">出库接口</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<div class="endpoint-header">
|
||||||
|
<span class="method">GET</span>
|
||||||
|
<span class="url">/api/v1/chuku</span>
|
||||||
|
</div>
|
||||||
|
<p>查询出库(销售)数据,支持分页和日期范围过滤</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>请求参数</h3>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>参数名</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>必填</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>start_date</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>开始日期,格式:YYYY-MM-DD</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>end_date</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>结束日期,格式:YYYY-MM-DD</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>page</td>
|
||||||
|
<td>Integer</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>页码,默认:1</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>limit</td>
|
||||||
|
<td>Integer</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>每页数量,默认:50,最大:500</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>请求示例</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
GET /api/v1/chuku?start_date=2024-02-02&end_date=2026-06-22&page=1&limit=100
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
X-API-Key: 4e2bf206a82f846c699e978cea8c2f87
|
||||||
|
X-Timestamp: 1782119268
|
||||||
|
X-Sign: 3f1b80b43f2bd83f85fc08ade76515773a58b8673b137b8f77e4e90d3dab9acc
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>响应示例</h3>
|
||||||
|
<div class="response-example">
|
||||||
|
<div class="response-title">成功响应 (200)</div>
|
||||||
|
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"total": 1500,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "销售登记",
|
||||||
|
"ticket_no": "A01C202606160002",
|
||||||
|
"register_time": "2026-06-16",
|
||||||
|
"customer_code": "001",
|
||||||
|
"customer_name": "喀喇沁旗中医蒙医医院",
|
||||||
|
"total_amount": 2035.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-example" style="background: #f8d7da; border-color: #f5c6cb;">
|
||||||
|
<div class="response-title" style="color: #721c24;">错误响应 (500)</div>
|
||||||
|
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "数据查询失败: SQL Server 连接不可用"
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="ruku">
|
||||||
|
<div class="section-title">入库接口</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<div class="endpoint-header">
|
||||||
|
<span class="method">GET</span>
|
||||||
|
<span class="url">/api/v1/ruku</span>
|
||||||
|
</div>
|
||||||
|
<p>查询入库(采购)数据,支持分页和日期范围过滤</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>请求参数</h3>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>参数名</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>必填</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>start_date</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>开始日期,格式:YYYY-MM-DD</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>end_date</td>
|
||||||
|
<td>String</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>结束日期,格式:YYYY-MM-DD</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>page</td>
|
||||||
|
<td>Integer</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>页码,默认:1</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>limit</td>
|
||||||
|
<td>Integer</td>
|
||||||
|
<td><span class="optional">否</span></td>
|
||||||
|
<td>每页数量,默认:50,最大:500</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>请求示例</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
GET /api/v1/ruku?start_date=2024-02-02&end_date=2026-06-22&page=1&limit=100
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
X-API-Key: 4e2bf206a82f846c699e978cea8c2f87
|
||||||
|
X-Timestamp: 1782119268
|
||||||
|
X-Sign: 3f1b80b43f2bd83f85fc08ade76515773a58b8673b137b8f77e4e90d3dab9acc
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>响应示例</h3>
|
||||||
|
<div class="response-example">
|
||||||
|
<div class="response-title">成功响应 (200)</div>
|
||||||
|
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"total": 800,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "采购入库",
|
||||||
|
"ticket_no": "A01Q202606160002",
|
||||||
|
"register_time": "2026-06-16",
|
||||||
|
"supplier_code": "1533",
|
||||||
|
"supplier_name": "通化华夏药业有限责任公司",
|
||||||
|
"total_amount": 101890.8,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-example" style="background: #f8d7da; border-color: #f5c6cb;">
|
||||||
|
<div class="response-title" style="color: #721c24;">错误响应 (500)</div>
|
||||||
|
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "数据查询失败: SQL Server 连接不可用"
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">常见错误码</div>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>错误码</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>200</td>
|
||||||
|
<td>请求成功</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>401</td>
|
||||||
|
<td>未授权,API Key无效或签名错误</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>403</td>
|
||||||
|
<td>禁止访问,权限不足或数据隔离配置错误</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>429</td>
|
||||||
|
<td>请求过于频繁,已触发限流</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>500</td>
|
||||||
|
<td>服务器内部错误,通常是数据库连接或查询失败</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; color: #999; margin-top: 40px; padding-bottom: 20px;">
|
||||||
|
<p>© 2026 进销存开放平台 | 文档版本 v1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
598
frontend/public/client.html
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>进销存数据开放平台 - 数据查询</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: #f0f2f5; padding: 12px; }
|
||||||
|
.container { max-width: 100%; margin: 0 auto; }
|
||||||
|
|
||||||
|
.header { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: white; padding: 16px 24px; border-radius: 8px; margin-bottom: 16px; box-shadow: 0 4px 16px rgba(24, 144, 255, 0.3); }
|
||||||
|
.header h1 { font-size: 20px; font-weight: 600; }
|
||||||
|
.header p { font-size: 13px; opacity: 0.9; margin-top: 4px; }
|
||||||
|
|
||||||
|
.card { background: white; border-radius: 8px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.card-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card-title::before { content: ''; width: 4px; height: 16px; background: #1890ff; border-radius: 2px; }
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 10px; }
|
||||||
|
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||||
|
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 13px; transition: all 0.2s; background: #fff; }
|
||||||
|
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); }
|
||||||
|
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.grid-5 { display: grid; grid-template-columns: 1fr 1fr 2fr 60px 1fr; gap: 8px; }
|
||||||
|
|
||||||
|
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 20px; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; gap: 6px; }
|
||||||
|
.btn-primary { background: #1890ff; color: white; }
|
||||||
|
.btn-primary:hover { background: #40a9ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); }
|
||||||
|
.btn-primary:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
.btn-group { display: flex; gap: 12px; }
|
||||||
|
|
||||||
|
.endpoint-tabs { display: flex; gap: 0; margin-bottom: 12px; background: #f5f5f5; padding: 4px; border-radius: 6px; }
|
||||||
|
.endpoint-tabs button { flex: 1; padding: 8px 12px; border: none; background: transparent; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; color: #666; }
|
||||||
|
.endpoint-tabs button.active { background: #1890ff; color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||||||
|
.endpoint-tabs button:hover:not(.active) { color: #333; }
|
||||||
|
|
||||||
|
.loading { display: inline-block; width: 20px; height: 20px; border: 2px solid #f0f0f0; border-top: 2px solid #1890ff; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.info-box { background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; font-size: 12px; color: #1890ff; line-height: 1.5; }
|
||||||
|
|
||||||
|
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.result-header .count { font-size: 13px; color: #666; }
|
||||||
|
.result-header .count strong { color: #1890ff; }
|
||||||
|
|
||||||
|
.table-container { overflow-x: auto; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.data-table th { background: #fafafa; padding: 8px 10px; text-align: left; font-weight: 600; color: #333; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
|
||||||
|
.data-table td { padding: 6px 10px; border-bottom: 1px solid #f5f5f5; color: #555; }
|
||||||
|
.data-table tr:hover { background: #fafafa; }
|
||||||
|
.data-table .text-right { text-align: right; }
|
||||||
|
.data-table .text-center { text-align: center; }
|
||||||
|
.data-table .money { color: #fa8c16; font-family: 'Consolas', monospace; }
|
||||||
|
.data-table .date { color: #8c8c8c; }
|
||||||
|
|
||||||
|
.status-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||||
|
.status-yes { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
|
||||||
|
.status-no { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
|
||||||
|
.empty-state .icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
.error-message { padding: 16px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 6px; color: #ff4d4f; margin-bottom: 16px; }
|
||||||
|
.success-message { padding: 16px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 6px; color: #52c41a; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 12px; }
|
||||||
|
.pagination button { padding: 5px 10px; border: 1px solid #d9d9d9; background: white; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||||
|
.pagination button:hover:not(:disabled) { border-color: #1890ff; color: #1890ff; }
|
||||||
|
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.pagination span { padding: 5px 10px; font-size: 12px; color: #666; }
|
||||||
|
|
||||||
|
.autocomplete-container { position: relative; }
|
||||||
|
.autocomplete-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
background: white;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.autocomplete-list.show { display: block; }
|
||||||
|
.autocomplete-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.autocomplete-item:last-child { border-bottom: none; }
|
||||||
|
.autocomplete-item:hover { background: #f5f7fa; }
|
||||||
|
.autocomplete-item .api-key-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; }
|
||||||
|
.autocomplete-item .delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.autocomplete-item .delete-btn:hover { background: #fff2f0; color: #ff4d4f; }
|
||||||
|
.autocomplete-empty { padding: 12px; text-align: center; color: #999; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
<script src="/admin/js/xlsx.full.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>进销存数据开放平台</h1>
|
||||||
|
<p>输入密钥后点击"点击查询"即可获取数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">API密钥配置</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<div class="autocomplete-container">
|
||||||
|
<input type="text" id="apiKey" value="" placeholder="请输入API Key" onclick="showApiHistory()">
|
||||||
|
<div id="apiHistoryList" class="autocomplete-list"></div>
|
||||||
|
</div>
|
||||||
|
</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">查询参数(注意您的账号权限:只有销售权限只能查询销售流向数据,只有入库权限只能查询入库数据,需要联系管理员配置权限)</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-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>开始日期</label>
|
||||||
|
<input type="date" id="startDate" value="2024-02-02">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>结束日期</label>
|
||||||
|
<input type="date" id="endDate">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>供方全称(拥有全量数据查询权限时可选填)</label>
|
||||||
|
<input type="text" id="supplierName" placeholder="输入供应商全称">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>页码</label>
|
||||||
|
<input type="number" id="page" value="1" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>每页数量(最大200条)</label>
|
||||||
|
<input type="number" id="limit" value="20" min="1" max="200" oninput="limitInputValue(this, 1, 200)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<button class="btn btn-primary" id="queryBtn" onclick="executeQuery()">
|
||||||
|
点击查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">查询结果</div>
|
||||||
|
<div id="resultArea"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentEndpoint = 'chuku';
|
||||||
|
let isQuerying = false;
|
||||||
|
let currentTotal = 0;
|
||||||
|
let currentData = [];
|
||||||
|
let currentColumns = [];
|
||||||
|
|
||||||
|
const CHUKU_COLUMNS = [
|
||||||
|
{ key: 'chuku_id', label: '出库序号' },
|
||||||
|
{ key: 'ruku_id', label: '入库序号' },
|
||||||
|
{ key: 'ticket_no', label: '票号' },
|
||||||
|
{ key: 'date', label: '日期', className: 'date' },
|
||||||
|
{ key: 'warehouse', label: '库房' },
|
||||||
|
{ key: 'batch_no', label: '批号' },
|
||||||
|
{ key: 'expire_date', label: '效期', className: 'date' },
|
||||||
|
{ key: 'quantity', label: '数量', className: 'text-right' },
|
||||||
|
{ key: 'unit_price', label: '单价', className: 'money text-right' },
|
||||||
|
{ key: 'amount', label: '金额', className: 'money text-right' },
|
||||||
|
{ key: 'purchase_price', label: '进价', className: 'money text-right' },
|
||||||
|
{ key: 'profit', label: '毛利', className: 'money text-right' },
|
||||||
|
{ key: 'operator', label: '操作员' },
|
||||||
|
{ key: 'salesman', label: '业务员' },
|
||||||
|
{ key: 'receiver', label: '提货人' },
|
||||||
|
{ key: 'supplier_name', label: '供方全称', width: '150px' },
|
||||||
|
{ key: 'customer_name', label: '销方全称', width: '150px' },
|
||||||
|
{ key: 'customer_region', label: '销方地区' },
|
||||||
|
{ key: 'product_name', label: '产品名称' },
|
||||||
|
{ key: 'product_spec', label: '规格' },
|
||||||
|
{ key: 'product_origin', label: '产地' },
|
||||||
|
{ key: 'product_unit', label: '单位' },
|
||||||
|
{ key: 'manufacturer', label: '生产厂家' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUKU_COLUMNS = [
|
||||||
|
{ key: 'ruku_id', label: '入库序号' },
|
||||||
|
{ key: 'ticket_no', label: '票号' },
|
||||||
|
{ key: 'date', label: '日期', className: 'date' },
|
||||||
|
{ key: 'warehouse', label: '库房' },
|
||||||
|
{ key: 'batch_no', label: '批号' },
|
||||||
|
{ key: 'expire_date', label: '效期', className: 'date' },
|
||||||
|
{ key: 'quantity', label: '数量', className: 'text-right' },
|
||||||
|
{ key: 'unit_price', label: '单价', className: 'money text-right' },
|
||||||
|
{ key: 'amount', label: '金额', className: 'money text-right' },
|
||||||
|
{ key: 'purchase_price', label: '进价', className: 'money text-right' },
|
||||||
|
{ key: 'warehouse_qty', label: '库房数量', className: 'text-right' },
|
||||||
|
{ key: 'operator', label: '操作员' },
|
||||||
|
{ key: 'purchaser', label: '采购员' },
|
||||||
|
{ key: 'responsible_person', label: '负责人' },
|
||||||
|
{ key: 'arrival_no', label: '来货单号' },
|
||||||
|
{ key: 'invoice_no', label: '发票单号' },
|
||||||
|
{ key: 'summary', label: '摘要' },
|
||||||
|
{ key: 'supplier_name', label: '供方全称', width: '150px' },
|
||||||
|
{ key: 'product_name', label: '产品名称' },
|
||||||
|
{ key: 'product_spec', label: '规格' },
|
||||||
|
{ key: 'product_origin', label: '产地' },
|
||||||
|
{ key: 'product_unit', label: '单位' },
|
||||||
|
{ key: 'manufacturer', label: '生产厂家' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function switchEndpoint(endpoint) {
|
||||||
|
currentEndpoint = endpoint;
|
||||||
|
document.querySelectorAll('.endpoint-tabs button').forEach(btn => btn.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById('resultArea').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeQuery() {
|
||||||
|
const apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
const apiSecret = document.getElementById('apiSecret').value.trim();
|
||||||
|
|
||||||
|
if (!apiKey || !apiSecret) {
|
||||||
|
showError('请先填写 API Key 和 API Secret');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuerying) return;
|
||||||
|
isQuerying = true;
|
||||||
|
|
||||||
|
const btn = document.getElementById('queryBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading"></span> 查询中...';
|
||||||
|
|
||||||
|
const resultArea = document.getElementById('resultArea');
|
||||||
|
resultArea.innerHTML = '<div style="text-align:center; padding:40px;"><span class="loading"></span><div style="margin-top:16px;color:#999;">正在生成签名并查询数据...</div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startDate = document.getElementById('startDate').value;
|
||||||
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
const supplierName = document.getElementById('supplierName').value.trim();
|
||||||
|
const page = document.getElementById('page').value;
|
||||||
|
const limit = document.getElementById('limit').value;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
supplier_name: supplierName,
|
||||||
|
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 + '×tamp=' + timestamp;
|
||||||
|
|
||||||
|
const sign = await hmacSha256(fullStr, apiSecret);
|
||||||
|
|
||||||
|
const url = `https://yisheng.cpolar.cn/api/v1/${currentEndpoint}?${paramStr}`;
|
||||||
|
|
||||||
|
//const url = `http://localhost:8000/api/v1/${currentEndpoint}?${paramStr}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
'X-Timestamp': timestamp,
|
||||||
|
'X-Sign': sign
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
saveApiToHistory(apiKey, apiSecret);
|
||||||
|
currentTotal = result.total || 0;
|
||||||
|
currentData = result.data || [];
|
||||||
|
currentColumns = currentEndpoint === 'chuku' ? CHUKU_COLUMNS : RUKU_COLUMNS;
|
||||||
|
renderTable(currentData);
|
||||||
|
} else {
|
||||||
|
currentData = [];
|
||||||
|
currentColumns = [];
|
||||||
|
showError(result.msg || '查询失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('请求失败: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isQuerying = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '查询数据';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const resultArea = document.getElementById('resultArea');
|
||||||
|
resultArea.innerHTML = '<div class="error-message">❌ ' + message + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(data) {
|
||||||
|
const resultArea = document.getElementById('resultArea');
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
resultArea.innerHTML = `
|
||||||
|
<div class="result-header">
|
||||||
|
<div class="count">共 <strong>0</strong> 条记录</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="icon">📭</div>
|
||||||
|
<p>暂无数据,请调整查询条件后重试</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = currentEndpoint === 'chuku' ? CHUKU_COLUMNS : RUKU_COLUMNS;
|
||||||
|
const page = parseInt(document.getElementById('page').value) || 1;
|
||||||
|
const limit = parseInt(document.getElementById('limit').value) || 20;
|
||||||
|
const totalPages = Math.ceil(currentTotal / limit);
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="result-header">
|
||||||
|
<div class="count">共 <strong>${currentTotal}</strong> 条记录,当前第 <strong>${page}</strong> 页</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<div class="pagination">
|
||||||
|
<button onclick="changePage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||||
|
<span>${page} / ${totalPages}</span>
|
||||||
|
<button onclick="changePage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="exportToExcel()" style="padding: 6px 16px; font-size: 13px;">
|
||||||
|
📥 导出Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
columns.forEach(col => {
|
||||||
|
html += `<th>${col.label}</th>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
html += '<tr>';
|
||||||
|
columns.forEach(col => {
|
||||||
|
const value = row[col.key];
|
||||||
|
let cellValue = '';
|
||||||
|
|
||||||
|
if (col.render) {
|
||||||
|
cellValue = col.render(value);
|
||||||
|
} else if (value === null || value === undefined || value === '') {
|
||||||
|
cellValue = '-';
|
||||||
|
} else if (col.className && col.className.includes('money')) {
|
||||||
|
cellValue = value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
} else {
|
||||||
|
cellValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<td class="${col.className || ''}">${cellValue}</td>`;
|
||||||
|
});
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<button onclick="changePage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||||
|
<span>${page} / ${totalPages}</span>
|
||||||
|
<button onclick="changePage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
resultArea.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(value) {
|
||||||
|
const yesValues = ['Y', '是', '1', 'true', 'TRUE', true];
|
||||||
|
const isYes = yesValues.includes(value);
|
||||||
|
return `<span class="status-tag ${isYes ? 'status-yes' : 'status-no'}">${isYes ? '是' : '否'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page) {
|
||||||
|
if (page < 1) return;
|
||||||
|
document.getElementById('page').value = page;
|
||||||
|
executeQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportToExcel() {
|
||||||
|
if (!currentData || currentData.length === 0) {
|
||||||
|
alert('没有数据可导出,请先查询数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = currentColumns.map(col => col.label);
|
||||||
|
const data = currentData.map(row => {
|
||||||
|
return currentColumns.map(col => {
|
||||||
|
const value = row[col.key];
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (col.className && col.className.includes('money')) {
|
||||||
|
return value || 0;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]);
|
||||||
|
|
||||||
|
const columnWidths = headers.map((header, index) => {
|
||||||
|
let maxLength = header.length;
|
||||||
|
data.forEach(row => {
|
||||||
|
const cellValue = String(row[index] || '');
|
||||||
|
if (cellValue.length > maxLength) {
|
||||||
|
maxLength = cellValue.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { wch: Math.min(maxLength + 2, 50) };
|
||||||
|
});
|
||||||
|
worksheet['!cols'] = columnWidths;
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, '数据');
|
||||||
|
|
||||||
|
const fileName = `${currentEndpoint === 'chuku' ? '销售数据' : '入库数据'}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, fileName);
|
||||||
|
} catch (error) {
|
||||||
|
alert('导出失败: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
document.getElementById('endDate').value = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const list = document.getElementById('apiHistoryList');
|
||||||
|
const container = document.querySelector('.autocomplete-container');
|
||||||
|
if (!container.contains(e.target)) {
|
||||||
|
list.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getApiHistory() {
|
||||||
|
const historyStr = localStorage.getItem('apiKeyHistory');
|
||||||
|
return historyStr ? JSON.parse(historyStr) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveApiToHistory(apiKey, apiSecret) {
|
||||||
|
let history = getApiHistory();
|
||||||
|
history = history.filter(item => item.apiKey !== apiKey);
|
||||||
|
history.unshift({ apiKey, apiSecret });
|
||||||
|
if (history.length > 10) {
|
||||||
|
history = history.slice(0, 10);
|
||||||
|
}
|
||||||
|
localStorage.setItem('apiKeyHistory', JSON.stringify(history));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteApiFromHistory(apiKey) {
|
||||||
|
let history = getApiHistory();
|
||||||
|
history = history.filter(item => item.apiKey !== apiKey);
|
||||||
|
localStorage.setItem('apiKeyHistory', JSON.stringify(history));
|
||||||
|
renderApiHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApiHistory() {
|
||||||
|
const list = document.getElementById('apiHistoryList');
|
||||||
|
renderApiHistory();
|
||||||
|
list.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectApiFromHistory(apiKey, apiSecret) {
|
||||||
|
document.getElementById('apiKey').value = apiKey;
|
||||||
|
document.getElementById('apiSecret').value = apiSecret;
|
||||||
|
document.getElementById('apiHistoryList').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApiHistory() {
|
||||||
|
const history = getApiHistory();
|
||||||
|
const list = document.getElementById('apiHistoryList');
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
list.innerHTML = '<div class="autocomplete-empty">暂无历史记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
history.forEach(item => {
|
||||||
|
html += `
|
||||||
|
<div class="autocomplete-item" onclick="selectApiFromHistory('${escapeHtml(item.apiKey)}', '${escapeHtml(item.apiSecret)}')">
|
||||||
|
<span class="api-key-text">${escapeHtml(item.apiKey)}</span>
|
||||||
|
<button class="delete-btn" onclick="event.stopPropagation(); deleteApiFromHistory('${escapeHtml(item.apiKey)}')" title="删除">🗑️</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
list.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitInputValue(input, min, max) {
|
||||||
|
let value = parseInt(input.value);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value < min) {
|
||||||
|
input.value = min;
|
||||||
|
} else if (value > max) {
|
||||||
|
input.value = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
24
frontend/public/js/xlsx.full.min.js
vendored
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
238
frontend/public/test_api.html
Normal 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 + '×tamp=' + timestamp;
|
||||||
|
|
||||||
|
const sign = await hmacSha256(fullStr, apiSecret);
|
||||||
|
|
||||||
|
const url = `https://yisheng.cpolar.cn/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>
|
||||||
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
343
frontend/src/app/clients/page.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table, Button, Modal, Form, Input, Select, Tag, Switch, Space, message,
|
||||||
|
Popconfirm, Typography, Tooltip, Card,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, KeyOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import AdminLayout from '@/components/AdminLayout';
|
||||||
|
import {
|
||||||
|
getClients, createClient, updateClient, toggleClient, generateApiKey,
|
||||||
|
getClientApiKeys, deleteApiKey,
|
||||||
|
} from '@/services/api';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
supplier: { label: '上游供应商', color: 'blue' },
|
||||||
|
distributor: { label: '下游分销商', color: 'green' },
|
||||||
|
hospital: { label: '医院/客户', color: 'orange' },
|
||||||
|
other: { label: '其他', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClientRecord {
|
||||||
|
id: number; client_no?: string; name: string; client_type: string; contact_person?: string;
|
||||||
|
phone?: string; status: number; remark?: string; api_keys: ApiKeyItem[];
|
||||||
|
}
|
||||||
|
interface ApiKeyItem {
|
||||||
|
id: number; api_key: string; api_secret_hash: string; status: number;
|
||||||
|
rate_limit_per_minute: number; rate_limit_per_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientsPage() {
|
||||||
|
const [data, setData] = useState<ClientRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||||
|
const [secretModal, setSecretModal] = useState<{ visible: boolean; apiKey?: string; apiSecret?: string }>({ visible: false });
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [searchForm] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchClients = useCallback(async (params: Record<string, unknown> = {}) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getClients(params);
|
||||||
|
setData(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('获取客户列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchClients(); }, [fetchClients]);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
const handleSearchChange = (key: string, val: unknown) => {
|
||||||
|
setFilters(f => ({ ...f, [key]: val || undefined }));
|
||||||
|
fetchClients({ ...filters, [key]: val || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFilters({});
|
||||||
|
fetchClients();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await createClient(values);
|
||||||
|
message.success('客户创建成功');
|
||||||
|
setModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchClients();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.data?.detail) message.error(err.response.data.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: ClientRecord) => {
|
||||||
|
setEditingClient(record);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
client_no: record.client_no,
|
||||||
|
name: record.name,
|
||||||
|
client_type: record.client_type,
|
||||||
|
contact_person: record.contact_person,
|
||||||
|
phone: record.phone,
|
||||||
|
remark: record.remark,
|
||||||
|
});
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!editingClient) return;
|
||||||
|
try {
|
||||||
|
const values = await editForm.validateFields();
|
||||||
|
await updateClient(editingClient.id, values);
|
||||||
|
message.success('客户信息更新成功');
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingClient(null);
|
||||||
|
editForm.resetFields();
|
||||||
|
fetchClients();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.data?.detail) message.error(err.response.data.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await toggleClient(id);
|
||||||
|
message.success('状态已更新');
|
||||||
|
fetchClients();
|
||||||
|
} catch (err) {
|
||||||
|
message.error('操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateKey = async (clientId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await generateApiKey(clientId);
|
||||||
|
setSecretModal({ visible: true, apiKey: res.data.api_key, apiSecret: res.data.api_secret });
|
||||||
|
fetchClients();
|
||||||
|
} catch (err) {
|
||||||
|
message.error('生成密钥失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (keyId: number) => {
|
||||||
|
try {
|
||||||
|
await deleteApiKey(keyId);
|
||||||
|
message.success('API Key 已删除');
|
||||||
|
fetchClients();
|
||||||
|
} catch (err) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
message.success('已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
|
{ title: '客户序号', dataIndex: 'client_no', width: 120 },
|
||||||
|
{ title: '客户名称', dataIndex: 'name', width: 200 },
|
||||||
|
{
|
||||||
|
title: '客户类型', dataIndex: 'client_type', width: 120,
|
||||||
|
render: (val: string) => {
|
||||||
|
const t = TYPE_MAP[val] || { label: val, color: 'default' };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '联系人', dataIndex: 'contact_person', width: 100 },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'status', width: 100,
|
||||||
|
render: (val: number, record: ClientRecord) => (
|
||||||
|
<Switch
|
||||||
|
checked={val === 1}
|
||||||
|
checkedChildren="启用"
|
||||||
|
unCheckedChildren="禁用"
|
||||||
|
onChange={() => handleToggle(record.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API Key 数量', width: 120,
|
||||||
|
render: (_: unknown, record: ClientRecord) => record.api_keys?.length || 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 200, fixed: 'right' as const,
|
||||||
|
render: (_: unknown, record: ClientRecord) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" onClick={() => handleEdit(record)}>修改</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const expandedRowRender = (record: ClientRecord) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<KeyOutlined />} onClick={() => handleGenerateKey(record.id)}>
|
||||||
|
生成密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={record.api_keys || []}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'API Key', dataIndex: 'api_key',
|
||||||
|
render: (val: string) => (
|
||||||
|
<Space>
|
||||||
|
<Text code copyable>{val}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API Secret Hash', dataIndex: 'api_secret_hash',
|
||||||
|
render: (val: string) => (
|
||||||
|
<Space>
|
||||||
|
<Text code copyable>{val}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '状态', dataIndex: 'status', render: (val: number) => val === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag> },
|
||||||
|
{ title: '限流/分钟', dataIndex: 'rate_limit_per_minute' },
|
||||||
|
{ title: '限流/天', dataIndex: 'rate_limit_per_day' },
|
||||||
|
{
|
||||||
|
title: '操作', width: 80,
|
||||||
|
render: (_: unknown, keyRecord: ApiKeyItem) => (
|
||||||
|
<Popconfirm title="确认删除此 API Key?" onConfirm={() => handleDeleteKey(keyRecord.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<Card
|
||||||
|
title="上下游客户管理"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
placeholder="客户序号"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filters.client_no as string}
|
||||||
|
onChange={(e) => handleSearchChange('client_no', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="客户名称"
|
||||||
|
style={{ width: 150 }}
|
||||||
|
value={filters.name as string}
|
||||||
|
onChange={(e) => handleSearchChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="客户类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filters.client_type as string}
|
||||||
|
onChange={(val) => handleSearchChange('client_type', val)}
|
||||||
|
options={[
|
||||||
|
{ label: '上游供应商', value: 'supplier' },
|
||||||
|
{ label: '下游分销商', value: 'distributor' },
|
||||||
|
{ label: '医院/客户', value: 'hospital' },
|
||||||
|
{ label: '其他', value: 'other' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>新建客户</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
loading={loading}
|
||||||
|
expandable={{ expandedRowRender }}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 新建客户 Modal */}
|
||||||
|
<Modal title="新建客户" open={modalVisible} onOk={handleCreate} onCancel={() => setModalVisible(false)} destroyOnHidden>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="client_no" label="客户序号">
|
||||||
|
<Input placeholder="例如:C001" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="客户名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:喀喇沁旗中医蒙医医院" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="client_type" label="客户类型" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="请选择">
|
||||||
|
<Select.Option value="supplier">上游供应商</Select.Option>
|
||||||
|
<Select.Option value="distributor">下游分销商</Select.Option>
|
||||||
|
<Select.Option value="hospital">医院/客户</Select.Option>
|
||||||
|
<Select.Option value="other">其他</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="contact_person" label="联系人"><Input /></Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 修改客户 Modal */}
|
||||||
|
<Modal title="修改客户" open={editModalVisible} onOk={handleUpdate} onCancel={() => setEditModalVisible(false)} destroyOnHidden>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="client_no" label="客户序号">
|
||||||
|
<Input placeholder="例如:C001" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="客户名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:喀喇沁旗中医蒙医医院" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="client_type" label="客户类型" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="请选择">
|
||||||
|
<Select.Option value="supplier">上游供应商</Select.Option>
|
||||||
|
<Select.Option value="distributor">下游分销商</Select.Option>
|
||||||
|
<Select.Option value="hospital">医院/客户</Select.Option>
|
||||||
|
<Select.Option value="other">其他</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="contact_person" label="联系人"><Input /></Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* API Secret 展示 */}
|
||||||
|
<Modal
|
||||||
|
title="API 密钥已生成"
|
||||||
|
open={secretModal.visible}
|
||||||
|
onCancel={() => setSecretModal({ visible: false })}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<div style={{ background: '#fff7e6', padding: 16, borderRadius: 8, marginBottom: 16 }}>
|
||||||
|
<Text type="warning" strong>API Secret 很重要,切勿泄漏。</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>API Key:</Text>
|
||||||
|
<Paragraph code copyable style={{ marginTop: 4 }}>{secretModal.apiKey}</Paragraph>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong>API Secret:</Text>
|
||||||
|
<Paragraph code copyable style={{ marginTop: 4 }}>{secretModal.apiSecret}</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, Col, Row, Statistic, Spin } from 'antd';
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Line } from '@ant-design/charts';
|
||||||
|
import AdminLayout from '@/components/AdminLayout';
|
||||||
|
import { getDashboardStats, getDashboardTrend } from '@/services/api';
|
||||||
|
|
||||||
|
interface Stats { today_total_calls: number; active_clients: number; error_calls: number; }
|
||||||
|
interface TrendPoint { hour: string; count: number; }
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [trend, setTrend] = useState<TrendPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [statsRes, trendRes] = await Promise.all([
|
||||||
|
getDashboardStats(),
|
||||||
|
getDashboardTrend(),
|
||||||
|
]);
|
||||||
|
setStats(statsRes.data);
|
||||||
|
setTrend(trendRes.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <AdminLayout><Spin size="large" style={{ display: 'block', margin: '100px auto' }} /></AdminLayout>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="今日 API 调用总数"
|
||||||
|
value={stats?.today_total_calls ?? 0}
|
||||||
|
prefix={<ApiOutlined />}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="活跃客户数"
|
||||||
|
value={stats?.active_clients ?? 0}
|
||||||
|
prefix={<TeamOutlined />}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="异常调用数"
|
||||||
|
value={stats?.error_calls ?? 0}
|
||||||
|
prefix={<WarningOutlined />}
|
||||||
|
valueStyle={{ color: stats?.error_calls ? '#ff4d4f' : '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card title="近 24 小时 API 调用趋势" style={{ marginBottom: 24 }}>
|
||||||
|
{trend.length > 0 ? (
|
||||||
|
<Line
|
||||||
|
data={trend.map(p => ({ time: p.hour.slice(5, 16), count: Number(p.count), fullHour: p.hour }))}
|
||||||
|
xField="time"
|
||||||
|
yField="count"
|
||||||
|
smooth
|
||||||
|
height={280}
|
||||||
|
color="#1890ff"
|
||||||
|
axis={{
|
||||||
|
x: { title: { text: '时间' }, label: { autoRotate: false }, grid: { line: { style: { stroke: '#333', lineWidth: 2 } } } },
|
||||||
|
y: { title: { text: '调用次数' }, min: 0, grid: { line: { style: { stroke: '#333', lineWidth: 2 } } } },
|
||||||
|
}}
|
||||||
|
tooltip={{
|
||||||
|
title: (datum: { fullHour: string }) => datum.fullHour,
|
||||||
|
items: [
|
||||||
|
(datum: { count: number }) => ({
|
||||||
|
name: '调用次数',
|
||||||
|
value: datum.count,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}>暂无调用数据</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
5
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
21
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AntdRegistry } from "@ant-design/nextjs-registry";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "进销存数据开放平台",
|
||||||
|
description: "进销存数据开放平台后台管理系统",
|
||||||
|
icons: {
|
||||||
|
icon: "./favicon.ico",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body>
|
||||||
|
<AntdRegistry>{children}</AntdRegistry>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, Button, Card, message, Typography, Spin } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { login } from '@/services/api';
|
||||||
|
import { buildRoute, matchRoute } from '@/utils/route';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
router.push(buildRoute('/dashboard'));
|
||||||
|
} else {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFinish = async (values: { username: string; password: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await login(values.username, values.password);
|
||||||
|
localStorage.setItem('token', res.data.access_token);
|
||||||
|
localStorage.setItem('username', res.data.username || values.username);
|
||||||
|
message.success('登录成功');
|
||||||
|
router.push(buildRoute('/dashboard'));
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail || '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
}}>
|
||||||
|
<Card style={{ width: 400, borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.15)' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<Title level={3} style={{ marginBottom: 4 }}>进销存数据开放平台</Title>
|
||||||
|
<p style={{ color: '#888' }}>后台管理系统</p>
|
||||||
|
</div>
|
||||||
|
<Form onFinish={onFinish} size="large">
|
||||||
|
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
|
登 录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
frontend/src/app/logs/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Card, Table, DatePicker, Select, Tag, Button, Space, message, Modal, Descriptions, Input } from 'antd';
|
||||||
|
import { DownloadOutlined, SearchOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import AdminLayout from '@/components/AdminLayout';
|
||||||
|
import { getLogs, exportLogs, getClients } from '@/services/api';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||||
|
page: 1, limit: 50,
|
||||||
|
});
|
||||||
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
|
const [currentLog, setCurrentLog] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getClients().then(res => setClients(res.data)).catch(() => {});
|
||||||
|
fetchLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getLogs(filters);
|
||||||
|
setData(res.data.data);
|
||||||
|
setTotal(res.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('获取日志失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters(f => ({ ...f, page: 1 }));
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const res = await exportLogs(filters);
|
||||||
|
let csvContent = '';
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
if (res.data instanceof Blob) {
|
||||||
|
responseText = await res.data.text();
|
||||||
|
} else {
|
||||||
|
responseText = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonData = JSON.parse(responseText);
|
||||||
|
if (jsonData && jsonData.data && Array.isArray(jsonData.data)) {
|
||||||
|
const headers = ['ID', '客户ID', '接口', '响应码', '返回条数', '耗时(ms)', 'IP', '时间'];
|
||||||
|
csvContent = headers.join(',') + '\n';
|
||||||
|
|
||||||
|
for (const log of jsonData.data) {
|
||||||
|
const row = [
|
||||||
|
log.id || '',
|
||||||
|
log.client_id || '',
|
||||||
|
log.endpoint || '',
|
||||||
|
log.response_code || '',
|
||||||
|
log.record_count || '',
|
||||||
|
log.duration_ms || '',
|
||||||
|
log.ip_address || '',
|
||||||
|
log.created_at ? dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss') : '',
|
||||||
|
];
|
||||||
|
csvContent += row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',') + '\n';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
csvContent = responseText;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
csvContent = responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `api_logs_${dayjs().format('YYYYMMDD')}.csv`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetail = (record: any) => {
|
||||||
|
setCurrentLog(record);
|
||||||
|
setDetailVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '时间', dataIndex: 'created_at', width: 170,
|
||||||
|
render: (val: string) => val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户', dataIndex: 'client_id', width: 160,
|
||||||
|
render: (val: number) => clients.find(c => c.id === val)?.name || `ID:${val}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '接口', dataIndex: 'endpoint', width: 140,
|
||||||
|
render: (val: string) => (
|
||||||
|
<Tag color={val.includes('chuku') ? 'blue' : 'green'}>
|
||||||
|
{val === '/api/v1/chuku' ? '销售' : val === '/api/v1/ruku' ? '入库' : val}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求参数', dataIndex: 'request_params', width: 200, ellipsis: true,
|
||||||
|
render: (val: any) => val ? JSON.stringify(val) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '响应码', dataIndex: 'response_code', width: 90,
|
||||||
|
render: (val: number) => {
|
||||||
|
const color = val === 200 ? 'green' : val === 429 ? 'orange' : 'red';
|
||||||
|
return <Tag color={color}>{val}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '返回条数', dataIndex: 'record_count', width: 90 },
|
||||||
|
{
|
||||||
|
title: '耗时', dataIndex: 'duration_ms', width: 90,
|
||||||
|
render: (val: number) => val ? `${val}ms` : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IP', dataIndex: 'ip_address', width: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 100, fixed: 'right' as const,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Button size="small" icon={<EyeOutlined />} onClick={() => showDetail(record)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<Card
|
||||||
|
title="API 调用日志审计"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<RangePicker
|
||||||
|
onChange={(dates) => {
|
||||||
|
setFilters(f => ({
|
||||||
|
...f,
|
||||||
|
start_date: dates?.[0]?.format('YYYY-MM-DD') || undefined,
|
||||||
|
end_date: dates?.[1]?.format('YYYY-MM-DD') || undefined,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="用户名称"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onChange={(e) => setFilters(f => ({ ...f, client_name: e.target.value }))}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="接口"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onChange={(val) => setFilters(f => ({ ...f, endpoint: val }))}
|
||||||
|
options={[
|
||||||
|
{ label: '销售', value: '/api/v1/chuku' },
|
||||||
|
{ label: '入库', value: '/api/v1/ruku' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onChange={(val) => setFilters(f => ({ ...f, status: val }))}
|
||||||
|
options={[
|
||||||
|
{ label: '正常', value: 'success' },
|
||||||
|
{ label: '错误', value: 'error' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出 CSV</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: filters.page as number,
|
||||||
|
pageSize: filters.limit as number,
|
||||||
|
total,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200'],
|
||||||
|
onChange: (page, limit) => setFilters(f => ({ ...f, page, limit })),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 日志详情 Modal */}
|
||||||
|
<Modal
|
||||||
|
title="调用日志详情"
|
||||||
|
open={detailVisible}
|
||||||
|
onCancel={() => setDetailVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" onClick={() => setDetailVisible(false)}>关闭</Button>,
|
||||||
|
]}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
{currentLog && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="日志ID">{currentLog.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">
|
||||||
|
{currentLog.created_at ? dayjs(currentLog.created_at).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{clients.find(c => c.id === currentLog.client_id)?.name || `ID:${currentLog.client_id}`}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="接口">
|
||||||
|
<Tag color={currentLog.endpoint?.includes('chuku') ? 'blue' : 'green'}>
|
||||||
|
{currentLog.endpoint === '/api/v1/chuku' ? '销售' : currentLog.endpoint === '/api/v1/ruku' ? '入库' : currentLog.endpoint}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="请求参数">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12 }}>
|
||||||
|
{currentLog.request_params ? JSON.stringify(currentLog.request_params, null, 2) : '-'}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="响应码">
|
||||||
|
<Tag color={currentLog.response_code === 200 ? 'green' : currentLog.response_code === 429 ? 'orange' : 'red'}>
|
||||||
|
{currentLog.response_code}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="返回条数">{currentLog.record_count ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="耗时">{currentLog.duration_ms ? `${currentLog.duration_ms}ms` : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IP地址">{currentLog.ip_address || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="响应体">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{currentLog.response_body ? JSON.stringify(currentLog.response_body, null, 2) : '-'}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="执行SQL">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, maxHeight: 300, overflowY: 'auto', background: '#f5f5f5', padding: 8, borderRadius: 4 }}>
|
||||||
|
{currentLog.sql_query || '-'}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="错误信息">
|
||||||
|
{currentLog.error_message ? (
|
||||||
|
<span style={{ color: '#ff4d4f' }}>{currentLog.error_message}</span>
|
||||||
|
) : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
338
frontend/src/app/permissions/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Card, Table, Checkbox, Radio, Select, Tag, Button, Space,
|
||||||
|
Form, message, Spin, Input, Empty,
|
||||||
|
} from 'antd';
|
||||||
|
import { SaveOutlined } from '@ant-design/icons';
|
||||||
|
import AdminLayout from '@/components/AdminLayout';
|
||||||
|
import { getClients, getClientApiKeys, getPermission, updatePermission } from '@/services/api';
|
||||||
|
|
||||||
|
interface ClientRecord { id: number; client_no?: string; name: string; client_type: string; api_keys: ApiKeyItem[]; }
|
||||||
|
interface ApiKeyItem { id: number; api_key: string; status: number; }
|
||||||
|
interface PermData {
|
||||||
|
id: number; api_key_id: number; allow_chuku: number; allow_ruku: number;
|
||||||
|
allow_full_data: number; filter_field?: string; filter_values?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_FIELDS = [
|
||||||
|
{ label: '销方全称', value: '销方全称' },
|
||||||
|
{ label: '供方全称', value: '供方全称' },
|
||||||
|
{ label: '销方序号', value: '销方序号' },
|
||||||
|
{ label: '供方序号', value: '供方序号' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
supplier: { label: '上游供应商', color: 'blue' },
|
||||||
|
distributor: { label: '下游分销商', color: 'green' },
|
||||||
|
hospital: { label: '医院/客户', color: 'orange' },
|
||||||
|
other: { label: '其他', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PermissionsPage() {
|
||||||
|
const [clients, setClients] = useState<ClientRecord[]>([]);
|
||||||
|
const [selectedKeyId, setSelectedKeyId] = useState<number | null>(null);
|
||||||
|
const [selectedClient, setSelectedClient] = useState<ClientRecord | null>(null);
|
||||||
|
const [perm, setPerm] = useState<PermData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [filterValues, setFilterValues] = useState<string[]>([]);
|
||||||
|
const [newFilterValue, setNewFilterValue] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
const fetchClients = useCallback(async (params: Record<string, unknown> = {}) => {
|
||||||
|
try {
|
||||||
|
const res = await getClients(params);
|
||||||
|
setClients(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('获取客户列表失败');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchClients(); }, [fetchClients]);
|
||||||
|
|
||||||
|
const handleSearchChange = (key: string, val: unknown) => {
|
||||||
|
setFilters(f => ({ ...f, [key]: val || undefined }));
|
||||||
|
fetchClients({ ...filters, [key]: val || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFilters({});
|
||||||
|
fetchClients();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutoFilterInfo = (client: ClientRecord, allowChuku: boolean, allowRuku: boolean) => {
|
||||||
|
const rules: { label: string; field?: string; value?: string; disabled: boolean }[] = [];
|
||||||
|
if (allowChuku) {
|
||||||
|
rules.push({ label: '出库规则', field: '销方全称', value: client.name, disabled: false });
|
||||||
|
} else {
|
||||||
|
rules.push({ label: '出库规则', disabled: true });
|
||||||
|
}
|
||||||
|
if (allowRuku) {
|
||||||
|
rules.push({ label: '入库规则', field: '供方全称', value: client.name, disabled: false });
|
||||||
|
} else {
|
||||||
|
rules.push({ label: '入库规则', disabled: true });
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPermission = useCallback(async (keyId: number, client: ClientRecord) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getPermission(keyId);
|
||||||
|
const p = res.data;
|
||||||
|
|
||||||
|
if (p) {
|
||||||
|
setPerm(p);
|
||||||
|
form.setFieldsValue({
|
||||||
|
allow_chuku: !!p.allow_chuku,
|
||||||
|
allow_ruku: !!p.allow_ruku,
|
||||||
|
data_mode: p.data_mode || 'filter',
|
||||||
|
filter_field: p.filter_field,
|
||||||
|
});
|
||||||
|
setFilterValues(p.filter_values || []);
|
||||||
|
} else {
|
||||||
|
setPerm(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
allow_chuku: false,
|
||||||
|
allow_ruku: false,
|
||||||
|
data_mode: 'filter',
|
||||||
|
filter_field: undefined,
|
||||||
|
});
|
||||||
|
setFilterValues([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('获取权限配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedKeyId) return;
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updatePermission(selectedKeyId, {
|
||||||
|
allow_chuku: values.allow_chuku ? 1 : 0,
|
||||||
|
allow_ruku: values.allow_ruku ? 1 : 0,
|
||||||
|
allow_full_data: values.data_mode === 'full' ? 1 : 0,
|
||||||
|
data_mode: values.data_mode,
|
||||||
|
filter_field: values.data_mode === 'full' ? null : values.filter_field,
|
||||||
|
filter_values: values.data_mode === 'full' ? null : filterValues,
|
||||||
|
});
|
||||||
|
message.success('权限配置已保存');
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFilterValue = () => {
|
||||||
|
const v = newFilterValue.trim();
|
||||||
|
if (v && !filterValues.includes(v)) {
|
||||||
|
setFilterValues([...filterValues, v]);
|
||||||
|
setNewFilterValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilterValue = (val: string) => {
|
||||||
|
setFilterValues(filterValues.filter(v => v !== val));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataMode = Form.useWatch('data_mode', form);
|
||||||
|
const allowChuku = Form.useWatch('allow_chuku', form);
|
||||||
|
const allowRuku = Form.useWatch('allow_ruku', form);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div style={{ position: 'relative', height: 'calc(100vh - 120px)' }}>
|
||||||
|
<Card
|
||||||
|
title="客户 / API Key"
|
||||||
|
style={{ width: 420, height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
placeholder="客户序号"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={filters.client_no as string}
|
||||||
|
onChange={(e) => handleSearchChange('client_no', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="客户名称"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filters.name as string}
|
||||||
|
onChange={(e) => handleSearchChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={filters.client_type as string}
|
||||||
|
onChange={(val) => handleSearchChange('client_type', val)}
|
||||||
|
options={[
|
||||||
|
{ label: '供应商', value: 'supplier' },
|
||||||
|
{ label: '分销商', value: 'distributor' },
|
||||||
|
{ label: '医院', value: 'hospital' },
|
||||||
|
{ label: '其他', value: 'other' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleReset} size="small">重置</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{clients.length === 0 ? <Empty description="暂无客户" /> : clients.map(client => (
|
||||||
|
<div key={client.id} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{client.name}</span>
|
||||||
|
<Tag color={TYPE_MAP[client.client_type]?.color}>
|
||||||
|
{TYPE_MAP[client.client_type]?.label}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{client.client_no && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>序号:{client.client_no}</div>
|
||||||
|
)}
|
||||||
|
{client.api_keys?.map(key => (
|
||||||
|
<div
|
||||||
|
key={key.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedKeyId(key.id);
|
||||||
|
setSelectedClient(client);
|
||||||
|
loadPermission(key.id, client);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px', cursor: 'pointer', borderRadius: 8, marginBottom: 6,
|
||||||
|
background: selectedKeyId === key.id ? '#e6f7ff' : '#fff',
|
||||||
|
border: selectedKeyId === key.id ? '1px solid #1890ff' : '1px solid #e8e8e8',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: 13, color: '#333' }}>{key.api_key}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
|
||||||
|
<Tag color={key.status === 1 ? 'green' : 'red'}>
|
||||||
|
{key.status === 1 ? '启用' : '禁用'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!client.api_keys || client.api_keys.length === 0) && (
|
||||||
|
<div style={{ color: '#bbb', fontSize: 12, padding: '8px 12px', border: '1px dashed #e8e8e8', borderRadius: 6 }}>
|
||||||
|
暂无 API Key,请先创建
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="权限配置" style={{ position: 'fixed', left: 460, right: 24, top: 96, bottom: 24, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{!selectedKeyId ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Empty description="请从左侧选择一个 API Key" />
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<div style={{ maxWidth: 600 }}>
|
||||||
|
<Form.Item label="接口权限">
|
||||||
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
|
<Form.Item name="allow_chuku" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox disabled={dataMode === 'full'}>销售数据查询接口</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="allow_ruku" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox disabled={dataMode === 'full'}>入库数据查询接口</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="数据过滤规则" name="data_mode" initialValue="filter">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="filter">按接口权限过滤</Radio>
|
||||||
|
{/* <Radio value="custom">自定义过滤规则</Radio> */}
|
||||||
|
<Radio value="full">允许全量数据(核心大户)</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{dataMode === 'filter' && selectedClient && (() => {
|
||||||
|
const autoRules = getAutoFilterInfo(selectedClient, allowChuku, allowRuku);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, background: '#f5f5f5', borderRadius: 8, marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#666', marginBottom: 12 }}>
|
||||||
|
根据接口权限自动生成过滤规则:
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{autoRules.map((rule, idx) => (
|
||||||
|
<div key={idx} style={{ display: 'flex', gap: 12, fontSize: 14 }}>
|
||||||
|
<span style={{ fontWeight: 600, color: '#1890ff' }}>{rule.label}:</span>
|
||||||
|
{rule.disabled ? (
|
||||||
|
<span style={{ color: '#999' }}>无{rule.label === '出库规则' ? '出库' : '入库'}查询权限</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>过滤字段:</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{rule.field}</span>
|
||||||
|
<span style={{ marginLeft: 16 }}>过滤值:</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{rule.value}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{dataMode === 'custom' && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="过滤字段" name="filter_field" rules={[{ required: true, message: '请选择过滤字段' }]}>
|
||||||
|
<Select
|
||||||
|
options={FILTER_FIELDS}
|
||||||
|
placeholder="请选择过滤字段"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="过滤值">
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入过滤值后按回车或点击添加"
|
||||||
|
value={newFilterValue}
|
||||||
|
onChange={e => setNewFilterValue(e.target.value)}
|
||||||
|
onPressEnter={addFilterValue}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={addFilterValue}>添加</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{filterValues.map(v => (
|
||||||
|
<Tag key={v} closable onClose={() => removeFilterValue(v)} color="blue">
|
||||||
|
{v}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{filterValues.length === 0 && <span style={{ color: '#999', fontSize: 13 }}>暂未添加过滤值</span>}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item style={{ marginTop: 24 }}>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave} size="large">
|
||||||
|
保存权限配置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/src/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Layout, Menu, Button, theme, Avatar, Dropdown, Spin, Modal, Form, Input, message } from 'antd';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { buildRoute, matchRoute } from '@/utils/route';
|
||||||
|
import { changePassword } from '@/services/api';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||||
|
const [passwordForm] = Form.useForm();
|
||||||
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token && !matchRoute(pathname, '/login')) {
|
||||||
|
router.push(buildRoute('/login'));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [router, pathname]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: buildRoute('/dashboard'), icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
|
{ key: buildRoute('/clients'), icon: <TeamOutlined />, label: '客户管理' },
|
||||||
|
{ key: buildRoute('/permissions'), icon: <SafetyOutlined />, label: '权限配置' },
|
||||||
|
{ key: buildRoute('/logs'), icon: <FileTextOutlined />, label: '调用日志' },
|
||||||
|
{ key: buildRoute('/client'), icon: <BookOutlined />, label: '客户端' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
if (key === buildRoute('/client')) {
|
||||||
|
window.open('/client.html', '_blank');
|
||||||
|
} else {
|
||||||
|
router.push(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
router.push(buildRoute('/login'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
|
||||||
|
setPasswordLoading(true);
|
||||||
|
try {
|
||||||
|
await changePassword(values.oldPassword, values.newPassword);
|
||||||
|
message.success('密码修改成功,请重新登录');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
router.push(buildRoute('/login'));
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail || '密码修改失败');
|
||||||
|
} finally {
|
||||||
|
setPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenu = {
|
||||||
|
items: [
|
||||||
|
{ key: 'changePassword', icon: <LockOutlined />, label: '修改密码', onClick: () => setShowChangePasswordModal(true) },
|
||||||
|
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
theme="dark"
|
||||||
|
style={{ position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', fontSize: collapsed ? 14 : 16, fontWeight: 600,
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
}}>
|
||||||
|
{collapsed ? '开放平台' : '进销存数据开放平台'}
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'all 0.2s' }}>
|
||||||
|
<Header style={{
|
||||||
|
padding: '0 24px', background: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
<Dropdown menu={userMenu}>
|
||||||
|
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Avatar icon={<UserOutlined />} size="small" />
|
||||||
|
<span>{localStorage.getItem('username') || '管理员'}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8, minHeight: 360 }}>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
<Modal
|
||||||
|
title="修改密码"
|
||||||
|
open={showChangePasswordModal}
|
||||||
|
onCancel={() => { setShowChangePasswordModal(false); passwordForm.resetFields(); }}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form form={passwordForm} onFinish={handleChangePassword} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="oldPassword"
|
||||||
|
label="旧密码"
|
||||||
|
rules={[{ required: true, message: '请输入旧密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="请输入旧密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="newPassword"
|
||||||
|
label="新密码"
|
||||||
|
rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '新密码长度不能少于6位' }]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirmPassword"
|
||||||
|
label="确认新密码"
|
||||||
|
dependencies={['newPassword']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请确认新密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('newPassword') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('两次输入的新密码不一致'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="请再次输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={passwordLoading} block>
|
||||||
|
确认修改
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { buildRoute } from '@/utils/route';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://yisheng.cpolar.cn';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截:自动附加 JWT Token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应拦截:401 跳转登录
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401 && typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
const ROUTE_PRE = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
|
||||||
|
const loginPath = ROUTE_PRE ? `/${ROUTE_PRE}/login` : '/login';
|
||||||
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
// ============ Auth ============
|
||||||
|
export const login = (username: string, password: string) =>
|
||||||
|
api.post('/api/admin/auth/login', { username, password });
|
||||||
|
export const changePassword = (oldPassword: string, newPassword: string) =>
|
||||||
|
api.put('/api/admin/auth/password', { old_password: oldPassword, new_password: newPassword });
|
||||||
|
|
||||||
|
// ============ Dashboard ============
|
||||||
|
export const getDashboardStats = () => api.get('/api/admin/dashboard/stats');
|
||||||
|
export const getDashboardTrend = () => api.get('/api/admin/dashboard/trend');
|
||||||
|
|
||||||
|
// ============ Clients ============
|
||||||
|
export const getClients = (params?: Record<string, unknown>) =>
|
||||||
|
api.get('/api/admin/clients', { params });
|
||||||
|
export const createClient = (data: Record<string, unknown>) =>
|
||||||
|
api.post('/api/admin/clients', data);
|
||||||
|
export const updateClient = (id: number, data: Record<string, unknown>) =>
|
||||||
|
api.put(`/api/admin/clients/update/${id}`, data);
|
||||||
|
export const toggleClient = (id: number) =>
|
||||||
|
api.patch(`/api/admin/clients/toggle/${id}`);
|
||||||
|
export const generateApiKey = (clientId: number) =>
|
||||||
|
api.post(`/api/admin/clients/api-keys/create/${clientId}`);
|
||||||
|
export const getClientApiKeys = (clientId: number) =>
|
||||||
|
api.get(`/api/admin/clients/api-keys/${clientId}`);
|
||||||
|
export const deleteApiKey = (keyId: number) =>
|
||||||
|
api.delete(`/api/admin/api-keys/delete/${keyId}`);
|
||||||
|
|
||||||
|
// ============ Permissions ============
|
||||||
|
export const getPermission = (apiKeyId: number) =>
|
||||||
|
api.get(`/api/admin/permissions/${apiKeyId}`);
|
||||||
|
export const updatePermission = (apiKeyId: number, data: Record<string, unknown>) =>
|
||||||
|
api.put(`/api/admin/permissions/${apiKeyId}`, data);
|
||||||
|
|
||||||
|
// ============ Logs ============
|
||||||
|
export const getLogs = (params?: Record<string, unknown>) =>
|
||||||
|
api.get('/api/admin/logs', { params });
|
||||||
|
export const exportLogs = (params?: Record<string, unknown>) =>
|
||||||
|
api.get('/api/admin/logs/export', { params, responseType: 'blob' });
|
||||||
25
frontend/src/utils/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const ROUTE_PRE = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoute(path: string): string {
|
||||||
|
return normalizePath(path.startsWith('/') ? path : `/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchRoute(pathname: string, route: string): boolean {
|
||||||
|
const normalizedPathname = normalizePath(pathname);
|
||||||
|
const normalizedRoute = normalizePath(route.startsWith('/') ? route : `/${route}`);
|
||||||
|
|
||||||
|
if (!ROUTE_PRE) {
|
||||||
|
return normalizedPathname === normalizedRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = `/${ROUTE_PRE}`;
|
||||||
|
const strippedPathname = normalizedPathname.startsWith(basePath)
|
||||||
|
? normalizedPathname.slice(basePath.length)
|
||||||
|
: normalizedPathname;
|
||||||
|
|
||||||
|
return strippedPathname === normalizedRoute;
|
||||||
|
}
|
||||||
46
frontend/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts",
|
||||||
|
"admin/types/**/*.ts",
|
||||||
|
"admin/dev/types/**/*.ts",
|
||||||
|
"out/types/**/*.ts",
|
||||||
|
"out/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
thinkphp/.example.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
APP_DEBUG = true
|
||||||
5
thinkphp/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/.idea
|
||||||
|
/.vscode
|
||||||
|
/vendor
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
42
thinkphp/.travis.yml
Normal 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
@@ -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
@@ -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 + '×tamp=' + 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 . '×tamp=' . 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
|
||||||
35
thinkphp/_postman_helper.php
Normal 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}×tamp={$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
@@ -0,0 +1 @@
|
|||||||
|
deny from all
|
||||||
22
thinkphp/app/AppService.php
Normal 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()
|
||||||
|
{
|
||||||
|
// 服务启动
|
||||||
|
}
|
||||||
|
}
|
||||||
94
thinkphp/app/BaseController.php
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
58
thinkphp/app/ExceptionHandle.php
Normal 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
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
namespace app;
|
||||||
|
|
||||||
|
// 应用请求对象类
|
||||||
|
class Request extends \think\Request
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
2
thinkphp/app/common.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// 应用公共文件
|
||||||
17
thinkphp/app/controller/Index.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
thinkphp/app/controller/api/v1/Chuku.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
thinkphp/app/controller/api/v1/Ruku.php
Normal 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
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
// 事件定义文件
|
||||||
|
return [
|
||||||
|
'bind' => [
|
||||||
|
],
|
||||||
|
|
||||||
|
'listen' => [
|
||||||
|
'AppInit' => [],
|
||||||
|
'HttpRun' => [],
|
||||||
|
'HttpEnd' => [],
|
||||||
|
'LogLevel' => [],
|
||||||
|
'LogWrite' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'subscribe' => [
|
||||||
|
],
|
||||||
|
];
|
||||||
12
thinkphp/app/middleware.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
// 全局中间件定义文件
|
||||||
|
return [
|
||||||
|
// 全局请求缓存
|
||||||
|
// \think\middleware\CheckRequestCache::class,
|
||||||
|
// 多语言加载
|
||||||
|
// \think\middleware\LoadLangPack::class,
|
||||||
|
// Session初始化
|
||||||
|
// \think\middleware\SessionInit::class,
|
||||||
|
// 跨域处理
|
||||||
|
\app\middleware\CrossDomain::class,
|
||||||
|
];
|
||||||
55
thinkphp/app/middleware/AdminAuth.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
thinkphp/app/middleware/Auth.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
thinkphp/app/middleware/CrossDomain.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
thinkphp/app/middleware/RateLimit.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
thinkphp/app/model/AdminUser.php
Normal 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 => '查看员',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
thinkphp/app/model/ApiKey.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
thinkphp/app/model/ApiLog.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
thinkphp/app/model/Client.php
Normal 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 => '其他',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
55
thinkphp/app/model/Permission.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
thinkphp/app/provider.php
Normal 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
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use app\AppService;
|
||||||
|
|
||||||
|
// 系统服务定义文件
|
||||||
|
// 服务在完成全局初始化之后执行
|
||||||
|
return [
|
||||||
|
AppService::class,
|
||||||
|
];
|
||||||
169
thinkphp/app/service/DataIsolation.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
428
thinkphp/app/service/MssqlQuery.php
Normal 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}
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
107
thinkphp/app/service/Signature.php
Normal 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 . '×tamp=' . $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 . '×tamp=' . $timestamp;
|
||||||
|
|
||||||
|
return hash_hmac('sha256', $fullStr, $apiSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
thinkphp/composer.json
Normal 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
32
thinkphp/config/app.php
Normal 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
@@ -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' => [],
|
||||||
|
],
|
||||||
|
// 更多的缓存连接
|
||||||
|
],
|
||||||
|
];
|
||||||
9
thinkphp/config/console.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 控制台配置
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
return [
|
||||||
|
// 指令定义
|
||||||
|
'commands' => [
|
||||||
|
],
|
||||||
|
];
|
||||||
20
thinkphp/config/cookie.php
Normal 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' => '',
|
||||||
|
];
|
||||||
80
thinkphp/config/database.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
24
thinkphp/config/filesystem.php
Normal 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
@@ -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
@@ -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,
|
||||||
|
],
|
||||||
|
// 其它日志通道配置
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
12
thinkphp/config/middleware.php
Normal 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
@@ -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',
|
||||||
|
];
|
||||||
19
thinkphp/config/session.php
Normal 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
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Trace设置 开启调试模式后有效
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
return [
|
||||||
|
// 内置Html和Console两种方式 支持扩展
|
||||||
|
'type' => 'Html',
|
||||||
|
// 读取的日志通道名
|
||||||
|
'channel' => '',
|
||||||
|
];
|
||||||
25
thinkphp/config/view.php
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
274
thinkphp/jc_open_platform.sql
Normal file
8
thinkphp/nginx.htaccess
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
location ~* (runtime|application)/{
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
if (!-e $request_filename){
|
||||||
|
rewrite ^(.*)$ /index.php?s=$1 last; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
thinkphp/public/.htaccess
Normal 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>
|
||||||
238
thinkphp/public/api_test.html
Normal 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 + '×tamp=' + 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
|
After Width: | Height: | Size: 1.1 KiB |
24
thinkphp/public/index.php
Normal 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);
|
||||||
2
thinkphp/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
19
thinkphp/public/router.php
Normal 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
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
73
thinkphp/route/app.php
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
81
thinkphp/test_mssql.php
Normal 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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
如果不使用模板,可以删除该目录
|
||||||
399
thinkphp/采坑文档.md
Normal 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.1:SQL 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` 解析为参数编号 65536(0xFFFF),导致参数编号溢出。
|
||||||
|
|
||||||
|
**尝试方案 1(失败)**:改用 `:` 前缀
|
||||||
|
```php
|
||||||
|
// SQL 模板
|
||||||
|
WHERE [登记时间] >= :start_date
|
||||||
|
|
||||||
|
// 参数绑定
|
||||||
|
$params = [':start_date' => $startDate];
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**:仍然报错 `HY093: Invalid parameter number`,因为 SQL Server 的 PDO 驱动在 CTE(WITH 子句)中对 `:` 前缀的参数支持不佳。
|
||||||
|
|
||||||
|
#### 子问题 5.2:ThinkPHP 自动添加 `:` 前缀
|
||||||
|
|
||||||
|
**根本原因**: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)
|
||||||