From 02d5686c7b84ef4cadc5fcd07809ad0105a74f2c Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Tue, 24 Mar 2026 10:36:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=B5=84=E6=96=99=E6=9B=B4=E6=96=B0=E4=B8=8E?= =?UTF-8?q?token=E5=88=B7=E6=96=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 userService.js,包含用户认证、资料更新、token 刷新等功能 - 新增 wechatService.js,处理微信API交互,获取openid和手机号 - 新增 appError.js,封装应用错误处理 - 新增 logger.js,提供日志记录功能 - 新增 response.js,统一成功响应格式 - 新增 sanitize.js,提供输入数据清洗功能 - 更新 OpenAPI 文档,描述新增接口及请求响应格式 - 更新 PocketBase 数据库结构,调整用户表字段及索引策略 - 增强错误处理机制,确保错误信息可观测性 - 更新变更记录文档,详细记录本次变更内容 --- back-end/.env | 10 +- back-end/dist/.env | 23 -- back-end/dist/build-info.json | 3 - back-end/dist/eslint.config.js | 13 - back-end/dist/package.json | 32 -- back-end/dist/spec/openapi.yaml | 382 ------------------ back-end/dist/src/config/env.js | 28 -- .../dist/src/controllers/wechatController.js | 36 -- back-end/dist/src/index.js | 39 -- .../dist/src/middlewares/duplicateGuard.js | 22 - back-end/dist/src/middlewares/errorHandler.js | 19 - back-end/dist/src/middlewares/jwtAuth.js | 20 - .../dist/src/middlewares/requestLogger.js | 17 - .../dist/src/middlewares/requireJsonBody.js | 15 - .../src/middlewares/requireWechatOpenid.js | 12 - .../src/middlewares/validateWechatAuth.js | 35 -- .../dist/src/middlewares/wechatHeadersAuth.js | 31 -- back-end/dist/src/routes/apiRoutes.js | 30 -- back-end/dist/src/routes/wechatRoutes.js | 14 - back-end/dist/src/services/jwtService.js | 12 - .../dist/src/services/pocketbaseService.js | 90 ----- back-end/dist/src/services/userService.js | 208 ---------- back-end/dist/src/services/wechatService.js | 117 ------ back-end/dist/src/utils/appError.js | 10 - back-end/dist/src/utils/asyncHandler.js | 5 - back-end/dist/src/utils/buildInfo.js | 30 -- back-end/dist/src/utils/logger.js | 27 -- back-end/dist/src/utils/response.js | 20 - back-end/dist/src/utils/sanitize.js | 17 - back-end/package.json | 4 +- back-end/spec/openapi.yaml | 329 +-------------- back-end/src/config/env.js | 6 +- back-end/src/controllers/wechatController.js | 36 -- back-end/src/index.js | 1 - back-end/src/middlewares/duplicateGuard.js | 22 - back-end/src/middlewares/jwtAuth.js | 20 - back-end/src/middlewares/requireJsonBody.js | 15 - .../src/middlewares/requireWechatOpenid.js | 12 - .../src/middlewares/validateWechatAuth.js | 35 -- back-end/src/middlewares/wechatHeadersAuth.js | 31 -- back-end/src/routes/apiRoutes.js | 15 - back-end/src/routes/wechatRoutes.js | 14 - back-end/src/services/jwtService.js | 12 - back-end/src/services/userService.js | 208 ---------- back-end/src/services/wechatService.js | 117 ------ back-end/src/utils/appError.js | 10 - back-end/src/utils/asyncHandler.js | 5 - back-end/src/utils/buildInfo.js | 30 -- back-end/src/utils/sanitize.js | 17 - back-end/tests/integration/wechat.test.js | 199 --------- back-end/tests/userService.test.js | 181 --------- docs/ARCHIVE.md | 114 ++++++ docs/api.md | 17 +- docs/newpb.md | 95 +++++ pocket-base/README.md | 139 +++++++ pocket-base/bai-api-main.pb.js | 24 ++ .../bai_api_routes/system/health.js | 8 + .../bai_api_routes/system/hello.js | 11 + .../bai_api_routes/wechat/login.js | 33 ++ .../bai_api_routes/wechat/profile.js | 14 + .../bai_api_routes/wechat/refresh-token.js | 10 + .../bai_api_shared/config/.env | 12 + .../bai_api_shared/config/env.js | 29 ++ .../bai_api_shared/config/runtime.example.js | 11 + .../bai_api_shared/config/runtime.js | 11 + .../middlewares/requestGuards.js | 98 +++++ .../bai_api_shared/services/userService.js | 264 ++++++++++++ .../bai_api_shared/services/wechatService.js | 92 +++++ .../bai_api_shared/utils/appError.js | 7 + .../bai_api_shared/utils/logger.js | 14 + .../bai_api_shared/utils/response.js | 11 + .../bai_api_shared/utils/sanitize.js | 21 + ...6-03-23-pocketbase-hooks-auth-hardening.md | 175 ++++++++ pocket-base/spec/openapi.yaml | 253 ++++++++++++ script/package.json | 3 +- script/pocketbase.newpb.js | 261 ++++++++++++ 76 files changed, 1722 insertions(+), 2641 deletions(-) delete mode 100644 back-end/dist/.env delete mode 100644 back-end/dist/build-info.json delete mode 100644 back-end/dist/eslint.config.js delete mode 100644 back-end/dist/package.json delete mode 100644 back-end/dist/spec/openapi.yaml delete mode 100644 back-end/dist/src/config/env.js delete mode 100644 back-end/dist/src/controllers/wechatController.js delete mode 100644 back-end/dist/src/index.js delete mode 100644 back-end/dist/src/middlewares/duplicateGuard.js delete mode 100644 back-end/dist/src/middlewares/errorHandler.js delete mode 100644 back-end/dist/src/middlewares/jwtAuth.js delete mode 100644 back-end/dist/src/middlewares/requestLogger.js delete mode 100644 back-end/dist/src/middlewares/requireJsonBody.js delete mode 100644 back-end/dist/src/middlewares/requireWechatOpenid.js delete mode 100644 back-end/dist/src/middlewares/validateWechatAuth.js delete mode 100644 back-end/dist/src/middlewares/wechatHeadersAuth.js delete mode 100644 back-end/dist/src/routes/apiRoutes.js delete mode 100644 back-end/dist/src/routes/wechatRoutes.js delete mode 100644 back-end/dist/src/services/jwtService.js delete mode 100644 back-end/dist/src/services/pocketbaseService.js delete mode 100644 back-end/dist/src/services/userService.js delete mode 100644 back-end/dist/src/services/wechatService.js delete mode 100644 back-end/dist/src/utils/appError.js delete mode 100644 back-end/dist/src/utils/asyncHandler.js delete mode 100644 back-end/dist/src/utils/buildInfo.js delete mode 100644 back-end/dist/src/utils/logger.js delete mode 100644 back-end/dist/src/utils/response.js delete mode 100644 back-end/dist/src/utils/sanitize.js delete mode 100644 back-end/src/controllers/wechatController.js delete mode 100644 back-end/src/middlewares/duplicateGuard.js delete mode 100644 back-end/src/middlewares/jwtAuth.js delete mode 100644 back-end/src/middlewares/requireJsonBody.js delete mode 100644 back-end/src/middlewares/requireWechatOpenid.js delete mode 100644 back-end/src/middlewares/validateWechatAuth.js delete mode 100644 back-end/src/middlewares/wechatHeadersAuth.js delete mode 100644 back-end/src/routes/wechatRoutes.js delete mode 100644 back-end/src/services/jwtService.js delete mode 100644 back-end/src/services/userService.js delete mode 100644 back-end/src/services/wechatService.js delete mode 100644 back-end/src/utils/appError.js delete mode 100644 back-end/src/utils/asyncHandler.js delete mode 100644 back-end/src/utils/buildInfo.js delete mode 100644 back-end/src/utils/sanitize.js delete mode 100644 back-end/tests/userService.test.js create mode 100644 docs/newpb.md create mode 100644 pocket-base/README.md create mode 100644 pocket-base/bai-api-main.pb.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/services/wechatService.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/utils/appError.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js create mode 100644 pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md create mode 100644 pocket-base/spec/openapi.yaml create mode 100644 script/pocketbase.newpb.js diff --git a/back-end/.env b/back-end/.env index 8608def..e6618af 100644 --- a/back-end/.env +++ b/back-end/.env @@ -12,12 +12,4 @@ APP_BASE_URL=https://bai-api.blv-oa.com # Database Configuration (Pocketbase) POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/ -POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo - -# WeChat Configuration -WECHAT_APPID=wx3bd7a7b19679da7a -WECHAT_SECRET=57e40438c2a9151257b1927674db10e1 - -# JWT Configuration -JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 -JWT_EXPIRES_IN=2h +POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDMxNTc3OSwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.PJqbHcHQ57WLYKQdycA-a96EwI5IFuKM1Mr1o-CNw_g diff --git a/back-end/dist/.env b/back-end/dist/.env deleted file mode 100644 index 8608def..0000000 --- a/back-end/dist/.env +++ /dev/null @@ -1,23 +0,0 @@ -# Server Configuration -PORT=3002 - -# Environment -NODE_ENV=development - -# API Configuration -API_PREFIX=/api -APP_PROTOCOL=https -APP_DOMAIN=bai-api.blv-oa.com -APP_BASE_URL=https://bai-api.blv-oa.com - -# Database Configuration (Pocketbase) -POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/ -POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo - -# WeChat Configuration -WECHAT_APPID=wx3bd7a7b19679da7a -WECHAT_SECRET=57e40438c2a9151257b1927674db10e1 - -# JWT Configuration -JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 -JWT_EXPIRES_IN=2h diff --git a/back-end/dist/build-info.json b/back-end/dist/build-info.json deleted file mode 100644 index a5d6b0b..0000000 --- a/back-end/dist/build-info.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "buildTime": "2026-03-20T14:36:26.964Z" -} \ No newline at end of file diff --git a/back-end/dist/eslint.config.js b/back-end/dist/eslint.config.js deleted file mode 100644 index 38d080f..0000000 --- a/back-end/dist/eslint.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = [ - { - files: ['src/**/*.js', 'tests/**/*.js'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'commonjs', - }, - rules: { - semi: ['error', 'never'], - 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - }, - }, -] diff --git a/back-end/dist/package.json b/back-end/dist/package.json deleted file mode 100644 index 2fd1e7e..0000000 --- a/back-end/dist/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "web-bai-manage-api-server", - "version": "1.0.0", - "description": "Backend API server for BAI Management System", - "main": "dist/src/index.js", - "scripts": { - "dev": "node src/index.js", - "build": "node scripts/build.js", - "start": "node dist/src/index.js", - "prestart": "node -e \"require('fs').accessSync('dist/src/index.js')\"", - "test": "node --test tests/**/*.test.js", - "lint": "eslint src tests --ext .js", - "spec:lint": "npx @fission-ai/openspec lint spec/", - "spec:validate": "npx @fission-ai/openspec validate spec/" - }, - "dependencies": { - "axios": "^1.13.2", - "express": "^4.18.2", - "dotenv": "^16.3.1", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { - "@fission-ai/openspec": "^1.0.0", - "eslint": "^9.23.0", - "supertest": "^7.1.1" - }, - "engines": { - "node": ">=22.0.0" - }, - "author": "", - "license": "ISC" -} \ No newline at end of file diff --git a/back-end/dist/spec/openapi.yaml b/back-end/dist/spec/openapi.yaml deleted file mode 100644 index 55492fb..0000000 --- a/back-end/dist/spec/openapi.yaml +++ /dev/null @@ -1,382 +0,0 @@ -openapi: 3.1.0 -info: - title: BAI Management API - description: BAI 管理系统后端 API 文档 - version: 1.0.0 -servers: - - url: https://bai-api.blv-oa.com - description: BAI-api生产环境 - - url: http://localhost:3000 - description: BAI-api本地开发环境 -tags: - - name: 系统 - description: 基础健康检查接口 - - name: 微信小程序用户 - description: 微信小程序注册、登录与鉴权接口 -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - schemas: - ApiResponse: - type: object - required: [code, msg, data] - properties: - code: - type: integer - description: 业务状态码 - example: 200 - msg: - type: string - description: 响应消息 - example: 操作成功 - data: - type: object - description: 响应数据 - additionalProperties: true - HealthData: - type: object - properties: - status: - type: string - example: healthy - timestamp: - type: string - format: date-time - HelloWorldData: - type: object - properties: - message: - type: string - example: Hello, World! - timestamp: - type: string - format: date-time - status: - type: string - example: success - build_time: - type: string - nullable: true - description: 最近一次构建时间;开发模式下若未执行构建则可能为空 - format: date-time - WechatProfileRequest: - type: object - required: [users_name, users_phone_code, users_picture] - properties: - users_name: - type: string - description: 用户姓名 - example: 张三 - users_phone_code: - type: string - description: 微信手机号获取凭证 code,由后端换取真实手机号后写入 users_phone - example: 2b7d9f2e3c4a5b6d7e8f - users_picture: - type: string - description: 用户头像 URL - example: https://example.com/avatar.png - WechatAuthRequest: - type: object - required: [users_wx_code] - properties: - users_wx_code: - type: string - description: 微信小程序登录临时凭证 code - example: 0a1b2c3d4e5f6g - CompanyInfo: - type: object - properties: - company_id: - type: string - example: C10001 - company_name: - type: string - example: 示例科技有限公司 - company_type: - type: string - example: 科技服务 - company_entity: - type: string - example: 李四 - company_usci: - type: string - example: 91330100XXXXXXXXXX - company_nationality: - type: string - example: 中国 - company_province: - type: string - example: 浙江省 - company_city: - type: string - example: 杭州市 - company_postalcode: - type: string - example: "310000" - company_add: - type: string - example: 某某大道 100 号 - company_status: - type: string - example: 正常 - company_level: - type: string - example: A - company_remark: - type: string - example: 重点合作客户 - UserInfo: - type: object - properties: - users_id: - type: string - example: U202603190001 - users_type: - type: string - description: | - 用户类型。 - - `游客`:仅完成微信新号注册,尚未首次完整补充 `users_name`、`users_phone`、`users_picture` - - `注册用户`:用户曾从“三项资料均为空”首次补充为“三项资料均完整” - enum: [游客, 注册用户] - example: 游客 - users_name: - type: string - example: 张三 - users_phone: - type: string - example: "13800138000" - users_phone_masked: - type: string - example: "138****8000" - users_picture: - type: string - example: https://example.com/avatar.png - users_wx_openid: - type: string - example: oAbCdEfGh123456789 - company_id: - type: string - example: C10001 - company: - $ref: '#/components/schemas/CompanyInfo' - pb_id: - type: string - description: PocketBase 记录 id - example: abc123xyz - created: - type: string - format: date-time - updated: - type: string - format: date-time - WechatProfileResponseData: - type: object - properties: - status: - type: string - description: 信息编辑状态 - enum: [update_success, update_failed] - user: - $ref: '#/components/schemas/UserInfo' - WechatAuthResponseData: - type: object - properties: - status: - type: string - description: 登录/注册结果状态 - enum: [register_success, login_success] - is_info_complete: - type: boolean - description: 用户资料是否已完善 - example: true - token: - type: string - description: JWT 令牌 - user: - $ref: '#/components/schemas/UserInfo' -paths: - /api/test-helloworld: - post: - tags: [系统] - summary: Test endpoint - description: Returns a hello world message and build time - responses: - '200': - description: Successful response - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/HelloWorldData' - /api/health: - post: - tags: [系统] - summary: 健康检查 - description: 检查服务是否正常运行 - responses: - '200': - description: 服务状态正常 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/HealthData' - /api/wechat/login: - post: - tags: [微信小程序用户] - summary: 微信小程序登录/注册合一 - description: | - 使用微信小程序临时登录凭证换取 openid。 - 若用户不存在,则自动创建新账号并返回完整用户信息;若已存在,则直接登录并返回完整用户信息。 - 登录/注册合一接口不处理手机号获取。 - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - users_wx_code - properties: - users_wx_code: - type: string - description: 微信小程序登录临时凭证 code - example: 0a1b2c3d4e5f6g - responses: - '200': - description: 登录或注册成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/WechatAuthResponseData' - '400': - description: 参数错误 - '415': - description: 请求体格式错误,仅支持 application/json - '429': - description: 重复提交过于频繁 - '500': - description: 服务端异常 - /api/wechat/profile: - post: - tags: [微信小程序用户] - summary: 微信小程序用户信息编辑 - description: | - 从请求头 `users_wx_openid` 定位用户,不再从 body 中传 `users_wx_code`。 - body 中传入 `users_name`、`users_phone_code`、`users_picture`。 - 后端会通过微信 `users_phone_code` 调用官方接口换取真实手机号,再写入 `users_phone`。 - `users_name`、`users_phone_code`、`users_picture` 均为必填项。 - 当且仅当用户原先这三项资料全部为空,且本次首次完整补充三项资料时,系统自动将 `users_type` 从 `游客` 更新为 `注册用户`。 - 后续资料修改不会再影响已确定的 `users_type`。 - 返回更新后的完整用户信息,其中手机号等敏感字段需脱敏处理。 - parameters: - - in: header - name: users_wx_openid - required: true - schema: - type: string - description: 微信用户唯一标识,用于数据库查询 - - in: header - name: Authorization - required: true - schema: - type: string - description: 标准 JWT 认证头,格式为 `Bearer ` - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - users_name - - users_phone_code - - users_picture - properties: - users_name: - type: string - description: 用户姓名 - example: 张三 - users_phone_code: - type: string - description: 微信手机号获取凭证 code - example: 2b7d9f2e3c4a5b6d7e8f - users_picture: - type: string - description: 用户头像 URL - example: https://example.com/avatar.png - responses: - '200': - description: 信息更新成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/WechatProfileResponseData' - '400': - description: 参数错误 - '401': - description: 请求头缺少 users_wx_openid 或 Authorization,或令牌无效 - '415': - description: 请求体格式错误,仅支持 application/json - '404': - description: 用户不存在 - '500': - description: 服务端异常 - /api/wechat/refresh-token: - post: - tags: [微信小程序用户] - summary: 微信小程序刷新 token - description: | - 小程序通过请求头中的 `users_wx_openid` 定位用户,并返回新的 JWT token。 - 本接口无 body 参数,请求方法固定为 POST。 - 本接口不要求旧 `Authorization`,仅依赖 `users_wx_openid` 识别用户并签发新 token。 - parameters: - - in: header - name: users_wx_openid - required: true - schema: - type: string - description: 微信用户唯一标识,用于数据库查询 - responses: - '200': - description: 刷新成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - type: object - properties: - token: - type: string - description: 新的 JWT 令牌 - '404': - description: 未注册用户 - '401': - description: 请求头缺少 users_wx_openid - '500': - description: 服务端异常 diff --git a/back-end/dist/src/config/env.js b/back-end/dist/src/config/env.js deleted file mode 100644 index 26a7203..0000000 --- a/back-end/dist/src/config/env.js +++ /dev/null @@ -1,28 +0,0 @@ -const dotenv = require('dotenv') - -dotenv.config() - -const requiredEnv = ['WECHAT_APPID', 'WECHAT_SECRET', 'JWT_SECRET'] - -for (const key of requiredEnv) { - if (!process.env[key]) { - console.warn(`[config] 缺少环境变量: ${key}`) - } -} - -module.exports = { - nodeEnv: process.env.NODE_ENV || 'development', - port: Number(process.env.PORT || 3000), - apiPrefix: process.env.API_PREFIX || '/api', - appProtocol: process.env.APP_PROTOCOL || 'http', - appDomain: process.env.APP_DOMAIN || 'localhost', - appBaseUrl: - process.env.APP_BASE_URL - || `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`, - pocketbaseUrl: process.env.POCKETBASE_API_URL || '', - pocketbaseAuthToken: process.env.POCKETBASE_AUTH_TOKEN || '', - wechatAppId: process.env.WECHAT_APPID || '', - wechatSecret: process.env.WECHAT_SECRET || '', - jwtSecret: process.env.JWT_SECRET || 'change_me', - jwtExpiresIn: process.env.JWT_EXPIRES_IN || '2h', -} diff --git a/back-end/dist/src/controllers/wechatController.js b/back-end/dist/src/controllers/wechatController.js deleted file mode 100644 index ecae0f0..0000000 --- a/back-end/dist/src/controllers/wechatController.js +++ /dev/null @@ -1,36 +0,0 @@ -const asyncHandler = require('../utils/asyncHandler') -const { success } = require('../utils/response') -const { - validateLoginBody, - validateProfileEditBody, -} = require('../middlewares/validateWechatAuth') -const userService = require('../services/userService') - -const login = asyncHandler(async (req, res) => { - const payload = validateLoginBody(req.body) - const data = await userService.authenticateWechatUser(payload) - - const messageMap = { - register_success: '注册成功', - login_success: '登录成功', - } - - return success(res, messageMap[data.status] || '登录成功', data) -}) - -const updateProfile = asyncHandler(async (req, res) => { - const payload = validateProfileEditBody(req.body) - const data = await userService.updateWechatUserProfile(req.usersWxOpenid, payload) - return success(res, '信息更新成功', data) -}) - -const refreshToken = asyncHandler(async (req, res) => { - const data = await userService.refreshWechatToken(req.usersWxOpenid) - return success(res, '刷新成功', data) -}) - -module.exports = { - updateProfile, - login, - refreshToken, -} diff --git a/back-end/dist/src/index.js b/back-end/dist/src/index.js deleted file mode 100644 index 826585e..0000000 --- a/back-end/dist/src/index.js +++ /dev/null @@ -1,39 +0,0 @@ -const express = require('express') -const env = require('./config/env') -const { fail } = require('./utils/response') -const requestLogger = require('./middlewares/requestLogger') -const errorHandler = require('./middlewares/errorHandler') -const apiRoutes = require('./routes/apiRoutes') - -function createApp() { - const app = express() - - app.use(express.json({ limit: '1mb' })) - app.use(requestLogger) - - app.use(env.apiPrefix, apiRoutes) - - app.use((req, res) => { - return fail(res, 404, 'Route not found', { - path: req.path, - }) - }) - - app.use(errorHandler) - - return app -} - -if (require.main === module) { - const app = createApp() - app.listen(env.port, () => { - console.log(`Server running on port ${env.port}`) - console.log(`Public base URL: ${env.appBaseUrl}`) - console.log(`Test endpoint: ${env.appBaseUrl}${env.apiPrefix}/test-helloworld`) - console.log(`Health check: ${env.appBaseUrl}${env.apiPrefix}/health`) - }) -} - -module.exports = { - createApp, -} \ No newline at end of file diff --git a/back-end/dist/src/middlewares/duplicateGuard.js b/back-end/dist/src/middlewares/duplicateGuard.js deleted file mode 100644 index bc188f8..0000000 --- a/back-end/dist/src/middlewares/duplicateGuard.js +++ /dev/null @@ -1,22 +0,0 @@ -const AppError = require('../utils/appError') - -const requestCache = new Map() -const WINDOW_MS = 5000 - -module.exports = function duplicateGuard(req, res, next) { - const key = `${req.ip}:${req.originalUrl}:${JSON.stringify(req.body || {})}` - const now = Date.now() - const lastTime = requestCache.get(key) - - if (lastTime && now - lastTime < WINDOW_MS) { - return next(new AppError('请求过于频繁,请稍后重试', 429)) - } - - requestCache.set(key, now) - - setTimeout(() => { - requestCache.delete(key) - }, WINDOW_MS) - - next() -} diff --git a/back-end/dist/src/middlewares/errorHandler.js b/back-end/dist/src/middlewares/errorHandler.js deleted file mode 100644 index 78e3ce5..0000000 --- a/back-end/dist/src/middlewares/errorHandler.js +++ /dev/null @@ -1,19 +0,0 @@ -const { fail } = require('../utils/response') -const logger = require('../utils/logger') - -module.exports = function errorHandler(err, req, res, next) { - logger.error('接口处理异常', { - path: req.originalUrl, - method: req.method, - error: err.message, - details: err.details || {}, - }) - - if (res.headersSent) { - return next(err) - } - - return fail(res, err.statusCode || 500, err.message || '服务器内部错误', { - ...(err.details || {}), - }) -} diff --git a/back-end/dist/src/middlewares/jwtAuth.js b/back-end/dist/src/middlewares/jwtAuth.js deleted file mode 100644 index 72adc09..0000000 --- a/back-end/dist/src/middlewares/jwtAuth.js +++ /dev/null @@ -1,20 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') -const AppError = require('../utils/appError') - -module.exports = function jwtAuth(req, res, next) { - const authHeader = req.headers.authorization || '' - const [scheme, token] = authHeader.split(' ') - - if (scheme !== 'Bearer' || !token) { - return next(new AppError('未提供有效的认证令牌', 401)) - } - - try { - const decoded = jwt.verify(token, env.jwtSecret) - req.user = decoded - next() - } catch { - next(new AppError('认证令牌无效或已过期', 401)) - } -} diff --git a/back-end/dist/src/middlewares/requestLogger.js b/back-end/dist/src/middlewares/requestLogger.js deleted file mode 100644 index 208ca4e..0000000 --- a/back-end/dist/src/middlewares/requestLogger.js +++ /dev/null @@ -1,17 +0,0 @@ -const logger = require('../utils/logger') - -module.exports = function requestLogger(req, res, next) { - const start = Date.now() - - res.on('finish', () => { - logger.info('请求完成', { - method: req.method, - path: req.originalUrl, - statusCode: res.statusCode, - durationMs: Date.now() - start, - ip: req.ip, - }) - }) - - next() -} diff --git a/back-end/dist/src/middlewares/requireJsonBody.js b/back-end/dist/src/middlewares/requireJsonBody.js deleted file mode 100644 index 4333dc8..0000000 --- a/back-end/dist/src/middlewares/requireJsonBody.js +++ /dev/null @@ -1,15 +0,0 @@ -const AppError = require('../utils/appError') - -module.exports = function requireJsonBody(req, res, next) { - const methods = ['POST', 'PUT', 'PATCH'] - - if (!methods.includes(req.method)) { - return next() - } - - if (!req.is('application/json')) { - return next(new AppError('请求体必须为 application/json', 415)) - } - - next() -} diff --git a/back-end/dist/src/middlewares/requireWechatOpenid.js b/back-end/dist/src/middlewares/requireWechatOpenid.js deleted file mode 100644 index 65dd8d8..0000000 --- a/back-end/dist/src/middlewares/requireWechatOpenid.js +++ /dev/null @@ -1,12 +0,0 @@ -const AppError = require('../utils/appError') - -module.exports = function requireWechatOpenid(req, res, next) { - const usersWxOpenid = req.headers['users_wx_openid'] - - if (!usersWxOpenid) { - return next(new AppError('请求头缺少 users_wx_openid', 401)) - } - - req.usersWxOpenid = usersWxOpenid - next() -} diff --git a/back-end/dist/src/middlewares/validateWechatAuth.js b/back-end/dist/src/middlewares/validateWechatAuth.js deleted file mode 100644 index 7a98a76..0000000 --- a/back-end/dist/src/middlewares/validateWechatAuth.js +++ /dev/null @@ -1,35 +0,0 @@ -const AppError = require('../utils/appError') -const { sanitizePayload } = require('../utils/sanitize') - -function validateLoginBody(body = {}) { - const payload = sanitizePayload(body) - - if (!payload.users_wx_code) { - throw new AppError('users_wx_code 为必填项', 400) - } - - return payload -} - -function validateProfileEditBody(body = {}) { - const payload = sanitizePayload(body) - - if (!payload.users_name) { - throw new AppError('users_name 为必填项', 400) - } - - if (!payload.users_phone_code) { - throw new AppError('users_phone_code 为必填项', 400) - } - - if (!payload.users_picture) { - throw new AppError('users_picture 为必填项', 400) - } - - return payload -} - -module.exports = { - validateLoginBody, - validateProfileEditBody, -} diff --git a/back-end/dist/src/middlewares/wechatHeadersAuth.js b/back-end/dist/src/middlewares/wechatHeadersAuth.js deleted file mode 100644 index db2d188..0000000 --- a/back-end/dist/src/middlewares/wechatHeadersAuth.js +++ /dev/null @@ -1,31 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') -const AppError = require('../utils/appError') -const requireWechatOpenid = require('./requireWechatOpenid') - -module.exports = function wechatHeadersAuth(req, res, next) { - requireWechatOpenid(req, res, (openidError) => { - if (openidError) { - return next(openidError) - } - - const usersWxOpenid = req.usersWxOpenid - const authHeader = req.headers.authorization || '' - const [scheme, token] = authHeader.split(' ') - - if (scheme !== 'Bearer' || !token) { - return next(new AppError('请求头缺少 Authorization', 401)) - } - - try { - const decoded = jwt.verify(token, env.jwtSecret) - if (decoded.users_wx_openid && decoded.users_wx_openid !== usersWxOpenid) { - return next(new AppError('请求头中的 users_wx_openid 与令牌不匹配', 401)) - } - req.user = decoded - next() - } catch { - next(new AppError('认证令牌无效或已过期', 401)) - } - }) -} diff --git a/back-end/dist/src/routes/apiRoutes.js b/back-end/dist/src/routes/apiRoutes.js deleted file mode 100644 index 0514f4d..0000000 --- a/back-end/dist/src/routes/apiRoutes.js +++ /dev/null @@ -1,30 +0,0 @@ -const express = require('express') -const { success } = require('../utils/response') -const { getBuildTimestamp } = require('../utils/buildInfo') -const wechatRoutes = require('./wechatRoutes') - -const router = express.Router() - -function respondHelloWorld(req, res) { - return success(res, '请求成功', { - message: 'Hello, World!', - timestamp: new Date().toISOString(), - status: 'success', - build_time: getBuildTimestamp(), - }) -} - -function respondHealth(req, res) { - return success(res, '服务运行正常', { - status: 'healthy', - timestamp: new Date().toISOString(), - }) -} - -router.post('/test-helloworld', respondHelloWorld) - -router.post('/health', respondHealth) - -router.use('/wechat', wechatRoutes) - -module.exports = router diff --git a/back-end/dist/src/routes/wechatRoutes.js b/back-end/dist/src/routes/wechatRoutes.js deleted file mode 100644 index 7b00d38..0000000 --- a/back-end/dist/src/routes/wechatRoutes.js +++ /dev/null @@ -1,14 +0,0 @@ -const express = require('express') -const duplicateGuard = require('../middlewares/duplicateGuard') -const requireJsonBody = require('../middlewares/requireJsonBody') -const requireWechatOpenid = require('../middlewares/requireWechatOpenid') -const wechatHeadersAuth = require('../middlewares/wechatHeadersAuth') -const controller = require('../controllers/wechatController') - -const router = express.Router() - -router.post('/login', requireJsonBody, duplicateGuard, controller.login) -router.post('/profile', requireJsonBody, wechatHeadersAuth, duplicateGuard, controller.updateProfile) -router.post('/refresh-token', requireWechatOpenid, controller.refreshToken) - -module.exports = router diff --git a/back-end/dist/src/services/jwtService.js b/back-end/dist/src/services/jwtService.js deleted file mode 100644 index bcdd926..0000000 --- a/back-end/dist/src/services/jwtService.js +++ /dev/null @@ -1,12 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') - -function signAccessToken(payload) { - return jwt.sign(payload, env.jwtSecret, { - expiresIn: env.jwtExpiresIn, - }) -} - -module.exports = { - signAccessToken, -} diff --git a/back-end/dist/src/services/pocketbaseService.js b/back-end/dist/src/services/pocketbaseService.js deleted file mode 100644 index 7837a41..0000000 --- a/back-end/dist/src/services/pocketbaseService.js +++ /dev/null @@ -1,90 +0,0 @@ -const axios = require('axios') -const env = require('../config/env') -const AppError = require('../utils/appError') - -function getHeaders() { - const headers = { - 'Content-Type': 'application/json', - } - - if (env.pocketbaseAuthToken) { - headers.Authorization = env.pocketbaseAuthToken.startsWith('Bearer ') - ? env.pocketbaseAuthToken - : `Bearer ${env.pocketbaseAuthToken}` - } - - return headers -} - -function buildUrl(path) { - const base = env.pocketbaseUrl.replace(/\/+$/, '') - const normalizedPath = path.replace(/^\/+/, '') - return `${base}/${normalizedPath}` -} - -async function request(config) { - try { - const response = await axios({ - timeout: 10000, - headers: getHeaders(), - ...config, - }) - return response.data - } catch (error) { - const detail = error.response?.data || error.message - throw new AppError('PocketBase 数据操作失败', 500, { - detail, - }) - } -} - -async function listUsersByFilter(filter) { - const data = await request({ - method: 'get', - url: buildUrl('collections/tbl_users/records'), - params: { - filter, - perPage: 1, - }, - }) - - return data.items || [] -} - -async function createUser(payload) { - return request({ - method: 'post', - url: buildUrl('collections/tbl_users/records'), - data: payload, - }) -} - -async function updateUser(recordId, payload) { - return request({ - method: 'patch', - url: buildUrl(`collections/tbl_users/records/${recordId}`), - data: payload, - }) -} - -async function getCompanyByCompanyId(companyId) { - if (!companyId) return null - - const data = await request({ - method: 'get', - url: buildUrl('collections/tbl_company/records'), - params: { - filter: `company_id = "${companyId}"`, - perPage: 1, - }, - }) - - return data.items?.[0] || null -} - -module.exports = { - listUsersByFilter, - createUser, - updateUser, - getCompanyByCompanyId, -} diff --git a/back-end/dist/src/services/userService.js b/back-end/dist/src/services/userService.js deleted file mode 100644 index cda2d23..0000000 --- a/back-end/dist/src/services/userService.js +++ /dev/null @@ -1,208 +0,0 @@ -const crypto = require('crypto') -const AppError = require('../utils/appError') -const logger = require('../utils/logger') -const wechatService = require('./wechatService') -const jwtService = require('./jwtService') -const pocketbaseService = require('./pocketbaseService') - -const userMutationLocks = new Map() -const GUEST_USER_TYPE = '游客' -const REGISTERED_USER_TYPE = '注册用户' - -function buildUserId() { - const now = new Date() - const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` - const suffix = crypto.randomInt(1000, 9999) - return `U${date}${suffix}` -} - -async function enrichUser(user) { - const company = await pocketbaseService.getCompanyByCompanyId(user.company_id) - return { - pb_id: user.id, - users_id: user.users_id, - users_type: user.users_type || GUEST_USER_TYPE, - users_name: user.users_name, - users_phone: user.users_phone, - users_phone_masked: maskPhone(user.users_phone), - users_picture: user.users_picture, - users_wx_openid: user.users_wx_openid, - company_id: user.company_id || '', - company, - created: user.created, - updated: user.updated, - raw: user, - } -} - -async function findUserByOpenid(usersWxOpenid) { - const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`) - return users[0] || null -} - -function maskPhone(phone = '') { - if (!phone || phone.length < 7) return '' - return `${phone.slice(0, 3)}****${phone.slice(-4)}` -} - -function isInfoComplete(user = {}) { - return Boolean(user.users_name && user.users_phone && user.users_picture) -} - -function isAllProfileFieldsEmpty(user = {}) { - return !user.users_name && !user.users_phone && !user.users_picture -} - -async function withUserLock(lockKey, handler) { - const previous = userMutationLocks.get(lockKey) || Promise.resolve() - let release - const current = new Promise((resolve) => { - release = resolve - }) - - userMutationLocks.set(lockKey, previous.then(() => current)) - - await previous - - try { - return await handler() - } finally { - release() - if (userMutationLocks.get(lockKey) === current) { - userMutationLocks.delete(lockKey) - } - } -} - -async function authenticateWechatUser(payload) { - const openid = await wechatService.getWxOpenId(payload.users_wx_code) - return withUserLock(`auth:${openid}`, async () => { - const existing = await findUserByOpenid(openid) - - if (existing) { - logger.warn('微信注册命中已存在账号', { - users_wx_openid: openid, - users_type: existing.users_type || GUEST_USER_TYPE, - }) - - const user = await enrichUser(existing) - const token = jwtService.signAccessToken({ - users_id: user.users_id, - users_wx_openid: user.users_wx_openid, - }) - - return { - status: 'login_success', - is_info_complete: isInfoComplete(existing), - token, - user, - } - } - - const created = await pocketbaseService.createUser({ - users_id: buildUserId(), - users_wx_openid: openid, - users_type: GUEST_USER_TYPE, - }) - - const user = await enrichUser(created) - const token = jwtService.signAccessToken({ - users_id: user.users_id, - users_wx_openid: user.users_wx_openid, - }) - - logger.info('微信用户注册成功', { - users_id: user.users_id, - users_phone: user.users_phone, - users_type: user.users_type, - }) - - return { - status: 'register_success', - is_info_complete: false, - token, - user, - } - }) -} - -async function updateWechatUserProfile(usersWxOpenid, payload) { - return withUserLock(`profile:${usersWxOpenid}`, async () => { - const currentUser = await findUserByOpenid(usersWxOpenid) - - if (!currentUser) { - throw new AppError('未找到待编辑的用户', 404) - } - - const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code) - - if (usersPhone && usersPhone !== currentUser.users_phone) { - const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`) - const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id) - if (phoneUsedByOther) { - throw new AppError('手机号已被注册', 400) - } - } - - const shouldPromoteUserType = - isAllProfileFieldsEmpty(currentUser) - && payload.users_name - && usersPhone - && payload.users_picture - && (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type) - - const updatePayload = { - users_name: payload.users_name, - users_phone: usersPhone, - users_picture: payload.users_picture, - } - - if (shouldPromoteUserType) { - updatePayload.users_type = REGISTERED_USER_TYPE - } - - const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload) - const user = await enrichUser(updated) - - logger.info('微信用户资料更新成功', { - users_id: user.users_id, - users_phone: user.users_phone, - users_type_before: currentUser.users_type || GUEST_USER_TYPE, - users_type_after: user.users_type, - users_type_promoted: shouldPromoteUserType, - }) - - return { - status: 'update_success', - user, - } - }) -} - -async function refreshWechatToken(usersWxOpenid) { - const userRecord = await findUserByOpenid(usersWxOpenid) - - if (!userRecord) { - throw new AppError('未注册用户', 404) - } - - const token = jwtService.signAccessToken({ - users_id: userRecord.users_id, - users_wx_openid: userRecord.users_wx_openid, - }) - - logger.info('微信用户刷新令牌成功', { - users_id: userRecord.users_id, - users_wx_openid: userRecord.users_wx_openid, - }) - - return { - token, - } -} - -module.exports = { - authenticateWechatUser, - updateWechatUserProfile, - refreshWechatToken, -} diff --git a/back-end/dist/src/services/wechatService.js b/back-end/dist/src/services/wechatService.js deleted file mode 100644 index 85ba310..0000000 --- a/back-end/dist/src/services/wechatService.js +++ /dev/null @@ -1,117 +0,0 @@ -const axios = require('axios') -const env = require('../config/env') -const AppError = require('../utils/appError') - -let accessTokenCache = { - token: '', - expiresAt: 0, -} - -async function getWxOpenId(code) { - if (!env.wechatAppId || !env.wechatSecret) { - throw new AppError('微信小程序配置缺失', 500) - } - - try { - const url = 'https://api.weixin.qq.com/sns/jscode2session' - const response = await axios.get(url, { - params: { - appid: env.wechatAppId, - secret: env.wechatSecret, - js_code: code, - grant_type: 'authorization_code', - }, - timeout: 10000, - }) - - const data = response.data || {} - - if (data.openid) { - return data.openid - } - - if (data.errcode || data.errmsg) { - throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } - - throw new AppError('获取OpenID失败: 响应中未包含openid', 502) - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502) - } -} - -async function getWechatAccessToken() { - if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) { - return accessTokenCache.token - } - - try { - const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', { - params: { - grant_type: 'client_credential', - appid: env.wechatAppId, - secret: env.wechatSecret, - }, - timeout: 10000, - }) - - const data = response.data || {} - - if (!data.access_token) { - throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } - - accessTokenCache = { - token: data.access_token, - expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000, - } - - return accessTokenCache.token - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502) - } -} - -async function getWxPhoneNumber(phoneCode) { - const accessToken = await getWechatAccessToken() - - try { - const response = await axios.post( - `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`, - { - code: phoneCode, - }, - { - headers: { - 'Content-Type': 'application/json', - }, - timeout: 10000, - } - ) - - const data = response.data || {} - const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber - - if (phone) { - return phone - } - - throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502) - } -} - -module.exports = { - getWxOpenId, - getWxPhoneNumber, -} diff --git a/back-end/dist/src/utils/appError.js b/back-end/dist/src/utils/appError.js deleted file mode 100644 index 5cfa9d5..0000000 --- a/back-end/dist/src/utils/appError.js +++ /dev/null @@ -1,10 +0,0 @@ -class AppError extends Error { - constructor(message, statusCode = 500, details = {}) { - super(message) - this.name = 'AppError' - this.statusCode = statusCode - this.details = details - } -} - -module.exports = AppError diff --git a/back-end/dist/src/utils/asyncHandler.js b/back-end/dist/src/utils/asyncHandler.js deleted file mode 100644 index 10bd82e..0000000 --- a/back-end/dist/src/utils/asyncHandler.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = function asyncHandler(fn) { - return function wrappedHandler(req, res, next) { - Promise.resolve(fn(req, res, next)).catch(next) - } -} diff --git a/back-end/dist/src/utils/buildInfo.js b/back-end/dist/src/utils/buildInfo.js deleted file mode 100644 index fecbbb0..0000000 --- a/back-end/dist/src/utils/buildInfo.js +++ /dev/null @@ -1,30 +0,0 @@ -const fs = require('fs') -const path = require('path') - -function getBuildInfoFilePath() { - return path.resolve(__dirname, '..', '..', 'build-info.json') -} - -function getBuildTimestamp() { - if (process.env.BUILD_TIME) { - return process.env.BUILD_TIME - } - - const filePath = getBuildInfoFilePath() - - if (!fs.existsSync(filePath)) { - return null - } - - try { - const content = fs.readFileSync(filePath, 'utf8') - const payload = JSON.parse(content) - return payload.buildTime || null - } catch { - return null - } -} - -module.exports = { - getBuildTimestamp, -} \ No newline at end of file diff --git a/back-end/dist/src/utils/logger.js b/back-end/dist/src/utils/logger.js deleted file mode 100644 index 9483dce..0000000 --- a/back-end/dist/src/utils/logger.js +++ /dev/null @@ -1,27 +0,0 @@ -function createLog(method, message, meta = {}) { - const payload = { - time: new Date().toISOString(), - method, - message, - ...meta, - } - return JSON.stringify(payload) -} - -function info(message, meta) { - console.log(createLog('INFO', message, meta)) -} - -function warn(message, meta) { - console.warn(createLog('WARN', message, meta)) -} - -function error(message, meta) { - console.error(createLog('ERROR', message, meta)) -} - -module.exports = { - info, - warn, - error, -} diff --git a/back-end/dist/src/utils/response.js b/back-end/dist/src/utils/response.js deleted file mode 100644 index bb1d6cd..0000000 --- a/back-end/dist/src/utils/response.js +++ /dev/null @@ -1,20 +0,0 @@ -function success(res, msg = '操作成功', data = {}, code = 200) { - return res.status(code).json({ - code, - msg, - data, - }) -} - -function fail(res, code = 500, msg = '服务器内部错误', data = {}) { - return res.status(code).json({ - code, - msg, - data, - }) -} - -module.exports = { - success, - fail, -} diff --git a/back-end/dist/src/utils/sanitize.js b/back-end/dist/src/utils/sanitize.js deleted file mode 100644 index 8ed5012..0000000 --- a/back-end/dist/src/utils/sanitize.js +++ /dev/null @@ -1,17 +0,0 @@ -function sanitizeString(value) { - if (typeof value !== 'string') return '' - return value.replace(/[<>\\]/g, '').trim() -} - -function sanitizePayload(payload = {}) { - return Object.keys(payload).reduce((acc, key) => { - const value = payload[key] - acc[key] = typeof value === 'string' ? sanitizeString(value) : value - return acc - }, {}) -} - -module.exports = { - sanitizeString, - sanitizePayload, -} diff --git a/back-end/package.json b/back-end/package.json index 2fd1e7e..43220f7 100644 --- a/back-end/package.json +++ b/back-end/package.json @@ -14,10 +14,8 @@ "spec:validate": "npx @fission-ai/openspec validate spec/" }, "dependencies": { - "axios": "^1.13.2", "express": "^4.18.2", - "dotenv": "^16.3.1", - "jsonwebtoken": "^9.0.2" + "dotenv": "^16.3.1" }, "devDependencies": { "@fission-ai/openspec": "^1.0.0", diff --git a/back-end/spec/openapi.yaml b/back-end/spec/openapi.yaml index 55492fb..4bf7c44 100644 --- a/back-end/spec/openapi.yaml +++ b/back-end/spec/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: BAI Management API - description: BAI 管理系统后端 API 文档 + description: 精简后的后端 API,仅保留健康检查接口 version: 1.0.0 servers: - url: https://bai-api.blv-oa.com @@ -11,14 +11,7 @@ servers: tags: - name: 系统 description: 基础健康检查接口 - - name: 微信小程序用户 - description: 微信小程序注册、登录与鉴权接口 components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT schemas: ApiResponse: type: object @@ -26,15 +19,12 @@ components: properties: code: type: integer - description: 业务状态码 example: 200 msg: type: string - description: 响应消息 example: 操作成功 data: type: object - description: 响应数据 additionalProperties: true HealthData: type: object @@ -45,176 +35,7 @@ components: timestamp: type: string format: date-time - HelloWorldData: - type: object - properties: - message: - type: string - example: Hello, World! - timestamp: - type: string - format: date-time - status: - type: string - example: success - build_time: - type: string - nullable: true - description: 最近一次构建时间;开发模式下若未执行构建则可能为空 - format: date-time - WechatProfileRequest: - type: object - required: [users_name, users_phone_code, users_picture] - properties: - users_name: - type: string - description: 用户姓名 - example: 张三 - users_phone_code: - type: string - description: 微信手机号获取凭证 code,由后端换取真实手机号后写入 users_phone - example: 2b7d9f2e3c4a5b6d7e8f - users_picture: - type: string - description: 用户头像 URL - example: https://example.com/avatar.png - WechatAuthRequest: - type: object - required: [users_wx_code] - properties: - users_wx_code: - type: string - description: 微信小程序登录临时凭证 code - example: 0a1b2c3d4e5f6g - CompanyInfo: - type: object - properties: - company_id: - type: string - example: C10001 - company_name: - type: string - example: 示例科技有限公司 - company_type: - type: string - example: 科技服务 - company_entity: - type: string - example: 李四 - company_usci: - type: string - example: 91330100XXXXXXXXXX - company_nationality: - type: string - example: 中国 - company_province: - type: string - example: 浙江省 - company_city: - type: string - example: 杭州市 - company_postalcode: - type: string - example: "310000" - company_add: - type: string - example: 某某大道 100 号 - company_status: - type: string - example: 正常 - company_level: - type: string - example: A - company_remark: - type: string - example: 重点合作客户 - UserInfo: - type: object - properties: - users_id: - type: string - example: U202603190001 - users_type: - type: string - description: | - 用户类型。 - - `游客`:仅完成微信新号注册,尚未首次完整补充 `users_name`、`users_phone`、`users_picture` - - `注册用户`:用户曾从“三项资料均为空”首次补充为“三项资料均完整” - enum: [游客, 注册用户] - example: 游客 - users_name: - type: string - example: 张三 - users_phone: - type: string - example: "13800138000" - users_phone_masked: - type: string - example: "138****8000" - users_picture: - type: string - example: https://example.com/avatar.png - users_wx_openid: - type: string - example: oAbCdEfGh123456789 - company_id: - type: string - example: C10001 - company: - $ref: '#/components/schemas/CompanyInfo' - pb_id: - type: string - description: PocketBase 记录 id - example: abc123xyz - created: - type: string - format: date-time - updated: - type: string - format: date-time - WechatProfileResponseData: - type: object - properties: - status: - type: string - description: 信息编辑状态 - enum: [update_success, update_failed] - user: - $ref: '#/components/schemas/UserInfo' - WechatAuthResponseData: - type: object - properties: - status: - type: string - description: 登录/注册结果状态 - enum: [register_success, login_success] - is_info_complete: - type: boolean - description: 用户资料是否已完善 - example: true - token: - type: string - description: JWT 令牌 - user: - $ref: '#/components/schemas/UserInfo' paths: - /api/test-helloworld: - post: - tags: [系统] - summary: Test endpoint - description: Returns a hello world message and build time - responses: - '200': - description: Successful response - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/HelloWorldData' /api/health: post: tags: [系统] @@ -232,151 +53,5 @@ paths: properties: data: $ref: '#/components/schemas/HealthData' - /api/wechat/login: - post: - tags: [微信小程序用户] - summary: 微信小程序登录/注册合一 - description: | - 使用微信小程序临时登录凭证换取 openid。 - 若用户不存在,则自动创建新账号并返回完整用户信息;若已存在,则直接登录并返回完整用户信息。 - 登录/注册合一接口不处理手机号获取。 - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - users_wx_code - properties: - users_wx_code: - type: string - description: 微信小程序登录临时凭证 code - example: 0a1b2c3d4e5f6g - responses: - '200': - description: 登录或注册成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object + properties: - data: - $ref: '#/components/schemas/WechatAuthResponseData' - '400': - description: 参数错误 - '415': - description: 请求体格式错误,仅支持 application/json - '429': - description: 重复提交过于频繁 - '500': - description: 服务端异常 - /api/wechat/profile: - post: - tags: [微信小程序用户] - summary: 微信小程序用户信息编辑 - description: | - 从请求头 `users_wx_openid` 定位用户,不再从 body 中传 `users_wx_code`。 - body 中传入 `users_name`、`users_phone_code`、`users_picture`。 - 后端会通过微信 `users_phone_code` 调用官方接口换取真实手机号,再写入 `users_phone`。 - `users_name`、`users_phone_code`、`users_picture` 均为必填项。 - 当且仅当用户原先这三项资料全部为空,且本次首次完整补充三项资料时,系统自动将 `users_type` 从 `游客` 更新为 `注册用户`。 - 后续资料修改不会再影响已确定的 `users_type`。 - 返回更新后的完整用户信息,其中手机号等敏感字段需脱敏处理。 - parameters: - - in: header - name: users_wx_openid - required: true - schema: - type: string - description: 微信用户唯一标识,用于数据库查询 - - in: header - name: Authorization - required: true - schema: - type: string - description: 标准 JWT 认证头,格式为 `Bearer ` - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - users_name - - users_phone_code - - users_picture - properties: - users_name: - type: string - description: 用户姓名 - example: 张三 - users_phone_code: - type: string - description: 微信手机号获取凭证 code - example: 2b7d9f2e3c4a5b6d7e8f - users_picture: - type: string - description: 用户头像 URL - example: https://example.com/avatar.png - responses: - '200': - description: 信息更新成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/WechatProfileResponseData' - '400': - description: 参数错误 - '401': - description: 请求头缺少 users_wx_openid 或 Authorization,或令牌无效 - '415': - description: 请求体格式错误,仅支持 application/json - '404': - description: 用户不存在 - '500': - description: 服务端异常 - /api/wechat/refresh-token: - post: - tags: [微信小程序用户] - summary: 微信小程序刷新 token - description: | - 小程序通过请求头中的 `users_wx_openid` 定位用户,并返回新的 JWT token。 - 本接口无 body 参数,请求方法固定为 POST。 - 本接口不要求旧 `Authorization`,仅依赖 `users_wx_openid` 识别用户并签发新 token。 - parameters: - - in: header - name: users_wx_openid - required: true - schema: - type: string - description: 微信用户唯一标识,用于数据库查询 - responses: - '200': - description: 刷新成功 - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - type: object - properties: - token: - type: string - description: 新的 JWT 令牌 - '404': - description: 未注册用户 - '401': - description: 请求头缺少 users_wx_openid - '500': - description: 服务端异常 diff --git a/back-end/src/config/env.js b/back-end/src/config/env.js index 26a7203..2070b56 100644 --- a/back-end/src/config/env.js +++ b/back-end/src/config/env.js @@ -2,7 +2,7 @@ const dotenv = require('dotenv') dotenv.config() -const requiredEnv = ['WECHAT_APPID', 'WECHAT_SECRET', 'JWT_SECRET'] +const requiredEnv = [] for (const key of requiredEnv) { if (!process.env[key]) { @@ -21,8 +21,4 @@ module.exports = { || `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`, pocketbaseUrl: process.env.POCKETBASE_API_URL || '', pocketbaseAuthToken: process.env.POCKETBASE_AUTH_TOKEN || '', - wechatAppId: process.env.WECHAT_APPID || '', - wechatSecret: process.env.WECHAT_SECRET || '', - jwtSecret: process.env.JWT_SECRET || 'change_me', - jwtExpiresIn: process.env.JWT_EXPIRES_IN || '2h', } diff --git a/back-end/src/controllers/wechatController.js b/back-end/src/controllers/wechatController.js deleted file mode 100644 index ecae0f0..0000000 --- a/back-end/src/controllers/wechatController.js +++ /dev/null @@ -1,36 +0,0 @@ -const asyncHandler = require('../utils/asyncHandler') -const { success } = require('../utils/response') -const { - validateLoginBody, - validateProfileEditBody, -} = require('../middlewares/validateWechatAuth') -const userService = require('../services/userService') - -const login = asyncHandler(async (req, res) => { - const payload = validateLoginBody(req.body) - const data = await userService.authenticateWechatUser(payload) - - const messageMap = { - register_success: '注册成功', - login_success: '登录成功', - } - - return success(res, messageMap[data.status] || '登录成功', data) -}) - -const updateProfile = asyncHandler(async (req, res) => { - const payload = validateProfileEditBody(req.body) - const data = await userService.updateWechatUserProfile(req.usersWxOpenid, payload) - return success(res, '信息更新成功', data) -}) - -const refreshToken = asyncHandler(async (req, res) => { - const data = await userService.refreshWechatToken(req.usersWxOpenid) - return success(res, '刷新成功', data) -}) - -module.exports = { - updateProfile, - login, - refreshToken, -} diff --git a/back-end/src/index.js b/back-end/src/index.js index 826585e..3448c6a 100644 --- a/back-end/src/index.js +++ b/back-end/src/index.js @@ -29,7 +29,6 @@ if (require.main === module) { app.listen(env.port, () => { console.log(`Server running on port ${env.port}`) console.log(`Public base URL: ${env.appBaseUrl}`) - console.log(`Test endpoint: ${env.appBaseUrl}${env.apiPrefix}/test-helloworld`) console.log(`Health check: ${env.appBaseUrl}${env.apiPrefix}/health`) }) } diff --git a/back-end/src/middlewares/duplicateGuard.js b/back-end/src/middlewares/duplicateGuard.js deleted file mode 100644 index bc188f8..0000000 --- a/back-end/src/middlewares/duplicateGuard.js +++ /dev/null @@ -1,22 +0,0 @@ -const AppError = require('../utils/appError') - -const requestCache = new Map() -const WINDOW_MS = 5000 - -module.exports = function duplicateGuard(req, res, next) { - const key = `${req.ip}:${req.originalUrl}:${JSON.stringify(req.body || {})}` - const now = Date.now() - const lastTime = requestCache.get(key) - - if (lastTime && now - lastTime < WINDOW_MS) { - return next(new AppError('请求过于频繁,请稍后重试', 429)) - } - - requestCache.set(key, now) - - setTimeout(() => { - requestCache.delete(key) - }, WINDOW_MS) - - next() -} diff --git a/back-end/src/middlewares/jwtAuth.js b/back-end/src/middlewares/jwtAuth.js deleted file mode 100644 index 72adc09..0000000 --- a/back-end/src/middlewares/jwtAuth.js +++ /dev/null @@ -1,20 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') -const AppError = require('../utils/appError') - -module.exports = function jwtAuth(req, res, next) { - const authHeader = req.headers.authorization || '' - const [scheme, token] = authHeader.split(' ') - - if (scheme !== 'Bearer' || !token) { - return next(new AppError('未提供有效的认证令牌', 401)) - } - - try { - const decoded = jwt.verify(token, env.jwtSecret) - req.user = decoded - next() - } catch { - next(new AppError('认证令牌无效或已过期', 401)) - } -} diff --git a/back-end/src/middlewares/requireJsonBody.js b/back-end/src/middlewares/requireJsonBody.js deleted file mode 100644 index 4333dc8..0000000 --- a/back-end/src/middlewares/requireJsonBody.js +++ /dev/null @@ -1,15 +0,0 @@ -const AppError = require('../utils/appError') - -module.exports = function requireJsonBody(req, res, next) { - const methods = ['POST', 'PUT', 'PATCH'] - - if (!methods.includes(req.method)) { - return next() - } - - if (!req.is('application/json')) { - return next(new AppError('请求体必须为 application/json', 415)) - } - - next() -} diff --git a/back-end/src/middlewares/requireWechatOpenid.js b/back-end/src/middlewares/requireWechatOpenid.js deleted file mode 100644 index 65dd8d8..0000000 --- a/back-end/src/middlewares/requireWechatOpenid.js +++ /dev/null @@ -1,12 +0,0 @@ -const AppError = require('../utils/appError') - -module.exports = function requireWechatOpenid(req, res, next) { - const usersWxOpenid = req.headers['users_wx_openid'] - - if (!usersWxOpenid) { - return next(new AppError('请求头缺少 users_wx_openid', 401)) - } - - req.usersWxOpenid = usersWxOpenid - next() -} diff --git a/back-end/src/middlewares/validateWechatAuth.js b/back-end/src/middlewares/validateWechatAuth.js deleted file mode 100644 index 7a98a76..0000000 --- a/back-end/src/middlewares/validateWechatAuth.js +++ /dev/null @@ -1,35 +0,0 @@ -const AppError = require('../utils/appError') -const { sanitizePayload } = require('../utils/sanitize') - -function validateLoginBody(body = {}) { - const payload = sanitizePayload(body) - - if (!payload.users_wx_code) { - throw new AppError('users_wx_code 为必填项', 400) - } - - return payload -} - -function validateProfileEditBody(body = {}) { - const payload = sanitizePayload(body) - - if (!payload.users_name) { - throw new AppError('users_name 为必填项', 400) - } - - if (!payload.users_phone_code) { - throw new AppError('users_phone_code 为必填项', 400) - } - - if (!payload.users_picture) { - throw new AppError('users_picture 为必填项', 400) - } - - return payload -} - -module.exports = { - validateLoginBody, - validateProfileEditBody, -} diff --git a/back-end/src/middlewares/wechatHeadersAuth.js b/back-end/src/middlewares/wechatHeadersAuth.js deleted file mode 100644 index db2d188..0000000 --- a/back-end/src/middlewares/wechatHeadersAuth.js +++ /dev/null @@ -1,31 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') -const AppError = require('../utils/appError') -const requireWechatOpenid = require('./requireWechatOpenid') - -module.exports = function wechatHeadersAuth(req, res, next) { - requireWechatOpenid(req, res, (openidError) => { - if (openidError) { - return next(openidError) - } - - const usersWxOpenid = req.usersWxOpenid - const authHeader = req.headers.authorization || '' - const [scheme, token] = authHeader.split(' ') - - if (scheme !== 'Bearer' || !token) { - return next(new AppError('请求头缺少 Authorization', 401)) - } - - try { - const decoded = jwt.verify(token, env.jwtSecret) - if (decoded.users_wx_openid && decoded.users_wx_openid !== usersWxOpenid) { - return next(new AppError('请求头中的 users_wx_openid 与令牌不匹配', 401)) - } - req.user = decoded - next() - } catch { - next(new AppError('认证令牌无效或已过期', 401)) - } - }) -} diff --git a/back-end/src/routes/apiRoutes.js b/back-end/src/routes/apiRoutes.js index 0514f4d..6cfebb3 100644 --- a/back-end/src/routes/apiRoutes.js +++ b/back-end/src/routes/apiRoutes.js @@ -1,19 +1,8 @@ const express = require('express') const { success } = require('../utils/response') -const { getBuildTimestamp } = require('../utils/buildInfo') -const wechatRoutes = require('./wechatRoutes') const router = express.Router() -function respondHelloWorld(req, res) { - return success(res, '请求成功', { - message: 'Hello, World!', - timestamp: new Date().toISOString(), - status: 'success', - build_time: getBuildTimestamp(), - }) -} - function respondHealth(req, res) { return success(res, '服务运行正常', { status: 'healthy', @@ -21,10 +10,6 @@ function respondHealth(req, res) { }) } -router.post('/test-helloworld', respondHelloWorld) - router.post('/health', respondHealth) -router.use('/wechat', wechatRoutes) - module.exports = router diff --git a/back-end/src/routes/wechatRoutes.js b/back-end/src/routes/wechatRoutes.js deleted file mode 100644 index 7b00d38..0000000 --- a/back-end/src/routes/wechatRoutes.js +++ /dev/null @@ -1,14 +0,0 @@ -const express = require('express') -const duplicateGuard = require('../middlewares/duplicateGuard') -const requireJsonBody = require('../middlewares/requireJsonBody') -const requireWechatOpenid = require('../middlewares/requireWechatOpenid') -const wechatHeadersAuth = require('../middlewares/wechatHeadersAuth') -const controller = require('../controllers/wechatController') - -const router = express.Router() - -router.post('/login', requireJsonBody, duplicateGuard, controller.login) -router.post('/profile', requireJsonBody, wechatHeadersAuth, duplicateGuard, controller.updateProfile) -router.post('/refresh-token', requireWechatOpenid, controller.refreshToken) - -module.exports = router diff --git a/back-end/src/services/jwtService.js b/back-end/src/services/jwtService.js deleted file mode 100644 index bcdd926..0000000 --- a/back-end/src/services/jwtService.js +++ /dev/null @@ -1,12 +0,0 @@ -const jwt = require('jsonwebtoken') -const env = require('../config/env') - -function signAccessToken(payload) { - return jwt.sign(payload, env.jwtSecret, { - expiresIn: env.jwtExpiresIn, - }) -} - -module.exports = { - signAccessToken, -} diff --git a/back-end/src/services/userService.js b/back-end/src/services/userService.js deleted file mode 100644 index cda2d23..0000000 --- a/back-end/src/services/userService.js +++ /dev/null @@ -1,208 +0,0 @@ -const crypto = require('crypto') -const AppError = require('../utils/appError') -const logger = require('../utils/logger') -const wechatService = require('./wechatService') -const jwtService = require('./jwtService') -const pocketbaseService = require('./pocketbaseService') - -const userMutationLocks = new Map() -const GUEST_USER_TYPE = '游客' -const REGISTERED_USER_TYPE = '注册用户' - -function buildUserId() { - const now = new Date() - const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` - const suffix = crypto.randomInt(1000, 9999) - return `U${date}${suffix}` -} - -async function enrichUser(user) { - const company = await pocketbaseService.getCompanyByCompanyId(user.company_id) - return { - pb_id: user.id, - users_id: user.users_id, - users_type: user.users_type || GUEST_USER_TYPE, - users_name: user.users_name, - users_phone: user.users_phone, - users_phone_masked: maskPhone(user.users_phone), - users_picture: user.users_picture, - users_wx_openid: user.users_wx_openid, - company_id: user.company_id || '', - company, - created: user.created, - updated: user.updated, - raw: user, - } -} - -async function findUserByOpenid(usersWxOpenid) { - const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`) - return users[0] || null -} - -function maskPhone(phone = '') { - if (!phone || phone.length < 7) return '' - return `${phone.slice(0, 3)}****${phone.slice(-4)}` -} - -function isInfoComplete(user = {}) { - return Boolean(user.users_name && user.users_phone && user.users_picture) -} - -function isAllProfileFieldsEmpty(user = {}) { - return !user.users_name && !user.users_phone && !user.users_picture -} - -async function withUserLock(lockKey, handler) { - const previous = userMutationLocks.get(lockKey) || Promise.resolve() - let release - const current = new Promise((resolve) => { - release = resolve - }) - - userMutationLocks.set(lockKey, previous.then(() => current)) - - await previous - - try { - return await handler() - } finally { - release() - if (userMutationLocks.get(lockKey) === current) { - userMutationLocks.delete(lockKey) - } - } -} - -async function authenticateWechatUser(payload) { - const openid = await wechatService.getWxOpenId(payload.users_wx_code) - return withUserLock(`auth:${openid}`, async () => { - const existing = await findUserByOpenid(openid) - - if (existing) { - logger.warn('微信注册命中已存在账号', { - users_wx_openid: openid, - users_type: existing.users_type || GUEST_USER_TYPE, - }) - - const user = await enrichUser(existing) - const token = jwtService.signAccessToken({ - users_id: user.users_id, - users_wx_openid: user.users_wx_openid, - }) - - return { - status: 'login_success', - is_info_complete: isInfoComplete(existing), - token, - user, - } - } - - const created = await pocketbaseService.createUser({ - users_id: buildUserId(), - users_wx_openid: openid, - users_type: GUEST_USER_TYPE, - }) - - const user = await enrichUser(created) - const token = jwtService.signAccessToken({ - users_id: user.users_id, - users_wx_openid: user.users_wx_openid, - }) - - logger.info('微信用户注册成功', { - users_id: user.users_id, - users_phone: user.users_phone, - users_type: user.users_type, - }) - - return { - status: 'register_success', - is_info_complete: false, - token, - user, - } - }) -} - -async function updateWechatUserProfile(usersWxOpenid, payload) { - return withUserLock(`profile:${usersWxOpenid}`, async () => { - const currentUser = await findUserByOpenid(usersWxOpenid) - - if (!currentUser) { - throw new AppError('未找到待编辑的用户', 404) - } - - const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code) - - if (usersPhone && usersPhone !== currentUser.users_phone) { - const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`) - const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id) - if (phoneUsedByOther) { - throw new AppError('手机号已被注册', 400) - } - } - - const shouldPromoteUserType = - isAllProfileFieldsEmpty(currentUser) - && payload.users_name - && usersPhone - && payload.users_picture - && (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type) - - const updatePayload = { - users_name: payload.users_name, - users_phone: usersPhone, - users_picture: payload.users_picture, - } - - if (shouldPromoteUserType) { - updatePayload.users_type = REGISTERED_USER_TYPE - } - - const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload) - const user = await enrichUser(updated) - - logger.info('微信用户资料更新成功', { - users_id: user.users_id, - users_phone: user.users_phone, - users_type_before: currentUser.users_type || GUEST_USER_TYPE, - users_type_after: user.users_type, - users_type_promoted: shouldPromoteUserType, - }) - - return { - status: 'update_success', - user, - } - }) -} - -async function refreshWechatToken(usersWxOpenid) { - const userRecord = await findUserByOpenid(usersWxOpenid) - - if (!userRecord) { - throw new AppError('未注册用户', 404) - } - - const token = jwtService.signAccessToken({ - users_id: userRecord.users_id, - users_wx_openid: userRecord.users_wx_openid, - }) - - logger.info('微信用户刷新令牌成功', { - users_id: userRecord.users_id, - users_wx_openid: userRecord.users_wx_openid, - }) - - return { - token, - } -} - -module.exports = { - authenticateWechatUser, - updateWechatUserProfile, - refreshWechatToken, -} diff --git a/back-end/src/services/wechatService.js b/back-end/src/services/wechatService.js deleted file mode 100644 index 85ba310..0000000 --- a/back-end/src/services/wechatService.js +++ /dev/null @@ -1,117 +0,0 @@ -const axios = require('axios') -const env = require('../config/env') -const AppError = require('../utils/appError') - -let accessTokenCache = { - token: '', - expiresAt: 0, -} - -async function getWxOpenId(code) { - if (!env.wechatAppId || !env.wechatSecret) { - throw new AppError('微信小程序配置缺失', 500) - } - - try { - const url = 'https://api.weixin.qq.com/sns/jscode2session' - const response = await axios.get(url, { - params: { - appid: env.wechatAppId, - secret: env.wechatSecret, - js_code: code, - grant_type: 'authorization_code', - }, - timeout: 10000, - }) - - const data = response.data || {} - - if (data.openid) { - return data.openid - } - - if (data.errcode || data.errmsg) { - throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } - - throw new AppError('获取OpenID失败: 响应中未包含openid', 502) - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502) - } -} - -async function getWechatAccessToken() { - if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) { - return accessTokenCache.token - } - - try { - const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', { - params: { - grant_type: 'client_credential', - appid: env.wechatAppId, - secret: env.wechatSecret, - }, - timeout: 10000, - }) - - const data = response.data || {} - - if (!data.access_token) { - throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } - - accessTokenCache = { - token: data.access_token, - expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000, - } - - return accessTokenCache.token - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502) - } -} - -async function getWxPhoneNumber(phoneCode) { - const accessToken = await getWechatAccessToken() - - try { - const response = await axios.post( - `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`, - { - code: phoneCode, - }, - { - headers: { - 'Content-Type': 'application/json', - }, - timeout: 10000, - } - ) - - const data = response.data || {} - const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber - - if (phone) { - return phone - } - - throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, { - wechat: data, - }) - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502) - } -} - -module.exports = { - getWxOpenId, - getWxPhoneNumber, -} diff --git a/back-end/src/utils/appError.js b/back-end/src/utils/appError.js deleted file mode 100644 index 5cfa9d5..0000000 --- a/back-end/src/utils/appError.js +++ /dev/null @@ -1,10 +0,0 @@ -class AppError extends Error { - constructor(message, statusCode = 500, details = {}) { - super(message) - this.name = 'AppError' - this.statusCode = statusCode - this.details = details - } -} - -module.exports = AppError diff --git a/back-end/src/utils/asyncHandler.js b/back-end/src/utils/asyncHandler.js deleted file mode 100644 index 10bd82e..0000000 --- a/back-end/src/utils/asyncHandler.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = function asyncHandler(fn) { - return function wrappedHandler(req, res, next) { - Promise.resolve(fn(req, res, next)).catch(next) - } -} diff --git a/back-end/src/utils/buildInfo.js b/back-end/src/utils/buildInfo.js deleted file mode 100644 index fecbbb0..0000000 --- a/back-end/src/utils/buildInfo.js +++ /dev/null @@ -1,30 +0,0 @@ -const fs = require('fs') -const path = require('path') - -function getBuildInfoFilePath() { - return path.resolve(__dirname, '..', '..', 'build-info.json') -} - -function getBuildTimestamp() { - if (process.env.BUILD_TIME) { - return process.env.BUILD_TIME - } - - const filePath = getBuildInfoFilePath() - - if (!fs.existsSync(filePath)) { - return null - } - - try { - const content = fs.readFileSync(filePath, 'utf8') - const payload = JSON.parse(content) - return payload.buildTime || null - } catch { - return null - } -} - -module.exports = { - getBuildTimestamp, -} \ No newline at end of file diff --git a/back-end/src/utils/sanitize.js b/back-end/src/utils/sanitize.js deleted file mode 100644 index 8ed5012..0000000 --- a/back-end/src/utils/sanitize.js +++ /dev/null @@ -1,17 +0,0 @@ -function sanitizeString(value) { - if (typeof value !== 'string') return '' - return value.replace(/[<>\\]/g, '').trim() -} - -function sanitizePayload(payload = {}) { - return Object.keys(payload).reduce((acc, key) => { - const value = payload[key] - acc[key] = typeof value === 'string' ? sanitizeString(value) : value - return acc - }, {}) -} - -module.exports = { - sanitizeString, - sanitizePayload, -} diff --git a/back-end/tests/integration/wechat.test.js b/back-end/tests/integration/wechat.test.js index a4b794d..9331e62 100644 --- a/back-end/tests/integration/wechat.test.js +++ b/back-end/tests/integration/wechat.test.js @@ -1,14 +1,7 @@ const test = require('node:test') const assert = require('node:assert/strict') const request = require('supertest') -const fs = require('fs') -const path = require('path') - -const env = require('../../src/config/env') const { createApp } = require('../../src/index') -const userService = require('../../src/services/userService') - -const buildInfoPath = path.resolve(__dirname, '..', '..', 'build-info.json') test('POST /api/health 仍返回统一结构', async () => { const app = createApp() @@ -20,47 +13,6 @@ test('POST /api/health 仍返回统一结构', async () => { assert.equal(response.body.data.status, 'healthy') }) -test('POST /api/test-helloworld 返回统一结构和构建时间', async (t) => { - const app = createApp() - const buildTime = '2026-03-20T08:00:00.000Z' - - fs.writeFileSync(buildInfoPath, JSON.stringify({ buildTime }, null, 2)) - - t.after(() => { - if (fs.existsSync(buildInfoPath)) { - fs.rmSync(buildInfoPath) - } - }) - - const response = await request(app).post('/api/test-helloworld').send({}) - - assert.equal(response.status, 200) - assert.equal(response.body.code, 200) - assert.equal(response.body.msg, '请求成功') - assert.equal(response.body.data.message, 'Hello, World!') - assert.equal(response.body.data.build_time, buildTime) -}) - -test('GET /api/test-helloworld 返回 404', async () => { - const app = createApp() - const response = await request(app).get('/api/test-helloworld') - - assert.equal(response.status, 404) - assert.equal(response.body.code, 404) - assert.equal(response.body.msg, 'Route not found') -}) - -test('POST /api/test-helloworld 返回统一结构', async () => { - const app = createApp() - const response = await request(app).post('/api/test-helloworld').send({}) - - assert.equal(response.status, 200) - assert.equal(response.body.code, 200) - assert.equal(response.body.msg, '请求成功') - assert.equal(response.body.data.message, 'Hello, World!') - assert.ok(Object.prototype.hasOwnProperty.call(response.body.data, 'build_time')) -}) - test('未匹配路由返回统一 404', async () => { const app = createApp() const response = await request(app).get('/not-found-route') @@ -70,154 +22,3 @@ test('未匹配路由返回统一 404', async () => { assert.equal(response.body.msg, 'Route not found') assert.equal(response.body.data.path, '/not-found-route') }) - -test('POST /api/wechat/login 未注册用户返回 404', async (t) => { - const origin = userService.authenticateWechatUser - userService.authenticateWechatUser = async () => { - const error = new Error('未注册用户') - error.statusCode = 404 - throw error - } - - t.after(() => { - userService.authenticateWechatUser = origin - }) - - const app = createApp() - const response = await request(app) - .post('/api/wechat/login') - .send({ users_wx_code: 'mock-code-success' }) - - assert.equal(response.status, 404) - assert.equal(response.body.code, 404) - assert.equal(response.body.msg, '未注册用户') -}) - -test('POST /api/wechat/login 非 JSON 请求体返回 415', async () => { - const app = createApp() - const response = await request(app) - .post('/api/wechat/login') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send('users_wx_code=mock-code') - - assert.equal(response.status, 415) - assert.equal(response.body.code, 415) - assert.equal(response.body.msg, '请求体必须为 application/json') -}) - -test('POST /api/wechat/login 成功时返回 is_info_complete', async (t) => { - const origin = userService.authenticateWechatUser - userService.authenticateWechatUser = async () => ({ - is_info_complete: true, - status: 'login_success', - token: 'mock-token', - user: { - users_id: 'U1003', - users_type: '注册用户', - users_name: '李四', - }, - }) - - t.after(() => { - userService.authenticateWechatUser = origin - }) - - const app = createApp() - const response = await request(app) - .post('/api/wechat/login') - .send({ users_wx_code: 'mock-code' }) - - assert.equal(response.status, 200) - assert.equal(response.body.code, 200) - assert.equal(response.body.msg, '登录成功') - assert.equal(response.body.data.is_info_complete, true) - assert.equal(response.body.data.user.users_type, '注册用户') -}) - -test('POST /api/wechat/profile 更新成功后返回脱敏手机号', async (t) => { - const origin = userService.updateWechatUserProfile - userService.updateWechatUserProfile = async () => ({ - status: 'update_success', - user: { - users_id: 'U1002', - users_type: '注册用户', - users_name: '张三', - users_phone: '13800138000', - users_phone_masked: '138****8000', - users_picture: 'https://example.com/a.png', - }, - }) - - t.after(() => { - userService.updateWechatUserProfile = origin - }) - - const token = require('jsonwebtoken').sign({ users_id: 'U1002', users_wx_openid: 'openid-1' }, env.jwtSecret, { - expiresIn: env.jwtExpiresIn, - }) - - const app = createApp() - const response = await request(app) - .post('/api/wechat/profile') - .set('users_wx_openid', 'openid-1') - .set('Authorization', `Bearer ${token}`) - .send({ - users_name: '张三', - users_phone_code: 'phone-code', - users_picture: 'https://example.com/a.png', - }) - - assert.equal(response.status, 200) - assert.equal(response.body.code, 200) - assert.equal(response.body.msg, '信息更新成功') - assert.equal(response.body.data.user.users_phone_masked, '138****8000') - assert.equal(response.body.data.user.users_type, '注册用户') -}) - -test('POST /api/wechat/profile 缺少 token 返回 401', async () => { - const app = createApp() - const response = await request(app) - .post('/api/wechat/profile') - .set('users_wx_openid', 'openid-1') - .send({ - users_name: '张三', - users_phone_code: 'phone-code', - users_picture: 'https://example.com/a.png', - }) - - assert.equal(response.status, 401) - assert.equal(response.body.code, 401) -}) - -test('POST /api/wechat/refresh-token 返回 token', async (t) => { - const origin = userService.refreshWechatToken - userService.refreshWechatToken = async () => ({ - token: 'new-token', - }) - - t.after(() => { - userService.refreshWechatToken = origin - }) - - const app = createApp() - const response = await request(app) - .post('/api/wechat/refresh-token') - .set('users_wx_openid', 'openid-1') - .send({}) - - assert.equal(response.status, 200) - assert.equal(response.body.code, 200) - assert.equal(response.body.msg, '刷新成功') - assert.equal(response.body.data.token, 'new-token') -}) - -test('POST /api/wechat/refresh-token 缺少 users_wx_openid 返回 401', async () => { - const app = createApp() - const response = await request(app) - .post('/api/wechat/refresh-token') - .send({}) - - assert.equal(response.status, 401) - assert.equal(response.body.code, 401) - assert.equal(response.body.msg, '请求头缺少 users_wx_openid') -}) diff --git a/back-end/tests/userService.test.js b/back-end/tests/userService.test.js deleted file mode 100644 index eb5dbe1..0000000 --- a/back-end/tests/userService.test.js +++ /dev/null @@ -1,181 +0,0 @@ -const test = require('node:test') -const assert = require('node:assert/strict') - -const wechatService = require('../src/services/wechatService') -const pocketbaseService = require('../src/services/pocketbaseService') -const jwtService = require('../src/services/jwtService') -const userService = require('../src/services/userService') - -test('authenticateWechatUser 首次注册默认写入游客类型', async (t) => { - const originGetWxOpenId = wechatService.getWxOpenId - const originListUsersByFilter = pocketbaseService.listUsersByFilter - const originCreateUser = pocketbaseService.createUser - const originSignAccessToken = jwtService.signAccessToken - const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId - - wechatService.getWxOpenId = async () => 'openid-register' - pocketbaseService.listUsersByFilter = async () => [] - pocketbaseService.createUser = async (payload) => ({ - id: 'pb-1', - created: '2026-03-19T00:00:00Z', - updated: '2026-03-19T00:00:00Z', - ...payload, - }) - pocketbaseService.getCompanyByCompanyId = async () => null - jwtService.signAccessToken = () => 'token-1' - - t.after(() => { - wechatService.getWxOpenId = originGetWxOpenId - pocketbaseService.listUsersByFilter = originListUsersByFilter - pocketbaseService.createUser = originCreateUser - pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId - jwtService.signAccessToken = originSignAccessToken - }) - - const result = await userService.authenticateWechatUser({ - users_wx_code: 'code-1', - }) - - assert.equal(result.status, 'register_success') - assert.equal(result.is_info_complete, false) - assert.equal(result.user.users_type, '游客') -}) - -test('updateWechatUserProfile 首次完整补充资料时升级为注册用户', async (t) => { - const originGetWxOpenId = wechatService.getWxOpenId - const originGetWxPhoneNumber = wechatService.getWxPhoneNumber - const originListUsersByFilter = pocketbaseService.listUsersByFilter - const originUpdateUser = pocketbaseService.updateUser - const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId - - wechatService.getWxOpenId = async () => 'openid-profile' - wechatService.getWxPhoneNumber = async () => '13800138000' - pocketbaseService.listUsersByFilter = async (filter) => { - if (filter.includes('users_wx_openid')) { - return [ - { - id: 'pb-2', - users_id: 'U2001', - users_type: '游客', - users_name: '', - users_phone: '', - users_picture: '', - users_wx_openid: 'openid-profile', - created: '2026-03-19T00:00:00Z', - updated: '2026-03-19T00:00:00Z', - }, - ] - } - return [] - } - pocketbaseService.updateUser = async (_id, payload) => ({ - id: 'pb-2', - users_id: 'U2001', - users_wx_openid: 'openid-profile', - users_type: payload.users_type || '游客', - users_name: payload.users_name, - users_phone: payload.users_phone, - users_picture: payload.users_picture, - created: '2026-03-19T00:00:00Z', - updated: '2026-03-19T00:10:00Z', - }) - pocketbaseService.getCompanyByCompanyId = async () => null - - t.after(() => { - wechatService.getWxOpenId = originGetWxOpenId - wechatService.getWxPhoneNumber = originGetWxPhoneNumber - pocketbaseService.listUsersByFilter = originListUsersByFilter - pocketbaseService.updateUser = originUpdateUser - pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId - }) - - const result = await userService.updateWechatUserProfile('openid-profile', { - users_name: '张三', - users_phone_code: 'phone-code-1', - users_picture: 'https://example.com/a.png', - }) - - assert.equal(result.status, 'update_success') - assert.equal(result.user.users_type, '注册用户') - assert.equal(result.user.users_phone_masked, '138****8000') -}) - -test('updateWechatUserProfile 非首次补充资料时不覆盖已确定类型', async (t) => { - const originGetWxOpenId = wechatService.getWxOpenId - const originGetWxPhoneNumber = wechatService.getWxPhoneNumber - const originListUsersByFilter = pocketbaseService.listUsersByFilter - const originUpdateUser = pocketbaseService.updateUser - const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId - - wechatService.getWxOpenId = async () => 'openid-registered' - wechatService.getWxPhoneNumber = async () => '13900139000' - pocketbaseService.listUsersByFilter = async (filter) => { - if (filter.includes('users_wx_openid')) { - return [ - { - id: 'pb-3', - users_id: 'U2002', - users_type: '注册用户', - users_name: '老用户', - users_phone: '13800138000', - users_picture: 'https://example.com/old.png', - users_wx_openid: 'openid-registered', - created: '2026-03-19T00:00:00Z', - updated: '2026-03-19T00:00:00Z', - }, - ] - } - return [] - } - pocketbaseService.updateUser = async (_id, payload) => ({ - id: 'pb-3', - users_id: 'U2002', - users_wx_openid: 'openid-registered', - users_type: '注册用户', - users_name: payload.users_name, - users_phone: payload.users_phone, - users_picture: payload.users_picture, - created: '2026-03-19T00:00:00Z', - updated: '2026-03-19T00:10:00Z', - }) - pocketbaseService.getCompanyByCompanyId = async () => null - - t.after(() => { - wechatService.getWxOpenId = originGetWxOpenId - wechatService.getWxPhoneNumber = originGetWxPhoneNumber - pocketbaseService.listUsersByFilter = originListUsersByFilter - pocketbaseService.updateUser = originUpdateUser - pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId - }) - - const result = await userService.updateWechatUserProfile('openid-registered', { - users_name: '新名字', - users_phone_code: 'phone-code-2', - users_picture: 'https://example.com/new.png', - }) - - assert.equal(result.user.users_type, '注册用户') -}) - -test('refreshWechatToken 返回新的 token', async (t) => { - const originListUsersByFilter = pocketbaseService.listUsersByFilter - const originSignAccessToken = jwtService.signAccessToken - - pocketbaseService.listUsersByFilter = async () => [ - { - id: 'pb-4', - users_id: 'U3001', - users_wx_openid: 'openid-refresh', - }, - ] - jwtService.signAccessToken = () => 'refresh-token' - - t.after(() => { - pocketbaseService.listUsersByFilter = originListUsersByFilter - jwtService.signAccessToken = originSignAccessToken - }) - - const result = await userService.refreshWechatToken('openid-refresh') - - assert.equal(result.token, 'refresh-token') -}) diff --git a/docs/ARCHIVE.md b/docs/ARCHIVE.md index 8d19464..ec5f8a5 100644 --- a/docs/ARCHIVE.md +++ b/docs/ARCHIVE.md @@ -2,6 +2,120 @@ ## 归档日期 +- 2026-03-23 + +## 归档范围 + +本次归档覆盖 PocketBase hooks 项目在微信登录注册、PocketBase 原生 token、openid 身份收敛、错误观测、索引修复与 auth 兼容字段方面的修复与规范同步,涉及: + +- `pocket-base/` 作为正式 hooks 项目继续收敛规范 +- 微信登录链路错误显式返回 +- `recordAuthResponse` 使用空 `authMethod` +- 登录/资料更新阶段 auth 保存失败信息透传 +- 移除 hooks 查询中的 `-created` 排序,修复 `invalid sort field "created"` +- `users_phone` 唯一索引改普通索引,允许空手机号用户注册 +- `tbl_auth_users` 继续以 `openid` 作为业务身份锚点 +- 为 PocketBase `auth` 集合兼容写入占位 `email`、随机密码与 `passwordConfirm` +- OpenSpec 变更文档补录到 `pocket-base/spec/` + +--- + +## 一、接口与认证结果 + +### 当前 active hooks 接口 + +- `POST /api/system/test-helloworld` +- `POST /api/system/health` +- `POST /api/wechat/login` +- `POST /api/wechat/profile` +- `POST /api/wechat/refresh-token` + +### 当前认证规则 + +- 正式鉴权仅使用 `Authorization: Bearer ` +- `Open-Authorization` 不属于接口契约 +- `users_wx_openid` Header 已移除 +- 业务身份由当前 auth record 的 `openid` 唯一确定 + +--- + +## 二、数据模型与落库策略 + +### 1. `tbl_auth_users` + +- 维持 PocketBase `auth` 集合 +- 业务身份锚点为 `openid` +- 目标规则:除 `openid` 外,自定义业务字段均允许为空 + +### 2. auth 集合兼容字段 + +由于 `tbl_auth_users` 是 auth 集合,为满足 PocketBase 原生 auth 保存要求,登录创建阶段补充: + +- `email = @wechat.local` +- 随机密码 +- `passwordConfirm` + +说明: + +- 上述 `email` 为占位认证标识,不代表真实邮箱。 + +--- + +## 三、问题修复归档 + +### 1. 通用 400 `Something went wrong while processing your request.` + +处理方式: + +- 登录路由本地 try/catch 显式返回 `{ code, msg, data }` +- 全局错误包装提前注册 +- 保存 auth 用户时透传原始错误信息 + +### 2. `invalid sort field "created"` + +原因: + +- hooks 内多个精确查询误用了 `-created` 排序 + +处理方式: + +- 统一移除 active hooks 中所有 `-created` 排序 + +### 3. 注册成功前数据库无新记录 + +已识别的高风险点: + +- `users_phone` 唯一索引会让空手机号重复冲突 + +处理方式: + +- 改为普通索引 +- 手机号唯一性改由资料完善阶段业务校验负责 + +--- + +## 四、规范同步位置 + +本次 OpenSpec 记录新增: + +- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md` + +当前 active 契约文件: + +- `pocket-base/spec/openapi.yaml` + +--- + +## 五、当前边界 + +1. `tbl_auth_users` 作为 PocketBase `auth` 集合,仍受 PocketBase 内置 auth 规则影响。 +2. schema 脚本放宽自定义字段必填约束时,PocketBase 服务端更新 auth 集合可能返回通用 500,需要结合服务端日志进一步确认。 +3. 若线上仍报旧错误,通常表示最新 hooks 或 schema 尚未部署生效。 + +--- + +## 归档日期 + - 2026-03-20 ## 归档范围 diff --git a/docs/api.md b/docs/api.md index 83137b1..420ddfe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -104,11 +104,11 @@ | 参数名 | 类型 | 必填 | 说明 | |---|---|---|---| -| `users_wx_code` | string | 是 | 微信小程序登录临时凭证 code,用于换取 `users_wx_openid` | +| `users_wx_code` | string | 是 | 微信小程序登录临时凭证 code,用于换取 `openid` | ### 处理逻辑 -- 使用 `users_wx_code` 向微信服务端换取 `users_wx_openid` +- 使用 `users_wx_code` 向微信服务端换取 `openid` - 如果数据库中不存在该用户,则自动创建新账号: - 初始化 `users_type = 游客` - 如果数据库中已存在该用户,则直接登录 @@ -135,7 +135,7 @@ "users_phone": "13800138000", "users_phone_masked": "138****8000", "users_picture": "https://example.com/avatar.png", - "users_wx_openid": "oAbCdEfGh123456789", + "openid": "oAbCdEfGh123456789", "company_id": "C10001", "company": null, "pb_id": "abc123xyz", @@ -154,7 +154,6 @@ - **请求方式**:`POST` - **请求头**: - `Content-Type: application/json` - - `users_wx_openid: 微信用户唯一标识` - `Authorization: Bearer ` ### 请求参数 @@ -177,7 +176,7 @@ ### 处理逻辑 -- 从请求头 `users_wx_openid` 读取当前用户身份 +- 从 `Authorization` 对应的 PocketBase auth record 读取当前用户 `openid` - 校验 `Authorization` - 不再从 body 读取 `users_wx_code` - 使用 `users_phone_code` 调微信官方接口换取真实手机号 @@ -200,7 +199,7 @@ "users_phone": "13800138000", "users_phone_masked": "138****8000", "users_picture": "https://example.com/avatar.png", - "users_wx_openid": "oAbCdEfGh123456789", + "openid": "oAbCdEfGh123456789", "company_id": "", "company": null, "pb_id": "abc123xyz", @@ -218,7 +217,7 @@ - **接口地址**:`/wechat/refresh-token` - **请求方式**:`POST` - **请求头**: - - `users_wx_openid: 微信用户唯一标识` + - `Authorization: Bearer ` > 说明:本接口**不要求旧 `Authorization`**。 @@ -228,7 +227,7 @@ ### 处理逻辑 -- 仅通过请求头中的 `users_wx_openid` 定位用户 +- 仅通过当前 `Authorization` 对应认证用户定位身份 - 若用户存在,则签发新的 JWT token - 若用户不存在,则返回 `404` @@ -279,7 +278,7 @@ Authorization: Bearer ### 3. `refresh-token` 接口当前只需要: ```http -users_wx_openid: +Authorization: Bearer ``` 不需要旧 `Authorization`。 diff --git a/docs/newpb.md b/docs/newpb.md new file mode 100644 index 0000000..ca76395 --- /dev/null +++ b/docs/newpb.md @@ -0,0 +1,95 @@ +针对你的复杂权限需求,建议采用 **“RBAC(基于角色的访问控制)+ ABAC(基于属性的访问控制)+ 个性化覆盖(Overrides)”** 的混合模式。这种结构能支持从“大颗粒度角色”到“极细颗粒度字段/行”的动态权限矩阵。 + +以下是为你设计的实施方案及关键表结构规划: + +### 一、 核心表方案设计 + +为了实现“字段级”和“行级”的动态控制,我们需要将 **资源(Resource)**、**权限定义(Permission)**、**角色(Role)** 与 **用户(User)** 彻底解耦。 + +#### 1. 用户基础表:`tbl_auth_users` +作为全局身份锚点,记录用户的静态信息和动态属性(部门、等级)。 + +| 字段名 | 类型 | 说明 | +| :--- | :--- | :--- | +| **user_id** | BigInt (PK) | 内部全局唯一 ID | +| **openid** | String (Unique) | **全局身份锚点**,微信唯一标识 | +| **user_name** | String | 姓名/昵称 | +| **org_id** | Int | 所属组织/部门 ID(影响行级权限的关键属性) | +| **rank_level** | Int | 职级/等级(影响动态权限矩阵的关键属性) | +| **status** | Int | 账户状态 (1: 正常, 0: 禁用) | +| **user_type** | Int | 账户类型 (0: 微信小程序,1: 管理平台,2: 其他) | + +--- + +#### 2. 资源定义表:`tbl_auth_resources` +定义系统中哪些表、哪些字段属于受控资源。 + +| 字段名 | 类型 | 说明 | +| :--- | :--- | :--- | +| **res_id** | Int (PK) | 资源 ID | +| **table_name** | String | 数据库表名 | +| **column_name** | String | 字段名(如果是表级权限,此项可为空或设为 '*') | +| **res_type** | Enum | 资源类型:`TABLE`(行/全表), `COLUMN`(字段级) | + +--- + +#### 3. 角色与基础权限:`tbl_auth_roles` & `tbl_auth_role_perms` +实现通用的权限模板,方便批量管理。 + +* **`tbl_auth_roles`**: 角色表(如:财务经理、普通销售)。 +* **`tbl_auth_role_perms`**: 角色权限关联表,定义该角色对某个资源的操作(读/写/无)。 + +--- + +#### 4. **核心:个性化权限覆盖表 `tbl_auth_user_overrides`** +这是满足你“某个用户对某个字段单独设置”需求的关键。 + +| 字段名 | 类型 | 说明 | +| :--- | :--- | :--- | +| **id** | BigInt (PK) | 自增 ID | +| **user_id** | BigInt | 用户 ID(关联 `tbl_auth_users`) | +| **res_id** | Int | 资源 ID(关联 `tbl_auth_resources`) | +| **access_level** | Int | 权限值 (0: 无权, 1: 只读, 2: 读写) | +| **priority** | Int | 优先级(当角色权限与个人设置冲突时,以此为准) | + +--- + +#### 5. **核心:行级过滤策略表 `tbl_auth_row_scopes`** +实现“某个用户只能看自己部门/某几个项目”的动态逻辑。 + +| 字段名 | 类型 | 说明 | +| :--- | :--- | :--- | +| **id** | Int (PK) | 策略 ID | +| **target_type** | Enum | 目标:`USER` 或 `ROLE` | +| **target_id** | BigInt | 对应的 UserID 或 RoleID | +| **table_name** | String | 作用的表名 | +| **filter_sql** | String | 过滤逻辑。例如:`dept_id = {user.org_id}` 或 `creator_id = {user.user_id}` | + +--- + +### 二、 权限实施方案逻辑 + +你的权限矩阵页面将由以下逻辑驱动: + +#### 1. 权限计算路径 (Effective Permissions) +当用户访问某个数据时,系统按照以下顺序合并权限: +1. **取基础属性**:获取用户的 `org_id` 和 `rank_level`。 +2. **取角色权限**:获取该用户所属角色对应的资源权限列表。 +3. **应用个性化覆盖**:查询 `tbl_auth_user_overrides`。如果该表中有记录,则**覆盖**(或叠加)角色权限。 +4. **注入行级过滤**:如果是查询操作,解析 `tbl_auth_row_scopes` 中的 `filter_sql`,将 `{user.xxx}` 变量替换为当前用户的真实值。 + +#### 2. 动态更新机制 +* **组织/等级变更**:当 `tbl_auth_users` 中的 `org_id` 或 `rank_level` 变化时,由于行级过滤表(`tbl_auth_row_scopes`)引用的是动态变量,**权限会自动生效**,无需重新授权。 +* **缓存策略**:建议将计算后的“最终权限清单”缓存到 Redis。当用户在后台矩阵页面修改权限,或者发生组织架构调整时,通过 `wx_openid` **主动失效(Purge)** 该用户的 Redis 缓存。 + +#### 3. 字段级权限实现 (Field-Level) +在接口层(中间件),根据计算出的字段级权限清单(即用户对该 Table 下哪些 Column 有读权),动态过滤返回的 JSON 结构。 + +### 三、 总结:你需要几张表? + +为了实现你描述的系统,最精简需要 **5 张表**: +1. **`tbl_auth_users`**:用户主体(含 OpenID、部门、等级)。 +2. **`tbl_auth_resources`**:资源清单(表名、字段名)。 +3. **`tbl_auth_roles`**:角色定义。 +4. **`tbl_auth_role_perms`** / **`tbl_auth_user_overrides`**:权限映射(解决字段级和个人特权)。 +5. **`tbl_auth_row_scopes`**:行级过滤表达式(解决多维数据隔离)。 diff --git a/pocket-base/README.md b/pocket-base/README.md new file mode 100644 index 0000000..d3c6856 --- /dev/null +++ b/pocket-base/README.md @@ -0,0 +1,139 @@ +# PocketBase Hooks API Project + +这是从 `back-end/` 迁移出来的 PocketBase `bai_api_pb_hooks` 原生 API 项目。 + +## 目标 + +- 使用 PocketBase 自定义路由承载对外 API +- 最终部署时只复制 `bai_api_pb_hooks/` 整个目录到服务器的 PocketBase 实例目录 +- 按接口类型拆分不同子目录,便于维护 + +## 目录结构 + +```text +pocket-base/ +├─ bai-api-main.pb.js +├─ bai_api_pb_hooks/ +│ ├─ bai_api_routes/ +│ │ ├─ system/ +│ │ └─ wechat/ +│ └─ bai_api_shared/ +│ ├─ config/ +│ ├─ middlewares/ +│ ├─ services/ +│ └─ utils/ +├─ spec/ +├─ tests/ +└─ scripts/ +``` + +## 当前接口 + +- `POST /api/system/test-helloworld` +- `POST /api/system/health` +- `POST /api/wechat/login` +- `POST /api/wechat/profile` +- `POST /api/wechat/refresh-token` + +> 当前自定义路由统一使用 `/api/...` 前缀。 + +## 鉴权说明 + +- 当前接口统一使用 PocketBase 原生 `Authorization: Bearer `。 +- `Open-Authorization` 不是本项目接口定义的 Header,如调试工具里出现,通常是工具全局预设,应删除。 +- `users_wx_openid` Header 已移除,不再需要客户端额外传递。 +- 当前用户身份以 PocketBase auth record 中的 `openid` 字段为准。 + +## 部署方式 + +由于服务器只有一个公共 `pb_hooks/` 目录,部署时请将以下内容复制到服务器 `pb_hooks/` 根目录下: + +- `bai-api-main.pb.js` +- `bai_api_pb_hooks/` + +建议部署后的结构类似: + +```text +pb_hooks/ +├─ bai-api-main.pb.js +└─ bai_api_pb_hooks/ + ├─ bai_api_routes/ + └─ bai_api_shared/ +``` + +## 环境配置 + +PocketBase JSVM 不会自动读取 `back-end/.env`。当前 Hook 运行配置来自 **PocketBase 进程环境变量**。 + +已补充模板文件: + +- `pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.example` +- `pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js` + +至少需要在服务器的 PocketBase 运行环境中提供,或写入 `runtime.js`: + +- `WECHAT_APPID` +- `WECHAT_SECRET` + +可选保留: + +- `APP_BASE_URL` +- `POCKETBASE_API_URL` +- `POCKETBASE_AUTH_TOKEN` + +说明: + +- `WECHAT_APPID` / `WECHAT_SECRET` 是必须的,因为微信登录逻辑会直接调用微信官方接口。 +- 旧 `back-end/.env` 中的这些值可以作为来源参考,但 **不会被 pb_hooks 自动读取**。 +- 如果不方便改 PocketBase 进程环境,可在服务器创建:`pb_hooks/bai_api_pb_hooks/bai_api_shared/config/runtime.js`。 +- `runtime.js` 的内容可直接参考 `runtime.example.js`。 +- 当前 Hook 代码已不依赖自定义 JWT,因此 `JWT_SECRET`、`JWT_EXPIRES_IN` 不是运行必需项。 + +## 额外要求 + +部署前请确认 PocketBase 所在环境提供以下环境变量: + +- `WECHAT_APPID` +- `WECHAT_SECRET` +如果你还需要构建时间展示,可额外提供: + +- `BUILD_TIME` + +## 注意 + +PocketBase JSVM 不是 Node.js 运行时: + +- 不能直接复用 axios/jsonwebtoken/express 中间件 +- 只能使用 PocketBase 暴露的全局对象,如 `$app`、`$apis`、`$http`、`$security` +- 共享逻辑必须通过 `require(`${__hooks}/...`)` 方式加载本地 CommonJS 模块 + +## 建议验证 + +迁移完成后,在 PocketBase 服务器上检查: + +1. 自定义路由是否生效 +2. `tbl_auth_users`、`tbl_company` 集合名是否与当前数据库一致 +3. PocketBase 所在服务器是否能访问微信开放接口 +4. 反向代理是否放行 `/api/` 路由 + +## OpenSpec 变更记录 + +本项目 active hooks 相关的最新规范记录见: + +- `pocket-base/spec/openapi.yaml` +- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md` + +本次变更重点包括: + +- 微信登录链路错误显式返回 +- `recordAuthResponse` 使用空 `authMethod` +- active hooks 中移除 `-created` 排序 +- `users_phone` 索引由唯一改为普通索引 +- `tbl_auth_users` 以 `openid` 为业务身份锚点 +- auth 集合兼容占位 `email`、随机密码与 `passwordConfirm` + +## 与原项目关系 + +- 原 `back-end/` 保留不动 +- 当前 `pocket-base/` 为新的正式 Hook 项目 +- 后续只部署 `bai_api_pb_hooks/` 的内部内容到服务器 `pb_hooks/` 即可 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js new file mode 100644 index 0000000..68e95da --- /dev/null +++ b/pocket-base/bai-api-main.pb.js @@ -0,0 +1,24 @@ +/// + +routerUse(function (e) { + try { + return e.next() + } catch (err) { + const status = + (err && typeof err.statusCode === 'number' && err.statusCode) + || (err && typeof err.status === 'number' && err.status) + || 500 + + return e.json(status, { + code: status, + msg: (err && err.message) || '服务器内部错误', + data: (err && err.data) || {}, + }) + } +}) + +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/hello.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/health.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js`) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js new file mode 100644 index 0000000..b7dc484 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js @@ -0,0 +1,8 @@ +routerAdd('POST', '/api/system/health', function (e) { + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + return success(e, '服务运行正常', { + status: 'healthy', + timestamp: new Date().toISOString(), + }) +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js new file mode 100644 index 0000000..2db4870 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js @@ -0,0 +1,11 @@ +routerAdd('POST', '/api/system/test-helloworld', function (e) { + const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + return success(e, '请求成功', { + message: 'Hello, World!', + timestamp: new Date().toISOString(), + status: 'success', + build_time: env.buildTime, + }) +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js new file mode 100644 index 0000000..68b2c3f --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js @@ -0,0 +1,33 @@ +routerAdd('POST', '/api/wechat/login', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + + try { + guards.requireJson(e) + guards.duplicateGuard(e) + + const payload = guards.validateLoginBody(e) + const data = userService.authenticateWechatUser(payload) + + $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) + return + } catch (err) { + const status = + (err && typeof err.statusCode === 'number' && err.statusCode) + || (err && typeof err.status === 'number' && err.status) + || 400 + + logger.error('微信登录失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + + return e.json(status, { + code: status, + msg: (err && err.message) || '微信登录失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js new file mode 100644 index 0000000..7459f6b --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js @@ -0,0 +1,14 @@ +routerAdd('POST', '/api/wechat/profile', function (e) { + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) + + guards.requireJson(e) + const authState = guards.requireWechatAuth(e) + guards.duplicateGuard(e) + + const payload = guards.validateProfileBody(e) + const data = userService.updateWechatUserProfile(authState.usersWxOpenid, payload) + + return success(e, '信息更新成功', data) +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js new file mode 100644 index 0000000..3aff8b4 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js @@ -0,0 +1,10 @@ +routerAdd('POST', '/api/wechat/refresh-token', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) + + const usersWxOpenid = guards.requireWechatOpenid(e) + const data = userService.refreshWechatToken(usersWxOpenid) + + $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) + return +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env new file mode 100644 index 0000000..d658c7c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env @@ -0,0 +1,12 @@ +NODE_ENV=production +APP_BASE_URL=https://bai-api.blv-oa.com +POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/ +POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo + +#正式服 +#WECHAT_APPID=wx3bd7a7b19679da7a +#WECHAT_SECRET=57e40438c2a9151257b1927674db10e1 + +#测试服 +WECHAT_APPID=wx42e9add0f91af98b +WECHAT_SECRET=5620f00b40297efaf3d197d61ae184d6 \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js new file mode 100644 index 0000000..b54acab --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js @@ -0,0 +1,29 @@ +let runtimeConfig = {} + +try { + runtimeConfig = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/runtime.js`) +} catch (_err) { + runtimeConfig = {} +} + +function pick(key, fallback) { + const envValue = $os.getenv(key) + if (envValue !== '') return envValue + + if (runtimeConfig && typeof runtimeConfig[key] !== 'undefined' && runtimeConfig[key] !== '') { + return runtimeConfig[key] + } + + return fallback +} + +module.exports = { + nodeEnv: pick('NODE_ENV', 'production'), + apiPrefix: '/api', + wechatAppId: pick('WECHAT_APPID', ''), + wechatSecret: pick('WECHAT_SECRET', ''), + pocketbaseApiUrl: pick('POCKETBASE_API_URL', ''), + pocketbaseAuthToken: pick('POCKETBASE_AUTH_TOKEN', ''), + appBaseUrl: pick('APP_BASE_URL', ''), + buildTime: pick('BUILD_TIME', null), +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js new file mode 100644 index 0000000..ea426f0 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js @@ -0,0 +1,11 @@ +module.exports = { + NODE_ENV: 'production', + APP_BASE_URL: 'https://bai-api.blv-oa.com', + POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', + POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo', +/* WECHAT_APPID: 'wx3bd7a7b19679da7a', + WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', */ + WECHAT_APPID: 'wx42e9add0f91af98b', + WECHAT_SECRET: '5620f00b40297efaf3d197d61ae184d6', + BUILD_TIME: '', +} \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js new file mode 100644 index 0000000..d5aea55 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js @@ -0,0 +1,11 @@ +module.exports = { + NODE_ENV: 'production', + APP_BASE_URL: 'https://bai-api.blv-oa.com', + POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', + POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo', + WECHAT_APPID: 'wx3bd7a7b19679da7a', + WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', + /* WECHAT_APPID: 'wx42e9add0f91af98b', + WECHAT_SECRET: '5620f00b40297efaf3d197d61ae184d6', */ + BUILD_TIME: '', +} \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js new file mode 100644 index 0000000..775c275 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js @@ -0,0 +1,98 @@ +const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) +const { sanitizePayload } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js`) + +const requestCache = {} +const WINDOW_MS = 5000 + +function parseBody(e) { + return sanitizePayload(e.requestInfo().body || {}) +} + +function requireJson(e) { + const contentType = e.request.header.get('Content-Type') || e.request.header.get('content-type') || '' + if (contentType.toLowerCase().indexOf('application/json') === -1) { + throw createAppError(415, '请求体必须为 application/json') + } +} + +function validateLoginBody(e) { + const payload = parseBody(e) + if (!payload.users_wx_code) { + throw createAppError(400, 'users_wx_code 为必填项') + } + return payload +} + +function validateProfileBody(e) { + const payload = parseBody(e) + if (!payload.users_name) throw createAppError(400, 'users_name 为必填项') + if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项') + if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项') + return payload +} + +function requireWechatOpenid(e) { + if (!e.auth) { + throw createAppError(401, '认证令牌无效或已过期') + } + + if (e.auth.collection().name !== 'tbl_auth_users') { + throw createAppError(401, '认证用户集合不正确') + } + + const openid = e.auth.getString('openid') + if (!openid) { + throw createAppError(401, '当前认证用户缺少 openid') + } + + return openid +} + +function requireWechatAuth(e) { + const authHeader = e.request.header.get('Authorization') || '' + const parts = authHeader.split(' ') + + if (parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) { + throw createAppError(401, '请求头缺少 Authorization') + } + + if (!e.auth) { + throw createAppError(401, '认证令牌无效或已过期') + } + + if (e.auth.collection().name !== 'tbl_auth_users') { + throw createAppError(401, '认证用户集合不正确') + } + + const authOpenid = e.auth.getString('openid') + if (!authOpenid) { + throw createAppError(401, '当前认证用户缺少 openid') + } + + return { + usersWxOpenid: authOpenid, + authRecord: e.auth, + } +} + +function duplicateGuard(e) { + const key = String(e.realIP()) + ':' + String(e.request.url.pathname) + ':' + JSON.stringify(e.requestInfo().body || {}) + const now = Date.now() + const lastTime = requestCache[key] + + if (lastTime && now - lastTime < WINDOW_MS) { + throw createAppError(429, '请求过于频繁,请稍后重试') + } + + requestCache[key] = now +} + +module.exports = { + parseBody, + requireJson, + validateLoginBody, + validateProfileBody, + requireWechatOpenid, + requireWechatAuth, + duplicateGuard, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js new file mode 100644 index 0000000..ae5796d --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js @@ -0,0 +1,264 @@ +const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) +const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) +const wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`) + +const GUEST_USER_TYPE = '游客' +const REGISTERED_USER_TYPE = '注册用户' +const mutationLocks = {} + +function buildUserId() { + const now = new Date() + const yyyy = String(now.getFullYear()) + const mm = String(now.getMonth() + 1) + const dd = String(now.getDate()) + const date = yyyy + (mm.length === 1 ? '0' + mm : mm) + (dd.length === 1 ? '0' + dd : dd) + const suffix = String(Math.floor(Math.random() * 9000) + 1000) + return 'U' + date + suffix +} + +function maskPhone(phone) { + const value = phone || '' + if (!value || value.length < 7) return '' + return value.slice(0, 3) + '****' + value.slice(-4) +} + +function isInfoComplete(record) { + return !!(record.getString('users_name') && record.getString('users_phone') && record.getString('users_picture')) +} + +function isAllProfileFieldsEmpty(record) { + return !record.getString('users_name') && !record.getString('users_phone') && !record.getString('users_picture') +} + +function withUserLock(lockKey, handler) { + if (mutationLocks[lockKey]) { + throw createAppError(429, '请求过于频繁,请稍后重试') + } + + mutationLocks[lockKey] = true + + try { + return handler() + } finally { + delete mutationLocks[lockKey] + } +} + +function findUserByOpenid(usersWxOpenid) { + const records = $app.findRecordsByFilter('tbl_auth_users', 'openid = {:openid}', '', 1, 0, { + openid: usersWxOpenid, + }) + return records.length ? records[0] : null +} + +function getCompanyByCompanyId(companyId) { + if (!companyId) return null + const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, { + companyId: companyId, + }) + return records.length ? records[0] : null +} + +function exportCompany(companyRecord) { + if (!companyRecord) return null + return companyRecord.publicExport() +} + +function enrichUser(userRecord) { + const companyId = userRecord.getString('company_id') + const companyRecord = getCompanyByCompanyId(companyId) + const openid = userRecord.getString('openid') + + return { + pb_id: userRecord.id, + users_id: userRecord.getString('users_id') || userRecord.getString('user_id'), + users_type: userRecord.getString('users_type') || GUEST_USER_TYPE, + users_name: userRecord.getString('users_name') || userRecord.getString('user_name'), + users_phone: userRecord.getString('users_phone'), + users_phone_masked: maskPhone(userRecord.getString('users_phone')), + users_picture: userRecord.getString('users_picture'), + openid: openid, + company_id: companyId || '', + company: exportCompany(companyRecord), + created: String(userRecord.created || ''), + updated: String(userRecord.updated || ''), + } +} + +function buildAuthMeta(user) { + return { + code: 200, + msg: '操作成功', + data: { + status: user.status, + is_info_complete: user.is_info_complete, + user: user.user, + }, + } +} + +function ensureAuthIdentity(record) { + if (!record.getString('email')) { + const openid = record.getString('openid') + if (openid) { + record.set('email', openid + '@wechat.local') + } + } + + if (!record.getString('password')) { + record.setPassword($security.randomString(24)) + } + + if (!record.getString('passwordConfirm')) { + record.set('passwordConfirm', record.getString('password')) + } +} + +function saveAuthUserRecord(record) { + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '保存微信用户失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } +} + +function authenticateWechatUser(payload) { + const openid = wechatService.getWxOpenId(payload.users_wx_code) + + return withUserLock('auth:' + openid, function () { + const existing = findUserByOpenid(openid) + if (existing) { + logger.warn('微信注册命中已存在账号', { + openid: openid, + users_type: existing.getString('users_type') || GUEST_USER_TYPE, + }) + + const user = enrichUser(existing) + + return { + status: 'login_success', + is_info_complete: isInfoComplete(existing), + user: user, + authRecord: existing, + authMethod: '', + meta: buildAuthMeta({ + status: 'login_success', + is_info_complete: isInfoComplete(existing), + user: user, + }), + } + } + + const collection = $app.findCollectionByNameOrId('tbl_auth_users') + const record = new Record(collection) + record.set('user_id', buildUserId()) + record.set('users_id', record.getString('user_id')) + record.set('openid', openid) + record.set('users_type', GUEST_USER_TYPE) + record.set('user_auth_type', 0) + ensureAuthIdentity(record) + saveAuthUserRecord(record) + + const user = enrichUser(record) + + logger.info('微信用户注册成功', { + users_id: user.users_id, + users_type: user.users_type, + }) + + return { + status: 'register_success', + is_info_complete: false, + user: user, + authRecord: record, + authMethod: '', + meta: buildAuthMeta({ + status: 'register_success', + is_info_complete: false, + user: user, + }), + } + }) +} + +function updateWechatUserProfile(usersWxOpenid, payload) { + return withUserLock('profile:' + usersWxOpenid, function () { + const currentUser = findUserByOpenid(usersWxOpenid) + if (!currentUser) { + throw createAppError(404, '未找到待编辑的用户') + } + + const usersPhone = wechatService.getWxPhoneNumber(payload.users_phone_code) + + if (usersPhone && usersPhone !== currentUser.getString('users_phone')) { + const samePhoneUsers = $app.findRecordsByFilter('tbl_auth_users', 'users_phone = {:phone}', '', 10, 0, { + phone: usersPhone, + }) + for (let i = 0; i < samePhoneUsers.length; i += 1) { + if (samePhoneUsers[i].id !== currentUser.id) { + throw createAppError(400, '手机号已被注册') + } + } + } + + const shouldPromote = isAllProfileFieldsEmpty(currentUser) + && !!payload.users_name + && !!usersPhone + && !!payload.users_picture + && ((currentUser.getString('users_type') || GUEST_USER_TYPE) === GUEST_USER_TYPE) + + currentUser.set('users_name', payload.users_name) + currentUser.set('user_name', payload.users_name) + currentUser.set('users_phone', usersPhone) + currentUser.set('users_picture', payload.users_picture) + if (shouldPromote) { + currentUser.set('users_type', REGISTERED_USER_TYPE) + } + + saveAuthUserRecord(currentUser) + + const user = enrichUser(currentUser) + + logger.info('微信用户资料更新成功', { + users_id: user.users_id, + users_type_after: user.users_type, + users_type_promoted: shouldPromote, + }) + + return { + status: 'update_success', + user: user, + } + }) +} + +function refreshWechatToken(usersWxOpenid) { + const userRecord = findUserByOpenid(usersWxOpenid) + if (!userRecord) { + throw createAppError(404, '未注册用户') + } + + logger.info('微信用户刷新令牌成功', { + users_id: userRecord.getString('users_id') || userRecord.getString('user_id'), + openid: userRecord.getString('openid'), + }) + + return { + authRecord: userRecord, + authMethod: '', + meta: { + code: 200, + msg: '刷新成功', + data: {}, + }, + } +} + +module.exports = { + authenticateWechatUser, + updateWechatUserProfile, + refreshWechatToken, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/wechatService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/wechatService.js new file mode 100644 index 0000000..449db5c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/wechatService.js @@ -0,0 +1,92 @@ +const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`) +const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) + +let accessTokenCache = { + token: '', + expiresAt: 0, +} + +function ensureWechatConfig() { + if (!env.wechatAppId || !env.wechatSecret) { + throw createAppError(500, '微信小程序配置缺失') + } +} + +function getWxOpenId(code) { + ensureWechatConfig() + + const res = $http.send({ + url: 'https://api.weixin.qq.com/sns/jscode2session?appid=' + encodeURIComponent(env.wechatAppId) + '&secret=' + encodeURIComponent(env.wechatSecret) + '&js_code=' + encodeURIComponent(code) + '&grant_type=authorization_code', + method: 'GET', + headers: { + 'content-type': 'application/json', + }, + timeout: 10, + }) + + const data = res.json || {} + + if (data.openid) return data.openid + + if (data.errcode || data.errmsg) { + throw createAppError(502, ('获取OpenID失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, '')) + } + + throw createAppError(502, '获取OpenID失败: 响应中未包含openid') +} + +function getWechatAccessToken() { + if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) { + return accessTokenCache.token + } + + ensureWechatConfig() + + const res = $http.send({ + url: 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=' + encodeURIComponent(env.wechatAppId) + '&secret=' + encodeURIComponent(env.wechatSecret), + method: 'GET', + headers: { + 'content-type': 'application/json', + }, + timeout: 10, + }) + + const data = res.json || {} + if (!data.access_token) { + throw createAppError(502, ('获取微信 access_token 失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, '')) + } + + accessTokenCache = { + token: data.access_token, + expiresAt: Date.now() + Math.max(((data.expires_in || 7200) - 300), 60) * 1000, + } + + return accessTokenCache.token +} + +function getWxPhoneNumber(phoneCode) { + const accessToken = getWechatAccessToken() + + const res = $http.send({ + url: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' + encodeURIComponent(accessToken), + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ code: phoneCode }), + timeout: 10, + }) + + const data = res.json || {} + const phoneInfo = data.phone_info || {} + const phone = phoneInfo.purePhoneNumber || phoneInfo.phoneNumber + + if (phone) return phone + + throw createAppError(502, ('获取微信手机号失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, '')) +} + +module.exports = { + getWxOpenId, + getWxPhoneNumber, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/appError.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/appError.js new file mode 100644 index 0000000..021819d --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/appError.js @@ -0,0 +1,7 @@ +function createAppError(statusCode, message, data) { + return new ApiError(statusCode || 500, message || '服务器内部错误', data || {}) +} + +module.exports = { + createAppError, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js new file mode 100644 index 0000000..4681632 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js @@ -0,0 +1,14 @@ +function log(level, message, meta) { + console.log(JSON.stringify({ + time: new Date().toISOString(), + level: level, + message: message, + meta: meta || {}, + })) +} + +module.exports = { + info: function (message, meta) { log('INFO', message, meta) }, + warn: function (message, meta) { log('WARN', message, meta) }, + error: function (message, meta) { log('ERROR', message, meta) }, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js new file mode 100644 index 0000000..7eda0b3 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js @@ -0,0 +1,11 @@ +function success(e, msg, data, code) { + return e.json(code || 200, { + code: code || 200, + msg: msg || '操作成功', + data: data || {}, + }) +} + +module.exports = { + success, +} diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js new file mode 100644 index 0000000..857c542 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js @@ -0,0 +1,21 @@ +function sanitizeString(value) { + if (typeof value !== 'string') return '' + return value.replace(/[<>\\]/g, '').replace(/^\s+|\s+$/g, '') +} + +function sanitizePayload(payload) { + const source = payload || {} + const result = {} + + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue + result[key] = typeof source[key] === 'string' ? sanitizeString(source[key]) : source[key] + } + + return result +} + +module.exports = { + sanitizeString, + sanitizePayload, +} diff --git a/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md new file mode 100644 index 0000000..e6540a9 --- /dev/null +++ b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md @@ -0,0 +1,175 @@ +# OpenSpec 变更记录:PocketBase Hooks 认证链路加固 + +## 日期 + +- 2026-03-23 + +## 范围 + +本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略与运行规范。 + +--- + +## 一、认证模型调整 + +### 1. 认证体系 + +- 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。 +- 登录与刷新响应统一通过 `$apis.recordAuthResponse(...)` 返回 PocketBase 原生认证结果。 +- `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。 + +### 2. Header 规则 + +- 正式认证 Header 为:`Authorization: Bearer ` +- 非标准 Header `Open-Authorization` 不属于本项目接口定义。 +- `users_wx_openid` Header 已从 active hooks 鉴权链路移除。 + +--- + +## 二、身份字段与数据模型约束 + +### 1. openid 作为唯一业务身份锚点 + +- `tbl_auth_users` 仅保留 `openid` 作为微信身份锚点。 +- 业务逻辑中不再使用 `users_wx_openid`。 +- 用户查询、token 刷新、资料更新均基于 auth record 的 `openid`。 + +### 2. auth 集合兼容字段 + +由于 `tbl_auth_users` 当前为 PocketBase `auth` 集合,登录注册时为兼容 PocketBase 原生 auth 校验,新增以下兼容策略: + +- `email` 使用占位格式:`@wechat.local` +- 自动生成随机密码 +- 自动补齐 `passwordConfirm` + +说明: + +- 占位 `email` 仅用于满足 auth 集合保存条件,不代表用户真实邮箱。 +- 业务主身份仍然是 `openid`。 + +### 3. 自定义字段可空策略 + +- `tbl_auth_users` 的自定义字段目标约束为:除 `openid` 外,其余业务字段均允许为空。 +- 已将 schema 脚本中的 `user_id` 改为非必填。 +- 其余业务字段保持非必填。 + +--- + +## 三、查询与排序修复 + +### 1. 移除无意义的 `created` 排序 + +在 hooks 查询中,以下查询原先使用 `'-created'` 排序: + +- 按 `openid` 查询用户 +- 按 `company_id` 查询公司 +- 按 `users_phone` 查询重复手机号 + +该写法在 PocketBase 当前运行场景下触发: + +- `invalid sort field "created"` + +现已统一移除排序参数,改为空排序字符串,因为这些查询本质上均为精确匹配或去重检查,不依赖排序。 + +--- + +## 四、错误可观测性增强 + +### 1. 登录路由显式错误响应 + +`POST /api/wechat/login` 新增局部 try/catch: + +- 保留业务状态码 +- 返回 `{ code, msg, data }` +- 写入 `logger.error('微信登录失败', ...)` + +### 2. 全局错误包装顺序修正 + +- `routerUse(...)` 全局错误包装提前到路由注册前。 +- 统一兼容 `err.statusCode` / `err.status`。 + +### 3. auth 保存失败透传 + +新增 `saveAuthUserRecord(record)` 包装 `$app.save(record)`: + +- 失败时统一抛出 `保存微信用户失败` +- 附带 `originalMessage` 与 `originalData` + +目的: + +- 避免 PocketBase 默认 `Something went wrong while processing your request.` 吞掉具体原因。 + +--- + +## 五、数据库索引策略修复 + +### 1. users_phone 索引调整 + +原设计: + +- `users_phone` 唯一索引 + +问题: + +- 新用户注册阶段手机号为空,多个空值会触发唯一约束冲突,导致注册失败。 + +现调整为: + +- `users_phone` 普通索引 + +说明: + +- 手机号唯一性改由业务逻辑在资料完善阶段校验。 +- 允许多个未完善资料用户以空手机号存在。 + +--- + +## 六、接口契约同步结果 + +当前 active PocketBase hooks 契约如下: + +- `POST /api/system/test-helloworld` +- `POST /api/system/health` +- `POST /api/wechat/login` +- `POST /api/wechat/profile` +- `POST /api/wechat/refresh-token` + +其中: + +### `POST /api/wechat/login` + +- body 必填:`users_wx_code` +- 自动以微信 code 换取 `openid` +- 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录 +- 成功时返回 PocketBase 原生 token + auth record + meta + +### `POST /api/wechat/profile` + +- 需 `Authorization` +- 基于当前 auth record 的 `openid` 定位用户 +- 服务端用 `users_phone_code` 换取手机号后保存 + +### `POST /api/wechat/refresh-token` + +- 需 `Authorization` +- 直接基于当前 auth record 返回新的 PocketBase 原生 token + +--- + +## 七、当前已知边界 + +1. `tbl_auth_users` 为 PocketBase `auth` 集合,因此仍受 PocketBase auth 内置规则影响。 +2. 自定义字段“除 openid 外均可空”已在脚本层按目标放宽,但 auth 集合结构更新仍可能触发 PocketBase 服务端限制。 +3. 若线上仍返回 PocketBase 默认 400,需要确保最新 hooks 已部署并重启生效。 + +--- + +## 八、归档建议 + +部署时至少同步以下文件: + +- `pocket-base/bai-api-main.pb.js` +- `pocket-base/bai_api_pb_hooks/` +- `script/pocketbase.newpb.js` + +并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。 diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml new file mode 100644 index 0000000..73ab951 --- /dev/null +++ b/pocket-base/spec/openapi.yaml @@ -0,0 +1,253 @@ +openapi: 3.1.0 +info: + title: BAI PocketBase Hooks API + description: 基于 PocketBase `bai_api_pb_hooks` 的对外接口文档,可直接导入 Postman。 + version: 1.0.0 +servers: + - url: https://bai-api.blv-oa.com/pb + description: 生产环境 + - url: http://localhost:8090 + description: PocketBase 本地环境 +tags: + - name: 系统 + description: 基础检查接口 + - name: 微信认证 + description: 基于微信 openid 与 PocketBase 原生 token 的认证接口 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: PocketBaseAuthToken + schemas: + ApiResponse: + type: object + required: [code, msg, data] + properties: + code: + type: integer + example: 200 + msg: + type: string + example: 操作成功 + data: + type: object + additionalProperties: true + HealthData: + type: object + properties: + status: + type: string + example: healthy + timestamp: + type: string + format: date-time + HelloWorldData: + type: object + properties: + message: + type: string + example: Hello, World! + timestamp: + type: string + format: date-time + status: + type: string + example: success + build_time: + type: string + nullable: true + format: date-time + CompanyInfo: + type: object + nullable: true + additionalProperties: true + UserInfo: + type: object + properties: + pb_id: + type: string + users_id: + type: string + users_type: + type: string + enum: [游客, 注册用户] + users_name: + type: string + users_phone: + type: string + users_phone_masked: + type: string + users_picture: + type: string + openid: + type: string + company_id: + type: string + company: + $ref: '#/components/schemas/CompanyInfo' + created: + type: string + updated: + type: string + PocketBaseAuthResponse: + type: object + properties: + token: + type: string + description: PocketBase 原生 auth token + record: + type: object + description: PocketBase auth record 原始对象 + meta: + type: object + properties: + code: + type: integer + example: 200 + msg: + type: string + example: 登录成功 + data: + type: object + properties: + status: + type: string + enum: [register_success, login_success] + is_info_complete: + type: boolean + user: + $ref: '#/components/schemas/UserInfo' + WechatLoginRequest: + type: object + required: [users_wx_code] + properties: + users_wx_code: + type: string + description: 微信小程序登录临时凭证 code + example: 0a1b2c3d4e5f6g + WechatProfileRequest: + type: object + required: [users_name, users_phone_code, users_picture] + properties: + users_name: + type: string + example: 张三 + users_phone_code: + type: string + example: 2b7d9f2e3c4a5b6d7e8f + users_picture: + type: string + example: https://example.com/avatar.png + WechatProfileResponseData: + type: object + properties: + status: + type: string + enum: [update_success] + user: + $ref: '#/components/schemas/UserInfo' +paths: + /api/system/test-helloworld: + post: + tags: [系统] + summary: HelloWorld 测试接口 + responses: + '200': + description: 成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/HelloWorldData' + /api/system/health: + post: + tags: [系统] + summary: 健康检查 + responses: + '200': + description: 成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/HealthData' + /api/wechat/login: + post: + tags: [微信认证] + summary: 微信登录/注册合一 + description: | + 使用微信 code 换取 openid。 + 若 `tbl_auth_users` 中不存在对应用户则自动创建 auth record,随后返回 PocketBase 原生 auth token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WechatLoginRequest' + responses: + '200': + description: 登录或注册成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + '400': + description: 参数错误 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /api/wechat/profile: + post: + tags: [微信认证] + summary: 更新微信用户资料 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WechatProfileRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/WechatProfileResponseData' + '401': + description: token 无效或当前 auth record 缺少 openid + /api/wechat/refresh-token: + post: + tags: [微信认证] + summary: 刷新 PocketBase 原生 token + description: | + 当前实现完全基于 PocketBase 原生鉴权,直接从当前 `Authorization` 对应的 auth record 读取 openid 并重新返回原生 auth token。 + security: + - bearerAuth: [] + responses: + '200': + description: 刷新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + '401': + description: token 无效或当前 auth record 缺少 openid + '404': + description: 用户不存在 diff --git a/script/package.json b/script/package.json index f860aa4..6edd191 100644 --- a/script/package.json +++ b/script/package.json @@ -4,7 +4,8 @@ "description": "", "main": "pocketbase.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "init:newpb": "node pocketbase.newpb.js" }, "keywords": [], "author": "", diff --git a/script/pocketbase.newpb.js b/script/pocketbase.newpb.js new file mode 100644 index 0000000..069d9c5 --- /dev/null +++ b/script/pocketbase.newpb.js @@ -0,0 +1,261 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import PocketBase from 'pocketbase'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function readEnvFile(filePath) { + if (!fs.existsSync(filePath)) return {}; + + const content = fs.readFileSync(filePath, 'utf8'); + const result = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const index = line.indexOf('='); + if (index === -1) continue; + const key = line.slice(0, index).trim(); + const value = line.slice(index + 1).trim(); + result[key] = value; + } + + return result; +} + +const backendEnv = readEnvFile(path.resolve(__dirname, '..', 'back-end', '.env')); +const PB_URL = (process.env.PB_URL || backendEnv.POCKETBASE_API_URL || 'http://127.0.0.1:8090').replace(/\/+$/, ''); +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || backendEnv.PB_ADMIN_EMAIL || ''; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD || backendEnv.PB_ADMIN_PASSWORD || ''; +const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || backendEnv.POCKETBASE_AUTH_TOKEN || ''; + +const pb = new PocketBase(PB_URL); + +const collections = [ + { + name: 'tbl_auth_users', + type: 'auth', + fields: [ + { name: 'user_id', type: 'text' }, + { name: 'openid', type: 'text', required: true }, + { name: 'user_name', type: 'text' }, + { name: 'org_id', type: 'number' }, + { name: 'rank_level', type: 'number' }, + { name: 'status', type: 'number' }, + { name: 'user_auth_type', type: 'number' }, + + { name: 'users_id', type: 'text' }, + { name: 'users_name', type: 'text' }, + { name: 'users_idtype', type: 'text' }, + { name: 'users_id_number', type: 'text' }, + { name: 'users_phone', type: 'text' }, + { name: 'users_level', type: 'text' }, + { name: 'users_type', type: 'text' }, + { name: 'users_status', type: 'text' }, + { name: 'company_id', type: 'text' }, + { name: 'users_parent_id', type: 'text' }, + { name: 'users_promo_code', type: 'text' }, + { name: 'users_id_pic_a', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, + { name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, + { name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, + { name: 'users_picture', type: 'text' }, + { name: 'usergroups_id', type: 'text' } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_users_user_id ON tbl_auth_users (user_id)', + 'CREATE UNIQUE INDEX idx_tbl_auth_users_openid ON tbl_auth_users (openid)', + 'CREATE INDEX idx_tbl_auth_users_org_id ON tbl_auth_users (org_id)', + 'CREATE INDEX idx_tbl_auth_users_rank_level ON tbl_auth_users (rank_level)', + 'CREATE INDEX idx_tbl_auth_users_status ON tbl_auth_users (status)', + 'CREATE INDEX idx_tbl_auth_users_user_auth_type ON tbl_auth_users (user_auth_type)', + + 'CREATE UNIQUE INDEX idx_tbl_auth_users_users_id ON tbl_auth_users (users_id)', + // Allow unbound users with empty phone while still speeding up phone lookups. + 'CREATE INDEX idx_tbl_auth_users_users_phone ON tbl_auth_users (users_phone)', + 'CREATE INDEX idx_tbl_auth_users_company_id ON tbl_auth_users (company_id)', + 'CREATE INDEX idx_tbl_auth_users_usergroups_id ON tbl_auth_users (usergroups_id)', + 'CREATE INDEX idx_tbl_auth_users_users_parent_id ON tbl_auth_users (users_parent_id)' + ] + }, + { + name: 'tbl_auth_resources', + type: 'base', + fields: [ + { name: 'res_id', type: 'text', required: true }, + { name: 'table_name', type: 'text', required: true }, + { name: 'column_name', type: 'text' }, + { name: 'res_type', type: 'text', required: true } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_resources_res_id ON tbl_auth_resources (res_id)', + 'CREATE INDEX idx_tbl_auth_resources_table_name ON tbl_auth_resources (table_name)', + 'CREATE INDEX idx_tbl_auth_resources_res_type ON tbl_auth_resources (res_type)', + 'CREATE UNIQUE INDEX idx_tbl_auth_resources_unique_res ON tbl_auth_resources (table_name, column_name, res_type)' + ] + }, + { + name: 'tbl_auth_roles', + type: 'base', + fields: [ + { name: 'role_id', type: 'text', required: true }, + { name: 'role_name', type: 'text', required: true }, + { name: 'role_code', type: 'text' }, + { name: 'role_status', type: 'number' }, + { name: 'role_remark', type: 'text' } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_id ON tbl_auth_roles (role_id)', + 'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_name ON tbl_auth_roles (role_name)', + 'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_code ON tbl_auth_roles (role_code)' + ] + }, + { + name: 'tbl_auth_role_perms', + type: 'base', + fields: [ + { name: 'role_perm_id', type: 'text', required: true }, + { name: 'role_id', type: 'text', required: true }, + { name: 'res_id', type: 'text', required: true }, + { name: 'access_level', type: 'number', required: true }, + { name: 'priority', type: 'number' } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_role_perms_role_perm_id ON tbl_auth_role_perms (role_perm_id)', + 'CREATE INDEX idx_tbl_auth_role_perms_role_id ON tbl_auth_role_perms (role_id)', + 'CREATE INDEX idx_tbl_auth_role_perms_res_id ON tbl_auth_role_perms (res_id)', + 'CREATE UNIQUE INDEX idx_tbl_auth_role_perms_unique_map ON tbl_auth_role_perms (role_id, res_id)' + ] + }, + { + name: 'tbl_auth_user_overrides', + type: 'base', + fields: [ + { name: 'override_id', type: 'text', required: true }, + { name: 'user_id', type: 'text', required: true }, + { name: 'res_id', type: 'text', required: true }, + { name: 'access_level', type: 'number', required: true }, + { name: 'priority', type: 'number' } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_user_overrides_override_id ON tbl_auth_user_overrides (override_id)', + 'CREATE INDEX idx_tbl_auth_user_overrides_user_id ON tbl_auth_user_overrides (user_id)', + 'CREATE INDEX idx_tbl_auth_user_overrides_res_id ON tbl_auth_user_overrides (res_id)', + 'CREATE UNIQUE INDEX idx_tbl_auth_user_overrides_unique_map ON tbl_auth_user_overrides (user_id, res_id)' + ] + }, + { + name: 'tbl_auth_row_scopes', + type: 'base', + fields: [ + { name: 'scope_id', type: 'text', required: true }, + { name: 'target_type', type: 'text', required: true }, + { name: 'target_id', type: 'text', required: true }, + { name: 'table_name', type: 'text', required: true }, + { name: 'filter_sql', type: 'editor', required: true } + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_auth_row_scopes_scope_id ON tbl_auth_row_scopes (scope_id)', + 'CREATE INDEX idx_tbl_auth_row_scopes_target_type ON tbl_auth_row_scopes (target_type)', + 'CREATE INDEX idx_tbl_auth_row_scopes_target_id ON tbl_auth_row_scopes (target_id)', + 'CREATE INDEX idx_tbl_auth_row_scopes_table_name ON tbl_auth_row_scopes (table_name)' + ] + } +]; + +async function init() { + try { + console.log('🔄 正在连接 PocketBase API...'); + + if (AUTH_TOKEN) { + pb.authStore.save(AUTH_TOKEN, null); + console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。'); + } else if (ADMIN_EMAIL && ADMIN_PASSWORD) { + await pb.collection('_superusers').authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log('✅ 已使用超级管理员账号登录。'); + } else { + throw new Error('缺少 PocketBase API 认证信息,请提供 POCKETBASE_AUTH_TOKEN 或 PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD'); + } + + console.log('🚀 开始初始化 newpb 权限模型表结构...\n'); + + for (const collectionData of collections) { + await createOrUpdateCollection(collectionData); + } + + await verifyCollections(collections); + console.log('\n🎉 newpb 权限模型表结构初始化并校验完成!'); + } catch (error) { + console.error('❌ 初始化失败:', error.response?.data || error.message); + process.exitCode = 1; + } +} + +async function createOrUpdateCollection(collectionData) { + console.log(`🔄 正在处理表: ${collectionData.name} ...`); + const payload = { + name: collectionData.name, + type: collectionData.type, + fields: collectionData.fields, + indexes: collectionData.indexes, + }; + + try { + await pb.collections.create(payload); + console.log(`✅ ${collectionData.name} 创建完成。`); + } catch (error) { + const nameErrorCode = error.response?.data?.name?.code; + if ( + error.status === 400 + && (nameErrorCode === 'validation_not_unique' || nameErrorCode === 'validation_collection_name_exists') + ) { + const existing = await pb.collections.getOne(collectionData.name); + await pb.collections.update(existing.id, payload); + console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`); + return; + } + console.error(`❌ 处理集合 ${collectionData.name} 失败:`, { + status: error.status, + message: error.message, + response: error.response?.data, + }); + throw error; + } +} + +async function verifyCollections(targetCollections) { + console.log('\n🔍 开始校验 newpb 表结构与索引...'); + + for (const target of targetCollections) { + const remote = await pb.collections.getOne(target.name); + const remoteFieldNames = new Set((remote.fields || []).map((field) => field.name)); + const missingFields = target.fields + .map((field) => field.name) + .filter((fieldName) => !remoteFieldNames.has(fieldName)); + + const remoteIndexes = new Set(remote.indexes || []); + const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql)); + + if (remote.type !== target.type) { + throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`); + } + + if (missingFields.length === 0 && missingIndexes.length === 0) { + console.log(`✅ ${target.name} 校验通过。`); + continue; + } + + console.log(`❌ ${target.name} 校验失败:`); + if (missingFields.length > 0) { + console.log(` - 缺失字段: ${missingFields.join(', ')}`); + } + if (missingIndexes.length > 0) { + console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`); + } + + throw new Error(`${target.name} 结构与预期不一致`); + } +} + +init(); \ No newline at end of file