2
0
Эх сурвалжийг харах

✨ feat(geo-areas): 新增中国行政区划管理模块

- 创建完整的省市区管理模块,包含实体、服务、API和类型定义
- 实现树形结构管理功能,支持完整树形结构、层级树形结构和子树查询
- 提供公共API接口,支持按省份、城市、区县分级查询
- 实现管理后台API,包含CRUD操作和树形结构管理功能
- 添加完整的Zod验证Schema和TypeScript类型定义
- 配置构建工具和测试环境,支持模块化开发和测试
yourname 3 долоо хоног өмнө
parent
commit
8edd45c6fa

+ 68 - 0
packages/geo-areas/package.json

@@ -0,0 +1,68 @@
+{
+  "name": "@d8d/geo-areas",
+  "version": "1.0.0",
+  "type": "module",
+  "description": "D8D Geo Areas Module - 中国行政区划管理",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./src/index.ts",
+      "require": "./src/index.ts",
+      "types": "./src/index.ts"
+    },
+    "./area.entity": {
+      "import": "./src/modules/areas/area.entity.ts",
+      "require": "./src/modules/areas/area.entity.ts",
+      "types": "./src/modules/areas/area.entity.ts"
+    },
+    "./area.service": {
+      "import": "./src/modules/areas/area.service.ts",
+      "require": "./src/modules/areas/area.service.ts",
+      "types": "./src/modules/areas/area.service.ts"
+    },
+    "./area.schema": {
+      "import": "./src/modules/areas/area.schema.ts",
+      "require": "./src/modules/areas/area.schema.ts",
+      "types": "./src/modules/areas/area.schema.ts"
+    },
+    "./api": {
+      "import": "./src/api/areas/index.ts",
+      "require": "./src/api/areas/index.ts",
+      "types": "./src/api/areas/index.ts"
+    },
+    "./api/admin": {
+      "import": "./src/api/admin/areas/index.ts",
+      "require": "./src/api/admin/areas/index.ts",
+      "types": "./src/api/admin/areas/index.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc",
+    "dev": "tsc --watch",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest",
+    "test:unit": "vitest run tests/unit",
+    "test:integration": "vitest run tests/integration",
+    "test:coverage": "vitest --coverage",
+    "test:typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@d8d/shared-types": "workspace:*",
+    "@d8d/shared-utils": "workspace:*",
+    "@d8d/shared-crud": "workspace:*",
+    "@hono/zod-openapi": "1.0.2",
+    "hono": "^4.8.5",
+    "typeorm": "^0.3.20",
+    "zod": "^4.1.12"
+  },
+  "devDependencies": {
+    "@d8d/shared-test-util": "workspace:*",
+    "typescript": "^5.8.3",
+    "vitest": "^3.2.4",
+    "@vitest/coverage-v8": "^3.2.4"
+  },
+  "files": [
+    "src"
+  ]
+}

+ 28 - 0
packages/geo-areas/src/api/admin/areas/index.ts

@@ -0,0 +1,28 @@
+import { createCrudRoutes } from '@d8d/shared-crud';
+import { authMiddleware } from '@d8d/auth-module';
+import { AreaEntity } from '../../modules/areas/area.entity';
+import {
+  createAreaSchema,
+  updateAreaSchema,
+  getAreaSchema,
+  areaListResponseSchema
+} from '../../modules/areas/area.schema';
+import treeRoutes from './tree';
+import { OpenAPIHono } from '@hono/zod-openapi';
+
+// 使用通用CRUD路由创建省市区管理API
+const areaRoutes = createCrudRoutes({
+  entity: AreaEntity,
+  createSchema: createAreaSchema,
+  updateSchema: updateAreaSchema,
+  getSchema: getAreaSchema,
+  listSchema: areaListResponseSchema,
+  searchFields: ['name', 'code'],
+  relations: ['parent', 'children'],
+  middleware: [authMiddleware]
+})
+
+export default new OpenAPIHono()
+  // 合并树形结构路由
+  .route('/', treeRoutes)
+  .route('/', areaRoutes);

+ 293 - 0
packages/geo-areas/src/api/admin/areas/tree.ts

@@ -0,0 +1,293 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { createRoute, z } from '@hono/zod-openapi';
+import { authMiddleware } from '@d8d/auth-module';
+import { AreaService } from '../../modules/areas/area.service';
+import { AreaLevel } from '../../modules/areas/area.entity';
+
+// 获取完整树形结构
+const getAreaTreeRoute = createRoute({
+  method: 'get',
+  path: '/tree',
+  description: '获取完整的省市区树形结构',
+  tags: ['省市区管理'],
+  middleware: [authMiddleware],
+  responses: {
+    200: {
+      description: '成功获取树形结构',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            data: z.array(z.object({
+              id: z.number(),
+              parentId: z.number().nullable(),
+              name: z.string(),
+              level: z.number(),
+              code: z.string(),
+              isDisabled: z.number(),
+              children: z.array(z.any()).optional()
+            }))
+          })
+        }
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    }
+  }
+});
+
+// 根据层级获取树形结构
+const getAreaTreeByLevelRoute = createRoute({
+  method: 'get',
+  path: '/tree/level/{level}',
+  description: '根据层级获取树形结构',
+  tags: ['省市区管理'],
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      level: z.coerce.number().min(1).max(3)
+    })
+  },
+  responses: {
+    200: {
+      description: '成功获取层级树形结构',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            data: z.array(z.object({
+              id: z.number(),
+              parentId: z.number().nullable(),
+              name: z.string(),
+              level: z.number(),
+              code: z.string(),
+              isDisabled: z.number(),
+              children: z.array(z.any()).optional()
+            }))
+          })
+        }
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    }
+  }
+});
+
+// 获取子树
+const getSubTreeRoute = createRoute({
+  method: 'get',
+  path: '/tree/{id}',
+  description: '获取指定节点的子树',
+  tags: ['省市区管理'],
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().positive('区域ID必须为正整数')
+    })
+  },
+  responses: {
+    200: {
+      description: '成功获取子树',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            data: z.object({
+              id: z.number(),
+              parentId: z.number().nullable(),
+              name: z.string(),
+              level: z.number(),
+              code: z.string(),
+              isDisabled: z.number(),
+              children: z.array(z.any()).optional()
+            }).nullable()
+          })
+        }
+      }
+    },
+    404: {
+      description: '区域不存在',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    }
+  }
+});
+
+// 获取区域路径
+const getAreaPathRoute = createRoute({
+  method: 'get',
+  path: '/path/{id}',
+  description: '获取区域路径(从根节点到当前节点)',
+  tags: ['省市区管理'],
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().positive('区域ID必须为正整数')
+    })
+  },
+  responses: {
+    200: {
+      description: '成功获取区域路径',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            data: z.array(z.object({
+              id: z.number(),
+              parentId: z.number().nullable(),
+              name: z.string(),
+              level: z.number(),
+              code: z.string(),
+              isDisabled: z.number()
+            }))
+          })
+        }
+      }
+    },
+    404: {
+      description: '区域不存在',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean(),
+            error: z.string()
+          })
+        }
+      }
+    }
+  }
+});
+
+const app = new OpenAPIHono()
+  // 注册路由 - 使用链式结构
+  .openapi(getAreaTreeRoute, async (c) => {
+    const areaService = new AreaService(c.var.dataSource);
+    try {
+      const treeData = await areaService.getAreaTree();
+      return c.json({
+        success: true,
+        data: treeData
+      }, 200);
+    } catch (error) {
+      console.error('获取省市区树形结构失败:', error);
+      return c.json({
+        success: false,
+        error: '获取省市区树形结构失败'
+      }, 500);
+    }
+  })
+  .openapi(getAreaTreeByLevelRoute, async (c) => {
+    const areaService = new AreaService(c.var.dataSource);
+    try {
+      const { level } = c.req.valid('param');
+      const treeData = await areaService.getAreaTreeByLevel(level);
+      return c.json({
+        success: true,
+        data: treeData
+      }, 200);
+    } catch (error) {
+      console.error('获取层级树形结构失败:', error);
+      return c.json({
+        success: false,
+        error: '获取层级树形结构失败'
+      }, 500);
+    }
+  })
+  .openapi(getSubTreeRoute, async (c) => {
+    const areaService = new AreaService(c.var.dataSource);
+    try {
+      const { id } = c.req.valid('param');
+      const subTree = await areaService.getSubTree(id);
+
+      if (!subTree) {
+        return c.json({
+          success: false,
+          error: '区域不存在'
+        }, 404);
+      }
+
+      return c.json({
+        success: true,
+        data: subTree
+      }, 200);
+    } catch (error) {
+      console.error('获取子树失败:', error);
+      return c.json({
+        success: false,
+        error: '获取子树失败'
+      }, 500);
+    }
+  })
+  .openapi(getAreaPathRoute, async (c) => {
+    const areaService = new AreaService(c.var.dataSource);
+    try {
+      const { id } = c.req.valid('param');
+      const path = await areaService.getAreaPath(id);
+
+      if (path.length === 0) {
+        return c.json({
+          success: false,
+          error: '区域不存在'
+        }, 404);
+      }
+
+      return c.json({
+        success: true,
+        data: path
+      }, 200);
+    } catch (error) {
+      console.error('获取区域路径失败:', error);
+      return c.json({
+        success: false,
+        error: '获取区域路径失败'
+      }, 500);
+    }
+  });
+
+export default app;

+ 293 - 0
packages/geo-areas/src/api/areas/index.ts

@@ -0,0 +1,293 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { AreaService } from '../../modules/areas/area.service';
+import { AreaLevel } from '../../modules/areas/area.entity';
+
+// 省份查询参数Schema
+const getProvincesSchema = z.object({
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
+    example: 1,
+    description: '页码'
+  }),
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
+    example: 50,
+    description: '每页数量'
+  })
+});
+
+// 城市查询参数Schema
+const getCitiesSchema = z.object({
+  provinceId: z.coerce.number<number>().int().positive('省份ID必须为正整数').openapi({
+    example: 1,
+    description: '省份ID'
+  }),
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
+    example: 1,
+    description: '页码'
+  }),
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
+    example: 50,
+    description: '每页数量'
+  })
+});
+
+// 区县查询参数Schema
+const getDistrictsSchema = z.object({
+  cityId: z.coerce.number<number>().int().positive('城市ID必须为正整数').openapi({
+    example: 34,
+    description: '城市ID'
+  }),
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
+    example: 1,
+    description: '页码'
+  }),
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
+    example: 50,
+    description: '每页数量'
+  })
+});
+
+// 省市区响应Schema
+const areaResponseSchema = z.object({
+  id: z.number(),
+  name: z.string(),
+  code: z.string(),
+  level: z.number(),
+  parentId: z.number().nullable()
+});
+
+// 省份列表响应Schema
+const provincesResponseSchema = z.object({
+  success: z.boolean(),
+  data: z.object({
+    provinces: z.array(areaResponseSchema),
+    pagination: z.object({
+      page: z.number(),
+      pageSize: z.number(),
+      total: z.number(),
+      totalPages: z.number()
+    })
+  }),
+  message: z.string()
+});
+
+// 城市列表响应Schema
+const citiesResponseSchema = z.object({
+  success: z.boolean(),
+  data: z.object({
+    cities: z.array(areaResponseSchema),
+    pagination: z.object({
+      page: z.number(),
+      pageSize: z.number(),
+      total: z.number(),
+      totalPages: z.number()
+    })
+  }),
+  message: z.string()
+});
+
+// 区县列表响应Schema
+const districtsResponseSchema = z.object({
+  success: z.boolean(),
+  data: z.object({
+    districts: z.array(areaResponseSchema),
+    pagination: z.object({
+      page: z.number(),
+      pageSize: z.number(),
+      total: z.number(),
+      totalPages: z.number()
+    })
+  }),
+  message: z.string()
+});
+
+// 错误响应Schema
+const errorSchema = z.object({
+  code: z.number(),
+  message: z.string(),
+  errors: z.array(z.object({
+    path: z.array(z.string()),
+    message: z.string()
+  })).optional()
+});
+
+// 创建省份查询路由
+const getProvincesRoute = createRoute({
+  method: 'get',
+  path: '/provinces',
+  request: {
+    query: getProvincesSchema
+  },
+  responses: {
+    200: {
+      description: '获取省份列表成功',
+      content: {
+        'application/json': { schema: provincesResponseSchema }
+      }
+    },
+    500: {
+      description: '获取省份列表失败',
+      content: { 'application/json': { schema: errorSchema } }
+    }
+  }
+});
+
+// 创建城市查询路由
+const getCitiesRoute = createRoute({
+  method: 'get',
+  path: '/cities',
+  request: {
+    query: getCitiesSchema
+  },
+  responses: {
+    200: {
+      description: '获取城市列表成功',
+      content: {
+        'application/json': { schema: citiesResponseSchema }
+      }
+    },
+    400: {
+      description: '参数错误',
+      content: { 'application/json': { schema: errorSchema } }
+    },
+    500: {
+      description: '获取城市列表失败',
+      content: { 'application/json': { schema: errorSchema } }
+    }
+  }
+});
+
+// 创建区县查询路由
+const getDistrictsRoute = createRoute({
+  method: 'get',
+  path: '/districts',
+  request: {
+    query: getDistrictsSchema
+  },
+  responses: {
+    200: {
+      description: '获取区县列表成功',
+      content: {
+        'application/json': { schema: districtsResponseSchema }
+      }
+    },
+    400: {
+      description: '参数错误',
+      content: { 'application/json': { schema: errorSchema } }
+    },
+    500: {
+      description: '获取区县列表失败',
+      content: { 'application/json': { schema: errorSchema } }
+    }
+  }
+});
+
+const app = new OpenAPIHono()
+  .openapi(getProvincesRoute, async (c) => {
+    try {
+      const { page, pageSize } = c.req.valid('query');
+      // 注意:AreaService 现在需要传入数据源,这里需要由调用方提供
+      // 在实际使用中,需要通过依赖注入或其他方式提供数据源
+      const areaService = new AreaService(c.var.dataSource);
+
+      // 获取所有省份数据
+      const provinces = await areaService.getAreaTreeByLevel(AreaLevel.PROVINCE);
+
+      // 分页
+      const startIndex = (page - 1) * pageSize;
+      const endIndex = startIndex + pageSize;
+      const paginatedProvinces = provinces.slice(startIndex, endIndex);
+
+      return c.json({
+        success: true,
+        data: {
+          provinces: paginatedProvinces,
+          pagination: {
+            page,
+            pageSize,
+            total: provinces.length,
+            totalPages: Math.ceil(provinces.length / pageSize)
+          }
+        },
+        message: '获取省份列表成功'
+      }, 200);
+    } catch (error) {
+      console.error('获取省份列表失败:', error);
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '获取省份列表失败'
+      }, 500);
+    }
+  })
+  .openapi(getCitiesRoute, async (c) => {
+    try {
+      const { provinceId, page, pageSize } = c.req.valid('query');
+      const areaService = new AreaService(c.var.dataSource);
+
+      // 获取指定省份下的所有城市
+      const allCities = await areaService.getAreaTreeByLevel(AreaLevel.CITY);
+      const cities = allCities.filter(city => city.parentId === provinceId);
+
+      // 分页
+      const startIndex = (page - 1) * pageSize;
+      const endIndex = startIndex + pageSize;
+      const paginatedCities = cities.slice(startIndex, endIndex);
+
+      return c.json({
+        success: true,
+        data: {
+          cities: paginatedCities,
+          pagination: {
+            page,
+            pageSize,
+            total: cities.length,
+            totalPages: Math.ceil(cities.length / pageSize)
+          }
+        },
+        message: '获取城市列表成功'
+      }, 200);
+    } catch (error) {
+      console.error('获取城市列表失败:', error);
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '获取城市列表失败'
+      }, 500);
+    }
+  })
+  .openapi(getDistrictsRoute, async (c) => {
+    try {
+      const { cityId, page, pageSize } = c.req.valid('query');
+      const areaService = new AreaService(c.var.dataSource);
+
+      // 获取指定城市下的所有区县
+      const allDistricts = await areaService.getAreaTreeByLevel(AreaLevel.DISTRICT);
+      const districts = allDistricts.filter(district => district.parentId === cityId);
+
+      // 分页
+      const startIndex = (page - 1) * pageSize;
+      const endIndex = startIndex + pageSize;
+      const paginatedDistricts = districts.slice(startIndex, endIndex);
+
+      return c.json({
+        success: true,
+        data: {
+          districts: paginatedDistricts,
+          pagination: {
+            page,
+            pageSize,
+            total: districts.length,
+            totalPages: Math.ceil(districts.length / pageSize)
+          }
+        },
+        message: '获取区县列表成功'
+      }, 200);
+    } catch (error) {
+      console.error('获取区县列表失败:', error);
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '获取区县列表失败'
+      }, 500);
+    }
+  });
+
+export default app;

+ 21 - 0
packages/geo-areas/src/index.ts

@@ -0,0 +1,21 @@
+// Geo Areas Module 主入口文件
+
+export { AreaEntity, AreaLevel } from './modules/areas/area.entity';
+export { AreaService } from './modules/areas/area.service';
+export * from './modules/areas/area.schema';
+
+export { default as areasRoutes } from './api/areas/index';
+export { default as adminAreasRoutes } from './api/admin/areas/index';
+
+// 类型导出
+export type {
+  CreateAreaInput,
+  UpdateAreaInput,
+  GetAreaInput,
+  ListAreasInput,
+  DeleteAreaInput,
+  ToggleAreaStatusInput,
+  GetAreasByLevelInput,
+  GetChildAreasInput,
+  GetAreaPathInput
+} from './modules/areas/area.schema';

+ 62 - 0
packages/geo-areas/src/modules/areas/area.entity.ts

@@ -0,0 +1,62 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { DeleteStatus, DisabledStatus } from '@d8d/shared-types';
+
+export enum AreaLevel {
+  PROVINCE = 1, // 省/直辖市
+  CITY = 2,     // 市
+  DISTRICT = 3  // 区/县
+}
+
+@Entity({ name: 'areas' })
+export class AreaEntity {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '区域ID' })
+  id!: number;
+
+  @Column({ name: 'parent_id', type: 'int', unsigned: true, nullable: true, default: null, comment: '父级区域ID,null表示顶级(省/直辖市)' })
+  parentId!: number | null;
+
+  @Column({ name: 'name', type: 'varchar', length: 100, comment: '区域名称' })
+  name!: string;
+
+  @Column({
+    name: 'level',
+    type: 'enum',
+    enum: AreaLevel,
+    comment: '层级: 1:省/直辖市, 2:市, 3:区/县'
+  })
+  level!: AreaLevel;
+
+  @Column({ name: 'code', type: 'varchar', length: 20, unique: true, comment: '行政区划代码' })
+  code!: string;
+
+  // 自关联关系 - 父级区域
+  @ManyToOne(() => AreaEntity, (area) => area.children)
+  @JoinColumn({ name: 'parent_id', referencedColumnName: 'id' })
+  parent!: AreaEntity | null;
+
+  // 自关联关系 - 子级区域
+  @OneToMany(() => AreaEntity, (area) => area.parent)
+  children!: AreaEntity[];
+
+  @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
+  isDisabled!: DisabledStatus;
+
+  @Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: DeleteStatus;
+
+  @Column({ name: 'created_by', type: 'int', nullable: true, comment: '创建人ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', nullable: true, comment: '更新人ID' })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  constructor(partial?: Partial<AreaEntity>) {
+    Object.assign(this, partial);
+  }
+}

+ 137 - 0
packages/geo-areas/src/modules/areas/area.schema.ts

@@ -0,0 +1,137 @@
+import { z } from 'zod';
+import { DisabledStatus } from '@d8d/shared-types';
+import { AreaLevel } from './area.entity';
+
+// 省市区创建Schema
+export const createAreaSchema = z.object({
+  parentId: z.number().int().min(0, '父级ID不能为负数').nullable().default(null),
+  name: z.string().min(1, '区域名称不能为空').max(100, '区域名称不能超过100个字符'),
+  level: z.nativeEnum(AreaLevel, {
+    message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'
+  }),
+  code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
+  isDisabled: z.nativeEnum(DisabledStatus).default(DisabledStatus.ENABLED),
+}).refine((data) => {
+  // 验证层级和父级ID的关系
+  if (data.level === AreaLevel.PROVINCE && data.parentId !== null) {
+    return false;
+  }
+  if (data.level !== AreaLevel.PROVINCE && data.parentId === null) {
+    return false;
+  }
+  return true;
+}, {
+  message: '层级和父级ID关系不正确:省/直辖市(parentId=null),市/区县(parentId>0)',
+  path: ['parentId'],
+});
+
+// 省市区更新Schema
+export const updateAreaSchema = z.object({
+  parentId: z.number().int().min(0, '父级ID不能为负数').nullable().optional(),
+  name: z.string().min(1, '区域名称不能为空').max(100, '区域名称不能超过100个字符').optional(),
+  level: z.nativeEnum(AreaLevel, {
+    message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'
+  }).optional(),
+  code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符').optional(),
+  isDisabled: z.nativeEnum(DisabledStatus).optional(),
+}).refine((data) => {
+  // 验证层级和父级ID的关系
+  // 只有当两个字段都有值且都不为undefined时才进行验证
+  if (data.level !== undefined && data.parentId !== undefined) {
+    if (data.level === AreaLevel.PROVINCE && data.parentId !== null) {
+      return false;
+    }
+    if (data.level !== AreaLevel.PROVINCE && data.parentId === null) {
+      return false;
+    }
+  }
+  return true;
+}, {
+  message: '层级和父级ID关系不正确:省/直辖市(parentId=null),市/区县(parentId>0)',
+  path: ['parentId'],
+});
+
+// 省市区获取Schema
+export const getAreaSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  parentId: z.number().int().min(0, '父级ID不能为负数').nullable(),
+  name: z.string().min(1, '区域名称不能为空').max(100, '区域名称不能超过100个字符'),
+  level: z.nativeEnum(AreaLevel, {
+    message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'
+  }),
+  code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+  isDeleted: z.number().int(),
+  createdAt: z.coerce.date(),
+  updatedAt: z.coerce.date(),
+  createdBy: z.number().int().nullable(),
+  updatedBy: z.number().int().nullable(),
+});
+
+// 省市区列表查询Schema
+export const listAreasSchema = z.object({
+  keyword: z.string().optional(),
+  level: z.nativeEnum(AreaLevel).optional(),
+  parentId: z.coerce.number().int().min(0).optional(),
+  isDisabled: z.coerce.number().int().optional(),
+  page: z.coerce.number().int().min(1).default(1),
+  pageSize: z.coerce.number().int().min(1).max(100).default(20),
+  sortBy: z.enum(['name', 'level', 'code', 'createdAt']).default('createdAt'),
+  sortOrder: z.enum(['ASC', 'DESC']).default('DESC'),
+});
+
+// 省市区列表返回Schema
+export const areaListResponseSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  parentId: z.coerce.number().int().min(0, '父级ID不能为负数').nullable(),
+  name: z.string().min(1, '区域名称不能为空').max(100, '区域名称不能超过100个字符'),
+  level: z.nativeEnum(AreaLevel, {
+    message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'
+  }),
+  code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+  isDeleted: z.number().int(),
+  createdAt: z.coerce.date(),
+  updatedAt: z.coerce.date(),
+  createdBy: z.number().int().nullable(),
+  updatedBy: z.number().int().nullable(),
+});
+
+// 省市区删除Schema
+export const deleteAreaSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+});
+
+// 省市区启用/禁用Schema
+export const toggleAreaStatusSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+});
+
+// 省市区层级查询Schema
+export const getAreasByLevelSchema = z.object({
+  level: z.nativeEnum(AreaLevel, {
+    message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'
+  }),
+});
+
+// 省市区子级查询Schema
+export const getChildAreasSchema = z.object({
+  parentId: z.number().int().positive('父级ID必须为正整数'),
+});
+
+// 省市区路径查询Schema
+export const getAreaPathSchema = z.object({
+  id: z.number().int().positive('区域ID必须为正整数'),
+});
+
+// 导出类型
+export type CreateAreaInput = z.infer<typeof createAreaSchema>;
+export type UpdateAreaInput = z.infer<typeof updateAreaSchema>;
+export type GetAreaInput = z.infer<typeof getAreaSchema>;
+export type ListAreasInput = z.infer<typeof listAreasSchema>;
+export type DeleteAreaInput = z.infer<typeof deleteAreaSchema>;
+export type ToggleAreaStatusInput = z.infer<typeof toggleAreaStatusSchema>;
+export type GetAreasByLevelInput = z.infer<typeof getAreasByLevelSchema>;
+export type GetChildAreasInput = z.infer<typeof getChildAreasSchema>;
+export type GetAreaPathInput = z.infer<typeof getAreaPathSchema>;

+ 163 - 0
packages/geo-areas/src/modules/areas/area.service.ts

@@ -0,0 +1,163 @@
+import { DataSource } from 'typeorm';
+import { AreaEntity, AreaLevel } from './area.entity';
+import { DisabledStatus } from '@d8d/shared-types';
+
+export class AreaService {
+  private areaRepository;
+
+  constructor(dataSource: DataSource) {
+    this.areaRepository = dataSource.getRepository(AreaEntity);
+  }
+
+  /**
+   * 获取完整的省市区树形结构
+   */
+  async getAreaTree(): Promise<AreaEntity[]> {
+    const areas = await this.areaRepository.find({
+      where: { isDeleted: 0 },
+      order: {
+        id: 'ASC',
+        level: 'ASC',
+        name: 'ASC',
+      }
+    });
+
+    // 构建树形结构
+    return this.buildTree(areas);
+  }
+
+  /**
+   * 根据层级获取树形结构
+   */
+  async getAreaTreeByLevel(level: AreaLevel): Promise<AreaEntity[]> {
+    const areas = await this.areaRepository.find({
+      where: {
+        level,
+        isDeleted: 0,
+        isDisabled: DisabledStatus.ENABLED
+      },
+      relations: ['children'],
+      order: {
+        id: 'ASC',
+        level: 'ASC',
+        name: 'ASC'
+      }
+    });
+
+    return areas;
+  }
+
+  /**
+   * 获取指定节点的子树
+   */
+  async getSubTree(areaId: number): Promise<AreaEntity | null> {
+    const area = await this.areaRepository.findOne({
+      where: {
+        id: areaId,
+        isDeleted: 0,
+        isDisabled: DisabledStatus.ENABLED
+      },
+      relations: ['children'],
+    });
+
+    if (!area) return null;
+
+    // 递归加载所有子节点
+    await this.loadChildrenRecursively(area);
+    return area;
+  }
+
+  /**
+   * 递归加载所有子节点
+   */
+  private async loadChildrenRecursively(area: AreaEntity): Promise<void> {
+    if (area.children && area.children.length > 0) {
+      for (const child of area.children) {
+        const fullChild = await this.areaRepository.findOne({
+          where: {
+            id: child.id,
+            isDeleted: 0,
+            isDisabled: DisabledStatus.ENABLED
+          },
+          relations: ['children'],
+        });
+
+        if (fullChild) {
+          child.children = fullChild.children;
+          await this.loadChildrenRecursively(child);
+        }
+      }
+    }
+  }
+
+  /**
+   * 构建树形结构
+   */
+  private buildTree(areas: AreaEntity[]): AreaEntity[] {
+    const areaMap = new Map<number, AreaEntity>();
+    const tree: AreaEntity[] = [];
+
+    // 创建映射并初始化children数组
+    areas.forEach(area => {
+      const node = new AreaEntity({ ...area });
+      node.children = [];
+      areaMap.set(area.id, node);
+    });
+
+    // 构建树形结构
+    areas.forEach(area => {
+      const node = areaMap.get(area.id)!;
+      if (area.parentId === null || area.parentId === 0) {
+        tree.push(node);
+      } else {
+        const parent = areaMap.get(area.parentId);
+        if (parent && parent.children) {
+          parent.children.push(node);
+        }
+      }
+    });
+
+    return tree;
+  }
+
+  /**
+   * 获取区域路径(从根节点到当前节点)
+   */
+  async getAreaPath(areaId: number): Promise<AreaEntity[]> {
+    const path: AreaEntity[] = [];
+    let currentArea = await this.areaRepository.findOne({
+      where: { id: areaId, isDeleted: 0, isDisabled: DisabledStatus.ENABLED }
+    });
+
+    while (currentArea) {
+      path.unshift(currentArea);
+
+      if (currentArea.parentId === null || currentArea.parentId === 0) {
+        break;
+      }
+
+      currentArea = await this.areaRepository.findOne({
+        where: { id: currentArea.parentId, isDeleted: 0, isDisabled: DisabledStatus.ENABLED }
+      });
+    }
+
+    return path;
+  }
+
+  /**
+   * 获取所有启用状态的省市区
+   */
+  async getEnabledAreas(): Promise<AreaEntity[]> {
+    return this.areaRepository.find({
+      where: {
+        isDeleted: 0,
+        isDisabled: DisabledStatus.ENABLED
+      },
+      order: {
+        id: 'ASC',
+        level: 'ASC',
+        name: 'ASC',
+      }
+    });
+  }
+}

+ 34 - 0
packages/geo-areas/tsconfig.json

@@ -0,0 +1,34 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "allowJs": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "skipLibCheck": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "composite": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"]
+    }
+  },
+  "include": [
+    "src/**/*",
+    "tests/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}

+ 36 - 0
packages/geo-areas/vitest.config.ts

@@ -0,0 +1,36 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: 'node',
+    include: ['tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'json', 'html'],
+      exclude: [
+        'coverage/**',
+        'dist/**',
+        '**/node_modules/**',
+        '**/[.]**',
+        '**/*.d.ts',
+        '**/virtual:*',
+        '**/__x00__*',
+        '**/\x00*',
+        'cypress/**',
+        'test?(s)/**',
+        'test?(-*).?(c|m)[jt]s?(x)',
+        '**/*{.,-}{test,spec,bench,benchmark}?(-d).?(c|m)[jt]s?(x)',
+        '**/__tests__/**',
+        '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
+        '**/vitest.config.*',
+        '**/vitest.workspace.*'
+      ]
+    }
+  },
+  resolve: {
+    alias: {
+      '~': new URL('./src', import.meta.url).pathname
+    }
+  }
+});