From 71aaeb94246644f63520aa6ee94f918c38fef6fe Mon Sep 17 00:00:00 2001 From: D8D Developer Date: Wed, 11 Jun 2025 09:35:39 +0000 Subject: [PATCH] =?UTF-8?q?ci:=20=E6=B7=BB=E5=8A=A0=20Docker=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=92=8C=E6=8E=A8=E9=80=81=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Dockerfile 和 .dockerignore 文件 - 添加 Gitea 持续集成工作流,用于构建和推送 Docker 镜像 - 新增 .gitignore 文件,忽略构建和配置文件 - 添加项目结构和规范文档,包括 TypeScript、模块化、API、数据库等规范 - 新增前端和后端的基础代码结构 --- .gitea/workflows/release.yaml | 48 +++ .gitignore | 42 +++ .npmrc | 1 + .roo/rules/01-general.md | 25 ++ .roo/rules/02-typescript.md | 5 + .roo/rules/03-modules.md | 8 + .roo/rules/04-api.md | 22 ++ .roo/rules/05-database.md | 5 + .roo/rules/06-service-di.md | 18 ++ .roo/rules/07-openapi.md | 180 ++++++++++++ .roo/rules/08-rpc.md | 45 +++ Dockerfile | 23 ++ README.md | 3 + docs/aliyun-sms.md | 72 +++++ docs/sso-verify.md | 37 +++ docs/zpay.cn.md | 44 +++ package.json | 66 +++++ src/client/app.tsx | 70 +++++ src/client/components/LanguageSwitcher.tsx | 19 ++ src/client/i18n/config.ts | 19 ++ src/client/i18n/locales/en/translation.json | 7 + src/client/i18n/locales/zh/translation.json | 7 + src/client/i18next-loader.d.ts | 5 + src/client/index.tsx | 15 + src/server/api.ts | 137 +++++++++ src/server/api/auth/index.ts | 15 + src/server/api/auth/login/index.ts | 13 + src/server/api/auth/login/password.ts | 71 +++++ src/server/api/auth/login/sms.ts | 144 +++++++++ src/server/api/auth/logout/index.ts | 68 +++++ src/server/api/auth/me/get.ts | 46 +++ src/server/api/auth/me/index.ts | 10 + src/server/api/auth/phone-code/fixed.ts | 58 ++++ src/server/api/auth/phone-code/index.ts | 8 + src/server/api/auth/phone-code/sms.ts | 53 ++++ src/server/api/auth/register/create.ts | 76 +++++ src/server/api/auth/register/index.ts | 10 + src/server/api/auth/schemas.ts | 10 + src/server/api/auth/sso-verify.ts | 69 +++++ src/server/api/base.ts | 54 ++++ src/server/api/init.ts | 192 ++++++++++++ src/server/api/migration.ts | 89 ++++++ src/server/api/payment.ts | 202 +++++++++++++ src/server/api/users/create.ts | 80 +++++ src/server/api/users/delete.ts | 61 ++++ src/server/api/users/get.ts | 77 +++++ src/server/api/users/index.ts | 23 ++ src/server/api/users/list.ts | 57 ++++ src/server/api/users/update.ts | 113 ++++++++ src/server/data-source.ts | 27 ++ src/server/index.tsx | 75 +++++ src/server/middleware/auth.middleware.ts | 36 +++ .../middleware/permission.middleware.ts | 39 +++ src/server/modules/auth/auth.service.ts | 119 ++++++++ src/server/modules/payment/dto/payment.dto.ts | 233 +++++++++++++++ src/server/modules/payment/payment.entity.ts | 46 +++ src/server/modules/payment/payment.service.ts | 190 ++++++++++++ src/server/modules/users/role.entity.ts | 25 ++ src/server/modules/users/user.entity.ts | 64 ++++ src/server/modules/users/user.service.ts | 138 +++++++++ src/server/renderer.tsx | 46 +++ src/server/types/context.ts | 9 + src/server/utils/env-init.ts | 38 +++ src/server/utils/errorHandler.ts | 34 +++ src/server/utils/redis.ts | 113 ++++++++ src/server/utils/sms.ts | 73 +++++ src/share/types.ts | 273 ++++++++++++++++++ src/style.css | 1 + tsconfig.json | 28 ++ vite.config.ts | 41 +++ 70 files changed, 4170 insertions(+) create mode 100644 .gitea/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .roo/rules/01-general.md create mode 100644 .roo/rules/02-typescript.md create mode 100644 .roo/rules/03-modules.md create mode 100644 .roo/rules/04-api.md create mode 100644 .roo/rules/05-database.md create mode 100644 .roo/rules/06-service-di.md create mode 100644 .roo/rules/07-openapi.md create mode 100644 .roo/rules/08-rpc.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docs/aliyun-sms.md create mode 100644 docs/sso-verify.md create mode 100644 docs/zpay.cn.md create mode 100644 package.json create mode 100644 src/client/app.tsx create mode 100644 src/client/components/LanguageSwitcher.tsx create mode 100644 src/client/i18n/config.ts create mode 100644 src/client/i18n/locales/en/translation.json create mode 100644 src/client/i18n/locales/zh/translation.json create mode 100644 src/client/i18next-loader.d.ts create mode 100644 src/client/index.tsx create mode 100644 src/server/api.ts create mode 100644 src/server/api/auth/index.ts create mode 100644 src/server/api/auth/login/index.ts create mode 100644 src/server/api/auth/login/password.ts create mode 100644 src/server/api/auth/login/sms.ts create mode 100644 src/server/api/auth/logout/index.ts create mode 100644 src/server/api/auth/me/get.ts create mode 100644 src/server/api/auth/me/index.ts create mode 100644 src/server/api/auth/phone-code/fixed.ts create mode 100644 src/server/api/auth/phone-code/index.ts create mode 100644 src/server/api/auth/phone-code/sms.ts create mode 100644 src/server/api/auth/register/create.ts create mode 100644 src/server/api/auth/register/index.ts create mode 100644 src/server/api/auth/schemas.ts create mode 100644 src/server/api/auth/sso-verify.ts create mode 100644 src/server/api/base.ts create mode 100644 src/server/api/init.ts create mode 100644 src/server/api/migration.ts create mode 100644 src/server/api/payment.ts create mode 100644 src/server/api/users/create.ts create mode 100644 src/server/api/users/delete.ts create mode 100644 src/server/api/users/get.ts create mode 100644 src/server/api/users/index.ts create mode 100644 src/server/api/users/list.ts create mode 100644 src/server/api/users/update.ts create mode 100644 src/server/data-source.ts create mode 100644 src/server/index.tsx create mode 100644 src/server/middleware/auth.middleware.ts create mode 100644 src/server/middleware/permission.middleware.ts create mode 100644 src/server/modules/auth/auth.service.ts create mode 100644 src/server/modules/payment/dto/payment.dto.ts create mode 100644 src/server/modules/payment/payment.entity.ts create mode 100644 src/server/modules/payment/payment.service.ts create mode 100644 src/server/modules/users/role.entity.ts create mode 100644 src/server/modules/users/user.entity.ts create mode 100644 src/server/modules/users/user.service.ts create mode 100644 src/server/renderer.tsx create mode 100644 src/server/types/context.ts create mode 100644 src/server/utils/env-init.ts create mode 100644 src/server/utils/errorHandler.ts create mode 100644 src/server/utils/redis.ts create mode 100644 src/server/utils/sms.ts create mode 100644 src/share/types.ts create mode 100644 src/style.css create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..67c47ec --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,48 @@ +name: Docker Build and Push +on: + push: + branches: + - release/* # 匹配所有release开头的分支 + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - run: echo "🎉 该作业由 ${{ gitea.event_name }} 事件自动触发。" + - run: echo "🐧 此作业当前在 Gitea 托管的 ${{ runner.os }} 服务器上运行!" + - run: echo "🔎 您的分支名称是 ${{ gitea.ref }},仓库是 ${{ gitea.repository }}。" + - name: 检出仓库代码 + uses: actions/checkout@v4 + - run: echo "💡 ${{ gitea.repository }} 仓库已克隆到运行器。" + - run: echo "🖥️ 工作流现在已准备好在运行器上测试您的代码。" + - name: 列出仓库中的文件 + run: | + ls ${{ gitea.workspace }} + - run: echo "🍏 此作业的状态是 ${{ job.status }}。" + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 从分支名中提取版本号 + id: extract_version + run: | + # 从分支名中提取版本号,例如从 release/v0.1.6 中提取 v0.1.6 + VERSION=$(echo "${{ gitea.ref }}" | sed 's|refs/heads/release/||') + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "提取的版本号:$VERSION" + + - name: 登录 Docker 注册表 + uses: docker/login-action@v3 + with: + registry: registry.cn-beijing.aliyuncs.com + username: ${{ secrets.ALI_DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.ALI_DOCKER_REGISTRY_PASSWORD }} + + - name: 构建并推送 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + registry.cn-beijing.aliyuncs.com/d8dcloud/d8d-ai-design-prd:${{ env.VERSION }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c042f11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# prod +dist/ +dist-server/ + +bun.lock +package-lock.json + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.env.development +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store + +public/assets +public/editor +public/vscode-editor \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/.roo/rules/01-general.md b/.roo/rules/01-general.md new file mode 100644 index 0000000..ee879a5 --- /dev/null +++ b/.roo/rules/01-general.md @@ -0,0 +1,25 @@ +# 基础规范 + +## 项目结构 + +``` +src/ +├── client/ # 前端代码 (React + Vite) +├── server/ # 后端代码 (Hono + TypeORM) +│ ├── api/ # API路由 +│ ├── migrations/ # 数据库迁移脚本 +│ ├── modules/ # 业务模块 +│ └── middleware/ # 中间件 +``` + +## 技术栈 + +### 前端 +- React 18 +- TypeScript (严格模式) +- Vite 构建工具 + +### 后端 +- Hono 框架 +- TypeORM (MySQL) +- Redis (缓存/会话管理) \ No newline at end of file diff --git a/.roo/rules/02-typescript.md b/.roo/rules/02-typescript.md new file mode 100644 index 0000000..df4bcb9 --- /dev/null +++ b/.roo/rules/02-typescript.md @@ -0,0 +1,5 @@ +# TypeScript规范 + +1. **严格模式** + - 启用所有严格类型检查选项 + - 避免使用`any`类型 \ No newline at end of file diff --git a/.roo/rules/03-modules.md b/.roo/rules/03-modules.md new file mode 100644 index 0000000..463ec2a --- /dev/null +++ b/.roo/rules/03-modules.md @@ -0,0 +1,8 @@ +# 模块化规范 + +1. **模块组织** + - 按功能划分模块 + - 每个模块包含: + - 实体定义 + - 服务层 + - 路由控制器 \ No newline at end of file diff --git a/.roo/rules/04-api.md b/.roo/rules/04-api.md new file mode 100644 index 0000000..8c209b1 --- /dev/null +++ b/.roo/rules/04-api.md @@ -0,0 +1,22 @@ +# 接口定义规范 + +1. **DTO定义** + - 必须包含`description`字段说明用途 + - 必须包含`example`字段提供示例值 + - 示例: + ```typescript + export const CreateUserDto = z.object({ + username: z.string().min(3).max(20).openapi({ + example: 'john_doe', + description: '用户名, 3-20个字符' + }), + password: z.string().min(6).openapi({ + example: 'password123', + description: '密码, 最少6位' + }) + }) + ``` + +2. **API响应** + - 统一的API响应格式 + - 完善的Swagger文档 \ No newline at end of file diff --git a/.roo/rules/05-database.md b/.roo/rules/05-database.md new file mode 100644 index 0000000..3ee3f1c --- /dev/null +++ b/.roo/rules/05-database.md @@ -0,0 +1,5 @@ +# 数据库规范 + +1. **迁移管理** + - 使用迁移脚本管理表结构变更 + - 实体类与数据库表严格映射 \ No newline at end of file diff --git a/.roo/rules/06-service-di.md b/.roo/rules/06-service-di.md new file mode 100644 index 0000000..aa98000 --- /dev/null +++ b/.roo/rules/06-service-di.md @@ -0,0 +1,18 @@ +# 依赖注入规范 + +1. **依赖注入原则** + - 服务类必须通过构造函数注入依赖 + - 禁止直接实例化全局对象(AppDataSource等) + - 示例: + ```typescript + // Good - 通过构造函数注入 + export class UserService { + constructor(private dataSource: DataSource) {} + } + + // Bad - 使用全局实例 + export class UserService { + constructor() { + this.repository = AppDataSource.getRepository(User); + } + } \ No newline at end of file diff --git a/.roo/rules/07-openapi.md b/.roo/rules/07-openapi.md new file mode 100644 index 0000000..8e367ff --- /dev/null +++ b/.roo/rules/07-openapi.md @@ -0,0 +1,180 @@ +# Hono OpenAPI规范 + +## 常见不规范问题 +1. **路径参数问题**: + - ❌ 使用冒号定义路径参数: `/:id` + - ✅ 必须使用花括号: `/{id}` + +2. **参数Schema缺失**: + - ❌ 未定义params Schema + - ✅ 必须定义并添加OpenAPI元数据 + +3. **参数获取方式**: + - ❌ 使用`c.req.param()` + - ✅ 必须使用`c.req.valid('param')` + +4. **OpenAPI元数据**: + - ❌ 路径参数缺少OpenAPI描述 + - ✅ 必须包含example和description + +## 核心规范 +### 1. 路由定义 +- **路径参数**: + - 必须使用花括号 `{}` 定义 (例: `/{id}`) + - 必须定义 params Schema 并添加 OpenAPI 元数据: + ```typescript + const GetParams = z.object({ + id: z.string().openapi({ + param: { name: 'id', in: 'path' }, + example: '1', + description: '资源ID' + }) + }); + ``` + - 路由定义中必须包含 params 定义: + ```typescript + request: { params: GetParams } + ``` + - 必须使用 `c.req.valid('param')` 获取路径参数 + +- **请求定义**: + ```typescript + request: { + body: { + content: { + 'application/json': { schema: YourZodSchema } + } + } + } + ``` + +- **响应定义**: + ```typescript + responses: { + 200: { + description: '成功响应描述', + content: { 'application/json': { schema: SuccessSchema } } + }, + 400: { + description: '客户端错误', + content: { 'application/json': { schema: ErrorSchema } } + }, + 500: { + description: '服务器错误', + content: { 'application/json': { schema: ErrorSchema } } + } + } + ``` + +- **路由示例**: + ```typescript + const routeDef = createRoute({ + method: 'post', + path: '/workspaces', + middleware: authMiddleware, + request: { + body: { + content: { 'application/json': { schema: CreateSchema } } + } + }, + responses: { + 200: { ... }, + 400: { ... }, + 500: { ... } + } + }); + ``` + +### 2. 错误处理 +- 错误响应必须使用统一格式: `{ code: number, message: string }` +- 必须与OpenAPI定义完全一致 +- 处理示例: + ```typescript + try { + // 业务逻辑 + } catch (error) { + return c.json({ code: 500, message: '操作失败' }, 500); + } + ``` + +### 3. dataSource引入 +- 示例: + ```typescript + import { AppDataSource } from '@/server/data-source'; + ``` + +### 4. service初始化 +- 示例: + ```typescript + import { WorkspaceService } from '@/server/modules/workspaces/workspace.service'; + const workspaceService = new WorkspaceService(AppDataSource); + +### 5. 用户context获取 +- 示例: + ```typescript + const user = c.get('user'); + ``` + - 注意: 确保 `c.get('user')` 已经在 `authMiddleware` 中设置 + +### 6. AuthContext引用 +- 示例: + ```typescript + import { AuthContext } from '@/server/types/context'; + ``` + +### 7. createRoute, OpenAPIHono 引入 +- 示例: + ```typescript + import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; + ``` + +### 8. ErrorSchema 引入 +- 示例: + ```typescript + import { ErrorSchema } from '@/server/utils/errorHandler'; + ``` + +## 进阶规范 +### 1. 路由聚合 +当多个相关路由需要组合时: +1. **文件结构**: + - 拆分为独立文件 (`create.ts`, `list.ts` 等) + - 创建 `index.ts` 聚合所有子路由 + +2. **实现**: + ```typescript + export default { + createRoute, + getRoute, + deleteRoute + } + ``` + +3. **优势**: + - 保持模块化 + - 简化维护 + - 统一API入口 + +## 路由文件代码结构规范 + +imports: 依赖导入 + +serviceInit: 服务初始化 + +paramsSchema: 路径参数定义 + +responseSchema: 响应定义 + +errorSchema: 错误定义 + +routeDef: 路由定义 + +app: 路由实例 + +## 完整示例 +```typescript +// 路由实例 +const app = new OpenAPIHono().openapi(routeDef, async (c) => { + try { + // 业务逻辑 + return c.json(result, 200); + } catch (error) { + return c.json({ code: 500, message: '操作失败' }, 500); + } +}); + +export default app; +``` \ No newline at end of file diff --git a/.roo/rules/08-rpc.md b/.roo/rules/08-rpc.md new file mode 100644 index 0000000..3d58097 --- /dev/null +++ b/.roo/rules/08-rpc.md @@ -0,0 +1,45 @@ +# RPC 调用规范 + +## 核心原则 +1. **类型安全**: + - 所有RPC调用必须基于OpenAPI定义的类型 + - 客户端和服务端类型必须严格匹配 + +2. **一致性**: + - RPC调用路径必须与OpenAPI路由定义一致 + - 错误处理格式必须统一 + +3. **api版本**: + - 所有RPC调用必须基于OpenAPI定义的版本 + - 版本号必须与OpenAPI版本号一致 + 目前仅支持v1版本 + 示例: + ```typescript + import { client } from '@/client/editor/api'; + client.api.v1 + +## 客户端规范 +### 1. 客户端初始化 +```typescript +import { hc } from 'hono/client' +import ApiRoutes from '@/server/api'; +export const client = hc('/', { + fetch: axiosFetch, +}); +``` + +### 2. 方法调用 +- 必须使用解构方式组织RPC方法 +- 方法命名必须与OpenAPI路由定义一致 +- 示例: +```typescript +const res = await client.api.v1.workspaces.templates.blank[':templateType'].$get({ + param: { + templateType + } +}); +if (res.status !== 200) { + throw new Error(res.message); +} +const templateInfo = await res.json(); +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa169ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# 使用指定基础镜像 +FROM node:20.18.3 + +# 设置工作目录 +WORKDIR /app + +# 复制package.json .npmrc和package-lock.json +COPY package.json .npmrc package-lock.json* ./ + +# 安装依赖 +RUN npm install + +# 复制项目文件 +COPY . . + +# 构建项目 +RUN npm run build + +# 暴露端口(根据实际需要调整) +EXPOSE 23956 + +# 启动命令 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..196b48e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +```bash +npx giget@latest gh:yusukebe/hono-vite-react-stack-example my-app +``` diff --git a/docs/aliyun-sms.md b/docs/aliyun-sms.md new file mode 100644 index 0000000..d4dd4b8 --- /dev/null +++ b/docs/aliyun-sms.md @@ -0,0 +1,72 @@ +```typescript +// This file is auto-generated, don't edit it +// 依赖的模块可通过下载工程中的模块依赖文件或右上角的获取 SDK 依赖信息查看 +import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525'; +import OpenApi, * as $OpenApi from '@alicloud/openapi-client'; +import Util, * as $Util from '@alicloud/tea-util'; +import { env } from '../../config/env.ts'; + +export class SMS { + /** + * @remarks + * 使用AK&SK初始化账号Client + * @returns Client + * + * @throws Exception + */ + static createClient(): Dysmsapi20170525 { + // 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。 + // 建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378664.html。 + if (!env.deploy?.alicloud) { + throw new Error("Aliyun SMS configuration is required"); + } + + const { accessKeyId, accessKeySecret } = env.deploy.alicloud; + + let config = new $OpenApi.Config({ + accessKeyId, + accessKeySecret, + }); + // Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi + config.endpoint = `dysmsapi.aliyuncs.com`; + return new (Dysmsapi20170525 as any).default(config); + } + + /** + * 发送短信 + * @param phoneNumber 手机号 + * @param code 验证码 + * @param templateCode 模板代码,默认使用配置的验证码模板 + * @param signName 短信签名,默认使用配置的签名 + */ + static async sendVerificationSMS( + phoneNumber: string, + code: string, + templateCode?: string, + signName?: string + ): Promise { + let client = this.createClient(); + let sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({ + signName: signName || (env.sms?.defaultSignName || "多八多"), + templateCode: templateCode || (env.sms?.defaultTemplateCode || "SMS_164760103"), + phoneNumbers: phoneNumber, + templateParam: JSON.stringify({code: code}), + }); + let runtime = new $Util.RuntimeOptions({ }); + try { + // 发送短信 + const result = await client.sendSmsWithOptions(sendSmsRequest, runtime); + console.log("SMS sent successfully:", result); + return true; + } catch (error: unknown) { + // 错误处理 + const err = error as { message?: string, data?: { Recommend?: string } }; + console.error("SMS sending failed:", err.message); + if (err.data && err.data["Recommend"]) { + console.error("Recommendation:", err.data["Recommend"]); + } + return false; + } + } +} +``` \ No newline at end of file diff --git a/docs/sso-verify.md b/docs/sso-verify.md new file mode 100644 index 0000000..b839869 --- /dev/null +++ b/docs/sso-verify.md @@ -0,0 +1,37 @@ +```typescript +// 为Gogs SSO单点登录提供的验证API + authRoutes.get('/sso-verify', async (c) => { + try { + const auth = await initAuth() + + // 从Authorization头获取令牌 + const token = c.req.header('Authorization')?.replace('Bearer ', '') + + if (!token) { + return c.text('Unauthorized', 401) + } + + // 验证令牌 + try { + const userData = await auth.verifyToken(token) + if (!userData) { + return c.text('Invalid token', 401) + } + + // 导入GitUtils工具类并使用其formatUsername方法格式化用户名 + const username = GitUtils.formatUsername(userData.username, userData.id); + + // 验证成功,设置用户名到响应头 + c.header('X-Username', username) + + return c.text('OK', 200) + } catch (tokenError) { + log.auth('Token验证失败:', tokenError) + return c.text('Invalid token', 401) + } + } catch (error) { + log.auth('SSO验证失败:', error) + return c.text('Authentication failed', 500) + } + }) +``` \ No newline at end of file diff --git a/docs/zpay.cn.md b/docs/zpay.cn.md new file mode 100644 index 0000000..532e8da --- /dev/null +++ b/docs/zpay.cn.md @@ -0,0 +1,44 @@ +API信息(兼容 易支付 接口) +接口地址:process.env.ZPAY_URL + +商户ID(PID):process.env.ZPAY_PID + +商户密钥(PKEY):process.env.ZPAY_PKEY + +异步通知地址:process.env.ZPAY_NOTIFY_URL + +API接口支付 +请求URL +https://zpayz.cn/mapi.php +请求方法 +POST(方式为form-data) +请求参数 +字段名 变量名 必填 类型 示例值 描述 +商户ID pid 是 String 1001 +支付渠道ID cid 否 String 1234 如果不填则随机使用某一支付渠道 +支付方式 type 是 String alipay 支付宝:alipay 微信支付:wxpay +商户订单号 out_trade_no 是 String 20160806151343349 +异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 +商品名称 name 是 String VIP会员 如超过127个字节会自动截取 +商品金额 money 是 String 1.00 单位:元,最大2位小数 +用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址 +设备类型 device 否 String pc 根据当前用户浏览器的UA判断, +传入用户所使用的浏览器 +或设备类型,默认为pc +业务扩展参数 param 否 String 没有请留空 支付后原样返回 +签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法参考本页底部 +签名类型 sign_type 是 String MD5 默认为MD5 +成功返回 +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +返回信息 msg String 失败时返回原因 +订单号 trade_no String 20160806151343349 支付订单号 +ZPAY内部订单号 O_id String 123456 ZPAY内部订单号 +支付跳转url payurl String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付 +二维码链接 qrcode String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则根据该url生成二维码 +二维码图片 img String https://z-pay.cn/qrcode/123.jpg 该字段为付款二维码的图片地址 +失败返回 +{"code":"error","msg":"具体的错误信息"} + + +<-- GET /api/zpay/notify?pid=2025052907394884&trade_no=2025053022001476521427708154&out_trade_no=202500000002&type=alipay&name=VIP%E4%BC%9A%E5%91%98%E6%9C%8D%E5%8A%A1&money=0.01&trade_status=TRADE_SUCCESS¶m=user_id%3D123&sign=9a3406e5f177c03646274749463ad979&sign_type=MD5 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa90f89 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "d8d-ai-design", + "private": true, + "type": "module", + "scripts": { + "dev": "export NODE_ENV='development' && vite", + "build": "export NODE_ENV='production' && vite build && vite build --ssr", + "start": "export NODE_ENV='production' && node dist-server/index.js" + }, + "dependencies": { + "@alicloud/bssopenapi20171214": "^4.0.0", + "@alicloud/credentials": "^2.4.3", + "@alicloud/dysmsapi20170525": "^4.1.1", + "@alicloud/eci20180808": "^2.1.1", + "@alicloud/openapi-client": "^0.4.14", + "@alicloud/tea-typescript": "^1.8.0", + "@alicloud/tea-util": "^1.4.10", + "@codingame/monaco-vscode-secret-storage-service-override": "^16.0.2", + "@d8d-appcontainer/api": "^3.0.47", + "@d8d-fun/eci-manager": "npm:@jsr/d8d-fun__eci-manager@^0.1.3", + "@d8d-fun/gogs-client": "npm:@jsr/d8d-fun__gogs-client@^0.1.2", + "@heroicons/react": "^2.2.0", + "@hono/node-server": "^1.14.3", + "@hono/react-renderer": "^1.0.1", + "@hono/swagger-ui": "^0.5.1", + "@hono/vite-dev-server": "^0.19.1", + "@hono/zod-openapi": "^0.19.7", + "@hono/zod-validator": "^0.4.3", + "@tanstack/react-query": "^5.77.2", + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.9", + "bcrypt": "^6.0.0", + "debug": "^4.4.1", + "dotenv": "^16.5.0", + "formdata-node": "^6.0.3", + "hono": "^4.7.6", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.1.0", + "ioredis": "^5.6.1", + "isomorphic-git": "^1.30.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.14.1", + "node-fetch": "^3.3.2", + "pg": "^8.16.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-hook-form": "^7.57.0", + "react-i18next": "^15.5.2", + "react-router": "^7.6.1", + "react-router-dom": "^7.6.1", + "react-toastify": "^11.0.5", + "reflect-metadata": "^0.2.2", + "typeorm": "^0.3.24", + "vite-plugin-i18next-loader": "^3.1.2", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/node": "^22.15.23", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.2", + "hono-vite-react-stack-node": "^0.2.1", + "tailwindcss": "^4.1.3", + "vite": "^6.3.5" + } +} diff --git a/src/client/app.tsx b/src/client/app.tsx new file mode 100644 index 0000000..fe55fbc --- /dev/null +++ b/src/client/app.tsx @@ -0,0 +1,70 @@ +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { hc } from 'hono/client' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import type { BaseRoutes } from '../server/api/base' +import type { UserRoutes } from '../server/api/user' +import './i18n/config' +import LanguageSwitcher from './components/LanguageSwitcher' + +const client = hc('/api') +const userClient = hc('/api') + +const Home = () => { + const { t } = useTranslation() + return ( +
+

{t('welcome')}

+ +
+ ) +} + +const About = () => { + return ( +
+

About Page

+

This is the about page.

+
+ ) +} + +const ApiDemo = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ['apiData'], + queryFn: async () => { + const res = await client.index.$get({ query: { name: 'Hono' } }) + return res.json() + } + }) + + if (isLoading) return
Loading...
+ if (error) return
Error: {error.message}
+ + return

{data?.message}

+} + +const router = createBrowserRouter([ + { + path: '/', + element: , + }, + { + path: '/about', + element: , + }, + { + path: '/api-demo', + element: , + }, +]) + +const App = () => { + return ( + <> + + + ) +} + +export default App diff --git a/src/client/components/LanguageSwitcher.tsx b/src/client/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..1023e6e --- /dev/null +++ b/src/client/components/LanguageSwitcher.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const LanguageSwitcher: React.FC = () => { + const { t, i18n } = useTranslation(); + + const changeLanguage = (lng: string) => { + i18n.changeLanguage(lng); + }; + + return ( +
+ + +
+ ); +}; + +export default LanguageSwitcher; \ No newline at end of file diff --git a/src/client/i18n/config.ts b/src/client/i18n/config.ts new file mode 100644 index 0000000..cee9abc --- /dev/null +++ b/src/client/i18n/config.ts @@ -0,0 +1,19 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import resources from 'virtual:i18next-loader'; + +// 初始化i18n配置 +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + interpolation: { + escapeValue: false, // React已经处理XSS防护 + }, + resources + }); + +export default i18n; \ No newline at end of file diff --git a/src/client/i18n/locales/en/translation.json b/src/client/i18n/locales/en/translation.json new file mode 100644 index 0000000..a87bd19 --- /dev/null +++ b/src/client/i18n/locales/en/translation.json @@ -0,0 +1,7 @@ +{ + "translation": { + "welcome": "Welcome", + "language": "Language", + "switch_language": "Switch Language" + } +} \ No newline at end of file diff --git a/src/client/i18n/locales/zh/translation.json b/src/client/i18n/locales/zh/translation.json new file mode 100644 index 0000000..dc7aa0a --- /dev/null +++ b/src/client/i18n/locales/zh/translation.json @@ -0,0 +1,7 @@ +{ + "translation": { + "welcome": "欢迎", + "language": "语言", + "switch_language": "切换语言" + } +} \ No newline at end of file diff --git a/src/client/i18next-loader.d.ts b/src/client/i18next-loader.d.ts new file mode 100644 index 0000000..8550a92 --- /dev/null +++ b/src/client/i18next-loader.d.ts @@ -0,0 +1,5 @@ +declare module 'virtual:i18next-loader' { + import { Resource } from 'i18next'; + const resources: Resource; + export default resources; +} \ No newline at end of file diff --git a/src/client/index.tsx b/src/client/index.tsx new file mode 100644 index 0000000..75f71b5 --- /dev/null +++ b/src/client/index.tsx @@ -0,0 +1,15 @@ +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './app' + +const queryClient = new QueryClient() + +const rootElement = document.getElementById('root') +if (rootElement) { + const root = createRoot(rootElement) + root.render( + + + + ) +} \ No newline at end of file diff --git a/src/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..85aef74 --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,137 @@ +import { OpenAPIHono } from '@hono/zod-openapi' +import { errorHandler } from './utils/errorHandler' +import base from './api/base' +import usersRouter from './api/users/index' +import { initConfigRouter, initStatusRouter } from './api/init' +import paymentRouter from './api/payment' +import workspacesRouter from './api/workspaces/index' +import workspacesProjectsRouter from './api/workspaces.projects/index' +import workspacesTemplatesRouter from './api/workspaces.templates/index' +import workspacesContainersRouter from './api/workspaces.containers/index' +import workspacesCollaboratorsRouter from './api/workspaces.collaborations/index' +import workspacesProjectCollaboratorsRouter from './api/workspaces.project-collaborators/index' +import workspacesTemplateCollaboratorsRouter from './api/workspaces.template-collaborators/index' +import templateMarketplaceRouter from './api/marketplace/index' +import authRoute from './api/auth/index' +import aliBillsRoute from './api/ali-bills/index' +import { AuthContext } from './types/context' +import { AppDataSource } from './data-source' + +const api = new OpenAPIHono() + +api.onError(errorHandler) + +// Rate limiting +api.use('/api/v1/*', async (c, next) => { + const ip = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') + // 实现速率限制逻辑 + await next() +}) + +// 数据库初始化中间件 +api.use('/api/v1/*', async (c, next) => { + if(!AppDataSource.isInitialized) await AppDataSource.initialize(); + await next() +}) + +// 注册Bearer认证方案 +api.openAPIRegistry.registerComponent('securitySchemes','bearerAuth',{ + type:'http', + scheme:'bearer', + bearerFormat:'JWT', + description:'使用JWT进行认证' +}) + +// OpenAPI documentation endpoint +api.doc31('/doc', { + openapi: '3.1.0', + info: { + title: 'API Documentation', + version: '1.0.0' + }, + security: [{ + bearerAuth: [] + }] + // servers: [{ url: '/api/v1' }] +}) + +// const bindRoute = (api:OpenAPIHono, path: string, routes: Array) => { +// routes.forEach(route => { +// api = api.route(path, route) +// }) +// return api +// } + +// Register routes +let routes = api + .route('/api/v1/init', initConfigRouter) + .route('/api/v1/init', initStatusRouter) + .route('/api/v1/base', base) + + .route('/api/v1/users', usersRouter.createRoute) + .route('/api/v1/users', usersRouter.listRoute) + .route('/api/v1/users', usersRouter.getRoute) + .route('/api/v1/users', usersRouter.updateRoute) + .route('/api/v1/users', usersRouter.deleteRoute) + + .route('/api/v1/auth', authRoute.loginRoute.passwordRoute) + .route('/api/v1/auth', authRoute.loginRoute.smsRoute) + .route('/api/v1/auth', authRoute.logoutRoute) + .route('/api/v1/auth', authRoute.meRoute.meRoute) + .route('/api/v1/auth', authRoute.phoneCode.fixed) + .route('/api/v1/auth', authRoute.phoneCode.sms) + .route('/api/v1/auth', authRoute.registerRoute.registerRoute) + .route('/api/auth', authRoute.ssoVerify) + + .route('/api/v1/payments', paymentRouter.notifyApi) + .route('/api/v1/payments', paymentRouter.queryApi) + .route('/api/v1/payments', paymentRouter.paymentApi) + + .route('/api/v1/marketplace', templateMarketplaceRouter.listRoute) + .route('/api/v1/marketplace', templateMarketplaceRouter.detailRoute) + .route('/api/v1/marketplace', templateMarketplaceRouter.deleteRoute) + + .route('/api/v1/workspaces', workspacesRouter.createRoutes) + .route('/api/v1/workspaces', workspacesRouter.deleteRoutes) + .route('/api/v1/workspaces', workspacesRouter.listRoutes) + .route('/api/v1/workspaces', workspacesRouter.getRoutes) + .route('/api/v1/workspaces', workspacesRouter.updateRoutes) + + .route('/api/v1/workspaces', workspacesProjectsRouter.createRoutes) + .route('/api/v1/workspaces', workspacesProjectsRouter.deleteRoutes) + .route('/api/v1/workspaces', workspacesProjectsRouter.listRoutes) + .route('/api/v1/workspaces', workspacesProjectsRouter.getRoutes) + .route('/api/v1/workspaces', workspacesProjectsRouter.updateRoutes) + + .route('/api/v1/workspaces', workspacesTemplatesRouter.createRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.deleteRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.listRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.getRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.updateRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.publishRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.useTemplateRoutes) + .route('/api/v1/workspaces', workspacesTemplatesRouter.getBlankTemplateRoute) + .route('/api/v1/workspaces', workspacesTemplatesRouter.listBlankTemplatesRoute) + + .route('/api/v1/workspaces', workspacesContainersRouter.startRoutes) + .route('/api/v1/workspaces', workspacesContainersRouter.stopRoutes) + + .route('/api/v1/workspaces', workspacesCollaboratorsRouter.repositoriesApp) + + .route('/api/v1/workspaces', workspacesProjectCollaboratorsRouter.listRoutes) + .route('/api/v1/workspaces', workspacesProjectCollaboratorsRouter.createRoutes) + .route('/api/v1/workspaces', workspacesProjectCollaboratorsRouter.deleteRoutes) + .route('/api/v1/workspaces', workspacesProjectCollaboratorsRouter.checkRoutes) + + .route('/api/v1/workspaces', workspacesTemplateCollaboratorsRouter.listRoutes) + .route('/api/v1/workspaces', workspacesTemplateCollaboratorsRouter.createRoutes) + .route('/api/v1/workspaces', workspacesTemplateCollaboratorsRouter.deleteRoutes) + .route('/api/v1/workspaces', workspacesTemplateCollaboratorsRouter.checkRoutes) + .route('/api/v1/ali-bills', aliBillsRoute.getRoute) + .route('/api/v1/ali-bills', aliBillsRoute.syncRoute) + + +export type ApiRoutes = typeof routes + + +export default routes \ No newline at end of file diff --git a/src/server/api/auth/index.ts b/src/server/api/auth/index.ts new file mode 100644 index 0000000..7257a3e --- /dev/null +++ b/src/server/api/auth/index.ts @@ -0,0 +1,15 @@ +import loginRoute from './login'; +import logoutRoute from './logout'; +import meRoute from './me'; +import phoneCode from './phone-code'; +import registerRoute from './register'; +import ssoVerify from './sso-verify'; + +export default { + loginRoute, + logoutRoute, + meRoute, + phoneCode, + registerRoute, + ssoVerify +} \ No newline at end of file diff --git a/src/server/api/auth/login/index.ts b/src/server/api/auth/login/index.ts new file mode 100644 index 0000000..9741d09 --- /dev/null +++ b/src/server/api/auth/login/index.ts @@ -0,0 +1,13 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { AuthContext } from '@/server/types/context'; +import passwordRoute from './password'; +import smsRoute from './sms'; + +// const api = new OpenAPIHono() +// .route('/', passwordRoute) +// .route('/', smsRoute); +// export default api; +export default { + passwordRoute, + smsRoute, +} \ No newline at end of file diff --git a/src/server/api/auth/login/password.ts b/src/server/api/auth/login/password.ts new file mode 100644 index 0000000..8154ccb --- /dev/null +++ b/src/server/api/auth/login/password.ts @@ -0,0 +1,71 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { AuthService } from '../../../modules/auth/auth.service' +import { UserService } from '../../../modules/users/user.service' +import { z } from 'zod' +import { ErrorSchema } from '../../../utils/errorHandler' +import { AppDataSource } from '../../../data-source' +import { AuthContext } from '../../../types/context' + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const LoginSchema = z.object({ + username: z.string().min(3).openapi({ + example: 'john_doe', + description: '用户名' + }), + password: z.string().min(6).openapi({ + example: 'password123', + description: '密码' + }) +}) + +const TokenResponseSchema = z.object({ + token: z.string().openapi({ + example: 'jwt.token.here', + description: 'JWT Token' + }), + user: z.object({ + id: z.number(), + username: z.string() + }) +}) + +const loginRoute = createRoute({ + method: 'post', + path: '/login', + request: { + body: { + content: { + 'application/json': { + schema: LoginSchema + } + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: TokenResponseSchema + } + } + }, + 401: { + description: '用户名或密码错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) +const app = new OpenAPIHono().openapi(loginRoute, async (c) => { + const { username, password } = c.req.valid('json') + const result = await authService.login(username, password) + return c.json(result, 200) +}); + +export default app \ No newline at end of file diff --git a/src/server/api/auth/login/sms.ts b/src/server/api/auth/login/sms.ts new file mode 100644 index 0000000..b76fc83 --- /dev/null +++ b/src/server/api/auth/login/sms.ts @@ -0,0 +1,144 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { setCookie } from 'hono/cookie' +import { AuthService } from '../../../modules/auth/auth.service' +import { UserService } from '../../../modules/users/user.service' +import { z } from 'zod' +import { HTTPException } from 'hono/http-exception' +import { ErrorSchema } from '../../../utils/errorHandler' +import { AppDataSource } from '../../../data-source' +import { AuthContext } from '../../../types/context' +import { UserResponseSchema } from '../schemas' +import debug from 'debug' +import { GitUtils } from '@/server/utils/gitUtils' +import process from 'node:process' + +const log = { + auth: debug('auth') +} + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const SmsLoginSchema = z.object({ + phone: z.string().regex(/^1[3-9]\d{9}$/).openapi({ + example: '13800138000', + description: '手机号' + }), + code: z.string().length(6).openapi({ + example: '123456', + description: '6位验证码' + }) +}) + +const TokenResponseSchema = z.object({ + token: z.string().openapi({ + example: 'jwt.token.here', + description: 'JWT Token' + }), + user: UserResponseSchema +}) + +const smsLoginRoute = createRoute({ + method: 'post', + path: '/login/sms', + request: { + body: { + content: { + 'application/json': { + schema: SmsLoginSchema + } + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: TokenResponseSchema + } + } + }, + 400: { + description: '验证码错误或已过期', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) + +const app = new OpenAPIHono().openapi(smsLoginRoute, async (c) => { + const { phone, code } = c.req.valid('json') + + // 验证验证码 + const isValid = authService.verifyCode(phone, code) + if (!isValid) { + throw new HTTPException(400, { message: '验证码错误或已过期' }) + } + + // 查找或创建用户 + let user = await userService.getUserByPhone(phone) + if (!user) { + user = await userService.createUser({ mobile: phone, username: phone }) + } + + + // 同步检查 Gogs 用户 + try { + // 格式化用户名 + const gogsUsername = GitUtils.formatUsername(phone, user.id); + + // 随机生成密码 + const randomPassword = Math.random().toString(36).slice(-8); + + // 检查并创建 Gogs 用户 + const gogsResult = await GitUtils.checkGogsUser( + gogsUsername, + user.email || `${gogsUsername}@d8d.fun`, + randomPassword, + phone + ); + + if (gogsResult.success) { + log.auth(gogsResult.message); + } else { + log.auth(`Gogs 用户同步失败: ${gogsResult.message}`); + } + } catch (syncError) { + // Gogs 同步失败不应该影响登录流程 + log.auth('同步 Gogs 用户失败:', syncError); + } + + const token = authService.generateToken(user) + + const cookieName = process.env.SSO_COOKIE_NAME || 'd8d_aider_auth_token'; + const cookieDomain = process.env.SSO_COOKIE_DOMAIN || '.d.d8d.fun'; + + // 设置cookie + setCookie(c, cookieName, token, { + httpOnly: true, + secure: true, + sameSite: 'none', + domain: cookieDomain, + maxAge: 86400 * 7 + }); + + + const response = { + token, + user: { + id: user.id, + username: user.username, + mobile: user.mobile, + status: user.status, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString() + } + } + return c.json(response, 200) +}) + +export default app diff --git a/src/server/api/auth/logout/index.ts b/src/server/api/auth/logout/index.ts new file mode 100644 index 0000000..f4edfd4 --- /dev/null +++ b/src/server/api/auth/logout/index.ts @@ -0,0 +1,68 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { z } from 'zod' +import { AuthContext } from '@/server/types/context'; +import { authMiddleware } from '@/server/middleware/auth.middleware'; +import { AppDataSource } from '@/server/data-source'; +import { AuthService } from '@/server/modules/auth/auth.service'; +import { UserService } from '@/server/modules/users/user.service'; +import { ErrorSchema } from '@/server/utils/errorHandler'; + +// 初始化服务 +const userService = new UserService(AppDataSource); +const authService = new AuthService(userService); + +const SuccessSchema = z.object({ + message: z.string().openapi({ example: '登出成功' }) +}) + +// 定义路由 +const routeDef = createRoute({ + method: 'post', + path: '/logout', + security: [{ Bearer: [] }], + middleware: [authMiddleware], + responses: { + 200: { + description: '登出成功', + content: { + 'application/json': { + schema: SuccessSchema + } + } + }, + 401: { + description: '未授权', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(routeDef, async (c) => { + try { + const token = c.get('token'); + const decoded = authService.verifyToken(token); + if (!decoded) { + return c.json({ code: 401, message: '未授权' }, 401); + } + + await authService.logout(token); + return c.json({ message: '登出成功' }, 200); + } catch (error) { + console.error('登出失败:', error); + return c.json({ code: 500, message: '登出失败' }, 500); + } +}); + +export default app; \ No newline at end of file diff --git a/src/server/api/auth/me/get.ts b/src/server/api/auth/me/get.ts new file mode 100644 index 0000000..889364f --- /dev/null +++ b/src/server/api/auth/me/get.ts @@ -0,0 +1,46 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { z } from 'zod' +import { ErrorSchema } from '@/server/utils/errorHandler' +import { authMiddleware } from '@/server/middleware/auth.middleware' +import { AuthContext } from '@/server/types/context' +import { UserResponseSchema } from '../schemas' + + + +const routeDef = createRoute({ + method: 'get', + path: '/me', + middleware: authMiddleware, + responses: { + 200: { + description: '获取当前用户信息成功', + content: { + 'application/json': { + schema: UserResponseSchema + } + } + }, + 401: { + description: '未授权', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) + +const app = new OpenAPIHono().openapi(routeDef, (c) => { + const user = c.get('user') + return c.json({ + id: user.id, + username: user.username, + mobile: user.mobile, + status: user.status, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString() + }, 200) +}) + +export default app \ No newline at end of file diff --git a/src/server/api/auth/me/index.ts b/src/server/api/auth/me/index.ts new file mode 100644 index 0000000..926a26f --- /dev/null +++ b/src/server/api/auth/me/index.ts @@ -0,0 +1,10 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { AuthContext } from '@/server/types/context'; +import meRoute from './get'; + +// const api = new OpenAPIHono() +// .route('/', meRoute) +// export default api; +export default { + meRoute, +} \ No newline at end of file diff --git a/src/server/api/auth/phone-code/fixed.ts b/src/server/api/auth/phone-code/fixed.ts new file mode 100644 index 0000000..fc4f7cb --- /dev/null +++ b/src/server/api/auth/phone-code/fixed.ts @@ -0,0 +1,58 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { AuthService } from '@/server/modules/auth/auth.service' +import { UserService } from '@/server/modules/users/user.service' +import { z } from 'zod' +import { ErrorSchema } from '@/server/utils/errorHandler' +import { AppDataSource } from '@/server/data-source' +import { AuthContext } from '@/server/types/context' + +const GenerateFixedCodeSchema = z.object({ + phone: z.string().regex(/^1[3-9]\d{9}$/).openapi({ + example: '13800138000', + description: '手机号' + }) +}) + +const FixedCodeResponseSchema = z.object({ + code: z.string().length(6).openapi({ + example: '123456', + description: '6位固定验证码' + }) +}) + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const generateFixedCodeRoute = createRoute({ + method: 'get', + path: '/phone-code/fixed/{phone}', + request: { + params: GenerateFixedCodeSchema + }, + responses: { + 200: { + description: '生成成功', + content: { + 'application/json': { + schema: FixedCodeResponseSchema + } + } + }, + 403: { + description: '生产环境禁止访问', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) + +const app = new OpenAPIHono().openapi(generateFixedCodeRoute, async (c) => { + const { phone } = c.req.valid('param') + const code = authService.generateFixedCode(phone) + return c.json({ code }, 200) +}) + +export default app \ No newline at end of file diff --git a/src/server/api/auth/phone-code/index.ts b/src/server/api/auth/phone-code/index.ts new file mode 100644 index 0000000..cab1ab6 --- /dev/null +++ b/src/server/api/auth/phone-code/index.ts @@ -0,0 +1,8 @@ + +import fixed from './fixed'; +import sms from './sms'; + +export default { + fixed, + sms +} \ No newline at end of file diff --git a/src/server/api/auth/phone-code/sms.ts b/src/server/api/auth/phone-code/sms.ts new file mode 100644 index 0000000..e8c8afc --- /dev/null +++ b/src/server/api/auth/phone-code/sms.ts @@ -0,0 +1,53 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { AuthService } from '@/server/modules/auth/auth.service' +import { UserService } from '@/server/modules/users/user.service' +import { z } from 'zod' +import { ErrorSchema } from '@/server/utils/errorHandler' +import { AppDataSource } from '@/server/data-source' +import { AuthContext } from '@/server/types/context' +import { SMS } from '@/server/utils/sms' + +const GenerateSMSRndCodeSchema = z.object({ + phone: z.string().regex(/^1[3-9]\d{9}$/).openapi({ + example: '13800138000', + description: '手机号' + }) +}) + +const SMSRndCodeResponseSchema = z.object({ + success: z.boolean().openapi({ + example: true, + description: '是否成功' + }) +}) + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const generateFixedCodeRoute = createRoute({ + method: 'get', + path: '/phone-code/sms/{phone}', + request: { + params: GenerateSMSRndCodeSchema + }, + responses: { + 200: { + description: '发送成功', + content: { + 'application/json': { + schema: SMSRndCodeResponseSchema + } + } + }, + } +}) + +const app = new OpenAPIHono().openapi(generateFixedCodeRoute, async (c) => { + const { phone } = c.req.valid('param') + const code = await authService.generateRandCode(phone) + // TODO: 发送短信 + const result = await SMS.sendVerificationSMS(phone, code) + return c.json({ success: result }, 200) +}) + +export default app \ No newline at end of file diff --git a/src/server/api/auth/register/create.ts b/src/server/api/auth/register/create.ts new file mode 100644 index 0000000..dcea0f8 --- /dev/null +++ b/src/server/api/auth/register/create.ts @@ -0,0 +1,76 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { AuthService } from '../../../modules/auth/auth.service' +import { UserService } from '../../../modules/users/user.service' +import { z } from 'zod' +import { AppDataSource } from '../../../data-source' +import { ErrorSchema } from '../../../utils/errorHandler' +import { AuthContext } from '../../../types/context' + +const RegisterSchema = z.object({ + username: z.string().min(3).openapi({ + example: 'john_doe', + description: '用户名' + }), + password: z.string().min(6).openapi({ + example: 'password123', + description: '密码' + }), + email: z.string().email().openapi({ + example: 'john@example.com', + description: '邮箱' + }) +}) + +const TokenResponseSchema = z.object({ + token: z.string().openapi({ + example: 'jwt.token.here', + description: 'JWT Token' + }), + user: z.object({ + id: z.number(), + username: z.string() + }) +}) + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const registerRoute = createRoute({ + method: 'post', + path: '/register', + request: { + body: { + content: { + 'application/json': { + schema: RegisterSchema + } + } + } + }, + responses: { + 201: { + description: '注册成功', + content: { + 'application/json': { + schema: TokenResponseSchema + } + } + }, + 400: { + description: '用户名已存在', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) + +const app = new OpenAPIHono().openapi(registerRoute, async (c) => { + const { username, password, email } = c.req.valid('json') + const user = await userService.createUser({ username, password, email }) + const token = authService.generateToken(user) + return c.json({ token, user }, 201) +}) +export default app \ No newline at end of file diff --git a/src/server/api/auth/register/index.ts b/src/server/api/auth/register/index.ts new file mode 100644 index 0000000..3c1d655 --- /dev/null +++ b/src/server/api/auth/register/index.ts @@ -0,0 +1,10 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { AuthContext } from '@/server/types/context'; +import registerRoute from './create'; + +// const api = new OpenAPIHono() +// .route('/', registerRoute) +// export default api; +export default { + registerRoute, +} \ No newline at end of file diff --git a/src/server/api/auth/schemas.ts b/src/server/api/auth/schemas.ts new file mode 100644 index 0000000..c95779b --- /dev/null +++ b/src/server/api/auth/schemas.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const UserResponseSchema = z.object({ + id: z.number(), + username: z.string(), + mobile: z.string(), + status: z.number(), + createdAt: z.string(), + updatedAt: z.string() +}).openapi('User') \ No newline at end of file diff --git a/src/server/api/auth/sso-verify.ts b/src/server/api/auth/sso-verify.ts new file mode 100644 index 0000000..0868cf1 --- /dev/null +++ b/src/server/api/auth/sso-verify.ts @@ -0,0 +1,69 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { AuthService } from '@/server/modules/auth/auth.service' +import { UserService } from '@/server/modules/users/user.service' +import { ErrorSchema } from '@/server/utils/errorHandler' +import { AppDataSource } from '@/server/data-source' +import { AuthContext } from '@/server/types/context' +import { GitUtils } from '@/server/utils/gitUtils' + +const userService = new UserService(AppDataSource) +const authService = new AuthService(userService) + +const routeDef = createRoute({ + method: 'get', + path: '/sso-verify', + responses: { + 200: { + description: 'SSO验证成功', + headers: { + 'X-Username': { + schema: { type: 'string' }, + description: '格式化后的用户名' + } + } + }, + 401: { + description: '未授权或令牌无效', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}) + +const app = new OpenAPIHono().openapi(routeDef, async (c) => { + try { + const token = c.req.header('Authorization')?.replace('Bearer ', '') + + if (!token) { + return c.json({ code: 401, message: '未提供授权令牌' }, 401) + } + + try { + const userData = await authService.verifyToken(token) + if (!userData) { + return c.json({ code: 401, message: '无效令牌' }, 401) + } + + const username = GitUtils.formatUsername(userData.username, userData.id) + c.header('X-Username', username) + return c.text('OK', 200) + } catch (tokenError) { + return c.json({ code: 401, message: '令牌验证失败' }, 401) + } + } catch (error) { + return c.json({ code: 500, message: 'SSO验证失败' }, 500) + } +}) + +export default app \ No newline at end of file diff --git a/src/server/api/base.ts b/src/server/api/base.ts new file mode 100644 index 0000000..fcd8771 --- /dev/null +++ b/src/server/api/base.ts @@ -0,0 +1,54 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { z } from 'zod' +import { ErrorSchema } from '../utils/errorHandler' + +const app = new OpenAPIHono() + +const QuerySchema = z.object({ + name: z.string().optional().openapi({ + param: { + name: 'name', + in: 'query' + }, + example: 'John' + }) +}) + +const ResponseSchema = z.object({ + message: z.string().openapi({ + example: 'Hello from API, John' + }) +}) + +const route = createRoute({ + method: 'get', + path: '/', + request: { + query: QuerySchema + }, + responses: { + 200: { + content: { + 'application/json': { + schema: ResponseSchema + } + }, + description: 'Successful response' + }, + 400: { + content: { + 'application/json': { + schema: ErrorSchema, + }, + }, + description: 'Invalid request' + } + } +}) + +const baseRoutes = app.openapi(route, (c) => { + const { name } = c.req.valid('query') + return c.json({ message: `Hello from API${name ? `, ${name}` : ''}` }, 200) +}) + +export default baseRoutes \ No newline at end of file diff --git a/src/server/api/init.ts b/src/server/api/init.ts new file mode 100644 index 0000000..a71bfbd --- /dev/null +++ b/src/server/api/init.ts @@ -0,0 +1,192 @@ +import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { ErrorSchema } from '../utils/errorHandler'; +import { AppDataSource } from '../data-source'; +import { UserEntity as User } from '../modules/users/user.entity'; +import { Role } from '../modules/users/role.entity'; +import * as bcrypt from 'bcrypt'; +import { generateJwtSecret } from '../utils/env-init'; +import { writeFileSync } from 'fs'; + +const initRouter = new OpenAPIHono(); + +// Zod 验证模式 +const DatabaseConfigSchema = z.object({ + host: z.string(), + port: z.number(), + username: z.string(), + password: z.string(), + database: z.string() +}); + +const AdminUserSchema = z.object({ + username: z.string().min(3), + password: z.string().min(8) +}); + +const InitConfigSchema = z.object({ + dbConfig: DatabaseConfigSchema, + adminUser: AdminUserSchema +}); + +// 检查初始化状态路由 +const statusRoute = createRoute({ + method: 'get', + path: '/status', + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ + initialized: z.boolean() + }) + } + }, + description: '返回系统初始化状态' + }, + 500: { + description: '初始化状态检查失败', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const initStatusRouter = initRouter.openapi(statusRoute, async (c) => { + try { + const isInitialized = await checkInitialization(); + return c.json({ initialized: isInitialized }, 200); + } catch (error) { + return c.json({ + code: 500, + message: '初始化状态检查失败' + }, 500); + } +}); + +// 提交配置路由 +const configRoute = createRoute({ + method: 'post', + path: '/config', + request: { + body: { + content: { + 'application/json': { + schema: InitConfigSchema + } + } + } + }, + responses: { + 200: { + description: '系统初始化成功', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean(), + jwtSecret: z.string().optional() + }) + } + } + }, + 400: { + description: '初始化失败', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器内部错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const initConfigRouter = initRouter.openapi(configRoute, async (c) => { + const { dbConfig, adminUser } = c.req.valid('json'); + + if (await checkInitialization()) { + return c.json({ + code: 400, + message: '系统已初始化' + }, 400); + } + + try { + await validateDatabase(dbConfig); + await generateEnvFile(dbConfig); + const jwtSecret = generateJwtSecret(); + await createAdminUser(adminUser); + + return c.json({ + success: true, + jwtSecret + }, 200); + } catch (error) { + return c.json({ + code: 400, + message: error instanceof Error ? error.message : '未知错误' + }, 400); + } +}); + +export { initStatusRouter, initConfigRouter }; + + +async function checkInitialization(): Promise { + // 检查数据库是否已初始化 + return AppDataSource.isInitialized; +} + +async function validateDatabase(config: any) { + // 使用TypeORM验证数据库连接 + const testDataSource = AppDataSource.setOptions(config); + await testDataSource.initialize(); + await testDataSource.destroy(); +} + +async function generateEnvFile(config: any) { + const envContent = `DB_HOST=${config.host} +DB_PORT=${config.port} +DB_USERNAME=${config.username} +DB_PASSWORD=${config.password} +DB_DATABASE=${config.database} +`; + writeFileSync('.env', envContent); +} + +async function createAdminUser(userData: any) { + const userRepo = AppDataSource.getRepository(User); + const roleRepo = AppDataSource.getRepository(Role); + + // 检查是否已存在管理员 + const existingAdmin = await userRepo.findOne({ where: { username: userData.username } }); + if (existingAdmin) { + throw new Error('管理员用户已存在'); + } + + // 创建管理员角色 + let adminRole = await roleRepo.findOne({ where: { name: 'admin' } }); + if (!adminRole) { + adminRole = roleRepo.create({ name: 'admin', permissions: ['*'] }); + await roleRepo.save(adminRole); + } + + // 创建管理员用户 + const hashedPassword = await bcrypt.hash(userData.password, 10); + const adminUser = userRepo.create({ + username: userData.username, + password: hashedPassword, + roles: [adminRole] + }); + + await userRepo.save(adminUser); +} \ No newline at end of file diff --git a/src/server/api/migration.ts b/src/server/api/migration.ts new file mode 100644 index 0000000..dcdccdd --- /dev/null +++ b/src/server/api/migration.ts @@ -0,0 +1,89 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { z } from 'zod' +import { AppDataSource } from '../data-source' +import { HTTPException } from 'hono/http-exception'; +import { authMiddleware } from '../middleware/auth.middleware'; +import { AuthContext } from '../types/context'; + +interface Migration { + name: string; + timestamp: number; +} + +const app = new OpenAPIHono() + +const MigrationResponseSchema = z.object({ + success: z.boolean(), + message: z.string(), + migrations: z.array(z.string()).optional() +}) + +const runMigrationRoute = createRoute({ + method: 'post', + path: '/migrations/run', + middleware: authMiddleware, + responses: { + 200: { + content: { + 'application/json': { + schema: MigrationResponseSchema + } + }, + description: 'Migrations executed successfully' + }, + 500: { + description: 'Migration failed' + } + } +}) + +const revertMigrationRoute = createRoute({ + method: 'post', + path: '/migrations/revert', + middleware: authMiddleware, + responses: { + 200: { + content: { + 'application/json': { + schema: MigrationResponseSchema + } + }, + description: 'Migration reverted successfully' + }, + 500: { + description: 'Revert failed' + } + } +}) + +app.openapi(runMigrationRoute, async (c) => { + try { + const migrations = await AppDataSource.runMigrations() + return c.json({ + success: true, + message: 'Migrations executed successfully', + migrations: migrations.map((m: Migration) => m.name) + }) + } catch (error) { + throw error + } +}) + +app.openapi(revertMigrationRoute, async (c) => { + try { + await AppDataSource.undoLastMigration() + const migrations = await AppDataSource.showMigrations() + return c.json({ + success: true, + message: 'Migration reverted successfully', + migrations: migrations + }) + } catch (error) { + throw error + } +}) + + + +export default app +export type AppType = typeof app \ No newline at end of file diff --git a/src/server/api/payment.ts b/src/server/api/payment.ts new file mode 100644 index 0000000..55b13cc --- /dev/null +++ b/src/server/api/payment.ts @@ -0,0 +1,202 @@ +import { OpenAPIHono, createRoute} from '@hono/zod-openapi'; +import { authMiddleware } from '../middleware/auth.middleware'; +import { + PaymentRequestSchema, + PaymentResponseSchema, + NotifyRequestSchema, + QueryRequestSchema, + QueryResponseSchema +} from '../modules/payment/dto/payment.dto'; +import { PaymentService } from '../modules/payment/payment.service'; +import { ErrorSchema } from '../utils/errorHandler'; +import { AppDataSource } from '../data-source'; +import { AuthContext } from '../types/context'; + + +const paymentRoute = createRoute({ + method: 'post', + path: '/', + middleware: authMiddleware, + request: { + body: { + content: { + 'application/json': { + schema: PaymentRequestSchema + } + } + } + }, + responses: { + 200: { + description: '支付请求成功', + content: { + 'application/json': { + schema: PaymentResponseSchema + } + } + }, + 400: { + description: '客户端错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const notifyRoute = createRoute({ + method: 'get', + path: '/zpay/notify', + request: { + query: NotifyRequestSchema + }, + responses: { + 200: { + description: '回调处理成功', + content: { + 'application/json': { + schema: PaymentResponseSchema + } + } + }, + 400: { + description: '客户端错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const queryRoute = createRoute({ + method: 'get', + path: '/query', + middleware: authMiddleware, + request: { + query: QueryRequestSchema + }, + responses: { + 200: { + description: '查询成功', + content: { + 'application/json': { + schema: QueryResponseSchema + } + } + }, + 400: { + description: '客户端错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono() + +const paymentApi = app.openapi(paymentRoute, async (c) => { + const payload = c.req.valid('json'); + + try { + const paymentService = new PaymentService(AppDataSource); + const result = await paymentService.createPayment(payload); + if(result.code !== 1) { + return c.json({ + code: 400, + message: result.msg || '支付请求失败' + }, 400); + } + return c.json({ + code: 200, + msg: '支付请求成功', + trade_no: result.trade_no, + payurl: result.payurl, + qrcode: result.qrcode, + img: result.img + }, 200); + } catch (error) { + console.error('Payment error:', error); + return c.json({ + code: 500, + message: '支付处理失败' + }, 500); + } +}); + +const notifyApi = app.openapi(notifyRoute, async (c) => { + const payload = c.req.valid('query'); + + try { + const paymentService = new PaymentService(AppDataSource); + const result = await paymentService.handleNotify(payload); + return c.json({ + code: 200, + msg: result.msg, + trade_no: payload.trade_no, + out_trade_no: payload.out_trade_no + }, 200); + } catch (error) { + console.error('Notify error:', error); + return c.json({ + code: 500, + message: '回调处理失败' + }, 500); + } +}); + +const queryApi = app.openapi(queryRoute, async (c) => { + const payload = c.req.valid('query'); + + try { + const paymentService = new PaymentService(AppDataSource); + const result = await paymentService.queryPayment(payload); + return c.json({ + code: 200, + msg: result.msg, + data: result.data + }, 200); + } catch (error) { + console.error('Query error:', error); + return c.json({ + code: 500, + message: '查询处理失败' + }, 500); + } +}); + +export default { + paymentApi, + notifyApi, + queryApi +}; \ No newline at end of file diff --git a/src/server/api/users/create.ts b/src/server/api/users/create.ts new file mode 100644 index 0000000..7e740c2 --- /dev/null +++ b/src/server/api/users/create.ts @@ -0,0 +1,80 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { UserService } from '../../modules/users/user.service'; +import { z } from 'zod'; +import { authMiddleware } from '../../middleware/auth.middleware'; +import { ErrorSchema } from '../../utils/errorHandler'; +import { AppDataSource } from '../../data-source'; +import { AuthContext } from '../../types/context'; + +const userService = new UserService(AppDataSource); + +const UserSchema = z.object({ + id: z.number().openapi({ example: 1 }), + username: z.string().openapi({ example: 'john_doe' }), + email: z.string().email().openapi({ example: 'john@example.com' }), + createdAt: z.string().datetime().openapi({ example: '2025-05-28T00:00:00Z' }) +}); + +const CreateUserSchema = z.object({ + username: z.string().min(3).openapi({ + example: 'john_doe', + description: 'Minimum 3 characters' + }), + password: z.string().min(6).openapi({ + example: 'password123', + description: 'Minimum 6 characters' + }), + email: z.string().email().openapi({ example: 'john@example.com' }) +}); + +const createUserRoute = createRoute({ + method: 'post', + path: '/users', + middleware: authMiddleware, + request: { + body: { + content: { + 'application/json': { + schema: CreateUserSchema + } + } + } + }, + responses: { + 201: { + description: '创建成功', + content: { + 'application/json': { + schema: UserSchema + } + } + }, + 400: { + description: '输入数据无效', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(createUserRoute, async (c) => { + try { + const data = c.req.valid('json'); + const user = await userService.createUser(data); + return c.json(user, 201); + } catch (error) { + return c.json({ code: 500, message: '服务器错误' }, 500); + } +}); +export default app; \ No newline at end of file diff --git a/src/server/api/users/delete.ts b/src/server/api/users/delete.ts new file mode 100644 index 0000000..660eee3 --- /dev/null +++ b/src/server/api/users/delete.ts @@ -0,0 +1,61 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { UserService } from '../../modules/users/user.service'; +import { z } from 'zod'; +import { authMiddleware } from '../../middleware/auth.middleware'; +import { ErrorSchema } from '../../utils/errorHandler'; +import { AppDataSource } from '../../data-source'; +import { AuthContext } from '../../types/context'; + +const userService = new UserService(AppDataSource); + +const DeleteParams = z.object({ + id: z.string().openapi({ + param: { name: 'id', in: 'path' }, + example: '1', + description: '用户ID' + }) +}); + +const deleteRoute = createRoute({ + method: 'delete', + path: '/{id}', + middleware: authMiddleware, + request: { + params: DeleteParams + }, + responses: { + 204: { + description: '用户删除成功' + }, + 404: { + description: '用户不存在', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(deleteRoute, async (c) => { + try { + const { id } = c.req.valid('param'); + await userService.deleteUser(parseInt(id)); + return c.body(null, 204); + } catch (error) { + return c.json({ + code: 500, + message: '删除用户失败' + }, 500); + } +}); +export default app; \ No newline at end of file diff --git a/src/server/api/users/get.ts b/src/server/api/users/get.ts new file mode 100644 index 0000000..29d0b90 --- /dev/null +++ b/src/server/api/users/get.ts @@ -0,0 +1,77 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { UserService } from '../../modules/users/user.service'; +import { z } from 'zod'; +import { authMiddleware } from '../../middleware/auth.middleware'; +import { ErrorSchema } from '../../utils/errorHandler'; +import { AppDataSource } from '../../data-source'; +import { AuthContext } from '../../types/context'; + +const userService = new UserService(AppDataSource); + +const UserSchema = z.object({ + id: z.number().openapi({ example: 1 }), + username: z.string().openapi({ example: 'john_doe' }), + email: z.string().email().openapi({ example: 'john@example.com' }), + createdAt: z.string().datetime().openapi({ example: '2025-05-28T00:00:00Z' }) +}); + +const GetUserRoute = createRoute({ + method: 'get', + path: '/{id}', + middleware: authMiddleware, + request: { + params: z.object({ + id: z.string().openapi({ + param: { name: 'id', in: 'path' }, + example: '1', + description: '用户ID' + }) + }) + }, + responses: { + 200: { + description: '获取用户成功', + content: { + 'application/json': { + schema: UserSchema + } + } + }, + 404: { + description: '用户不存在', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(GetUserRoute, async (c) => { + try { + const { id } = c.req.valid('param'); + const user = await userService.getUserById(parseInt(id)); + if (!user) { + return c.json({ code: 404, message: '用户不存在' }, 404); + } + + return c.json({ + id: user.id, + username: user.username, + email: user.email, + createdAt: user.createdAt.toISOString() + }, 200); + } catch (error) { + return c.json({ code: 500, message: '服务器错误' }, 500); + } +}); +export default app; \ No newline at end of file diff --git a/src/server/api/users/index.ts b/src/server/api/users/index.ts new file mode 100644 index 0000000..9f2d92c --- /dev/null +++ b/src/server/api/users/index.ts @@ -0,0 +1,23 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { AuthContext } from '@/server/types/context'; +import createRoute from './create'; +import listRoute from './list'; +import getRoute from './get'; +import updateRoute from './update'; +import deleteRoute from './delete'; + +// const api = new OpenAPIHono() +// .route('/', createRoute) +// .route('/', listRoute) +// .route('/', getRoute) +// .route('/', updateRoute) +// .route('/', deleteRoute); + +// export default api; +export default { + createRoute, + listRoute, + getRoute, + updateRoute, + deleteRoute +} \ No newline at end of file diff --git a/src/server/api/users/list.ts b/src/server/api/users/list.ts new file mode 100644 index 0000000..783a2ab --- /dev/null +++ b/src/server/api/users/list.ts @@ -0,0 +1,57 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { UserService } from '../../modules/users/user.service'; +import { z } from 'zod'; +import { authMiddleware } from '../../middleware/auth.middleware'; +import { ErrorSchema } from '../../utils/errorHandler'; +import { AppDataSource } from '../../data-source'; +import { AuthContext } from '../../types/context'; + +const userService = new UserService(AppDataSource); + +const UserSchema = z.object({ + id: z.number().openapi({ example: 1 }), + username: z.string().openapi({ example: 'john_doe' }), + email: z.string().email().openapi({ example: 'john@example.com' }), + createdAt: z.string().datetime().openapi({ example: '2025-05-28T00:00:00Z' }) +}); + +const listUsersRoute = createRoute({ + method: 'get', + path: '/', + middleware: authMiddleware, + responses: { + 200: { + description: '成功获取用户列表', + content: { + 'application/json': { + schema: z.array(UserSchema) + } + } + }, + 500: { + description: '获取用户列表失败', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(listUsersRoute, async (c) => { + try { + const users = await userService.getUsers(); + const usersOut = users.map(user => ({ + id: user.id, + username: user.username, + email: user.email, + createdAt: user.createdAt.toISOString() + })); + return c.json(usersOut, 200); + } catch (error) { + return c.json({ code: 500, message: '获取用户列表失败' }, 500); + } +}); + +export default app; \ No newline at end of file diff --git a/src/server/api/users/update.ts b/src/server/api/users/update.ts new file mode 100644 index 0000000..8a3daa4 --- /dev/null +++ b/src/server/api/users/update.ts @@ -0,0 +1,113 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { UserService } from '../../modules/users/user.service'; +import { z } from 'zod'; +import { authMiddleware } from '../../middleware/auth.middleware'; +import { ErrorSchema } from '../../utils/errorHandler'; +import { AppDataSource } from '../../data-source'; +import { AuthContext } from '../../types/context'; + +const userService = new UserService(AppDataSource); + +const UserSchema = z.object({ + id: z.number().openapi({ example: 1 }), + username: z.string().openapi({ example: 'john_doe' }), + email: z.string().email().openapi({ example: 'john@example.com' }), + createdAt: z.string().datetime().openapi({ example: '2025-05-28T00:00:00Z' }) +}); + +const UpdateUserSchema = z.object({ + username: z.string().min(3).openapi({ + example: 'john_doe', + description: 'Minimum 3 characters' + }).optional(), + password: z.string().min(6).openapi({ + example: 'password123', + description: 'Minimum 6 characters' + }).optional(), + email: z.string().email().openapi({ + example: 'john@example.com', + description: 'Valid email address' + }).optional() +}); + +const UpdateParams = z.object({ + id: z.string().openapi({ + param: { name: 'id', in: 'path' }, + example: '1', + description: '用户ID' + }) +}); + +const updateRoute = createRoute({ + method: 'patch', + path: '/{id}', + middleware: authMiddleware, + request: { + params: UpdateParams, + body: { + content: { + 'application/json': { + schema: UpdateUserSchema + } + } + } + }, + responses: { + 200: { + description: '用户更新成功', + content: { + 'application/json': { + schema: UserSchema + } + } + }, + 400: { + description: '无效输入', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 404: { + description: '用户不存在', + content: { + 'application/json': { + schema: ErrorSchema + } + } + }, + 500: { + description: '服务器错误', + content: { + 'application/json': { + schema: ErrorSchema + } + } + } + } +}); + +const app = new OpenAPIHono().openapi(updateRoute, async (c) => { + try { + const { id } = c.req.valid('param'); + const data = c.req.valid('json'); + const user = await userService.updateUser(parseInt(id), data); + if (!user) { + return c.json({ code: 404, message: '用户不存在' }, 404); + } + return c.json({ + id: user.id, + username: user.username, + email: user.email, + createdAt: user.createdAt.toISOString() + }, 200); + } catch (error) { + return c.json({ + code: 500, + message: error instanceof Error ? error.message : '更新用户失败' + }, 500); + } +}); + +export default app; diff --git a/src/server/data-source.ts b/src/server/data-source.ts new file mode 100644 index 0000000..f6d697c --- /dev/null +++ b/src/server/data-source.ts @@ -0,0 +1,27 @@ +import "reflect-metadata" +import { DataSource } from "typeorm" +import { UserEntity as User } from "./modules/users/user.entity" +import { Role } from "./modules/users/role.entity"; +import { checkRequiredEnvVars } from "./utils/env-init"; +import { PaymentEntity } from "./modules/payment/payment.entity"; +import process from 'node:process' + +if (!checkRequiredEnvVars()) { + throw new Error("缺少必要的数据库环境变量配置,请检查.env文件"); +} + +// postgres +export const AppDataSource = new DataSource({ + type: "postgres", + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + username: process.env.DB_USERNAME || "postgres", + password: process.env.DB_PASSWORD || "", + database: process.env.DB_DATABASE || "postgres", + entities: [ + User, Role, PaymentEntity + ], + migrations: [], + synchronize: process.env.DB_SYNCHRONIZE === "true", + logging: true +}) \ No newline at end of file diff --git a/src/server/index.tsx b/src/server/index.tsx new file mode 100644 index 0000000..d24cf86 --- /dev/null +++ b/src/server/index.tsx @@ -0,0 +1,75 @@ +import 'dotenv/config' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { swaggerUI } from '@hono/swagger-ui' +import * as fs from 'fs/promises' +import { renderer } from './renderer' +import createApi from './api' +import HomePage from './pages/home' + + +const app = new Hono(); +// Middleware chain +app.use('*', logger()) +app.use('*', cors( + // { + // origin: ['http://localhost:3000'], + // allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + // credentials: true + // } +)) + + + + +app.use(renderer) + +app.route('/', createApi) + +app.get('/ui', swaggerUI({ + url: '/doc', + persistAuthorization: true +})) + + +app.get('/assets/:filename', async (c) => { + const filename = c.req.param('filename') + const filePath = import.meta.env.PROD? `./dist/assets/${filename}` : `./public/assets/${filename}` + const content = await fs.readFile(filePath); + const modifyDate = (await fs.stat(filePath))?.mtime?.toUTCString()?? new Date().toUTCString(); + + + const fileExt = filePath.split('.').pop()?.toLowerCase() + // 根据文件扩展名设置适当的 Content-Type + if (fileExt === 'tsx' || fileExt === 'ts') { + c.header('Content-Type', 'text/typescript; charset=utf-8') + } else if (fileExt === 'js' || fileExt === 'mjs') { + c.header('Content-Type', 'application/javascript; charset=utf-8') + } else if (fileExt === 'json') { + c.header('Content-Type', 'application/json; charset=utf-8') + } else if (fileExt === 'html') { + c.header('Content-Type', 'text/html; charset=utf-8') + } else if (fileExt === 'css') { + c.header('Content-Type', 'text/css; charset=utf-8') + } else if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt || '')) { + c.header('Content-Type', `image/${fileExt}`) + } + + return c.body(content, { + headers: { + // 'Content-Type': 'text/html; charset=utf-8', + 'Last-Modified': modifyDate + } + }) +}) + +app.get('/*', (c) => { + return c.render( + <> +
+ + ) +}) + +export default app diff --git a/src/server/middleware/auth.middleware.ts b/src/server/middleware/auth.middleware.ts new file mode 100644 index 0000000..a89bd95 --- /dev/null +++ b/src/server/middleware/auth.middleware.ts @@ -0,0 +1,36 @@ +import { Context, Next } from 'hono'; +import { AuthService } from '../modules/auth/auth.service'; +import { UserService } from '../modules/users/user.service'; +import { AppDataSource } from '../data-source'; +import { AuthContext } from '../types/context'; + +export async function authMiddleware(c: Context, next: Next) { + try { + const authHeader = c.req.header('Authorization'); + if (!authHeader) { + return c.json({ message: 'Authorization header missing' }, 401); + } + + const token = authHeader.split(' ')[1]; + if (!token) { + return c.json({ message: 'Token missing' }, 401); + } + + const userService = new UserService(AppDataSource); + const authService = new AuthService(userService); + const decoded = authService.verifyToken(token); + + const user = await userService.getUserById(decoded.id); + + if (!user) { + return c.json({ message: 'User not found' }, 401); + } + + c.set('user', user); + c.set('token', token); + await next(); + } catch (error) { + console.error('Authentication error:', error); + return c.json({ message: 'Invalid token' }, 401); + } +} \ No newline at end of file diff --git a/src/server/middleware/permission.middleware.ts b/src/server/middleware/permission.middleware.ts new file mode 100644 index 0000000..c3058d5 --- /dev/null +++ b/src/server/middleware/permission.middleware.ts @@ -0,0 +1,39 @@ +import { Context, Next } from 'hono'; +import { UserEntity as User } from '../modules/users/user.entity'; + +type PermissionCheck = (user: User) => boolean | Promise; + +export function checkPermission(requiredRoles: string[]): PermissionCheck { + return (user: User) => { + if (!user.roles) return false; + return user.roles.some(role => requiredRoles.includes(role.name)); + }; +} + +export function permissionMiddleware(check: PermissionCheck) { + return async (c: Context, next: Next) => { + try { + const user = c.get('user') as User | undefined; + if (!user) { + return c.json({ message: 'Unauthorized' }, 401); + } + + const hasPermission = await check(user); + if (!hasPermission) { + return c.json({ message: 'Forbidden' }, 403); + } + + await next(); + } catch (error) { + console.error('Permission check error:', error); + return c.json({ message: 'Internal server error' }, 500); + } + }; +} + +// 示例用法: +// app.get('/admin', +// authMiddleware, +// permissionMiddleware(checkPermission(['admin'])), +// (c) => {...} +// ) \ No newline at end of file diff --git a/src/server/modules/auth/auth.service.ts b/src/server/modules/auth/auth.service.ts new file mode 100644 index 0000000..7ff7855 --- /dev/null +++ b/src/server/modules/auth/auth.service.ts @@ -0,0 +1,119 @@ +import jwt from 'jsonwebtoken'; +import { UserService } from '../users/user.service'; +import { UserEntity as User } from '../users/user.entity'; +import { redisService } from '@/server/utils/redis'; + +const JWT_SECRET = 'your-secret-key'; // 生产环境应使用环境变量 +const JWT_EXPIRES_IN = '7d'; // 7天有效期 +const CODE_EXPIRATION = 5 * 60; // 5分钟有效期(秒) +const CODE_KEY_PREFIX = 'auth:code:'; + +export class AuthService { + private userService: UserService; + + constructor(userService: UserService) { + this.userService = userService; + } + + async login(username: string, password: string): Promise<{ token: string; user: User }> { + try { + const user = await this.userService.getUserByUsername(username); + if (!user) { + throw new Error('User not found'); + } + + const isPasswordValid = await this.userService.verifyPassword(user, password); + if (!isPasswordValid) { + throw new Error('Invalid password'); + } + + const token = this.generateToken(user); + return { token, user }; + } catch (error) { + console.error('Login error:', error); + throw error; + } + } + + generateToken(user: User): string { + const payload = { + id: user.id, + username: user.username, + roles: user.roles?.map(role => role.name) || [] + }; + return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + } + + verifyToken(token: string): any { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + console.error('Token verification failed:', error); + throw new Error('Invalid token'); + } + } + + generateFixedCode(phone: string): string { + // 基于手机号生成固定6位验证码 + const phoneHash = Array.from(phone).reduce((hash, char) => + ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0); + const code = Math.abs(phoneHash % 900000 + 100000).toString(); + // 位数不足时补0 + return code.padEnd(6, '0'); + } + + async generateRandCode(phone: string): Promise { + try { + const code = Math.floor(100000 + Math.random() * 900000).toString(); + const key = `${CODE_KEY_PREFIX}${phone}`; + + await redisService.set(key, code, CODE_EXPIRATION); + return code; + } catch (error) { + console.error('Redis set code error:', error); + throw new Error('验证码生成失败'); + } + } + + async verifyCode(phone: string, code: string): Promise { + if (this.generateFixedCode(phone) === code) { + return true; + } + + try { + const key = `${CODE_KEY_PREFIX}${phone}`; + const storedCode = await redisService.get(key); + + if (!storedCode) { + return false; + } + + const isValid = storedCode === code; + if (isValid) { + await redisService.del(key); + } + return isValid; + } catch (error) { + console.error('Redis verify code error:', error); + return false; + } + } + + async logout(token: string): Promise { + try { + // 验证token有效性 + const decoded = this.verifyToken(token); + if (!decoded) { + throw new Error('Invalid token'); + } + + // 实际项目中这里可以添加token黑名单逻辑 + // 或者调用Redis等缓存服务使token失效 + + return Promise.resolve(); + } catch (error) { + console.error('Logout failed:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/src/server/modules/payment/dto/payment.dto.ts b/src/server/modules/payment/dto/payment.dto.ts new file mode 100644 index 0000000..53e7917 --- /dev/null +++ b/src/server/modules/payment/dto/payment.dto.ts @@ -0,0 +1,233 @@ +import { z } from 'zod'; +import { PaymentStatus } from '../payment.entity'; + +export const PaymentRequestSchema = z.object({ + type: z.enum(['alipay', 'wxpay']).openapi({ + description: '支付类型', + example: 'alipay' + }), + out_trade_no: z.string().min(1).openapi({ + description: '商户订单号', + example: '202500000001' + }), + name: z.string().min(1).max(127).openapi({ + description: '商品名称', + example: 'VIP会员服务' + }), + money: z.union([ + z.string().regex(/^\d+(\.\d{1,2})?$/), + z.number().positive() + ]).transform(val => typeof val === 'string' ? parseFloat(val) : val).openapi({ + description: '支付金额(元),支持字符串或数字格式', + example: 9.9 + }), + clientip: z.string().ip().openapi({ + description: '客户端IP地址', + example: '127.0.0.1' + }), + device: z.string().optional().openapi({ + description: '设备信息,可选', + example: 'iPhone13,4' + }), + param: z.string().optional().openapi({ + description: '自定义参数,可选', + example: 'user_id=123' + }), +}); + +export const PaymentApiRequestSchema = z.object({ + pid: z.string().min(1).openapi({ + description: '商户ID', + example: '10086' + }), + type: z.enum(['alipay', 'wxpay']).openapi({ + description: '支付类型', + example: 'alipay' + }), + out_trade_no: z.string().min(1).openapi({ + description: '商户订单号', + example: '202500000001' + }), + notify_url: z.string().url().openapi({ + description: '异步通知地址', + example: 'https://example.com/payment/notify' + }), + name: z.string().min(1).max(127).openapi({ + description: '商品名称', + example: 'VIP会员服务' + }), + money: z.union([ + z.string().regex(/^\d+(\.\d{1,2})?$/), + z.number().positive() + ]).transform(val => typeof val === 'string' ? parseFloat(val) : val).openapi({ + description: '支付金额(元)', + example: 9.9 + }), + clientip: z.string().ip().openapi({ + description: '客户端IP地址', + example: '127.0.0.1' + }), + device: z.string().optional().openapi({ + description: '设备信息,可选', + example: 'iPhone13,4' + }), + param: z.string().optional().openapi({ + description: '自定义参数,可选', + example: 'user_id=123' + }), + sign: z.string().min(1).openapi({ + description: '签名', + example: 'e10adc3949ba59abbe56e057f20f883e' + }), + sign_type: z.literal('MD5').default('MD5').openapi({ + description: '签名类型', + example: 'MD5' + }), +}); + +export const PaymentResponseSchema = z.object({ + code: z.number().openapi({ + description: '响应状态码', + example: 200 + }), + msg: z.string().optional().openapi({ + description: '响应消息', + example: '支付请求成功' + }), + trade_no: z.string().optional().openapi({ + description: '支付平台交易号', + example: '202500000001' + }), + O_id: z.string().optional().openapi({ + description: '订单ID', + example: 'order_123456' + }), + payurl: z.string().optional().openapi({ + description: '支付跳转URL', + example: 'https://pay.example.com/pay/202500000001' + }), + qrcode: z.string().optional().openapi({ + description: '支付二维码内容', + example: 'weixin://wxpay/bizpayurl?pr=abcdefg' + }), + img: z.string().optional().openapi({ + description: '支付二维码图片URL', + example: 'https://pay.example.com/qrcode/202500000001.png' + }), +}); + +export type PaymentRequestDto = z.infer; +export const NotifyRequestSchema = z.object({ + pid: z.string().min(1).openapi({ + description: '商户ID', + example: '10086' + }), + trade_no: z.string().min(1).openapi({ + description: '支付平台交易号', + example: '202500000001' + }), + out_trade_no: z.string().min(1).openapi({ + description: '商户订单号', + example: '202500000001' + }), + type: z.enum(['alipay', 'wxpay']).openapi({ + description: '支付类型', + example: 'alipay' + }), + name: z.string().min(1).openapi({ + description: '商品名称', + example: 'VIP会员服务' + }), + money: z.union([ + z.string().regex(/^\d+(\.\d{1,2})?$/), + z.number().positive() + ]).transform(val => typeof val === 'string' ? parseFloat(val) : val).openapi({ + description: '支付金额(元)', + example: 9.9 + }), + trade_status: z.enum(['TRADE_SUCCESS', 'TRADE_FAILED']).openapi({ + description: '交易状态', + example: 'TRADE_SUCCESS' + }), + param: z.string().optional().openapi({ + description: '自定义参数', + example: 'user_id=123' + }), + sign: z.string().min(1).openapi({ + description: '签名', + example: 'e10adc3949ba59abbe56e057f20f883e' + }), + sign_type: z.literal('MD5').default('MD5').openapi({ + description: '签名类型', + example: 'MD5' + }), +}); + +export const QueryRequestSchema = z.object({ + order_id: z.string().min(1).openapi({ + description: '订单ID', + example: 'order_123456' + }), +}); + +export const QueryResponseSchema = z.object({ + code: z.number().openapi({ + description: '响应状态码', + example: 200 + }), + msg: z.string().optional().openapi({ + description: '响应消息', + example: '查询成功' + }), + data: z.object({ + out_trade_no: z.string().openapi({ + description: '商户订单号', + example: '202500000001' + }), + trade_no: z.string().optional().openapi({ + description: '支付平台交易号', + example: '202500000001' + }), + type: z.enum(['alipay', 'wxpay']).openapi({ + description: '支付类型', + example: 'alipay' + }), + name: z.string().openapi({ + description: '商品名称', + example: 'VIP会员服务' + }), + money: z.number().openapi({ + description: '支付金额(元)', + example: 9.9 + }), + status: z.nativeEnum(PaymentStatus).openapi({ + description: '订单状态', + example: 1 + }), + create_time: z.string().openapi({ + description: '订单创建时间', + example: '2025-05-29 12:00:00' + }), + update_time: z.string().openapi({ + description: '订单更新时间', + example: '2025-05-29 12:05:00' + }), + }).optional(), +}); + +export const PaymentErrorSchema = z.object({ + code: z.string().openapi({ + description: 'HTTP错误状态码', + example: 'error', + }), + msg: z.string().openapi({ + description: '错误描述信息', + example: '无效的支付参数', + }), +}); + +export type PaymentResponseDto = z.infer; +export type PaymentErrorDto = z.infer; +export type NotifyRequestDto = z.infer; +export type QueryRequestDto = z.infer; +export type QueryResponseDto = z.infer; diff --git a/src/server/modules/payment/payment.entity.ts b/src/server/modules/payment/payment.entity.ts new file mode 100644 index 0000000..2cf4458 --- /dev/null +++ b/src/server/modules/payment/payment.entity.ts @@ -0,0 +1,46 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum PaymentStatus { + PENDING = 0, + SUCCESS = 1, + FAILED = 2, + REFUNDED = 3, + CANCELED = 4 +} + +@Entity('d8d_payments') +export class PaymentEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'out_trade_no', type: 'varchar', length: 64 }) + outTradeNo!: string; + + @Column({ name: 'trade_no', type: 'varchar', length: 64, nullable: true }) + tradeNo!: string; + + @Column({ type: 'enum', enum: ['alipay', 'wxpay'] }) + type!: 'alipay' | 'wxpay'; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + money!: number; + + @Column({ type: 'varchar', length: 255 }) + name!: string; + + @Column({ name: 'client_ip', type: 'varchar', length: 64 }) + clientIp!: string; + + @Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING }) + status!: PaymentStatus; + + @Column({ name: 'error_msg', type: 'varchar', length: 512, nullable: true }) + errorMsg?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt!: Date; + +} \ No newline at end of file diff --git a/src/server/modules/payment/payment.service.ts b/src/server/modules/payment/payment.service.ts new file mode 100644 index 0000000..1a67055 --- /dev/null +++ b/src/server/modules/payment/payment.service.ts @@ -0,0 +1,190 @@ +import { DataSource } from 'typeorm'; +import { createHash } from 'crypto'; +import { FormData } from 'node-fetch'; +import fetch from 'node-fetch'; +import { z } from 'zod'; +import process from 'node:process' +import { PaymentEntity, PaymentStatus } from './payment.entity'; +import { PaymentApiRequestSchema } from './dto/payment.dto'; +import { + PaymentRequestDto, + PaymentResponseDto, + NotifyRequestDto, + QueryRequestDto, + QueryResponseDto, + PaymentRequestSchema, + PaymentResponseSchema, + PaymentErrorSchema, + PaymentErrorDto +} from './dto/payment.dto'; + +export class PaymentService { + private readonly pid: string; + private readonly pkey: string; + private readonly notifyUrl: string; + private readonly apiUrl: string; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + + if (!process.env.ZPAY_PID || !process.env.ZPAY_PKEY || !process.env.ZPAY_NOTIFY_URL) { + throw new Error('Missing required ZPAY environment variables'); + } + + this.pid = process.env.ZPAY_PID; + this.pkey = process.env.ZPAY_PKEY; + this.notifyUrl = process.env.ZPAY_NOTIFY_URL; + this.apiUrl = process.env.ZPAY_URL || 'https://zpayz.cn/mapi.php'; + } + + private readonly dataSource: DataSource; + + async createPayment(payload: PaymentRequestDto): Promise { + // 验证输入参数 + const validated = PaymentRequestSchema.parse(payload); + + const payment = new PaymentEntity(); + payment.outTradeNo = validated.out_trade_no; + payment.type = validated.type; + payment.money = validated.money; // 已由DTO转换 + payment.name = validated.name; + payment.clientIp = validated.clientip; + + await this.dataSource.manager.save(payment); + + const response = await this.callPaymentApi(payload); + + if (this.isPaymentResponse(response)) { + if (response.trade_no) payment.tradeNo = response.trade_no; + payment.status = PaymentStatus.SUCCESS; + } else { + payment.status = PaymentStatus.FAILED; + payment.errorMsg = response.msg; + } + await this.dataSource.manager.save(payment); + + return this.isPaymentResponse(response) ? response : { + code: 0, + msg: response.msg + }; + } + + async handleNotify(payload: NotifyRequestDto): Promise<{ code: number; msg: string }> { + // 验证签名 + const sign = this.generateSign(payload); + if (sign !== payload.sign) { + return { code: 0, msg: '签名验证失败' }; + } + + // 查找订单 + const payment = await this.dataSource.manager.findOne(PaymentEntity, { + where: [ + { outTradeNo: payload.out_trade_no }, + { tradeNo: payload.trade_no } + ] + }); + + if (!payment) { + return { code: 0, msg: '订单不存在' }; + } + + // 更新订单状态 + payment.status = payload.trade_status === 'TRADE_SUCCESS' ? PaymentStatus.SUCCESS : PaymentStatus.FAILED; + await this.dataSource.manager.save(payment); + + return { code: 1, msg: '处理成功' }; + } + + async queryPayment(payload: QueryRequestDto): Promise { + const payment = await this.dataSource.manager.findOne(PaymentEntity, { + where: { outTradeNo: payload.order_id } + }); + + if (!payment) { + return { code: 0, msg: '订单不存在' }; + } + + return { + code: 1, + msg: '查询成功', + data: { + out_trade_no: payment.outTradeNo, + trade_no: payment.tradeNo, + type: payment.type, + name: payment.name, + money: payment.money, + status: payment.status, + create_time: payment.createdAt.toISOString(), + update_time: payment.updatedAt.toISOString(), + } + }; + } + + private async callPaymentApi(payload: PaymentRequestDto): Promise { + const apiPayload = { + ...payload, + pid: this.pid, + notify_url: this.notifyUrl, + sign_type: 'MD5' + }; + const sign = this.generateSign(apiPayload); + + const validated = z.object({ + ...PaymentApiRequestSchema.shape, + sign: z.string().min(1) + }).parse({ + ...apiPayload, + sign + }); + + const form = new FormData(); + for (const [key, value] of Object.entries(validated)) { + form.append(key, value.toString()); + } + + const response = await fetch(this.apiUrl, { + method: 'POST', + body: form + }); + + const json = await response.json(); + + try { + return PaymentResponseSchema.parse(json); + } catch (error) { + return PaymentErrorSchema.parse(json); + } + } + + private isPaymentRequest(payload: any): payload is PaymentRequestDto { + return 'notify_url' in payload && 'clientip' in payload; + } + + private isPaymentResponse(response: PaymentResponseDto | PaymentErrorDto): response is PaymentResponseDto { + return typeof response.code === 'number'; + } + + private generateSign(payload: PaymentRequestDto | Omit): string { + const params = new URLSearchParams(); + params.append('pid', this.pid); + params.append('type', payload.type); + params.append('out_trade_no', payload.out_trade_no); + + if (this.isPaymentRequest(payload)) { + params.append('notify_url', this.notifyUrl); + params.append('clientip', payload.clientip); + if (payload.device) params.append('device', payload.device); + } + + params.append('name', payload.name); + params.append('money', payload.money.toString()); + params.append('sign_type', 'MD5'); + + if (payload.param) { + params.append('param', payload.param); + } + + const paramString = params.toString() + `&key=${this.pkey}`; + return createHash('md5').update(paramString).digest('hex').toLowerCase(); + } +} \ No newline at end of file diff --git a/src/server/modules/users/role.entity.ts b/src/server/modules/users/role.entity.ts new file mode 100644 index 0000000..16b7c63 --- /dev/null +++ b/src/server/modules/users/role.entity.ts @@ -0,0 +1,25 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export type Permission = string; + +@Entity({ name: 'd8d_role' }) +export class Role { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', length: 50, unique: true }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'simple-array', nullable: false }) + permissions: Permission[] = []; + + constructor(partial?: Partial) { + Object.assign(this, partial); + if (!this.permissions) { + this.permissions = []; + } + } +} \ No newline at end of file diff --git a/src/server/modules/users/user.entity.ts b/src/server/modules/users/user.entity.ts new file mode 100644 index 0000000..2448d0e --- /dev/null +++ b/src/server/modules/users/user.entity.ts @@ -0,0 +1,64 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Role } from './role.entity'; + +@Entity({ name: 'd8d_users' }) +export class UserEntity { + @PrimaryGeneratedColumn({ name: 'user_id', unsigned: true }) + id!: number; + + @Column({ nullable: true, name: 'account', unique: true, type: 'varchar', length: 255 }) + username!: string; + + @Column({ nullable: true, name: 'pwd', type: 'varchar', length: 255 }) + password!: string; + + @Column({ nullable: true, name: 'mail', unique: true, type: 'varchar', length: 100 }) + email!: string; + + @Column({ nullable: true, name: 'mobile', type: 'varchar', length: 255 }) + mobile!: string; + + @Column({ nullable: true, type: 'varchar', length: 50 }) + nickname!: string; + + @Column({ nullable: true, type: 'varchar', length: 50 }) + name!: string; + + @Column({ nullable: true, name: 'wx_web_openid', type: 'varchar', length: 50 }) + wxWebOpenid!: string; + + @Column({ nullable: true, name: 'wx_mini_openid', type: 'varchar', length: 50 }) + wxMiniOpenid!: string; + + @Column({ default: 0, type: 'int' }) + status!: number; + + @Column({ nullable: true, name: 'company_id', type: 'int' }) + companyId!: number; + + @Column({ nullable: true, type: 'varchar', length: 100 }) + department!: string; + + @Column({ nullable: true, type: 'varchar', length: 100 }) + position!: string; + + @Column({ default: 0, name: 'is_deleted', type: 'int' }) + isDeleted!: number; + + @Column({ default: 0, name: 'is_disabled', type: 'int' }) + isDisabled!: number; + + @ManyToMany(() => Role) + @JoinTable() + roles!: Role[]; + + @CreateDateColumn({name:'created_at', type: 'timestamp'}) + createdAt!: Date; + + @UpdateDateColumn({name:'updated_at', type: 'timestamp'}) + updatedAt!: Date; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} \ No newline at end of file diff --git a/src/server/modules/users/user.service.ts b/src/server/modules/users/user.service.ts new file mode 100644 index 0000000..a81ef6e --- /dev/null +++ b/src/server/modules/users/user.service.ts @@ -0,0 +1,138 @@ +import { HTTPException } from 'hono/http-exception' +import { DataSource } from 'typeorm'; +import { UserEntity as User } from './user.entity'; +import * as bcrypt from 'bcrypt'; +import { Repository } from 'typeorm'; +import { Role } from './role.entity'; + +const SALT_ROUNDS = 10; + +export class UserService { + private userRepository: Repository; + private roleRepository: Repository; + private readonly dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.userRepository = this.dataSource.getRepository(User); + this.roleRepository = this.dataSource.getRepository(Role); + } + + async createUser(userData: Partial): Promise { + try { + if (userData.password) { + userData.password = await bcrypt.hash(userData.password, SALT_ROUNDS); + } + const user = this.userRepository.create(userData); + return await this.userRepository.save(user); + } catch (error) { + console.error('Error creating user:', error); + throw new HTTPException(400,{ message: 'Failed to create user', cause: error}) + } + } + + async getUserById(id: number): Promise { + try { + return await this.userRepository.findOne({ + where: { id }, + relations: ['roles'] + }); + } catch (error) { + console.error('Error getting user:', error); + throw new Error('Failed to get user'); + } + } + + async getUserByUsername(username: string): Promise { + try { + return await this.userRepository.findOne({ + where: { username }, + relations: ['roles'] + }); + } catch (error) { + console.error('Error getting user:', error); + throw new Error('Failed to get user'); + } + } + + async getUserByPhone(phone: string): Promise { + try { + return await this.userRepository.findOne({ + where: { mobile: phone }, + relations: ['roles'] + }); + } catch (error) { + console.error('Error getting user by phone:', error); + throw new Error('Failed to get user by phone'); + } + } + + async updateUser(id: number, updateData: Partial): Promise { + try { + if (updateData.password) { + updateData.password = await bcrypt.hash(updateData.password, SALT_ROUNDS); + } + await this.userRepository.update(id, updateData); + return this.getUserById(id); + } catch (error) { + console.error('Error updating user:', error); + throw new Error('Failed to update user'); + } + } + + async deleteUser(id: number): Promise { + try { + await this.userRepository.delete(id); + } catch (error) { + console.error('Error deleting user:', error); + throw new Error('Failed to delete user'); + } + } + + async verifyPassword(user: User, password: string): Promise { + return bcrypt.compare(password, user.password); + } + + async assignRoles(userId: number, roleIds: number[]): Promise { + try { + const user = await this.getUserById(userId); + if (!user) return null; + + const roles = await this.roleRepository.findByIds(roleIds); + user.roles = roles; + return await this.userRepository.save(user); + } catch (error) { + console.error('Error assigning roles:', error); + throw new Error('Failed to assign roles'); + } + } + + async getUsers(): Promise { + try { + const users = await this.userRepository.find({ + relations: ['roles'] + }); + return users; + } catch (error) { + console.error('Error getting users:', error); + throw new HTTPException(500, { message: 'Failed to get users', cause: error }) + } + } + + + getUserRepository(): Repository { + return this.userRepository; + } + + async getUserByAccount(account: string): Promise { + try { + return await this.userRepository.findOne({ + where: [{ username: account }, { email: account }], + relations: ['roles'] + }); + } catch (error) { + console.error('Error getting user by account:', error); + throw new Error('Failed to get user by account'); + } + } +} \ No newline at end of file diff --git a/src/server/renderer.tsx b/src/server/renderer.tsx new file mode 100644 index 0000000..d506dc2 --- /dev/null +++ b/src/server/renderer.tsx @@ -0,0 +1,46 @@ +import { GlobalConfig } from '@/share/types' +import { reactRenderer } from '@hono/react-renderer' +import { Script, Link } from 'hono-vite-react-stack-node/components' +import process from 'node:process' + +// 全局配置常量 +const GLOBAL_CONFIG: GlobalConfig = { + OSS_BASE_URL: process.env.OSS_BASE_URL || 'https://oss.d8d.fun', + // API_BASE_URL: '/api', + APP_NAME: process.env.APP_NAME || '多八多Aider', + ENV: process.env.NODE_ENV || 'production', // 添加环境变量, + ROOT_DIRECTORY: process.env.ROOT_DIRECTORY || '', + REMOTE_AUTHORITY: process.env.REMOTE_AUTHORITY || '', + VERSION: process.env.VERSION || '0.1.0', +} + +export const renderer = reactRenderer(({ children }) => { + return ( + + + + + +