Pārlūkot izejas kodu

fix(statistics): 修复数据统计API安全漏洞和路由集成

- 修复statistics-module安全漏洞:移除查询参数中的companyId,强制从JWT token获取企业ID
- 修复order-module企业统计API类似安全漏洞
- 添加403响应定义到统计API路由
- 将statistics-module集成到server包,添加依赖和路由注册
- 修复statistics.service.ts中的类型错误
- 更新故事011.005文档,反映安全要求变更

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 3 nedēļas atpakaļ
vecāks
revīzija
0828d3d7e8

+ 17 - 26
allin-packages/order-module/src/routes/order-custom.routes.ts

@@ -579,12 +579,7 @@ const checkinStatisticsRoute = createRoute({
   path: '/checkin-statistics',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: z.object({
-      companyId: z.coerce.number<number>().int().positive().optional().openapi({
-        description: '企业ID(从认证用户获取,可覆盖)',
-        example: 1
-      })
-    })
+    query: z.object({})
   },
   responses: {
     200: {
@@ -619,10 +614,6 @@ const videoStatisticsRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     query: z.object({
-      companyId: z.coerce.number<number>().int().positive().optional().openapi({
-        description: '企业ID(从认证用户获取,可覆盖)',
-        example: 1
-      }),
       assetType: z.nativeEnum(AssetType).optional().openapi({
         description: '视频类型过滤',
         example: AssetType.CHECKIN_VIDEO
@@ -1290,10 +1281,10 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const orderService = new OrderService(AppDataSource);
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const companyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const result = await orderService.getCheckinStatistics(companyId);
@@ -1323,10 +1314,10 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const orderService = new OrderService(AppDataSource);
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const companyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const result = await orderService.getVideoStatistics(companyId, query.assetType);
@@ -1356,10 +1347,10 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const orderService = new OrderService(AppDataSource);
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const companyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const result = await orderService.getCompanyOrders(companyId, query);
@@ -1405,7 +1396,7 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       // 验证订单是否属于当前企业
       const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       if (result.companyId !== companyId) {
@@ -1428,10 +1419,10 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const orderService = new OrderService(AppDataSource);
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const companyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const result = await orderService.getCompanyVideos(companyId, {
@@ -1467,10 +1458,10 @@ const enterpriseOrderCustomRoutes = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const orderService = new OrderService(AppDataSource);
 
-      // 确定企业ID:优先使用请求中的companyId,否则使用认证用户的companyId
-      const companyId = data.companyId || user?.companyId;
+      // 确定企业ID:企业ID强制从认证token获取
+      const companyId = user?.companyId;
       if (!companyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       // 验证个人维度下载需要personId

+ 0 - 4
allin-packages/order-module/src/schemas/order.schema.ts

@@ -529,10 +529,6 @@ export const VideoStatisticsResponseSchema = z.object({
 
 // 企业订单查询参数Schema
 export const CompanyOrdersQuerySchema = z.object({
-  companyId: z.coerce.number().int().positive().optional().openapi({
-    description: '企业ID(从认证用户获取,可覆盖)',
-    example: 1
-  }),
   orderName: z.string().optional().openapi({
     description: '订单名称过滤',
     example: '2024年Q1'

+ 55 - 31
allin-packages/statistics-module/src/routes/statistics.routes.ts

@@ -11,7 +11,7 @@ import {
   HouseholdDistributionResponseSchema,
   JobStatusDistributionResponseSchema,
   SalaryDistributionResponseSchema,
-  StatisticsQuerySchema
+  EnterpriseStatisticsQuerySchema
 } from '../schemas/statistics.schema';
 
 // 获取数据源和统计服务
@@ -25,7 +25,7 @@ const disabilityTypeDistributionRoute = createRoute({
   path: '/disability-type-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -39,7 +39,11 @@ const disabilityTypeDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -55,7 +59,7 @@ const genderDistributionRoute = createRoute({
   path: '/gender-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -69,7 +73,11 @@ const genderDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -85,7 +93,7 @@ const ageDistributionRoute = createRoute({
   path: '/age-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -99,7 +107,11 @@ const ageDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -115,7 +127,7 @@ const householdDistributionRoute = createRoute({
   path: '/household-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -129,7 +141,11 @@ const householdDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -145,7 +161,7 @@ const jobStatusDistributionRoute = createRoute({
   path: '/job-status-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -159,7 +175,11 @@ const jobStatusDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -175,7 +195,7 @@ const salaryDistributionRoute = createRoute({
   path: '/salary-distribution',
   middleware: [enterpriseAuthMiddleware],
   request: {
-    query: StatisticsQuerySchema
+    query: EnterpriseStatisticsQuerySchema
   },
   responses: {
     200: {
@@ -189,7 +209,11 @@ const salaryDistributionRoute = createRoute({
       content: { 'application/json': { schema: ErrorSchema } }
     },
     401: {
-      description: '认证失败或企业权限不足',
+      description: '认证失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    403: {
+      description: '企业权限不足',
       content: { 'application/json': { schema: ErrorSchema } }
     },
     500: {
@@ -207,11 +231,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();
@@ -242,11 +266,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();
@@ -277,11 +301,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();
@@ -312,11 +336,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();
@@ -347,11 +371,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();
@@ -382,11 +406,11 @@ const app = new OpenAPIHono<AuthContext>()
       const user = c.get('user');
       const query = c.req.valid('query');
 
-      // 优先使用查询参数中的companyId,否则使用认证用户的companyId
-      const targetCompanyId = query.companyId || user?.companyId;
+      // 企业ID强制从认证token获取
+      const targetCompanyId = user?.companyId;
 
       if (!targetCompanyId) {
-        return c.json({ code: 400, message: '企业ID不能为空' }, 400);
+        return c.json({ code: 403, message: '无企业权限' }, 403);
       }
 
       const statisticsService = await getStatisticsService();

+ 5 - 7
allin-packages/statistics-module/src/schemas/statistics.schema.ts

@@ -136,13 +136,11 @@ export const SalaryDistributionResponseSchema = z.object({
   })
 });
 
-// 通用查询参数Schema
-export const StatisticsQuerySchema = z.object({
-  companyId: z.coerce.number().int().positive().optional().openapi({
-    description: '企业ID(从认证用户获取,可覆盖)',
-    example: 1
-  })
-});
+// 通用查询参数Schema(已移除companyId,企业ID强制从认证token获取)
+export const StatisticsQuerySchema = z.object({});
+
+// 企业统计查询参数Schema(空的object,仅用于中间件验证)
+export const EnterpriseStatisticsQuerySchema = z.object({});
 
 // 类型定义
 export type StatItem = z.infer<typeof StatItemSchema>;

+ 8 - 4
allin-packages/statistics-module/src/services/statistics.service.ts

@@ -159,12 +159,16 @@ export class StatisticsService {
 
     // 统计年龄分组
     const ageGroups = ['18-25', '26-35', '36-45', '46+'] as const;
-    const ageStats: Record<string, number> = {};
-    ageGroups.forEach(group => ageStats[group] = 0);
+    const ageStats: Record<typeof ageGroups[number], number> = {
+      '18-25': 0,
+      '26-35': 0,
+      '36-45': 0,
+      '46+': 0
+    };
 
     rawAgeData.forEach(item => {
-      if (item.age_group && ageStats[item.age_group] !== undefined) {
-        ageStats[item.age_group]++;
+      if (item.age_group && ageStats.hasOwnProperty(item.age_group)) {
+        ageStats[item.age_group as typeof ageGroups[number]]++;
       }
     });
 

+ 9 - 9
docs/stories/011.005.story.md

@@ -72,24 +72,24 @@ Ready
 
 ### API规范
 **数据统计API**(statistics模块):
-- **实现状态**:后端`@d8d/allin-statistics-module`已实现6个分布统计API,均为企业专用版本。前端`@d8d/yongren-statistics-ui`包目前只有占位符API客户端,需要按照故事实现完整的API客户端集成。
+- **实现状态**:后端`@d8d/allin-statistics-module`已实现6个分布统计API,均为企业专用版本。statistics路由已在server包注册(路径前缀`/api/v1/yongren/statistics`),API实际可用。前端`@d8d/yongren-statistics-ui`包目前只有占位符API客户端,需要按照故事实现完整的API客户端集成。
 - **架构说明**:按照史诗011的mini-ui-packages架构,API客户端在各UI包内创建。数据统计API客户端应在`@d8d/yongren-statistics-ui`包内创建,而非在`mini/src/api.ts`中统一注册。
 - **后端路由类型**:从`@d8d/allin-statistics-module`导入`statisticsRoutes`类型定义
 - **路径前缀**:`/api/v1/yongren/statistics`(企业专用版本,通过`enterpriseAuthMiddleware`中间件保护)
 - **主要接口**(6个分布统计接口,均为企业专用版本):
   - `GET /disability-type-distribution` - 残疾类型分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选。如未提供,从认证用户的token中获取)
+    - 查询参数:无(企业ID强制从认证token获取)
   - `GET /gender-distribution` - 性别分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选
+    - 查询参数:无(企业ID强制从认证token获取
   - `GET /age-distribution` - 年龄分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选
+    - 查询参数:无(企业ID强制从认证token获取
   - `GET /household-distribution` - 户籍分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选
+    - 查询参数:无(企业ID强制从认证token获取
   - `GET /job-status-distribution` - 在职状态分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选
+    - 查询参数:无(企业ID强制从认证token获取
   - `GET /salary-distribution` - 薪资分布统计接口
-    - 查询参数:`companyId?`(企业ID,可选
-- **安全要求**:所有API通过`enterpriseAuthMiddleware`中间件保护,自动验证企业用户权限,确保数据安全隔离。
+    - 查询参数:无(企业ID强制从认证token获取
+- **安全要求**:所有API通过`enterpriseAuthMiddleware`中间件保护,自动验证企业用户权限,确保数据安全隔离。企业ID强制从认证token获取,不接受查询参数,防止越权访问。
 
 **企业专用数据统计API客户端创建**:
 - **实现状态**:`@d8d/yongren-statistics-ui`包目前只有占位符API客户端,需要按照本故事实现完整的API客户端。
@@ -101,7 +101,7 @@ Ready
   import { rpcClient } from '@d8d/mini-shared-ui-components/utils/rpc/rpc-client';
 
   // 注意:企业专用数据统计API通过enterpriseAuthMiddleware中间件保护,确保仅限企业用户访问
-  // **重要安全要求**:企业专用API自动验证JWT token中的`companyId`字段,确保数据隔离安全
+  // **重要安全要求**:企业专用API强制从JWT token中的`companyId`字段获取企业ID,不接受查询参数,确保数据隔离安全
   // 路径前缀 /api/v1/yongren/statistics 在路由层配置
   export const enterpriseStatisticsClient = rpcClient<typeof statisticsRoutes>('/api/v1/yongren/statistics');
   ```

+ 1 - 1
docs/stories/012.015.story.md

@@ -1,7 +1,7 @@
 # 故事 012.015:数据统计API安全修复与路由集成
 
 ## 状态
-Ready
+Ready for Review
 
 ## 故事
 **作为**企业用户,

+ 134 - 0
docs/stories/015.001.story.md

@@ -0,0 +1,134 @@
+# Story 015.001: 数据库schema扩展
+
+## Status
+Draft
+
+## Story
+**作为** 系统开发者,
+**我希望** 扩展数据库schema以支持人才用户类型并建立人才用户关联,
+**以便** 人才小程序能够基于用户类型访问相关的人才数据。
+
+## Acceptance Criteria
+1. `users2`表的`user_type`枚举成功扩展,新增`talent`(人才用户)类型
+2. `users2`表成功添加`person_id`字段,现有admin用户和企业用户的该字段值为NULL
+3. TypeORM实体定义更新完成
+4. 现有业务功能不受影响,测试通过
+
+## Tasks / Subtasks
+- [ ] 任务1:扩展users2表的user_type枚举,新增talent类型 (AC: 1, 4)
+  - [ ] 在UserEntity中添加user_type字段定义
+  - [ ] 定义UserType枚举(admin, employer, talent)
+  - [ ] 设置默认值确保现有用户类型兼容
+  - [ ] 更新TypeORM迁移配置
+- [ ] 任务2:在users2表添加person_id字段并建立外键关联 (AC: 2, 4)
+  - [ ] 在UserEntity中添加personId字段(可为空)
+  - [ ] 添加@ManyToOne关系指向DisabledPerson实体
+  - [ ] 配置外键约束(可选)
+  - [ ] 验证现有数据的person_id字段为NULL
+- [ ] 任务3:更新TypeORM实体定义和验证 (AC: 3)
+  - [ ] 更新UserEntity的TypeORM装饰器配置
+  - [ ] 添加相应的Zod Schema验证
+  - [ ] 更新相关的TypeScript类型定义
+- [ ] 任务4:验证现有功能不受影响 (AC: 4)
+  - [ ] 运行现有测试确保通过
+  - [ ] 验证管理员和企业用户登录功能正常
+  - [ ] 测试数据库迁移脚本的向后兼容性
+  - [ ] 检查相关API端点是否正常工作
+
+## Dev Notes
+
+### 先前故事见解
+- **故事012系列**:已为企业用户扩展了users2表,添加了company_id字段和employer用户类型支持 [Source: docs/prd/epic-012-api-supplement-for-employer-mini-program.md#L55-L65]
+- **故事007.004**:已移植disabled-person实体和相关模块,包含完整的人才数据模型 [Source: docs/stories/007.004.transplant-disability-management-module.story.md#L25-L40]
+- **现有用户类型**:当前系统支持admin和employer用户类型,需要扩展为支持talent类型
+
+### 技术栈要求
+- **后端框架**:Hono 4.8.5 [Source: docs/architecture/tech-stack.md#L12]
+- **数据库**:PostgreSQL 17 [Source: docs/architecture/tech-stack.md#L15]
+- **ORM**:TypeORM 0.3.25 [Source: docs/architecture/tech-stack.md#L16]
+- **测试框架**:Vitest 2.x [Source: docs/architecture/tech-stack.md#L24]
+- **包管理**:pnpm workspace [Source: docs/architecture/tech-stack.md#L30]
+
+### 数据模型规范
+- **users2表实体位置**:`packages/core-module/user-module/src/entities/user.entity.ts` [Source: 通过文件搜索确认]
+- **disabled_person表实体位置**:`allin-packages/disability-module/src/entities/disabled-person.entity.ts` [Source: 通过文件搜索确认]
+- **字段命名转换**:数据库下划线命名 → TypeScript驼峰命名 [Source: docs/architecture/backend-module-package-standards.md#L64-L74]
+- **主键命名**:实体主键统一命名为`id` [Source: docs/architecture/backend-module-package-standards.md#L53-L61]
+- **外键关联**:使用@ManyToOne装饰器建立关系映射 [Source: docs/architecture/backend-module-package-standards.md#L86-L95]
+
+### 实体设计规范
+1. **user_type枚举扩展**:
+   - 现有枚举值:admin, employer
+   - 新增枚举值:talent
+   - 枚举定义应采用字符串枚举(如:`enum UserType { ADMIN = 'admin', EMPLOYER = 'employer', TALENT = 'talent' }`)
+
+2. **person_id字段设计**:
+   - 字段名:`person_id`(数据库),`personId`(TypeScript实体)
+   - 类型:`int`,可为空(nullable: true)
+   - 外键引用:`disabled_person.person_id`
+   - 现有数据:admin和employer用户的person_id应为NULL
+
+3. **向后兼容性要求**:
+   - 新增字段必须可为空(nullable: true)
+   - 枚举扩展必须保留原有枚举值
+   - 现有数据查询和操作不受影响
+   - 现有API接口功能保持不变
+
+### 项目结构指南
+- **核心用户模块**:`packages/core-module/user-module/` [Source: docs/architecture/source-tree.md#L92-L108]
+- **残疾人管理模块**:`allin-packages/disability-module/` [Source: docs/architecture/source-tree.md#L250-L253]
+- **实体文件位置**:各模块的`src/entities/`目录 [Source: docs/architecture/backend-module-package-standards.md#L14-L22]
+- **测试文件位置**:`tests/`文件夹与源码并列 [Source: docs/architecture/coding-standards.md#L17]
+
+### 测试要求
+- **单元测试覆盖率**:核心业务逻辑 > 80% [Source: docs/architecture/coding-standards.md#L18]
+- **测试框架**:Vitest + TypeORM测试工具 [Source: docs/architecture/testing-strategy.md#L45-L46]
+- **测试策略**:单元测试、集成测试、数据库测试 [Source: docs/architecture/testing-strategy.md#L35-L66]
+- **测试验证点**:
+  - 枚举扩展不影响现有用户类型识别
+  - 新增字段可为空,现有数据不受影响
+  - 外键关联正确建立
+  - 现有API功能回归测试通过
+
+### 技术约束
+- **数据库迁移**:开发阶段通过TypeORM自动同步,上线前统一生成迁移脚本 [Source: docs/prd/epic-015-talent-mini-program-api-support.md#L85]
+- **枚举值一致性**:保持与数据库值一致(小写字符串) [Source: docs/architecture/backend-module-package-standards.md#L138-L147]
+- **字段默认值**:新增字段应有合理的默认值或可为空
+- **性能影响**:新增索引需考虑查询性能优化
+
+### 文件位置
+- **User实体文件**:`packages/core-module/user-module/src/entities/user.entity.ts`
+- **DisabledPerson实体文件**:`allin-packages/disability-module/src/entities/disabled-person.entity.ts`
+- **用户类型枚举定义**:建议在`packages/core-module/user-module/src/types/`或共享枚举包中定义
+- **测试文件**:`packages/core-module/user-module/tests/integration/user-entity.integration.test.ts`
+- **Schema验证文件**:`packages/core-module/user-module/src/schemas/user.schema.ts`
+
+### 参考实现
+- **企业用户扩展**:参考故事012中company_id字段的实现方式 [Source: docs/stories/012.001.story.md#L45-L55]
+- **枚举定义模式**:参考OrderStatus枚举定义 [Source: docs/architecture/backend-module-package-standards.md#L139-L147]
+- **外键关联实现**:参考company-module中platform关联的实现 [Source: docs/architecture/backend-module-package-standards.md#L88-L91]
+
+## 项目结构注意事项
+- **模块依赖关系**:user-module可能需要添加对disability-module的依赖(或使用ID引用解耦)
+- **循环依赖处理**:避免直接实体引用导致的循环依赖,考虑使用ID引用模式 [Source: docs/architecture/backend-module-package-standards.md#L314-L328]
+- **包配置更新**:如果添加新依赖,需要更新package.json和pnpm-workspace.yaml
+- **类型导出**:确保新增的枚举和类型正确导出供其他模块使用
+
+## Change Log
+| Date | Version | Description | Author |
+|------|---------|-------------|--------|
+| 2025-12-23 | 1.0 | 初始故事创建 | Scrum Master |
+
+## Dev Agent Record
+*此部分由开发代理在实施过程中填写*
+
+### Agent Model Used
+
+### Debug Log References
+
+### Completion Notes List
+
+### File List
+
+## QA Results
+*此部分由QA代理在审查完成后填写*

+ 1 - 0
packages/server/package.json

@@ -40,6 +40,7 @@
     "@d8d/allin-company-module": "workspace:*",
     "@d8d/allin-disability-module": "workspace:*",
     "@d8d/allin-order-module": "workspace:*",
+    "@d8d/allin-statistics-module": "workspace:*",
     "@d8d/allin-platform-module": "workspace:*",
     "@d8d/allin-salary-module": "workspace:*",
     "@d8d/allin-enums": "workspace:*",

+ 3 - 0
packages/server/src/index.ts

@@ -19,6 +19,7 @@ import { Company } from '@d8d/allin-company-module/entities'
 import { disabledPersonRoutes, personExtensionRoutes } from '@d8d/allin-disability-module'
 import { DisabledPerson, DisabledBankCard, DisabledPhoto, DisabledRemark, DisabledVisit } from '@d8d/allin-disability-module/entities'
 import { orderRoutes, enterpriseOrderRoutes } from '@d8d/allin-order-module'
+import { statisticsRoutes } from '@d8d/allin-statistics-module'
 import { EmploymentOrder, OrderPerson, OrderPersonAsset } from '@d8d/allin-order-module/entities'
 import { platformRoutes } from '@d8d/allin-platform-module'
 import { Platform } from '@d8d/allin-platform-module/entities'
@@ -152,6 +153,7 @@ export const enterpriseAuthApiRoutes = api.route('/api/v1/yongren/auth', enterpr
 export const enterpriseCompanyApiRoutes = api.route('/api/v1/yongren/company', companyEnterpriseRoutes)
 export const enterpriseDisabilityApiRoutes = api.route('/api/v1/yongren/disability-person', personExtensionRoutes)
 export const enterpriseOrderApiRoutes = api.route('/api/v1/yongren/order', enterpriseOrderRoutes)
+export const enterpriseStatisticsApiRoutes = api.route('/api/v1/yongren/statistics', statisticsRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -170,6 +172,7 @@ export type EnterpriseAuthRoutes = typeof enterpriseAuthApiRoutes
 export type EnterpriseCompanyRoutes = typeof enterpriseCompanyApiRoutes
 export type EnterpriseDisabilityRoutes = typeof enterpriseDisabilityApiRoutes
 export type EnterpriseOrderRoutes = typeof enterpriseOrderApiRoutes
+export type EnterpriseStatisticsRoutes = typeof enterpriseStatisticsApiRoutes
 
 app.route('/', api)
 export default app

+ 3 - 0
pnpm-lock.yaml

@@ -5129,6 +5129,9 @@ importers:
       '@d8d/allin-salary-module':
         specifier: workspace:*
         version: link:../../allin-packages/salary-module
+      '@d8d/allin-statistics-module':
+        specifier: workspace:*
+        version: link:../../allin-packages/statistics-module
       '@d8d/bank-names-module':
         specifier: workspace:*
         version: link:../bank-names-module