diff --git a/app/common/controller/PublicApiController.php b/app/common/controller/PublicApiController.php new file mode 100644 index 0000000..e4222d2 --- /dev/null +++ b/app/common/controller/PublicApiController.php @@ -0,0 +1,105 @@ +input('page', 1)); + $pageSize = (int)($request->input('page_size', 20)); + $piNo = $request->input('pi_no', ''); + + if ($page < 1) $page = 1; + if ($pageSize < 1 || $pageSize > 200) $pageSize = 20; + + $query = StockOrders::where('approval_status', 2) + ->whereNotNull('pi_no') + ->where('pi_no', '<>', ''); + + if ($piNo !== '') { + $query->where('pi_no', 'like', '%' . addcslashes($piNo, '%_') . '%'); + } + + $query->with(['items' => function ($q) { + $q->select('id', 'order_id', 'vin', 'model_snapshot'); + }]); + + $paginator = $query->orderBy('id', 'desc') + ->paginate($pageSize, ['id', 'pi_no'], 'page', $page); + + $data = $paginator->getCollection()->map(function ($order) { + return [ + 'pi_no' => $order->pi_no, + 'vins' => $order->items->pluck('vin')->filter()->values()->toArray(), + 'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(), + ]; + })->toArray(); + + return $this->success([ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'data' => $data, + ]); + } catch (\Throwable $e) { + return $this->error('查询失败,请稍后重试'); + } + } + + /** + * VIN 号列表(含车型名称) + * GET /common/public/vin/list + */ + public function vinList(Request $request) + { + try { + $page = (int)($request->input('page', 1)); + $pageSize = (int)($request->input('page_size', 20)); + $vin = $request->input('vin', ''); + + if ($page < 1) $page = 1; + if ($pageSize < 1 || $pageSize > 200) $pageSize = 20; + + $query = StockOrderItems::whereHas('order', function ($q) { + $q->where('approval_status', 2); + })->whereNotNull('vin') + ->where('vin', '<>', ''); + + if ($vin !== '') { + $query->where('vin', 'like', '%' . addcslashes($vin, '%_') . '%'); + } + + $paginator = $query->orderBy('id', 'desc') + ->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page); + + $data = $paginator->getCollection()->map(function ($item) { + return [ + 'vin' => $item->vin, + 'model_name' => $item->model_snapshot, + ]; + })->toArray(); + + return $this->success([ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'data' => $data, + ]); + } catch (\Throwable $e) { + return $this->error('查询失败,请稍后重试'); + } + } +} diff --git a/app/middleware/ApiKeyAuth.php b/app/middleware/ApiKeyAuth.php new file mode 100644 index 0000000..d1068ff --- /dev/null +++ b/app/middleware/ApiKeyAuth.php @@ -0,0 +1,28 @@ +header('Fdesk-Api-Key', ''); + $validKey = getenv('PUBLIC_API_KEY'); + + if ($validKey === false || $apiKey === '' || !hash_equals($validKey, $apiKey)) { + $body = [ + 'status' => 'fail', + 'code' => 401, + 'message' => '无效的 API Key', + 'data' => [], + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']); + } + + return $handler($request); + } +} diff --git a/config/route.php b/config/route.php index 9132489..02c32da 100644 --- a/config/route.php +++ b/config/route.php @@ -637,4 +637,10 @@ Route::group('/crm', function () { Route::any('/update', [app\common\controller\VehModificationController::class, 'update']); Route::any('/destroy', [app\common\controller\VehModificationController::class, 'destroy']); }); -}); \ No newline at end of file +}); + +// 公共 API(API Key 鉴权;通过 noNeedLogin/noNeedHeaderData 跳过 JWT 和 BasicData) +Route::group('/common/public', function () { + Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']); + Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']); +})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]); \ No newline at end of file diff --git a/docs/public-stock-api.md b/docs/public-stock-api.md new file mode 100644 index 0000000..94529b1 --- /dev/null +++ b/docs/public-stock-api.md @@ -0,0 +1,174 @@ +# 备货单公共 API 对接文档 + +## 基础信息 + +| 项 | 值 | +|---|---| +| Base URL | `https://your-domain.com` | +| 鉴权方式 | 请求头 `Fdesk-Api-Key` | +| 响应格式 | JSON | + +## 鉴权 + +所有请求必须携带 API Key 请求头: + +``` +Fdesk-Api-Key: +``` + +缺少或无效时返回: + +```json +{ + "status": "fail", + "code": 401, + "message": "无效的 API Key", + "data": [] +} +``` + +## 通用响应结构 + +```json +{ + "status": "success | fail", + "code": 200, + "message": "描述信息", + "data": {} +} +``` + +| code | 含义 | +|------|------| +| 200 | 成功 | +| 400 | 查询失败 | +| 401 | API Key 无效或缺失 | + +--- + +## 接口列表 + +### 1. PI 号列表 + +查询已审批通过的备货单 PI 号,含关联的车架号和车型。 + +**请求** + +``` +GET /common/public/pi/list +``` + +**参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认 1 | +| page_size | int | 否 | 每页条数,默认 20,最大 200 | +| pi_no | string | 否 | PI 号筛选,模糊匹配 | + +**响应示例** + +```json +{ + "status": "success", + "code": 200, + "message": "请求成功", + "data": { + "total": 150, + "per_page": 20, + "current_page": 1, + "data": [ + { + "pi_no": "PI20250601001", + "vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"], + "model_names": ["BYD Song Plus DM-i", "BYD Seal"] + } + ] + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| pi_no | string | 备货单 PI 号 | +| vins | string[] | 该 PI 下所有车架号 | +| model_names | string[] | 该 PI 下所有车型名称(与 vins 一一对应) | + +--- + +### 2. VIN 号列表 + +查询已审批通过的备货单车架号及对应车型。 + +**请求** + +``` +GET /common/public/vin/list +``` + +**参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认 1 | +| page_size | int | 否 | 每页条数,默认 20,最大 200 | +| vin | string | 否 | 车架号筛选,模糊匹配 | + +**响应示例** + +```json +{ + "status": "success", + "code": 200, + "message": "请求成功", + "data": { + "total": 500, + "per_page": 20, + "current_page": 1, + "data": [ + { + "vin": "LSVAU2A39N2123456", + "model_name": "BYD Song Plus DM-i" + } + ] + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| vin | string | 车架号 | +| model_name | string | 车型名称 | + +--- + +## 调用示例 + +```bash +# 查询 PI 列表(第一页,每页10条) +curl -H "Fdesk-Api-Key: your-api-key" \ + "https://your-domain.com/common/public/pi/list?page=1&page_size=10" + +# 按 PI 号搜索 +curl -H "Fdesk-Api-Key: your-api-key" \ + "https://your-domain.com/common/public/pi/list?pi_no=PI202506" + +# 查询 VIN 列表(第一页,每页50条) +curl -H "Fdesk-Api-Key: your-api-key" \ + "https://your-domain.com/common/public/vin/list?page=1&page_size=50" + +# 按 VIN 号搜索 +curl -H "Fdesk-Api-Key: your-api-key" \ + "https://your-domain.com/common/public/vin/list?vin=LSVAU2A3" +``` + +## 注意事项 + +- 两个接口仅返回审批状态为"已通过"(approval_status=2)的备货单数据 +- 已删除的备货单不会出现在查询结果中 +- 接口为只读,不支持新增、修改、删除操作 +- `pi_no` 和 `vin` 筛选参数支持模糊匹配(包含即可) diff --git a/docs/superpowers/plans/2026-06-02-public-stock-api.md b/docs/superpowers/plans/2026-06-02-public-stock-api.md new file mode 100644 index 0000000..e5d03f7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-public-stock-api.md @@ -0,0 +1,290 @@ +# Public Stock Order API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two read-only public APIs (PI list, VIN list) for internal systems to query approved stock orders, authenticated via API Key. + +**Architecture:** New `PublicApiController` in `app/common/controller/` with a dedicated `ApiKeyAuth` middleware. Routes registered in a standalone route group that bypasses JWT and BasicData middleware. Pagination via Eloquent's `paginate()`. + +**Tech Stack:** PHP 8.1+, Webman framework, Eloquent ORM, illuminate/pagination + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `app/middleware/ApiKeyAuth.php` | Validate `Fdesk-Api-Key` header against env config | +| Create | `app/common/controller/PublicApiController.php` | Two endpoints: PI list, VIN list | +| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth middleware | + +--- + +### Task 1: Create ApiKeyAuth Middleware + +**Files:** +- Create: `app/middleware/ApiKeyAuth.php` + +- [ ] **Step 1: Create the middleware file** + +```php +header('Fdesk-Api-Key'); + $validKey = getenv('PUBLIC_API_KEY'); + + if (!$apiKey || !$validKey || $apiKey !== $validKey) { + $body = [ + 'status' => 'fail', + 'code' => 401, + 'message' => '无效的 API Key', + 'data' => [], + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']); + } + + return $handler($request); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/middleware/ApiKeyAuth.php +git commit -m "feat: add ApiKeyAuth middleware for public API endpoints" +``` + +--- + +### Task 2: Create PublicApiController + +**Files:** +- Create: `app/common/controller/PublicApiController.php` + +- [ ] **Step 1: Create the controller file** + +```php +input('page', 1)); + $pageSize = (int)($request->input('page_size', 20)); + $piNo = $request->input('pi_no', ''); + + if ($page < 1) $page = 1; + if ($pageSize < 1 || $pageSize > 200) $pageSize = 20; + + $query = StockOrders::where('approval_status', 2) + ->whereNotNull('pi_no') + ->where('pi_no', '<>', ''); + + if ($piNo !== '') { + $query->where('pi_no', 'like', "%{$piNo}%"); + } + + $query->with(['items' => function ($q) { + $q->select('id', 'order_id', 'vin', 'model_snapshot'); + }]); + + $paginator = $query->orderBy('id', 'desc') + ->paginate($pageSize, ['id', 'pi_no'], 'page', $page); + + $data = $paginator->getCollection()->map(function ($order) { + return [ + 'pi_no' => $order->pi_no, + 'vins' => $order->items->pluck('vin')->filter()->values()->toArray(), + 'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(), + ]; + })->toArray(); + + return $this->success([ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'data' => $data, + ]); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * VIN 号列表(含车型名称) + * GET /common/public/vin/list + */ + public function vinList(Request $request) + { + try { + $page = (int)($request->input('page', 1)); + $pageSize = (int)($request->input('page_size', 20)); + $vin = $request->input('vin', ''); + + if ($page < 1) $page = 1; + if ($pageSize < 1 || $pageSize > 200) $pageSize = 20; + + $query = StockOrderItems::whereHas('order', function ($q) { + $q->where('approval_status', 2); + })->whereNotNull('vin') + ->where('vin', '<>', ''); + + if ($vin !== '') { + $query->where('vin', 'like', "%{$vin}%"); + } + + $paginator = $query->orderBy('id', 'desc') + ->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page); + + $data = $paginator->getCollection()->map(function ($item) { + return [ + 'vin' => $item->vin, + 'model_name' => $item->model_snapshot, + ]; + })->toArray(); + + return $this->success([ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'data' => $data, + ]); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/common/controller/PublicApiController.php +git commit -m "feat: add PublicApiController with PI and VIN list endpoints" +``` + +--- + +### Task 3: Register Routes + +**Files:** +- Modify: `config/route.php` + +- [ ] **Step 1: Add the route group** + +在 `config/route.php` 文件末尾(最后一个 `});` 之后)追加: + +```php +// 公共 API(API Key 鉴权,不走 JWT / BasicData) +Route::group('/common/public', function () { + Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']); + Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']); +})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]); +``` + +This route group only uses `AccessControl` (CORS) + `ApiKeyAuth`. It explicitly bypasses `BasicData` and `AuthCheck`. + +- [ ] **Step 2: Commit** + +```bash +git add config/route.php +git commit -m "feat: register public API routes with ApiKeyAuth middleware" +``` + +--- + +### Task 4: Configure API Key in Environment + +**Files:** +- Modify: `.env` (or equivalent environment config) + +- [ ] **Step 1: Add the API key to the environment config** + +Add to the `.env` file (or equivalent environment variable source): + +``` +PUBLIC_API_KEY=your-secret-api-key-here +``` + +Generate a real key, e.g.: + +```bash +php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;" +``` + +Use the generated value as the key. + +- [ ] **Step 2: Verify the middleware reads it correctly** + +The middleware uses `getenv('PUBLIC_API_KEY')`. Confirm this is consistent with how other env vars are read in the project (e.g., `getenv('DB_HOST')` in `config/database.php`). + +--- + +### Task 5: Manual Verification + +- [ ] **Step 1: Test PI list endpoint (no auth)** + +```bash +curl -s http://localhost:8787/common/public/pi/list | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);' +``` + +Expected: `{"status":"fail","code":401,"message":"无效的 API Key"}` + +- [ ] **Step 2: Test PI list endpoint (with auth)** + +```bash +curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);' +``` + +Expected: JSON with `status: success`, `data` containing `total`, `per_page`, `current_page`, and array of `{pi_no, vins, model_names}`. + +- [ ] **Step 3: Test VIN list endpoint (with auth)** + +```bash +curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/vin/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);' +``` + +Expected: JSON with `status: success`, `data` containing array of `{vin, model_name}`. + +- [ ] **Step 4: Test with pi_no filter** + +```bash +curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?pi_no=PI2025" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);' +``` + +Expected: filtered results or empty data array. + +- [ ] **Step 5: Commit final verification** + +No code changes expected. If fixes were needed during testing, commit them: + +```bash +git add -A +git commit -m "fix: adjustments from manual verification of public stock API" +``` diff --git a/docs/superpowers/specs/2026-06-02-public-stock-api-design.md b/docs/superpowers/specs/2026-06-02-public-stock-api-design.md new file mode 100644 index 0000000..5498f06 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-public-stock-api-design.md @@ -0,0 +1,107 @@ +# Public Stock Order API Design + +## Goal + +Provide two read-only public APIs for internal systems to query approved stock order PI numbers and VIN numbers, with API Key authentication. + +## Data Model + +- `stock_orders.approval_status = 2` means approved +- `stock_orders.pi_no` — PI number (string) +- `stock_order_items.vin` — VIN/chassis number (string) +- `stock_order_items.model_snapshot` — car model name (plain string) +- `stock_order_items.order_id` → `stock_orders.id` + +## Authentication + +New middleware `ApiKeyAuth`: + +- Read `Fdesk-Api-Key` header +- Compare against key stored in `.env` (`API_KEY`) or `config/app.php` +- Reject with 401 on mismatch or missing key +- Bypass both `AuthCheck` (JWT) and `BasicData` middleware + +## Endpoints + +### A. PI Number List + +``` +GET /common/public/pi/list +Headers: Fdesk-Api-Key: +Query: page, page_size, pi_no (optional filter) +``` + +Response: +```json +{ + "status": "success", + "code": 200, + "data": { + "total": 150, + "per_page": 20, + "current_page": 1, + "data": [ + { + "pi_no": "PI20250601001", + "vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"], + "model_names": ["Model Y", "Model 3"] + } + ] + } +} +``` + +Logic: +1. Query `stock_orders` where `approval_status = 2` and `pi_no IS NOT NULL` and `pi_no != ''` +2. Optional filter by `pi_no` (LIKE match) +3. Paginate +4. For each order, eager-load `items` → collect `vin` and `model_snapshot` +5. Return aggregated result + +### B. VIN Number List + +``` +GET /common/public/vin/list +Headers: Fdesk-Api-Key: +Query: page, page_size, vin (optional filter) +``` + +Response: +```json +{ + "status": "success", + "code": 200, + "data": { + "total": 500, + "per_page": 20, + "current_page": 1, + "data": [ + { + "vin": "LSVAU2A39N2123456", + "model_name": "Model Y" + } + ] + } +} +``` + +Logic: +1. Query `stock_order_items` via `stock_orders` where `approval_status = 2` +2. Optional filter by `vin` (LIKE match) +3. Paginate +4. Return `vin` + `model_snapshot` per item + +## File Changes + +| Action | File | Description | +|--------|------|-------------| +| New | `app/middleware/ApiKeyAuth.php` | API Key auth middleware | +| New | `app/common/controller/PublicApiController.php` | Two endpoints | +| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth | + +## Constraints + +- Read-only, no write operations +- Only `approval_status = 2` records +- Pagination required (default page_size = 20) +- No JWT / BasicData middleware for these routes