Procházet zdrojové kódy

✨ feat(consultation): 新增客户需求咨询功能

- 新增客户需求咨询实体、Schema和服务层,支持游客提交项目咨询需求
- 添加公共API接口用于处理客户需求提交,包含反垃圾邮件保护机制
- 创建客户需求咨询表单组件,包含完整的表单验证和用户界面
- 在方案设计页面集成咨询表单模态框,提供便捷的项目咨询服务入口
- 扩展API客户端配置,支持客户需求管理相关接口调用

【架构】新增咨询请求模块,包含完整的CRUD操作和业务逻辑
- 创建ConsultationRequest实体,存储客户咨询信息
- 实现SpamProtectionService防止垃圾提交和机器人攻击
- 提供统计功能,支持查看咨询请求的处理状态和数据统计

【前端】实现用户友好的咨询表单界面
- 使用React Hook Form和Zod进行表单验证
- 提供项目类型、预算范围、时间要求等选项选择
- 支持响应式设计,适配不同屏幕尺寸
- 集成toast通知系统,提供用户操作反馈
yourname před 3 měsíci
rodič
revize
cd0ce6e12f

+ 14 - 2
src/client/api.ts

@@ -4,7 +4,9 @@ import type {
   FileRoutes, MembershipPlanRoutes, PaymentRoutes,
   TemplateRoutes, PublicTemplateRoutes, SettingsRoutes, PublicSettingsRoutes,
   DocumentsRoutes,
-  SolutionDesignsRoutes
+  SolutionDesignsRoutes,
+  ConsultationRequestRoutes,
+  PublicConsultationRequestRoutes
 } from '@/server/api';
 import { axiosFetch } from './utils/axios-fetch';
 
@@ -60,4 +62,14 @@ export const documentsClient = hc<DocumentsRoutes>('/', {
 // 方案设计客户端
 export const solutionDesignsClient = hc<SolutionDesignsRoutes>('/', {
   fetch: axiosFetch,
-}).api.v1['solution-designs'];
+}).api.v1['solution-designs'];
+
+// 客户需求管理客户端(需要认证)
+export const consultationRequestClient = hc<ConsultationRequestRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['consultation-requests'];
+
+// 公共客户需求提交客户端(无需认证)
+export const publicConsultationRequestClient = hc<PublicConsultationRequestRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.public['consultation-requests'];

+ 274 - 0
src/client/components/ConsultationRequestForm.tsx

@@ -0,0 +1,274 @@
+import React, { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Textarea } from '@/client/components/ui/textarea';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Label } from '@/client/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+import { toast } from 'react-toastify';
+import { publicConsultationRequestClient } from '@/client/api';
+import type { InferRequestType } from 'hono/client';
+
+// 表单验证Schema
+const ConsultationRequestFormSchema = z.object({
+  customerName: z.string().min(2, '姓名至少2个字符').max(255),
+  companyName: z.string().max(255).optional(),
+  phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号'),
+  email: z.string().email('请输入正确的邮箱地址').optional(),
+  projectType: z.string().min(1, '请选择项目类型'),
+  projectDescription: z.string().min(10, '项目描述至少10个字符').max(2000),
+  budgetRange: z.string().max(100).optional(),
+  timeline: z.string().max(100).optional(),
+  isGuest: z.boolean().default(true)
+});
+
+type ConsultationRequestFormData = z.infer<typeof ConsultationRequestFormSchema>;
+
+// 项目类型选项
+const projectTypes = [
+  { value: '企业ERP系统', label: '企业ERP系统' },
+  { value: '智慧政务平台', label: '智慧政务平台' },
+  { value: '医疗信息化系统', label: '医疗信息化系统' },
+  { value: '教育信息化平台', label: '教育信息化平台' },
+  { value: '电商平台', label: '电商平台' },
+  { value: '移动应用开发', label: '移动应用开发' },
+  { value: '大数据分析平台', label: '大数据分析平台' },
+  { value: '人工智能应用', label: '人工智能应用' },
+  { value: '物联网系统', label: '物联网系统' },
+  { value: '其他', label: '其他' }
+];
+
+// 预算范围选项
+const budgetRanges = [
+  { value: '10万以下', label: '10万以下' },
+  { value: '10-50万', label: '10-50万' },
+  { value: '50-100万', label: '50-100万' },
+  { value: '100-500万', label: '100-500万' },
+  { value: '500万以上', label: '500万以上' }
+];
+
+// 时间要求选项
+const timelines = [
+  { value: '1-3个月', label: '1-3个月' },
+  { value: '3-6个月', label: '3-6个月' },
+  { value: '6-12个月', label: '6-12个月' },
+  { value: '12个月以上', label: '12个月以上' }
+];
+
+interface ConsultationRequestFormProps {
+  onSuccess?: () => void;
+  onCancel?: () => void;
+  className?: string;
+}
+
+export default function ConsultationRequestForm({
+  onSuccess,
+  onCancel,
+  className = ''
+}: ConsultationRequestFormProps) {
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+    setValue,
+    watch
+  } = useForm<ConsultationRequestFormData>({
+    resolver: zodResolver(ConsultationRequestFormSchema),
+    defaultValues: {
+      isGuest: true
+    }
+  });
+
+  const onSubmit = async (data: ConsultationRequestFormData) => {
+    setIsSubmitting(true);
+
+    try {
+      const response = await publicConsultationRequestClient.$post({
+        json: data
+      });
+
+      if (response.status === 200) {
+        const result = await response.json();
+        toast.success(result.message || '客户需求提交成功!');
+        onSuccess?.();
+      } else {
+        const error = await response.json();
+        toast.error(error.message || '提交失败,请稍后重试');
+      }
+    } catch (error) {
+      console.error('提交客户需求失败:', error);
+      toast.error('网络错误,请检查网络连接后重试');
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Card className={`w-full max-w-2xl mx-auto ${className}`}>
+      <CardHeader>
+        <CardTitle className="text-2xl font-bold">项目咨询需求</CardTitle>
+        <CardDescription>
+          请填写您的项目需求信息,我们将尽快与您联系并提供专业的咨询服务
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
+          {/* 基本信息 */}
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="customerName">客户姓名 *</Label>
+              <Input
+                id="customerName"
+                placeholder="请输入您的姓名"
+                {...register('customerName')}
+                className={errors.customerName ? 'border-red-500' : ''}
+              />
+              {errors.customerName && (
+                <p className="text-sm text-red-500">{errors.customerName.message}</p>
+              )}
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="companyName">公司名称</Label>
+              <Input
+                id="companyName"
+                placeholder="请输入公司名称(选填)"
+                {...register('companyName')}
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="phone">手机号 *</Label>
+              <Input
+                id="phone"
+                placeholder="请输入手机号"
+                {...register('phone')}
+                className={errors.phone ? 'border-red-500' : ''}
+              />
+              {errors.phone && (
+                <p className="text-sm text-red-500">{errors.phone.message}</p>
+              )}
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="email">邮箱地址</Label>
+              <Input
+                id="email"
+                type="email"
+                placeholder="请输入邮箱地址(选填)"
+                {...register('email')}
+                className={errors.email ? 'border-red-500' : ''}
+              />
+              {errors.email && (
+                <p className="text-sm text-red-500">{errors.email.message}</p>
+              )}
+            </div>
+          </div>
+
+          {/* 项目信息 */}
+          <div className="space-y-4">
+            <div className="space-y-2">
+              <Label htmlFor="projectType">项目类型 *</Label>
+              <Select onValueChange={(value) => setValue('projectType', value)}>
+                <SelectTrigger className={errors.projectType ? 'border-red-500' : ''}>
+                  <SelectValue placeholder="请选择项目类型" />
+                </SelectTrigger>
+                <SelectContent>
+                  {projectTypes.map((type) => (
+                    <SelectItem key={type.value} value={type.value}>
+                      {type.label}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+              {errors.projectType && (
+                <p className="text-sm text-red-500">{errors.projectType.message}</p>
+              )}
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="projectDescription">项目描述 *</Label>
+              <Textarea
+                id="projectDescription"
+                placeholder="请详细描述您的项目需求、目标和期望效果..."
+                rows={4}
+                {...register('projectDescription')}
+                className={errors.projectDescription ? 'border-red-500' : ''}
+              />
+              {errors.projectDescription && (
+                <p className="text-sm text-red-500">{errors.projectDescription.message}</p>
+              )}
+              <p className="text-sm text-gray-500">
+                已输入 {watch('projectDescription')?.length || 0} / 2000 字符
+              </p>
+            </div>
+
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="budgetRange">预算范围</Label>
+                <Select onValueChange={(value) => setValue('budgetRange', value)}>
+                  <SelectTrigger>
+                    <SelectValue placeholder="请选择预算范围(选填)" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {budgetRanges.map((range) => (
+                      <SelectItem key={range.value} value={range.value}>
+                        {range.label}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+
+              <div className="space-y-2">
+                <Label htmlFor="timeline">时间要求</Label>
+                <Select onValueChange={(value) => setValue('timeline', value)}>
+                  <SelectTrigger>
+                    <SelectValue placeholder="请选择时间要求(选填)" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {timelines.map((timeline) => (
+                      <SelectItem key={timeline.value} value={timeline.value}>
+                        {timeline.label}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+            </div>
+          </div>
+
+          {/* 操作按钮 */}
+          <div className="flex gap-4 pt-4">
+            <Button
+              type="submit"
+              className="flex-1 bg-blue-600 hover:bg-blue-700"
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? '提交中...' : '提交咨询需求'}
+            </Button>
+            {onCancel && (
+              <Button
+                type="button"
+                variant="outline"
+                onClick={onCancel}
+                disabled={isSubmitting}
+              >
+                取消
+              </Button>
+            )}
+          </div>
+
+          <p className="text-sm text-gray-500 text-center">
+            提交即表示您同意我们的服务条款和隐私政策
+          </p>
+        </form>
+      </CardContent>
+    </Card>
+  );
+}

+ 31 - 3
src/client/home/pages/DesignPlanningPage.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
 import { Button } from '@/client/components/ui/button';
 import { Badge } from '@/client/components/ui/badge';
@@ -19,10 +19,14 @@ import {
   Search,
   FileCheck,
   Calculator,
-  Archive
+  Archive,
+  X
 } from 'lucide-react';
+import ConsultationRequestForm from '@/client/components/ConsultationRequestForm';
 
 export default function DesignPlanningPage() {
+  const [showConsultationForm, setShowConsultationForm] = useState(false);
+
   const services = [
     {
       icon: <Search className="h-8 w-8" />,
@@ -366,15 +370,39 @@ export default function DesignPlanningPage() {
           <p className="text-xl text-gray-600 mb-8">
             让我们共同打造成功的信息化项目
           </p>
-          <Button 
+          <Button
             size="lg"
             className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3"
+            onClick={() => setShowConsultationForm(true)}
           >
             <ArrowRight className="h-4 w-4 mr-2" />
             咨询项目服务
           </Button>
         </div>
       </div>
+
+      {/* 咨询需求表单模态框 */}
+      {showConsultationForm && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+          <div className="relative">
+            <Button
+              variant="ghost"
+              size="icon"
+              className="absolute -top-4 -right-4 bg-white rounded-full shadow-lg z-10"
+              onClick={() => setShowConsultationForm(false)}
+            >
+              <X className="h-4 w-4" />
+            </Button>
+            <ConsultationRequestForm
+              onSuccess={() => {
+                setShowConsultationForm(false);
+                // 可以在这里添加成功后的回调逻辑
+              }}
+              onCancel={() => setShowConsultationForm(false)}
+            />
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 6 - 0
src/server/api.ts

@@ -13,6 +13,8 @@ import publicSettingsRoute from './api/public/settings/index'
 import settingsRoute from './api/settings/index'
 import documentsRoute from './api/documents/index'
 import solutionDesignsRoute from './api/solution-designs/index'
+import consultationRequestRoutes from './api/consultation-requests/index'
+import publicConsultationRequestRoutes from './api/public/consultation-requests/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -125,6 +127,8 @@ export const publicSettingsRoutes = api.route('/api/v1/public/settings', publicS
 export const settingsRoutes = api.route('/api/v1/settings', settingsRoute)
 export const documentsRoutes = api.route('/api/v1/documents', documentsRoute)
 export const solutionDesignsRoutes = api.route('/api/v1/solution-designs', solutionDesignsRoute)
+export const consultationRequestRoutes = api.route('/api/v1/consultation-requests', consultationRequestRoutes)
+export const publicConsultationRequestRoutes = api.route('/api/v1/public/consultation-requests', publicConsultationRequestRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -138,6 +142,8 @@ export type PublicSettingsRoutes = typeof publicSettingsRoutes
 export type SettingsRoutes = typeof settingsRoutes
 export type DocumentsRoutes = typeof documentsRoutes
 export type SolutionDesignsRoutes = typeof solutionDesignsRoutes
+export type ConsultationRequestRoutes = typeof consultationRequestRoutes
+export type PublicConsultationRequestRoutes = typeof publicConsultationRequestRoutes
 
 app.route('/', api)
 export default app

+ 17 - 0
src/server/api/consultation-requests/index.ts

@@ -0,0 +1,17 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { ConsultationRequest } from '@/server/modules/consultation/consultation-request.entity';
+import { ConsultationRequestSchema, CreateConsultationRequestDto, UpdateConsultationRequestDto } from '@/server/modules/consultation/consultation-request.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 使用通用CRUD路由创建管理后台API
+const consultationRequestRoutes = createCrudRoutes({
+  entity: ConsultationRequest,
+  createSchema: CreateConsultationRequestDto,
+  updateSchema: UpdateConsultationRequestDto,
+  getSchema: ConsultationRequestSchema,
+  listSchema: ConsultationRequestSchema,
+  searchFields: ['customerName', 'companyName', 'projectType', 'projectDescription'],
+  middleware: [authMiddleware]
+});
+
+export default consultationRequestRoutes;

+ 7 - 0
src/server/api/public/consultation-requests/index.ts

@@ -0,0 +1,7 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import createRoute from './post';
+
+const app = new OpenAPIHono()
+  .route('/', createRoute);
+
+export default app;

+ 76 - 0
src/server/api/public/consultation-requests/post.ts

@@ -0,0 +1,76 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { ConsultationRequestSchema, CreateConsultationRequestDto } from '@/server/modules/consultation/consultation-request.schema';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { ConsultationRequestService } from '@/server/modules/consultation/consultation-request.service';
+
+// 响应Schema
+const CreateResponse = z.object({
+  success: z.boolean().openapi({ description: '是否成功', example: true }),
+  message: z.string().openapi({ description: '响应消息', example: '客户需求提交成功' }),
+  data: ConsultationRequestSchema.optional().openapi({ description: '创建的客户需求数据' })
+});
+
+// 路由定义
+const routeDef = createRoute({
+  method: 'post',
+  path: '/',
+  // 注意:不使用authMiddleware,支持游客提交
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateConsultationRequestDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功提交客户需求',
+      content: { 'application/json': { schema: CreateResponse } }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    429: {
+      description: '提交频率过高',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono().openapi(routeDef, async (c) => {
+  try {
+    const data = await c.req.json();
+    const service = new ConsultationRequestService(AppDataSource);
+
+    // 获取客户端信息
+    const ipAddress = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
+    const userAgent = c.req.header('user-agent') || 'unknown';
+
+    // 创建客户需求
+    const result = await service.create(data, ipAddress, userAgent);
+
+    return c.json({
+      success: true,
+      message: '客户需求提交成功,我们将尽快与您联系',
+      data: result
+    }, 200);
+  } catch (error) {
+    const errorMessage = error instanceof Error ? error.message : '提交失败';
+    const statusCode = errorMessage.includes('频率过高') ? 429 : 400;
+
+    return c.json({
+      code: statusCode,
+      message: errorMessage
+    }, statusCode);
+  }
+});
+
+export default app;

+ 3 - 2
src/server/data-source.ts

@@ -13,6 +13,7 @@ import { InitSystemSettings1735900000000 } from "./migrations/1735900000000-init
 import { SystemSetting } from "./modules/settings/system-setting.entity"
 import { SolutionDesign } from "./modules/solution-designs/solution-design.entity"
 import { SolutionChapter } from "./modules/solution-designs/solution-chapter.entity"
+import { ConsultationRequest } from "./modules/consultation/consultation-request.entity"
 
 // 在测试环境下使用测试数据库配置
 const isTestEnv = process.env.NODE_ENV === 'test';
@@ -24,7 +25,7 @@ const dataSource = isTestEnv && testDatabaseUrl
       url: testDatabaseUrl,
       entities: [
         User, Role, File, PaymentEntity, MembershipPlan, Template, SystemSetting,
-        SolutionDesign, SolutionChapter,
+        SolutionDesign, SolutionChapter, ConsultationRequest,
       ],
       migrations: [
         InitSystemSettings1735900000000,
@@ -42,7 +43,7 @@ const dataSource = isTestEnv && testDatabaseUrl
   database: process.env.DB_DATABASE || "d8dai",
       entities: [
         User, Role, File, PaymentEntity, MembershipPlan, Template, SystemSetting,
-        SolutionDesign, SolutionChapter,
+        SolutionDesign, SolutionChapter, ConsultationRequest,
       ],
       migrations: [
         InitSystemSettings1735900000000,

+ 49 - 0
src/server/modules/consultation/consultation-request.entity.ts

@@ -0,0 +1,49 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('consultation_requests')
+export class ConsultationRequest {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'customer_name', type: 'varchar', length: 255, comment: '客户姓名' })
+  customerName!: string;
+
+  @Column({ name: 'company_name', type: 'varchar', length: 255, nullable: true, comment: '公司名称' })
+  companyName!: string | null;
+
+  @Column({ name: 'phone', type: 'varchar', length: 20, comment: '手机号' })
+  phone!: string;
+
+  @Column({ name: 'email', type: 'varchar', length: 255, nullable: true, comment: '邮箱地址' })
+  email!: string | null;
+
+  @Column({ name: 'project_type', type: 'varchar', length: 100, comment: '项目类型' })
+  projectType!: string;
+
+  @Column({ name: 'project_description', type: 'text', comment: '项目描述' })
+  projectDescription!: string;
+
+  @Column({ name: 'budget_range', type: 'varchar', length: 100, nullable: true, comment: '预算范围' })
+  budgetRange!: string | null;
+
+  @Column({ name: 'timeline', type: 'varchar', length: 100, nullable: true, comment: '项目时间要求' })
+  timeline!: string | null;
+
+  @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending', comment: '状态:pending-待处理, processing-处理中, completed-已完成' })
+  status!: string;
+
+  @Column({ name: 'is_guest', type: 'tinyint', default: 0, comment: '是否为游客提交' })
+  isGuest!: number;
+
+  @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true, comment: '提交IP地址' })
+  ipAddress!: string | null;
+
+  @Column({ name: 'user_agent', type: 'text', nullable: true, comment: '用户代理信息' })
+  userAgent!: string | null;
+
+  @CreateDateColumn({ name: 'created_at', comment: '创建时间' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', comment: '更新时间' })
+  updatedAt!: Date;
+}

+ 149 - 0
src/server/modules/consultation/consultation-request.schema.ts

@@ -0,0 +1,149 @@
+import { z } from '@hono/zod-openapi';
+
+// 客户需求实体Schema
+export const ConsultationRequestSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '客户需求ID',
+    example: 1
+  }),
+  customerName: z.string().max(255).openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  companyName: z.string().max(255).nullable().openapi({
+    description: '公司名称',
+    example: 'ABC科技有限公司'
+  }),
+  phone: z.string().max(20).openapi({
+    description: '手机号',
+    example: '13800138000'
+  }),
+  email: z.string().email().nullable().openapi({
+    description: '邮箱地址',
+    example: 'zhangsan@example.com'
+  }),
+  projectType: z.string().max(100).openapi({
+    description: '项目类型',
+    example: '企业ERP系统'
+  }),
+  projectDescription: z.string().openapi({
+    description: '项目描述',
+    example: '我们需要开发一个企业ERP系统,包含财务、采购、销售等模块'
+  }),
+  budgetRange: z.string().max(100).nullable().openapi({
+    description: '预算范围',
+    example: '10-50万'
+  }),
+  timeline: z.string().max(100).nullable().openapi({
+    description: '项目时间要求',
+    example: '3-6个月'
+  }),
+  status: z.string().openapi({
+    description: '状态',
+    example: 'pending'
+  }),
+  isGuest: z.number().openapi({
+    description: '是否为游客提交',
+    example: 1
+  }),
+  ipAddress: z.string().max(45).nullable().openapi({
+    description: '提交IP地址',
+    example: '192.168.1.1'
+  }),
+  userAgent: z.string().nullable().openapi({
+    description: '用户代理信息',
+    example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
+  }),
+  createdAt: z.coerce.date().openapi({
+    description: '创建时间',
+    example: '2024-01-01T12:00:00Z'
+  }),
+  updatedAt: z.coerce.date().openapi({
+    description: '更新时间',
+    example: '2024-01-01T12:00:00Z'
+  })
+});
+
+// 创建客户需求DTO Schema(支持游客提交)
+export const CreateConsultationRequestDto = z.object({
+  customerName: z.string().min(2, '姓名至少2个字符').max(255).openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  companyName: z.string().max(255).optional().openapi({
+    description: '公司名称',
+    example: 'ABC科技有限公司'
+  }),
+  phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号').openapi({
+    description: '手机号',
+    example: '13800138000'
+  }),
+  email: z.string().email('请输入正确的邮箱地址').optional().openapi({
+    description: '邮箱地址',
+    example: 'zhangsan@example.com'
+  }),
+  projectType: z.string().min(1, '请选择项目类型').max(100).openapi({
+    description: '项目类型',
+    example: '企业ERP系统'
+  }),
+  projectDescription: z.string().min(10, '项目描述至少10个字符').max(2000).openapi({
+    description: '项目描述',
+    example: '我们需要开发一个企业ERP系统,包含财务、采购、销售等模块'
+  }),
+  budgetRange: z.string().max(100).optional().openapi({
+    description: '预算范围',
+    example: '10-50万'
+  }),
+  timeline: z.string().max(100).optional().openapi({
+    description: '项目时间要求',
+    example: '3-6个月'
+  }),
+  isGuest: z.boolean().default(true).openapi({
+    description: '是否为游客提交',
+    example: true
+  }),
+  captchaToken: z.string().optional().openapi({
+    description: '机器人验证token',
+    example: 'captcha_token_123'
+  })
+});
+
+// 更新客户需求DTO Schema
+export const UpdateConsultationRequestDto = z.object({
+  customerName: z.string().min(2, '姓名至少2个字符').max(255).optional().openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  companyName: z.string().max(255).optional().openapi({
+    description: '公司名称',
+    example: 'ABC科技有限公司'
+  }),
+  phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号').optional().openapi({
+    description: '手机号',
+    example: '13800138000'
+  }),
+  email: z.string().email('请输入正确的邮箱地址').optional().openapi({
+    description: '邮箱地址',
+    example: 'zhangsan@example.com'
+  }),
+  projectType: z.string().min(1, '请选择项目类型').max(100).optional().openapi({
+    description: '项目类型',
+    example: '企业ERP系统'
+  }),
+  projectDescription: z.string().min(10, '项目描述至少10个字符').max(2000).optional().openapi({
+    description: '项目描述',
+    example: '我们需要开发一个企业ERP系统,包含财务、采购、销售等模块'
+  }),
+  budgetRange: z.string().max(100).optional().openapi({
+    description: '预算范围',
+    example: '10-50万'
+  }),
+  timeline: z.string().max(100).optional().openapi({
+    description: '项目时间要求',
+    example: '3-6个月'
+  }),
+  status: z.enum(['pending', 'processing', 'completed']).optional().openapi({
+    description: '状态',
+    example: 'processing'
+  })
+});

+ 139 - 0
src/server/modules/consultation/consultation-request.service.ts

@@ -0,0 +1,139 @@
+import { DataSource, Repository } from 'typeorm';
+import { ConsultationRequest } from './consultation-request.entity';
+import { CreateConsultationRequestDto, UpdateConsultationRequestDto } from './consultation-request.schema';
+import { SpamProtectionService } from './spam-protection.service';
+
+export class ConsultationRequestService {
+  private repository: Repository<ConsultationRequest>;
+  private spamProtectionService: SpamProtectionService;
+
+  constructor(dataSource: DataSource) {
+    this.repository = dataSource.getRepository(ConsultationRequest);
+    this.spamProtectionService = new SpamProtectionService(dataSource);
+  }
+
+  /**
+   * 获取客户需求列表(带分页和筛选)
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    status?: string
+  ): Promise<[ConsultationRequest[], number]> {
+    const query = this.repository.createQueryBuilder('request');
+
+    // 关键词搜索
+    if (keyword) {
+      query.andWhere(
+        '(request.customerName LIKE :keyword OR request.companyName LIKE :keyword OR request.projectType LIKE :keyword OR request.projectDescription LIKE :keyword)',
+        { keyword: `%${keyword}%` }
+      );
+    }
+
+    // 状态筛选
+    if (status) {
+      query.andWhere('request.status = :status', { status });
+    }
+
+    // 按创建时间倒序排列
+    query.orderBy('request.createdAt', 'DESC');
+
+    // 分页
+    const [items, total] = await query
+      .skip((page - 1) * pageSize)
+      .take(pageSize)
+      .getManyAndCount();
+
+    return [items, total];
+  }
+
+  /**
+   * 根据ID获取单个客户需求
+   */
+  async getById(id: number): Promise<ConsultationRequest> {
+    const request = await this.repository.findOneBy({ id });
+    if (!request) {
+      throw new Error('客户需求不存在');
+    }
+    return request;
+  }
+
+  /**
+   * 创建客户需求(支持游客提交)
+   */
+  async create(
+    data: CreateConsultationRequestDto,
+    ipAddress?: string,
+    userAgent?: string
+  ): Promise<ConsultationRequest> {
+    // 反垃圾邮件验证
+    if (ipAddress) {
+      await this.spamProtectionService.validateSubmission(data, ipAddress);
+    }
+
+    // 创建客户需求
+    const request = this.repository.create({
+      ...data,
+      isGuest: data.isGuest ? 1 : 0,
+      ipAddress: ipAddress || null,
+      userAgent: userAgent || null,
+      status: 'pending'
+    });
+
+    return this.repository.save(request);
+  }
+
+  /**
+   * 更新客户需求
+   */
+  async update(id: number, data: UpdateConsultationRequestDto): Promise<ConsultationRequest> {
+    const request = await this.getById(id);
+
+    Object.assign(request, data);
+    return this.repository.save(request);
+  }
+
+  /**
+   * 删除客户需求
+   */
+  async delete(id: number): Promise<boolean> {
+    const request = await this.getById(id);
+    await this.repository.remove(request);
+    return true;
+  }
+
+  /**
+   * 更新客户需求状态
+   */
+  async updateStatus(id: number, status: 'pending' | 'processing' | 'completed'): Promise<ConsultationRequest> {
+    const request = await this.getById(id);
+    request.status = status;
+    return this.repository.save(request);
+  }
+
+  /**
+   * 获取统计数据
+   */
+  async getStatistics(): Promise<{
+    total: number;
+    pending: number;
+    processing: number;
+    completed: number;
+    guestSubmissions: number;
+  }> {
+    const total = await this.repository.count();
+    const pending = await this.repository.countBy({ status: 'pending' });
+    const processing = await this.repository.countBy({ status: 'processing' });
+    const completed = await this.repository.countBy({ status: 'completed' });
+    const guestSubmissions = await this.repository.countBy({ isGuest: 1 });
+
+    return {
+      total,
+      pending,
+      processing,
+      completed,
+      guestSubmissions
+    };
+  }
+}

+ 94 - 0
src/server/modules/consultation/spam-protection.service.ts

@@ -0,0 +1,94 @@
+import { DataSource, Repository } from 'typeorm';
+import { ConsultationRequest } from './consultation-request.entity';
+import { CreateConsultationRequestDto } from './consultation-request.schema';
+
+export class SpamProtectionService {
+  private repository: Repository<ConsultationRequest>;
+
+  constructor(dataSource: DataSource) {
+    this.repository = dataSource.getRepository(ConsultationRequest);
+  }
+
+  /**
+   * 验证提交是否合法(反垃圾邮件和机器人验证)
+   */
+  async validateSubmission(
+    requestData: CreateConsultationRequestDto,
+    ipAddress: string
+  ): Promise<boolean> {
+    // 1. IP频率限制:同一IP24小时内最多提交5次
+    const recentSubmissions = await this.getRecentSubmissionsByIP(ipAddress);
+    if (recentSubmissions >= 5) {
+      throw new Error('提交频率过高,请稍后再试');
+    }
+
+    // 2. 内容重复检测:检查相似的项目描述
+    const similarRequests = await this.findSimilarRequests(requestData.projectDescription);
+    if (similarRequests.length > 0) {
+      throw new Error('检测到重复提交,请修改项目描述');
+    }
+
+    // 3. 机器人验证:如果提供了captcha token,进行验证
+    if (requestData.captchaToken) {
+      const isValid = await this.validateCaptcha(requestData.captchaToken);
+      if (!isValid) {
+        throw new Error('机器人验证失败');
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * 获取同一IP最近24小时的提交次数
+   */
+  private async getRecentSubmissionsByIP(ipAddress: string): Promise<number> {
+    const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
+
+    const count = await this.repository
+      .createQueryBuilder('request')
+      .where('request.ipAddress = :ipAddress', { ipAddress })
+      .andWhere('request.createdAt >= :twentyFourHoursAgo', { twentyFourHoursAgo })
+      .getCount();
+
+    return count;
+  }
+
+  /**
+   * 查找相似的项目描述(防止重复提交)
+   */
+  private async findSimilarRequests(projectDescription: string): Promise<ConsultationRequest[]> {
+    // 简单的相似度检测:检查是否有完全相同的描述
+    const similarRequests = await this.repository
+      .createQueryBuilder('request')
+      .where('request.projectDescription = :projectDescription', { projectDescription })
+      .andWhere('request.createdAt >= :oneHourAgo', {
+        oneHourAgo: new Date(Date.now() - 60 * 60 * 1000)
+      })
+      .getMany();
+
+    return similarRequests;
+  }
+
+  /**
+   * 验证captcha token(基础实现,可根据需要集成reCAPTCHA等)
+   */
+  private async validateCaptcha(token: string): Promise<boolean> {
+    // 这里实现具体的验证逻辑
+    // 目前返回true表示验证通过,实际项目中应集成真实的验证服务
+    // 例如:reCAPTCHA、hCaptcha等
+
+    // 简单的token验证(示例)
+    if (!token || token.length < 10) {
+      return false;
+    }
+
+    // 在实际项目中,这里应该调用验证服务的API
+    // const isValid = await fetch('https://recaptcha.google.com/verify', {
+    //   method: 'POST',
+    //   body: JSON.stringify({ token, secret: process.env.RECAPTCHA_SECRET })
+    // }).then(res => res.json()).then(data => data.success);
+
+    return true; // 暂时返回true,实际项目中需要真实验证
+  }
+}