feat(contact): 添加联系表单销售线索功能支持

- 新增联系表单数据库表结构设计与实体类定义
- 实现联系表单提交接口,支持公开访问无需登录验证
- 添加后台管理接口,支持线索查询、状态更新和删除操作
- 集成企业微信和飞书机器人通知功能,实时推送新线索
- 在安全配置中开放联系表单提交接口访问权限
- 添加应用配置中的通知机器人Webhook配置项
This commit is contained in:
2026-03-30 11:08:12 +08:00
parent bd2a92d832
commit df7a41f3c4
10 changed files with 670 additions and 7 deletions

View File

@@ -0,0 +1,142 @@
package com.gxwebsoft.cms.controller;
import com.gxwebsoft.cms.entity.CmsContactLead;
import com.gxwebsoft.cms.param.CmsContactLeadParam;
import com.gxwebsoft.cms.service.CmsContactLeadService;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import java.util.List;
/**
* 联系表单/销售线索控制器
*
* @author 科技小王子
* @since 2026-03-30
*/
@Slf4j
@Validated
@Tag(name = "联系表单/销售线索管理")
@RestController
@RequestMapping("/api/cms/cms-contact-lead")
public class CmsContactLeadController extends BaseController {
@Resource
private CmsContactLeadService cmsContactLeadService;
// -------------------------
// 公开接口(无需登录)
// -------------------------
/**
* 提交联系表单(官网 /contact 页调用,不需要登录)
*/
@Operation(summary = "提交联系表单")
@PostMapping("/submit")
public ApiResult<?> submit(@RequestBody @Valid CmsContactLead lead, HttpServletRequest request) {
// 获取客户端真实IP
String ip = getClientIp(request);
if (cmsContactLeadService.submit(lead, ip)) {
return success("提交成功,我们将尽快与您联系!");
}
return fail("提交失败,请稍后重试");
}
// -------------------------
// 后台管理接口(需要权限)
// -------------------------
@Operation(summary = "分页查询线索列表")
@PreAuthorize("hasAuthority('cms:contactLead:list')")
@GetMapping("/page")
public ApiResult<PageResult<CmsContactLead>> page(CmsContactLeadParam param) {
return success(cmsContactLeadService.pageRel(param));
}
@Operation(summary = "查询全部线索")
@PreAuthorize("hasAuthority('cms:contactLead:list')")
@GetMapping()
public ApiResult<List<CmsContactLead>> list(CmsContactLeadParam param) {
return success(cmsContactLeadService.listRel(param));
}
@Operation(summary = "根据ID查询线索")
@PreAuthorize("hasAuthority('cms:contactLead:list')")
@GetMapping("/{id}")
public ApiResult<CmsContactLead> get(@PathVariable("id") Integer id) {
return success(cmsContactLeadService.getById(id));
}
@Operation(summary = "更新跟进状态/备注")
@PreAuthorize("hasAuthority('cms:contactLead:update')")
@PutMapping()
public ApiResult<?> update(@RequestBody CmsContactLead lead) {
if (cmsContactLeadService.updateById(lead)) {
return success("更新成功");
}
return fail("更新失败");
}
@Operation(summary = "删除线索")
@PreAuthorize("hasAuthority('cms:contactLead:remove')")
@DeleteMapping("/{id}")
public ApiResult<?> remove(@PathVariable("id") Integer id) {
if (cmsContactLeadService.removeById(id)) {
return success("删除成功");
}
return fail("删除失败");
}
@Operation(summary = "批量修改")
@PreAuthorize("hasAuthority('cms:contactLead:update')")
@PutMapping("/batch")
public ApiResult<?> updateBatch(@RequestBody BatchParam<CmsContactLead> batchParam) {
if (batchParam.update(cmsContactLeadService, "lead_id")) {
return success("批量修改成功");
}
return fail("批量修改失败");
}
@Operation(summary = "批量删除")
@PreAuthorize("hasAuthority('cms:contactLead:remove')")
@DeleteMapping("/batch")
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
if (cmsContactLeadService.removeByIds(ids)) {
return success("删除成功");
}
return fail("删除失败");
}
// -------------------------
// 工具方法
// -------------------------
/**
* 获取客户端真实IP兼容反向代理
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// X-Forwarded-For 多个IP取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@@ -0,0 +1,83 @@
package com.gxwebsoft.cms.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 联系表单/销售线索
*
* @author 科技小王子
* @since 2026-03-30
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "CmsContactLead对象", description = "联系表单/销售线索")
public class CmsContactLead implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "线索ID")
@TableId(value = "lead_id", type = IdType.AUTO)
private Integer leadId;
@Schema(description = "联系人姓名")
private String name;
@Schema(description = "手机号")
private String phone;
@Schema(description = "单位/公司名称")
private String company;
@Schema(description = "交付方式: saas/private/hybrid")
private String delivery;
@Schema(description = "需求描述")
private String need;
@Schema(description = "来源渠道: web/miniapp/app")
private String source;
@Schema(description = "提交IP")
private String ip;
@Schema(description = "跟进状态: 0待跟进 1跟进中 2已成交 3无效")
private Integer status;
@Schema(description = "销售备注")
private String remarks;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
public String getStatusText() {
if (this.status == null) return "";
return switch (this.status) {
case 0 -> "待跟进";
case 1 -> "跟进中";
case 2 -> "已成交";
case 3 -> "无效";
default -> "";
};
}
}

View File

@@ -0,0 +1,36 @@
package com.gxwebsoft.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.cms.entity.CmsContactLead;
import com.gxwebsoft.cms.param.CmsContactLeadParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 联系表单/销售线索Mapper
*
* @author 科技小王子
* @since 2026-03-30
*/
public interface CmsContactLeadMapper extends BaseMapper<CmsContactLead> {
/**
* 分页查询
*
* @param page 分页对象
* @param param 查询参数
* @return List<CmsContactLead>
*/
List<CmsContactLead> selectPageRel(@Param("page") IPage<CmsContactLead> page,
@Param("param") CmsContactLeadParam param);
/**
* 查询全部
*
* @param param 查询参数
* @return List<CmsContactLead>
*/
List<CmsContactLead> selectListRel(@Param("param") CmsContactLeadParam param);
}

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.cms.mapper.CmsContactLeadMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM cms_contact_lead a
<where>
<if test="param.leadId != null">
AND a.lead_id = #{param.leadId}
</if>
<if test="param.name != null">
AND a.name LIKE CONCAT('%', #{param.name}, '%')
</if>
<if test="param.phone != null">
AND a.phone LIKE CONCAT('%', #{param.phone}, '%')
</if>
<if test="param.company != null">
AND a.company LIKE CONCAT('%', #{param.company}, '%')
</if>
<if test="param.delivery != null">
AND a.delivery = #{param.delivery}
</if>
<if test="param.source != null">
AND a.source = #{param.source}
</if>
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<if test="param.keywords != null">
AND (
a.name LIKE CONCAT('%', #{param.keywords}, '%')
OR a.phone LIKE CONCAT('%', #{param.keywords}, '%')
OR a.company LIKE CONCAT('%', #{param.keywords}, '%')
OR a.need LIKE CONCAT('%', #{param.keywords}, '%')
)
</if>
<if test="param.deleted != null">
AND a.deleted = #{param.deleted}
</if>
<if test="param.deleted == null">
AND a.deleted = 0
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
</where>
</sql>
<!-- 分页查询 -->
<select id="selectPageRel" resultType="com.gxwebsoft.cms.entity.CmsContactLead">
<include refid="selectSql"/>
ORDER BY a.create_time DESC
</select>
<!-- 查询全部 -->
<select id="selectListRel" resultType="com.gxwebsoft.cms.entity.CmsContactLead">
<include refid="selectSql"/>
ORDER BY a.create_time DESC
</select>
</mapper>

View File

@@ -0,0 +1,56 @@
package com.gxwebsoft.cms.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 联系表单/销售线索查询参数
*
* @author 科技小王子
* @since 2026-03-30
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CmsContactLeadParam对象", description = "联系表单/销售线索查询参数")
public class CmsContactLeadParam extends BaseParam {
private static final long serialVersionUID = 1L;
@Schema(description = "线索ID")
@QueryField(type = QueryType.EQ)
private Integer leadId;
@Schema(description = "联系人姓名")
@QueryField(type = QueryType.LIKE)
private String name;
@Schema(description = "手机号")
@QueryField(type = QueryType.LIKE)
private String phone;
@Schema(description = "单位/公司名称")
@QueryField(type = QueryType.LIKE)
private String company;
@Schema(description = "交付方式: saas/private/hybrid")
@QueryField(type = QueryType.EQ)
private String delivery;
@Schema(description = "来源渠道")
@QueryField(type = QueryType.EQ)
private String source;
@Schema(description = "跟进状态: 0待跟进 1跟进中 2已成交 3无效")
@QueryField(type = QueryType.EQ)
private Integer status;
@Schema(description = "是否删除, 0否, 1是")
@QueryField(type = QueryType.EQ)
private Integer deleted;
}

View File

@@ -0,0 +1,42 @@
package com.gxwebsoft.cms.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.cms.entity.CmsContactLead;
import com.gxwebsoft.cms.param.CmsContactLeadParam;
import com.gxwebsoft.common.core.web.PageResult;
import java.util.List;
/**
* 联系表单/销售线索Service
*
* @author 科技小王子
* @since 2026-03-30
*/
public interface CmsContactLeadService extends IService<CmsContactLead> {
/**
* 分页关联查询
*
* @param param 查询参数
* @return PageResult<CmsContactLead>
*/
PageResult<CmsContactLead> pageRel(CmsContactLeadParam param);
/**
* 关联查询全部
*
* @param param 查询参数
* @return List<CmsContactLead>
*/
List<CmsContactLead> listRel(CmsContactLeadParam param);
/**
* 提交联系表单(不需要登录)
*
* @param lead 表单数据
* @param ip 客户端IP
* @return boolean
*/
boolean submit(CmsContactLead lead, String ip);
}

View File

@@ -0,0 +1,209 @@
package com.gxwebsoft.cms.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.cms.entity.CmsContactLead;
import com.gxwebsoft.cms.mapper.CmsContactLeadMapper;
import com.gxwebsoft.cms.param.CmsContactLeadParam;
import com.gxwebsoft.cms.service.CmsContactLeadService;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 联系表单/销售线索Service实现
*
* @author 科技小王子
* @since 2026-03-30
*/
@Slf4j
@Service
public class CmsContactLeadServiceImpl extends ServiceImpl<CmsContactLeadMapper, CmsContactLead>
implements CmsContactLeadService {
/** 企业微信群机器人 Webhook留空则不发送 */
@Value("${notify.wecom-webhook:}")
private String wecomWebhook;
/** 飞书群机器人 Webhook留空则不发送 */
@Value("${notify.feishu-webhook:}")
private String feishuWebhook;
// --------------------------------------------------------
// 分页 / 列表
// --------------------------------------------------------
@Override
public PageResult<CmsContactLead> pageRel(CmsContactLeadParam param) {
PageParam<CmsContactLead, CmsContactLeadParam> page = new PageParam<>(param);
page.setDefaultOrder("create_time desc");
List<CmsContactLead> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<CmsContactLead> listRel(CmsContactLeadParam param) {
return baseMapper.selectListRel(param);
}
// --------------------------------------------------------
// 公开提交接口
// --------------------------------------------------------
@Override
public boolean submit(CmsContactLead lead, String ip) {
// 补充默认值
if (StrUtil.isBlank(lead.getSource())) {
lead.setSource("web");
}
lead.setIp(ip);
lead.setStatus(0); // 默认待跟进
boolean result = save(lead);
if (result) {
log.info("[联系表单] 新线索提交成功name={}, phone={}", lead.getName(), lead.getPhone());
// 异步推送通知,不阻塞接口响应
sendNotifyAsync(lead);
}
return result;
}
// --------------------------------------------------------
// 异步通知(企业微信 + 飞书)
// --------------------------------------------------------
@Async
public void sendNotifyAsync(CmsContactLead lead) {
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm"));
String deliveryLabel = deliveryLabel(lead.getDelivery());
if (StrUtil.isNotBlank(wecomWebhook)) {
try {
sendWecom(lead, deliveryLabel, now);
} catch (Exception e) {
log.warn("[联系表单] 企业微信通知发送失败: {}", e.getMessage());
}
}
if (StrUtil.isNotBlank(feishuWebhook)) {
try {
sendFeishu(lead, deliveryLabel, now);
} catch (Exception e) {
log.warn("[联系表单] 飞书通知发送失败: {}", e.getMessage());
}
}
}
/**
* 企业微信群机器人 - markdown 消息
* 文档https://developer.work.weixin.qq.com/document/path/91770
*/
private void sendWecom(CmsContactLead lead, String deliveryLabel, String now) {
String content = String.format(
"## 📋 新销售线索(%s\n" +
"> **姓名:** %s\n" +
"> **手机:** <font color=\"warning\">%s</font>\n" +
"> **公司:** %s\n" +
"> **交付:** %s\n" +
"> **需求:** %s\n" +
"> **来源:** %s",
now,
nullSafe(lead.getName()),
nullSafe(lead.getPhone()),
nullSafe(lead.getCompany()),
deliveryLabel,
truncate(lead.getNeed(), 100),
nullSafe(lead.getSource())
);
Map<String, Object> textMap = new HashMap<>();
textMap.put("content", content);
Map<String, Object> payload = new HashMap<>();
payload.put("msgtype", "markdown");
payload.put("markdown", textMap);
String body = JSON.toJSONString(payload);
String resp = HttpUtil.post(wecomWebhook, body);
log.info("[联系表单] 企业微信通知结果: {}", resp);
}
/**
* 飞书群机器人 - 富文本post消息
* 文档https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
*/
private void sendFeishu(CmsContactLead lead, String deliveryLabel, String now) {
// 标题行
String title = "📋 新销售线索(" + now + "";
// 正文每行:[{"tag":"text","text":"xxx"}]
List<List<Map<String, String>>> content = List.of(
line("姓 名:" + nullSafe(lead.getName())),
line("手 机:" + nullSafe(lead.getPhone())),
line("公 司:" + nullSafe(lead.getCompany())),
line("交 付:" + deliveryLabel),
line("需 求:" + truncate(lead.getNeed(), 100)),
line("来 源:" + nullSafe(lead.getSource()))
);
Map<String, Object> zhCn = new HashMap<>();
zhCn.put("title", title);
zhCn.put("content", content);
Map<String, Object> postContent = new HashMap<>();
postContent.put("zh_cn", zhCn);
Map<String, Object> post = new HashMap<>();
post.put("content", postContent);
Map<String, Object> payload = new HashMap<>();
payload.put("msg_type", "post");
payload.put("content", post);
String body = JSON.toJSONString(payload);
String resp = HttpUtil.post(feishuWebhook, body);
log.info("[联系表单] 飞书通知结果: {}", resp);
}
// --------------------------------------------------------
// 工具方法
// --------------------------------------------------------
private String deliveryLabel(String delivery) {
if (delivery == null) return "未选择";
return switch (delivery) {
case "saas" -> "SaaS云端";
case "private" -> "私有化部署";
case "hybrid" -> "混合部署";
default -> delivery;
};
}
private String nullSafe(String s) {
return StrUtil.isBlank(s) ? "" : s;
}
private String truncate(String s, int max) {
if (StrUtil.isBlank(s)) return "";
return s.length() > max ? s.substring(0, max) + "" : s;
}
/** 飞书 post 消息单行 */
private List<Map<String, String>> line(String text) {
Map<String, String> node = new HashMap<>();
node.put("tag", "text");
node.put("text", text);
return List.of(node);
}
}

View File

@@ -0,0 +1,25 @@
-- ----------------------------
-- 联系表单/销售线索表
-- 存放官网 /contact 页面提交的咨询需求
-- ----------------------------
CREATE TABLE IF NOT EXISTS `cms_contact_lead` (
`lead_id` INT NOT NULL AUTO_INCREMENT COMMENT '线索ID',
`name` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
`phone` VARCHAR(20) NOT NULL COMMENT '手机号',
`company` VARCHAR(100) DEFAULT NULL COMMENT '单位/公司名称',
`delivery` VARCHAR(20) DEFAULT NULL COMMENT '交付方式: saas/private/hybrid',
`need` TEXT DEFAULT NULL COMMENT '需求描述',
`source` VARCHAR(50) DEFAULT 'web' COMMENT '来源渠道: web/miniapp/app',
`ip` VARCHAR(50) DEFAULT NULL COMMENT '提交IP',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '跟进状态: 0待跟进 1跟进中 2已成交 3无效',
`remarks` VARCHAR(500) DEFAULT NULL COMMENT '销售备注',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是',
`tenant_id` INT DEFAULT NULL COMMENT '租户ID',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`lead_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_phone` (`phone`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系表单/销售线索';

View File

@@ -80,7 +80,8 @@ public class SecurityConfig {
"/api/shop/getShopInfo", "/api/shop/getShopInfo",
"/api/shop/shop-order/test", "/api/shop/shop-order/test",
"/api/qr-code/**", "/api/qr-code/**",
"/api/shop/order-delivery/notify" "/api/shop/order-delivery/notify",
"/api/cms/cms-contact-lead/submit"
) )
.permitAll() .permitAll()
.anyRequest() .anyRequest()

View File

@@ -258,9 +258,12 @@ payment:
# 开发环境是否启用环境感知 # 开发环境是否启用环境感知
environment-aware: true environment-aware: true
# 生产环境配置
prod: # 通知配置(企业微信/飞书机器人 Webhook
# 生产环境回调地址 notify:
notify-url: "https://cms-api.websoft.top/api/shop/shop-order/notify" # 企业微信群机器人 Webhook格式https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
# 生产环境是否启用环境感知 # 不启用时留空或删除此行
environment-aware: false wecom-webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=aa0d2f30-b785-44a2-ad19-05834569b7c5"
# 飞书群机器人 Webhook格式https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN
feishu-webhook: ""